Compare commits

...

15 commits
1.0.0 ... main

Author SHA1 Message Date
f8f91427ad
Rename License.txt to License.md 2025-06-29 13:44:42 -07:00
80bade5c28
Merge pull request #2 from samsonjs/codable-request
Add Codable support and overhaul the API
2025-06-24 00:09:27 -04:00
d2576b729e
Add Codable support and overhaul the API
This introduces a cleaner, more intuitive API for making HTTP requests
with explicit methods for different content types and built-in Codable
support.

**New**
  - Add explicit request methods: .postJSON(), .postForm(),
    .postMultipart() for clear intent
  - Add direct `Codable` body support with automatic JSON
    encoding/decoding
  - Add `HTTPRequestBody` enum for internal type safety and cleaner
    implementation
  - Add proper query parameter encoding for GET and DELETE requests
    (previously ignored)
  - Add URLSession extensions for streamlined async JSON decoding with
    `HTTPError` for failure response status codes
  - Add comprehensive test coverage

The new API replaces the parameter-based methods using dictionaries with
explicitly-typed ones. Instead of passing a content-type parameter, you
now use purpose-built methods like `postJSON` and `postForm`.

**Breaking changes**
  - Minimum deployment targets raised to iOS 16.0 and macOS 13.0
  - Direct access to `parameters` and `parts` properties deprecated on
    `HTTPRequest`
  - GET and DELETE requests now validate that they don't have request
    bodies, and the new API prevents you from constructing them
2025-06-23 23:55:55 -04:00
955cb19c98
Update changelog for unreleased changes 2025-06-15 17:03:10 -07:00
59a11b9cee
Prepare for 2.0.1 release 2025-06-15 17:02:10 -07:00
bc3ce2c93e
Fix stray question marks 2025-06-15 16:59:44 -07:00
bcf402db8f
Remove Linux testing cruft that we no longer need 2025-06-15 08:38:19 -07:00
9189796756
Fix readme formatting 2025-06-15 08:36:58 -07:00
374e456641
Update changelog for unreleased changes 2025-06-15 08:34:48 -07:00
ff3b0b6e12
Prepare for 2.0.0 release 2025-06-15 08:34:21 -07:00
310607ed00
Merge pull request #2 from samsonjs/modernize
Modernize and make it a real Swift package
2025-06-15 08:29:50 -07:00
77446ccf2d
Add support for query params on GET and DELETE requests 2025-06-15 08:26:22 -07:00
863d712e42
Remove unnecessary return statements 2025-06-15 08:18:43 -07:00
4615329e46
Modernize, add tests, remove assertions 2025-06-15 07:41:45 -07:00
41334e85e4
Add 0dependencies.dev badge 2024-10-31 11:55:45 -07:00
28 changed files with 3532 additions and 380 deletions

3
.gitignore vendored
View file

@ -1 +1,2 @@
.swiftpm .swiftpm
.build/

67
Changelog.md Normal file
View file

@ -0,0 +1,67 @@
# Changelog
## [2.1.0] - Unreleased
### Added
- Codable body support in HTTPRequest factory methods: `post(body:encoder:)`, `put(body:encoder:)`, `patch(body:encoder:)`
- URLSession extensions for automatic JSON decoding with custom decoder support
- HTTPError type with status code and response body for better error debugging
- Multipart convenience methods: `postMultipart()`, `putMultipart()`, `patchMultipart()`
- Explicit body encoding methods with clear naming: `postJSON()`, `postForm()`, etc.
- Query parameter support for GET and DELETE requests - parameters are now properly encoded as query strings
### Removed
- `CodableRequest<Response>` - Replaced with direct HTTPRequest Codable support for simplicity
### Changed
- Minimum deployment targets changed to ones that actually build: iOS 16.0 and macOS 13.0
- `HTTPRequest` now uses `HTTPRequestBody` enum internally for better type safety
- GET and DELETE requests now properly encode parameters as query strings instead of ignoring them
- Added validation to prevent GET/DELETE requests from having request bodies
### Deprecated
- `HTTPRequest.post(url:contentType:parameters:)` - Use `postJSON()` or `postForm()` instead
- `HTTPRequest.put(url:contentType:parameters:)` - Use `putJSON()` or `putForm()` instead
- `HTTPRequest.patch(url:contentType:parameters:)` - Use `patchJSON()` or `patchForm()` instead
- Direct access to `HTTPRequest.parameters` property
- Direct access to `HTTPRequest.parts` property
### Migration Guide
- Swap `HTTPRequest.post(url, contentType: .json, parameters: params)` for `HTTPRequest.postJSON(url, body: params)`
- Swap `HTTPRequest.post(url, contentType: .formEncoded, parameters: params)` for `HTTPRequest.postForm(url, parameters: params)`
- Swap `HTTPRequest.put(url, contentType: .json, parameters: params)` for `HTTPRequest.putJSON(url, body: params)`
- Swap `HTTPRequest.patch(url, contentType: .json, parameters: params)` for `HTTPRequest.patchJSON(url, body: params)`
- For multipart requests, use `HTTPRequest.postMultipart(url, parts: parts)` instead of setting the `parts` property directly
[2.1.0]: https://github.com/samsonjs/Osiris/compare/2.0.1...main
## [2.0.1] - 2025-06-15
### Fixed
- GET and DELETE requests with empty parameters no longer include unnecessary question mark in URL
[2.0.1]: https://github.com/samsonjs/Osiris/compare/2.0.0...2.0.1
## [2.0.0] - 2025-06-15
### Added
- **GET/DELETE query parameter support** - Parameters are now automatically encoded as query strings for GET and DELETE requests
- **Enhanced error types** with localized descriptions and failure reasons
- **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

View file

