diff --git a/Sources/LCEssentials/Classes/LCEssentials+API+Logging.swift b/Sources/LCEssentials/Classes/LCEssentials+API+Logging.swift new file mode 100644 index 0000000..1b0012a --- /dev/null +++ b/Sources/LCEssentials/Classes/LCEssentials+API+Logging.swift @@ -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 diff --git a/Sources/LCEssentials/Classes/LCEssentials+API.swift b/Sources/LCEssentials/Classes/LCEssentials+API.swift index b7e9ab6..5b8c339 100644 --- a/Sources/LCEssentials/Classes/LCEssentials+API.swift +++ b/Sources/LCEssentials/Classes/LCEssentials+API.swift @@ -275,6 +275,80 @@ 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( + 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) @@ -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 diff --git a/Tests/LCEssentialsTests/APIUploadTests.swift b/Tests/LCEssentialsTests/APIUploadTests.swift new file mode 100644 index 0000000..ca77364 --- /dev/null +++ b/Tests/LCEssentialsTests/APIUploadTests.swift @@ -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)") + } + } +}