From 4615329e46685ac8cba343784d56e268b0611b66 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Sun, 15 Jun 2025 06:18:42 -0700 Subject: [PATCH] Modernize, add tests, remove assertions --- .gitignore | 3 +- CHANGELOG.md | 25 ++ Package.swift | 6 +- Readme.md | 206 +++++++++------ Service.swift | 71 +++--- Sources/Osiris/FormEncoder.swift | 43 +++- Sources/Osiris/HTTP.swift | 160 ------------ Sources/Osiris/HTTPContentType.swift | 36 +++ Sources/Osiris/HTTPMethod.swift | 25 ++ Sources/Osiris/HTTPRequest.swift | 146 +++++++++++ Sources/Osiris/HTTPRequestError.swift | 53 ++++ Sources/Osiris/HTTPResponse.swift | 140 ++++++++++ Sources/Osiris/MultipartFormEncoder.swift | 182 +++++++++++-- Sources/Osiris/RequestBuilder.swift | 120 +++++++-- Tests/OsirisTests/FormEncoderTests.swift | 181 +++++++++++++ Tests/OsirisTests/HTTPMethodTests.swift | 19 ++ Tests/OsirisTests/HTTPRequestErrorTests.swift | 62 +++++ Tests/OsirisTests/HTTPRequestTests.swift | 166 ++++++++++++ Tests/OsirisTests/HTTPResponseTests.swift | 240 ++++++++++++++++++ Tests/OsirisTests/RequestBuilderTests.swift | 239 +++++++++++++++++ 20 files changed, 1802 insertions(+), 321 deletions(-) create mode 100644 CHANGELOG.md delete mode 100644 Sources/Osiris/HTTP.swift create mode 100644 Sources/Osiris/HTTPContentType.swift create mode 100644 Sources/Osiris/HTTPMethod.swift create mode 100644 Sources/Osiris/HTTPRequest.swift create mode 100644 Sources/Osiris/HTTPRequestError.swift create mode 100644 Sources/Osiris/HTTPResponse.swift create mode 100644 Tests/OsirisTests/FormEncoderTests.swift create mode 100644 Tests/OsirisTests/HTTPMethodTests.swift create mode 100644 Tests/OsirisTests/HTTPRequestErrorTests.swift create mode 100644 Tests/OsirisTests/HTTPRequestTests.swift create mode 100644 Tests/OsirisTests/HTTPResponseTests.swift create mode 100644 Tests/OsirisTests/RequestBuilderTests.swift diff --git a/.gitignore b/.gitignore index 0dc5c83..3f62277 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.swiftpm \ No newline at end of file +.swiftpm +.build/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d3b49ca --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +## [2.0.0] - 2025-06-15 + +### Added +- **Enhanced error types** with localized descriptions and failure reasons +- **Header convenience method** `addHeader(name:value:)` on `HTTPRequest` +- **Comprehensive test coverage** + +### Enhanced +- **Public API** - All types and methods now have proper public access modifiers +- **Error handling** - More specific error cases with `LocalizedError` conformance +- **Debugging support** - All types now conform to `CustomStringConvertible` with idiomatic descriptions for better OSLog output + +[2.0.0]: https://github.com/samsonjs/Osiris/compare/1.0.0...2.0.0 + +## [1.0.0] - 2017-07-28 + +### Added +- Initial release with multipart form encoding +- HTTPRequest and HTTPResponse abstractions +- RequestBuilder for URLRequest conversion +- FormEncoder for URL-encoded forms + +[1.0.0]: https://github.com/samsonjs/Osiris/releases/tag/1.0.0 diff --git a/Package.swift b/Package.swift index 6cd2d46..5e5e3f3 100644 --- a/Package.swift +++ b/Package.swift @@ -1,10 +1,14 @@ -// swift-tools-version:5.3 +// swift-tools-version:6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "Osiris", + platforms: [ + .iOS(.v14), + .macOS(.v11), + ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( diff --git a/Readme.md b/Readme.md index 34cc9cb..0caa2b0 100644 --- a/Readme.md +++ b/Readme.md @@ -1,134 +1,190 @@ # Osiris [![0 dependencies!](https://0dependencies.dev/0dependencies.svg)](https://0dependencies.dev) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fsamsonjs%2FOsiris%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/samsonjs/Osiris) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fsamsonjs%2FOsiris%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/samsonjs/Osiris) -# Overview +## Overview -A multipart form encoder for Swift, as well as some other utilities that make -working with HTTP a bit simpler and more flexible. +Osiris is a Swift library that provides a multipart form encoder and HTTP utilities designed to make working with HTTP requests simpler and more flexible. The library focuses on practical utility over complexity, offering tools that handle common HTTP tasks like multipart form encoding, request building, and response handling. -# Installation +The main components include a robust `MultipartFormEncoder` that can encode forms either to memory or directly to files for streaming, and clean abstractions for HTTP requests and responses. All types conform to `CustomStringConvertible` with idiomatic descriptions, making debugging with OSLog significantly easier. -Copy the files you want to use into your project, and then customize them to suit your needs. +## Installation -# Multipart Form Encoding +You can install Osiris using Swift Package Manager (SPM) or copy the files directly into your project and customize them as needed. + +### Supported Platforms + +This package supports iOS 14.0+ and macOS 11.0+. The package is built with Swift 6.0+ but doesn't require projects importing Osiris to use Swift 6 language mode. + +### Xcode + +Add the package to your project's Package Dependencies by entering the URL `https://github.com/samsonjs/Osiris` and following the usual flow for adding packages. + +### Swift Package Manager (SPM) + +Add this to your Package.swift dependencies: + +```swift +.package(url: "https://github.com/samsonjs/Osiris.git", .upToNextMajor(from: "1.0.0")) +``` + +and add `"Osiris"` to your target dependencies. + +### Direct Integration + +Alternatively, copy the files you want to use into your project and customize them to suit your needs. + +## Usage + +### Multipart Form Encoding Create an encoder and then add parts to it as needed: -```Swift -let avatarData = UIImage(from: somewhere).jpegData(compressionQuality: 1) +```swift +import Osiris + +let avatarData = UIImage(systemName: "person.circle")?.jpegData(compressionQuality: 1.0) let encoder = MultipartFormEncoder() let body = try encoder.encodeData(parts: [ - .text("somebody@example.com", name: "email"), - .text("secret", name: "password"), - .data(Data(), name: "avatar", type: "image/jpeg", filename: "avatar.jpg"), + .text("ziggy@example.net", name: "email"), + .text("StarmanWaiting", name: "password"), + .data(avatarData ?? Data(), name: "avatar", type: "image/jpeg", filename: "avatar.jpg"), ]) ``` The form can be encoded as `Data` in memory, or to a file. There's a hard limit of 50 MB on encoding to memory but in practice you probably never want to go that high purely in memory. If you're adding any kind of image or video file then it's probably better to stream to a file. -```Swift -let body = try encoder.encodeFile(parts: [/* ... */]) -var request = URLRequest(url: URL(string: "https://example.com/accounts")!) +```swift +let body = try encoder.encodeFile(parts: [ + .text("ziggy@example.net", name: "email"), + .text("StarmanWaiting", name: "password"), + .data(avatarData ?? Data(), name: "avatar", type: "image/jpeg", filename: "avatar.jpg"), +]) + +var request = URLRequest(url: URL(string: "https://example.net/accounts")!) request.httpMethod = "POST" request.httpBodyStream = InputStream(url: body.url) request.addValue(body.contentType, forHTTPHeaderField: "Content-Type") request.addValue("\(body.contentLength)", forHTTPHeaderField: "Content-Length") -// ... whatever you normally do with requests ``` -# HTTPRequest +### HTTPRequest Basic usage: -```Swift -let url = URL(string: "https://example.com")! +```swift +let url = URL(string: "https://example.net")! let request = HTTPRequest(method: .get, url: url) ``` -Fancier usage: +More advanced usage with parameters and headers: -```Swift -let url = URL(string: "https://example.com")! -let params = ["email" : "someone@example.com", "password" : "secret"] +```swift +let url = URL(string: "https://example.net")! +let params = ["email": "freddie@example.net", "password": "BohemianRhapsody"] let request = HTTPRequest(method: .post, url: url, contentType: .json, parameters: params) request.addHeader(name: "x-custom", value: "42") request.addMultipartJPEG(name: "avatar", image: UIImage(), quality: 1, filename: "avatar.jpg") ``` -You can build a `URLRequest` from an `HTTPRequest` instance using `RequestBuilder`. Or make your own builder. +You can build a `URLRequest` from an `HTTPRequest` instance using `RequestBuilder`: -# HTTPResponse +```swift +let urlRequest = try RequestBuilder.build(request: request) +``` + +### HTTPResponse This enum makes sense of the 3 parameters of `URLSession`'s completion block. Its initializer takes in the optional `URLResponse`, `Data`, and `Error` values and determines if the request succeeded or failed, taking the HTTP status code into account. 200-level statuses are successes and anything else is a failure. -The success case has two associated values: `HTTPURLResponse` and `Data?`, while the failure case has three associated values: `Error`, `HTTPURLResponse`, and `Data?`. - -Some properties are exposed for convenience: - -- `data`: the optional body data returned by the server. - -- `status`: the HTTP status code returned by the server, or 0 if the request itself failed, e.g. if the server cannot be reached. - -- `headers`: a dictionary of headers. - -- `bodyString`: the response body as a `String`. This is an empty string if the body is empty or there was an error decoding it as UTF8. - -- `dictionaryFromJSON`: the decoded body for JSON responses. This is an empty dictionary if the body is empty or there was an error decoding it as a JSON dictionary. - -- `underlyingResponse`: the `HTTPURLResponse` in case you need to dive in. - -# RequestBuilder - -This class takes in an `HTTPRequest` instance and turns it into a `URLRequest` for use with `URLSession`. - -Usage: - -```Swift -let urlRequest: URLRequest -do { - urlRequest = try RequestBuilder.build(request: request) +```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)") + if let data = data { + print("Response: \(String(data: data, encoding: .utf8) ?? "")") + } + case .failure(let error, let httpURLResponse, let data): + print("Failed: \(error)") + if let httpURLResponse = httpURLResponse { + print("Status: \(httpURLResponse.statusCode)") + } + } } -catch { - log.error("Invalid request \(request): \(error)") - return -} -// ... do something with urlRequest ``` -It encodes multipart requests in memory, so you'll need to change it or make your own builder for advanced functionality like encoding multipart forms to disk instead. +The response provides convenient properties: -# FormEncoder +- `data`: the optional body data returned by the server +- `status`: the HTTP status code returned by the server, or 0 if the request itself failed +- `headers`: a dictionary of headers +- `bodyString`: the response body as a `String` +- `dictionaryFromJSON`: the decoded body for JSON responses +- `underlyingResponse`: the optional `HTTPURLResponse` for direct access -This was lifted from [Alamofire][], but with some minor changes. +### FormEncoder -```Swift -let body = FormEncoder.encode(["email" : "someone@example.com", "password" : "secret"]) -// => "email=someone%40example.com&password=secret" +URL-encoded form data encoder adapted from [Alamofire][]: + +```swift +let body = FormEncoder.encode(["email": "bowie@example.net", "password": "MajorTom"]) +// => "email=bowie%40example.net&password=MajorTom" ``` [Alamofire]: https://github.com/Alamofire/Alamofire -# Service: Putting it all Together +### Complete Example -Take a look at `Service.swift` to see how it can all come together. Grafting your specific service API onto the primitives shown there is an exercise. In 1SE we're just adding methods to `Service` for each specific call, but you could keep them separate instead if you prefer that. +Here's how everything comes together: -I don't recommend you use `Service` as shown here, but maybe use it as a jumping off point for something that makes sense to you for your specific application. +```swift +import Osiris -# Credits +// Create an HTTP request +let url = URL(string: "https://httpbin.org/post")! +let request = HTTPRequest(method: .post, url: url) -Mostly created by Sami Samhuri for [1SE][]. `FormEncoder.swift` was lifted from [Alamofire][]. +// Add multipart form data +let encoder = MultipartFormEncoder() +let formData = try encoder.encodeData(parts: [ + .text("John Doe", name: "name"), + .text("john@example.net", name: "email"), +]) + +// Build URLRequest +var urlRequest = try RequestBuilder.build(request: request) +urlRequest.httpBody = formData.data +urlRequest.addValue(formData.contentType, forHTTPHeaderField: "Content-Type") + +// Make the request +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("Upload successful: \(httpURLResponse.statusCode)") + case .failure(let error, _, _): + print("Upload failed: \(error)") + } +} +task.resume() +``` + +## Credits + +Originally created by [@samsonjs][] for [1 Second Everyday][1SE]. `FormEncoder.swift` was adapted from [Alamofire][]. [1SE]: https://1se.co +[Alamofire]: https://github.com/Alamofire/Alamofire +[samsonjs]: https://github.com/samsonjs -# License +## License -Copyright © 2017 [1 Second Everyday][1SE]. All rights reserved. +Copyright © 2017-2025 [1 Second Everyday][1SE]. Released under the terms of the [MIT License][MIT]. -Released under the terms of the MIT license: - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +[MIT]: https://sjs.mit-license.org diff --git a/Service.swift b/Service.swift index 97188fa..50950f2 100644 --- a/Service.swift +++ b/Service.swift @@ -2,10 +2,15 @@ // Created by Sami Samhuri on 2016-07-30. // Copyright © 2016 1 Second Everyday. All rights reserved. // +// This file shows how you can actually use Osiris with URLSession. +// -import PromiseKit +import Foundation +import OSLog import UIKit +private let log = Logger(subsystem: "co.1se.Osiris", category: "Service") + enum ServiceError: Error { case malformedRequest(HTTPRequest) case malformedResponse(message: String) @@ -37,9 +42,9 @@ enum ServiceEnvironment: String { var baseURL: URL { switch self { - case .production: return URL(string: "https://example.com")! - case .staging: return URL(string: "https://staging.example.com")! - case .development: return URL(string: "https://dev.example.com")! + case .production: return URL(string: "https://example.net")! + case .staging: return URL(string: "https://staging.example.net")! + case .development: return URL(string: "https://dev.example.net")! } } } @@ -51,8 +56,7 @@ final class Service { init(environment: ServiceEnvironment, urlSessionConfig: URLSessionConfiguration? = nil) { self.environment = environment - self.urlSession = URLSession(configuration: .urlSessionConfig ?? .default) - super.init() + self.urlSession = URLSession(configuration: urlSessionConfig ?? .default) } func reconfigure(environment: ServiceEnvironment, urlSessionConfig: URLSessionConfiguration? = nil) { @@ -73,17 +77,17 @@ final class Service { // MARK: - Your service calls here // For example... (you probably want a more specific result type unpacked from the response though) - func signUp(email: String, password: String, avatar: UIImage) -> Promise { + func signUp(email: String, password: String, avatar: UIImage) async throws -> HTTPResponse { let parameters = ["email" : email, "password" : password] let url = environment.baseURL.appendingPathComponent("accounts") var request = HTTPRequest.post(url, contentType: .formEncoded, parameters: parameters) request.addMultipartJPEG(name: "avatar", image: avatar, quality: 1) - return performRequest(request) + return try await performRequest(request) } // MARK: - Requests - fileprivate func deleteRequest(path: String, parameters: [String : Any]? = nil) -> HTTPRequest { + fileprivate func deleteRequest(path: String, parameters: [String: any Sendable]? = nil) -> HTTPRequest { return newRequest(method: .delete, path: path, parameters: parameters) } @@ -91,32 +95,32 @@ final class Service { return newRequest(method: .get, path: path) } - fileprivate func patchRequest(path: String, parameters: [String : Any]) -> HTTPRequest { + fileprivate func patchRequest(path: String, parameters: [String: any Sendable]) -> HTTPRequest { return newRequest(method: .patch, path: path, contentType: .formEncoded, parameters: parameters) } - fileprivate func postJSONRequest(path: String, parameters: [String : Any]) -> HTTPRequest { + fileprivate func postJSONRequest(path: String, parameters: [String: any Sendable]) -> HTTPRequest { return newRequest(method: .post, path: path, contentType: .json, parameters: parameters) } - fileprivate func postRequest(path: String, parameters: [String : Any]) -> HTTPRequest { + fileprivate func postRequest(path: String, parameters: [String: any Sendable]) -> HTTPRequest { return newRequest(method: .post, path: path, contentType: .formEncoded, parameters: parameters) } - fileprivate func putJSONRequest(path: String, parameters: [String : Any]) -> HTTPRequest { + fileprivate func putJSONRequest(path: String, parameters: [String: any Sendable]) -> HTTPRequest { return newRequest(method: .put, path: path, contentType: .json, parameters: parameters) } - fileprivate func putRequest(path: String, parameters: [String : Any]) -> HTTPRequest { + fileprivate func putRequest(path: String, parameters: [String: any Sendable]) -> HTTPRequest { return newRequest(method: .put, path: path, contentType: .formEncoded, parameters: parameters) } - fileprivate func newRequest(method: HTTPMethod, path: String, contentType: HTTPContentType = .none, parameters: [String : Any]? = nil) -> HTTPRequest { + fileprivate func newRequest(method: HTTPMethod, path: String, contentType: HTTPContentType = .none, parameters: [String: any Sendable]? = nil) -> HTTPRequest { let url = environment.baseURL.appendingPathComponent(path) return newRequest(method: method, url: url, contentType: contentType, parameters: parameters) } - fileprivate func newRequest(method: HTTPMethod, url: URL, contentType: HTTPContentType = .none, parameters: [String : Any]? = nil) -> HTTPRequest { + fileprivate func newRequest(method: HTTPMethod, url: URL, contentType: HTTPContentType = .none, parameters: [String: any Sendable]? = nil) -> HTTPRequest { let request = HTTPRequest(method: method, url: url, contentType: contentType, parameters: parameters) // Authorize requests to our service automatically. @@ -132,41 +136,38 @@ final class Service { request.addHeader(name: "Authorization", value: basicAuth) } - func performRequest(_ request: HTTPRequest) -> Promise { + func performRequest(_ request: HTTPRequest) async throws -> HTTPResponse { let urlRequest: URLRequest do { urlRequest = try RequestBuilder.build(request: request) } catch { log.error("Invalid request \(request): \(error)") - return Promise(error: ServiceError.malformedRequest(request)) - } - return Promise { fulfill, reject in - let start = Date() - let task = self.urlSession.dataTask(with: urlRequest) { maybeData, maybeResponse, maybeError in - let response = HTTPResponse(response: maybeResponse, data: maybeData, error: maybeError) - _ = { - let end = Date() - let duration = end.timeIntervalSince1970 - start.timeIntervalSince1970 - self.logRequest(request, response: response, duration: duration) - }() - fulfill(response) - } - task.resume() + throw ServiceError.malformedRequest(request) } + + let start = Date() + let (data, response) = try await urlSession.data(for: urlRequest) + let httpResponse = HTTPResponse(response: response, data: data, error: nil) + + let end = Date() + let duration = end.timeIntervalSince1970 - start.timeIntervalSince1970 + logRequest(request, response: httpResponse, duration: duration) + + return httpResponse } - private func scrubParameters(_ parameters: [String : Any], for url: URL) -> [String : Any] { + private func scrubParameters(_ parameters: [String: any Sendable], for url: URL) -> [String: any Sendable] { return parameters.reduce([:], { params, param in var params = params let (name, value) = param - let isBlacklisted = self.isBlacklisted(url: url, paramName: name) - params[name] = isBlacklisted ? "" : value + let isSensitive = self.isSensitive(url: url, paramName: name) + params[name] = isSensitive ? "" : value return params }) } - private func isBlacklisted(url: URL, paramName: String) -> Bool { + private func isSensitive(url: URL, paramName: String) -> Bool { return paramName.contains("password") } diff --git a/Sources/Osiris/FormEncoder.swift b/Sources/Osiris/FormEncoder.swift index 68775e6..5b5eed6 100644 --- a/Sources/Osiris/FormEncoder.swift +++ b/Sources/Osiris/FormEncoder.swift @@ -5,6 +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. @@ -13,8 +14,40 @@ extension NSNumber { } } -final class FormEncoder { - class func encode(_ parameters: [String: Any]) -> String { +/// URL-encoded form data encoder adapted from Alamofire. +/// +/// FormEncoder converts Swift dictionaries into URL-encoded form data strings +/// suitable for application/x-www-form-urlencoded requests. It handles nested +/// dictionaries, arrays, and various data types including proper boolean encoding. +/// +/// ## Usage +/// +/// ```swift +/// let parameters = [ +/// "name": "Jane Doe", +/// "email": "jane@example.net", +/// "age": 30, +/// "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: + /// - Keys are sorted alphabetically for consistent output + /// - Nested dictionaries use bracket notation: `key[subkey]=value` + /// - Arrays use empty brackets: `key[]=value1&key[]=value2` + /// - Booleans are encoded as "1" for true, "0" for false + /// - All keys and values are percent-escaped according to RFC 3986 + /// + /// - Parameter parameters: The dictionary to encode + /// - Returns: A URL-encoded form string ready for use in HTTP requests + public class func encode(_ parameters: [String: any Sendable]) -> String { var components: [(String, String)] = [] for key in parameters.keys.sorted(by: <) { @@ -33,7 +66,7 @@ final class FormEncoder { static func pairs(from key: String, value: Any) -> [(String, String)] { var components: [(String, String)] = [] - if let dictionary = value as? [String: Any] { + if let dictionary = value as? [String: any Sendable] { for (nestedKey, value) in dictionary { components += pairs(from: "\(key)[\(nestedKey)]", value: value) } @@ -86,4 +119,8 @@ final class FormEncoder { let escaped = string.addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) ?? string return escaped } + + public var description: String { + return "FormEncoder" + } } diff --git a/Sources/Osiris/HTTP.swift b/Sources/Osiris/HTTP.swift deleted file mode 100644 index a59bd42..0000000 --- a/Sources/Osiris/HTTP.swift +++ /dev/null @@ -1,160 +0,0 @@ -// -// Created by Sami Samhuri on 2017-07-28. -// Copyright © 2017 1 Second Everyday. All rights reserved. -// Released under the terms of the MIT license. -// - -import Foundation - -#if canImport(UIKit) -import UIKit -#endif - -enum HTTPMethod: String { - case delete - case get - case patch - case post - case put - - var string: String { - return rawValue.uppercased() - } -} - -enum HTTPContentType { - case formEncoded - case none - case json - case multipart -} - -struct HTTPRequest { - var method: HTTPMethod - var url: URL - var contentType: HTTPContentType - var parameters: [String : Any]? - var headers: [String : String] = [:] - var parts: [MultipartFormEncoder.Part] = [] { - didSet { - if !parts.isEmpty { contentType = .multipart } - } - } - - init(method: HTTPMethod, url: URL, contentType: HTTPContentType = .none, parameters: [String : Any]? = nil) { - self.method = method - self.url = url - self.contentType = contentType - self.parameters = parameters - } - - static func get(_ url: URL, contentType: HTTPContentType = .none) -> HTTPRequest { - HTTPRequest(method: .get, url: url, contentType: contentType) - } - - static func put(_ url: URL, contentType: HTTPContentType = .none, parameters: [String: Any]? = nil) -> HTTPRequest { - HTTPRequest(method: .put, url: url, contentType: contentType, parameters: parameters) - } - - static func post(_ url: URL, contentType: HTTPContentType = .none, parameters: [String: Any]? = nil) -> HTTPRequest { - HTTPRequest(method: .post, url: url, contentType: contentType, parameters: parameters) - } - - static func delete(_ url: URL, contentType: HTTPContentType = .none) -> HTTPRequest { - HTTPRequest(method: .delete, url: url, contentType: contentType) - } - -#if canImport(UIKit) - mutating func addMultipartJPEG(name: String, image: UIImage, quality: CGFloat, filename: String? = nil) { - guard let data = image.jpegData(compressionQuality: quality) else { - assertionFailure() - return - } - parts.append( - .data(data, name: name, type: "image/jpeg", filename: filename ?? "image.jpeg") - ) - } -#endif -} - -enum HTTPRequestError: Error { - case http - case unknown -} - -enum HTTPResponse { - case success(HTTPURLResponse, Data?) - case failure(Error, HTTPURLResponse, Data?) - - init(response maybeResponse: URLResponse?, data: Data?, error: Error?) { - guard let response = maybeResponse as? HTTPURLResponse else { - self = .failure(error ?? HTTPRequestError.unknown, HTTPURLResponse(), data) - return - } - - if let error = error { - self = .failure(error, response, data) - } - else if response.statusCode >= 200 && response.statusCode < 300 { - self = .success(response, data) - } - else { - self = .failure(HTTPRequestError.http, response, data) - } - } - - var data: Data? { - switch self { - case let .success(_, data): return data - case let .failure(_, _, data): return data - } - } - - var underlyingResponse: HTTPURLResponse { - switch self { - case let .success(response, _): return response - case let .failure(_, response, _): return response - } - } - - var status: Int { - return underlyingResponse.statusCode - } - - var headers: [AnyHashable : Any] { - return underlyingResponse.allHeaderFields - } - - var bodyString: String { - guard let data = self.data else { - NSLog("[WARN] No data found on response: \(self)") - return "" - } - guard let string = String(data: data, encoding: .utf8) else { - NSLog("[WARN] Data is not UTF8: \(data)") - return "" - } - return string - } - - var dictionaryFromJSON: [String : Any] { - guard let data = self.data else { - NSLog("[WARN] No data found on response: \(self)") - return [:] - } - do { - guard let dictionary = try JSONSerialization.jsonObject(with: data, options: []) as? [String : Any] else { - if let parsed = try? JSONSerialization.jsonObject(with: data, options: []) { - NSLog("[ERROR] Failed to parse JSON as dictionary: \(parsed)") - } - return [:] - } - return dictionary - } - catch { - let json = String(data: data, encoding: .utf8) ?? "" - NSLog("[ERROR] Failed to parse JSON \(json): \(error)") - return [:] - } - } -} diff --git a/Sources/Osiris/HTTPContentType.swift b/Sources/Osiris/HTTPContentType.swift new file mode 100644 index 0000000..96b830f --- /dev/null +++ b/Sources/Osiris/HTTPContentType.swift @@ -0,0 +1,36 @@ +// +// Created by Sami Samhuri on 2017-07-28. +// Copyright © 2017 1 Second Everyday. All rights reserved. +// Released under the terms of the MIT license. +// + +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: + return "application/x-www-form-urlencoded" + case .none: + return "none" + case .json: + return "application/json" + case .multipart: + return "multipart/form-data" + } + } +} diff --git a/Sources/Osiris/HTTPMethod.swift b/Sources/Osiris/HTTPMethod.swift new file mode 100644 index 0000000..95b8f30 --- /dev/null +++ b/Sources/Osiris/HTTPMethod.swift @@ -0,0 +1,25 @@ +// +// Created by Sami Samhuri on 2017-07-28. +// Copyright © 2017 1 Second Everyday. All rights reserved. +// Released under the terms of the MIT license. +// + +import Foundation + +/// HTTP methods supported by HTTPRequest. +public enum HTTPMethod: String, Sendable, CustomStringConvertible { + case delete + case get + case patch + case post + case put + + /// The uppercased string representation of the HTTP method. + var string: String { + return rawValue.uppercased() + } + + public var description: String { + return string + } +} diff --git a/Sources/Osiris/HTTPRequest.swift b/Sources/Osiris/HTTPRequest.swift new file mode 100644 index 0000000..e85fbb6 --- /dev/null +++ b/Sources/Osiris/HTTPRequest.swift @@ -0,0 +1,146 @@ +// +// Created by Sami Samhuri on 2017-07-28. +// Copyright © 2017 1 Second Everyday. All rights reserved. +// Released under the terms of the MIT license. +// + +import Foundation +import OSLog + +#if canImport(UIKit) +import UIKit +#endif + +private let log = Logger(subsystem: "co.1se.Osiris", category: "HTTPRequest") + +/// A structure representing an HTTP request with support for various content types and multipart forms. +/// +/// HTTPRequest provides a clean abstraction over URLRequest with built-in support for common +/// HTTP tasks like JSON encoding, form encoding, and multipart forms. +/// +/// ## Usage +/// +/// ```swift +/// // Simple GET request +/// let request = HTTPRequest.get(URL(string: "https://api.example.net/users")!) +/// +/// // POST with JSON parameters +/// let jsonRequest = HTTPRequest.post( +/// URL(string: "https://api.example.net/users")!, +/// contentType: .json, +/// parameters: ["name": "Jane", "email": "jane@example.net"] +/// ) +/// +/// // Multipart form with file upload +/// var multipartRequest = HTTPRequest.post(URL(string: "https://api.example.net/upload")!) +/// multipartRequest.parts = [ +/// .text("Jane Doe", name: "name"), +/// .data(imageData, name: "avatar", type: "image/jpeg", filename: "avatar.jpg") +/// ] +/// ``` +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 { + if !parts.isEmpty { contentType = .multipart } + } + } + + /// Creates a new HTTP request. + /// - Parameters: + /// - method: The HTTP method to use + /// - 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) { + self.method = method + self.url = url + self.contentType = contentType + self.parameters = parameters + } + + /// Creates a GET request. + /// - Parameters: + /// - url: The target URL + /// - contentType: The content type (typically .none for GET) + /// - Returns: A configured HTTPRequest + public static func get(_ url: URL, contentType: HTTPContentType = .none) -> HTTPRequest { + HTTPRequest(method: .get, url: url, contentType: contentType) + } + + /// Creates a PUT request. + /// - Parameters: + /// - url: The target URL + /// - contentType: The content type for encoding parameters + /// - parameters: Optional parameters to include in the request body + /// - Returns: A configured HTTPRequest + public static func put(_ url: URL, contentType: HTTPContentType = .none, parameters: [String: any Sendable]? = nil) -> HTTPRequest { + HTTPRequest(method: .put, url: url, contentType: contentType, parameters: parameters) + } + + /// Creates a POST request. + /// - Parameters: + /// - url: The target URL + /// - contentType: The content type for encoding parameters + /// - parameters: Optional parameters to include in the request body + /// - Returns: A configured HTTPRequest + public static func post(_ url: URL, contentType: HTTPContentType = .none, parameters: [String: any Sendable]? = nil) -> HTTPRequest { + HTTPRequest(method: .post, url: url, contentType: contentType, parameters: parameters) + } + + /// Creates a DELETE request. + /// - Parameters: + /// - url: The target URL + /// - contentType: The content type (typically .none for DELETE) + /// - Returns: A configured HTTPRequest + public static func delete(_ url: URL, contentType: HTTPContentType = .none) -> HTTPRequest { + HTTPRequest(method: .delete, url: url, contentType: contentType) + } + +#if canImport(UIKit) + + /// Adds a JPEG image to the multipart form (iOS/tvOS only). + /// - Parameters: + /// - name: The form field name + /// - image: The UIImage to convert to JPEG + /// - quality: JPEG compression quality (0.0 to 1.0) + /// - filename: Optional filename (defaults to "image.jpeg") + public mutating func addMultipartJPEG(name: String, image: UIImage, quality: CGFloat, filename: String? = nil) { + guard let data = image.jpegData(compressionQuality: quality) else { + log.error("Cannot compress image as JPEG data for parameter \(name) (\(filename ?? ""))") + return + } + parts.append( + .data(data, name: name, type: "image/jpeg", filename: filename ?? "image.jpeg") + ) + } +#endif + + /// Adds a header to this request. + /// - Parameters: + /// - name: The header name + /// - value: The header value + public mutating func addHeader(name: String, value: String) { + headers[name] = value + } + + public var description: String { + return "" + } +} diff --git a/Sources/Osiris/HTTPRequestError.swift b/Sources/Osiris/HTTPRequestError.swift new file mode 100644 index 0000000..3cf61bc --- /dev/null +++ b/Sources/Osiris/HTTPRequestError.swift @@ -0,0 +1,53 @@ +// +// Created by Sami Samhuri on 2017-07-28. +// Copyright © 2017 1 Second Everyday. All rights reserved. +// Released under the terms of the MIT license. +// + +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: + return "HTTP request failed with non-2xx status code" + case .unknown: + return "An unknown error occurred" + } + } + + public var failureReason: String? { + switch self { + case .http: + return "The server returned an error status code" + case .unknown: + return "An unexpected error occurred during the request" + } + } + + public var recoverySuggestion: String? { + switch self { + case .http: + return "Check the server response for error details" + case .unknown: + return "Check network connectivity and try again" + } + } + + public var description: String { + switch self { + case .http: + "HTTPRequestError.http" + case .unknown: + "HTTPRequestError.unknown" + } + } +} diff --git a/Sources/Osiris/HTTPResponse.swift b/Sources/Osiris/HTTPResponse.swift new file mode 100644 index 0000000..5b2e82b --- /dev/null +++ b/Sources/Osiris/HTTPResponse.swift @@ -0,0 +1,140 @@ +// +// Created by Sami Samhuri on 2017-07-28. +// Copyright © 2017 1 Second Everyday. All rights reserved. +// Released under the terms of the MIT license. +// + +import Foundation +import OSLog + +private let log = Logger(subsystem: "co.1se.Osiris", category: "HTTPResponse") + +/// A response from an HTTP request that simplifies URLSession's completion handler parameters. +/// +/// HTTPResponse consolidates URLSession's three optional parameters (URLResponse?, Data?, Error?) +/// into a single enum that clearly indicates success or failure. Success cases include 2xx status +/// codes, while all other status codes and network errors are treated as failures. +/// +/// ## Usage +/// +/// ```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)") +/// // Handle successful response +/// case .failure(let error, let httpURLResponse, let data): +/// print("Failed: \(error)") +/// // Handle error response +/// } +/// } +/// ``` +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?) + + /// Creates an HTTPResponse from URLSession completion handler parameters. + /// - Parameters: + /// - maybeResponse: The URLResponse from URLSession (may be nil) + /// - data: The response body data (may be nil) + /// - error: Any error that occurred (may be nil) + public init(response maybeResponse: URLResponse?, data: Data?, error: Error?) { + guard let response = maybeResponse as? HTTPURLResponse else { + self = .failure(error ?? HTTPRequestError.unknown, nil, data) + return + } + + if let error = error { + self = .failure(error, response, data) + } + else if response.statusCode >= 200 && response.statusCode < 300 { + self = .success(response, data) + } + else { + self = .failure(HTTPRequestError.http, response, data) + } + } + + /// The response body data, available for both success and failure cases. + public var data: Data? { + switch self { + case let .success(_, data): return data + case let .failure(_, _, data): return data + } + } + + /// The underlying HTTPURLResponse for direct access to response properties. + /// Returns nil when the response wasn't an HTTPURLResponse. + public var underlyingResponse: HTTPURLResponse? { + switch self { + case let .success(response, _): return response + case let .failure(_, response, _): return response + } + } + + /// The HTTP status code returned by the server, or 0 if the request failed completely. + public var status: Int { + return underlyingResponse?.statusCode ?? 0 + } + + /// All HTTP headers returned by the server. + public var headers: [AnyHashable : Any] { + return underlyingResponse?.allHeaderFields ?? [:] + } + + /// The response body decoded as a UTF-8 string. + /// Returns an empty string if there's no data or if decoding fails. + public var bodyString: String { + guard let data = self.data else { + log.warning("No data found on response: \(String(describing: self))") + return "" + } + guard let string = String(data: data, encoding: .utf8) else { + log.warning("Data is not UTF8: \(data.count) bytes") + return "" + } + return string + } + + /// The response body decoded as a JSON dictionary. + /// Returns an empty dictionary if there's no data, if JSON parsing fails, + /// or if the JSON is not a dictionary. + public var dictionaryFromJSON: [String: any Sendable] { + guard let data = self.data else { + log.warning("No data found on response: \(String(describing: self))") + return [:] + } + do { + guard let dictionary = try JSONSerialization.jsonObject(with: data, options: []) as? [String: any Sendable] else { + if let parsed = try? JSONSerialization.jsonObject(with: data, options: []) { + log.error("Failed to parse JSON as dictionary: \(String(describing: parsed))") + } + return [:] + } + return dictionary + } + catch { + let json = String(data: data, encoding: .utf8) ?? "" + log.error("Failed to parse JSON \(json): \(error)") + return [:] + } + } + + public var description: String { + switch self { + case let .success(response, data): + let dataSize = data?.count ?? 0 + return "" + case let .failure(error, response, data): + let status = response?.statusCode ?? 0 + let dataSize = data?.count ?? 0 + return "" + } + } +} diff --git a/Sources/Osiris/MultipartFormEncoder.swift b/Sources/Osiris/MultipartFormEncoder.swift index caf9e5b..1f35241 100644 --- a/Sources/Osiris/MultipartFormEncoder.swift +++ b/Sources/Osiris/MultipartFormEncoder.swift @@ -24,64 +24,190 @@ import Foundation extension MultipartFormEncoder { - struct BodyData { - let contentType: String - let data: Data + + /// 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 - var contentLength: Int { + /// The length of the encoded data in bytes. + public var contentLength: Int { data.count } + + public var description: String { + return "" + } } - struct BodyFile { - let contentType: String - let url: URL - let contentLength: Int64 + /// 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 { + return "" + } } - struct Part: Equatable { - enum Content: Equatable { + /// Represents a single part in a multipart form. + public struct Part: Equatable, Sendable, CustomStringConvertible { + + /// 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): + let preview = value.count > 50 ? "\(value.prefix(50))..." : value + return "" + case let .binaryData(data, type, filename): + return "" + case let .binaryFile(url, size, type, filename): + return "" + } + } } - let name: String - let content: Content + /// The form field name for this part. + public let name: String + + /// The content of this part. + public let content: Content - static func text(_ value: String, name: String) -> Part { + /// Creates a text part for the multipart form. + /// - Parameters: + /// - value: The text value to include + /// - name: The form field name + /// - Returns: A configured Part instance + public static func text(_ value: String, name: String) -> Part { Part(name: name, content: .text(value)) } - static func data(_ data: Data, name: String, type: String, filename: String) -> Part { + /// Creates a binary data part for the multipart form. + /// - Parameters: + /// - data: The binary data to include + /// - name: The form field name + /// - type: The MIME type of the data + /// - filename: The filename to report to the server + /// - Returns: A configured Part instance + public static func data(_ data: Data, name: String, type: String, filename: String) -> Part { Part(name: name, content: .binaryData(data, type: type, filename: filename)) } - static func file(_ url: URL, name: String, type: String, filename: String? = nil) throws -> Part { + /// Creates a file part for the multipart form by reading from disk. + /// - Parameters: + /// - url: The file URL to read from + /// - name: The form field name + /// - type: The MIME type of the file + /// - filename: The filename to report to the server (defaults to the file's name) + /// - Returns: A configured Part instance + /// - Throws: `Error.invalidFile` if the file cannot be read or sized + public static func file(_ url: URL, name: String, type: String, filename: String? = nil) throws -> Part { let attributes = try FileManager.default.attributesOfItem(atPath: url.path) guard let size = attributes[.size] as? Int64 else { throw Error.invalidFile(url) } return Part(name: name, content: .binaryFile(url, size: size, type: type, filename: filename ?? url.lastPathComponent)) } + + public var description: String { + return "" + } } } -final class MultipartFormEncoder { - enum Error: Swift.Error { +/// A multipart/form-data encoder that can encode forms either to memory or to files for streaming. +/// +/// This encoder supports text fields, binary data, and file uploads in a single multipart form. +/// It can encode forms either to memory (with a 50MB limit) or directly to temporary files for +/// streaming large amounts of data. +/// +/// ## Usage +/// +/// ```swift +/// let encoder = MultipartFormEncoder() +/// let parts: [MultipartFormEncoder.Part] = [ +/// .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): + return "" + case let .invalidOutputFile(url): + return "" + case .streamError: + return "MultipartFormEncoder.Error.streamError" + case .tooMuchDataForMemory: + return "MultipartFormEncoder.Error.tooMuchDataForMemory" + } + } } - let boundary: String + /// The boundary string used to separate parts in the multipart form. + public let boundary: String - init(boundary: String? = nil) { + /// Creates a new multipart form encoder. + /// - Parameter boundary: Optional custom boundary string. If nil, a unique boundary is generated. + public init(boundary: String? = nil) { self.boundary = boundary ?? "Osiris-\(UUID().uuidString)" } - func encodeData(parts: [Part]) throws -> BodyData { + /// Encodes the multipart form to memory as Data. + /// + /// This method has a hard limit of 50MB to prevent excessive memory usage. + /// For larger forms, use `encodeFile(parts:)` instead. + /// + /// - Parameter parts: The parts to include in the multipart form + /// - Returns: A BodyData containing the encoded form and content type + /// - Throws: `Error.tooMuchDataForMemory` if the total size exceeds 50MB, + /// or `Error.streamError` if encoding fails + public func encodeData(parts: [Part]) throws -> BodyData { let totalSize: Int64 = parts.reduce(0, { size, part in switch part.content { case let .text(string): @@ -116,7 +242,17 @@ final class MultipartFormEncoder { return BodyData(contentType: "multipart/form-data; boundary=\"\(boundary)\"", data: bodyData) } - func encodeFile(parts: [Part]) throws -> BodyFile { + /// Encodes the multipart form to a temporary file for streaming. + /// + /// This method is recommended for large forms or when memory usage is a concern. + /// The returned file should be streamed using an InputStream and then deleted when no longer needed. + /// + /// - Parameter parts: The parts to include in the multipart form + /// - Returns: A BodyFile containing the file URL, content type, and size + /// - Throws: `Error.invalidFile` if the output file cannot be created, + /// `Error.invalidOutputFile` if the file size cannot be determined, + /// or `Error.streamError` if encoding fails + public func encodeFile(parts: [Part]) throws -> BodyFile { let fm = FileManager.default let outputURL = tempFileURL() guard let stream = OutputStream(url: outputURL, append: false) else { @@ -223,4 +359,8 @@ final class MultipartFormEncoder { } } } + + public var description: String { + return "" + } } diff --git a/Sources/Osiris/RequestBuilder.swift b/Sources/Osiris/RequestBuilder.swift index 9b07fe3..ce641b9 100644 --- a/Sources/Osiris/RequestBuilder.swift +++ b/Sources/Osiris/RequestBuilder.swift @@ -5,45 +5,115 @@ // import Foundation +import OSLog -enum RequestBuilderError: Error { +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) } -final class RequestBuilder { - class func build(request: HTTPRequest) throws -> URLRequest { +/// Converts HTTPRequest instances to URLRequest for use with URLSession. +/// +/// RequestBuilder handles the encoding of different content types including JSON, +/// form-encoded parameters, and multipart forms. For multipart forms, it encodes +/// everything in memory, so consider using the MultipartFormEncoder directly for +/// large files that should be streamed. +/// +/// ## Usage +/// +/// ```swift +/// let httpRequest = HTTPRequest.post( +/// URL(string: "https://api.example.net/users")!, +/// 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) +/// // Handle response... +/// } +/// ``` +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 content type: + /// - `.json`: Parameters are encoded as JSON in the request body + /// - `.formEncoded`: Parameters are URL-encoded in the request body + /// - `.multipart`: Parts are encoded as multipart/form-data (in memory) + /// - `.none`: Falls back to form encoding for compatibility + /// + /// - Parameter request: The HTTPRequest to convert + /// - Returns: A URLRequest ready for URLSession + /// - Throws: `RequestBuilderError.invalidFormData` if form encoding fails, + /// or various encoding errors from JSONSerialization or MultipartFormEncoder + /// + /// - Note: GET and DELETE requests with parameters are not currently supported + /// - Warning: Multipart requests are encoded entirely in memory. For large files, + /// consider using MultipartFormEncoder.encodeFile() directly + public class func build(request: HTTPRequest) throws -> URLRequest { assert(!(request.method == .get && request.parameters != nil), "encoding GET params is not yet implemented") assert(!(request.method == .delete && request.parameters != nil), "encoding DELETE params is not yet implemented") + var result = URLRequest(url: request.url) result.httpMethod = request.method.string + for (name, value) in request.headers { result.addValue(value, forHTTPHeaderField: name) } - if let params = request.parameters { - switch request.contentType { - case .json: - result.addValue("application/json", forHTTPHeaderField: "Content-Type") - result.httpBody = try JSONSerialization.data(withJSONObject: params, options: []) - case .none: - // Fall back to form encoding for maximum compatibility. - assertionFailure("Cannot serialize parameters without a content type") - fallthrough - case .formEncoded: - result.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - guard let formData = FormEncoder.encode(params).data(using: .utf8) else { - throw RequestBuilderError.invalidFormData(request) - } - result.httpBody = formData - - case .multipart: - let encoder = MultipartFormEncoder() - let body = try encoder.encodeData(parts: request.parts) - result.addValue(body.contentType, forHTTPHeaderField: "Content-Type") - result.addValue("\(body.contentLength)", forHTTPHeaderField: "Content-Length") - result.httpBody = body.data + // When parts are provided then override to be multipart regardless of the content type. + if !request.parts.isEmpty || request.contentType == .multipart { + if request.contentType != .multipart { + log.info("Encoding request as multipart, overriding its content type of \(request.contentType)") } + try encodeMultipartContent(to: &result, request: request) + } else if let params = request.parameters { + try encodeParameters(to: &result, request: request, parameters: params) } + return result } + + private class func encodeMultipartContent(to urlRequest: inout URLRequest, request: HTTPRequest) throws { + let encoder = MultipartFormEncoder() + let body = try encoder.encodeData(parts: request.parts) + urlRequest.addValue(body.contentType, forHTTPHeaderField: "Content-Type") + 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 { + throw RequestBuilderError.invalidFormData(request) + } + urlRequest.httpBody = formData + } } diff --git a/Tests/OsirisTests/FormEncoderTests.swift b/Tests/OsirisTests/FormEncoderTests.swift new file mode 100644 index 0000000..efff177 --- /dev/null +++ b/Tests/OsirisTests/FormEncoderTests.swift @@ -0,0 +1,181 @@ +// +// FormEncoderTests.swift +// OsirisTests +// +// Created by Sami Samhuri on 2025-06-15. +// + +@testable import Osiris +import XCTest + +class FormEncoderTests: XCTestCase { + func testEncodeEmptyDictionary() { + 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] + let result = FormEncoder.encode(parameters) + // Order can vary, so check both possible orderings + let expected1 = "person%5Bage%5D=30&person%5Bname%5D=Jane" + 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"] + let personData: [String: any Sendable] = [ + "name": "Jane", + "preferences": preferences, + "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")) + XCTAssertTrue(result.contains("person%5Bpreferences%5D%5Bnotifications%5D=1")) + XCTAssertTrue(result.contains("person%5Bpreferences%5D%5Btheme%5D=dark")) + 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 "" 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": "!*'();:@&=+$,/?#[]"] + let result = FormEncoder.encode(parameters) + // 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"] + let parameters: [String: any Sendable] = [ + "string": "value", + "integer": 42, + "boolean": true, + "double": 3.14, + "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) + } +} + +// 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) + } +} diff --git a/Tests/OsirisTests/HTTPMethodTests.swift b/Tests/OsirisTests/HTTPMethodTests.swift new file mode 100644 index 0000000..cfcf0e3 --- /dev/null +++ b/Tests/OsirisTests/HTTPMethodTests.swift @@ -0,0 +1,19 @@ +// +// HTTPMethodTests.swift +// OsirisTests +// +// Created by Sami Samhuri on 2025-06-15. +// + +@testable import Osiris +import XCTest + +class HTTPMethodTests: XCTestCase { + func testHTTPMethodStrings() { + XCTAssertEqual(HTTPMethod.get.string, "GET") + XCTAssertEqual(HTTPMethod.post.string, "POST") + XCTAssertEqual(HTTPMethod.put.string, "PUT") + XCTAssertEqual(HTTPMethod.patch.string, "PATCH") + XCTAssertEqual(HTTPMethod.delete.string, "DELETE") + } +} diff --git a/Tests/OsirisTests/HTTPRequestErrorTests.swift b/Tests/OsirisTests/HTTPRequestErrorTests.swift new file mode 100644 index 0000000..dd98e07 --- /dev/null +++ b/Tests/OsirisTests/HTTPRequestErrorTests.swift @@ -0,0 +1,62 @@ +// +// HTTPRequestErrorTests.swift +// OsirisTests +// +// Created by Sami Samhuri on 2025-06-15. +// + +@testable import Osiris +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) + } + } +} \ No newline at end of file diff --git a/Tests/OsirisTests/HTTPRequestTests.swift b/Tests/OsirisTests/HTTPRequestTests.swift new file mode 100644 index 0000000..7dd2948 --- /dev/null +++ b/Tests/OsirisTests/HTTPRequestTests.swift @@ -0,0 +1,166 @@ +// +// HTTPRequestTests.swift +// OsirisTests +// +// Created by Sami Samhuri on 2025-06-15. +// + +@testable import Osiris +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) + XCTAssertEqual(request.url, baseURL) + XCTAssertEqual(request.contentType, .none) + XCTAssertNil(request.parameters) + 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") + } else { + 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 + } +} diff --git a/Tests/OsirisTests/HTTPResponseTests.swift b/Tests/OsirisTests/HTTPResponseTests.swift new file mode 100644 index 0000000..2371ac5 --- /dev/null +++ b/Tests/OsirisTests/HTTPResponseTests.swift @@ -0,0 +1,240 @@ +// +// HTTPResponseTests.swift +// OsirisTests +// +// Created by Sami Samhuri on 2025-06-15. +// + +@testable import Osiris +import XCTest + +class HTTPResponseTests: XCTestCase { + func testSuccessResponse() { + 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) + } else { + 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) + XCTAssertNil(responseData) + } else { + 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) + XCTAssertEqual(responseData, data) + } else { + 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") + } else { + 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 + 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 { + XCTFail("Status code \(statusCode) should be failure") + } + } + } + + 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) + let responseNonHTTP = HTTPResponse(response: fileResponse, data: nil, error: nil) + XCTAssertEqual(responseNonHTTP.status, 0) + } +} diff --git a/Tests/OsirisTests/RequestBuilderTests.swift b/Tests/OsirisTests/RequestBuilderTests.swift new file mode 100644 index 0000000..9bddae2 --- /dev/null +++ b/Tests/OsirisTests/RequestBuilderTests.swift @@ -0,0 +1,239 @@ +// +// RequestBuilderTests.swift +// OsirisTests +// +// Created by Sami Samhuri on 2025-06-15. +// + +@testable import Osiris +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 = [ + "X-Custom-Header": "value with spaces and symbols: !@#$%", + "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"] + let complexParams: [String: any Sendable] = [ + "person": [ + "name": "David Bowie", + "age": 69, + "preferences": nestedData, + "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")) + } + +}