diff --git a/Sources/Osiris/HTTPRequest.swift b/Sources/Osiris/HTTPRequest.swift index 03d5e23..c3dd630 100644 --- a/Sources/Osiris/HTTPRequest.swift +++ b/Sources/Osiris/HTTPRequest.swift @@ -61,6 +61,9 @@ public struct HTTPRequest: Sendable, CustomStringConvertible { /// Parameters to be encoded according to the content type. public var parameters: [String: any Sendable]? + /// Codable body to be JSON encoded (takes precedence over parameters). + public var body: (any Codable & Sendable)? + /// Additional HTTP headers for the request. public var headers: [String: String] = [:] @@ -77,11 +80,13 @@ public struct HTTPRequest: Sendable, CustomStringConvertible { /// - url: The target URL /// - contentType: The content type for encoding parameters /// - parameters: Optional parameters to include in the request body - public init(method: HTTPMethod, url: URL, contentType: HTTPContentType = .none, parameters: [String: any Sendable]? = nil) { + /// - body: Optional Codable body to be JSON encoded + public init(method: HTTPMethod, url: URL, contentType: HTTPContentType = .none, parameters: [String: any Sendable]? = nil, body: (any Codable & Sendable)? = nil) { self.method = method self.url = url self.contentType = contentType self.parameters = parameters + self.body = body } /// Creates a GET request. @@ -121,6 +126,24 @@ public struct HTTPRequest: Sendable, CustomStringConvertible { public static func delete(_ url: URL, parameters: [String: any Sendable]? = nil) -> HTTPRequest { HTTPRequest(method: .delete, url: url, contentType: .none, parameters: parameters) } + + /// Creates a POST request with a Codable body. + /// - Parameters: + /// - url: The target URL + /// - body: The Codable body to be JSON encoded + /// - Returns: A configured HTTPRequest with JSON content type + public static func postJSON(_ url: URL, body: T) -> HTTPRequest { + HTTPRequest(method: .post, url: url, contentType: .json, body: body) + } + + /// Creates a PUT request with a Codable body. + /// - Parameters: + /// - url: The target URL + /// - body: The Codable body to be JSON encoded + /// - Returns: A configured HTTPRequest with JSON content type + public static func putJSON(_ url: URL, body: T) -> HTTPRequest { + HTTPRequest(method: .put, url: url, contentType: .json, body: body) + } #if canImport(UIKit) diff --git a/Sources/Osiris/HTTPResponse.swift b/Sources/Osiris/HTTPResponse.swift index dbe5f0c..dcdbc22 100644 --- a/Sources/Osiris/HTTPResponse.swift +++ b/Sources/Osiris/HTTPResponse.swift @@ -137,4 +137,33 @@ public enum HTTPResponse: CustomStringConvertible { return "" } } + + /// Decodes the response body as a Codable type using JSONDecoder. + /// - Parameters: + /// - type: The Codable type to decode to + /// - decoder: Optional JSONDecoder to use (defaults to a new instance) + /// - Returns: The decoded object + /// - Throws: DecodingError if decoding fails, or various other errors + public func decode(_ type: T.Type, using decoder: JSONDecoder = JSONDecoder()) throws -> T { + guard let data = self.data else { + throw DecodingError.dataCorrupted( + DecodingError.Context(codingPath: [], debugDescription: "No data found in response") + ) + } + return try decoder.decode(type, from: data) + } + + /// Attempts to decode the response body as a Codable type, returning nil on failure. + /// - Parameters: + /// - type: The Codable type to decode to + /// - decoder: Optional JSONDecoder to use (defaults to a new instance) + /// - Returns: The decoded object, or nil if decoding fails + public func tryDecode(_ type: T.Type, using decoder: JSONDecoder = JSONDecoder()) -> T? { + do { + return try decode(type, using: decoder) + } catch { + log.warning("Failed to decode response as \(String(describing: type)): \(error)") + return nil + } + } } diff --git a/Sources/Osiris/RequestBuilder.swift b/Sources/Osiris/RequestBuilder.swift index 5f4f809..3608c1a 100644 --- a/Sources/Osiris/RequestBuilder.swift +++ b/Sources/Osiris/RequestBuilder.swift @@ -77,6 +77,8 @@ public final class RequestBuilder { log.info("Encoding request as multipart, overriding its content type of \(request.contentType)") } try encodeMultipartContent(to: &result, request: request) + } else if let body = request.body { + try encodeCodableBody(to: &result, body: body) } else if let params = request.parameters { try encodeParameters(to: &result, request: request, parameters: params) } @@ -121,6 +123,12 @@ public final class RequestBuilder { urlRequest.httpBody = formData } + private class func encodeCodableBody(to urlRequest: inout URLRequest, body: any Codable & Sendable) throws { + urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") + let encoder = JSONEncoder() + urlRequest.httpBody = try encoder.encode(body) + } + private class func encodeQueryParameters(to urlRequest: inout URLRequest, parameters: [String: any Sendable]) throws { guard let url = urlRequest.url else { return diff --git a/Tests/OsirisTests/CodableTests.swift b/Tests/OsirisTests/CodableTests.swift new file mode 100644 index 0000000..9fed1b9 --- /dev/null +++ b/Tests/OsirisTests/CodableTests.swift @@ -0,0 +1,273 @@ +// +// CodableTests.swift +// OsirisTests +// +// Created by Sami Samhuri on 2025-06-15. +// + +@testable import Osiris +import XCTest + +class CodableTests: XCTestCase { + let baseURL = URL(string: "https://api.example.net")! + + // Test models + struct Person: Codable, Sendable, Equatable { + let name: String + let email: String + let age: Int? + } + + struct APIResponse: Codable, Sendable, Equatable { + let success: Bool + let data: Person? + let message: String? + } + + // MARK: - HTTPRequest Codable Body Tests + + func testHTTPRequestWithCodableBody() throws { + let person = Person(name: "Jane Doe", email: "jane@example.net", age: 30) + let request = HTTPRequest(method: .post, url: baseURL, body: person) + + XCTAssertEqual(request.method, .post) + XCTAssertEqual(request.url, baseURL) + XCTAssertNotNil(request.body) + XCTAssertNil(request.parameters) + } + + func testPostJSONConvenience() throws { + let person = Person(name: "John Doe", email: "john@example.net", age: 25) + let request = HTTPRequest.postJSON(baseURL, body: person) + + XCTAssertEqual(request.method, .post) + XCTAssertEqual(request.url, baseURL) + XCTAssertEqual(request.contentType, .json) + XCTAssertNotNil(request.body) + XCTAssertNil(request.parameters) + } + + func testPutJSONConvenience() throws { + let person = Person(name: "John Doe", email: "john@example.net", age: 26) + let request = HTTPRequest.putJSON(baseURL, body: person) + + XCTAssertEqual(request.method, .put) + XCTAssertEqual(request.url, baseURL) + XCTAssertEqual(request.contentType, .json) + XCTAssertNotNil(request.body) + XCTAssertNil(request.parameters) + } + + // MARK: - RequestBuilder Codable Encoding Tests + + func testRequestBuilderWithCodableBody() throws { + let person = Person(name: "Jane Doe", email: "jane@example.net", age: 30) + let httpRequest = HTTPRequest.postJSON(baseURL, body: person) + + let urlRequest = try RequestBuilder.build(request: httpRequest) + + XCTAssertEqual(urlRequest.url, baseURL) + XCTAssertEqual(urlRequest.httpMethod, "POST") + XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Content-Type"), "application/json") + XCTAssertNotNil(urlRequest.httpBody) + + // Verify the JSON was encoded correctly + let decodedPerson = try JSONDecoder().decode(Person.self, from: urlRequest.httpBody!) + XCTAssertEqual(decodedPerson, person) + } + + func testRequestBuilderPrefersCodableOverParameters() throws { + let person = Person(name: "Jane Doe", email: "jane@example.net", age: 30) + let params = ["name": "Different Name", "email": "different@example.net"] + + let httpRequest = HTTPRequest( + method: .post, + url: baseURL, + contentType: .json, + parameters: params, + body: person + ) + + let urlRequest = try RequestBuilder.build(request: httpRequest) + + // Should use the Codable body, not the parameters + let decodedPerson = try JSONDecoder().decode(Person.self, from: urlRequest.httpBody!) + XCTAssertEqual(decodedPerson, person) + XCTAssertEqual(decodedPerson.name, "Jane Doe") // Not "Different Name" + } + + // MARK: - HTTPResponse Decoding Tests + + func testHTTPResponseDecodeSuccess() throws { + let person = Person(name: "Jane Doe", email: "jane@example.net", age: 30) + let jsonData = try JSONEncoder().encode(person) + + let httpURLResponse = HTTPURLResponse( + url: baseURL, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + + let response = HTTPResponse.success(httpURLResponse, jsonData) + + let decodedPerson = try response.decode(Person.self) + XCTAssertEqual(decodedPerson, person) + } + + func testHTTPResponseDecodeFailureCase() throws { + let person = Person(name: "Jane Doe", email: "jane@example.net", age: 30) + let jsonData = try JSONEncoder().encode(person) + + let httpURLResponse = HTTPURLResponse( + url: baseURL, + statusCode: 400, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + + let response = HTTPResponse.failure(HTTPRequestError.http, httpURLResponse, jsonData) + + // Should still be able to decode data from failure responses + let decodedPerson = try response.decode(Person.self) + XCTAssertEqual(decodedPerson, person) + } + + func testHTTPResponseDecodeNoData() throws { + let httpURLResponse = HTTPURLResponse( + url: baseURL, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + + let response = HTTPResponse.success(httpURLResponse, nil) + + XCTAssertThrowsError(try response.decode(Person.self)) { error in + XCTAssertTrue(error is DecodingError) + if case DecodingError.dataCorrupted(let context) = error { + XCTAssertEqual(context.debugDescription, "No data found in response") + } else { + XCTFail("Expected DecodingError.dataCorrupted") + } + } + } + + func testHTTPResponseDecodeInvalidJSON() throws { + let invalidJSON = "{ invalid json }".data(using: .utf8)! + + let httpURLResponse = HTTPURLResponse( + url: baseURL, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + + let response = HTTPResponse.success(httpURLResponse, invalidJSON) + + XCTAssertThrowsError(try response.decode(Person.self)) { error in + XCTAssertTrue(error is DecodingError) + } + } + + func testHTTPResponseTryDecodeSuccess() throws { + let person = Person(name: "Jane Doe", email: "jane@example.net", age: 30) + let jsonData = try JSONEncoder().encode(person) + + let httpURLResponse = HTTPURLResponse( + url: baseURL, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + + let response = HTTPResponse.success(httpURLResponse, jsonData) + + let decodedPerson = response.tryDecode(Person.self) + XCTAssertNotNil(decodedPerson) + XCTAssertEqual(decodedPerson, person) + } + + func testHTTPResponseTryDecodeFailure() throws { + let invalidJSON = "{ invalid json }".data(using: .utf8)! + + let httpURLResponse = HTTPURLResponse( + url: baseURL, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + + let response = HTTPResponse.success(httpURLResponse, invalidJSON) + + let decodedPerson = response.tryDecode(Person.self) + XCTAssertNil(decodedPerson) + } + + func testHTTPResponseTryDecodeNoData() throws { + let httpURLResponse = HTTPURLResponse( + url: baseURL, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + + let response = HTTPResponse.success(httpURLResponse, nil) + + let decodedPerson = response.tryDecode(Person.self) + XCTAssertNil(decodedPerson) + } + + // MARK: - Custom JSONDecoder Tests + + func testHTTPResponseDecodeWithCustomDecoder() throws { + // Create a Person with an ISO8601 date as a string (for testing custom decoder) + let apiResponse = APIResponse( + success: true, + data: Person(name: "Jane Doe", email: "jane@example.net", age: 30), + message: "Success" + ) + + let jsonData = try JSONEncoder().encode(apiResponse) + + let httpURLResponse = HTTPURLResponse( + url: baseURL, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + + let response = HTTPResponse.success(httpURLResponse, jsonData) + + // Test with custom decoder (just using default for this test) + let customDecoder = JSONDecoder() + let decodedResponse = try response.decode(APIResponse.self, using: customDecoder) + XCTAssertEqual(decodedResponse, apiResponse) + } + + // MARK: - Integration Tests + + func testEndToEndCodableFlow() throws { + // Create request with Codable body + let person = Person(name: "Jane Doe", email: "jane@example.net", age: 30) + let httpRequest = HTTPRequest.postJSON(baseURL, body: person) + + // Build URLRequest + let urlRequest = try RequestBuilder.build(request: httpRequest) + + // Simulate successful response with the same data + let responseData = urlRequest.httpBody! + let httpURLResponse = HTTPURLResponse( + url: baseURL, + statusCode: 201, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + + let httpResponse = HTTPResponse.success(httpURLResponse, responseData) + + // Decode response + let decodedPerson = try httpResponse.decode(Person.self) + XCTAssertEqual(decodedPerson, person) + } +} \ No newline at end of file