From e89935a69c6289ae6f368b4bdf7205e6577fb1c2 Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Sat, 29 Aug 2026 16:59:08 -0300 Subject: [PATCH 1/7] [api-upload-refactor] Add test target, relax decode constraints, add HTTPBody + MultipartForm - Package: new LCEssentialsTests target (runs on iOS simulator) - JSONDecoder.decode: T: Codable -> T: Decodable & Sendable (4 overloads); remove try! in decode(fromURL:); honour encoding param in decode(_:using:) - Propagate & Sendable to Data.object, Dictionary.toObjetct, API.request return - LCEHTTPBody: HTTPBody protocol, JSONBody, FormURLEncodedBody, RawBody, factories - LCEMultipartForm: multipart builder, OutputStream-streamed serialize(), in-memory + on-disk file parts, ported mimeType(for:) - Tests: StubURLProtocol harness + 15 tests --- Package.resolved | 2 +- Package.swift | 3 + .../LCEssentials/Classes/LCEHTTPBody.swift | 116 +++++++++++ .../Classes/LCEMultipartForm.swift | 190 ++++++++++++++++++ .../Classes/LCEssentials+API.swift | 2 +- .../Extensions/LCEssentials+Data.swift | 2 +- .../Extensions/LCEssentials+Dictionary.swift | 2 +- .../LCEssentials+EncodableDecodable.swift | 32 ++- Tests/LCEssentialsTests/HTTPBodyTests.swift | 58 ++++++ .../JSONDecoderDecodeTests.swift | 26 +++ .../MultipartFormTests.swift | 106 ++++++++++ Tests/LCEssentialsTests/SmokeTests.swift | 8 + .../Support/StubURLProtocol.swift | 116 +++++++++++ .../Support/StubURLProtocolTests.swift | 47 +++++ 14 files changed, 688 insertions(+), 22 deletions(-) create mode 100644 Sources/LCEssentials/Classes/LCEHTTPBody.swift create mode 100644 Sources/LCEssentials/Classes/LCEMultipartForm.swift create mode 100644 Tests/LCEssentialsTests/HTTPBodyTests.swift create mode 100644 Tests/LCEssentialsTests/JSONDecoderDecodeTests.swift create mode 100644 Tests/LCEssentialsTests/MultipartFormTests.swift create mode 100644 Tests/LCEssentialsTests/SmokeTests.swift create mode 100644 Tests/LCEssentialsTests/Support/StubURLProtocol.swift create mode 100644 Tests/LCEssentialsTests/Support/StubURLProtocolTests.swift diff --git a/Package.resolved b/Package.resolved index 152f883..848404c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "488bc8ba375dd6a42de3f34d4290056a146f9bd52f1bd6cd8ed81cbf4c63984f", + "originHash" : "50e672f8971a68675dcb3ca27b4bd4832c7e96f2216ae062f002554d445c6714", "pins" : [ { "identity" : "lcecryptokitbinary", diff --git a/Package.swift b/Package.swift index a7576e4..26a9503 100644 --- a/Package.swift +++ b/Package.swift @@ -39,5 +39,8 @@ let package = Package( .target( name: "LCEssentials", dependencies: targetDependencies), + .testTarget( + name: "LCEssentialsTests", + dependencies: ["LCEssentials"]), ] ) diff --git a/Sources/LCEssentials/Classes/LCEHTTPBody.swift b/Sources/LCEssentials/Classes/LCEHTTPBody.swift new file mode 100644 index 0000000..f69a8e2 --- /dev/null +++ b/Sources/LCEssentials/Classes/LCEHTTPBody.swift @@ -0,0 +1,116 @@ +// +// Copyright (c) 2020 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation + +/// A request payload plus the `Content-Type` it implies. +/// +/// Adopt this to teach `API.request` a new body encoding without changing `API`. +public protocol HTTPBody: Sendable { + /// - Returns: the encoded bytes and the `Content-Type` header value to send with them. + func encoded() throws -> (data: Data, contentType: String) +} + +// MARK: - JSON + +/// JSON-encodes an `Encodable` payload. +public struct JSONBody: HTTPBody { + + public let payload: Payload + private let encoder: @Sendable () -> JSONEncoder + + /// - Parameters: + /// - payload: the value to encode. + /// - encoderProvider: builds the `JSONEncoder` to use. Defaults to a plain encoder. + public init(_ payload: Payload, + encoderProvider: @escaping @Sendable () -> JSONEncoder = { JSONEncoder() }) { + self.payload = payload + self.encoder = encoderProvider + } + + public func encoded() throws -> (data: Data, contentType: String) { + (try encoder().encode(payload), "application/json; charset=UTF-8") + } +} + +// MARK: - Form URL Encoded + +/// `application/x-www-form-urlencoded` body. Every value is percent-escaped — +/// nothing is silently dropped for containing reserved characters. +public struct FormURLEncodedBody: HTTPBody { + + public let fields: [String: String] + + public init(_ fields: [String: String]) { + self.fields = fields + } + + public func encoded() throws -> (data: Data, contentType: String) { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-._~") // RFC 3986 unreserved + + let pairs: [String] = fields.map { key, value in + let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key + let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + return "\(k)=\(v)" + } + let body = Data(pairs.joined(separator: "&").utf8) + return (body, "application/x-www-form-urlencoded; charset=UTF-8") + } +} + +// MARK: - Raw + +/// A pre-encoded body with an explicit content type. +public struct RawBody: HTTPBody { + + public let data: Data + public let contentType: String + + public init(data: Data, contentType: String) { + self.data = data + self.contentType = contentType + } + + public func encoded() throws -> (data: Data, contentType: String) { + (data, contentType) + } +} + +// MARK: - Factories + +/// JSON body from any `Encodable & Sendable` value. +public func jsonBody(_ value: T) -> JSONBody { + JSONBody(value) +} + +/// Form-url-encoded body from a string dictionary. +public func formBody(_ fields: [String: String]) -> FormURLEncodedBody { + FormURLEncodedBody(fields) +} + +public extension HTTPBody where Self == FormURLEncodedBody { + /// Call-site sugar for `request(body: .form([...]))`. + static func form(_ fields: [String: String]) -> FormURLEncodedBody { + FormURLEncodedBody(fields) + } +} diff --git a/Sources/LCEssentials/Classes/LCEMultipartForm.swift b/Sources/LCEssentials/Classes/LCEMultipartForm.swift new file mode 100644 index 0000000..2323e8f --- /dev/null +++ b/Sources/LCEssentials/Classes/LCEMultipartForm.swift @@ -0,0 +1,190 @@ +// +// Copyright (c) 2020 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation + +/// A `multipart/form-data` body builder. +/// +/// Text fields and small in-memory blobs are held as `Data`; on-disk files are +/// referenced by `URL` and streamed straight into the serialised body, so a +/// large upload never becomes fully resident in memory. +public struct MultipartForm: Sendable { + + /// Where a part's content comes from. + public enum Source: Sendable { + case data(Data) + case file(URL) + } + + struct Part: Sendable { + let name: String + let filename: String? + let mimeType: String? + let source: Source + /// `true` → plain form field: no `filename` / `Content-Type` header lines. + let isField: Bool + } + + /// Error thrown while serialising the body. + public enum SerializationError: Error { + case cannotCreateTempFile(URL) + case cannotOpenOutput(URL) + case cannotOpenInput(URL) + case writeFailed(underlying: Error?) + case readFailed(URL) + } + + private(set) var parts: [Part] = [] + public let boundary: String + + private static let chunkSize = 64 * 1024 + private static let crlf = "\r\n" + + /// - Parameter boundary: multipart boundary token. Defaults to a random value. + public init(boundary: String = "LCEssentials-\(UUID().uuidString)") { + self.boundary = boundary + } + + // MARK: - Building + + /// Appends a plain text field. + public mutating func field(_ name: String, _ value: String) { + parts.append(Part(name: name, filename: nil, mimeType: nil, + source: .data(Data(value.utf8)), isField: true)) + } + + /// Appends an in-memory file part. + public mutating func file(_ name: String, data: Data, filename: String, mime: String? = nil) { + parts.append(Part(name: name, filename: filename, + mimeType: mime ?? Self.mimeType(for: filename), + source: .data(data), isField: false)) + } + + /// Appends an on-disk file part. The file is streamed at serialisation time. + public mutating func file(_ name: String, url: URL, filename: String? = nil, mime: String? = nil) { + let resolvedName = filename ?? url.lastPathComponent + parts.append(Part(name: name, filename: resolvedName, + mimeType: mime ?? Self.mimeType(for: resolvedName), + source: .file(url), isField: false)) + } + + // MARK: - Serialisation + + /// Writes the whole body to a temporary file. + /// + /// - Returns: the temp file URL (caller must delete it once the upload + /// finishes) and the `multipart/form-data; boundary=…` content type. + public func serialize() throws -> (fileURL: URL, contentType: String) { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("lce-multipart-\(UUID().uuidString).tmp") + + guard FileManager.default.createFile(atPath: fileURL.path, contents: nil) else { + throw SerializationError.cannotCreateTempFile(fileURL) + } + guard let output = OutputStream(url: fileURL, append: false) else { + throw SerializationError.cannotOpenOutput(fileURL) + } + output.open() + defer { output.close() } + + for part in parts { + try write(Data(header(for: part).utf8), to: output) + switch part.source { + case .data(let data): + try write(data, to: output) + case .file(let url): + try stream(fileAt: url, to: output) + } + try write(Data(Self.crlf.utf8), to: output) + } + try write(Data("--\(boundary)--\(Self.crlf)".utf8), to: output) + + return (fileURL, "multipart/form-data; boundary=\(boundary)") + } + + private func header(for part: Part) -> String { + var header = "--\(boundary)\(Self.crlf)" + header += "Content-Disposition: form-data; name=\"\(part.name)\"" + if let filename = part.filename, !part.isField { + header += "; filename=\"\(filename)\"" + } + header += Self.crlf + if !part.isField, let mime = part.mimeType { + header += "Content-Type: \(mime)\(Self.crlf)" + } + header += Self.crlf + return header + } + + private func write(_ data: Data, to output: OutputStream) throws { + guard !data.isEmpty else { return } + var bytesRemaining = data + while !bytesRemaining.isEmpty { + let written = bytesRemaining.withUnsafeBytes { raw -> Int in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return -1 } + return output.write(base, maxLength: bytesRemaining.count) + } + guard written > 0 else { + throw SerializationError.writeFailed(underlying: output.streamError) + } + bytesRemaining.removeFirst(written) + } + } + + private func stream(fileAt url: URL, to output: OutputStream) throws { + guard let input = InputStream(url: url) else { + throw SerializationError.cannotOpenInput(url) + } + input.open() + defer { input.close() } + + var buffer = [UInt8](repeating: 0, count: Self.chunkSize) + while input.hasBytesAvailable { + let read = input.read(&buffer, maxLength: buffer.count) + if read == 0 { break } + guard read > 0 else { throw SerializationError.readFailed(url) } + try write(Data(buffer[0.. String { + let ext = (path as NSString).pathExtension.lowercased() + return mimeTypes[ext] ?? "application/octet-stream" + } + + private static let mimeTypes: [String: String] = [ + "jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "gif": "image/gif", + "pdf": "application/pdf", "txt": "text/plain", "html": "text/html", "htm": "text/html", + "json": "application/json", "xml": "application/xml", "zip": "application/zip", + "mp3": "audio/mpeg", "mp4": "video/mp4", "mov": "video/quicktime", + "doc": "application/msword", + "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls": "application/vnd.ms-excel", + "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt": "application/vnd.ms-powerpoint", + "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ] +} diff --git a/Sources/LCEssentials/Classes/LCEssentials+API.swift b/Sources/LCEssentials/Classes/LCEssentials+API.swift index 0b04569..1f76106 100644 --- a/Sources/LCEssentials/Classes/LCEssentials+API.swift +++ b/Sources/LCEssentials/Classes/LCEssentials+API.swift @@ -99,7 +99,7 @@ public struct API { /// - persistConnection: A boolean indicating whether to persist the connection on certain error codes (e.g., 4xx). Defaults to `false`. /// - Returns: An instance of the `T` type, decoded from the response data. /// - Throws: An `Error` if the request fails, including `URLError` for network issues or `DecodingError` for JSON decoding failures. - public func request(url: String, + public func request(url: String, params: Any? = nil, method: httpMethod, headers: [String: String] = [:], diff --git a/Sources/LCEssentials/Extensions/LCEssentials+Data.swift b/Sources/LCEssentials/Extensions/LCEssentials+Data.swift index e60b9ef..45aaae6 100644 --- a/Sources/LCEssentials/Extensions/LCEssentials+Data.swift +++ b/Sources/LCEssentials/Extensions/LCEssentials+Data.swift @@ -112,7 +112,7 @@ public extension Data { #endif } - func object() -> T? { + func object() -> T? { do { let outPut: T = try JSONDecoder.decode(data: self) return outPut diff --git a/Sources/LCEssentials/Extensions/LCEssentials+Dictionary.swift b/Sources/LCEssentials/Extensions/LCEssentials+Dictionary.swift index 53d0ffb..2645b59 100644 --- a/Sources/LCEssentials/Extensions/LCEssentials+Dictionary.swift +++ b/Sources/LCEssentials/Extensions/LCEssentials+Dictionary.swift @@ -138,7 +138,7 @@ public extension Dictionary { /// - LoverdeCo: Convert Dictonary to Object /// /// - returns: Object: Codable/Decodable - func toObjetct() -> T { + func toObjetct() -> T { let jsonString = self.convertToJSON let output: T = try! JSONDecoder.decode(jsonString) return output diff --git a/Sources/LCEssentials/Extensions/LCEssentials+EncodableDecodable.swift b/Sources/LCEssentials/Extensions/LCEssentials+EncodableDecodable.swift index 5059b74..70ec74b 100644 --- a/Sources/LCEssentials/Extensions/LCEssentials+EncodableDecodable.swift +++ b/Sources/LCEssentials/Extensions/LCEssentials+EncodableDecodable.swift @@ -44,8 +44,8 @@ extension JSONDecoder { /// - LoverdeCo: Decode JSON Data to Object /// /// - Parameter data: Data - /// - returns: Object: Codable/Decodable - public static func decode(data: Data) throws -> T { + /// - returns: Object: Decodable & Sendable + public static func decode(data: Data) throws -> T { var error = NSError(domain: "", code: 0) let decoder = JSONDecoder() decoder.keyDecodingStrategy = .useDefaultKeys @@ -70,28 +70,24 @@ extension JSONDecoder { /// - LoverdeCo: Decode JSON String to Object /// /// - Parameter json: String - /// - returns: Object: Codable/Decodable - public static func decode(_ json: String, using encoding: String.Encoding = .utf8) throws -> T { - var error = NSError() - if let jsonData = json.data(using: .utf8) { - do { - return try decode(data: jsonData) - } catch { - throw error - } + /// - returns: Object: Decodable & Sendable + public static func decode(_ json: String, using encoding: String.Encoding = .utf8) throws -> T { + guard let jsonData = json.data(using: encoding) else { + let msg = "Could not convert string to \(encoding) data for \(T.self)" + throw NSError.createErrorWith(code: 0, description: msg, reasonForError: msg) } - throw error + return try decode(data: jsonData) } - + /// - LoverdeCo: Decode JSON URL to Object /// /// - Parameter url: URL - /// - returns: Object: Codable/Decodable - public static func decode(fromURL url: URL) throws -> T { - return try decode(data: try! Data(contentsOf: url)) + /// - returns: Object: Decodable & Sendable + public static func decode(fromURL url: URL) throws -> T { + return try decode(data: Data(contentsOf: url)) } - - public static func decode(dictionary: Any) throws -> T { + + public static func decode(dictionary: Any) throws -> T { do { let json = try JSONSerialization.data(withJSONObject: dictionary) let decoder = JSONDecoder() diff --git a/Tests/LCEssentialsTests/HTTPBodyTests.swift b/Tests/LCEssentialsTests/HTTPBodyTests.swift new file mode 100644 index 0000000..3b6b12a --- /dev/null +++ b/Tests/LCEssentialsTests/HTTPBodyTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import LCEssentials + +private struct SamplePayload: Encodable, Sendable { + let name: String + let age: Int +} + +final class HTTPBodyTests: XCTestCase { + + // MARK: - JSONBody + + func testJSONBodyEncodesPayloadAndContentType() throws { + let payload = SamplePayload(name: "loverde", age: 3) + let (data, contentType) = try JSONBody(payload).encoded() + + XCTAssertEqual(contentType, "application/json; charset=UTF-8") + + let decoded = try JSONDecoder().decode([String: AnyDecodable].self, from: data) + XCTAssertEqual(decoded["name"]?.value as? String, "loverde") + XCTAssertEqual(decoded["age"]?.value as? Int, 3) + } + + func testJSONBodyFactory() throws { + let (data, _) = try jsonBody(SamplePayload(name: "x", age: 1)).encoded() + XCTAssertFalse(data.isEmpty) + } + + // MARK: - FormURLEncodedBody + + func testFormURLEncodedEncodesPairsAndPercentEscapes() throws { + let (data, contentType) = try FormURLEncodedBody(["a": "1", "b": "two words"]).encoded() + + XCTAssertEqual(contentType, "application/x-www-form-urlencoded; charset=UTF-8") + + let pairs = Set(String(data: data, encoding: .utf8)!.split(separator: "&").map(String.init)) + XCTAssertEqual(pairs, ["a=1", "b=two%20words"]) + } + + func testFormURLEncodedEscapesReservedCharactersInsteadOfDropping() throws { + let (data, _) = try FormURLEncodedBody(["q": "a&b=c"]).encoded() + let body = String(data: data, encoding: .utf8)! + XCTAssertEqual(body, "q=a%26b%3Dc") + } +} + +/// Minimal type-erased decoder for asserting JSON shape in tests. +struct AnyDecodable: Decodable { + let value: Any + init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if let i = try? c.decode(Int.self) { value = i } + else if let s = try? c.decode(String.self) { value = s } + else if let b = try? c.decode(Bool.self) { value = b } + else if let d = try? c.decode(Double.self) { value = d } + else { value = "" } + } +} diff --git a/Tests/LCEssentialsTests/JSONDecoderDecodeTests.swift b/Tests/LCEssentialsTests/JSONDecoderDecodeTests.swift new file mode 100644 index 0000000..0ec8f0c --- /dev/null +++ b/Tests/LCEssentialsTests/JSONDecoderDecodeTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import LCEssentials + +private struct OnlyDecodable: Decodable, Sendable, Equatable { + let id: Int + let name: String +} + +final class JSONDecoderDecodeTests: XCTestCase { + + func testDecodesTypeThatIsDecodableAndSendableButNotEncodable() throws { + let data = Data(#"{"id":7,"name":"loverde"}"#.utf8) + let value: OnlyDecodable = try JSONDecoder.decode(data: data) + XCTAssertEqual(value, OnlyDecodable(id: 7, name: "loverde")) + } + + func testDecodeFromStringOverload() throws { + let value: OnlyDecodable = try JSONDecoder.decode(#"{"id":1,"name":"a"}"#) + XCTAssertEqual(value, OnlyDecodable(id: 1, name: "a")) + } + + func testDecodeFromURLThrowsInsteadOfCrashingOnMissingFile() { + let missing = URL(fileURLWithPath: "/tmp/does-not-exist-\(UUID().uuidString).json") + XCTAssertThrowsError(try JSONDecoder.decode(fromURL: missing) as OnlyDecodable) + } +} diff --git a/Tests/LCEssentialsTests/MultipartFormTests.swift b/Tests/LCEssentialsTests/MultipartFormTests.swift new file mode 100644 index 0000000..fdd3461 --- /dev/null +++ b/Tests/LCEssentialsTests/MultipartFormTests.swift @@ -0,0 +1,106 @@ +import XCTest +@testable import LCEssentials + +final class MultipartFormTests: XCTestCase { + + private func readSerialized(_ form: MultipartForm) throws -> (body: Data, contentType: String, url: URL) { + let result = try form.serialize() + let data = try Data(contentsOf: result.fileURL) + return (data, result.contentType, result.fileURL) + } + + private func addCleanup(_ url: URL) { + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + } + + // MARK: - Fields + + func testFieldPartHasNoContentTypeLine() throws { + var form = MultipartForm() + form.field("caption", "hello world") + + let (body, contentType, url) = try readSerialized(form) + addCleanup(url) + let text = String(data: body, encoding: .utf8)! + + XCTAssertTrue(contentType.hasPrefix("multipart/form-data; boundary=")) + let boundary = String(contentType.split(separator: "=", maxSplits: 1)[1]) + + XCTAssertEqual(text, [ + "--\(boundary)\r\n", + "Content-Disposition: form-data; name=\"caption\"\r\n", + "\r\n", + "hello world\r\n", + "--\(boundary)--\r\n" + ].joined()) + } + + // MARK: - In-memory data file + + func testDataFilePartCarriesFilenameAndMimeAndRawBytes() throws { + let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0xFF]) + var form = MultipartForm() + form.file("photo", data: png, filename: "p.png") + + let (body, contentType, url) = try readSerialized(form) + addCleanup(url) + let boundary = String(contentType.split(separator: "=", maxSplits: 1)[1]) + let text = String(data: body, encoding: .isoLatin1)! + + XCTAssertTrue(text.contains("Content-Disposition: form-data; name=\"photo\"; filename=\"p.png\"\r\n")) + XCTAssertTrue(text.contains("Content-Type: image/png\r\n")) + XCTAssertTrue(text.hasSuffix("--\(boundary)--\r\n")) + + // raw bytes appear verbatim between the blank line and the trailing CRLF + let marker = Data("\r\n\r\n".utf8) + let range = body.range(of: marker)! + let afterHeader = body[range.upperBound...] + XCTAssertTrue(afterHeader.starts(with: png)) + } + + func testExplicitMimeOverridesGuess() throws { + var form = MultipartForm() + form.file("f", data: Data("x".utf8), filename: "a.png", mime: "application/octet-stream") + let (body, _, url) = try readSerialized(form) + addCleanup(url) + XCTAssertTrue(String(data: body, encoding: .utf8)!.contains("Content-Type: application/octet-stream\r\n")) + } + + // MARK: - Disk file, streamed + + func testDiskFilePartIsStreamedAndContentMatches() throws { + let big = Data((0..<(2 * 1024 * 1024)).map { UInt8($0 % 251) }) + let src = FileManager.default.temporaryDirectory.appendingPathComponent("src-\(UUID().uuidString).bin") + try big.write(to: src) + addCleanup(src) + + var form = MultipartForm() + form.field("kind", "raw") + form.file("doc", url: src) + + let (body, contentType, out) = try readSerialized(form) + addCleanup(out) + let boundary = String(contentType.split(separator: "=", maxSplits: 1)[1]) + + // header uses the source filename + XCTAssertTrue(String(data: body.prefix(400), encoding: .isoLatin1)! + .contains("filename=\"\(src.lastPathComponent)\"")) + + // the 2 MB payload is present verbatim + let marker = Data("Content-Type: application/octet-stream\r\n\r\n".utf8) + let r = body.range(of: marker)! + let payload = body[r.upperBound..<(body.index(r.upperBound, offsetBy: big.count))] + XCTAssertEqual(Data(payload), big) + + XCTAssertTrue(String(data: body.suffix(boundary.count + 8), encoding: .utf8)! + .hasSuffix("--\(boundary)--\r\n")) + } + + // MARK: - MIME lookup + + func testMimeTypeLookup() { + XCTAssertEqual(MultipartForm.mimeType(for: "a.jpg"), "image/jpeg") + XCTAssertEqual(MultipartForm.mimeType(for: "a.PDF"), "application/pdf") + XCTAssertEqual(MultipartForm.mimeType(for: "a.unknownext"), "application/octet-stream") + } +} diff --git a/Tests/LCEssentialsTests/SmokeTests.swift b/Tests/LCEssentialsTests/SmokeTests.swift new file mode 100644 index 0000000..37d1b04 --- /dev/null +++ b/Tests/LCEssentialsTests/SmokeTests.swift @@ -0,0 +1,8 @@ +import XCTest +@testable import LCEssentials + +final class SmokeTests: XCTestCase { + func testTargetBuildsAndRuns() { + XCTAssertTrue(true) + } +} diff --git a/Tests/LCEssentialsTests/Support/StubURLProtocol.swift b/Tests/LCEssentialsTests/Support/StubURLProtocol.swift new file mode 100644 index 0000000..bd3c94e --- /dev/null +++ b/Tests/LCEssentialsTests/Support/StubURLProtocol.swift @@ -0,0 +1,116 @@ +import Foundation + +/// Test double for `URLProtocol`. Intercepts every request on a `URLSession` +/// configured with it, records the outgoing `URLRequest`, and replays a canned +/// response or error supplied by the test. +/// +/// Register via: +/// ``` +/// let cfg = URLSessionConfiguration.ephemeral +/// cfg.protocolClasses = [StubURLProtocol.self] +/// let session = URLSession(configuration: cfg) +/// ``` +final class StubURLProtocol: URLProtocol, @unchecked Sendable { + + struct Stub { + var statusCode: Int = 200 + var headers: [String: String] = ["Content-Type": "application/json"] + var body: Data = Data() + var error: Error? + /// Bytes reported through `URLSession`'s upload progress, in order. + var uploadProgressChunks: [Int] = [] + } + + // MARK: - Test-facing state (guarded) + + private static let lock = NSLock() + // Access is serialised through `lock`; the unsafe opt-out is the documented + // pattern for lock-guarded mutable statics under strict concurrency. + nonisolated(unsafe) private static var _stub = Stub() + nonisolated(unsafe) private static var _capturedRequests: [URLRequest] = [] + nonisolated(unsafe) private static var _capturedBodies: [Data] = [] + + static func setStub(_ stub: Stub) { + lock.lock(); defer { lock.unlock() } + _stub = stub + _capturedRequests = [] + _capturedBodies = [] + } + + static func reset() { setStub(Stub()) } + + static var capturedRequests: [URLRequest] { + lock.lock(); defer { lock.unlock() } + return _capturedRequests + } + + /// Body of the last intercepted request. `URLProtocol` strips `httpBody` for + /// stream bodies, so this reads `httpBodyStream` when needed. + static var lastCapturedBody: Data? { + lock.lock(); defer { lock.unlock() } + return _capturedBodies.last + } + + static var requestCount: Int { + lock.lock(); defer { lock.unlock() } + return _capturedRequests.count + } + + private static func currentStub() -> Stub { + lock.lock(); defer { lock.unlock() } + return _stub + } + + private static func record(_ request: URLRequest, body: Data) { + lock.lock(); defer { lock.unlock() } + _capturedRequests.append(request) + _capturedBodies.append(body) + } + + // MARK: - URLProtocol + + override class func canInit(with request: URLRequest) -> Bool { true } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let stub = Self.currentStub() + Self.record(request, body: Self.bodyData(from: request)) + + guard let client = client else { return } + + if let error = stub.error { + client.urlProtocol(self, didFailWithError: error) + return + } + + let url = request.url ?? URL(string: "https://stub.invalid")! + let response = HTTPURLResponse(url: url, + statusCode: stub.statusCode, + httpVersion: "HTTP/1.1", + headerFields: stub.headers)! + client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client.urlProtocol(self, didLoad: stub.body) + client.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + + // MARK: - Body extraction + + private static func bodyData(from request: URLRequest) -> Data { + if let body = request.httpBody { return body } + guard let stream = request.httpBodyStream else { return Data() } + stream.open() + defer { stream.close() } + var data = Data() + let bufferSize = 64 * 1024 + var buffer = [UInt8](repeating: 0, count: bufferSize) + while stream.hasBytesAvailable { + let read = stream.read(&buffer, maxLength: bufferSize) + if read <= 0 { break } + data.append(buffer, count: read) + } + return data + } +} diff --git a/Tests/LCEssentialsTests/Support/StubURLProtocolTests.swift b/Tests/LCEssentialsTests/Support/StubURLProtocolTests.swift new file mode 100644 index 0000000..3b553cc --- /dev/null +++ b/Tests/LCEssentialsTests/Support/StubURLProtocolTests.swift @@ -0,0 +1,47 @@ +import XCTest + +final class StubURLProtocolTests: XCTestCase { + + private func makeSession() -> URLSession { + let cfg = URLSessionConfiguration.ephemeral + cfg.protocolClasses = [StubURLProtocol.self] + return URLSession(configuration: cfg) + } + + override func tearDown() { + StubURLProtocol.reset() + super.tearDown() + } + + func testReplaysCannedResponseAndCapturesRequest() async throws { + var stub = StubURLProtocol.Stub() + stub.statusCode = 201 + stub.body = Data(#"{"ok":true}"#.utf8) + StubURLProtocol.setStub(stub) + + var request = URLRequest(url: URL(string: "https://example.com/things")!) + request.httpMethod = "POST" + request.httpBody = Data(#"{"name":"x"}"#.utf8) + + let (data, response) = try await makeSession().data(for: request) + + XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 201) + XCTAssertEqual(String(data: data, encoding: .utf8), #"{"ok":true}"#) + XCTAssertEqual(StubURLProtocol.requestCount, 1) + XCTAssertEqual(StubURLProtocol.capturedRequests.first?.httpMethod, "POST") + XCTAssertEqual(StubURLProtocol.lastCapturedBody, Data(#"{"name":"x"}"#.utf8)) + } + + func testReplaysError() async { + var stub = StubURLProtocol.Stub() + stub.error = URLError(.notConnectedToInternet) + StubURLProtocol.setStub(stub) + + do { + _ = try await makeSession().data(for: URLRequest(url: URL(string: "https://example.com")!)) + XCTFail("expected throw") + } catch { + XCTAssertEqual((error as? URLError)?.code, .notConnectedToInternet) + } + } +} From 9581e49ce06876cb95f9859cd55934ceba56137c Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Sat, 29 Aug 2026 17:09:09 -0300 Subject: [PATCH 2/7] [api-upload-refactor] Convert API to an actor, drop @MainActor - API: @MainActor struct -> actor. certData/certPassword/persistConnectionDelay move from static var (data race) to actor-isolated instance state - setupCertificationRequest -> setupCertification; add setPersistConnectionDelay - add internal init(testConfiguration:) seam; makeSession() helper picks test / cert-delegate / shared session - URLSessionDelegateHandler: final + @unchecked Sendable, immutable let certs, no @MainActor; challenge logic unchanged - getIdentity(): remove as! SecIdentity, extract via typed Unmanaged bridging - delete unused Result enum and defaultParams - APIActorTests: actor conformance, delay round-trip, per-instance cert state --- .../Classes/LCEssentials+API.swift | 186 +++++++++++------- Tests/LCEssentialsTests/APIActorTests.swift | 26 +++ 2 files changed, 139 insertions(+), 73 deletions(-) create mode 100644 Tests/LCEssentialsTests/APIActorTests.swift diff --git a/Sources/LCEssentials/Classes/LCEssentials+API.swift b/Sources/LCEssentials/Classes/LCEssentials+API.swift index 1f76106..26312cc 100644 --- a/Sources/LCEssentials/Classes/LCEssentials+API.swift +++ b/Sources/LCEssentials/Classes/LCEssentials+API.swift @@ -29,14 +29,6 @@ import Security import UIKit #endif -/// A generic `Result` enumeration to represent either a success `Value` or a failure `Error`. -public enum Result { - /// Indicates a successful operation with an associated `Value`. - case success(Value) - /// Indicates a failed operation with an associated `Error`. - case failure(Error) -} - /// Enumeration defining common HTTP methods. public enum httpMethod: String { /// The POST method. @@ -51,39 +43,63 @@ public enum httpMethod: String { case delete = "DELETE" } -/// Loverde Co.: API generic struct for simple requests. +/// Loverde Co.: API entry point for simple requests. /// -/// This struct provides a convenient way to perform network requests with various configurations, -/// including handling different HTTP methods, parameter encoding, and certificate-based authentication. +/// An `actor`, so callers are never forced onto the main thread. It performs +/// network requests with various configurations, handling different HTTP +/// methods, typed request bodies, multipart uploads, and certificate-based +/// authentication. @available(iOS 13.0.0, *) -@MainActor -public struct API { - - private static var certData: Data? - private static var certPassword: String? - +public actor API { + + /// Client-certificate data (`.p12`) for mutual-TLS, if configured. + private var certData: Data? + /// Password for `certData`, if any. + private var certPassword: String? + + /// Session configuration used to build `URLSession`s. `nil` in production + /// (the shared session / a dedicated cert session is used); injected by + /// tests to register a stub `URLProtocol`. + private let sessionConfiguration: URLSessionConfiguration? + /// The default error used when an unexpected issue occurs during a request. - static let defaultError = NSError.createErrorWith(code: LCEssentials.DEFAULT_ERROR_CODE, - description: LCEssentials.DEFAULT_ERROR_MSG, - reasonForError: LCEssentials.DEFAULT_ERROR_MSG) - + nonisolated static var defaultError: NSError { + NSError.createErrorWith(code: LCEssentials.DEFAULT_ERROR_CODE, + description: LCEssentials.DEFAULT_ERROR_MSG, + reasonForError: LCEssentials.DEFAULT_ERROR_MSG) + } + /// The delay in seconds before retrying a persistent connection request. - public static var persistConnectionDelay: Double = 3 - - /// Default parameters that will be included in all requests unless explicitly overridden. - public static var defaultParams: [String:Any] = [String: Any]() - + public private(set) var persistConnectionDelay: Double = 3 + /// Default HTTP headers for requests. /// /// By default, it includes "Accept", "Content-Type", and "Accept-Encoding" headers. - var defaultHeaders: [String: String] = ["Accept": "application/json", - "Content-Type": "application/json; charset=UTF-8", - "Accept-Encoding": "gzip"] - - /// The shared singleton instance of the `API` struct. + nonisolated let defaultHeaders: [String: String] = ["Accept": "application/json", + "Content-Type": "application/json; charset=UTF-8", + "Accept-Encoding": "gzip"] + + /// The shared singleton instance of `API`. public static let shared = API() - - private init(){} + + private init() { + self.sessionConfiguration = nil + } + + /// Test-only. Builds an isolated instance whose `URLSession`s use + /// `configuration` — register a stub `URLProtocol` on it. Never call from + /// production code; `shared` state is untouched. + init(testConfiguration configuration: URLSessionConfiguration) { + self.sessionConfiguration = configuration + } + + /// `true` when a client certificate has been supplied via ``setupCertification(certData:password:)``. + var hasClientCertificateConfigured: Bool { certData != nil } + + /// Overrides ``persistConnectionDelay``. + public func setPersistConnectionDelay(_ seconds: Double) { + persistConnectionDelay = seconds + } /// Performs an asynchronous network request and decodes the response into a `Codable` type. /// @@ -194,28 +210,9 @@ public struct API { API.requestLOG(method: method, request: request) } - // Only spin up a dedicated session (with its own @MainActor - // delegate hop for every TLS/auth challenge) when client - // certificate auth is actually configured. Creating one of - // these per request unconditionally — and never invalidating - // it — could stall the async challenge callback waiting on an - // already-busy MainActor, hanging the request indefinitely with - // no timeout or error ever surfacing. The common case (no - // client cert) uses the shared session, which has none of this - // risk and is what URLSession is designed to be reused as. - let usesCertSession = API.certData != nil - let session: URLSession = usesCertSession - ? URLSession( - configuration: .default, - delegate: URLSessionDelegateHandler( - certData: API.certData, - password: API.certPassword - ), - delegateQueue: nil - ) - : URLSession.shared + let (session, mustInvalidate) = makeSession() defer { - if usesCertSession { + if mustInvalidate { session.finishTasksAndInvalidate() } } @@ -292,33 +289,63 @@ public struct API { /// - Parameters: /// - certData: The `Data` representation of the client certificate (e.g., a .p12 file). /// - password: The password for the certificate, if required. Defaults to an empty string. - public func setupCertificationRequest(certData: Data, password: String = "") { - API.certData = certData - API.certPassword = password + public func setupCertification(certData: Data, password: String = "") { + self.certData = certData + self.certPassword = password + } + + /// Builds the `URLSession` for one request. + /// + /// - A test configuration (if injected) always wins, carrying the cert + /// delegate when one is configured. + /// - Otherwise a dedicated, delegate-backed session is created only when a + /// client certificate is configured, and must be invalidated afterwards. + /// - The common no-cert case reuses `URLSession.shared`. + /// + /// - Returns: the session and whether the caller must invalidate it. + private func makeSession() -> (session: URLSession, mustInvalidate: Bool) { + #if canImport(Security) + let delegate: URLSessionDelegateHandler? = certData != nil + ? URLSessionDelegateHandler(certData: certData, password: certPassword) + : nil + #else + let delegate: URLSessionDelegate? = nil + #endif + + if let configuration = sessionConfiguration { + return (URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil), true) + } + if delegate != nil { + return (URLSession(configuration: .default, delegate: delegate, delegateQueue: nil), true) + } + return (URLSession.shared, false) } } #if canImport(Security) /// A custom `URLSessionDelegate` handler for managing URL session challenges, /// particularly for client and server trust authentication. +/// +/// Immutable after `init`, so it is safe to hand to `URLSession` and have its +/// challenge callback invoked on any thread. The auth logic touches only the +/// Security framework, which is thread-safe. @available(iOS 13.0.0, *) -@MainActor -private class URLSessionDelegateHandler: NSObject, URLSessionDelegate { - - private var certData: Data? - private var certPass: String? - +private final class URLSessionDelegateHandler: NSObject, URLSessionDelegate, @unchecked Sendable { + + private let certData: Data? + private let certPass: String? + /// Initializes a new `URLSessionDelegateHandler` instance. /// /// - Parameters: /// - certData: Optional `Data` for the client certificate. /// - password: Optional password for the client certificate. init(certData: Data? = nil, password: String? = nil) { - super.init() self.certData = certData self.certPass = password + super.init() } - + /// Handles URL session authentication challenges. /// /// This method is responsible for providing client certificates for client certificate @@ -362,17 +389,30 @@ private class URLSessionDelegateHandler: NSObject, URLSessionDelegate { // Import the .p12 certificate to get the identity let status = SecPKCS12Import(certData as CFData, options as CFDictionary, &items) - - if status == errSecSuccess, - let item = (items as? [[String: Any]])?.first, - let identityRef = item[kSecImportItemIdentity as String] as CFTypeRef?, - CFGetTypeID(identityRef) == SecIdentityGetTypeID() { - return (identityRef as! SecIdentity) - } else { - print("Erro ao importar a identidade do certificado: \(status)") + + guard status == errSecSuccess, + let items, + CFArrayGetCount(items) > 0, + let itemPtr = CFArrayGetValueAtIndex(items, 0) else { + printError(title: "Certificate", msg: "Failed to import client identity from .p12 (status: \(status))") return nil } + // Read the identity straight out of the CF result via typed `Unmanaged` + // bridging — no `as!`. `SecPKCS12Import` boxes a CoreFoundation ref that + // Swift will not let us downcast without a force operation. + let itemDict = Unmanaged.fromOpaque(itemPtr).takeUnretainedValue() + let identityKey = Unmanaged.passUnretained(kSecImportItemIdentity).toOpaque() + guard let identityPtr = CFDictionaryGetValue(itemDict, identityKey) else { + printError(title: "Certificate", msg: "Imported .p12 contained no client identity") + return nil + } + let identity = Unmanaged.fromOpaque(identityPtr).takeUnretainedValue() + guard CFGetTypeID(identity) == SecIdentityGetTypeID() else { + printError(title: "Certificate", msg: "Imported .p12 entry is not a SecIdentity") + return nil + } + return identity } /// Checks if the provided data is a valid PKCS#12 (P12) certificate with the given password. diff --git a/Tests/LCEssentialsTests/APIActorTests.swift b/Tests/LCEssentialsTests/APIActorTests.swift new file mode 100644 index 0000000..59115dc --- /dev/null +++ b/Tests/LCEssentialsTests/APIActorTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import LCEssentials + +final class APIActorTests: XCTestCase { + + func testAPIIsAnActorNotMainActorBound() async { + let isActor = (API.shared as Any) is any Actor + XCTAssertTrue(isActor, "API must be an actor so callers are not forced onto the main thread") + } + + func testPersistConnectionDelayRoundTrips() async { + let api = API(testConfiguration: .ephemeral) + await api.setPersistConnectionDelay(9) + let value = await api.persistConnectionDelay + XCTAssertEqual(value, 9) + } + + func testIsolatedTestInstanceDoesNotTouchSharedCertState() async { + let api = API(testConfiguration: .ephemeral) + await api.setupCertification(certData: Data([0x01, 0x02]), password: "pw") + let sharedHasCert = await API.shared.hasClientCertificateConfigured + let instanceHasCert = await api.hasClientCertificateConfigured + XCTAssertFalse(sharedHasCert) + XCTAssertTrue(instanceHasCert) + } +} From bcd358cb091004dfffd72ea00f51b848eb85c28e Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Sat, 29 Aug 2026 17:12:52 -0300 Subject: [PATCH 3/7] [api-upload-refactor] Replace request surface: typed body, pathParams, bounded retry - request(): drop params: Any? and jsonEncoding:; add body: (any HTTPBody)? and pathParams: [String: String] - remove legacy magic-"file" multipart branch (-> upload() in T6) and the now-unused mimeTypeForPath - decompose into makeURL / buildRequest / attach / send / classify / decodeResponse / friendlyError (each < 30 lines) - headers now merge over defaults (custom wins per key) instead of replacing - fix unbounded persistConnection recursion on permanent 4xx: cap at maxPersistRetries (3) - APIRequestTests: 7 cases over StubURLProtocol --- .../Classes/LCEssentials+API.swift | 349 +++++++----------- Tests/LCEssentialsTests/APIRequestTests.swift | 122 ++++++ 2 files changed, 256 insertions(+), 215 deletions(-) create mode 100644 Tests/LCEssentialsTests/APIRequestTests.swift diff --git a/Sources/LCEssentials/Classes/LCEssentials+API.swift b/Sources/LCEssentials/Classes/LCEssentials+API.swift index 26312cc..b7e9ab6 100644 --- a/Sources/LCEssentials/Classes/LCEssentials+API.swift +++ b/Sources/LCEssentials/Classes/LCEssentials+API.swift @@ -101,189 +101,144 @@ public actor API { persistConnectionDelay = seconds } - /// Performs an asynchronous network request and decodes the response into a `Codable` type. + /// Maximum number of extra attempts made when `persistConnection` is set and + /// the server keeps returning a 4xx. Bounds what was previously an unbounded + /// recursion on a permanent client error. + public static let maxPersistRetries = 3 + + /// Performs an asynchronous network request and decodes the JSON response. /// /// - Parameters: - /// - url: The URL string for the request. - /// - params: Optional parameters for the request. Can be `[String: Any]` for JSON/form-data, or `Data` for raw body. - /// - method: The HTTP method to use for the request (`.get`, `.post`, `.put`, `.delete`,`.patch` ). - /// - headers: Optional custom HTTP headers to be added to the request. These override default headers if there are conflicts. - /// - jsonEncoding: A boolean indicating whether parameters should be JSON encoded. Defaults to `true`. - /// - debug: A boolean indicating whether to print debug logs for the request and response. Defaults to `true`. - /// - timeoutInterval: The timeout interval in seconds for the request. Defaults to `30`. - /// - networkServiceType: The `URLRequest.NetworkServiceType` for the request. Defaults to `.default`. - /// - persistConnection: A boolean indicating whether to persist the connection on certain error codes (e.g., 4xx). Defaults to `false`. - /// - Returns: An instance of the `T` type, decoded from the response data. - /// - Throws: An `Error` if the request fails, including `URLError` for network issues or `DecodingError` for JSON decoding failures. - public func request(url: String, - params: Any? = nil, - method: httpMethod, - headers: [String: String] = [:], - jsonEncoding: Bool = true, - debug: Bool = true, - timeoutInterval: TimeInterval = 30, - networkServiceType: URLRequest.NetworkServiceType = .default, - persistConnection: Bool = false) async throws -> T { - - if let urlReq = URL(string: url.replaceURL(params as? [String: Any] ?? [:] )) { - var request = URLRequest(url: urlReq, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeoutInterval) - if method == .post || method == .put || method == .delete || method == .patch { - if let params = params as? [String: Any], - let pathFile = params["file"] as? String, - let fileURL = URL(string: pathFile) { - let boundary = UUID().uuidString - request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") - - var body = Data() - - // Add additional fields (if any) - for (key, value) in params where key != "file" { - let stringValue = "\(value)" - body.append("--\(boundary)\r\n".data(using: .utf8)!) - body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!) - body.append("\(stringValue)\r\n".data(using: .utf8)!) - } - - // Add the file - let fileName = fileURL.lastPathComponent - let mimeType = mimeTypeForPath(path: fileName) - printInfo(title: "Body size before", msg: "\(body.count) bytes") - - let fileData: Data - do { - fileData = try Data(contentsOf: fileURL) - printInfo(title: "Body size after", msg: "\(body.count) bytes") - } catch { - printError(title: "Upload File", msg: error.localizedDescription) - throw error - } - - do { - body.append("--\(boundary)\r\n".data(using: .utf8)!) - body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!) - body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) - let fileDataCopy = Data(fileData) - body.append(fileDataCopy) - let dataUTF8 = "\r\n".data(using: .utf8)! - body.append(dataUTF8) - printInfo(title: "Body size after", msg: "\(body.count) bytes") - } - - // Finalize the request body - body.append("--\(boundary)--\r\n".data(using: .utf8)!) - request.httpBody = body - request.setValue("\(body.count)", forHTTPHeaderField: "Content-Length") - // Debug logs - printLog(title: "Boundary", msg: boundary) - if let bodyString = String(data: body, encoding: .utf8) { - printLog(title: "Body Content", msg: bodyString) - } - } else if jsonEncoding, let params = params as? [String: Any] { - let requestObject = try JSONSerialization.data(withJSONObject: params) - request.httpBody = requestObject - } else if let params = params as? [String: Any] { - var bodyComponents = URLComponents() - params.forEach({ (key, value) in - bodyComponents.queryItems?.append(URLQueryItem(name: key, value: value as? String)) - }) - request.httpBody = bodyComponents.query?.data(using: .utf8) - } else if let params = params as? Data { - request.httpBody = params - } - } - request.httpMethod = method.rawValue - request.timeoutInterval = timeoutInterval - request.networkServiceType = networkServiceType - - // - Put Default Headers together with user defined params - if !headers.isEmpty { - // - Add it to request - headers.forEach { (key, value) in - request.addValue(value, forHTTPHeaderField: key) - } - }else{ - defaultHeaders.forEach { (key, value) in - request.addValue(value, forHTTPHeaderField: key) - } - } - if debug { - API.requestLOG(method: method, request: request) - } - - let (session, mustInvalidate) = makeSession() - defer { - if mustInvalidate { - session.finishTasksAndInvalidate() - } - } - do { - let (data, response) = try await session.data(for: request) - - - var code: Int = LCEssentials.DEFAULT_ERROR_CODE - let httpResponse = response as? HTTPURLResponse ?? HTTPURLResponse() - code = httpResponse.statusCode - let error = URLError(URLError.Code(rawValue: code)) - switch code { - case 200..<300: - // - Debug LOG - if debug { - API.responseLOG(method: method, request: request, data: data, statusCode: code, error: nil) - } - - // - Check if is JSON result and try decode it - if let string = data.string as? T, T.self == String.self { - return string - } - // - Normal decoding - do { - return try JSONDecoder.decode(data: data) - } catch { - printError(title: "JSONDecoder", msg: error.localizedDescription) - throw error - } - case 400..<500: - // - Debug LOG - if debug { - API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error) - } - if persistConnection { - printError(title: "INTERNET CONNECTION ERROR", msg: "WILL PERSIST") - // Recursive call for persistence - let persist: T = try await self.request( - url: url, - params: params, - method: method, - headers: headers, - jsonEncoding: jsonEncoding, - debug: debug, - timeoutInterval: timeoutInterval, - networkServiceType: networkServiceType, - persistConnection: persistConnection - ) - return persist - } else { - if debug { - API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error) - } - let friendlyError = NSError.createErrorWith(code: code, description: error.localizedDescription, reasonForError: data.prettyJson ?? "") - throw friendlyError - } - default: - // - Debug LOG - if debug { - API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error) - } - let friendlyError = NSError.createErrorWith(code: code, description: error.localizedDescription, reasonForError: data.prettyJson ?? "") - throw friendlyError - } - } catch { - throw error - } + /// - url: The URL string. `{name}` placeholders are filled from `pathParams`. + /// - method: The HTTP method (`.get`, `.post`, `.put`, `.delete`, `.patch`). + /// - body: Optional typed request body (``JSONBody``, ``FormURLEncodedBody``, + /// ``RawBody``, or any ``HTTPBody``). Its `Content-Type` and + /// `Content-Length` are set automatically. + /// - pathParams: Values substituted into `{name}` placeholders in `url`. + /// - headers: Custom headers, merged over the defaults (custom wins). + /// - debug: Print request/response debug logs. Defaults to `true`. + /// - timeoutInterval: Request timeout in seconds. Defaults to `30`. + /// - networkServiceType: `URLRequest.NetworkServiceType`. Defaults to `.default`. + /// - persistConnection: Retry (bounded by ``maxPersistRetries``) on a 4xx. + /// - Returns: `T` decoded from the response body, or the raw string when `T == String`. + /// - Throws: `URLError` for transport failures, an `NSError` carrying the HTTP + /// status for non-2xx responses, or `DecodingError` on a malformed body. + public func request( + url: String, + method: httpMethod, + body: (any HTTPBody)? = nil, + pathParams: [String: String] = [:], + headers: [String: String] = [:], + debug: Bool = true, + timeoutInterval: TimeInterval = 30, + networkServiceType: URLRequest.NetworkServiceType = .default, + persistConnection: Bool = false + ) async throws -> T { + let resolvedURL = try makeURL(url, pathParams: pathParams) + var urlRequest = buildRequest(url: resolvedURL, method: method, headers: headers, + timeout: timeoutInterval, serviceType: networkServiceType) + if let body { + try Self.attach(body: body, to: &urlRequest) } - throw API.defaultError + return try await send(urlRequest, method: method, debug: debug, + persistConnection: persistConnection, retriesLeft: Self.maxPersistRetries) } - + + /// Sends a fully-built request, handles the response, and applies bounded + /// `persistConnection` retries. + private func send( + _ urlRequest: URLRequest, + method: httpMethod, + debug: Bool, + persistConnection: Bool, + retriesLeft: Int + ) async throws -> T { + if debug { API.requestLOG(method: method, request: urlRequest) } + + let (session, mustInvalidate) = makeSession() + defer { if mustInvalidate { session.finishTasksAndInvalidate() } } + + let (data, response) = try await session.data(for: urlRequest) + let code = (response as? HTTPURLResponse)?.statusCode ?? LCEssentials.DEFAULT_ERROR_CODE + + switch Self.classify(code: code, data: data, method: method, request: urlRequest, debug: debug) { + case .success: + return try Self.decodeResponse(data) + case .clientError where persistConnection && retriesLeft > 0: + printError(title: "INTERNET CONNECTION ERROR", msg: "WILL PERSIST (\(retriesLeft) left)") + return try await send(urlRequest, method: method, debug: debug, + persistConnection: persistConnection, retriesLeft: retriesLeft - 1) + case .clientError, .otherError: + throw Self.friendlyError(code: code, data: data) + } + } + + // MARK: - Request building + + private func makeURL(_ template: String, pathParams: [String: String]) throws -> URL { + guard let url = URL(string: template.replaceURL(pathParams)) else { throw API.defaultError } + return url + } + + private func buildRequest(url: URL, + method: httpMethod, + headers: [String: String], + timeout: TimeInterval, + serviceType: URLRequest.NetworkServiceType) -> URLRequest { + var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout) + request.httpMethod = method.rawValue + request.timeoutInterval = timeout + request.networkServiceType = serviceType + defaultHeaders.merging(headers) { _, custom in custom } + .forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) } + return request + } + + /// Sets the body plus its `Content-Type` and `Content-Length`. The body's own + /// content type wins over any set through `headers`. + private static func attach(body: any HTTPBody, to request: inout URLRequest) throws { + let (data, contentType) = try body.encoded() + request.httpBody = data + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + request.setValue("\(data.count)", forHTTPHeaderField: "Content-Length") + } + + // MARK: - Response handling + + private enum ResponseDisposition { case success, clientError, otherError } + + private static func classify(code: Int, data: Data, method: httpMethod, + request: URLRequest, debug: Bool) -> ResponseDisposition { + let error = (200..<300).contains(code) ? nil : URLError(URLError.Code(rawValue: code)) + if debug { + API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error) + } + switch code { + case 200..<300: return .success + case 400..<500: return .clientError + default: return .otherError + } + } + + private static func decodeResponse(_ data: Data) throws -> T { + if T.self == String.self, let string = String(data: data, encoding: .utf8) as? T { + return string + } + do { + return try JSONDecoder.decode(data: data) + } catch { + printError(title: "JSONDecoder", msg: error.localizedDescription) + throw error + } + } + + private static func friendlyError(code: Int, data: Data) -> NSError { + let urlError = URLError(URLError.Code(rawValue: code)) + return NSError.createErrorWith(code: code, + description: urlError.localizedDescription, + reasonForError: data.prettyJson ?? "") + } + /// Sets up client certificate data and an optional password for authentication. /// /// - Parameters: @@ -533,41 +488,5 @@ extension API { // print("<======================================================================================>") } - - /// Determines the MIME type for a given file path based on its extension. - /// - /// - Parameter path: The file path string. - /// - Returns: A string representing the MIME type. Defaults to "application/octet-stream" if the type is unknown. - func mimeTypeForPath(path: String) -> String { - let url = URL(fileURLWithPath: path) - let pathExtension = url.pathExtension.lowercased() - - // Dictionary of common extensions and MIME types - let mimeTypes: [String: String] = [ - "jpg": "image/jpeg", - "jpeg": "image/jpeg", - "png": "image/png", - "gif": "image/gif", - "pdf": "application/pdf", - "txt": "text/plain", - "html": "text/html", - "htm": "text/html", - "json": "application/json", - "xml": "application/xml", - "zip": "application/zip", - "mp3": "audio/mpeg", - "mp4": "video/mp4", - "mov": "video/quicktime", - "doc": "application/msword", - "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "xls": "application/vnd.ms-excel", - "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "ppt": "application/vnd.ms-powerpoint", - "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation" - ] - - // Returns the corresponding MIME type for the extension, or "application/octet-stream" as default - return mimeTypes[pathExtension] ?? "application/octet-stream" - } } #endif diff --git a/Tests/LCEssentialsTests/APIRequestTests.swift b/Tests/LCEssentialsTests/APIRequestTests.swift new file mode 100644 index 0000000..c3236e2 --- /dev/null +++ b/Tests/LCEssentialsTests/APIRequestTests.swift @@ -0,0 +1,122 @@ +import XCTest +@testable import LCEssentials + +private struct Echo: Decodable, Sendable, Equatable { + let id: Int + let name: String +} + +private struct CreateDTO: Encodable, Sendable { + let name: String +} + +final class APIRequestTests: XCTestCase { + + private var api: API! + + override func setUp() { + super.setUp() + let cfg = URLSessionConfiguration.ephemeral + cfg.protocolClasses = [StubURLProtocol.self] + api = API(testConfiguration: cfg) + } + + override func tearDown() { + StubURLProtocol.reset() + api = nil + super.tearDown() + } + + func testJSONBodyRequestSendsEncodedBodyAndDecodesResponse() async throws { + var stub = StubURLProtocol.Stub() + stub.statusCode = 200 + stub.body = Data(#"{"id":10,"name":"x"}"#.utf8) + StubURLProtocol.setStub(stub) + + let result: Echo = try await api.request( + url: "https://api.example.com/users", + method: .post, + body: jsonBody(CreateDTO(name: "x")) + ) + + XCTAssertEqual(result, Echo(id: 10, name: "x")) + let sent = StubURLProtocol.capturedRequests.first + XCTAssertEqual(sent?.httpMethod, "POST") + XCTAssertEqual(sent?.value(forHTTPHeaderField: "Content-Type"), "application/json; charset=UTF-8") + XCTAssertEqual(StubURLProtocol.lastCapturedBody, Data(#"{"name":"x"}"#.utf8)) + } + + func testCustomHeaderOverridesDefault() async throws { + StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"id":1,"name":"a"}"#.utf8))) + + let _: Echo = try await api.request( + url: "https://api.example.com/x", + method: .get, + headers: ["Accept": "application/xml"] + ) + + XCTAssertEqual(StubURLProtocol.capturedRequests.first?.value(forHTTPHeaderField: "Accept"), + "application/xml") + } + + func testPathParamsSubstitution() async throws { + StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"id":42,"name":"a"}"#.utf8))) + + let _: Echo = try await api.request( + url: "https://api.example.com/users/{id}/posts", + method: .get, + pathParams: ["id": "42"] + ) + + XCTAssertEqual(StubURLProtocol.capturedRequests.first?.url?.absoluteString, + "https://api.example.com/users/42/posts") + } + + func testStringResponsePassthroughSkipsJSONDecoding() async throws { + StubURLProtocol.setStub(.init(statusCode: 200, + headers: ["Content-Type": "text/plain"], + body: Data("plain hello".utf8))) + + let text: String = try await api.request(url: "https://api.example.com/ping", method: .get) + XCTAssertEqual(text, "plain hello") + } + + func testClientErrorThrowsNSErrorWithStatusAndBody() async { + StubURLProtocol.setStub(.init(statusCode: 422, + body: Data(#"{"error":"invalid"}"#.utf8))) + + do { + let _: Echo = try await api.request(url: "https://api.example.com/x", method: .post, + body: jsonBody(CreateDTO(name: ""))) + XCTFail("expected throw") + } catch let error as NSError { + XCTAssertEqual(error.code, 422) + XCTAssertTrue(error.localizedFailureReason?.contains("invalid") ?? false, + "reason: \(error.localizedFailureReason ?? "nil")") + } + } + + func testPersistConnectionRetriesButIsBoundedOnPermanentClientError() async { + StubURLProtocol.setStub(.init(statusCode: 400, body: Data(#"{"error":"nope"}"#.utf8))) + + do { + let _: Echo = try await api.request(url: "https://api.example.com/x", + method: .get, persistConnection: true) + XCTFail("expected throw after retries exhausted") + } catch let error as NSError { + XCTAssertEqual(error.code, 400) + } catch { + XCTFail("unexpected error type: \(error)") + } + + // retried, but did NOT loop forever + XCTAssertGreaterThanOrEqual(StubURLProtocol.requestCount, 2) + XCTAssertLessThanOrEqual(StubURLProtocol.requestCount, API.maxPersistRetries + 1) + } + + func testNoPersistConnectionDoesNotRetry() async { + StubURLProtocol.setStub(.init(statusCode: 400, body: Data(#"{"error":"nope"}"#.utf8))) + let _: Echo? = try? await api.request(url: "https://api.example.com/x", method: .get) + XCTAssertEqual(StubURLProtocol.requestCount, 1) + } +} From 3c64f364be4ecf3d2472fbe807dd2244862ddaec Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Sat, 29 Aug 2026 17:16:16 -0300 Subject: [PATCH 4/7] [api-upload-refactor] Add multipart upload(), split logging to its own file - upload(url:method:form:pathParams:headers:debug:timeoutInterval:networkServiceType:) serialises MultipartForm to a temp file, streams it via uploadTask(with:fromFile:) bridged to async (works on iOS 13+, unlike URLSession.upload(for:fromFile:)) - temp body file always removed via defer (success and throw) - move requestLOG/responseLOG to LCEssentials+API+Logging.swift; extract logResponseError helper; keeps API file at 473 lines - APIUploadTests: 4 cases - progress-reporting overload deferred (no stub seam for didSendBodyData) --- .../Classes/LCEssentials+API+Logging.swift | 94 ++++++++++ .../Classes/LCEssentials+API.swift | 167 ++++++++---------- Tests/LCEssentialsTests/APIUploadTests.swift | 95 ++++++++++ 3 files changed, 263 insertions(+), 93 deletions(-) create mode 100644 Sources/LCEssentials/Classes/LCEssentials+API+Logging.swift create mode 100644 Tests/LCEssentialsTests/APIUploadTests.swift diff --git a/Sources/LCEssentials/Classes/LCEssentials+API+Logging.swift b/Sources/LCEssentials/Classes/LCEssentials+API+Logging.swift new file mode 100644 index 0000000..1b0012a --- /dev/null +++ b/Sources/LCEssentials/Classes/LCEssentials+API+Logging.swift @@ -0,0 +1,94 @@ +// +// Copyright (c) 2020 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +#if os(iOS) || os(watchOS) + +@available(iOS 13.0.0, *) +extension API { + + /// Logs details of an outgoing network request for debugging purposes. + static func requestLOG(method: httpMethod, request: URLRequest) { + print("\n<========================= 🟠 INTERNET CONNECTION - REQUEST =========================>") + printLog(title: "DATE AND TIME", msg: Date().debugDescription) + printLog(title: "METHOD", msg: method.rawValue) + printLog(title: "REQUEST", msg: String(describing: request)) + printLog(title: "HEADERS", msg: request.allHTTPHeaderFields?.debugDescription ?? "") + if let dataBody = request.httpBody, let prettyJson = dataBody.prettyJson { + printLog(title: "PARAMETERS", msg: prettyJson) + } else if let dataBody = request.httpBody { + printLog(title: "PARAMETERS", msg: String(data: dataBody, encoding: .utf8) ?? "-") + } + print("<======================================================================================>") + } + + /// Logs details of an incoming network response for debugging purposes. + static func responseLOG(method: httpMethod, request: URLRequest, data: Data?, statusCode: Int, error: Error?) { + let icon = error != nil ? "🔴" : "🟢" + print("\n<========================= \(icon) INTERNET CONNECTION - RESPONSE =========================>") + printLog(title: "DATE AND TIME", msg: Date().debugDescription) + printLog(title: "METHOD", msg: method.rawValue) + printLog(title: "REQUEST", msg: String(describing: request)) + printLog(title: "HEADERS", msg: request.allHTTPHeaderFields?.debugDescription ?? "") + if let dataBody = request.httpBody, let prettyJson = dataBody.prettyJson { + printLog(title: "PARAMETERS", msg: prettyJson) + } else if let dataBody = request.httpBody { + printLog(title: "PARAMETERS", msg: String(data: dataBody, encoding: .utf8) ?? "-") + } + printLog(title: "STATUS CODE", msg: String(describing: statusCode)) + if let dataResponse = data, let prettyJson = dataResponse.prettyJson { + printLog(title: "RESPONSE", msg: prettyJson) + } else { + printLog(title: "RESPONSE", msg: String(data: data ?? Data(), encoding: .utf8) ?? "-") + } + logResponseError(error, data: data, statusCode: statusCode) + print("<======================================================================================>") + } + + private static func logResponseError(_ error: Error?, data: Data?, statusCode: Int) { + if let error { + switch error.statusCode { + case NSURLErrorTimedOut: + printError(title: "RESPONSE ERROR TIMEOUT", msg: "DESCRICAO: \(error.localizedDescription)") + case NSURLErrorNotConnectedToInternet: + printError(title: "RESPONSE ERROR NO INTERNET", msg: "DESCRICAO: \(error.localizedDescription)") + case NSURLErrorNetworkConnectionLost: + printError(title: "RESPONSE ERROR CONNECTION LOST", msg: "DESCRICAO: \(error.localizedDescription)") + case NSURLErrorCancelledReasonUserForceQuitApplication: + printError(title: "RESPONSE ERROR APP QUIT", msg: "DESCRICAO: \(error.localizedDescription)") + case NSURLErrorCancelledReasonBackgroundUpdatesDisabled: + printError(title: "RESPONSE ERROR BG DISABLED", msg: "DESCRICAO: \(error.localizedDescription)") + case NSURLErrorBackgroundSessionWasDisconnected: + printError(title: "RESPONSE ERROR BG SESSION DISCONNECTED", msg: "DESCRICAO: \(error.localizedDescription)") + default: + printError(title: "GENERAL", msg: error.localizedDescription) + } + } else if let data, statusCode != 200 { + if let jsonString = String(data: data, encoding: .utf8) { + printError(title: "JSON STATUS CODE \(statusCode)", msg: jsonString) + } else { + printError(title: "DATA STATUS CODE \(statusCode)", msg: data.debugDescription) + } + } + } +} +#endif diff --git a/Sources/LCEssentials/Classes/LCEssentials+API.swift b/Sources/LCEssentials/Classes/LCEssentials+API.swift index b7e9ab6..5b8c339 100644 --- a/Sources/LCEssentials/Classes/LCEssentials+API.swift +++ b/Sources/LCEssentials/Classes/LCEssentials+API.swift @@ -275,6 +275,80 @@ public actor API { } return (URLSession.shared, false) } + + // MARK: - Upload + + /// Uploads a `multipart/form-data` body and decodes the JSON response. + /// + /// The body is serialised to a temporary file and streamed from disk, so a + /// large file never becomes fully resident in memory. The temp file is + /// always removed before returning. + /// + /// - Parameters: + /// - url: The URL string. `{name}` placeholders are filled from `pathParams`. + /// - method: The HTTP method. Defaults to `.post`. + /// - form: The multipart body (see ``MultipartForm``). + /// - pathParams: Values substituted into `{name}` placeholders in `url`. + /// - headers: Custom headers, merged over the defaults (custom wins). The + /// `Content-Type` is always set to the multipart type. + /// - debug: Print request/response debug logs. Defaults to `true`. + /// - timeoutInterval: Request timeout in seconds. Defaults to `120`. + /// - networkServiceType: `URLRequest.NetworkServiceType`. Defaults to `.default`. + /// - Returns: `T` decoded from the response body. + /// - Throws: `URLError` for transport failures, an `NSError` carrying the HTTP + /// status for non-2xx responses, or `DecodingError` on a malformed body. + public func upload( + url: String, + method: httpMethod = .post, + form: MultipartForm, + pathParams: [String: String] = [:], + headers: [String: String] = [:], + debug: Bool = true, + timeoutInterval: TimeInterval = 120, + networkServiceType: URLRequest.NetworkServiceType = .default + ) async throws -> T { + let resolvedURL = try makeURL(url, pathParams: pathParams) + var urlRequest = buildRequest(url: resolvedURL, method: method, headers: headers, + timeout: timeoutInterval, serviceType: networkServiceType) + + let serialized = try form.serialize() + defer { try? FileManager.default.removeItem(at: serialized.fileURL) } + urlRequest.setValue(serialized.contentType, forHTTPHeaderField: "Content-Type") + + if debug { API.requestLOG(method: method, request: urlRequest) } + let (session, mustInvalidate) = makeSession() + defer { if mustInvalidate { session.finishTasksAndInvalidate() } } + + let (data, response) = try await Self.performUpload(urlRequest, + fromFile: serialized.fileURL, + session: session) + let code = (response as? HTTPURLResponse)?.statusCode ?? LCEssentials.DEFAULT_ERROR_CODE + switch Self.classify(code: code, data: data, method: method, request: urlRequest, debug: debug) { + case .success: + return try Self.decodeResponse(data) + case .clientError, .otherError: + throw Self.friendlyError(code: code, data: data) + } + } + + /// Bridges `URLSession.uploadTask(with:fromFile:)` to `async` so the upload + /// works down to iOS 13 (`URLSession.upload(for:fromFile:)` is iOS 15+). + private static func performUpload(_ request: URLRequest, + fromFile fileURL: URL, + session: URLSession) async throws -> (Data, URLResponse) { + try await withCheckedThrowingContinuation { continuation in + let task = session.uploadTask(with: request, fromFile: fileURL) { data, response, error in + if let error { + continuation.resume(throwing: error) + } else if let data, let response { + continuation.resume(returning: (data, response)) + } else { + continuation.resume(throwing: API.defaultError) + } + } + task.resume() + } + } } #if canImport(Security) @@ -396,97 +470,4 @@ extension Error { } } -@available(iOS 13.0.0, *) -extension API { - - /// Logs details of an outgoing network request for debugging purposes. - /// - /// - Parameters: - /// - method: The HTTP method of the request. - /// - request: The `URLRequest` object. - fileprivate static func requestLOG(method: httpMethod, request: URLRequest) { - - print("\n<========================= 🟠 INTERNET CONNECTION - REQUEST =========================>") - printLog(title: "DATE AND TIME", msg: Date().debugDescription) - printLog(title: "METHOD", msg: method.rawValue) - printLog(title: "REQUEST", msg: String(describing: request)) - printLog(title: "HEADERS", msg: request.allHTTPHeaderFields?.debugDescription ?? "") - - // - if let dataBody = request.httpBody, let prettyJson = dataBody.prettyJson { - printLog(title: "PARAMETERS", msg: prettyJson) - } else if let dataBody = request.httpBody { - printLog(title: "PARAMETERS", msg: String(data: dataBody, encoding: .utf8) ?? "-") - } - // - print("<======================================================================================>") - } - - /// Logs details of an incoming network response for debugging purposes. - /// - /// - Parameters: - /// - method: The HTTP method of the original request. - /// - request: The `URLRequest` object that generated this response. - /// - data: The data received in the response. - /// - statusCode: The HTTP status code of the response. - /// - error: An optional `Error` object if the request failed. - fileprivate static func responseLOG(method: httpMethod, request: URLRequest, data: Data?, statusCode: Int, error: Error?) { - /// - let icon = error != nil ? "🔴" : "🟢" - print("\n<========================= \(icon) INTERNET CONNECTION - RESPONSE =========================>") - printLog(title: "DATE AND TIME", msg: Date().debugDescription) - printLog(title: "METHOD", msg: method.rawValue) - printLog(title: "REQUEST", msg: String(describing: request)) - printLog(title: "HEADERS", msg: request.allHTTPHeaderFields?.debugDescription ?? "") - - // - if let dataBody = request.httpBody, let prettyJson = dataBody.prettyJson { - printLog(title: "PARAMETERS", msg: prettyJson) - } else if let dataBody = request.httpBody { - printLog(title: "PARAMETERS", msg: String(data: dataBody, encoding: .utf8) ?? "-") - } - // - printLog(title: "STATUS CODE", msg: String(describing: statusCode)) - // - if let dataResponse = data, let prettyJson = dataResponse.prettyJson { - printLog(title: "RESPONSE", msg: prettyJson) - } else { - printLog(title: "RESPONSE", msg: String(data: data ?? Data(), encoding: .utf8) ?? "-") - } - // - if let error = error { - switch error.statusCode { - case NSURLErrorTimedOut: - printError(title: "RESPONSE ERROR TIMEOUT", msg: "DESCRICAO: \(error.localizedDescription)") - - case NSURLErrorNotConnectedToInternet: - printError(title: "RESPONSE ERROR NO INTERNET", msg: "DESCRICAO: \(error.localizedDescription)") - - case NSURLErrorNetworkConnectionLost: - printError(title: "RESPONSE ERROR CONNECTION LOST", msg: "DESCRICAO: \(error.localizedDescription)") - - case NSURLErrorCancelledReasonUserForceQuitApplication: - printError(title: "RESPONSE ERROR APP QUIT", msg: "DESCRICAO: \(error.localizedDescription)") - - case NSURLErrorCancelledReasonBackgroundUpdatesDisabled: - printError(title: "RESPONSE ERROR BG DISABLED", msg: "DESCRICAO: \(error.localizedDescription)") - - case NSURLErrorBackgroundSessionWasDisconnected: - printError(title: "RESPONSE ERROR BG SESSION DISCONNECTED", msg: "DESCRICAO: \(error.localizedDescription)") - - default: - printError(title: "GENERAL", msg: error.localizedDescription) - } - }else if let data = data, statusCode != 200 { - // - Check if is JSON result - if let jsonString = String(data: data, encoding: .utf8) { - printError(title: "JSON STATUS CODE \(statusCode)", msg: jsonString) - }else{ - printError(title: "DATA STATUS CODE \(statusCode)", msg: data.debugDescription) - } - } - // - print("<======================================================================================>") - } -} #endif diff --git a/Tests/LCEssentialsTests/APIUploadTests.swift b/Tests/LCEssentialsTests/APIUploadTests.swift new file mode 100644 index 0000000..ca77364 --- /dev/null +++ b/Tests/LCEssentialsTests/APIUploadTests.swift @@ -0,0 +1,95 @@ +import XCTest +@testable import LCEssentials + +private struct UploadEcho: Decodable, Sendable, Equatable { + let ok: Bool +} + +final class APIUploadTests: XCTestCase { + + private var api: API! + + override func setUp() { + super.setUp() + let cfg = URLSessionConfiguration.ephemeral + cfg.protocolClasses = [StubURLProtocol.self] + api = API(testConfiguration: cfg) + } + + override func tearDown() { + StubURLProtocol.reset() + api = nil + super.tearDown() + } + + private func tempCountInDir(_ dir: URL) -> Int { + (try? FileManager.default.contentsOfDirectory(atPath: dir.path).filter { + $0.hasPrefix("lce-multipart-") + }.count) ?? -1 + } + + func testUploadSendsMultipartBodyAndDecodesResponse() async throws { + StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"ok":true}"#.utf8))) + + var form = MultipartForm() + form.field("caption", "hi") + form.file("photo", data: Data([0x89, 0x50, 0x4E, 0x47]), filename: "p.png") + let expectedBody = try Data(contentsOf: form.serialize().fileURL) + + let result: UploadEcho = try await api.upload( + url: "https://api.example.com/media", + form: form + ) + + XCTAssertEqual(result, UploadEcho(ok: true)) + let sent = StubURLProtocol.capturedRequests.first + XCTAssertEqual(sent?.httpMethod, "POST") + XCTAssertTrue(sent?.value(forHTTPHeaderField: "Content-Type")? + .hasPrefix("multipart/form-data; boundary=") ?? false) + // body framing matches a fresh serialize() (boundary differs per form + // instance, so compare structure, not bytes, by re-serialising the SAME form) + XCTAssertEqual(StubURLProtocol.lastCapturedBody?.count, expectedBody.count) + } + + func testTempBodyFileRemovedAfterSuccess() async throws { + StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"ok":true}"#.utf8))) + let dir = FileManager.default.temporaryDirectory + let before = tempCountInDir(dir) + + var form = MultipartForm() + form.file("f", data: Data("x".utf8), filename: "a.txt") + let _: UploadEcho = try await api.upload(url: "https://api.example.com/x", form: form) + + XCTAssertEqual(tempCountInDir(dir), before) + } + + func testTempBodyFileRemovedAfterThrow() async { + StubURLProtocol.setStub(.init(statusCode: 500, body: Data(#"{"error":"boom"}"#.utf8))) + let dir = FileManager.default.temporaryDirectory + let before = tempCountInDir(dir) + + var form = MultipartForm() + form.file("f", data: Data("x".utf8), filename: "a.txt") + do { + let _: UploadEcho = try await api.upload(url: "https://api.example.com/x", form: form) + XCTFail("expected throw") + } catch { + // expected + } + XCTAssertEqual(tempCountInDir(dir), before) + } + + func testUploadServerErrorThrowsWithStatus() async { + StubURLProtocol.setStub(.init(statusCode: 413, body: Data(#"{"error":"too big"}"#.utf8))) + var form = MultipartForm() + form.file("f", data: Data("x".utf8), filename: "a.txt") + do { + let _: UploadEcho = try await api.upload(url: "https://api.example.com/x", form: form) + XCTFail("expected throw") + } catch let error as NSError { + XCTAssertEqual(error.code, 413) + } catch { + XCTFail("wrong error: \(error)") + } + } +} From fb2e4171044fc0d8e9a7b57cff0ff0cf261e2c4e Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Sat, 29 Aug 2026 17:46:41 -0300 Subject: [PATCH 5/7] [api-upload-refactor] Add iOS15 upload progress overload; toObjetct throws - upload(..., onProgress: @Sendable (Double) -> Void) @available(iOS 15.0, *) via URLSession.upload(for:fromFile:delegate:) + UploadProgressDelegate; emits 1.0 on completion. Base upload stays iOS 13+ - move all upload code to LCEssentials+API+Upload.swift (buildUploadRequest / finishUpload / performUpload shared); API file 400 lines. Shared request helpers private -> internal - Dictionary.toObjetct: try! -> throws - APIUploadTests: +progress contract test (final 1.0, monotonic, decodes) --- .../Classes/LCEssentials+API+Upload.swift | 193 ++++++++++++++++++ .../Classes/LCEssentials+API.swift | 87 +------- .../Extensions/LCEssentials+Dictionary.swift | 9 +- Tests/LCEssentialsTests/APIUploadTests.swift | 29 +++ 4 files changed, 233 insertions(+), 85 deletions(-) create mode 100644 Sources/LCEssentials/Classes/LCEssentials+API+Upload.swift diff --git a/Sources/LCEssentials/Classes/LCEssentials+API+Upload.swift b/Sources/LCEssentials/Classes/LCEssentials+API+Upload.swift new file mode 100644 index 0000000..67b2bd3 --- /dev/null +++ b/Sources/LCEssentials/Classes/LCEssentials+API+Upload.swift @@ -0,0 +1,193 @@ +// +// Copyright (c) 2020 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +#if os(iOS) || os(watchOS) + +@available(iOS 13.0.0, *) +public extension API { + + /// Uploads a `multipart/form-data` body and decodes the JSON response. + /// + /// The body is serialised to a temporary file and streamed from disk, so a + /// large file never becomes fully resident in memory. The temp file is + /// always removed before returning. + /// + /// - Parameters: + /// - url: The URL string. `{name}` placeholders are filled from `pathParams`. + /// - method: The HTTP method. Defaults to `.post`. + /// - form: The multipart body (see ``MultipartForm``). + /// - pathParams: Values substituted into `{name}` placeholders in `url`. + /// - headers: Custom headers, merged over the defaults (custom wins). The + /// `Content-Type` is always set to the multipart type. + /// - debug: Print request/response debug logs. Defaults to `true`. + /// - timeoutInterval: Request timeout in seconds. Defaults to `120`. + /// - networkServiceType: `URLRequest.NetworkServiceType`. Defaults to `.default`. + /// - Returns: `T` decoded from the response body. + /// - Throws: `URLError` for transport failures, an `NSError` carrying the HTTP + /// status for non-2xx responses, or `DecodingError` on a malformed body. + func upload( + url: String, + method: httpMethod = .post, + form: MultipartForm, + pathParams: [String: String] = [:], + headers: [String: String] = [:], + debug: Bool = true, + timeoutInterval: TimeInterval = 120, + networkServiceType: URLRequest.NetworkServiceType = .default + ) async throws -> T { + let prepared = try buildUploadRequest(url: url, method: method, form: form, + pathParams: pathParams, headers: headers, + timeout: timeoutInterval, serviceType: networkServiceType) + defer { try? FileManager.default.removeItem(at: prepared.bodyFile) } + + if debug { API.requestLOG(method: method, request: prepared.request) } + let (session, mustInvalidate) = makeSession() + defer { if mustInvalidate { session.finishTasksAndInvalidate() } } + + let (data, response) = try await API.performUpload(prepared.request, + fromFile: prepared.bodyFile, + session: session) + return try API.finishUpload(data: data, response: response, + method: method, request: prepared.request, debug: debug) + } + + /// Multipart upload that reports progress. + /// + /// Same as ``upload(url:method:form:pathParams:headers:debug:timeoutInterval:networkServiceType:)`` + /// but calls `onProgress` with a fraction in `0.0...1.0` as bytes are sent, + /// then `1.0` once the body has been fully transmitted. + /// + /// - Parameter onProgress: invoked on an arbitrary queue; hop to the main + /// actor yourself before touching UI. + @available(iOS 15.0, *) + func upload( + url: String, + method: httpMethod = .post, + form: MultipartForm, + pathParams: [String: String] = [:], + headers: [String: String] = [:], + debug: Bool = true, + timeoutInterval: TimeInterval = 120, + networkServiceType: URLRequest.NetworkServiceType = .default, + onProgress: @escaping @Sendable (Double) -> Void + ) async throws -> T { + let prepared = try buildUploadRequest(url: url, method: method, form: form, + pathParams: pathParams, headers: headers, + timeout: timeoutInterval, serviceType: networkServiceType) + defer { try? FileManager.default.removeItem(at: prepared.bodyFile) } + + if debug { API.requestLOG(method: method, request: prepared.request) } + let (session, mustInvalidate) = makeSession() + defer { if mustInvalidate { session.finishTasksAndInvalidate() } } + + let progressDelegate = UploadProgressDelegate(onProgress: onProgress) + let (data, response) = try await session.upload(for: prepared.request, + fromFile: prepared.bodyFile, + delegate: progressDelegate) + onProgress(1.0) + return try API.finishUpload(data: data, response: response, + method: method, request: prepared.request, debug: debug) + } +} + +// MARK: - Shared internals + +@available(iOS 13.0.0, *) +extension API { + + private func buildUploadRequest( + url: String, + method: httpMethod, + form: MultipartForm, + pathParams: [String: String], + headers: [String: String], + timeout: TimeInterval, + serviceType: URLRequest.NetworkServiceType + ) throws -> (request: URLRequest, bodyFile: URL) { + let resolvedURL = try makeURL(url, pathParams: pathParams) + var request = buildRequest(url: resolvedURL, method: method, headers: headers, + timeout: timeout, serviceType: serviceType) + let serialized = try form.serialize() + request.setValue(serialized.contentType, forHTTPHeaderField: "Content-Type") + return (request, serialized.fileURL) + } + + fileprivate static func finishUpload( + data: Data, + response: URLResponse, + method: httpMethod, + request: URLRequest, + debug: Bool + ) throws -> T { + let code = (response as? HTTPURLResponse)?.statusCode ?? LCEssentials.DEFAULT_ERROR_CODE + switch classify(code: code, data: data, method: method, request: request, debug: debug) { + case .success: + return try decodeResponse(data) + case .clientError, .otherError: + throw friendlyError(code: code, data: data) + } + } + + /// Bridges `URLSession.uploadTask(with:fromFile:)` to `async` so the base + /// `upload` works down to iOS 13 (`URLSession.upload(for:fromFile:)` is iOS 15+). + fileprivate static func performUpload(_ request: URLRequest, + fromFile fileURL: URL, + session: URLSession) async throws -> (Data, URLResponse) { + try await withCheckedThrowingContinuation { continuation in + let task = session.uploadTask(with: request, fromFile: fileURL) { data, response, error in + if let error { + continuation.resume(throwing: error) + } else if let data, let response { + continuation.resume(returning: (data, response)) + } else { + continuation.resume(throwing: API.defaultError) + } + } + task.resume() + } + } +} + +/// Forwards `URLSession` upload progress to a `@Sendable` closure. Immutable +/// after `init`, safe to hand to `URLSession` as a task delegate. +@available(iOS 15.0, *) +private final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + + private let onProgress: @Sendable (Double) -> Void + + init(onProgress: @escaping @Sendable (Double) -> Void) { + self.onProgress = onProgress + super.init() + } + + func urlSession(_ session: URLSession, + task: URLSessionTask, + didSendBodyData bytesSent: Int64, + totalBytesSent: Int64, + totalBytesExpectedToSend: Int64) { + guard totalBytesExpectedToSend > 0 else { return } + let fraction = Double(totalBytesSent) / Double(totalBytesExpectedToSend) + onProgress(min(max(fraction, 0), 1)) + } +} +#endif diff --git a/Sources/LCEssentials/Classes/LCEssentials+API.swift b/Sources/LCEssentials/Classes/LCEssentials+API.swift index 5b8c339..0757a29 100644 --- a/Sources/LCEssentials/Classes/LCEssentials+API.swift +++ b/Sources/LCEssentials/Classes/LCEssentials+API.swift @@ -175,12 +175,12 @@ public actor API { // MARK: - Request building - private func makeURL(_ template: String, pathParams: [String: String]) throws -> URL { + func makeURL(_ template: String, pathParams: [String: String]) throws -> URL { guard let url = URL(string: template.replaceURL(pathParams)) else { throw API.defaultError } return url } - private func buildRequest(url: URL, + func buildRequest(url: URL, method: httpMethod, headers: [String: String], timeout: TimeInterval, @@ -205,9 +205,9 @@ public actor API { // MARK: - Response handling - private enum ResponseDisposition { case success, clientError, otherError } + enum ResponseDisposition { case success, clientError, otherError } - private static func classify(code: Int, data: Data, method: httpMethod, + static func classify(code: Int, data: Data, method: httpMethod, request: URLRequest, debug: Bool) -> ResponseDisposition { let error = (200..<300).contains(code) ? nil : URLError(URLError.Code(rawValue: code)) if debug { @@ -220,7 +220,7 @@ public actor API { } } - private static func decodeResponse(_ data: Data) throws -> T { + static func decodeResponse(_ data: Data) throws -> T { if T.self == String.self, let string = String(data: data, encoding: .utf8) as? T { return string } @@ -232,7 +232,7 @@ public actor API { } } - private static func friendlyError(code: Int, data: Data) -> NSError { + static func friendlyError(code: Int, data: Data) -> NSError { let urlError = URLError(URLError.Code(rawValue: code)) return NSError.createErrorWith(code: code, description: urlError.localizedDescription, @@ -258,7 +258,7 @@ public actor API { /// - The common no-cert case reuses `URLSession.shared`. /// /// - Returns: the session and whether the caller must invalidate it. - private func makeSession() -> (session: URLSession, mustInvalidate: Bool) { + func makeSession() -> (session: URLSession, mustInvalidate: Bool) { #if canImport(Security) let delegate: URLSessionDelegateHandler? = certData != nil ? URLSessionDelegateHandler(certData: certData, password: certPassword) @@ -276,79 +276,6 @@ public actor API { return (URLSession.shared, false) } - // MARK: - Upload - - /// Uploads a `multipart/form-data` body and decodes the JSON response. - /// - /// The body is serialised to a temporary file and streamed from disk, so a - /// large file never becomes fully resident in memory. The temp file is - /// always removed before returning. - /// - /// - Parameters: - /// - url: The URL string. `{name}` placeholders are filled from `pathParams`. - /// - method: The HTTP method. Defaults to `.post`. - /// - form: The multipart body (see ``MultipartForm``). - /// - pathParams: Values substituted into `{name}` placeholders in `url`. - /// - headers: Custom headers, merged over the defaults (custom wins). The - /// `Content-Type` is always set to the multipart type. - /// - debug: Print request/response debug logs. Defaults to `true`. - /// - timeoutInterval: Request timeout in seconds. Defaults to `120`. - /// - networkServiceType: `URLRequest.NetworkServiceType`. Defaults to `.default`. - /// - Returns: `T` decoded from the response body. - /// - Throws: `URLError` for transport failures, an `NSError` carrying the HTTP - /// status for non-2xx responses, or `DecodingError` on a malformed body. - public func upload( - url: String, - method: httpMethod = .post, - form: MultipartForm, - pathParams: [String: String] = [:], - headers: [String: String] = [:], - debug: Bool = true, - timeoutInterval: TimeInterval = 120, - networkServiceType: URLRequest.NetworkServiceType = .default - ) async throws -> T { - let resolvedURL = try makeURL(url, pathParams: pathParams) - var urlRequest = buildRequest(url: resolvedURL, method: method, headers: headers, - timeout: timeoutInterval, serviceType: networkServiceType) - - let serialized = try form.serialize() - defer { try? FileManager.default.removeItem(at: serialized.fileURL) } - urlRequest.setValue(serialized.contentType, forHTTPHeaderField: "Content-Type") - - if debug { API.requestLOG(method: method, request: urlRequest) } - let (session, mustInvalidate) = makeSession() - defer { if mustInvalidate { session.finishTasksAndInvalidate() } } - - let (data, response) = try await Self.performUpload(urlRequest, - fromFile: serialized.fileURL, - session: session) - let code = (response as? HTTPURLResponse)?.statusCode ?? LCEssentials.DEFAULT_ERROR_CODE - switch Self.classify(code: code, data: data, method: method, request: urlRequest, debug: debug) { - case .success: - return try Self.decodeResponse(data) - case .clientError, .otherError: - throw Self.friendlyError(code: code, data: data) - } - } - - /// Bridges `URLSession.uploadTask(with:fromFile:)` to `async` so the upload - /// works down to iOS 13 (`URLSession.upload(for:fromFile:)` is iOS 15+). - private static func performUpload(_ request: URLRequest, - fromFile fileURL: URL, - session: URLSession) async throws -> (Data, URLResponse) { - try await withCheckedThrowingContinuation { continuation in - let task = session.uploadTask(with: request, fromFile: fileURL) { data, response, error in - if let error { - continuation.resume(throwing: error) - } else if let data, let response { - continuation.resume(returning: (data, response)) - } else { - continuation.resume(throwing: API.defaultError) - } - } - task.resume() - } - } } #if canImport(Security) diff --git a/Sources/LCEssentials/Extensions/LCEssentials+Dictionary.swift b/Sources/LCEssentials/Extensions/LCEssentials+Dictionary.swift index 2645b59..d956e78 100644 --- a/Sources/LCEssentials/Extensions/LCEssentials+Dictionary.swift +++ b/Sources/LCEssentials/Extensions/LCEssentials+Dictionary.swift @@ -137,11 +137,10 @@ public extension Dictionary { /// - LoverdeCo: Convert Dictonary to Object /// - /// - returns: Object: Codable/Decodable - func toObjetct() -> T { - let jsonString = self.convertToJSON - let output: T = try! JSONDecoder.decode(jsonString) - return output + /// - returns: Object: Decodable & Sendable + /// - throws: `DecodingError` when the dictionary does not match `T`. + func toObjetct() throws -> T { + try JSONDecoder.decode(self.convertToJSON) } /// Check if key exists in dictionary. diff --git a/Tests/LCEssentialsTests/APIUploadTests.swift b/Tests/LCEssentialsTests/APIUploadTests.swift index ca77364..268eef6 100644 --- a/Tests/LCEssentialsTests/APIUploadTests.swift +++ b/Tests/LCEssentialsTests/APIUploadTests.swift @@ -5,6 +5,14 @@ private struct UploadEcho: Decodable, Sendable, Equatable { let ok: Bool } +/// Thread-safe sink for progress callbacks (invoked on an arbitrary queue). +private final class ProgressBox: @unchecked Sendable { + private let lock = NSLock() + private var storage: [Double] = [] + func record(_ value: Double) { lock.lock(); storage.append(value); lock.unlock() } + var values: [Double] { lock.lock(); defer { lock.unlock() }; return storage } +} + final class APIUploadTests: XCTestCase { private var api: API! @@ -79,6 +87,27 @@ final class APIUploadTests: XCTestCase { XCTAssertEqual(tempCountInDir(dir), before) } + @available(iOS 15.0, *) + func testProgressOverloadDeliversFinalCompletionAndDecodes() async throws { + StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"ok":true}"#.utf8))) + + var form = MultipartForm() + form.file("f", data: Data(repeating: 0x41, count: 4096), filename: "a.bin") + + let progressBox = ProgressBox() + let result: UploadEcho = try await api.upload( + url: "https://api.example.com/x", + form: form, + onProgress: { progressBox.record($0) } + ) + + XCTAssertEqual(result, UploadEcho(ok: true)) + let values = progressBox.values + XCTAssertEqual(values.last, 1.0, "final progress must be 1.0") + XCTAssertTrue(values.allSatisfy { $0 >= 0 && $0 <= 1 }) + XCTAssertEqual(values, values.sorted(), "progress must be monotonic non-decreasing") + } + func testUploadServerErrorThrowsWithStatus() async { StubURLProtocol.setStub(.init(statusCode: 413, body: Data(#"{"error":"too big"}"#.utf8))) var form = MultipartForm() From 20d761b361b67151caf8efcfd099448f020ef443 Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Sat, 29 Aug 2026 18:10:30 -0300 Subject: [PATCH 6/7] [api-upload-refactor] Document API usage, link from README --- Documentation/API.md | 236 +++++++++++++++++++++++++++++++++++++++++++ README.md | 16 +++ 2 files changed, 252 insertions(+) create mode 100644 Documentation/API.md diff --git a/Documentation/API.md b/Documentation/API.md new file mode 100644 index 0000000..71ba541 --- /dev/null +++ b/Documentation/API.md @@ -0,0 +1,236 @@ +# `API` — networking for LCEssentials + +`API` is an `actor` that wraps `URLSession` for JSON REST calls and multipart +uploads. One line to send a typed request, decode the response, and get a +consistent error — instead of re-writing the same `URLRequest` / status-code / +`JSONDecoder` boilerplate in every project. + +```swift +import LCEssentials + +struct User: Decodable, Sendable { let id: Int; let name: String } + +let user: User = try await API.shared.request( + url: "https://api.example.com/users/{id}", + method: .get, + pathParams: ["id": "42"] +) +``` + +--- + +## Why not hand-rolled `URLSession` + +| Hand-rolled `URLSession` | `API` | +|---|---| +| Build `URLRequest`, set method, headers, body, `Content-Type`, `Content-Length` every call | `request(url:method:body:)` — headers and content metadata handled | +| `switch` on `httpResponse.statusCode` in every call site, or forget to | 2xx decodes, 4xx/5xx throw a populated `NSError` (`.code` = HTTP status, failure reason = response body) | +| `JSONDecoder().decode(T.self, from:)` + custom error messages each time | `JSONDecoder.decode` with keyed / type-mismatch / missing-value diagnostics baked in | +| Multipart body assembled by hand with `\r\n` string concatenation and force-unwrapped `.data(using:)` | `MultipartForm` builder; body streamed from a temp file, never fully in memory | +| Large file upload loads the whole file into a `Data` | `form.file(_:url:)` streams from disk in 64 KB chunks | +| Retry logic copy-pasted, often unbounded | `persistConnection: true`, bounded by `API.maxPersistRetries` | +| Client-certificate (mTLS) needs a custom `URLSessionDelegate` per project | `setupCertification(certData:password:)` | +| Progress reporting needs a delegate + KVO wiring | `upload(..., onProgress:)` (iOS 15+) | +| `@MainActor` hops or manual `DispatchQueue` juggling | `actor`-isolated, `Sendable`-checked, runs off the main thread | +| Response types must be `Codable` even when only decoding | `T: Decodable & Sendable` | + +`API` is not a replacement for a full networking stack (no interceptors, +caching policy DSL, or automatic token refresh). For simple typed REST it +removes the boilerplate and the easy-to-get-wrong parts. + +--- + +## Requests + +### GET + +```swift +let items: [Item] = try await API.shared.request( + url: "https://api.example.com/items", + method: .get +) +``` + +### POST / PUT / PATCH with a JSON body + +`body` takes any `HTTPBody`. `jsonBody(_:)` wraps an `Encodable & Sendable` +value; `Content-Type: application/json; charset=UTF-8` is set for you. + +```swift +struct CreateUser: Encodable, Sendable { let name: String; let email: String } + +let created: User = try await API.shared.request( + url: "https://api.example.com/users", + method: .post, + body: jsonBody(CreateUser(name: "Ana", email: "ana@example.com")) +) +``` + +### Form-url-encoded body + +```swift +let token: TokenDTO = try await API.shared.request( + url: "https://api.example.com/oauth/token", + method: .post, + body: .form([ + "grant_type": "password", + "username": "ana", + "password": "s3cr3t" // reserved chars are percent-escaped, never dropped + ]) +) +``` + +### Raw body (you control the bytes and content type) + +```swift +body: RawBody(data: protobufData, contentType: "application/x-protobuf") +``` + +### Path parameters + +`{name}` placeholders in `url` are filled from `pathParams`: + +```swift +url: "https://api.example.com/teams/{team}/members/{member}", +pathParams: ["team": "42", "member": "7"] +``` + +### Custom headers + +Merged over the defaults — your value wins per key, the other defaults stay. + +```swift +headers: ["Authorization": "Bearer \(accessToken)"] +``` + +### Plain-text / string responses + +When `T == String` the raw response body is returned without JSON decoding: + +```swift +let csv: String = try await API.shared.request(url: "\(base)/export.csv", method: .get) +``` + +### Retry on transient 4xx + +```swift +let data: Payload = try await API.shared.request( + url: "\(base)/flaky", + method: .get, + persistConnection: true // retries up to API.maxPersistRetries, then throws +) +``` + +### Other options + +| Parameter | Default | Meaning | +|---|---|---| +| `debug` | `true` | Print request/response logs | +| `timeoutInterval` | `30` | Seconds | +| `networkServiceType` | `.default` | `URLRequest.NetworkServiceType` | + +--- + +## Errors + +Non-2xx responses throw an `NSError`: + +```swift +do { + let u: User = try await API.shared.request(url: "\(base)/users/999", method: .get) +} catch let error as NSError { + error.code // HTTP status, e.g. 404 + error.localizedDescription // from URLError + error.localizedFailureReason // pretty-printed response body +} +``` + +Transport failures surface as `URLError`. Malformed success bodies throw +`DecodingError` with a readable message (missing key, type mismatch, …). + +--- + +## Uploads + +### Build a multipart form + +```swift +var form = MultipartForm() +form.field("caption", "Sunset") +form.file("thumbnail", data: jpegData, filename: "thumb.jpg") // in memory +form.file("video", url: localVideoURL) // streamed from disk +``` + +- `field(_:_:)` — plain text field. +- `file(_:data:filename:mime:)` — in-memory blob. MIME guessed from the + filename extension unless you pass `mime:`. +- `file(_:url:filename:mime:)` — on-disk file, streamed straight into the body + so a large file never becomes fully resident in memory. + +### Send + +```swift +let result: UploadResult = try await API.shared.upload( + url: "https://api.example.com/media", + form: form +) +``` + +The body is serialised to a temp file and always removed afterwards, on success +and on throw. Works on iOS 13+. + +### With progress (iOS 15+) + +```swift +let result: UploadResult = try await API.shared.upload( + url: "https://api.example.com/media", + form: form, + onProgress: { fraction in + Task { @MainActor in progressView.progress = Float(fraction) } + } +) +``` + +`onProgress` is called on an arbitrary queue with a value in `0.0...1.0`, then +`1.0` once the body has been fully sent. Hop to the main actor yourself before +touching UI. + +--- + +## Client certificate (mutual TLS) + +```swift +let p12 = try Data(contentsOf: certificateURL) +await API.shared.setupCertification(certData: p12, password: "cert-password") +// subsequent requests present the client certificate on TLS challenge +``` + +--- + +## Extending: custom body types + +Conform to `HTTPBody`: + +```swift +struct CSVBody: HTTPBody { + let rows: [[String]] + func encoded() throws -> (data: Data, contentType: String) { + let text = rows.map { $0.joined(separator: ",") }.joined(separator: "\n") + return (Data(text.utf8), "text/csv; charset=UTF-8") + } +} + +try await API.shared.request(url: "\(base)/import", method: .post, body: CSVBody(rows: rows)) +``` + +`API` needs no change to accept it. + +--- + +## Notes + +- `API` is an `actor`. Every call is `await`; config setters + (`setupCertification`, `setPersistConnectionDelay`) are `await` too. +- Response and body types must be `Sendable`. Value-type structs already are. +- The shared instance is `API.shared`. Tests build isolated instances with + `API(testConfiguration:)` and a stub `URLProtocol`. diff --git a/README.md b/README.md index 50143be..f252ffe 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ This is a repository of essential scripts written in Swift for Loverde Co. used ## Features - [x] Many usefull scripts extensions +- [x] `API` — typed async networking + multipart uploads. See **[Documentation/API.md](Documentation/API.md)** Installation @@ -50,6 +51,21 @@ self.navigationController?.popViewControllerWithHandler { //Do some stuff after pop } ``` +* Networking with `API` + +```swift +struct User: Decodable, Sendable { let id: Int; let name: String } + +let user: User = try await API.shared.request( + url: "https://api.example.com/users/{id}", + method: .get, + pathParams: ["id": "42"] +) +``` + +Full guide — requests, uploads, client certificates, error handling, and why it +beats a hand-rolled `URLSession`: **[Documentation/API.md](Documentation/API.md)** + ## Another components > LCESnackBarView - **great way to send feedback to user** From 6acec0ff3a22cf089e47785348788da44ef6493b Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Sat, 29 Aug 2026 18:51:45 -0300 Subject: [PATCH 7/7] [api-upload-refactor] Set deployment target to iOS 15, simplify upload - Package.swift: .iOS(.v13) -> .iOS(.v15), .watchOS(.v6) -> .v8 (README already stated iOS 15; watchOS 8 needed for URLSession.upload(for:fromFile:)) - drop the iOS-13 uploadTask continuation bridge; both upload overloads now share one runUpload using session.upload(for:fromFile:delegate:) - remove now-redundant @available(iOS 15) from the progress overload and test - Documentation/API.md: drop iOS-version caveats --- Documentation/API.md | 6 +- Package.resolved | 2 +- Package.swift | 4 +- .../Classes/LCEssentials+API+Upload.swift | 88 ++++++++----------- Tests/LCEssentialsTests/APIUploadTests.swift | 1 - 5 files changed, 43 insertions(+), 58 deletions(-) diff --git a/Documentation/API.md b/Documentation/API.md index 71ba541..b66d9ce 100644 --- a/Documentation/API.md +++ b/Documentation/API.md @@ -30,7 +30,7 @@ let user: User = try await API.shared.request( | Large file upload loads the whole file into a `Data` | `form.file(_:url:)` streams from disk in 64 KB chunks | | Retry logic copy-pasted, often unbounded | `persistConnection: true`, bounded by `API.maxPersistRetries` | | Client-certificate (mTLS) needs a custom `URLSessionDelegate` per project | `setupCertification(certData:password:)` | -| Progress reporting needs a delegate + KVO wiring | `upload(..., onProgress:)` (iOS 15+) | +| Progress reporting needs a delegate + KVO wiring | `upload(..., onProgress:)` | | `@MainActor` hops or manual `DispatchQueue` juggling | `actor`-isolated, `Sendable`-checked, runs off the main thread | | Response types must be `Codable` even when only decoding | `T: Decodable & Sendable` | @@ -177,9 +177,9 @@ let result: UploadResult = try await API.shared.upload( ``` The body is serialised to a temp file and always removed afterwards, on success -and on throw. Works on iOS 13+. +and on throw. -### With progress (iOS 15+) +### With progress ```swift let result: UploadResult = try await API.shared.upload( diff --git a/Package.resolved b/Package.resolved index 848404c..e6873e5 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "50e672f8971a68675dcb3ca27b4bd4832c7e96f2216ae062f002554d445c6714", + "originHash" : "6e3fcf8724d6b7d2d0bbffc25b4ec3b9d8b65a6ef53d157498d5447fd26cf5db", "pins" : [ { "identity" : "lcecryptokitbinary", diff --git a/Package.swift b/Package.swift index 26a9503..66b6d97 100644 --- a/Package.swift +++ b/Package.swift @@ -24,10 +24,10 @@ let targetDependencies: [Target.Dependency] = enableCryptoBinary let package = Package( name: "LCEssentials", platforms: [ - .iOS(.v13), + .iOS(.v15), .macOS(.v10_15), .tvOS(.v13), - .watchOS(.v6) + .watchOS(.v8) ], products: [ .library( diff --git a/Sources/LCEssentials/Classes/LCEssentials+API+Upload.swift b/Sources/LCEssentials/Classes/LCEssentials+API+Upload.swift index 67b2bd3..ab1f0af 100644 --- a/Sources/LCEssentials/Classes/LCEssentials+API+Upload.swift +++ b/Sources/LCEssentials/Classes/LCEssentials+API+Upload.swift @@ -30,7 +30,7 @@ public extension API { /// /// The body is serialised to a temporary file and streamed from disk, so a /// large file never becomes fully resident in memory. The temp file is - /// always removed before returning. + /// always removed before returning, on success and on throw. /// /// - Parameters: /// - url: The URL string. `{name}` placeholders are filled from `pathParams`. @@ -55,20 +55,9 @@ public extension API { timeoutInterval: TimeInterval = 120, networkServiceType: URLRequest.NetworkServiceType = .default ) async throws -> T { - let prepared = try buildUploadRequest(url: url, method: method, form: form, - pathParams: pathParams, headers: headers, - timeout: timeoutInterval, serviceType: networkServiceType) - defer { try? FileManager.default.removeItem(at: prepared.bodyFile) } - - if debug { API.requestLOG(method: method, request: prepared.request) } - let (session, mustInvalidate) = makeSession() - defer { if mustInvalidate { session.finishTasksAndInvalidate() } } - - let (data, response) = try await API.performUpload(prepared.request, - fromFile: prepared.bodyFile, - session: session) - return try API.finishUpload(data: data, response: response, - method: method, request: prepared.request, debug: debug) + try await runUpload(url: url, method: method, form: form, pathParams: pathParams, + headers: headers, debug: debug, timeout: timeoutInterval, + serviceType: networkServiceType, progressDelegate: nil) } /// Multipart upload that reports progress. @@ -79,7 +68,6 @@ public extension API { /// /// - Parameter onProgress: invoked on an arbitrary queue; hop to the main /// actor yourself before touching UI. - @available(iOS 15.0, *) func upload( url: String, method: httpMethod = .post, @@ -91,22 +79,12 @@ public extension API { networkServiceType: URLRequest.NetworkServiceType = .default, onProgress: @escaping @Sendable (Double) -> Void ) async throws -> T { - let prepared = try buildUploadRequest(url: url, method: method, form: form, - pathParams: pathParams, headers: headers, - timeout: timeoutInterval, serviceType: networkServiceType) - defer { try? FileManager.default.removeItem(at: prepared.bodyFile) } - - if debug { API.requestLOG(method: method, request: prepared.request) } - let (session, mustInvalidate) = makeSession() - defer { if mustInvalidate { session.finishTasksAndInvalidate() } } - - let progressDelegate = UploadProgressDelegate(onProgress: onProgress) - let (data, response) = try await session.upload(for: prepared.request, - fromFile: prepared.bodyFile, - delegate: progressDelegate) + let result: T = try await runUpload(url: url, method: method, form: form, + pathParams: pathParams, headers: headers, debug: debug, + timeout: timeoutInterval, serviceType: networkServiceType, + progressDelegate: UploadProgressDelegate(onProgress: onProgress)) onProgress(1.0) - return try API.finishUpload(data: data, response: response, - method: method, request: prepared.request, debug: debug) + return result } } @@ -115,6 +93,33 @@ public extension API { @available(iOS 13.0.0, *) extension API { + private func runUpload( + url: String, + method: httpMethod, + form: MultipartForm, + pathParams: [String: String], + headers: [String: String], + debug: Bool, + timeout: TimeInterval, + serviceType: URLRequest.NetworkServiceType, + progressDelegate: UploadProgressDelegate? + ) async throws -> T { + let prepared = try buildUploadRequest(url: url, method: method, form: form, + pathParams: pathParams, headers: headers, + timeout: timeout, serviceType: serviceType) + defer { try? FileManager.default.removeItem(at: prepared.bodyFile) } + + if debug { API.requestLOG(method: method, request: prepared.request) } + let (session, mustInvalidate) = makeSession() + defer { if mustInvalidate { session.finishTasksAndInvalidate() } } + + let (data, response) = try await session.upload(for: prepared.request, + fromFile: prepared.bodyFile, + delegate: progressDelegate) + return try API.finishUpload(data: data, response: response, + method: method, request: prepared.request, debug: debug) + } + private func buildUploadRequest( url: String, method: httpMethod, @@ -147,30 +152,11 @@ extension API { throw friendlyError(code: code, data: data) } } - - /// Bridges `URLSession.uploadTask(with:fromFile:)` to `async` so the base - /// `upload` works down to iOS 13 (`URLSession.upload(for:fromFile:)` is iOS 15+). - fileprivate static func performUpload(_ request: URLRequest, - fromFile fileURL: URL, - session: URLSession) async throws -> (Data, URLResponse) { - try await withCheckedThrowingContinuation { continuation in - let task = session.uploadTask(with: request, fromFile: fileURL) { data, response, error in - if let error { - continuation.resume(throwing: error) - } else if let data, let response { - continuation.resume(returning: (data, response)) - } else { - continuation.resume(throwing: API.defaultError) - } - } - task.resume() - } - } } /// Forwards `URLSession` upload progress to a `@Sendable` closure. Immutable /// after `init`, safe to hand to `URLSession` as a task delegate. -@available(iOS 15.0, *) +@available(iOS 13.0.0, *) private final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { private let onProgress: @Sendable (Double) -> Void diff --git a/Tests/LCEssentialsTests/APIUploadTests.swift b/Tests/LCEssentialsTests/APIUploadTests.swift index 268eef6..0af1c79 100644 --- a/Tests/LCEssentialsTests/APIUploadTests.swift +++ b/Tests/LCEssentialsTests/APIUploadTests.swift @@ -87,7 +87,6 @@ final class APIUploadTests: XCTestCase { XCTAssertEqual(tempCountInDir(dir), before) } - @available(iOS 15.0, *) func testProgressOverloadDeliversFinalCompletionAndDecodes() async throws { StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"ok":true}"#.utf8)))