[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:
94
Sources/LCEssentials/Classes/LCEssentials+API+Logging.swift
Normal file
94
Sources/LCEssentials/Classes/LCEssentials+API+Logging.swift
Normal 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
|
||||
@@ -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<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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user