@ -1,23 +1,21 @@
// 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. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
let package = Package( let package = Package(
name: "Osiris", name: "Osiris",
platforms: [
.iOS(.v16),
.macOS(.v13),
],
products: [ products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library( .library(
name: "Osiris", name: "Osiris",
targets: ["Osiris"]), targets: ["Osiris"]),
], ],
dependencies: [ dependencies: [],
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
],
targets: [ targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target( .target(
name: "Osiris", name: "Osiris",
dependencies: []), dependencies: []),

390
Readme.md
View file

@ -1,130 +1,346 @@
# Osiris # Osiris
A multipart form encoder for Swift, as well as some other utilities that make [![0 dependencies!](https://0dependencies.dev/0dependencies.svg)](https://0dependencies.dev)
working with HTTP a bit simpler and more flexible. [![](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)
# Installation ## Overview
Copy the files you want to use into your project, and then customize them to suit your needs. Osiris is a Swift library that makes HTTP requests less ambiguous. It gives you multipart form encoding, cleaner abstractions for requests and responses, and stops you from wrangling optionals and errors manually. Instead you get types and an API that feels more Swifty.
# Multipart Form Encoding The main components are clean abstractions for HTTP requests and responses, and a `MultipartFormEncoder` that can encode forms either to memory or to files. The multipart encoder can stream data to files for large request bodies. Everything conforms to `CustomStringConvertible` with helpful descriptions so debugging with OSLog doesn't involve `String(describing:)` or other annoyances.
Create an encoder and then add parts to it as needed: For the most part Osiris strives you give you tools to make `URLSession` easier to use. However there are convenience methods to directly perform `HTTPRequest`s without building them yourself, and also to decode JSON response bodies. They're optional but you'll probably want to use them.
```Swift ## Installation
let avatarData = UIImage(from: somewhere).jpegData(compressionQuality: 1)
let encoder = MultipartFormEncoder() You can install Osiris using Swift Package Manager (SPM) or copy the files directly into your project and customize them as needed.
let body = try encoder.encodeData(parts: [
.text("somebody@example.com", name: "email"), ### Supported Platforms
.text("secret", name: "password"),
.data(Data(), name: "avatar", type: "image/jpeg", filename: "avatar.jpg"), This package supports iOS 16.0+ and macOS 13.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: "2.1.0"))
``` ```
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. and add `"Osiris"` to your target dependencies.
```Swift ## Usage
let body = try encoder.encodeFile(parts: [/* ... */])
var request = URLRequest(url: URL(string: "https://example.com/accounts")!) ### HTTPRequest with Codable Support
request.httpMethod = "POST"
request.httpBodyStream = InputStream(url: body.url) Automatic JSON encoding/decoding with URLSession extensions:
request.addValue(body.contentType, forHTTPHeaderField: "Content-Type")
request.addValue("\(body.contentLength)", forHTTPHeaderField: "Content-Length") ```swift
// ... whatever you normally do with requests import Osiris
let url = URL(string: "https://trails.example.net/riders")!
// GET request with automatic JSON decoding
let riders: [RiderProfile] = try await URLSession.shared.perform(.get(url))
// POST with Codable body and automatic response decoding
struct CreateRiderRequest: Codable {
let name: String
let email: String
let bike: String
}
let danny = CreateRiderRequest(name: "Danny MacAskill", email: "danny@trails.example.net", bike: "Santa Cruz 5010")
let created: RiderProfile = try await URLSession.shared.perform(.post(url, body: danny))
// Custom JSON encoding/decoding (e.g., snake_case)
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let customRequest = try HTTPRequest.post(url, body: danny, encoder: encoder)
let result: RiderProfile = try await URLSession.shared.perform(customRequest, decoder: decoder)
// For requests expecting no content (204, etc.)
try await URLSession.shared.perform(.delete(url.appendingPathComponent("123")))
``` ```
# HTTPRequest ### Basic HTTPRequest
Basic usage: For simple requests:
```Swift ```swift
let url = URL(string: "https://example.com")! import Osiris
let request = HTTPRequest(method: .get, url: url)
let url = URL(string: "https://example.net/kittens")!
// Basic GET request
let request = HTTPRequest.get(url)
// GET request with query parameters
let getRequest = HTTPRequest.get(url, parameters: ["page": 1, "limit": 10])
// DELETE request with query parameters
let deleteRequest = HTTPRequest.delete(url, parameters: ["confirm": "true"])
``` ```
Fancier usage: More complicated POST requests with bodies and headers:
```Swift ```swift
let url = URL(string: "https://example.com")! // POST with JSON body and a custom header
let params = ["email" : "someone@example.com", "password" : "secret"] let url = URL(string: "https://example.net/band")!
let request = HTTPRequest(method: .post, url: url, contentType: .json, parameters: params) let params = ["email": "fatmike@example.net", "password": "LinoleumSupportsMyHead"]
request.addHeader(name: "x-custom", value: "42") var jsonRequest = HTTPRequest.postJSON(url, body: params)
request.addMultipartJPEG(name: "avatar", image: UIImage(), quality: 1, filename: "avatar.jpg") jsonRequest.headers["x-the-answer"] = "42"
// POST with form-encoded body
let formRequest = HTTPRequest.postForm(url, parameters: params)
// POST with multipart body
let multipartRequest = HTTPRequest.postMultipart(url, parts: [.text("all day", name: "album")])
``` ```
You can build a `URLRequest` from an `HTTPRequest` instance using `RequestBuilder`. Or make your own builder. You can build a `URLRequest` from an `HTTPRequest` using `RequestBuilder`:
# HTTPResponse ```swift
let urlRequest = try RequestBuilder.build(request: request)
```
### More Codable Examples
HTTPRequest has built-in support for Codable request bodies:
```swift
struct Artist: Codable {
let name: String
let email: String
let genre: String
}
let url = URL(string: "https://beats.example.net/artists")!
let artist = Artist(name: "Trent Reznor", email: "trent@example.net", genre: "Industrial")
// POST with Codable body
let postRequest = try HTTPRequest.post(url, body: artist)
let created: Artist = try await URLSession.shared.perform(postRequest)
// PUT with Codable body
let putRequest = try HTTPRequest.put(url, body: artist)
let updated: Artist = try await URLSession.shared.perform(putRequest)
// PATCH with Codable body
let patchRequest = try HTTPRequest.patch(url, body: artist)
let patched: Artist = try await URLSession.shared.perform(patchRequest)
// Custom encoder for different JSON formatting
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
let customRequest = try HTTPRequest.post(url, body: artist, encoder: encoder)
```
### 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. 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?`. ```swift
let task = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
let httpResponse = HTTPResponse(response: response, data: data, error: error)
Some properties are exposed for convenience: switch httpResponse {
case let .success(httpURLResponse, data):
- `data`: the optional body data returned by the server. print("Success: \(httpURLResponse.statusCode)")
if let data = data {
- `status`: the HTTP status code returned by the server, or 0 if the request itself failed, e.g. if the server cannot be reached. print("Response: \(String(data: data, encoding: .utf8) ?? "")")
}
- `headers`: a dictionary of headers. case let .failure(error, httpURLResponse, data):
print("Failed: \(error)")
- `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. if let httpURLResponse = httpURLResponse {
print("Status: \(httpURLResponse.statusCode)")
- `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)
} }
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 URL-encoded form data encoder adapted from [Alamofire][]:
let body = FormEncoder.encode(["email" : "someone@example.com", "password" : "secret"])
// => "email=someone%40example.com&password=secret" ```swift
let body = FormEncoder.encode(["email": "trent@example.net", "password": "CloserToGod"])
// => "email=trent%40example.net&password=CloserToGod"
``` ```
[Alamofire]: https://github.com/Alamofire/Alamofire [Alamofire]: https://github.com/Alamofire/Alamofire
# Service: Putting it all Together ### Multipart Form Encoding
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. Create an encoder and then add parts to it as needed:
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
let avatarData = UIImage(systemName: "person.circle")?.jpegData(compressionQuality: 1.0)
let encoder = MultipartFormEncoder()
let body = try encoder.encodeData(parts: [
.text("chali@example.net", name: "email"),
.text("QualityControl", name: "password"),
.data(avatarData ?? Data(), name: "avatar", type: "image/jpeg", filename: "avatar.jpg"),
])
```
# Credits You can encode forms as `Data` in memory, or encode to a file which can then be streamed from disk. There's a 50 MB limit on in-memory encoding, but honestly you probably don't want to go anywhere near that. If you're dealing with images or video files, just encode to a file from the start.
Mostly created by Sami Samhuri for [1SE][]. `FormEncoder.swift` was lifted from [Alamofire][]. ```swift
let body = try encoder.encodeFile(parts: [
.text("chali@example.net", name: "email"),
.text("QualityControl", 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")
// Clean up the temporary file when done
defer { _ = body.cleanup() }
```
### Complete Example
Here's a realistic example with error handling:
```swift
import Osiris
struct ArtistProfile: Codable {
let name: String
let email: String
let genre: String
}
struct UpdateProfileRequest: Codable {
let name: String
let email: String
let genre: String
}
func updateProfile(name: String, email: String, genre: String) async throws -> ArtistProfile {
let url = URL(string: "https://beats.example.net/profile")!
let updateRequest = UpdateProfileRequest(name: name, email: email, genre: genre)
// Use Codable body instead of dictionary
let request = try HTTPRequest.put(url, body: updateRequest)
// URLSession extension handles status checking and JSON decoding
return try await URLSession.shared.perform(request)
}
// For varied data structures, dictionaries are still available as an escape hatch:
func updateProfileWithDictionary(fields: [String: String]) async throws -> ArtistProfile {
let url = URL(string: "https://beats.example.net/profile")!
let request = HTTPRequest.putJSON(url, body: fields)
return try await URLSession.shared.perform(request)
}
// Usage with error handling
do {
let profile = try await updateProfile(name: "Trent Reznor", email: "trent@example.net", genre: "Industrial")
print("Profile updated: \(profile)")
} catch let httpError as HTTPError {
switch httpError {
case let .failure(statusCode, data, _):
print("HTTP \(statusCode) error: \(String(data: data, encoding: .utf8) ?? "No body")")
case .invalidResponse:
print("Invalid response from server")
}
} catch is DecodingError {
print("Failed to decode response JSON")
} catch {
print("Network error: \(error)")
}
```
## Migration Guide
### Migrating from 2.0 to 2.1
Version 2.1.0 cleans up the API while keeping everything backward compatible. The old methods are deprecated and you'll get warnings, but they still work. We'll remove them in 3.0.
#### HTTPRequest Changes
**Old API (deprecated):**
```swift
// Parameters with content type
let request = HTTPRequest.post(url, contentType: .json, parameters: ["key": "value"])
let request = HTTPRequest.put(url, contentType: .formEncoded, parameters: params)
// Direct property access
var request = HTTPRequest.post(url)
request.parameters = ["key": "value"]
request.parts = [.text("value", name: "field")]
```
**New API:**
```swift
// Explicit methods for different encodings
let request = HTTPRequest.postJSON(url, body: ["key": "value"])
let request = HTTPRequest.putForm(url, parameters: params)
// Multipart convenience methods
let request = HTTPRequest.postMultipart(url, parts: [.text("value", name: "field")])
// Codable support
struct Artist: Codable {
let name: String
let genre: String
}
let request = try HTTPRequest.post(url, body: Artist(name: "Trent Reznor", genre: "Industrial"))
```
#### What's Different
1. **GET/DELETE Query Parameters**: You can now pass parameters to GET and DELETE requests and they'll be automatically encoded as query strings like they should have been all along, instead of having to build the URL yourself.
2. **Codable Support**: HTTPRequest now has direct Codable support and URLSession extensions for automatic JSON handling:
```swift
let request = try HTTPRequest.post(url, body: newArtist)
let response: ArtistResponse = try await URLSession.shared.perform(request)
```
3. **Clearer Method Names**: Methods like `postJSON()` and `postForm()` tell you exactly what encoding you're getting.
#### Migration Steps
1. Update to version 2.1
2. Fix the deprecation warnings by swapping old method calls for new ones
3. You can now pass query parameters to GET/DELETE requests instead of adding them to the URL yourself
4. Take advantage of the built-in Codable support for type-safe request bodies
## Credits
Originally created by [@samsonjs][] for [1 Second Everyday][1SE]. `FormEncoder.swift` was adapted from [Alamofire][].
[1SE]: https://1se.co [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: [MIT]: https://sjs.mit-license.org
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.

View file

@ -2,10 +2,15 @@
// Created by Sami Samhuri on 2016-07-30. // Created by Sami Samhuri on 2016-07-30.
// Copyright © 2016 1 Second Everyday. All rights reserved. // 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 import UIKit
private let log = Logger(subsystem: "net.samhuri.Osiris", category: "Service")
enum ServiceError: Error { enum ServiceError: Error {
case malformedRequest(HTTPRequest) case malformedRequest(HTTPRequest)
case malformedResponse(message: String) case malformedResponse(message: String)
@ -27,7 +32,6 @@ enum ServiceEnvironment: String {
return selected return selected
} }
set { set {
assert(Thread.isMainThread)
guard newValue != selected else { guard newValue != selected else {
return return
} }
@ -37,9 +41,9 @@ enum ServiceEnvironment: String {
var baseURL: URL { var baseURL: URL {
switch self { switch self {
case .production: return URL(string: "https://example.com")! case .production: return URL(string: "https://example.net")!
case .staging: return URL(string: "https://staging.example.com")! case .staging: return URL(string: "https://staging.example.net")!
case .development: return URL(string: "https://dev.example.com")! case .development: return URL(string: "https://dev.example.net")!
} }
} }
} }
@ -51,8 +55,7 @@ final class Service {
init(environment: ServiceEnvironment, urlSessionConfig: URLSessionConfiguration? = nil) { init(environment: ServiceEnvironment, urlSessionConfig: URLSessionConfiguration? = nil) {
self.environment = environment self.environment = environment
self.urlSession = URLSession(configuration: .urlSessionConfig ?? .default) self.urlSession = URLSession(configuration: urlSessionConfig ?? .default)
super.init()
} }
func reconfigure(environment: ServiceEnvironment, urlSessionConfig: URLSessionConfiguration? = nil) { func reconfigure(environment: ServiceEnvironment, urlSessionConfig: URLSessionConfiguration? = nil) {
@ -73,53 +76,53 @@ final class Service {
// MARK: - Your service calls here // MARK: - Your service calls here
// For example... (you probably want a more specific result type unpacked from the response though) // For example... (you probably want a more specific result type unpacked from the response though)
func signUp(email: String, password: String, avatar: UIImage) -> Promise<HTTPResponse> { func signUp(email: String, password: String, avatar: UIImage) async throws -> HTTPResponse {
let parameters = ["email" : email, "password" : password] let parameters = ["email" : email, "password" : password]
let url = environment.baseURL.appendingPathComponent("accounts") let url = environment.baseURL.appendingPathComponent("accounts")
var request = HTTPRequest.post(url, contentType: .formEncoded, parameters: parameters) var request = HTTPRequest.post(url, contentType: .formEncoded, parameters: parameters)
request.addMultipartJPEG(name: "avatar", image: avatar, quality: 1) request.addMultipartJPEG(name: "avatar", image: avatar, quality: 1)
return performRequest(request) return try await performRequest(request)
} }
// MARK: - Requests // MARK: - Requests
fileprivate func deleteRequest(path: String, parameters: [String : Any]? = nil) -> HTTPRequest { fileprivate func delete(_ path: String, parameters: [String: any Sendable]? = nil) -> HTTPRequest {
return newRequest(method: .delete, path: path, parameters: parameters) newRequest(method: .delete, path: path, parameters: parameters)
} }
fileprivate func getRequest(path: String) -> HTTPRequest { fileprivate func get(_ path: String) -> HTTPRequest {
return newRequest(method: .get, path: path) newRequest(method: .get, path: path)
} }
fileprivate func patchRequest(path: String, parameters: [String : Any]) -> HTTPRequest { fileprivate func patch(_ path: String, parameters: [String: any Sendable]) -> HTTPRequest {
return newRequest(method: .patch, path: path, contentType: .formEncoded, parameters: parameters) newRequest(method: .patch, path: path, contentType: .formEncoded, parameters: parameters)
} }
fileprivate func postJSONRequest(path: String, parameters: [String : Any]) -> HTTPRequest { fileprivate func postJSON(_ path: String, parameters: [String: any Sendable]) -> HTTPRequest {
return newRequest(method: .post, path: path, contentType: .json, parameters: parameters) newRequest(method: .post, path: path, contentType: .json, parameters: parameters)
} }
fileprivate func postRequest(path: String, parameters: [String : Any]) -> HTTPRequest { fileprivate func post(_ path: String, parameters: [String: any Sendable]) -> HTTPRequest {
return newRequest(method: .post, path: path, contentType: .formEncoded, parameters: parameters) newRequest(method: .post, path: path, contentType: .formEncoded, parameters: parameters)
} }
fileprivate func putJSONRequest(path: String, parameters: [String : Any]) -> HTTPRequest { fileprivate func putJSON(_ path: String, parameters: [String: any Sendable]) -> HTTPRequest {
return newRequest(method: .put, path: path, contentType: .json, parameters: parameters) newRequest(method: .put, path: path, contentType: .json, parameters: parameters)
} }
fileprivate func putRequest(path: String, parameters: [String : Any]) -> HTTPRequest { fileprivate func put(_ path: String, parameters: [String: any Sendable]) -> HTTPRequest {
return newRequest(method: .put, path: path, contentType: .formEncoded, parameters: parameters) 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) let url = environment.baseURL.appendingPathComponent(path)
return newRequest(method: method, url: url, contentType: contentType, parameters: parameters) 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) let request = HTTPRequest(method: method, url: url, contentType: contentType, parameters: parameters)
// Authorize requests to our service automatically. // Authorize requests to your service automatically.
if let token = self.token, url.hasBaseURL(environment.baseURL) { if let token = self.token, url.hasBaseURL(environment.baseURL) {
authorizeRequest(request, token: token) authorizeRequest(request, token: token)
} }
@ -129,44 +132,24 @@ final class Service {
fileprivate func authorizeRequest(_ request: HTTPRequest, token: String) { fileprivate func authorizeRequest(_ request: HTTPRequest, token: String) {
let encodedCredentials = "api:\(token)".base64 let encodedCredentials = "api:\(token)".base64
let basicAuth = "Basic \(encodedCredentials)" let basicAuth = "Basic \(encodedCredentials)"
request.addHeader(name: "Authorization", value: basicAuth) request.headers["Authorization"] = basicAuth
} }
func performRequest(_ request: HTTPRequest) -> Promise<HTTPResponse> { func performRequest<Response: Decodable>(_ request: HTTPRequest) async throws -> Response {
let urlRequest: URLRequest try await urlSession.perform(RequestBuilder.build(request: request))
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()
}
} }
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 return parameters.reduce([:], { params, param in
var params = params var params = params
let (name, value) = param let (name, value) = param
let isBlacklisted = self.isBlacklisted(url: url, paramName: name) let isSensitive = self.isSensitive(url: url, paramName: name)
params[name] = isBlacklisted ? "<secret>" : value params[name] = isSensitive ? "<secret>" : value
return params return params
}) })
} }
private func isBlacklisted(url: URL, paramName: String) -> Bool { private func isSensitive(url: URL, paramName: String) -> Bool {
return paramName.contains("password") return paramName.contains("password")
} }

View file

@ -5,6 +5,7 @@
import Foundation import Foundation
extension NSNumber { extension NSNumber {
/// [From Argo](https://github.com/thoughtbot/Argo/blob/3da833411e2633bc01ce89542ac16803a163e0f0/Argo/Extensions/NSNumber.swift) /// [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. /// - 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 { /// URL-encoded form data encoder adapted from Alamofire.
class func encode(_ parameters: [String: Any]) -> String { ///
/// 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)] = [] var components: [(String, String)] = []
for key in parameters.keys.sorted(by: <) { for key in parameters.keys.sorted(by: <) {
@ -33,7 +66,7 @@ final class FormEncoder {
static func pairs(from key: String, value: Any) -> [(String, String)] { static func pairs(from key: String, value: Any) -> [(String, String)] {
var components: [(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 { for (nestedKey, value) in dictionary {
components += pairs(from: "\(key)[\(nestedKey)]", value: value) components += pairs(from: "\(key)[\(nestedKey)]", value: value)
} }
@ -86,4 +119,8 @@ final class FormEncoder {
let escaped = string.addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) ?? string let escaped = string.addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) ?? string
return escaped return escaped
} }
public var description: String {
"FormEncoder"
}
} }

View file

@ -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) ?? "<invalid data>"
NSLog("[ERROR] Failed to parse JSON \(json): \(error)")
return [:]
}
}
}

View file

@ -0,0 +1,41 @@
//
// 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, Equatable, 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
/// Custom content type with arbitrary MIME type
case custom(String)
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"
case .custom(let mimeType):
return mimeType
}
}
}

View file

@ -0,0 +1,50 @@
//
// Created by Sami Samhuri on 2025-06-23.
// Copyright © 2025 Sami Samhuri. All rights reserved.
// Released under the terms of the MIT license.
//
import Foundation
/// Errors that can occur during HTTP operations, outside of the network layer which is already covered by `URLError`.
public enum HTTPError: Error {
/// The server returned a non-success HTTP status code.
case failure(statusCode: Int, data: Data, response: HTTPURLResponse)
/// The response was not a valid HTTP response.
case invalidResponse
}
private extension String {
func truncated(to maxCharacters: Int = 50) -> String {
count < 50 ? self : "\(prefix(maxCharacters))..."
}
}
extension HTTPError: LocalizedError {
public var errorDescription: String? {
switch self {
case let .failure(statusCode, data, _):
let bodyString = String(data: data, encoding: .utf8) ?? "<non-UTF8 data>"
return "HTTP \(statusCode) error. Response body: \(bodyString.truncated())"
case .invalidResponse:
return "Invalid HTTP response"
}
}
}
extension HTTPError: CustomDebugStringConvertible {
public var debugDescription: String {
switch self {
case let .failure(statusCode, data, response):
let bodyString = String(data: data, encoding: .utf8) ?? "<\(data.count) bytes of non-UTF8 data>"
return """
HTTPError: \(statusCode) \(HTTPURLResponse.localizedString(forStatusCode: statusCode))
URL: \(response.url?.absoluteString ?? "nil")
Body: \(bodyString.truncated())
"""
case .invalidResponse:
return "HTTPError: Invalid HTTP response"
}
}
}

View file

@ -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 {
rawValue.uppercased()
}
public var description: String {
string
}
}

View file

@ -0,0 +1,568 @@
//
// 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
import UniformTypeIdentifiers
#if canImport(UIKit)
import UIKit
#endif
private let log = Logger(subsystem: "net.samhuri.Osiris", category: "HTTPRequest")
/// The body content of an HTTP request.
public enum HTTPRequestBody: Sendable {
/// No body content.
case none
/// Parameters to be encoded as form data.
case formParameters([String: any Sendable])
/// Parameters to be encoded as JSON.
case jsonParameters([String: any Sendable])
/// Raw data with specified content type.
case data(Data, contentType: UTType)
/// Multipart form data parts.
case multipart([MultipartFormEncoder.Part])
/// File data to be streamed from disk.
case fileData(URL)
/// Returns true if this body represents no content.
public var isEmpty: Bool {
switch self {
case .none:
return true
default:
return false
}
}
}
/// 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
/// // GET request with query parameters
/// let getRequest = HTTPRequest.get(
/// URL(string: "https://api.example.net/users")!,
/// parameters: ["page": "1", "limit": "10"]
/// )
///
/// // POST with JSON body
/// let jsonRequest = HTTPRequest.postJSON(
/// URL(string: "https://api.example.net/users")!,
/// body: ["name": "Chali 2na", "email": "chali@example.net"]
/// )
///
/// // DELETE with query parameters
/// let deleteRequest = HTTPRequest.delete(
/// URL(string: "https://api.example.net/users/123")!,
/// parameters: ["confirm": "true"]
/// )
///
/// // Multipart form with file upload
/// let uploadURL = URL(string: "https://api.example.net/upload")!
/// let multipartRequest = HTTPRequest.postMultipart(uploadURL, parts: [
/// .text("Trent Reznor", name: "name"),
/// .data(imageData, name: "avatar", type: "image/jpeg", filename: "avatar.jpg")
/// ])
///
/// // File streaming for large request bodies
/// let fileRequest = HTTPRequest.postFile(
/// URL(string: "https://api.example.net/upload")!,
/// fileURL: URL(fileURLWithPath: "/path/to/large/file.zip")
/// )
///
/// // Custom content types like XML
/// let xmlData = "<request><artist>Nine Inch Nails</artist></request>".data(using: .utf8)!
/// let xmlRequest = HTTPRequest.post(
/// URL(string: "https://api.example.net/music")!,
/// data: xmlData,
/// contentType: .xml
/// )
/// ```
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 body content for this request.
public var body: HTTPRequestBody
/// Additional HTTP headers for the request.
public var headers: [String: String] = [:]
// MARK: - Deprecated Properties
/// Parameters to be encoded according to the content type.
@available(*, deprecated, message: "Access parameters through the body property instead")
public var parameters: [String: any Sendable]? {
get {
switch body {
case .formParameters(let params), .jsonParameters(let params):
return params
default:
return nil
}
}
set {
guard let newValue = newValue else {
body = .none
return
}
// Preserve existing encoding type if possible
switch body {
case .jsonParameters:
body = .jsonParameters(newValue)
case .formParameters, .none:
body = .formParameters(newValue)
default:
// Can't set parameters on other body types
break
}
}
}
/// Multipart form parts.
@available(*, deprecated, message: "Use postMultipart(_:parts:) or access parts through the body property")
public var parts: [MultipartFormEncoder.Part] {
get {
if case .multipart(let parts) = body {
return parts
}
return []
}
set {
body = .multipart(newValue)
}
}
/// The content type for the request body (computed from body content).
public var contentType: HTTPContentType {
switch body {
case .none:
return .none
case .formParameters:
// For GET/DELETE, parameters go in query string, so no body content type
if method == .get || method == .delete {
return .none
}
return .formEncoded
case .jsonParameters:
// For GET/DELETE, parameters go in query string, so no body content type
if method == .get || method == .delete {
return .none
}
return .json
case let .data(_, contentType):
if let mimeType = contentType.preferredMIMEType {
return .custom(mimeType)
} else {
log.warning("No MIME type found for UTType \(contentType), falling back to application/octet-stream")
return .custom("application/octet-stream")
}
case .multipart:
return .multipart
case .fileData:
return .none
}
}
/// Creates a new HTTP request, verifying that no body is provided for GET and DELETE requests. When a body is provided with
/// those requests then an error is thrown.
/// - Parameters:
/// - method: The HTTP method to use
/// - url: The target URL
/// - body: The body content for the request
/// - Throws: HTTPRequestError.invalidRequestBody if GET or DELETE request has a body
public init(method: HTTPMethod, url: URL, body: HTTPRequestBody = .none) throws {
guard method != .get && method != .delete || body.isEmpty else {
throw HTTPRequestError.invalidRequestBody
}
self.method = method
self.url = url
self.body = body
}
/// Internal initializer that bypasses validation for convenience methods that we don't want to be throwing in the public API.
/// - Parameters:
/// - method: The HTTP method to use
/// - url: The target URL
/// - body: The body content for the request
init(uncheckedMethod method: HTTPMethod, url: URL, body: HTTPRequestBody = .none) {
self.method = method
self.url = url
self.body = body
}
/// Creates a new HTTP request with the old API.
/// - 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
@available(*, deprecated, message: "Use the new initializer or convenience methods like postJSON() instead. Note: GET/DELETE with parameters now use query strings.")
public init(method: HTTPMethod, url: URL, contentType: HTTPContentType = .none, parameters: [String: any Sendable]? = nil) {
self.method = method
self.url = url
// Convert old API to new body format
if let parameters = parameters {
if method == .get || method == .delete {
// For backward compatibility, GET/DELETE with parameters were previously ignored
// Now they're encoded as query parameters via formParameters
self.body = .formParameters(parameters)
} else {
switch contentType {
case .json:
self.body = .jsonParameters(parameters)
case .formEncoded:
self.body = .formParameters(parameters)
default:
self.body = .formParameters(parameters)
}
}
} else {
self.body = .none
}
}
/// Creates a GET request.
/// - Parameters:
/// - url: The target URL
/// - parameters: Optional parameters to include as query string
/// - Returns: A configured HTTPRequest
public static func get(_ url: URL, parameters: [String: any Sendable]? = nil) -> HTTPRequest {
let body: HTTPRequestBody = parameters.map { .formParameters($0) } ?? .none
return HTTPRequest(uncheckedMethod: .get, url: url, body: body)
}
/// Creates a PUT request with a URL-encoded form body.
/// - Parameters:
/// - url: The target URL
/// - parameters: Parameters to encode as URL-encoded form body
/// - Returns: A configured HTTPRequest
public static func putForm(_ url: URL, parameters: [String: any Sendable]? = nil) -> HTTPRequest {
let body: HTTPRequestBody = parameters.map { .formParameters($0) } ?? .none
return HTTPRequest(uncheckedMethod: .put, url: url, body: body)
}
/// Creates a PUT request with a JSON body.
/// - Parameters:
/// - url: The target URL
/// - body: Dictionary to encode as JSON body
/// - Returns: A configured HTTPRequest
public static func putJSON(_ url: URL, body: [String: any Sendable]) -> HTTPRequest {
HTTPRequest(uncheckedMethod: .put, url: url, body: .jsonParameters(body))
}
/// Creates a POST request with a URL-encoded form body.
/// - Parameters:
/// - url: The target URL
/// - parameters: Parameters to encode as URL-encoded form body
/// - Returns: A configured HTTPRequest
public static func postForm(_ url: URL, parameters: [String: any Sendable]? = nil) -> HTTPRequest {
let body: HTTPRequestBody = parameters.map { .formParameters($0) } ?? .none
return HTTPRequest(uncheckedMethod: .post, url: url, body: body)
}
/// Creates a POST request with a JSON body.
/// - Parameters:
/// - url: The target URL
/// - body: Dictionary to encode as JSON body
/// - Returns: A configured HTTPRequest
public static func postJSON(_ url: URL, body: [String: any Sendable]) -> HTTPRequest {
HTTPRequest(uncheckedMethod: .post, url: url, body: .jsonParameters(body))
}
/// Creates a DELETE request.
/// - Parameters:
/// - url: The target URL
/// - parameters: Optional parameters to include as query string
/// - Returns: A configured HTTPRequest
public static func delete(_ url: URL, parameters: [String: any Sendable]? = nil) -> HTTPRequest {
let body: HTTPRequestBody = parameters.map { .formParameters($0) } ?? .none
return HTTPRequest(uncheckedMethod: .delete, url: url, body: body)
}
/// Creates a basic POST request with no body.
/// - Parameters:
/// - url: The target URL
/// - Returns: A configured HTTPRequest
public static func post(_ url: URL) -> HTTPRequest {
HTTPRequest(uncheckedMethod: .post, url: url, body: .none)
}
/// Creates a basic PUT request with no body.
/// - Parameters:
/// - url: The target URL
/// - Returns: A configured HTTPRequest
public static func put(_ url: URL) -> HTTPRequest {
HTTPRequest(uncheckedMethod: .put, url: url, body: .none)
}
/// Creates a basic PATCH request with no body.
/// - Parameters:
/// - url: The target URL
/// - Returns: A configured HTTPRequest
public static func patch(_ url: URL) -> HTTPRequest {
HTTPRequest(uncheckedMethod: .patch, url: url, body: .none)
}
/// Creates a POST request with a Codable body.
/// - Parameters:
/// - url: The target URL
/// - body: The Codable object to encode as JSON
/// - encoder: The JSONEncoder to use (defaults to standard encoder)
/// - Returns: A configured HTTPRequest
/// - Throws: EncodingError if the body cannot be encoded
public static func post<T: Codable>(_ url: URL, body: T, encoder: JSONEncoder = JSONEncoder()) throws -> HTTPRequest {
let jsonData = try encoder.encode(body)
return HTTPRequest(uncheckedMethod: .post, url: url, body: .data(jsonData, contentType: .json))
}
/// Creates a PUT request with a Codable body.
/// - Parameters:
/// - url: The target URL
/// - body: The Codable object to encode as JSON
/// - encoder: The JSONEncoder to use (defaults to standard encoder)
/// - Returns: A configured HTTPRequest
/// - Throws: EncodingError if the body cannot be encoded
public static func put<T: Codable>(_ url: URL, body: T, encoder: JSONEncoder = JSONEncoder()) throws -> HTTPRequest {
let jsonData = try encoder.encode(body)
return HTTPRequest(uncheckedMethod: .put, url: url, body: .data(jsonData, contentType: .json))
}
/// Creates a PATCH request with a URL-encoded form body.
/// - Parameters:
/// - url: The target URL
/// - parameters: Parameters to encode as URL-encoded form body
/// - Returns: A configured HTTPRequest
public static func patchForm(_ url: URL, parameters: [String: any Sendable]? = nil) -> HTTPRequest {
let body: HTTPRequestBody = parameters.map { .formParameters($0) } ?? .none
return HTTPRequest(uncheckedMethod: .patch, url: url, body: body)
}
/// Creates a PATCH request with a JSON body.
/// - Parameters:
/// - url: The target URL
/// - body: Dictionary to encode as JSON body
/// - Returns: A configured HTTPRequest
public static func patchJSON(_ url: URL, body: [String: any Sendable]) -> HTTPRequest {
HTTPRequest(uncheckedMethod: .patch, url: url, body: .jsonParameters(body))
}
/// Creates a PATCH request with a Codable body.
/// - Parameters:
/// - url: The target URL
/// - body: The Codable object to encode as JSON
/// - encoder: The JSONEncoder to use (defaults to standard encoder)
/// - Returns: A configured HTTPRequest
/// - Throws: EncodingError if the body cannot be encoded
public static func patch<T: Codable>(_ url: URL, body: T, encoder: JSONEncoder = JSONEncoder()) throws -> HTTPRequest {
let jsonData = try encoder.encode(body)
return HTTPRequest(uncheckedMethod: .patch, url: url, body: .data(jsonData, contentType: .json))
}
/// Creates a POST request with multipart form data.
/// - Parameters:
/// - url: The target URL
/// - parts: The multipart form parts
/// - Returns: A configured HTTPRequest
public static func postMultipart(_ url: URL, parts: [MultipartFormEncoder.Part]) -> HTTPRequest {
HTTPRequest(uncheckedMethod: .post, url: url, body: .multipart(parts))
}
/// Creates a PUT request with multipart form data.
/// - Parameters:
/// - url: The target URL
/// - parts: The multipart form parts
/// - Returns: A configured HTTPRequest
public static func putMultipart(_ url: URL, parts: [MultipartFormEncoder.Part]) -> HTTPRequest {
HTTPRequest(uncheckedMethod: .put, url: url, body: .multipart(parts))
}
/// Creates a PATCH request with multipart form data.
/// - Parameters:
/// - url: The target URL
/// - parts: The multipart form parts
/// - Returns: A configured HTTPRequest
public static func patchMultipart(_ url: URL, parts: [MultipartFormEncoder.Part]) -> HTTPRequest {
HTTPRequest(uncheckedMethod: .patch, url: url, body: .multipart(parts))
}
/// Creates a POST request with raw data and content type.
/// - Parameters:
/// - url: The target URL
/// - data: The raw data to send
/// - contentType: The uniform type identifier for the data
/// - Returns: A configured HTTPRequest
public static func post(_ url: URL, data: Data, contentType: UTType) -> HTTPRequest {
HTTPRequest(uncheckedMethod: .post, url: url, body: .data(data, contentType: contentType))
}
/// Creates a PUT request with raw data and content type.
/// - Parameters:
/// - url: The target URL
/// - data: The raw data to send
/// - contentType: The uniform type identifier for the data
/// - Returns: A configured HTTPRequest
public static func put(_ url: URL, data: Data, contentType: UTType) -> HTTPRequest {
HTTPRequest(uncheckedMethod: .put, url: url, body: .data(data, contentType: contentType))
}
/// Creates a PATCH request with raw data and content type.
/// - Parameters:
/// - url: The target URL
/// - data: The raw data to send
/// - contentType: The uniform type identifier for the data
/// - Returns: A configured HTTPRequest
public static func patch(_ url: URL, data: Data, contentType: UTType) -> HTTPRequest {
HTTPRequest(uncheckedMethod: .patch, url: url, body: .data(data, contentType: contentType))
}
/// Creates a POST request with file data to be streamed from disk.
/// - Parameters:
/// - url: The target URL
/// - fileURL: The file URL to stream as the request body
/// - Returns: A configured HTTPRequest
public static func postFile(_ url: URL, fileURL: URL) -> HTTPRequest {
HTTPRequest(uncheckedMethod: .post, url: url, body: .fileData(fileURL))
}
/// Creates a PUT request with file data to be streamed from disk.
/// - Parameters:
/// - url: The target URL
/// - fileURL: The file URL to stream as the request body
/// - Returns: A configured HTTPRequest
public static func putFile(_ url: URL, fileURL: URL) -> HTTPRequest {
HTTPRequest(uncheckedMethod: .put, url: url, body: .fileData(fileURL))
}
/// Creates a PATCH request with file data to be streamed from disk.
/// - Parameters:
/// - url: The target URL
/// - fileURL: The file URL to stream as the request body
/// - Returns: A configured HTTPRequest
public static func patchFile(_ url: URL, fileURL: URL) -> HTTPRequest {
HTTPRequest(uncheckedMethod: .patch, url: url, body: .fileData(fileURL))
}
// MARK: - Deprecated Methods
/// Creates a POST request with parameters.
/// - 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
@available(*, deprecated, message: "Use postJSON(_:body:) or postForm(_:parameters:) instead")
public static func post(_ url: URL, contentType: HTTPContentType = .none, parameters: [String: any Sendable]? = nil) -> HTTPRequest {
guard let parameters = parameters else {
return HTTPRequest(uncheckedMethod: .post, url: url)
}
switch contentType {
case .json:
return postJSON(url, body: parameters)
case .formEncoded:
return postForm(url, parameters: parameters)
default:
// For backward compatibility, treat as form encoded
return postForm(url, parameters: parameters)
}
}
/// Creates a PUT request with parameters.
/// - 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
@available(*, deprecated, message: "Use putJSON(_:body:) or putForm(_:parameters:) instead")
public static func put(_ url: URL, contentType: HTTPContentType = .none, parameters: [String: any Sendable]? = nil) -> HTTPRequest {
guard let parameters = parameters else {
return HTTPRequest(uncheckedMethod: .put, url: url)
}
switch contentType {
case .json:
return putJSON(url, body: parameters)
case .formEncoded:
return putForm(url, parameters: parameters)
default:
// For backward compatibility, treat as form encoded
return putForm(url, parameters: parameters)
}
}
/// Creates a PATCH request with parameters.
/// - 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
@available(*, deprecated, message: "Use patchJSON(_:body:) or patchForm(_:parameters:) instead")
public static func patch(_ url: URL, contentType: HTTPContentType = .none, parameters: [String: any Sendable]? = nil) -> HTTPRequest {
guard let parameters = parameters else {
return HTTPRequest(uncheckedMethod: .patch, url: url)
}
switch contentType {
case .json:
return patchJSON(url, body: parameters)
case .formEncoded:
return patchForm(url, parameters: parameters)
default:
// For backward compatibility, treat as form encoded
return patchForm(url, parameters: parameters)
}
}
#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
}
let newPart = MultipartFormEncoder.Part.data(data, name: name, type: "image/jpeg", filename: filename ?? "image.jpeg")
switch body {
case let .multipart(existingParts):
body = .multipart(existingParts + [newPart])
default:
body = .multipart([newPart])
}
}
#endif
/// Adds a header to this request. Deprecated in favour of directly modifying the ``headers`` dictionary.
/// - Parameters:
/// - name: The header name
/// - value: The header value
@available(*, deprecated, message: "Modify the headers dictionary directly on this request instead.")
public mutating func addHeader(name: String, value: String) {
headers[name] = value
}
public var description: String {
"<HTTPRequest \(method) \(url)>"
}
}

View file

@ -0,0 +1,64 @@
//
// 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
/// Invalid request body for the HTTP method.
case invalidRequestBody
public var errorDescription: String? {
switch self {
case .http:
return "HTTP request failed with non-2xx status code"
case .unknown:
return "An unknown error occurred"
case .invalidRequestBody:
return "GET and DELETE requests cannot have a body"
}
}
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"
case .invalidRequestBody:
return "The HTTP method does not support a request body"
}
}
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"
case .invalidRequestBody:
return "Use query parameters instead of a request body for GET and DELETE requests"
}
}
public var description: String {
switch self {
case .http:
"HTTPRequestError.http"
case .unknown:
"HTTPRequestError.unknown"
case .invalidRequestBody:
"HTTPRequestError.invalidRequestBody"
}
}
}

View file

@ -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: "net.samhuri.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 let .success(httpURLResponse, data):
/// print("Success: \(httpURLResponse.statusCode)")
/// // Handle successful response
/// case let .failure(error, httpURLResponse, 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 {
underlyingResponse?.statusCode ?? 0
}
/// All HTTP headers returned by the server.
public var headers: [AnyHashable : Any] {
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) ?? "<invalid data>"
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 "<HTTPResponse.success status=\(response.statusCode) size=\(dataSize)>"
case let .failure(error, response, data):
let status = response?.statusCode ?? 0
let dataSize = data?.count ?? 0
return "<HTTPResponse.failure error=\(error) status=\(status) size=\(dataSize)>"
}
}
}

