[api-upload-refactor] Replace request surface: typed body, pathParams, bounded retry
- request(): drop params: Any? and jsonEncoding:; add body: (any HTTPBody)? and pathParams: [String: String] - remove legacy magic-"file" multipart branch (-> upload() in T6) and the now-unused mimeTypeForPath - decompose into makeURL / buildRequest / attach / send / classify / decodeResponse / friendlyError (each < 30 lines) - headers now merge over defaults (custom wins per key) instead of replacing - fix unbounded persistConnection recursion on permanent 4xx: cap at maxPersistRetries (3) - APIRequestTests: 7 cases over StubURLProtocol
This commit is contained in:
@@ -101,189 +101,144 @@ public actor API {
|
||||
persistConnectionDelay = seconds
|
||||
}
|
||||
|
||||
/// Performs an asynchronous network request and decodes the response into a `Codable` type.
|
||||
/// Maximum number of extra attempts made when `persistConnection` is set and
|
||||
/// the server keeps returning a 4xx. Bounds what was previously an unbounded
|
||||
/// recursion on a permanent client error.
|
||||
public static let maxPersistRetries = 3
|
||||
|
||||
/// Performs an asynchronous network request and decodes the JSON response.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - url: The URL string for the request.
|
||||
/// - params: Optional parameters for the request. Can be `[String: Any]` for JSON/form-data, or `Data` for raw body.
|
||||
/// - method: The HTTP method to use for the request (`.get`, `.post`, `.put`, `.delete`,`.patch` ).
|
||||
/// - headers: Optional custom HTTP headers to be added to the request. These override default headers if there are conflicts.
|
||||
/// - jsonEncoding: A boolean indicating whether parameters should be JSON encoded. Defaults to `true`.
|
||||
/// - debug: A boolean indicating whether to print debug logs for the request and response. Defaults to `true`.
|
||||
/// - timeoutInterval: The timeout interval in seconds for the request. Defaults to `30`.
|
||||
/// - networkServiceType: The `URLRequest.NetworkServiceType` for the request. Defaults to `.default`.
|
||||
/// - persistConnection: A boolean indicating whether to persist the connection on certain error codes (e.g., 4xx). Defaults to `false`.
|
||||
/// - Returns: An instance of the `T` type, decoded from the response data.
|
||||
/// - Throws: An `Error` if the request fails, including `URLError` for network issues or `DecodingError` for JSON decoding failures.
|
||||
public func request<T: Decodable & Sendable>(url: String,
|
||||
params: Any? = nil,
|
||||
method: httpMethod,
|
||||
headers: [String: String] = [:],
|
||||
jsonEncoding: Bool = true,
|
||||
debug: Bool = true,
|
||||
timeoutInterval: TimeInterval = 30,
|
||||
networkServiceType: URLRequest.NetworkServiceType = .default,
|
||||
persistConnection: Bool = false) async throws -> T {
|
||||
|
||||
if let urlReq = URL(string: url.replaceURL(params as? [String: Any] ?? [:] )) {
|
||||
var request = URLRequest(url: urlReq, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeoutInterval)
|
||||
if method == .post || method == .put || method == .delete || method == .patch {
|
||||
if let params = params as? [String: Any],
|
||||
let pathFile = params["file"] as? String,
|
||||
let fileURL = URL(string: pathFile) {
|
||||
let boundary = UUID().uuidString
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
var body = Data()
|
||||
|
||||
// Add additional fields (if any)
|
||||
for (key, value) in params where key != "file" {
|
||||
let stringValue = "\(value)"
|
||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
|
||||
body.append("\(stringValue)\r\n".data(using: .utf8)!)
|
||||
}
|
||||
|
||||
// Add the file
|
||||
let fileName = fileURL.lastPathComponent
|
||||
let mimeType = mimeTypeForPath(path: fileName)
|
||||
printInfo(title: "Body size before", msg: "\(body.count) bytes")
|
||||
|
||||
let fileData: Data
|
||||
do {
|
||||
fileData = try Data(contentsOf: fileURL)
|
||||
printInfo(title: "Body size after", msg: "\(body.count) bytes")
|
||||
} catch {
|
||||
printError(title: "Upload File", msg: error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
|
||||
do {
|
||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
|
||||
let fileDataCopy = Data(fileData)
|
||||
body.append(fileDataCopy)
|
||||
let dataUTF8 = "\r\n".data(using: .utf8)!
|
||||
body.append(dataUTF8)
|
||||
printInfo(title: "Body size after", msg: "\(body.count) bytes")
|
||||
}
|
||||
|
||||
// Finalize the request body
|
||||
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
|
||||
request.httpBody = body
|
||||
request.setValue("\(body.count)", forHTTPHeaderField: "Content-Length")
|
||||
// Debug logs
|
||||
printLog(title: "Boundary", msg: boundary)
|
||||
if let bodyString = String(data: body, encoding: .utf8) {
|
||||
printLog(title: "Body Content", msg: bodyString)
|
||||
}
|
||||
} else if jsonEncoding, let params = params as? [String: Any] {
|
||||
let requestObject = try JSONSerialization.data(withJSONObject: params)
|
||||
request.httpBody = requestObject
|
||||
} else if let params = params as? [String: Any] {
|
||||
var bodyComponents = URLComponents()
|
||||
params.forEach({ (key, value) in
|
||||
bodyComponents.queryItems?.append(URLQueryItem(name: key, value: value as? String))
|
||||
})
|
||||
request.httpBody = bodyComponents.query?.data(using: .utf8)
|
||||
} else if let params = params as? Data {
|
||||
request.httpBody = params
|
||||
}
|
||||
}
|
||||
request.httpMethod = method.rawValue
|
||||
request.timeoutInterval = timeoutInterval
|
||||
request.networkServiceType = networkServiceType
|
||||
|
||||
// - Put Default Headers together with user defined params
|
||||
if !headers.isEmpty {
|
||||
// - Add it to request
|
||||
headers.forEach { (key, value) in
|
||||
request.addValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
}else{
|
||||
defaultHeaders.forEach { (key, value) in
|
||||
request.addValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
}
|
||||
if debug {
|
||||
API.requestLOG(method: method, request: request)
|
||||
}
|
||||
|
||||
let (session, mustInvalidate) = makeSession()
|
||||
defer {
|
||||
if mustInvalidate {
|
||||
session.finishTasksAndInvalidate()
|
||||
}
|
||||
}
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
|
||||
var code: Int = LCEssentials.DEFAULT_ERROR_CODE
|
||||
let httpResponse = response as? HTTPURLResponse ?? HTTPURLResponse()
|
||||
code = httpResponse.statusCode
|
||||
let error = URLError(URLError.Code(rawValue: code))
|
||||
switch code {
|
||||
case 200..<300:
|
||||
// - Debug LOG
|
||||
if debug {
|
||||
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: nil)
|
||||
}
|
||||
|
||||
// - Check if is JSON result and try decode it
|
||||
if let string = data.string as? T, T.self == String.self {
|
||||
return string
|
||||
}
|
||||
// - Normal decoding
|
||||
do {
|
||||
return try JSONDecoder.decode(data: data)
|
||||
} catch {
|
||||
printError(title: "JSONDecoder", msg: error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
case 400..<500:
|
||||
// - Debug LOG
|
||||
if debug {
|
||||
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
|
||||
}
|
||||
if persistConnection {
|
||||
printError(title: "INTERNET CONNECTION ERROR", msg: "WILL PERSIST")
|
||||
// Recursive call for persistence
|
||||
let persist: T = try await self.request(
|
||||
url: url,
|
||||
params: params,
|
||||
method: method,
|
||||
headers: headers,
|
||||
jsonEncoding: jsonEncoding,
|
||||
debug: debug,
|
||||
timeoutInterval: timeoutInterval,
|
||||
networkServiceType: networkServiceType,
|
||||
persistConnection: persistConnection
|
||||
)
|
||||
return persist
|
||||
} else {
|
||||
if debug {
|
||||
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
|
||||
}
|
||||
let friendlyError = NSError.createErrorWith(code: code, description: error.localizedDescription, reasonForError: data.prettyJson ?? "")
|
||||
throw friendlyError
|
||||
}
|
||||
default:
|
||||
// - Debug LOG
|
||||
if debug {
|
||||
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
|
||||
}
|
||||
let friendlyError = NSError.createErrorWith(code: code, description: error.localizedDescription, reasonForError: data.prettyJson ?? "")
|
||||
throw friendlyError
|
||||
}
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
/// - url: The URL string. `{name}` placeholders are filled from `pathParams`.
|
||||
/// - method: The HTTP method (`.get`, `.post`, `.put`, `.delete`, `.patch`).
|
||||
/// - body: Optional typed request body (``JSONBody``, ``FormURLEncodedBody``,
|
||||
/// ``RawBody``, or any ``HTTPBody``). Its `Content-Type` and
|
||||
/// `Content-Length` are set automatically.
|
||||
/// - pathParams: Values substituted into `{name}` placeholders in `url`.
|
||||
/// - headers: Custom headers, merged over the defaults (custom wins).
|
||||
/// - debug: Print request/response debug logs. Defaults to `true`.
|
||||
/// - timeoutInterval: Request timeout in seconds. Defaults to `30`.
|
||||
/// - networkServiceType: `URLRequest.NetworkServiceType`. Defaults to `.default`.
|
||||
/// - persistConnection: Retry (bounded by ``maxPersistRetries``) on a 4xx.
|
||||
/// - Returns: `T` decoded from the response body, or the raw string when `T == String`.
|
||||
/// - Throws: `URLError` for transport failures, an `NSError` carrying the HTTP
|
||||
/// status for non-2xx responses, or `DecodingError` on a malformed body.
|
||||
public func request<T: Decodable & Sendable>(
|
||||
url: String,
|
||||
method: httpMethod,
|
||||
body: (any HTTPBody)? = nil,
|
||||
pathParams: [String: String] = [:],
|
||||
headers: [String: String] = [:],
|
||||
debug: Bool = true,
|
||||
timeoutInterval: TimeInterval = 30,
|
||||
networkServiceType: URLRequest.NetworkServiceType = .default,
|
||||
persistConnection: Bool = false
|
||||
) async throws -> T {
|
||||
let resolvedURL = try makeURL(url, pathParams: pathParams)
|
||||
var urlRequest = buildRequest(url: resolvedURL, method: method, headers: headers,
|
||||
timeout: timeoutInterval, serviceType: networkServiceType)
|
||||
if let body {
|
||||
try Self.attach(body: body, to: &urlRequest)
|
||||
}
|
||||
throw API.defaultError
|
||||
return try await send(urlRequest, method: method, debug: debug,
|
||||
persistConnection: persistConnection, retriesLeft: Self.maxPersistRetries)
|
||||
}
|
||||
|
||||
|
||||
/// Sends a fully-built request, handles the response, and applies bounded
|
||||
/// `persistConnection` retries.
|
||||
private func send<T: Decodable & Sendable>(
|
||||
_ urlRequest: URLRequest,
|
||||
method: httpMethod,
|
||||
debug: Bool,
|
||||
persistConnection: Bool,
|
||||
retriesLeft: Int
|
||||
) async throws -> T {
|
||||
if debug { API.requestLOG(method: method, request: urlRequest) }
|
||||
|
||||
let (session, mustInvalidate) = makeSession()
|
||||
defer { if mustInvalidate { session.finishTasksAndInvalidate() } }
|
||||
|
||||
let (data, response) = try await session.data(for: urlRequest)
|
||||
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 where persistConnection && retriesLeft > 0:
|
||||
printError(title: "INTERNET CONNECTION ERROR", msg: "WILL PERSIST (\(retriesLeft) left)")
|
||||
return try await send(urlRequest, method: method, debug: debug,
|
||||
persistConnection: persistConnection, retriesLeft: retriesLeft - 1)
|
||||
case .clientError, .otherError:
|
||||
throw Self.friendlyError(code: code, data: data)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Request building
|
||||
|
||||
private 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,
|
||||
method: httpMethod,
|
||||
headers: [String: String],
|
||||
timeout: TimeInterval,
|
||||
serviceType: URLRequest.NetworkServiceType) -> URLRequest {
|
||||
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout)
|
||||
request.httpMethod = method.rawValue
|
||||
request.timeoutInterval = timeout
|
||||
request.networkServiceType = serviceType
|
||||
defaultHeaders.merging(headers) { _, custom in custom }
|
||||
.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
|
||||
return request
|
||||
}
|
||||
|
||||
/// Sets the body plus its `Content-Type` and `Content-Length`. The body's own
|
||||
/// content type wins over any set through `headers`.
|
||||
private static func attach(body: any HTTPBody, to request: inout URLRequest) throws {
|
||||
let (data, contentType) = try body.encoded()
|
||||
request.httpBody = data
|
||||
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("\(data.count)", forHTTPHeaderField: "Content-Length")
|
||||
}
|
||||
|
||||
// MARK: - Response handling
|
||||
|
||||
private enum ResponseDisposition { case success, clientError, otherError }
|
||||
|
||||
private 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 {
|
||||
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
|
||||
}
|
||||
switch code {
|
||||
case 200..<300: return .success
|
||||
case 400..<500: return .clientError
|
||||
default: return .otherError
|
||||
}
|
||||
}
|
||||
|
||||
private 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
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder.decode(data: data)
|
||||
} catch {
|
||||
printError(title: "JSONDecoder", msg: error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private static func friendlyError(code: Int, data: Data) -> NSError {
|
||||
let urlError = URLError(URLError.Code(rawValue: code))
|
||||
return NSError.createErrorWith(code: code,
|
||||
description: urlError.localizedDescription,
|
||||
reasonForError: data.prettyJson ?? "")
|
||||
}
|
||||
|
||||
/// Sets up client certificate data and an optional password for authentication.
|
||||
///
|
||||
/// - Parameters:
|
||||
@@ -533,41 +488,5 @@ extension API {
|
||||
//
|
||||
print("<======================================================================================>")
|
||||
}
|
||||
|
||||
/// Determines the MIME type for a given file path based on its extension.
|
||||
///
|
||||
/// - Parameter path: The file path string.
|
||||
/// - Returns: A string representing the MIME type. Defaults to "application/octet-stream" if the type is unknown.
|
||||
func mimeTypeForPath(path: String) -> String {
|
||||
let url = URL(fileURLWithPath: path)
|
||||
let pathExtension = url.pathExtension.lowercased()
|
||||
|
||||
// Dictionary of common extensions and MIME types
|
||||
let mimeTypes: [String: String] = [
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"png": "image/png",
|
||||
"gif": "image/gif",
|
||||
"pdf": "application/pdf",
|
||||
"txt": "text/plain",
|
||||
"html": "text/html",
|
||||
"htm": "text/html",
|
||||
"json": "application/json",
|
||||
"xml": "application/xml",
|
||||
"zip": "application/zip",
|
||||
"mp3": "audio/mpeg",
|
||||
"mp4": "video/mp4",
|
||||
"mov": "video/quicktime",
|
||||
"doc": "application/msword",
|
||||
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"xls": "application/vnd.ms-excel",
|
||||
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"ppt": "application/vnd.ms-powerpoint",
|
||||
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
]
|
||||
|
||||
// Returns the corresponding MIME type for the extension, or "application/octet-stream" as default
|
||||
return mimeTypes[pathExtension] ?? "application/octet-stream"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user