[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:
@@ -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 |
|
| 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` |
|
| Retry logic copy-pasted, often unbounded | `persistConnection: true`, bounded by `API.maxPersistRetries` |
|
||||||
| Client-certificate (mTLS) needs a custom `URLSessionDelegate` per project | `setupCertification(certData:password:)` |
|
| 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 |
|
| `@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` |
|
| 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
|
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
|
```swift
|
||||||
let result: UploadResult = try await API.shared.upload(
|
let result: UploadResult = try await API.shared.upload(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "50e672f8971a68675dcb3ca27b4bd4832c7e96f2216ae062f002554d445c6714",
|
"originHash" : "6e3fcf8724d6b7d2d0bbffc25b4ec3b9d8b65a6ef53d157498d5447fd26cf5db",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "lcecryptokitbinary",
|
"identity" : "lcecryptokitbinary",
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ let targetDependencies: [Target.Dependency] = enableCryptoBinary
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "LCEssentials",
|
name: "LCEssentials",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v13),
|
.iOS(.v15),
|
||||||
.macOS(.v10_15),
|
.macOS(.v10_15),
|
||||||
.tvOS(.v13),
|
.tvOS(.v13),
|
||||||
.watchOS(.v6)
|
.watchOS(.v8)
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
.library(
|
.library(
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ public extension API {
|
|||||||
///
|
///
|
||||||
/// The body is serialised to a temporary file and streamed from disk, so a
|
/// 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
|
/// 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:
|
/// - Parameters:
|
||||||
/// - url: The URL string. `{name}` placeholders are filled from `pathParams`.
|
/// - url: The URL string. `{name}` placeholders are filled from `pathParams`.
|
||||||
@@ -55,20 +55,9 @@ public extension API {
|
|||||||
timeoutInterval: TimeInterval = 120,
|
timeoutInterval: TimeInterval = 120,
|
||||||
networkServiceType: URLRequest.NetworkServiceType = .default
|
networkServiceType: URLRequest.NetworkServiceType = .default
|
||||||
) async throws -> T {
|
) async throws -> T {
|
||||||
let prepared = try buildUploadRequest(url: url, method: method, form: form,
|
try await runUpload(url: url, method: method, form: form, pathParams: pathParams,
|
||||||
pathParams: pathParams, headers: headers,
|
headers: headers, debug: debug, timeout: timeoutInterval,
|
||||||
timeout: timeoutInterval, serviceType: networkServiceType)
|
serviceType: networkServiceType, progressDelegate: nil)
|
||||||
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.
|
/// Multipart upload that reports progress.
|
||||||
@@ -79,7 +68,6 @@ public extension API {
|
|||||||
///
|
///
|
||||||
/// - Parameter onProgress: invoked on an arbitrary queue; hop to the main
|
/// - Parameter onProgress: invoked on an arbitrary queue; hop to the main
|
||||||
/// actor yourself before touching UI.
|
/// actor yourself before touching UI.
|
||||||
@available(iOS 15.0, *)
|
|
||||||
func upload<T: Decodable & Sendable>(
|
func upload<T: Decodable & Sendable>(
|
||||||
url: String,
|
url: String,
|
||||||
method: httpMethod = .post,
|
method: httpMethod = .post,
|
||||||
@@ -91,22 +79,12 @@ public extension API {
|
|||||||
networkServiceType: URLRequest.NetworkServiceType = .default,
|
networkServiceType: URLRequest.NetworkServiceType = .default,
|
||||||
onProgress: @escaping @Sendable (Double) -> Void
|
onProgress: @escaping @Sendable (Double) -> Void
|
||||||
) async throws -> T {
|
) async throws -> T {
|
||||||
let prepared = try buildUploadRequest(url: url, method: method, form: form,
|
let result: T = try await runUpload(url: url, method: method, form: form,
|
||||||
pathParams: pathParams, headers: headers,
|
pathParams: pathParams, headers: headers, debug: debug,
|
||||||
timeout: timeoutInterval, serviceType: networkServiceType)
|
timeout: timeoutInterval, serviceType: networkServiceType,
|
||||||
defer { try? FileManager.default.removeItem(at: prepared.bodyFile) }
|
progressDelegate: UploadProgressDelegate(onProgress: onProgress))
|
||||||
|
|
||||||
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)
|
onProgress(1.0)
|
||||||
return try API.finishUpload(data: data, response: response,
|
return result
|
||||||
method: method, request: prepared.request, debug: debug)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +93,33 @@ public extension API {
|
|||||||
@available(iOS 13.0.0, *)
|
@available(iOS 13.0.0, *)
|
||||||
extension API {
|
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(
|
private func buildUploadRequest(
|
||||||
url: String,
|
url: String,
|
||||||
method: httpMethod,
|
method: httpMethod,
|
||||||
@@ -147,30 +152,11 @@ extension API {
|
|||||||
throw friendlyError(code: code, data: data)
|
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
|
/// Forwards `URLSession` upload progress to a `@Sendable` closure. Immutable
|
||||||
/// after `init`, safe to hand to `URLSession` as a task delegate.
|
/// 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 final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
||||||
|
|
||||||
private let onProgress: @Sendable (Double) -> Void
|
private let onProgress: @Sendable (Double) -> Void
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ final class APIUploadTests: XCTestCase {
|
|||||||
XCTAssertEqual(tempCountInDir(dir), before)
|
XCTAssertEqual(tempCountInDir(dir), before)
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 15.0, *)
|
|
||||||
func testProgressOverloadDeliversFinalCompletionAndDecodes() async throws {
|
func testProgressOverloadDeliversFinalCompletionAndDecodes() async throws {
|
||||||
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"ok":true}"#.utf8)))
|
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"ok":true}"#.utf8)))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user