mirror of
https://github.com/samsonjs/Osiris.git
synced 2026-03-25 08:55:48 +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.
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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