View file

@ -24,64 +24,205 @@
import Foundation import Foundation
extension MultipartFormEncoder { extension MultipartFormEncoder {
struct BodyData {
let contentType: String
let data: Data
var contentLength: Int { /// 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
/// The length of the encoded data in bytes.
public var contentLength: Int {
data.count data.count
} }
public var description: String {
"<BodyData size=\(contentLength)>"
}
} }
struct BodyFile { /// Contains the encoded multipart form data written to a file for streaming.
let contentType: String public struct BodyFile: CustomStringConvertible {
let url: URL
let contentLength: Int64
}
struct Part: Equatable { /// The content type header value including boundary.
enum Content: Equatable { public let contentType: String
case text(String)
case binaryData(Data, type: String, filename: String) /// The URL of the temporary file containing the encoded data.
case binaryFile(URL, size: Int64, type: String, filename: String) 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)>"
} }
let name: String /// Removes the temporary file from disk.
let content: Content ///
/// Call this method when you're done with the file to clean up disk space.
/// This is especially important for large files or when creating many requests.
///
/// - Returns: `true` if the file was successfully removed, `false` otherwise
public func cleanup() -> Bool {
do {
try FileManager.default.removeItem(at: url)
return true
} catch {
return false
}
}
}
static func text(_ value: String, name: String) -> Part { /// 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 "<Content.text value=\"\(preview)\">"
case let .binaryData(data, type, filename):
return "<Content.binaryData size=\(data.count) type=\(type) filename=\(filename)>"
case let .binaryFile(url, size, type, filename):
return "<Content.binaryFile file=\(url.lastPathComponent) size=\(size) type=\(type) filename=\(filename)>"
}
}
}
/// The form field name for this part.
public let name: String
/// The content of this part.
public let content: Content
/// 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)) 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)) 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) let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
guard let size = attributes[.size] as? Int64 else { guard let size = attributes[.size] as? Int64 else {
throw Error.invalidFile(url) throw Error.invalidFile(url)
} }
return Part(name: name, content: .binaryFile(url, size: size, type: type, filename: filename ?? url.lastPathComponent)) return Part(name: name, content: .binaryFile(url, size: size, type: type, filename: filename ?? url.lastPathComponent))
} }
public var description: String {
"<Part name=\(name) content=\(content)>"
}
} }
} }
final class MultipartFormEncoder { /// A multipart/form-data encoder that can encode forms either to memory or to files for streaming.
enum Error: Swift.Error { ///
/// 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) case invalidFile(URL)
/// The output file cannot be created or written to.
case invalidOutputFile(URL) case invalidOutputFile(URL)
/// An error occurred while reading from or writing to a stream.
case streamError case streamError
/// The total data size exceeds the 50MB limit for in-memory encoding.
case tooMuchDataForMemory case tooMuchDataForMemory
public var description: String {
switch self {
case let .invalidFile(url):
return "<MultipartFormEncoder.Error.invalidFile file=\(url.lastPathComponent)>"
case let .invalidOutputFile(url):
return "<MultipartFormEncoder.Error.invalidOutputFile file=\(url.lastPathComponent)>"
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)" 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 let totalSize: Int64 = parts.reduce(0, { size, part in
switch part.content { switch part.content {
case let .text(string): case let .text(string):
@ -116,7 +257,17 @@ final class MultipartFormEncoder {
return BodyData(contentType: "multipart/form-data; boundary=\"\(boundary)\"", data: bodyData) 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 fm = FileManager.default
let outputURL = tempFileURL() let outputURL = tempFileURL()
guard let stream = OutputStream(url: outputURL, append: false) else { guard let stream = OutputStream(url: outputURL, append: false) else {
@ -223,4 +374,8 @@ final class MultipartFormEncoder {
} }
} }
} }
public var description: String {
"<MultipartFormEncoder boundary=\(boundary)>"
}
} }

View file

@ -5,45 +5,176 @@
// //
import Foundation import Foundation
import OSLog
import UniformTypeIdentifiers
enum RequestBuilderError: Error { private let log = Logger(subsystem: "net.samhuri.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) case invalidFormData(HTTPRequest)
} }
final class RequestBuilder { /// Converts ``HTTPRequest`` instances to URLRequest for use with URLSession.
class func build(request: HTTPRequest) throws -> URLRequest { ///
assert(!(request.method == .get && request.parameters != nil), "encoding GET params is not yet implemented") /// ``RequestBuilder`` handles the encoding of different content types including JSON,
assert(!(request.method == .delete && request.parameters != nil), "encoding DELETE params is not yet implemented") /// form-encoded parameters, and multipart forms. For multipart forms, it encodes
/// everything in memory, so consider using the ``MultipartFormEncoder`` directly to
/// encode large files to disk for streaming.
///
/// ## Usage
///
/// ```swift
/// let httpRequest = HTTPRequest.postJSON(
/// URL(string: "https://trails.example.net/riders")!,
/// body: ["name": "Trent Reznor", "email": "trent@example.net", "bike": "Santa Cruz Nomad"]
/// )
///
/// 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 method and content type:
/// - **GET/DELETE**: Parameters are encoded as query string parameters
/// - `.json`: Parameters are encoded as JSON in the request body (POST/PUT/PATCH)
/// - `.formEncoded`: Parameters are URL-encoded in the request body (POST/PUT/PATCH)
/// - `.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, if GET/DELETE
/// requests contain multipart parts, or various encoding errors from JSONSerialization
/// or ``MultipartFormEncoder``
///
/// - Warning: Multipart requests are encoded entirely in memory. For large files,
/// consider using ``MultipartFormEncoder/encodeFile(parts:to:)`` to encode to disk first
public class func build(request: HTTPRequest) throws -> URLRequest {
var result = URLRequest(url: request.url) var result = URLRequest(url: request.url)
result.httpMethod = request.method.string result.httpMethod = request.method.string
for (name, value) in request.headers { for (name, value) in request.headers {
result.addValue(value, forHTTPHeaderField: name) 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: // Handle body content based on HTTP method and body type
// Fall back to form encoding for maximum compatibility. switch request.body {
assertionFailure("Cannot serialize parameters without a content type") case .none:
fallthrough break
case .formEncoded: case let .formParameters(params):
result.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") if request.method == .get || request.method == .delete {
guard let formData = FormEncoder.encode(params).data(using: .utf8) else { try encodeQueryParameters(to: &result, parameters: params)
throw RequestBuilderError.invalidFormData(request) } else {
} try encodeFormParameters(to: &result, request: request, parameters: params)
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
} }
case let .jsonParameters(params):
if request.method == .get || request.method == .delete {
try encodeQueryParameters(to: &result, parameters: params)
} else {
try encodeJSONParameters(to: &result, parameters: params)
}
case let .data(data, contentType):
result.httpBody = data
let mimeType = contentType.preferredMIMEType ?? "application/octet-stream"
if request.headers["Content-Type"] != nil {
log.warning("Overriding existing Content-Type header with \(mimeType) for data body")
}
result.addValue(mimeType, forHTTPHeaderField: "Content-Type")
case let .multipart(parts):
try encodeMultipartContent(to: &result, parts: parts)
case let .fileData(fileURL):
try encodeFileData(to: &result, fileURL: fileURL)
} }
return result return result
} }
private class func encodeMultipartContent(to urlRequest: inout URLRequest, parts: [MultipartFormEncoder.Part]) throws {
let encoder = MultipartFormEncoder()
let body = try encoder.encodeData(parts: parts)
if urlRequest.value(forHTTPHeaderField: "Content-Type") != nil {
log.warning("Overriding existing Content-Type header with \(body.contentType) for multipart body")
}
if urlRequest.value(forHTTPHeaderField: "Content-Length") != nil {
log.warning("Overriding existing Content-Length header with \(body.contentLength) for multipart body")
}
urlRequest.addValue(body.contentType, forHTTPHeaderField: "Content-Type")
urlRequest.addValue("\(body.contentLength)", forHTTPHeaderField: "Content-Length")
urlRequest.httpBody = body.data
}
private class func encodeJSONParameters(to urlRequest: inout URLRequest, parameters: [String: any Sendable]) throws {
if urlRequest.value(forHTTPHeaderField: "Content-Type") != nil {
log.warning("Overriding existing Content-Type header with application/json for JSON body")
}
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 {
if urlRequest.value(forHTTPHeaderField: "Content-Type") != nil {
log.warning("Overriding existing Content-Type header with application/x-www-form-urlencoded for form body")
}
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
}
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 if !newQueryItems.isEmpty {
components?.queryItems = newQueryItems
}
urlRequest.url = components?.url ?? url
}
private class func encodeFileData(to urlRequest: inout URLRequest, fileURL: URL) throws {
let inputStream = InputStream(url: fileURL)
urlRequest.httpBodyStream = inputStream
// Try to get file size for Content-Length header
if let fileAttributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
let fileSize = fileAttributes[.size] as? Int {
if urlRequest.value(forHTTPHeaderField: "Content-Length") != nil {
log.warning("Overriding existing Content-Length header with \(fileSize) for file data")
}
urlRequest.addValue("\(fileSize)", forHTTPHeaderField: "Content-Length")
}
// Try to determine Content-Type from file extension
let fileExtension = fileURL.pathExtension
if !fileExtension.isEmpty,
let utType = UTType(filenameExtension: fileExtension),
let mimeType = utType.preferredMIMEType {
if urlRequest.value(forHTTPHeaderField: "Content-Type") != nil {
log.warning("Overriding existing Content-Type header with \(mimeType) for file data")
}
urlRequest.addValue(mimeType, forHTTPHeaderField: "Content-Type")
}
}
} }

