[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)
This commit is contained in:
Daniel Arantes Loverde
2026-08-29 17:46:41 -03:00
parent 3c64f364be
commit fb2e417104
4 changed files with 233 additions and 85 deletions

View File

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

View File

@@ -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<T: Decodable & Sendable>(_ data: Data) throws -> T {
static func decodeResponse<T: Decodable & Sendable>(_ 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<T: Decodable & Sendable>(
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)

View File

@@ -137,11 +137,10 @@ public extension Dictionary {
/// - LoverdeCo: Convert Dictonary to Object
///
/// - returns: Object: Codable/Decodable
func toObjetct<T: Codable & Sendable>() -> 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<T: Codable & Sendable>() throws -> T {
try JSONDecoder.decode(self.convertToJSON)
}
/// Check if key exists in dictionary.

View File

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