From fb2e4171044fc0d8e9a7b57cff0ff0cf261e2c4e Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Sat, 29 Aug 2026 17:46:41 -0300 Subject: [PATCH] [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()