View file

@ -0,0 +1,70 @@
//
// Created by Sami Samhuri on 2025-06-23.
// Copyright © 2025 Sami Samhuri. All rights reserved.
// Released under the terms of the MIT license.
//
import Foundation
/// URLSession extensions for Osiris HTTP requests with automatic JSON decoding.
extension URLSession {
/// Performs an ``HTTPRequest`` and returns decoded JSON response.
/// - Parameters:
/// - request: The ``HTTPRequest`` to perform
/// - type: The expected response type
/// - decoder: JSONDecoder to use (defaults to standard decoder)
/// - Returns: Decoded response of the specified type
/// - Throws: ``HTTPError`` for HTTP errors, URLError for network issues, DecodingError for JSON parsing issues
public func perform<T: Decodable>(
_ request: HTTPRequest,
expecting type: T.Type,
decoder: JSONDecoder = JSONDecoder()
) async throws -> T {
let urlRequest = try RequestBuilder.build(request: request)
let (data, response) = try await self.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw HTTPError.invalidResponse
}
guard (200..<300).contains(httpResponse.statusCode) else {
throw HTTPError.failure(statusCode: httpResponse.statusCode, data: data, response: httpResponse)
}
do {
return try decoder.decode(type, from: data)
} catch let decodingError as DecodingError {
throw decodingError
}
}
/// Performs an ``HTTPRequest`` and returns decoded JSON response with type inference.
/// - Parameters:
/// - request: The ``HTTPRequest`` to perform
/// - decoder: JSONDecoder to use (defaults to standard decoder)
/// - Returns: Decoded response inferred from the return type
/// - Throws: ``HTTPError`` for HTTP errors, URLError for network issues, DecodingError for JSON parsing issues
public func perform<T: Decodable>(
_ request: HTTPRequest,
decoder: JSONDecoder = JSONDecoder()
) async throws -> T {
try await perform(request, expecting: T.self, decoder: decoder)
}
/// Performs an ``HTTPRequest`` expecting no content (e.g., 204 No Content).
/// - Parameter request: The ``HTTPRequest`` to perform
/// - Throws: ``HTTPError`` for HTTP errors, URLError for network issues
public func perform(_ request: HTTPRequest) async throws {
let urlRequest = try RequestBuilder.build(request: request)
let (data, response) = try await self.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw HTTPError.invalidResponse
}
guard (200..<300).contains(httpResponse.statusCode) else {
throw HTTPError.failure(statusCode: httpResponse.statusCode, data: data, response: httpResponse)
}
}
}

