diff --git a/Sources/LCEssentials/Classes/LCEssentials+API.swift b/Sources/LCEssentials/Classes/LCEssentials+API.swift index 26312cc..b7e9ab6 100644 --- a/Sources/LCEssentials/Classes/LCEssentials+API.swift +++ b/Sources/LCEssentials/Classes/LCEssentials+API.swift @@ -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(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( + 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( + _ 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(_ 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 diff --git a/Tests/LCEssentialsTests/APIRequestTests.swift b/Tests/LCEssentialsTests/APIRequestTests.swift new file mode 100644 index 0000000..c3236e2 --- /dev/null +++ b/Tests/LCEssentialsTests/APIRequestTests.swift @@ -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) + } +}