[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
This commit is contained in:
Daniel Arantes Loverde
2026-08-29 18:51:45 -03:00
parent 20d761b361
commit 6acec0ff3a
5 changed files with 43 additions and 58 deletions

View File

@@ -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<T: Decodable & Sendable>(
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<T: Decodable & Sendable>(
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