View file

@ -1,7 +0,0 @@
import XCTest
import OsirisTests
var tests = [XCTestCaseEntry]()
tests += OsirisTests.allTests()
XCTMain(tests)

View file

@ -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 "<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": "!*'();:@&=+$,/?#[]"]
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)
}
}

View file

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

View file

@ -0,0 +1,146 @@
//
// Created by Sami Samhuri on 2025-06-23.
// Copyright © 2025 Sami Samhuri. All rights reserved.
// Released under the terms of the MIT license.
//
import XCTest
@testable import Osiris
class HTTPRequestCodableTests: XCTestCase {
let baseURL = URL(string: "https://trails.example.net")!
func testPOSTWithCodableBody() throws {
let rachel = CreateRiderRequest(name: "Rachel Atherton", email: "rachel@trails.example.net", bike: "Trek Session")
let request = try HTTPRequest.post(baseURL.appendingPathComponent("riders"), body: rachel)
XCTAssertEqual(request.method, .post)
XCTAssertEqual(request.url.path, "/riders")
// Verify the body contains JSON data
if case let .data(data, contentType) = request.body {
XCTAssertEqual(contentType, .json)
let decodedRider = try JSONDecoder().decode(CreateRiderRequest.self, from: data)
XCTAssertEqual(decodedRider.name, rachel.name)
XCTAssertEqual(decodedRider.email, rachel.email)
XCTAssertEqual(decodedRider.bike, rachel.bike)
} else {
XCTFail("Expected data body with JSON content type")
}
}
func testPOSTWithCustomEncoder() throws {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
let danny = CreateRiderRequest(name: "Danny MacAskill", email: "danny@trails.example.net", bike: "Santa Cruz 5010")
let request = try HTTPRequest.post(baseURL.appendingPathComponent("riders"), body: danny, encoder: encoder)
XCTAssertEqual(request.method, .post)
if case let .data(data, _) = request.body {
let jsonString = String(data: data, encoding: .utf8)!
// Should use snake_case for JSON keys - verify the raw JSON contains the right keys
XCTAssertTrue(jsonString.contains("name"))
XCTAssertTrue(jsonString.contains("email"))
XCTAssertTrue(jsonString.contains("bike"))
} else {
XCTFail("Expected data body")
}
}
func testPUTWithCodableBody() throws {
let updateRider = CreateRiderRequest(name: "Greg Minnaar", email: "greg@trails.example.net", bike: "Santa Cruz V10")
let request = try HTTPRequest.put(baseURL.appendingPathComponent("riders/greg-minnaar"), body: updateRider)
XCTAssertEqual(request.method, .put)
XCTAssertEqual(request.url.path, "/riders/greg-minnaar")
if case let .data(data, contentType) = request.body {
XCTAssertEqual(contentType, .json)
XCTAssertNotNil(data)
} else {
XCTFail("Expected data body")
}
}
func testPATCHWithCodableBody() throws {
let patchData = CreateRiderRequest(name: "Brandon Semenuk", email: "brandon@trails.example.net", bike: "Trek Ticket S")
let request = try HTTPRequest.patch(baseURL.appendingPathComponent("riders/brandon-semenuk"), body: patchData)
XCTAssertEqual(request.method, .patch)
XCTAssertEqual(request.url.path, "/riders/brandon-semenuk")
if case .data = request.body {
// Success - has data body
} else {
XCTFail("Expected data body")
}
}
func testRequestBuilderWithCodableBody() throws {
let aaron = CreateRiderRequest(name: "Aaron Gwin", email: "aaron@trails.example.net", bike: "Intense M29")
let request = try HTTPRequest.post(baseURL.appendingPathComponent("riders"), body: aaron)
let urlRequest = try RequestBuilder.build(request: request)
XCTAssertEqual(urlRequest.httpMethod, "POST")
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Content-Type"), "application/json")
XCTAssertNotNil(urlRequest.httpBody)
// Test that the JSON is valid
if let body = urlRequest.httpBody {
let decodedRider = try JSONDecoder().decode(CreateRiderRequest.self, from: body)
XCTAssertEqual(decodedRider.name, "Aaron Gwin")
XCTAssertEqual(decodedRider.email, "aaron@trails.example.net")
XCTAssertEqual(decodedRider.bike, "Intense M29")
}
}
func testURLSessionExtensionWithCustomDecoder() async throws {
// Test the URLSession extension methods exist and compile
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let request = HTTPRequest.get(baseURL.appendingPathComponent("riders"))
// These would work with a real server, but we're just testing compilation
Task {
do {
// Test type inference version
let inferred: [RiderProfile] = try await URLSession.shared.perform(request, decoder: decoder)
// Test explicit type version
let explicit: [RiderProfile] = try await URLSession.shared.perform(request, expecting: [RiderProfile].self, decoder: decoder)
XCTAssertEqual(inferred, explicit)
// Test perform version (no return value)
try await URLSession.shared.perform(request)
} catch {
// Expected to fail without a real server
}
}
// If we get here, the methods exist and compile correctly
XCTAssertTrue(true)
}
func testHTTPErrorTypes() {
// Test HTTPError enum cases exist and provide useful information
let data = "Error message".data(using: .utf8)!
let response = HTTPURLResponse(url: baseURL, statusCode: 404, httpVersion: nil, headerFields: nil)!
let httpError = HTTPError.failure(statusCode: 404, data: data, response: response)
let invalidResponse = HTTPError.invalidResponse
// Test error descriptions are helpful
XCTAssertTrue(httpError.errorDescription?.contains("404") ?? false)
XCTAssertTrue(httpError.errorDescription?.contains("Error message") ?? false)
XCTAssertNotNil(invalidResponse.errorDescription)
// Test debug descriptions
XCTAssertTrue(httpError.debugDescription.contains("404"))
XCTAssertTrue(httpError.debugDescription.contains("Error message"))
}
}

