mirror of
https://github.com/samsonjs/Osiris.git
synced 2026-04-27 14:57:38 +00:00
WIP: codable
This commit is contained in:
parent
bcf402db8f
commit
a077d7fdc6
4 changed files with 334 additions and 1 deletions
|
|
@ -61,6 +61,9 @@ public struct HTTPRequest: Sendable, CustomStringConvertible {
|
||||||
/// Parameters to be encoded according to the content type.
|
/// Parameters to be encoded according to the content type.
|
||||||
public var parameters: [String: any Sendable]?
|
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.
|
/// Additional HTTP headers for the request.
|
||||||
public var headers: [String: String] = [:]
|
public var headers: [String: String] = [:]
|
||||||
|
|
||||||
|
|
@ -77,11 +80,13 @@ public struct HTTPRequest: Sendable, CustomStringConvertible {
|
||||||
/// - url: The target URL
|
/// - url: The target URL
|
||||||
/// - contentType: The content type for encoding parameters
|
/// - contentType: The content type for encoding parameters
|
||||||
/// - parameters: Optional parameters to include in the request body
|
/// - 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.method = method
|
||||||
self.url = url
|
self.url = url
|
||||||
self.contentType = contentType
|
self.contentType = contentType
|
||||||
self.parameters = parameters
|
self.parameters = parameters
|
||||||
|
self.body = body
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a GET request.
|
/// 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 {
|
public static func delete(_ url: URL, parameters: [String: any Sendable]? = nil) -> HTTPRequest {
|
||||||
HTTPRequest(method: .delete, url: url, contentType: .none, parameters: parameters)
|
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)
|
#if canImport(UIKit)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -137,4 +137,33 @@ public enum HTTPResponse: CustomStringConvertible {
|
||||||
return "<HTTPResponse.failure error=\(error) status=\(status) size=\(dataSize)>"
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,8 @@ public final class RequestBuilder {
|
||||||
log.info("Encoding request as multipart, overriding its content type of \(request.contentType)")
|
log.info("Encoding request as multipart, overriding its content type of \(request.contentType)")
|
||||||
}
|
}
|
||||||
try encodeMultipartContent(to: &result, request: request)
|
try encodeMultipartContent(to: &result, request: request)
|
||||||
|
} else if let body = request.body {
|
||||||
|
try encodeCodableBody(to: &result, body: body)
|
||||||
} else if let params = request.parameters {
|
} else if let params = request.parameters {
|
||||||
try encodeParameters(to: &result, request: request, parameters: params)
|
try encodeParameters(to: &result, request: request, parameters: params)
|
||||||
}
|
}
|
||||||
|
|
@ -121,6 +123,12 @@ public final class RequestBuilder {
|
||||||
urlRequest.httpBody = formData
|
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 {
|
private class func encodeQueryParameters(to urlRequest: inout URLRequest, parameters: [String: any Sendable]) throws {
|
||||||
guard let url = urlRequest.url else {
|
guard let url = urlRequest.url else {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
273
Tests/OsirisTests/CodableTests.swift
Normal file
273
Tests/OsirisTests/CodableTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue