[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:
Daniel Arantes Loverde
2026-08-29 17:12:52 -03:00
parent 9581e49ce0
commit bcd358cb09
2 changed files with 256 additions and 215 deletions

View File

@@ -101,187 +101,142 @@ 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,
/// - 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] = [:],
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)!)
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)
}
return try await send(urlRequest, method: method, debug: debug,
persistConnection: persistConnection, retriesLeft: Self.maxPersistRetries)
}
// 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)
}
/// 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()
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)
}
}
do {
let (data, response) = try await session.data(for: request)
// MARK: - Request building
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
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: nil)
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
}
}
// - Check if is JSON result and try decode it
if let string = data.string as? T, T.self == String.self {
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
}
// - 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
}
}
throw API.defaultError
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.
@@ -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

View File

@@ -0,0 +1,122 @@
import XCTest
@testable import LCEssentials
private struct Echo: Decodable, Sendable, Equatable {
let id: Int
let name: String
}
private struct CreateDTO: Encodable, Sendable {
let name: String
}
final class APIRequestTests: 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()
}
func testJSONBodyRequestSendsEncodedBodyAndDecodesResponse() async throws {
var stub = StubURLProtocol.Stub()
stub.statusCode = 200
stub.body = Data(#"{"id":10,"name":"x"}"#.utf8)
StubURLProtocol.setStub(stub)
let result: Echo = try await api.request(
url: "https://api.example.com/users",
method: .post,
body: jsonBody(CreateDTO(name: "x"))
)
XCTAssertEqual(result, Echo(id: 10, name: "x"))
let sent = StubURLProtocol.capturedRequests.first
XCTAssertEqual(sent?.httpMethod, "POST")
XCTAssertEqual(sent?.value(forHTTPHeaderField: "Content-Type"), "application/json; charset=UTF-8")
XCTAssertEqual(StubURLProtocol.lastCapturedBody, Data(#"{"name":"x"}"#.utf8))
}
func testCustomHeaderOverridesDefault() async throws {
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"id":1,"name":"a"}"#.utf8)))
let _: Echo = try await api.request(
url: "https://api.example.com/x",
method: .get,
headers: ["Accept": "application/xml"]
)
XCTAssertEqual(StubURLProtocol.capturedRequests.first?.value(forHTTPHeaderField: "Accept"),
"application/xml")
}
func testPathParamsSubstitution() async throws {
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"id":42,"name":"a"}"#.utf8)))
let _: Echo = try await api.request(
url: "https://api.example.com/users/{id}/posts",
method: .get,
pathParams: ["id": "42"]
)
XCTAssertEqual(StubURLProtocol.capturedRequests.first?.url?.absoluteString,
"https://api.example.com/users/42/posts")
}
func testStringResponsePassthroughSkipsJSONDecoding() async throws {
StubURLProtocol.setStub(.init(statusCode: 200,
headers: ["Content-Type": "text/plain"],
body: Data("plain hello".utf8)))
let text: String = try await api.request(url: "https://api.example.com/ping", method: .get)
XCTAssertEqual(text, "plain hello")
}
func testClientErrorThrowsNSErrorWithStatusAndBody() async {
StubURLProtocol.setStub(.init(statusCode: 422,
body: Data(#"{"error":"invalid"}"#.utf8)))
do {
let _: Echo = try await api.request(url: "https://api.example.com/x", method: .post,
body: jsonBody(CreateDTO(name: "")))
XCTFail("expected throw")
} catch let error as NSError {
XCTAssertEqual(error.code, 422)
XCTAssertTrue(error.localizedFailureReason?.contains("invalid") ?? false,
"reason: \(error.localizedFailureReason ?? "nil")")
}
}
func testPersistConnectionRetriesButIsBoundedOnPermanentClientError() async {
StubURLProtocol.setStub(.init(statusCode: 400, body: Data(#"{"error":"nope"}"#.utf8)))
do {
let _: Echo = try await api.request(url: "https://api.example.com/x",
method: .get, persistConnection: true)
XCTFail("expected throw after retries exhausted")
} catch let error as NSError {
XCTAssertEqual(error.code, 400)
} catch {
XCTFail("unexpected error type: \(error)")
}
// retried, but did NOT loop forever
XCTAssertGreaterThanOrEqual(StubURLProtocol.requestCount, 2)
XCTAssertLessThanOrEqual(StubURLProtocol.requestCount, API.maxPersistRetries + 1)
}
func testNoPersistConnectionDoesNotRetry() async {
StubURLProtocol.setStub(.init(statusCode: 400, body: Data(#"{"error":"nope"}"#.utf8)))
let _: Echo? = try? await api.request(url: "https://api.example.com/x", method: .get)
XCTAssertEqual(StubURLProtocol.requestCount, 1)
}
}