Fix stray question marks

This commit is contained in:
Sami Samhuri 2025-06-15 16:59:44 -07:00
parent bcf402db8f
commit bc3ce2c93e
No known key found for this signature in database
13 changed files with 258 additions and 245 deletions

View file

@ -5,7 +5,7 @@
import Foundation
extension NSNumber {
/// [From Argo](https://github.com/thoughtbot/Argo/blob/3da833411e2633bc01ce89542ac16803a163e0f0/Argo/Extensions/NSNumber.swift)
///
/// - Returns: `true` if this instance represent a `CFBoolean` under the hood, as opposed to say a double or integer.
@ -30,12 +30,12 @@ extension NSNumber {
/// "active": true,
/// "preferences": ["color": "blue", "theme": "dark"]
/// ]
///
///
/// let encoded = FormEncoder.encode(parameters)
/// // Result: "active=1&age=30&email=jane%40example.net&name=Jane%20Doe&preferences%5Bcolor%5D=blue&preferences%5Btheme%5D=dark"
/// ```
public final class FormEncoder: CustomStringConvertible {
/// Encodes a dictionary of parameters into a URL-encoded form string.
///
/// The encoding follows these rules:
@ -119,7 +119,7 @@ public final class FormEncoder: CustomStringConvertible {
let escaped = string.addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) ?? string
return escaped
}
public var description: String {
"FormEncoder"
}

View file

@ -8,19 +8,19 @@ import Foundation
/// Content types that can be automatically handled by HTTPRequest.
public enum HTTPContentType: Sendable, CustomStringConvertible {
/// application/x-www-form-urlencoded
case formEncoded
/// No specific content type
case none
/// application/json
case json
/// multipart/form-data (set automatically when parts are added)
case multipart
public var description: String {
switch self {
case .formEncoded:

View file

@ -18,7 +18,7 @@ public enum HTTPMethod: String, Sendable, CustomStringConvertible {
var string: String {
rawValue.uppercased()
}
public var description: String {
string
}

View file

@ -51,19 +51,19 @@ public struct HTTPRequest: Sendable, CustomStringConvertible {
/// The HTTP method for this request.
public var method: HTTPMethod
/// The target URL for this request.
public var url: URL
/// The content type for the request body.
public var contentType: HTTPContentType
/// Parameters to be encoded according to the content type.
public var parameters: [String: any Sendable]?
/// Additional HTTP headers for the request.
public var headers: [String: String] = [:]
/// Multipart form parts (automatically sets contentType to .multipart when non-empty).
public var parts: [MultipartFormEncoder.Part] = [] {
didSet {
@ -123,7 +123,7 @@ public struct HTTPRequest: Sendable, CustomStringConvertible {
}
#if canImport(UIKit)
/// Adds a JPEG image to the multipart form (iOS/tvOS only).
/// - Parameters:
/// - name: The form field name
@ -148,7 +148,7 @@ public struct HTTPRequest: Sendable, CustomStringConvertible {
public mutating func addHeader(name: String, value: String) {
headers[name] = value
}
public var description: String {
"<HTTPRequest \(method) \(url)>"
}

View file

@ -8,13 +8,13 @@ import Foundation
/// Specific errors for HTTP request processing.
public enum HTTPRequestError: Error, LocalizedError, CustomStringConvertible {
/// An HTTP error occurred (non-2xx status code).
case http
/// An unknown error occurred (typically when URLResponse isn't HTTPURLResponse).
case unknown
public var errorDescription: String? {
switch self {
case .http:
@ -23,7 +23,7 @@ public enum HTTPRequestError: Error, LocalizedError, CustomStringConvertible {
return "An unknown error occurred"
}
}
public var failureReason: String? {
switch self {
case .http:
@ -32,7 +32,7 @@ public enum HTTPRequestError: Error, LocalizedError, CustomStringConvertible {
return "An unexpected error occurred during the request"
}
}
public var recoverySuggestion: String? {
switch self {
case .http:
@ -41,7 +41,7 @@ public enum HTTPRequestError: Error, LocalizedError, CustomStringConvertible {
return "Check network connectivity and try again"
}
}
public var description: String {
switch self {
case .http:

View file

@ -20,7 +20,7 @@ private let log = Logger(subsystem: "co.1se.Osiris", category: "HTTPResponse")
/// ```swift
/// let task = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
/// let httpResponse = HTTPResponse(response: response, data: data, error: error)
///
///
/// switch httpResponse {
/// case .success(let httpURLResponse, let data):
/// print("Success: \(httpURLResponse.statusCode)")
@ -32,10 +32,10 @@ private let log = Logger(subsystem: "co.1se.Osiris", category: "HTTPResponse")
/// }
/// ```
public enum HTTPResponse: CustomStringConvertible {
/// A successful response (2xx status code) with the HTTP response and optional body data.
case success(HTTPURLResponse, Data?)
/// A failed response with the error, optional HTTP response, and optional body data.
case failure(Error, HTTPURLResponse?, Data?)
@ -125,7 +125,7 @@ public enum HTTPResponse: CustomStringConvertible {
return [:]
}
}
public var description: String {
switch self {
case let .success(response, data):

View file

@ -24,13 +24,13 @@
import Foundation
extension MultipartFormEncoder {
/// Contains the encoded multipart form data for in-memory storage.
public struct BodyData: CustomStringConvertible {
/// The content type header value including boundary.
public let contentType: String
/// The encoded form data.
public let data: Data
@ -38,7 +38,7 @@ extension MultipartFormEncoder {
public var contentLength: Int {
data.count
}
public var description: String {
"<BodyData size=\(contentLength)>"
}
@ -46,16 +46,16 @@ extension MultipartFormEncoder {
/// Contains the encoded multipart form data written to a file for streaming.
public struct BodyFile: CustomStringConvertible {
/// The content type header value including boundary.
public let contentType: String
/// The URL of the temporary file containing the encoded data.
public let url: URL
/// The length of the encoded data in bytes.
public let contentLength: Int64
public var description: String {
"<BodyFile file=\(url.lastPathComponent) size=\(contentLength)>"
}
@ -66,16 +66,16 @@ extension MultipartFormEncoder {
/// The content types supported in multipart forms.
public enum Content: Equatable, Sendable, CustomStringConvertible {
/// Plain text content.
case text(String)
/// Binary data with MIME type and filename.
case binaryData(Data, type: String, filename: String)
/// Binary data from a file with size, MIME type and filename.
case binaryFile(URL, size: Int64, type: String, filename: String)
public var description: String {
switch self {
case let .text(value):
@ -91,7 +91,7 @@ extension MultipartFormEncoder {
/// The form field name for this part.
public let name: String
/// The content of this part.
public let content: Content
@ -130,7 +130,7 @@ extension MultipartFormEncoder {
}
return Part(name: name, content: .binaryFile(url, size: size, type: type, filename: filename ?? url.lastPathComponent))
}
public var description: String {
"<Part name=\(name) content=\(content)>"
}
@ -151,30 +151,30 @@ extension MultipartFormEncoder {
/// .text("jane@example.net", name: "email"),
/// .data(imageData, name: "avatar", type: "image/jpeg", filename: "avatar.jpg")
/// ]
///
///
/// // Encode to memory (< 50MB)
/// let bodyData = try encoder.encodeData(parts: parts)
///
///
/// // Or encode to file for streaming
/// let bodyFile = try encoder.encodeFile(parts: parts)
/// ```
public final class MultipartFormEncoder: CustomStringConvertible {
/// Errors that can occur during multipart encoding.
public enum Error: Swift.Error, CustomStringConvertible {
/// The specified file cannot be read or is invalid.
case invalidFile(URL)
/// The output file cannot be created or written to.
case invalidOutputFile(URL)
/// An error occurred while reading from or writing to a stream.
case streamError
/// The total data size exceeds the 50MB limit for in-memory encoding.
case tooMuchDataForMemory
public var description: String {
switch self {
case let .invalidFile(url):
@ -359,7 +359,7 @@ public final class MultipartFormEncoder: CustomStringConvertible {
}
}
}
public var description: String {
"<MultipartFormEncoder boundary=\(boundary)>"
}

View file

@ -11,7 +11,7 @@ private let log = Logger(subsystem: "co.1se.Osiris", category: "RequestBuilder")
/// Errors that can occur when building URLRequest from HTTPRequest.
public enum RequestBuilderError: Error {
/// The form data could not be encoded properly.
case invalidFormData(HTTPRequest)
}
@ -31,7 +31,7 @@ public enum RequestBuilderError: Error {
/// contentType: .json,
/// parameters: ["name": "Jane", "email": "jane@example.net"]
/// )
///
///
/// let urlRequest = try RequestBuilder.build(request: httpRequest)
/// let task = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
/// let httpResponse = HTTPResponse(response: response, data: data, error: error)
@ -39,7 +39,7 @@ public enum RequestBuilderError: Error {
/// }
/// ```
public final class RequestBuilder {
/// Converts an HTTPRequest to a URLRequest ready for use with URLSession.
///
/// This method handles encoding of parameters according to the request's method and content type:
@ -60,7 +60,7 @@ public final class RequestBuilder {
public class func build(request: HTTPRequest) throws -> URLRequest {
var result = URLRequest(url: request.url)
result.httpMethod = request.method.string
for (name, value) in request.headers {
result.addValue(value, forHTTPHeaderField: name)
}
@ -83,7 +83,7 @@ public final class RequestBuilder {
return result
}
private class func encodeMultipartContent(to urlRequest: inout URLRequest, request: HTTPRequest) throws {
let encoder = MultipartFormEncoder()
let body = try encoder.encodeData(parts: request.parts)
@ -91,28 +91,28 @@ public final class RequestBuilder {
urlRequest.addValue("\(body.contentLength)", forHTTPHeaderField: "Content-Length")
urlRequest.httpBody = body.data
}
private class func encodeParameters(to urlRequest: inout URLRequest, request: HTTPRequest, parameters: [String: any Sendable]) throws {
switch request.contentType {
case .json:
try encodeJSONParameters(to: &urlRequest, parameters: parameters)
case .none:
log.warning("Cannot serialize parameters without a content type, falling back to form encoding")
fallthrough
case .formEncoded:
try encodeFormParameters(to: &urlRequest, request: request, parameters: parameters)
case .multipart:
try encodeMultipartContent(to: &urlRequest, request: request)
}
}
private class func encodeJSONParameters(to urlRequest: inout URLRequest, parameters: [String: any Sendable]) throws {
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: parameters, options: [])
}
private class func encodeFormParameters(to urlRequest: inout URLRequest, request: HTTPRequest, parameters: [String: any Sendable]) throws {
urlRequest.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
guard let formData = FormEncoder.encode(parameters).data(using: .utf8) else {
@ -120,23 +120,23 @@ public final class RequestBuilder {
}
urlRequest.httpBody = formData
}
private class func encodeQueryParameters(to urlRequest: inout URLRequest, parameters: [String: any Sendable]) throws {
guard let url = urlRequest.url else {
return
}
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let newQueryItems = parameters.compactMap { (key, value) -> URLQueryItem? in
URLQueryItem(name: key, value: String(describing: value))
}
if let existingQueryItems = components?.queryItems {
components?.queryItems = existingQueryItems + newQueryItems
} else {
} else if !newQueryItems.isEmpty {
components?.queryItems = newQueryItems
}
urlRequest.url = components?.url ?? url
}
}

View file

@ -13,44 +13,44 @@ class FormEncoderTests: XCTestCase {
let result = FormEncoder.encode([:])
XCTAssertEqual(result, "")
}
func testEncodeSingleStringValue() {
let parameters = ["name": "Jane Doe"]
let result = FormEncoder.encode(parameters)
XCTAssertEqual(result, "name=Jane%20Doe")
}
func testEncodeMultipleStringValues() {
let parameters = ["name": "John", "email": "john@example.net"]
let result = FormEncoder.encode(parameters)
// Keys should be sorted alphabetically
XCTAssertEqual(result, "email=john%40example.net&name=John")
}
func testEncodeIntegerValue() {
let parameters = ["age": 30]
let result = FormEncoder.encode(parameters)
XCTAssertEqual(result, "age=30")
}
func testEncodeBooleanValues() {
let parameters = ["active": true, "verified": false]
let result = FormEncoder.encode(parameters)
XCTAssertEqual(result, "active=1&verified=0")
}
func testEncodeNSNumberBooleanValues() {
let parameters = ["active": NSNumber(value: true), "verified": NSNumber(value: false)]
let result = FormEncoder.encode(parameters)
XCTAssertEqual(result, "active=1&verified=0")
}
func testEncodeNSNumberIntegerValues() {
let parameters = ["count": NSNumber(value: 42)]
let result = FormEncoder.encode(parameters)
XCTAssertEqual(result, "count=42")
}
func testEncodeNestedDictionary() {
let personData: [String: any Sendable] = ["name": "Jane", "age": 30]
let parameters: [String: any Sendable] = ["person": personData]
@ -60,13 +60,13 @@ class FormEncoderTests: XCTestCase {
let expected2 = "person%5Bname%5D=Jane&person%5Bage%5D=30"
XCTAssertTrue(result == expected1 || result == expected2, "Result '\(result)' doesn't match either expected format")
}
func testEncodeArray() {
let parameters = ["tags": ["swift", "ios", "mobile"]]
let result = FormEncoder.encode(parameters)
XCTAssertEqual(result, "tags%5B%5D=swift&tags%5B%5D=ios&tags%5B%5D=mobile")
}
func testEncodeComplexNestedStructure() {
let preferences: [String: any Sendable] = ["theme": "dark", "notifications": true]
let tags: [any Sendable] = ["rockstar", "swiftie"]
@ -76,7 +76,7 @@ class FormEncoderTests: XCTestCase {
"tags": tags
]
let parameters: [String: any Sendable] = ["person": personData]
let result = FormEncoder.encode(parameters)
// The actual order depends on how the dictionary is sorted, so let's test the components
XCTAssertTrue(result.contains("person%5Bname%5D=Jane"))
@ -85,38 +85,38 @@ class FormEncoderTests: XCTestCase {
XCTAssertTrue(result.contains("person%5Btags%5D%5B%5D=rockstar"))
XCTAssertTrue(result.contains("person%5Btags%5D%5B%5D=swiftie"))
}
func testEncodeSpecialCharacters() {
let parameters = ["message": "Hello & welcome to Abbey Road Studios! 100% music magic guaranteed."]
let result = FormEncoder.encode(parameters)
XCTAssertEqual(result, "message=Hello%20%26%20welcome%20to%20Abbey%20Road%20Studios%21%20100%25%20music%20magic%20guaranteed.")
}
func testEncodeUnicodeCharacters() {
let parameters = ["emoji": "🚀👨‍💻", "chinese": "你好"]
let result = FormEncoder.encode(parameters)
XCTAssertEqual(result, "chinese=%E4%BD%A0%E5%A5%BD&emoji=%F0%9F%9A%80%F0%9F%91%A8%E2%80%8D%F0%9F%92%BB")
}
func testKeysAreSortedAlphabetically() {
let parameters = ["zebra": "z", "alpha": "a", "beta": "b"]
let result = FormEncoder.encode(parameters)
XCTAssertEqual(result, "alpha=a&beta=b&zebra=z")
}
func testEncodeDoubleValue() {
let parameters = ["price": 19.99]
let result = FormEncoder.encode(parameters)
XCTAssertEqual(result, "price=19.99")
}
func testEncodeNilValuesAsStrings() {
// Swift's Any type handling - nil values become "<null>" strings
let parameters = ["optional": NSNull()]
let result = FormEncoder.encode(parameters)
XCTAssertEqual(result, "optional=%3Cnull%3E")
}
func testRFC3986Compliance() {
// Test that reserved characters are properly encoded according to RFC 3986
let parameters = ["reserved": "!*'();:@&=+$,/?#[]"]
@ -124,14 +124,14 @@ class FormEncoderTests: XCTestCase {
// According to the implementation, ? and / are NOT encoded per RFC 3986 Section 3.4
XCTAssertEqual(result, "reserved=%21%2A%27%28%29%3B%3A%40%26%3D%2B%24%2C/?%23%5B%5D")
}
func testURLQueryAllowedCharacters() {
// Test characters that should NOT be encoded
let parameters = ["allowed": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"]
let result = FormEncoder.encode(parameters)
XCTAssertEqual(result, "allowed=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
}
func testMixedDataTypes() {
let array: [any Sendable] = [1, 2, 3]
let nested: [String: any Sendable] = ["key": "nested_value"]
@ -143,7 +143,7 @@ class FormEncoderTests: XCTestCase {
"array": array,
"nested": nested
]
let result = FormEncoder.encode(parameters)
let expected = "array%5B%5D=1&array%5B%5D=2&array%5B%5D=3&boolean=1&double=3.14&integer=42&nested%5Bkey%5D=nested_value&string=value"
XCTAssertEqual(result, expected)
@ -152,29 +152,29 @@ class FormEncoderTests: XCTestCase {
// Test the NSNumber extension
class NSNumberBoolExtensionTests: XCTestCase {
func testNSNumberIsBoolForBooleans() {
let trueNumber = NSNumber(value: true)
let falseNumber = NSNumber(value: false)
XCTAssertTrue(trueNumber.isBool)
XCTAssertTrue(falseNumber.isBool)
}
func testNSNumberIsBoolForIntegers() {
let intNumber = NSNumber(value: 42)
let zeroNumber = NSNumber(value: 0)
let oneNumber = NSNumber(value: 1)
XCTAssertFalse(intNumber.isBool)
XCTAssertFalse(zeroNumber.isBool)
XCTAssertFalse(oneNumber.isBool)
}
func testNSNumberIsBoolForDoubles() {
let doubleNumber = NSNumber(value: 3.14)
let zeroDouble = NSNumber(value: 0.0)
XCTAssertFalse(doubleNumber.isBool)
XCTAssertFalse(zeroDouble.isBool)
}

View file

@ -9,51 +9,51 @@
import XCTest
class HTTPRequestErrorTests: XCTestCase {
func testHTTPError() {
let error = HTTPRequestError.http
XCTAssertEqual(error.localizedDescription, "HTTP request failed with non-2xx status code")
XCTAssertEqual(error.failureReason, "The server returned an error status code")
XCTAssertEqual(error.recoverySuggestion, "Check the server response for error details")
}
func testUnknownError() {
let error = HTTPRequestError.unknown
XCTAssertEqual(error.localizedDescription, "An unknown error occurred")
XCTAssertEqual(error.failureReason, "An unexpected error occurred during the request")
XCTAssertEqual(error.recoverySuggestion, "Check network connectivity and try again")
}
func testErrorDescriptionIsNeverNil() {
let allErrors: [HTTPRequestError] = [
.http,
.unknown
]
for error in allErrors {
XCTAssertNotNil(error.errorDescription)
XCTAssertFalse(error.errorDescription!.isEmpty)
}
}
func testFailureReasonIsNeverNil() {
let allErrors: [HTTPRequestError] = [
.http,
.unknown
]
for error in allErrors {
XCTAssertNotNil(error.failureReason)
XCTAssertFalse(error.failureReason!.isEmpty)
}
}
func testRecoverySuggestionIsNeverNil() {
let allErrors: [HTTPRequestError] = [
.http,
.unknown
]
for error in allErrors {
XCTAssertNotNil(error.recoverySuggestion)
XCTAssertFalse(error.recoverySuggestion!.isEmpty)

View file

@ -10,7 +10,7 @@ import XCTest
class HTTPRequestTests: XCTestCase {
let baseURL = URL(string: "https://api.example.net")!
func testHTTPRequestInitialization() {
let request = HTTPRequest(method: .get, url: baseURL)
XCTAssertEqual(request.method, .get)
@ -20,74 +20,74 @@ class HTTPRequestTests: XCTestCase {
XCTAssertTrue(request.headers.isEmpty)
XCTAssertTrue(request.parts.isEmpty)
}
func testHTTPRequestWithParameters() {
let params = ["key": "value", "number": 42] as [String: any Sendable]
let request = HTTPRequest(method: .post, url: baseURL, contentType: .json, parameters: params)
XCTAssertEqual(request.method, .post)
XCTAssertEqual(request.contentType, .json)
XCTAssertNotNil(request.parameters)
}
func testGETConvenience() {
let request = HTTPRequest.get(baseURL)
XCTAssertEqual(request.method, .get)
XCTAssertEqual(request.url, baseURL)
XCTAssertEqual(request.contentType, .none)
}
func testPOSTConvenience() {
let params = ["name": "Jane"]
let request = HTTPRequest.post(baseURL, contentType: .json, parameters: params)
XCTAssertEqual(request.method, .post)
XCTAssertEqual(request.contentType, .json)
XCTAssertNotNil(request.parameters)
}
func testPUTConvenience() {
let params = ["name": "Jane"]
let request = HTTPRequest.put(baseURL, contentType: .formEncoded, parameters: params)
XCTAssertEqual(request.method, .put)
XCTAssertEqual(request.contentType, .formEncoded)
XCTAssertNotNil(request.parameters)
}
func testDELETEConvenience() {
let request = HTTPRequest.delete(baseURL)
XCTAssertEqual(request.method, .delete)
XCTAssertEqual(request.url, baseURL)
XCTAssertEqual(request.contentType, .none)
}
func testMultipartPartsAutomaticallySetContentType() {
var request = HTTPRequest.post(baseURL)
XCTAssertEqual(request.contentType, .none)
request.parts = [.text("value", name: "field")]
XCTAssertEqual(request.contentType, .multipart)
}
#if canImport(UIKit)
func testAddMultipartJPEG() {
var request = HTTPRequest.post(baseURL)
// Create a simple 1x1 pixel image
let size = CGSize(width: 1, height: 1)
UIGraphicsBeginImageContext(size)
let image = UIGraphicsGetImageFromCurrentImageContext()!
UIGraphicsEndImageContext()
request.addMultipartJPEG(name: "avatar", image: image, quality: 0.8, filename: "test.jpg")
XCTAssertEqual(request.parts.count, 1)
XCTAssertEqual(request.contentType, .multipart)
let part = request.parts.first!
XCTAssertEqual(part.name, "avatar")
if case let .binaryData(_, type, filename) = part.content {
XCTAssertEqual(type, "image/jpeg")
XCTAssertEqual(filename, "test.jpg")
@ -95,71 +95,71 @@ class HTTPRequestTests: XCTestCase {
XCTFail("Expected binary data content")
}
}
func testAddMultipartJPEGWithInvalidQuality() {
var request = HTTPRequest.post(baseURL)
// Create a valid image
let size = CGSize(width: 1, height: 1)
UIGraphicsBeginImageContext(size)
let image = UIGraphicsGetImageFromCurrentImageContext()!
UIGraphicsEndImageContext()
// Test with extreme quality values that might cause issues
request.addMultipartJPEG(name: "avatar1", image: image, quality: -1.0)
request.addMultipartJPEG(name: "avatar2", image: image, quality: 2.0)
// The method should handle extreme quality values gracefully
// Either by clamping them or by having jpegData handle them
XCTAssertTrue(request.parts.count >= 0) // Should not crash
}
#endif
func testHTTPRequestPATCHConvenience() {
let params = ["status": "active"]
let request = HTTPRequest(method: .patch, url: baseURL, contentType: .json, parameters: params)
XCTAssertEqual(request.method, .patch)
XCTAssertEqual(request.contentType, .json)
XCTAssertNotNil(request.parameters)
}
func testHTTPRequestWithMultipleHeaders() {
var request = HTTPRequest.get(baseURL)
request.addHeader(name: "Authorization", value: "Bearer token123")
request.addHeader(name: "User-Agent", value: "Osiris/2.0")
request.addHeader(name: "Accept", value: "application/json")
XCTAssertEqual(request.headers["Authorization"], "Bearer token123")
XCTAssertEqual(request.headers["User-Agent"], "Osiris/2.0")
XCTAssertEqual(request.headers["Accept"], "application/json")
XCTAssertEqual(request.headers.count, 3)
}
func testHTTPRequestOverwriteHeaders() {
var request = HTTPRequest.get(baseURL)
request.addHeader(name: "Accept", value: "application/xml")
request.addHeader(name: "Accept", value: "application/json") // Should overwrite
XCTAssertEqual(request.headers["Accept"], "application/json")
XCTAssertEqual(request.headers.count, 1)
}
func testHTTPRequestWithEmptyMultipartParts() {
var request = HTTPRequest.post(baseURL)
request.parts = [] // Empty parts array
XCTAssertEqual(request.contentType, .none) // Should not be set to multipart
XCTAssertTrue(request.parts.isEmpty)
}
func testHTTPRequestMultipartPartsResetContentType() {
var request = HTTPRequest.post(baseURL, contentType: .json)
XCTAssertEqual(request.contentType, .json)
request.parts = [.text("test", name: "field")]
XCTAssertEqual(request.contentType, .multipart) // Should be automatically changed
request.parts = [] // Clear parts
XCTAssertEqual(request.contentType, .multipart) // Should remain multipart
}

View file

@ -13,9 +13,9 @@ class HTTPResponseTests: XCTestCase {
let url = URL(string: "https://api.example.net")!
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!
let data = Data("{}".utf8)
let response = HTTPResponse(response: httpResponse, data: data, error: nil)
if case let .success(urlResponse, responseData) = response {
XCTAssertEqual(urlResponse.statusCode, 200)
XCTAssertEqual(responseData, data)
@ -23,14 +23,14 @@ class HTTPResponseTests: XCTestCase {
XCTFail("Expected success response")
}
}
func testFailureResponseWithError() {
let url = URL(string: "https://api.example.net")!
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
let error = NSError(domain: "test", code: 1, userInfo: nil)
let response = HTTPResponse(response: httpResponse, data: nil, error: error)
if case let .failure(responseError, urlResponse, responseData) = response {
XCTAssertEqual((responseError as NSError).domain, "test")
XCTAssertEqual(urlResponse?.statusCode, 200)
@ -39,14 +39,14 @@ class HTTPResponseTests: XCTestCase {
XCTFail("Expected failure response")
}
}
func testFailureResponseWithHTTPError() {
let url = URL(string: "https://api.example.net")!
let httpResponse = HTTPURLResponse(url: url, statusCode: 404, httpVersion: nil, headerFields: nil)!
let data = Data("Not Found".utf8)
let response = HTTPResponse(response: httpResponse, data: data, error: nil)
if case let .failure(error, urlResponse, responseData) = response {
XCTAssertTrue(error is HTTPRequestError)
XCTAssertEqual(urlResponse?.statusCode, 404)
@ -55,114 +55,114 @@ class HTTPResponseTests: XCTestCase {
XCTFail("Expected failure response")
}
}
func testResponseWithoutHTTPURLResponse() {
let response = HTTPResponse(response: nil, data: nil, error: nil)
if case let .failure(error, _, _) = response {
XCTAssertTrue(error is HTTPRequestError)
} else {
XCTFail("Expected failure response")
}
}
func testDataProperty() {
let data = Data("test".utf8)
let url = URL(string: "https://api.example.net")!
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
let successResponse = HTTPResponse(response: httpResponse, data: data, error: nil)
XCTAssertEqual(successResponse.data, data)
let httpErrorResponse = HTTPURLResponse(url: url, statusCode: 400, httpVersion: nil, headerFields: nil)!
let failureResponse = HTTPResponse(response: httpErrorResponse, data: data, error: nil)
XCTAssertEqual(failureResponse.data, data)
}
func testStatusProperty() {
let url = URL(string: "https://api.example.net")!
let httpResponse = HTTPURLResponse(url: url, statusCode: 201, httpVersion: nil, headerFields: nil)!
let response = HTTPResponse(response: httpResponse, data: nil, error: nil)
XCTAssertEqual(response.status, 201)
}
func testHeadersProperty() {
let url = URL(string: "https://api.example.net")!
let headers = ["Content-Type": "application/json", "X-Custom": "value"]
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: headers)!
let response = HTTPResponse(response: httpResponse, data: nil, error: nil)
XCTAssertEqual(response.headers["Content-Type"] as? String, "application/json")
XCTAssertEqual(response.headers["X-Custom"] as? String, "value")
}
func testBodyStringProperty() {
let url = URL(string: "https://api.example.net")!
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
let data = Data("Hello, World!".utf8)
let response = HTTPResponse(response: httpResponse, data: data, error: nil)
XCTAssertEqual(response.bodyString, "Hello, World!")
}
func testBodyStringPropertyWithNoData() {
let url = URL(string: "https://api.example.net")!
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
let response = HTTPResponse(response: httpResponse, data: nil, error: nil)
XCTAssertEqual(response.bodyString, "")
}
func testDictionaryFromJSONProperty() {
let url = URL(string: "https://api.example.net")!
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
let json = ["name": "John", "age": 30] as [String: any Sendable]
let data = try! JSONSerialization.data(withJSONObject: json)
let response = HTTPResponse(response: httpResponse, data: data, error: nil)
let dictionary = response.dictionaryFromJSON
XCTAssertEqual(dictionary["name"] as? String, "John")
XCTAssertEqual(dictionary["age"] as? Int, 30)
}
func testDictionaryFromJSONPropertyWithInvalidJSON() {
let url = URL(string: "https://api.example.net")!
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
let data = Data("invalid json".utf8)
let response = HTTPResponse(response: httpResponse, data: data, error: nil)
let dictionary = response.dictionaryFromJSON
XCTAssertTrue(dictionary.isEmpty)
}
func testDictionaryFromJSONPropertyWithNoData() {
let url = URL(string: "https://api.example.net")!
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
let response = HTTPResponse(response: httpResponse, data: nil, error: nil)
XCTAssertEqual(response.bodyString, "")
}
func testDictionaryFromJSONPropertyWithNonDictionaryJSON() {
let url = URL(string: "https://api.example.net")!
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
let arrayJSON = try! JSONSerialization.data(withJSONObject: ["item1", "item2", "item3"])
let response = HTTPResponse(response: httpResponse, data: arrayJSON, error: nil)
let dictionary = response.dictionaryFromJSON
XCTAssertTrue(dictionary.isEmpty)
}
func testUnderlyingResponseProperty() {
let url = URL(string: "https://api.example.net")!
let httpResponse = HTTPURLResponse(url: url, statusCode: 201, httpVersion: "HTTP/1.1", headerFields: ["Server": "nginx"])!
let response = HTTPResponse(response: httpResponse, data: nil, error: nil)
if case let .success(underlyingResponse, _) = response {
XCTAssertEqual(underlyingResponse.statusCode, 201)
XCTAssertEqual(underlyingResponse.allHeaderFields["Server"] as? String, "nginx")
@ -170,41 +170,41 @@ class HTTPResponseTests: XCTestCase {
XCTFail("Expected success response")
}
}
func testResponseStringDescription() {
let url = URL(string: "https://api.example.net")!
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
let data = Data("test response".utf8)
let successResponse = HTTPResponse(response: httpResponse, data: data, error: nil)
let description = String(describing: successResponse)
XCTAssertTrue(description.contains("success"))
let failureResponse = HTTPResponse(response: httpResponse, data: data, error: HTTPRequestError.http)
let failureDescription = String(describing: failureResponse)
XCTAssertTrue(failureDescription.contains("failure"))
}
func testResponseWithDifferentStatusCodes() {
let url = URL(string: "https://api.example.net")!
// Test various 2xx success codes
for statusCode in [200, 201, 202, 204, 206] {
let httpResponse = HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil)!
let response = HTTPResponse(response: httpResponse, data: nil, error: nil)
if case .success = response {
// Expected
} else {
XCTFail("Status code \(statusCode) should be success")
}
}
// Test various error status codes
// Test various error status codes
for statusCode in [300, 400, 401, 404, 500, 503] {
let httpResponse = HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil)!
let response = HTTPResponse(response: httpResponse, data: nil, error: nil)
if case .failure = response {
// Expected
} else {
@ -212,25 +212,25 @@ class HTTPResponseTests: XCTestCase {
}
}
}
func testResponseWithBinaryData() {
let url = URL(string: "https://api.example.net")!
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
let binaryData = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) // PNG header
let response = HTTPResponse(response: httpResponse, data: binaryData, error: nil)
XCTAssertEqual(response.data, binaryData)
// bodyString should handle binary data gracefully - it will be empty since this isn't valid UTF-8
let bodyString = response.bodyString
XCTAssertTrue(bodyString.isEmpty) // Binary data that isn't valid UTF-8 returns empty string
}
func testResponseStatusPropertyEdgeCases() {
// Test with no HTTP response - creates dummy HTTPURLResponse with status 0
let responseNoHTTP = HTTPResponse(response: nil, data: nil, error: nil)
XCTAssertEqual(responseNoHTTP.status, 0)
// Test with URLResponse that's not HTTPURLResponse - creates dummy HTTPURLResponse with status 0
let url = URL(string: "file:///test.txt")!
let fileResponse = URLResponse(url: url, mimeType: "text/plain", expectedContentLength: 10, textEncodingName: nil)

View file

@ -10,153 +10,153 @@ import XCTest
class RequestBuilderTests: XCTestCase {
let baseURL = URL(string: "https://api.example.net/users")!
func testBuildBasicGETRequest() throws {
let httpRequest = HTTPRequest.get(baseURL)
let urlRequest = try RequestBuilder.build(request: httpRequest)
XCTAssertEqual(urlRequest.url, baseURL)
XCTAssertEqual(urlRequest.httpMethod, "GET")
XCTAssertNil(urlRequest.httpBody)
}
func testBuildBasicPOSTRequest() throws {
let httpRequest = HTTPRequest.post(baseURL)
let urlRequest = try RequestBuilder.build(request: httpRequest)
XCTAssertEqual(urlRequest.url, baseURL)
XCTAssertEqual(urlRequest.httpMethod, "POST")
XCTAssertNil(urlRequest.httpBody)
}
func testBuildRequestWithHeaders() throws {
var httpRequest = HTTPRequest.get(baseURL)
httpRequest.headers = ["Authorization": "Bearer token", "X-Custom": "value"]
let urlRequest = try RequestBuilder.build(request: httpRequest)
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Authorization"), "Bearer token")
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "X-Custom"), "value")
}
func testBuildJSONRequest() throws {
let parameters = ["name": "Jane", "age": 30] as [String: any Sendable]
let httpRequest = HTTPRequest.post(baseURL, contentType: .json, parameters: parameters)
let urlRequest = try RequestBuilder.build(request: httpRequest)
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Content-Type"), "application/json")
XCTAssertNotNil(urlRequest.httpBody)
// Verify the JSON content
let bodyData = urlRequest.httpBody!
let decodedJSON = try JSONSerialization.jsonObject(with: bodyData) as! [String: any Sendable]
XCTAssertEqual(decodedJSON["name"] as? String, "Jane")
XCTAssertEqual(decodedJSON["age"] as? Int, 30)
}
func testBuildFormEncodedRequest() throws {
let parameters = ["email": "john@example.net", "password": "TaylorSwift1989"]
let httpRequest = HTTPRequest.post(baseURL, contentType: .formEncoded, parameters: parameters)
let urlRequest = try RequestBuilder.build(request: httpRequest)
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Content-Type"), "application/x-www-form-urlencoded")
XCTAssertNotNil(urlRequest.httpBody)
let bodyString = String(data: urlRequest.httpBody!, encoding: .utf8)!
XCTAssertTrue(bodyString.contains("email=john%40example.net"))
XCTAssertTrue(bodyString.contains("password=TaylorSwift1989"))
}
// Note: Testing .none content type with parameters would trigger an assertion failure
// This is by design - developers should specify an appropriate content type
func testBuildMultipartRequest() throws {
var httpRequest = HTTPRequest.post(baseURL)
httpRequest.parts = [
.text("Jane Doe", name: "name"),
.data(Data("test".utf8), name: "file", type: "text/plain", filename: "test.txt")
]
let urlRequest = try RequestBuilder.build(request: httpRequest)
let contentType = urlRequest.value(forHTTPHeaderField: "Content-Type")
XCTAssertNotNil(contentType)
XCTAssertTrue(contentType!.hasPrefix("multipart/form-data; boundary="))
let contentLength = urlRequest.value(forHTTPHeaderField: "Content-Length")
XCTAssertNotNil(contentLength)
XCTAssertGreaterThan(Int(contentLength!)!, 0)
XCTAssertNotNil(urlRequest.httpBody)
}
func testBuildRequestWithInvalidFormData() throws {
// Create a parameter that would cause UTF-8 encoding to fail
// FormEncoder.encode() returns a String, but String.data(using: .utf8) could theoretically fail
// However, this is extremely rare in practice. Let's test the error path by creating a mock scenario.
// Since FormEncoder is quite robust and UTF-8 encoding rarely fails,
// we'll test this by creating a subclass that can force the failure
// But for now, we'll document this edge case exists
XCTAssertNoThrow(try RequestBuilder.build(request: HTTPRequest.post(baseURL, contentType: .formEncoded, parameters: ["test": "value"])))
}
func testBuildRequestWithAllHTTPMethods() throws {
let methods: [HTTPMethod] = [.get, .post, .put, .patch, .delete]
for method in methods {
let httpRequest = HTTPRequest(method: method, url: baseURL)
let urlRequest = try RequestBuilder.build(request: httpRequest)
XCTAssertEqual(urlRequest.httpMethod, method.string)
}
}
func testBuildRequestPreservesURL() throws {
let complexURL = URL(string: "https://api.example.net/users?page=1#section")!
let httpRequest = HTTPRequest.get(complexURL)
let urlRequest = try RequestBuilder.build(request: httpRequest)
XCTAssertEqual(urlRequest.url, complexURL)
}
func testMultipleHeadersWithSameName() throws {
var httpRequest = HTTPRequest.get(baseURL)
httpRequest.headers = ["Accept": "application/json"]
let urlRequest = try RequestBuilder.build(request: httpRequest)
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Accept"), "application/json")
}
func testBuildRequestWithEmptyMultipartParts() throws {
var httpRequest = HTTPRequest.post(baseURL)
httpRequest.parts = []
httpRequest.contentType = .multipart // Explicitly set to multipart
let urlRequest = try RequestBuilder.build(request: httpRequest)
let contentType = try XCTUnwrap(urlRequest.value(forHTTPHeaderField: "Content-Type"))
XCTAssertTrue(contentType.hasPrefix("multipart/form-data; boundary="))
XCTAssertNotNil(urlRequest.httpBody)
}
func testBuildRequestWithLargeMultipartData() throws {
var httpRequest = HTTPRequest.post(baseURL)
let largeData = Data(repeating: 65, count: 1024 * 1024) // 1MB of 'A' characters
httpRequest.parts = [
.data(largeData, name: "largefile", type: "application/octet-stream", filename: "large.bin")
]
let urlRequest = try RequestBuilder.build(request: httpRequest)
XCTAssertNotNil(urlRequest.httpBody)
XCTAssertGreaterThan(urlRequest.httpBody!.count, 1024 * 1024)
}
func testBuildRequestWithSpecialCharactersInHeaders() throws {
var httpRequest = HTTPRequest.get(baseURL)
httpRequest.headers = [
@ -164,42 +164,42 @@ class RequestBuilderTests: XCTestCase {
"X-Unicode": "🚀 rocket emoji",
"X-Empty": ""
]
let urlRequest = try RequestBuilder.build(request: httpRequest)
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "X-Custom-Header"), "value with spaces and symbols: !@#$%")
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "X-Unicode"), "🚀 rocket emoji")
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "X-Empty"), "")
}
func testBuildRequestWithNilParameters() throws {
let httpRequest = HTTPRequest.post(baseURL, contentType: .json, parameters: nil)
let urlRequest = try RequestBuilder.build(request: httpRequest)
// RequestBuilder may not set Content-Type if there are no parameters to encode
XCTAssertNil(urlRequest.httpBody)
}
func testBuildRequestWithEmptyParameters() throws {
let httpRequest = HTTPRequest.post(baseURL, contentType: .json, parameters: [:])
let urlRequest = try RequestBuilder.build(request: httpRequest)
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Content-Type"), "application/json")
XCTAssertNotNil(urlRequest.httpBody)
let bodyString = String(data: urlRequest.httpBody!, encoding: .utf8)!
XCTAssertEqual(bodyString, "{}")
}
func testBuildRequestSetsContentType() throws {
let httpRequest = HTTPRequest.post(baseURL, contentType: .json, parameters: ["test": "value"])
let urlRequest = try RequestBuilder.build(request: httpRequest)
// RequestBuilder should set the correct content type when there are parameters to encode
let contentType = urlRequest.value(forHTTPHeaderField: "Content-Type")
XCTAssertTrue(contentType?.contains("application/json") == true)
}
func testBuildRequestWithComplexJSONParameters() throws {
let nestedData: [String: any Sendable] = ["theme": "dark", "notifications": true]
let arrayData: [any Sendable] = ["rock", "pop", "jazz"]
@ -211,86 +211,99 @@ class RequestBuilderTests: XCTestCase {
"genres": arrayData
] as [String: any Sendable]
]
let httpRequest = HTTPRequest.post(baseURL, contentType: .json, parameters: complexParams)
let urlRequest = try RequestBuilder.build(request: httpRequest)
XCTAssertNotNil(urlRequest.httpBody)
let jsonObject = try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any]
let person = jsonObject["person"] as! [String: Any]
XCTAssertEqual(person["name"] as? String, "David Bowie")
XCTAssertEqual(person["age"] as? Int, 69)
}
func testBuildRequestWithNoneContentTypeFallsBackToFormEncoding() throws {
// Test the .none content type fallthrough case with a warning
let httpRequest = HTTPRequest.post(baseURL, contentType: .none, parameters: ["email": "freddie@example.net", "band": "Queen"])
let urlRequest = try RequestBuilder.build(request: httpRequest)
// Should fall back to form encoding and log a warning
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Content-Type"), "application/x-www-form-urlencoded")
XCTAssertNotNil(urlRequest.httpBody)
let bodyString = String(data: urlRequest.httpBody!, encoding: .utf8)!
XCTAssertTrue(bodyString.contains("email=freddie%40example.net"))
XCTAssertTrue(bodyString.contains("band=Queen"))
}
func testBuildGETRequestWithQueryParameters() throws {
let httpRequest = HTTPRequest.get(baseURL, parameters: ["name": "John Doe", "email": "john@example.net"])
let urlRequest = try RequestBuilder.build(request: httpRequest)
XCTAssertEqual(urlRequest.httpMethod, "GET")
XCTAssertNil(urlRequest.httpBody)
XCTAssertNil(urlRequest.value(forHTTPHeaderField: "Content-Type"))
let urlString = urlRequest.url?.absoluteString ?? ""
XCTAssertTrue(urlString.contains("name=John%20Doe"), "URL should contain encoded name parameter")
XCTAssertTrue(urlString.contains("email=john@example.net"), "URL should contain email parameter")
XCTAssertTrue(urlString.contains("?"), "URL should contain query separator")
}
func testBuildDELETERequestWithQueryParameters() throws {
let httpRequest = HTTPRequest.delete(baseURL, parameters: ["id": "123", "confirm": "true"])
let urlRequest = try RequestBuilder.build(request: httpRequest)
XCTAssertEqual(urlRequest.httpMethod, "DELETE")
XCTAssertNil(urlRequest.httpBody)
XCTAssertNil(urlRequest.value(forHTTPHeaderField: "Content-Type"))
let urlString = urlRequest.url?.absoluteString ?? ""
XCTAssertTrue(urlString.contains("id=123"))
XCTAssertTrue(urlString.contains("confirm=true"))
XCTAssertTrue(urlString.contains("?"))
}
func testBuildGETRequestWithExistingQueryString() throws {
let urlWithQuery = URL(string: "https://api.example.net/users?existing=param")!
let httpRequest = HTTPRequest.get(urlWithQuery, parameters: ["new": "value"])
let urlRequest = try RequestBuilder.build(request: httpRequest)
let urlString = urlRequest.url?.absoluteString ?? ""
XCTAssertTrue(urlString.contains("existing=param"))
XCTAssertTrue(urlString.contains("new=value"))
XCTAssertTrue(urlString.contains("&"))
}
func testBuildGETRequestWithMultipartThrowsError() throws {
var httpRequest = HTTPRequest.get(baseURL, parameters: ["name": "value"])
httpRequest.contentType = HTTPContentType.multipart
XCTAssertThrowsError(try RequestBuilder.build(request: httpRequest)) { error in
XCTAssertTrue(error is RequestBuilderError)
}
}
func testBuildDELETERequestWithPartsThrowsError() throws {
var httpRequest = HTTPRequest.delete(baseURL, parameters: ["id": "123"])
httpRequest.parts = [MultipartFormEncoder.Part.text("value", name: "test")]
XCTAssertThrowsError(try RequestBuilder.build(request: httpRequest)) { error in
XCTAssertTrue(error is RequestBuilderError)
}
}
func testBuildGETRequestWithEmptyParametersDoesNotIncludeQueryString() throws {
let httpRequest = HTTPRequest.get(baseURL, parameters: [:])
let urlRequest = try RequestBuilder.build(request: httpRequest)
XCTAssertEqual(urlRequest.httpMethod, "GET")
XCTAssertNil(urlRequest.httpBody)
XCTAssertNil(urlRequest.value(forHTTPHeaderField: "Content-Type"))
let urlString = urlRequest.url?.absoluteString ?? ""
XCTAssertEqual(urlString, baseURL.absoluteString, "URL should not contain query string when parameters are empty")
XCTAssertFalse(urlString.contains("?"), "URL should not contain question mark when parameters are empty")
}
}