WIP: codable

This commit is contained in:
Sami Samhuri 2025-06-15 08:45:02 -07:00
parent bcf402db8f
commit a077d7fdc6
No known key found for this signature in database
4 changed files with 334 additions and 1 deletions

View file

@ -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<T: Codable & Sendable>(_ 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<T: Codable & Sendable>(_ url: URL, body: T) -> HTTPRequest {
HTTPRequest(method: .put, url: url, contentType: .json, body: body)
}
#if canImport(UIKit)

View file

@ -137,4 +137,33 @@ public enum HTTPResponse: CustomStringConvertible {
return "<HTTPResponse.failure error=\(error) status=\(status) size=\(dataSize)>"
}
}
/// 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<T: Codable>(_ 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<T: Codable>(_ 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
}
}
}

View file

@ -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

View file

@ -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)
}
}