View file

@ -0,0 +1,72 @@
//
// 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,
.invalidRequestBody
]
for error in allErrors {
XCTAssertNotNil(error.errorDescription)
XCTAssertFalse(error.errorDescription!.isEmpty)
}
}
func testFailureReasonIsNeverNil() {
let allErrors: [HTTPRequestError] = [
.http,
.unknown,
.invalidRequestBody
]
for error in allErrors {
XCTAssertNotNil(error.failureReason)
XCTAssertFalse(error.failureReason!.isEmpty)
}
}
func testRecoverySuggestionIsNeverNil() {
let allErrors: [HTTPRequestError] = [
.http,
.unknown,
.invalidRequestBody
]
for error in allErrors {
XCTAssertNotNil(error.recoverySuggestion)
XCTAssertFalse(error.recoverySuggestion!.isEmpty)
}
}
func testInvalidRequestBodyError() {
let error = HTTPRequestError.invalidRequestBody
XCTAssertEqual(error.localizedDescription, "GET and DELETE requests cannot have a body")
XCTAssertEqual(error.failureReason, "The HTTP method does not support a request body")
XCTAssertEqual(error.recoverySuggestion, "Use query parameters instead of a request body for GET and DELETE requests")
}
}

View file

@ -0,0 +1,410 @@
//
// HTTPRequestTests.swift
// OsirisTests
//
// Created by Sami Samhuri on 2025-06-15.
//
@testable import Osiris
import UniformTypeIdentifiers
import XCTest
class HTTPRequestTests: XCTestCase {
let baseURL = URL(string: "https://api.example.net")!
func testHTTPRequestInitialization() {
let request = HTTPRequest.get(baseURL)
XCTAssertEqual(request.method, .get)
XCTAssertEqual(request.url, baseURL)
XCTAssertEqual(request.contentType, .none)
XCTAssertTrue(request.body.isEmpty)
XCTAssertTrue(request.headers.isEmpty)
}
func testHTTPRequestInitializerValidation() throws {
// Valid requests should work
XCTAssertNoThrow(try HTTPRequest(method: .post, url: baseURL, body: .data(Data(), contentType: .json)))
XCTAssertNoThrow(try HTTPRequest(method: .put, url: baseURL, body: .formParameters(["test": "value"])))
XCTAssertNoThrow(try HTTPRequest(method: .patch, url: baseURL, body: .multipart([])))
// GET and DELETE with no body should work
XCTAssertNoThrow(try HTTPRequest(method: .get, url: baseURL))
XCTAssertNoThrow(try HTTPRequest(method: .delete, url: baseURL))
// GET with body should throw
XCTAssertThrowsError(try HTTPRequest(method: .get, url: baseURL, body: .data(Data(), contentType: .json))) { error in
XCTAssertEqual(error as? HTTPRequestError, .invalidRequestBody)
}
// DELETE with body should throw
XCTAssertThrowsError(try HTTPRequest(method: .delete, url: baseURL, body: .formParameters(["test": "value"]))) { error in
XCTAssertEqual(error as? HTTPRequestError, .invalidRequestBody)
}
}
func testHTTPRequestWithParameters() {
let params = ["key": "value", "number": 42] as [String: any Sendable]
let request = HTTPRequest.postJSON(baseURL, body: params)
XCTAssertEqual(request.method, HTTPMethod.post)
XCTAssertEqual(request.contentType, HTTPContentType.json)
if case .jsonParameters(let bodyParams) = request.body {
XCTAssertEqual(bodyParams.count, 2)
} else {
XCTFail("Expected jsonParameters body")
}
}
func testGETConvenience() {
let request = HTTPRequest.get(baseURL)
XCTAssertEqual(request.method, HTTPMethod.get)
XCTAssertEqual(request.url, baseURL)
XCTAssertEqual(request.contentType, HTTPContentType.none)
}
func testPOSTConvenience() {
let params = ["name": "Trent"]
let request = HTTPRequest.postJSON(baseURL, body: params)
XCTAssertEqual(request.method, HTTPMethod.post)
XCTAssertEqual(request.contentType, HTTPContentType.json)
if case .jsonParameters(let bodyParams) = request.body {
XCTAssertEqual(bodyParams.count, 1)
} else {
XCTFail("Expected jsonParameters body")
}
}
func testPUTConvenience() {
let params = ["name": "Trent"]
let request = HTTPRequest.putForm(baseURL, parameters: params)
XCTAssertEqual(request.method, HTTPMethod.put)
XCTAssertEqual(request.contentType, HTTPContentType.formEncoded)
if case .formParameters(let bodyParams) = request.body {
XCTAssertEqual(bodyParams.count, 1)
} else {
XCTFail("Expected formParameters body")
}
}
func testDELETEConvenience() {
let request = HTTPRequest.delete(baseURL)
XCTAssertEqual(request.method, HTTPMethod.delete)
XCTAssertEqual(request.url, baseURL)
XCTAssertEqual(request.contentType, HTTPContentType.none)
}
func testMultipartPartsAutomaticallySetContentType() {
let parts = [MultipartFormEncoder.Part.text("value", name: "field")]
let request = HTTPRequest.postMultipart(baseURL, parts: parts)
XCTAssertEqual(request.contentType, HTTPContentType.multipart)
if case .multipart(let bodyParts) = request.body {
XCTAssertEqual(bodyParts.count, 1)
} else {
XCTFail("Expected multipart body")
}
}
#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.contentType, HTTPContentType.multipart)
if case .multipart(let parts) = request.body {
XCTAssertEqual(parts.count, 1)
let part = 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")
}
} else {
XCTFail("Expected multipart body")
}
}
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
if case .multipart(let parts) = request.body {
XCTAssertTrue(parts.count >= 0) // Should not crash
}
}
#endif
func testHTTPRequestPATCHConvenience() {
let params = ["status": "active"]
let request = HTTPRequest.patchJSON(baseURL, body: params)
XCTAssertEqual(request.method, HTTPMethod.patch)
XCTAssertEqual(request.contentType, HTTPContentType.json)
if case .jsonParameters(let bodyParams) = request.body {
XCTAssertEqual(bodyParams.count, 1)
} else {
XCTFail("Expected jsonParameters body")
}
}
func testHTTPRequestWithEmptyMultipartParts() {
let request = HTTPRequest.postMultipart(baseURL, parts: [])
XCTAssertEqual(request.contentType, HTTPContentType.multipart) // Multipart even with empty parts
if case .multipart(let parts) = request.body {
XCTAssertTrue(parts.isEmpty)
} else {
XCTFail("Expected multipart body")
}
}
func testHTTPRequestBodyTypeDeterminesContentType() {
let jsonRequest = HTTPRequest.postJSON(baseURL, body: ["test": "value"])
XCTAssertEqual(jsonRequest.contentType, HTTPContentType.json)
let formRequest = HTTPRequest.postForm(baseURL, parameters: ["test": "value"])
XCTAssertEqual(formRequest.contentType, HTTPContentType.formEncoded)
let multipartRequest = HTTPRequest.postMultipart(baseURL, parts: [])
XCTAssertEqual(multipartRequest.contentType, HTTPContentType.multipart)
}
func testFileStreamingConveniences() {
let fileURL = URL(fileURLWithPath: "/tmp/test.txt")
let postRequest = HTTPRequest.postFile(baseURL, fileURL: fileURL)
XCTAssertEqual(postRequest.method, HTTPMethod.post)
XCTAssertEqual(postRequest.contentType, HTTPContentType.none)
if case .fileData(let url) = postRequest.body {
XCTAssertEqual(url, fileURL)
} else {
XCTFail("Expected fileData body")
}
let putRequest = HTTPRequest.putFile(baseURL, fileURL: fileURL)
XCTAssertEqual(putRequest.method, HTTPMethod.put)
if case .fileData(let url) = putRequest.body {
XCTAssertEqual(url, fileURL)
} else {
XCTFail("Expected fileData body")
}
let patchRequest = HTTPRequest.patchFile(baseURL, fileURL: fileURL)
XCTAssertEqual(patchRequest.method, HTTPMethod.patch)
if case .fileData(let url) = patchRequest.body {
XCTAssertEqual(url, fileURL)
} else {
XCTFail("Expected fileData body")
}
}
func testRawDataConveniences() {
let xmlData = "<xml><test>value</test></xml>".data(using: .utf8)!
let postRequest = HTTPRequest.post(baseURL, data: xmlData, contentType: .xml)
XCTAssertEqual(postRequest.method, HTTPMethod.post)
XCTAssertEqual(postRequest.contentType, HTTPContentType.custom("application/xml"))
if case let .data(data, contentType) = postRequest.body {
XCTAssertEqual(data, xmlData)
XCTAssertEqual(contentType, .xml)
} else {
XCTFail("Expected data body")
}
let putRequest = HTTPRequest.put(baseURL, data: xmlData, contentType: .plainText)
XCTAssertEqual(putRequest.method, HTTPMethod.put)
XCTAssertEqual(putRequest.contentType, HTTPContentType.custom("text/plain"))
let patchRequest = HTTPRequest.patch(baseURL, data: xmlData, contentType: .xml)
XCTAssertEqual(patchRequest.method, HTTPMethod.patch)
XCTAssertEqual(patchRequest.contentType, HTTPContentType.custom("application/xml"))
}
func testUTTypeIntegration() {
let data = Data("test".utf8)
// Test common UTTypes
let jsonRequest = HTTPRequest.post(baseURL, data: data, contentType: .json)
XCTAssertEqual(jsonRequest.contentType, HTTPContentType.custom("application/json"))
let xmlRequest = HTTPRequest.post(baseURL, data: data, contentType: .xml)
XCTAssertEqual(xmlRequest.contentType, HTTPContentType.custom("application/xml"))
let textRequest = HTTPRequest.post(baseURL, data: data, contentType: .plainText)
XCTAssertEqual(textRequest.contentType, HTTPContentType.custom("text/plain"))
let pngRequest = HTTPRequest.post(baseURL, data: data, contentType: .png)
XCTAssertEqual(pngRequest.contentType, HTTPContentType.custom("image/png"))
// Test custom UTType
if let customType = UTType(mimeType: "application/custom") {
let customRequest = HTTPRequest.post(baseURL, data: data, contentType: customType)
XCTAssertEqual(customRequest.contentType, HTTPContentType.custom("application/custom"))
}
}
// MARK: - Deprecated API Tests
func testDeprecatedPOSTWithContentType() {
let params = ["name": "Chali 2na", "email": "chali@example.net"]
// Test JSON content type
let jsonRequest = HTTPRequest.post(baseURL, contentType: .json, parameters: params)
XCTAssertEqual(jsonRequest.method, .post)
XCTAssertEqual(jsonRequest.contentType, .json)
if case .jsonParameters(let bodyParams) = jsonRequest.body {
XCTAssertEqual(bodyParams.count, 2)
} else {
XCTFail("Expected jsonParameters body")
}
// Test form encoded content type
let formRequest = HTTPRequest.post(baseURL, contentType: .formEncoded, parameters: params)
XCTAssertEqual(formRequest.method, .post)
XCTAssertEqual(formRequest.contentType, .formEncoded)
if case .formParameters(let bodyParams) = formRequest.body {
XCTAssertEqual(bodyParams.count, 2)
} else {
XCTFail("Expected formParameters body")
}
// Test with no parameters
let emptyRequest = HTTPRequest.post(baseURL, contentType: .json, parameters: nil)
XCTAssertTrue(emptyRequest.body.isEmpty)
}
func testDeprecatedPUTWithContentType() {
let params = ["status": "active"]
let request = HTTPRequest.put(baseURL, contentType: .json, parameters: params)
XCTAssertEqual(request.method, .put)
XCTAssertEqual(request.contentType, .json)
if case .jsonParameters(let bodyParams) = request.body {
XCTAssertEqual(bodyParams.count, 1)
} else {
XCTFail("Expected jsonParameters body")
}
}
func testDeprecatedPATCHWithContentType() {
let params = ["field": "value"]
let request = HTTPRequest.patch(baseURL, contentType: .formEncoded, parameters: params)
XCTAssertEqual(request.method, .patch)
XCTAssertEqual(request.contentType, .formEncoded)
if case .formParameters(let bodyParams) = request.body {
XCTAssertEqual(bodyParams.count, 1)
} else {
XCTFail("Expected formParameters body")
}
}
func testDeprecatedInitializer() {
let params = ["key": "value"]
// Test POST with JSON
let postRequest = HTTPRequest(method: .post, url: baseURL, contentType: .json, parameters: params)
XCTAssertEqual(postRequest.method, .post)
XCTAssertEqual(postRequest.contentType, .json)
if case .jsonParameters(let bodyParams) = postRequest.body {
XCTAssertEqual(bodyParams.count, 1)
} else {
XCTFail("Expected jsonParameters body")
}
// Test GET with parameters (should use query strings)
let getRequest = HTTPRequest(method: .get, url: baseURL, contentType: .none, parameters: params)
XCTAssertEqual(getRequest.method, .get)
if case .formParameters(let bodyParams) = getRequest.body {
XCTAssertEqual(bodyParams.count, 1)
} else {
XCTFail("Expected formParameters body for query encoding")
}
// Test DELETE with parameters (should use query strings)
let deleteRequest = HTTPRequest(method: .delete, url: baseURL, contentType: .none, parameters: params)
XCTAssertEqual(deleteRequest.method, .delete)
if case .formParameters(let bodyParams) = deleteRequest.body {
XCTAssertEqual(bodyParams.count, 1)
} else {
XCTFail("Expected formParameters body for query encoding")
}
}
func testDeprecatedParametersProperty() {
let params = ["test": "value", "number": 42] as [String: any Sendable]
// Test reading parameters from JSON body
let jsonRequest = HTTPRequest.postJSON(baseURL, body: params)
XCTAssertEqual(jsonRequest.parameters?.count, 2)
// Test reading parameters from form body
let formRequest = HTTPRequest.postForm(baseURL, parameters: params)
XCTAssertEqual(formRequest.parameters?.count, 2)
// Test setting parameters
var request = HTTPRequest.post(baseURL)
request.parameters = params
XCTAssertEqual(request.parameters?.count, 2)
if case .formParameters = request.body {
// Good, defaults to form parameters
} else {
XCTFail("Expected formParameters body")
}
// Test setting nil parameters
request.parameters = nil
XCTAssertNil(request.parameters)
XCTAssertTrue(request.body.isEmpty)
}
func testDeprecatedPartsProperty() {
let parts = [
MultipartFormEncoder.Part.text("value", name: "field"),
MultipartFormEncoder.Part.data(Data("test".utf8), name: "file", type: "text/plain", filename: "test.txt")
]
// Test reading parts
let multipartRequest = HTTPRequest.postMultipart(baseURL, parts: parts)
XCTAssertEqual(multipartRequest.parts.count, 2)
// Test setting parts
var request = HTTPRequest.post(baseURL)
request.parts = parts
XCTAssertEqual(request.parts.count, 2)
XCTAssertEqual(request.contentType, .multipart)
// Test empty parts
request.parts = []
XCTAssertEqual(request.parts.count, 0)
if case .multipart(let bodyParts) = request.body {
XCTAssertTrue(bodyParts.isEmpty)
} else {
XCTFail("Expected multipart body")
}
}
}

