[api-upload-refactor] Add multipart upload(), split logging to its own file

- upload<T>(url:method:form:pathParams:headers:debug:timeoutInterval:networkServiceType:)
  serialises MultipartForm to a temp file, streams it via uploadTask(with:fromFile:)
  bridged to async (works on iOS 13+, unlike URLSession.upload(for:fromFile:))
- temp body file always removed via defer (success and throw)
- move requestLOG/responseLOG to LCEssentials+API+Logging.swift; extract
  logResponseError helper; keeps API file at 473 lines
- APIUploadTests: 4 cases
- progress-reporting overload deferred (no stub seam for didSendBodyData)
This commit is contained in:
Daniel Arantes Loverde
2026-08-29 17:16:16 -03:00
parent bcd358cb09
commit 3c64f364be
3 changed files with 263 additions and 93 deletions

View File

@@ -0,0 +1,94 @@
//
// 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, *)
extension API {
/// Logs details of an outgoing network request for debugging purposes.
static func requestLOG(method: httpMethod, request: URLRequest) {
print("\n<========================= 🟠 INTERNET CONNECTION - REQUEST =========================>")
printLog(title: "DATE AND TIME", msg: Date().debugDescription)
printLog(title: "METHOD", msg: method.rawValue)
printLog(title: "REQUEST", msg: String(describing: request))
printLog(title: "HEADERS", msg: request.allHTTPHeaderFields?.debugDescription ?? "")
if let dataBody = request.httpBody, let prettyJson = dataBody.prettyJson {
printLog(title: "PARAMETERS", msg: prettyJson)
} else if let dataBody = request.httpBody {
printLog(title: "PARAMETERS", msg: String(data: dataBody, encoding: .utf8) ?? "-")
}
print("<======================================================================================>")
}
/// Logs details of an incoming network response for debugging purposes.
static func responseLOG(method: httpMethod, request: URLRequest, data: Data?, statusCode: Int, error: Error?) {
let icon = error != nil ? "🔴" : "🟢"
print("\n<========================= \(icon) INTERNET CONNECTION - RESPONSE =========================>")
printLog(title: "DATE AND TIME", msg: Date().debugDescription)
printLog(title: "METHOD", msg: method.rawValue)
printLog(title: "REQUEST", msg: String(describing: request))
printLog(title: "HEADERS", msg: request.allHTTPHeaderFields?.debugDescription ?? "")
if let dataBody = request.httpBody, let prettyJson = dataBody.prettyJson {
printLog(title: "PARAMETERS", msg: prettyJson)
} else if let dataBody = request.httpBody {
printLog(title: "PARAMETERS", msg: String(data: dataBody, encoding: .utf8) ?? "-")
}
printLog(title: "STATUS CODE", msg: String(describing: statusCode))
if let dataResponse = data, let prettyJson = dataResponse.prettyJson {
printLog(title: "RESPONSE", msg: prettyJson)
} else {
printLog(title: "RESPONSE", msg: String(data: data ?? Data(), encoding: .utf8) ?? "-")
}
logResponseError(error, data: data, statusCode: statusCode)
print("<======================================================================================>")
}
private static func logResponseError(_ error: Error?, data: Data?, statusCode: Int) {
if let error {
switch error.statusCode {
case NSURLErrorTimedOut:
printError(title: "RESPONSE ERROR TIMEOUT", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorNotConnectedToInternet:
printError(title: "RESPONSE ERROR NO INTERNET", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorNetworkConnectionLost:
printError(title: "RESPONSE ERROR CONNECTION LOST", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorCancelledReasonUserForceQuitApplication:
printError(title: "RESPONSE ERROR APP QUIT", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorCancelledReasonBackgroundUpdatesDisabled:
printError(title: "RESPONSE ERROR BG DISABLED", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorBackgroundSessionWasDisconnected:
printError(title: "RESPONSE ERROR BG SESSION DISCONNECTED", msg: "DESCRICAO: \(error.localizedDescription)")
default:
printError(title: "GENERAL", msg: error.localizedDescription)
}
} else if let data, statusCode != 200 {
if let jsonString = String(data: data, encoding: .utf8) {
printError(title: "JSON STATUS CODE \(statusCode)", msg: jsonString)
} else {
printError(title: "DATA STATUS CODE \(statusCode)", msg: data.debugDescription)
}
}
}
}
#endif

View File

@@ -275,6 +275,80 @@ public actor API {
} }
return (URLSession.shared, false) 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) #if canImport(Security)
@@ -396,97 +470,4 @@ extension Error {
} }
} }
@available(iOS 13.0.0, *)
extension API {
/// Logs details of an outgoing network request for debugging purposes.
///
/// - Parameters:
/// - method: The HTTP method of the request.
/// - request: The `URLRequest` object.
fileprivate static func requestLOG(method: httpMethod, request: URLRequest) {
print("\n<========================= 🟠 INTERNET CONNECTION - REQUEST =========================>")
printLog(title: "DATE AND TIME", msg: Date().debugDescription)
printLog(title: "METHOD", msg: method.rawValue)
printLog(title: "REQUEST", msg: String(describing: request))
printLog(title: "HEADERS", msg: request.allHTTPHeaderFields?.debugDescription ?? "")
//
if let dataBody = request.httpBody, let prettyJson = dataBody.prettyJson {
printLog(title: "PARAMETERS", msg: prettyJson)
} else if let dataBody = request.httpBody {
printLog(title: "PARAMETERS", msg: String(data: dataBody, encoding: .utf8) ?? "-")
}
//
print("<======================================================================================>")
}
/// Logs details of an incoming network response for debugging purposes.
///
/// - Parameters:
/// - method: The HTTP method of the original request.
/// - request: The `URLRequest` object that generated this response.
/// - data: The data received in the response.
/// - statusCode: The HTTP status code of the response.
/// - error: An optional `Error` object if the request failed.
fileprivate static func responseLOG(method: httpMethod, request: URLRequest, data: Data?, statusCode: Int, error: Error?) {
///
let icon = error != nil ? "🔴" : "🟢"
print("\n<========================= \(icon) INTERNET CONNECTION - RESPONSE =========================>")
printLog(title: "DATE AND TIME", msg: Date().debugDescription)
printLog(title: "METHOD", msg: method.rawValue)
printLog(title: "REQUEST", msg: String(describing: request))
printLog(title: "HEADERS", msg: request.allHTTPHeaderFields?.debugDescription ?? "")
//
if let dataBody = request.httpBody, let prettyJson = dataBody.prettyJson {
printLog(title: "PARAMETERS", msg: prettyJson)
} else if let dataBody = request.httpBody {
printLog(title: "PARAMETERS", msg: String(data: dataBody, encoding: .utf8) ?? "-")
}
//
printLog(title: "STATUS CODE", msg: String(describing: statusCode))
//
if let dataResponse = data, let prettyJson = dataResponse.prettyJson {
printLog(title: "RESPONSE", msg: prettyJson)
} else {
printLog(title: "RESPONSE", msg: String(data: data ?? Data(), encoding: .utf8) ?? "-")
}
//
if let error = error {
switch error.statusCode {
case NSURLErrorTimedOut:
printError(title: "RESPONSE ERROR TIMEOUT", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorNotConnectedToInternet:
printError(title: "RESPONSE ERROR NO INTERNET", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorNetworkConnectionLost:
printError(title: "RESPONSE ERROR CONNECTION LOST", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorCancelledReasonUserForceQuitApplication:
printError(title: "RESPONSE ERROR APP QUIT", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorCancelledReasonBackgroundUpdatesDisabled:
printError(title: "RESPONSE ERROR BG DISABLED", msg: "DESCRICAO: \(error.localizedDescription)")
case NSURLErrorBackgroundSessionWasDisconnected:
printError(title: "RESPONSE ERROR BG SESSION DISCONNECTED", msg: "DESCRICAO: \(error.localizedDescription)")
default:
printError(title: "GENERAL", msg: error.localizedDescription)
}
}else if let data = data, statusCode != 200 {
// - Check if is JSON result
if let jsonString = String(data: data, encoding: .utf8) {
printError(title: "JSON STATUS CODE \(statusCode)", msg: jsonString)
}else{
printError(title: "DATA STATUS CODE \(statusCode)", msg: data.debugDescription)
}
}
//
print("<======================================================================================>")
}
}
#endif #endif

View File

@@ -0,0 +1,95 @@
import XCTest
@testable import LCEssentials
private struct UploadEcho: Decodable, Sendable, Equatable {
let ok: Bool
}
final class APIUploadTests: XCTestCase {
private var api: API!
override func setUp() {
super.setUp()
let cfg = URLSessionConfiguration.ephemeral
cfg.protocolClasses = [StubURLProtocol.self]
api = API(testConfiguration: cfg)
}
override func tearDown() {
StubURLProtocol.reset()
api = nil
super.tearDown()
}
private func tempCountInDir(_ dir: URL) -> Int {
(try? FileManager.default.contentsOfDirectory(atPath: dir.path).filter {
$0.hasPrefix("lce-multipart-")
}.count) ?? -1
}
func testUploadSendsMultipartBodyAndDecodesResponse() async throws {
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"ok":true}"#.utf8)))
var form = MultipartForm()
form.field("caption", "hi")
form.file("photo", data: Data([0x89, 0x50, 0x4E, 0x47]), filename: "p.png")
let expectedBody = try Data(contentsOf: form.serialize().fileURL)
let result: UploadEcho = try await api.upload(
url: "https://api.example.com/media",
form: form
)
XCTAssertEqual(result, UploadEcho(ok: true))
let sent = StubURLProtocol.capturedRequests.first
XCTAssertEqual(sent?.httpMethod, "POST")
XCTAssertTrue(sent?.value(forHTTPHeaderField: "Content-Type")?
.hasPrefix("multipart/form-data; boundary=") ?? false)
// body framing matches a fresh serialize() (boundary differs per form
// instance, so compare structure, not bytes, by re-serialising the SAME form)
XCTAssertEqual(StubURLProtocol.lastCapturedBody?.count, expectedBody.count)
}
func testTempBodyFileRemovedAfterSuccess() async throws {
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"ok":true}"#.utf8)))
let dir = FileManager.default.temporaryDirectory
let before = tempCountInDir(dir)
var form = MultipartForm()
form.file("f", data: Data("x".utf8), filename: "a.txt")
let _: UploadEcho = try await api.upload(url: "https://api.example.com/x", form: form)
XCTAssertEqual(tempCountInDir(dir), before)
}
func testTempBodyFileRemovedAfterThrow() async {
StubURLProtocol.setStub(.init(statusCode: 500, body: Data(#"{"error":"boom"}"#.utf8)))
let dir = FileManager.default.temporaryDirectory
let before = tempCountInDir(dir)
var form = MultipartForm()
form.file("f", data: Data("x".utf8), filename: "a.txt")
do {
let _: UploadEcho = try await api.upload(url: "https://api.example.com/x", form: form)
XCTFail("expected throw")
} catch {
// expected
}
XCTAssertEqual(tempCountInDir(dir), before)
}
func testUploadServerErrorThrowsWithStatus() async {
StubURLProtocol.setStub(.init(statusCode: 413, body: Data(#"{"error":"too big"}"#.utf8)))
var form = MultipartForm()
form.file("f", data: Data("x".utf8), filename: "a.txt")
do {
let _: UploadEcho = try await api.upload(url: "https://api.example.com/x", form: form)
XCTFail("expected throw")
} catch let error as NSError {
XCTAssertEqual(error.code, 413)
} catch {
XCTFail("wrong error: \(error)")
}
}
}