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)))