View file

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

View file

@ -0,0 +1,309 @@
//
// Created by Sami Samhuri on 2025-06-23.
// Copyright © 2025 Sami Samhuri. All rights reserved.
// Released under the terms of the MIT license.
//
import Foundation
@testable import Osiris
func httpRequestWithCodableSupport() async throws {
let url = URL(string: "https://trails.example.net/riders")!
// GET request with automatic JSON decoding
let riders: [RiderProfile] = try await URLSession.shared.perform(.get(url))
// POST with Codable body and automatic response decoding
struct CreateRiderRequest: Codable {
let name: String
let email: String
let bike: String
}
let danny = CreateRiderRequest(name: "Danny MacAskill", email: "danny@trails.example.net", bike: "Santa Cruz 5010")
let created: RiderProfile = try await URLSession.shared.perform(.post(url, body: danny))
// Custom encoder/decoder
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
let customRequest = try HTTPRequest.post(url, body: danny, encoder: encoder)
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let createdAgain: RiderProfile = try await URLSession.shared.perform(customRequest, decoder: decoder)
// For requests expecting no content (204, etc.)
try await URLSession.shared.perform(.delete(url.appendingPathComponent("123")))
_ = riders
_ = created
_ = createdAgain
}
func basicHTTPRequest() throws {
let url = URL(string: "https://example.net/kittens")!
// Basic GET request
let request = HTTPRequest.get(url)
// GET request with query parameters
let getRequest = HTTPRequest.get(url, parameters: ["page": 1, "limit": 10])
// DELETE request with query parameters
let deleteRequest = HTTPRequest.delete(url, parameters: ["confirm": "true"])
_ = request
_ = getRequest
_ = deleteRequest
}
func moreComplicatedPOSTRequest() throws {
let url = URL(string: "https://example.net/band")!
let params = ["email": "fatmike@example.net", "password": "LinoleumSupportsMyHead"]
// POST with JSON body
let jsonRequest = HTTPRequest.postJSON(url, body: params)
// POST with form-encoded body
let formRequest = HTTPRequest.postForm(url, parameters: params)
// POST with multipart body
let multipartRequest = HTTPRequest.postMultipart(url, parts: [.text("all day", name: "album")])
_ = jsonRequest
_ = formRequest
_ = multipartRequest
}
func requestBuilderExample() throws {
let url = URL(string: "https://example.net/band")!
let params = ["email": "fatmike@example.net", "password": "LinoleumSupportsMyHead"]
let request = HTTPRequest.postJSON(url, body: params)
let urlRequest = try RequestBuilder.build(request: request)
_ = urlRequest
}
func moreCodableExamples() throws {
struct Artist: Codable {
let name: String
let email: String
let genre: String
}
let url = URL(string: "https://beats.example.net/artists")!
let artist = Artist(name: "Trent Reznor", email: "trent@example.net", genre: "Industrial")
// POST with Codable body
let postRequest = try HTTPRequest.post(url, body: artist)
// PUT with Codable body
let putRequest = try HTTPRequest.put(url, body: artist)
// PATCH with Codable body
let patchRequest = try HTTPRequest.patch(url, body: artist)
// Custom encoder for different JSON formatting
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
let customRequest = try HTTPRequest.post(url, body: artist, encoder: encoder)
_ = postRequest
_ = putRequest
_ = patchRequest
_ = customRequest
}
func formEncoderExample() {
let body = FormEncoder.encode(["email": "trent@example.net", "password": "CloserToGod"])
_ = body
}
func multipartFormExample() throws {
let avatarData = Data("fake image data".utf8) // Simplified for compilation
let encoder = MultipartFormEncoder()
let body = try encoder.encodeData(parts: [
.text("chali@example.net", name: "email"),
.text("QualityControl", name: "password"),
.data(avatarData, name: "avatar", type: "image/jpeg", filename: "avatar.jpg"),
])
_ = body
}
func completeExample() async throws {
struct ArtistProfile: Codable {
let name: String
let email: String
let genre: String
}
struct UpdateProfileRequest: Codable {
let name: String
let email: String
let genre: String
}
func updateProfile(name: String, email: String, genre: String) async throws -> ArtistProfile {
let url = URL(string: "https://beats.example.net/profile")!
let updateRequest = UpdateProfileRequest(name: name, email: email, genre: genre)
// Use Codable body instead of dictionary
let request = try HTTPRequest.put(url, body: updateRequest)
// URLSession extension handles status checking and JSON decoding
return try await URLSession.shared.perform(request)
}
// For varied data structures, dictionaries are still available as an escape hatch:
func updateProfileWithDictionary(fields: [String: String]) async throws -> ArtistProfile {
let url = URL(string: "https://beats.example.net/profile")!
let request = HTTPRequest.putJSON(url, body: fields)
return try await URLSession.shared.perform(request)
}
_ = updateProfile
_ = updateProfileWithDictionary
}
func httpResponseExample() throws {
let url = URL(string: "https://example.net/test")!
let urlRequest = URLRequest(url: url)
let task = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
let httpResponse = HTTPResponse(response: response, data: data, error: error)
switch httpResponse {
case let .success(httpURLResponse, data):
print("Success: \(httpURLResponse.statusCode)")
if let data = data {
print("Response: \(String(data: data, encoding: .utf8) ?? "")")
}
case let .failure(error, httpURLResponse, _):
print("Failed: \(error)")
if let httpURLResponse = httpURLResponse {
print("Status: \(httpURLResponse.statusCode)")
}
}
}
_ = task
}
func multipartFileStreamingExample() throws {
let encoder = MultipartFormEncoder()
let avatarData = Data("fake image data".utf8)
let body = try encoder.encodeFile(parts: [
.text("chali@example.net", name: "email"),
.text("QualityControl", name: "password"),
.data(avatarData, name: "avatar", type: "image/jpeg", filename: "avatar.jpg"),
])
defer { _ = body.cleanup() }
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")
_ = request
}
func errorHandlingExample() throws {
func handleErrors() async {
do {
let url = URL(string: "https://example.net/test")!
let request = HTTPRequest.get(url)
let response: [String] = try await URLSession.shared.perform(request)
_ = response
} catch let httpError as HTTPError {
switch httpError {
case let .failure(statusCode, data, _):
print("HTTP \(statusCode) error: \(String(data: data, encoding: .utf8) ?? "No body")")
case .invalidResponse:
print("Invalid response from server")
}
} catch is DecodingError {
print("Failed to decode response JSON")
} catch {
print("Network error: \(error)")
}
}
_ = handleErrors
}
func migrationGuideExamples() throws {
let url = URL(string: "https://example.net/test")!
// Explicit methods for different encodings
let jsonRequest = HTTPRequest.postJSON(url, body: ["key": "value"])
let formRequest = HTTPRequest.putForm(url, parameters: ["key": "value"])
// Multipart convenience methods
let multipartRequest = HTTPRequest.postMultipart(url, parts: [.text("value", name: "field")])
// Codable support
struct Artist: Codable {
let name: String
let genre: String
}
let codableRequest = try HTTPRequest.post(url, body: Artist(name: "Trent Reznor", genre: "Industrial"))
_ = jsonRequest
_ = formRequest
_ = multipartRequest
_ = codableRequest
}
func additionalHTTPRequestExamples() throws {
// Examples from HTTPRequest documentation
// GET request with query parameters
let getRequest = HTTPRequest.get(
URL(string: "https://api.example.net/users")!,
parameters: ["page": "1", "limit": "10"]
)
// POST with JSON body
let jsonRequest = HTTPRequest.postJSON(
URL(string: "https://api.example.net/users")!,
body: ["name": "Chali 2na", "email": "chali@example.net"]
)
// DELETE with query parameters
let deleteRequest = HTTPRequest.delete(
URL(string: "https://api.example.net/users/123")!,
parameters: ["confirm": "true"]
)
// Multipart form with file upload
let uploadURL = URL(string: "https://api.example.net/upload")!
let imageData = Data("fake image".utf8)
let multipartRequest = HTTPRequest.postMultipart(uploadURL, parts: [
.text("Trent Reznor", name: "name"),
.data(imageData, name: "avatar", type: "image/jpeg", filename: "avatar.jpg")
])
// File streaming for large request bodies
let tempFile = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("test.txt")
try "test content".write(to: tempFile, atomically: true, encoding: .utf8)
let fileRequest = HTTPRequest.postFile(
URL(string: "https://api.example.net/upload")!,
fileURL: tempFile
)
// Custom content types like XML
let xmlData = "<request><artist>Nine Inch Nails</artist></request>".data(using: .utf8)!
let xmlRequest = HTTPRequest.post(
URL(string: "https://api.example.net/music")!,
data: xmlData,
contentType: .xml
)
_ = getRequest
_ = jsonRequest
_ = deleteRequest
_ = multipartRequest
_ = fileRequest
_ = xmlRequest
}

View file

@ -0,0 +1,357 @@
//
// 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/riders")!
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.postJSON(baseURL, body: 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.postForm(baseURL, 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 {
let parts = [
MultipartFormEncoder.Part.text("Jane Doe", name: "name"),
MultipartFormEncoder.Part.data(Data("test".utf8), name: "file", type: "text/plain", filename: "test.txt")
]
let httpRequest = HTTPRequest.postMultipart(baseURL, parts: parts)
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.postForm(baseURL, parameters: ["test": "value"])))
}
func testBuildRequestWithAllHTTPMethods() throws {
let methods: [HTTPMethod] = [.get, .post, .put, .patch, .delete]
for method in methods {
let httpRequest = try 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/riders?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 {
let httpRequest = HTTPRequest.postMultipart(baseURL, parts: [])
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 {
let largeData = Data(repeating: 65, count: 1024 * 1024) // 1MB of 'A' characters
let parts = [
MultipartFormEncoder.Part.data(largeData, name: "largefile", type: "application/octet-stream", filename: "large.bin")
]
let httpRequest = HTTPRequest.postMultipart(baseURL, parts: parts)
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)
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.postJSON(baseURL, body: [:])
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.postJSON(baseURL, body: ["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.postJSON(baseURL, body: 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 testBuildRequestWithExplicitFormEncoding() throws {
// Test explicit form encoding
let httpRequest = HTTPRequest.postForm(baseURL, parameters: ["email": "freddie@example.net", "band": "Queen"])
let urlRequest = try RequestBuilder.build(request: httpRequest)
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Content-Type"), "application/x-www-form-urlencoded")
XCTAssertNotNil(urlRequest.httpBody)
let bodyString = String(bytes: urlRequest.httpBody!, encoding: .utf8)
XCTAssertTrue(bodyString?.contains("email=freddie%40example.net") ?? false)
XCTAssertTrue(bodyString?.contains("band=Queen") ?? false)
}
func testBuildGETRequestWithQueryParameters() throws {
let httpRequest = HTTPRequest.get(baseURL, parameters: ["name": "Neko Case", "email": "neko@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=Neko%20Case"), "URL should contain encoded name parameter")
XCTAssertTrue(urlString.contains("email=neko@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/riders?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 testBuildGETRequestWithFormParameters() throws {
let httpRequest = HTTPRequest.get(baseURL, parameters: ["name": "value"])
let urlRequest = try RequestBuilder.build(request: httpRequest)
// GET parameters should be encoded as query string
XCTAssertTrue(urlRequest.url?.query?.contains("name=value") ?? false)
XCTAssertNil(urlRequest.httpBody)
}
func testBuildDELETERequestWithParameters() throws {
let httpRequest = HTTPRequest.delete(baseURL, parameters: ["id": "123"])
let urlRequest = try RequestBuilder.build(request: httpRequest)
// DELETE parameters should be encoded as query string
XCTAssertTrue(urlRequest.url?.query?.contains("id=123") ?? false)
XCTAssertNil(urlRequest.httpBody)
}
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")
}
func testBuildRequestWithFileData() throws {
// Create a temporary file for testing
let tempDir = FileManager.default.temporaryDirectory
let testFileURL = tempDir.appendingPathComponent("test_file.txt")
let testContent = "This is test file content for streaming"
try testContent.write(to: testFileURL, atomically: true, encoding: .utf8)
defer {
try? FileManager.default.removeItem(at: testFileURL)
}
let httpRequest = HTTPRequest.postFile(baseURL, fileURL: testFileURL)
let urlRequest = try RequestBuilder.build(request: httpRequest)
XCTAssertEqual(urlRequest.httpMethod, "POST")
XCTAssertNotNil(urlRequest.httpBodyStream)
XCTAssertNil(urlRequest.httpBody) // Should use stream, not body
// Should set Content-Length if file size is available
let contentLength = urlRequest.value(forHTTPHeaderField: "Content-Length")
XCTAssertNotNil(contentLength)
XCTAssertEqual(Int(contentLength!), testContent.utf8.count)
// Should set Content-Type based on file extension
let contentType = urlRequest.value(forHTTPHeaderField: "Content-Type")
XCTAssertEqual(contentType, "text/plain")
}
func testBuildRequestWithFileDataSetsCorrectContentType() throws {
let tempDir = FileManager.default.temporaryDirectory
// Test JSON file
let jsonFileURL = tempDir.appendingPathComponent("test.json")
try "{}".write(to: jsonFileURL, atomically: true, encoding: .utf8)
defer { try? FileManager.default.removeItem(at: jsonFileURL) }
let jsonRequest = HTTPRequest.postFile(baseURL, fileURL: jsonFileURL)
let jsonURLRequest = try RequestBuilder.build(request: jsonRequest)
XCTAssertEqual(jsonURLRequest.value(forHTTPHeaderField: "Content-Type"), "application/json")
// Test PNG file
let pngFileURL = tempDir.appendingPathComponent("test.png")
try Data().write(to: pngFileURL)
defer { try? FileManager.default.removeItem(at: pngFileURL) }
let pngRequest = HTTPRequest.putFile(baseURL, fileURL: pngFileURL)
let pngURLRequest = try RequestBuilder.build(request: pngRequest)
XCTAssertEqual(pngURLRequest.value(forHTTPHeaderField: "Content-Type"), "image/png")
}
}

View file

@ -0,0 +1,48 @@
//
// Created by Sami Samhuri on 2025-06-23.
// Copyright © 2025 Sami Samhuri. All rights reserved.
// Released under the terms of the MIT license.
//
import Foundation
// MARK: - Rider Models
struct RiderProfile: Codable, Equatable {
let id: Int
let name: String
let email: String
let bike: String
}
struct CreateRiderRequest: Codable {
let name: String
let email: String
let bike: String
}
// MARK: - Artist Models
struct ArtistProfile: Codable {
let name: String
let email: String
let genre: String
}
struct UpdateProfileRequest: Codable {
let name: String
let email: String
let genre: String
}
// MARK: - Generic Test Models
struct TestResponse: Codable {
let message: String
let success: Bool
}
struct TestRequestData: Codable {
let name: String
let email: String
}

View file

@ -1,9 +0,0 @@
import XCTest
#if !canImport(ObjectiveC)
public func allTests() -> [XCTestCaseEntry] {
return [
testCase(MultipartFormEncoderTests.allTests),
]
}
#endif