mirror of
https://github.com/samsonjs/Osiris.git
synced 2026-03-25 08:55:48 +00:00
Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8f91427ad | |||
| 80bade5c28 | |||
| d2576b729e | |||
| 955cb19c98 | |||
| 59a11b9cee | |||
| bc3ce2c93e | |||
| bcf402db8f | |||
| 9189796756 | |||
| 374e456641 |
26 changed files with 2181 additions and 608 deletions
26
CHANGELOG.md
26
CHANGELOG.md
|
|
@ -1,26 +0,0 @@
|
||||||
# Changelog
|
|
||||||
|
|
||||||
## [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
|
|
||||||
- **Header convenience method** `addHeader(name:value:)` on `HTTPRequest`
|
|
||||||
- **Comprehensive test coverage**
|
|
||||||
|
|
||||||
### Enhanced
|
|
||||||
- **Public API** - All types and methods now have proper public access modifiers
|
|
||||||
- **Error handling** - More specific error cases with `LocalizedError` conformance
|
|
||||||
- **Debugging support** - All types now conform to `CustomStringConvertible` with idiomatic descriptions for better OSLog output
|
|
||||||
|
|
||||||
[2.0.0]: https://github.com/samsonjs/Osiris/compare/1.0.0...2.0.0
|
|
||||||
|
|
||||||
## [1.0.0] - 2017-07-28
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Initial release with multipart form encoding
|
|
||||||
- HTTPRequest and HTTPResponse abstractions
|
|
||||||
- RequestBuilder for URLRequest conversion
|
|
||||||
- FormEncoder for URL-encoded forms
|
|
||||||
|
|
||||||
[1.0.0]: https://github.com/samsonjs/Osiris/releases/tag/1.0.0
|
|
||||||
67
Changelog.md
Normal file
67
Changelog.md
Normal 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
|
||||||
|
|
@ -6,8 +6,8 @@ import PackageDescription
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "Osiris",
|
name: "Osiris",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v14),
|
.iOS(.v16),
|
||||||
.macOS(.v11),
|
.macOS(.v13),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
.library(
|
.library(
|
||||||
|
|
|
||||||
310
Readme.md
310
Readme.md
|
|
@ -6,9 +6,11 @@
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Osiris is a Swift library that provides a multipart form encoder and HTTP utilities designed to make working with HTTP requests simpler and more flexible. The library focuses on practical utility over complexity, offering tools that handle common HTTP tasks like multipart form encoding, request building, and response handling.
|
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.
|
||||||
|
|
||||||
The main components include a robust `MultipartFormEncoder` that can encode forms either to memory or directly to files for streaming, and clean abstractions for HTTP requests and responses. All types conform to `CustomStringConvertible` with idiomatic descriptions, making debugging with OSLog significantly easier.
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
@ -16,7 +18,7 @@ You can install Osiris using Swift Package Manager (SPM) or copy the files direc
|
||||||
|
|
||||||
### Supported Platforms
|
### Supported Platforms
|
||||||
|
|
||||||
This package supports iOS 14.0+ and macOS 11.0+. The package is built with Swift 6.0+ but doesn't require projects importing Osiris to use Swift 6 language mode.
|
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
|
### Xcode
|
||||||
|
|
||||||
|
|
@ -27,84 +29,121 @@ Add the package to your project's Package Dependencies by entering the URL `http
|
||||||
Add this to your Package.swift dependencies:
|
Add this to your Package.swift dependencies:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
.package(url: "https://github.com/samsonjs/Osiris.git", .upToNextMajor(from: "2.0.0"))
|
.package(url: "https://github.com/samsonjs/Osiris.git", .upToNextMajor(from: "2.1.0"))
|
||||||
```
|
```
|
||||||
|
|
||||||
and add `"Osiris"` to your target dependencies.
|
and add `"Osiris"` to your target dependencies.
|
||||||
|
|
||||||
### Direct Integration
|
|
||||||
|
|
||||||
Alternatively, copy the files you want to use into your project and customize them to suit your needs.
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Multipart Form Encoding
|
### HTTPRequest with Codable Support
|
||||||
|
|
||||||
Create an encoder and then add parts to it as needed:
|
Automatic JSON encoding/decoding with URLSession extensions:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
import Osiris
|
import Osiris
|
||||||
|
|
||||||
let avatarData = UIImage(systemName: "person.circle")?.jpegData(compressionQuality: 1.0)
|
let url = URL(string: "https://trails.example.net/riders")!
|
||||||
let encoder = MultipartFormEncoder()
|
|
||||||
let body = try encoder.encodeData(parts: [
|
// GET request with automatic JSON decoding
|
||||||
.text("ziggy@example.net", name: "email"),
|
let riders: [RiderProfile] = try await URLSession.shared.perform(.get(url))
|
||||||
.text("StarmanWaiting", name: "password"),
|
|
||||||
.data(avatarData ?? Data(), name: "avatar", type: "image/jpeg", filename: "avatar.jpg"),
|
// 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")))
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
### Basic HTTPRequest
|
||||||
|
|
||||||
|
For simple requests:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
let body = try encoder.encodeFile(parts: [
|
import Osiris
|
||||||
.text("ziggy@example.net", name: "email"),
|
|
||||||
.text("StarmanWaiting", name: "password"),
|
|
||||||
.data(avatarData ?? Data(), name: "avatar", type: "image/jpeg", filename: "avatar.jpg"),
|
|
||||||
])
|
|
||||||
|
|
||||||
var request = URLRequest(url: URL(string: "https://example.net/accounts")!)
|
let url = URL(string: "https://example.net/kittens")!
|
||||||
request.httpMethod = "POST"
|
|
||||||
request.httpBodyStream = InputStream(url: body.url)
|
|
||||||
request.addValue(body.contentType, forHTTPHeaderField: "Content-Type")
|
|
||||||
request.addValue("\(body.contentLength)", forHTTPHeaderField: "Content-Length")
|
|
||||||
```
|
|
||||||
|
|
||||||
### HTTPRequest
|
// Basic GET request
|
||||||
|
let request = HTTPRequest.get(url)
|
||||||
Basic usage:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let url = URL(string: "https://example.net")!
|
|
||||||
|
|
||||||
// GET request with query parameters
|
// GET request with query parameters
|
||||||
let getRequest = HTTPRequest.get(url, parameters: ["page": "1", "limit": "10"])
|
let getRequest = HTTPRequest.get(url, parameters: ["page": 1, "limit": 10])
|
||||||
|
|
||||||
// DELETE request with query parameters
|
// DELETE request with query parameters
|
||||||
let deleteRequest = HTTPRequest.delete(url, parameters: ["confirm": "true"])
|
let deleteRequest = HTTPRequest.delete(url, parameters: ["confirm": "true"])
|
||||||
|
|
||||||
// Or use the general initializer
|
|
||||||
let request = HTTPRequest(method: .get, url: url)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
More advanced usage with parameters and headers:
|
More complicated POST requests with bodies and headers:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
let url = URL(string: "https://example.net")!
|
// POST with JSON body and a custom header
|
||||||
let params = ["email": "freddie@example.net", "password": "BohemianRhapsody"]
|
let url = URL(string: "https://example.net/band")!
|
||||||
|
let params = ["email": "fatmike@example.net", "password": "LinoleumSupportsMyHead"]
|
||||||
|
var jsonRequest = HTTPRequest.postJSON(url, body: params)
|
||||||
|
jsonRequest.headers["x-the-answer"] = "42"
|
||||||
|
|
||||||
// POST with JSON parameters (goes in request body)
|
// POST with form-encoded body
|
||||||
let request = HTTPRequest.post(url, contentType: .json, parameters: params)
|
let formRequest = HTTPRequest.postForm(url, parameters: params)
|
||||||
request.addHeader(name: "x-custom", value: "42")
|
|
||||||
request.addMultipartJPEG(name: "avatar", image: UIImage(), quality: 1, filename: "avatar.jpg")
|
// 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`:
|
You can build a `URLRequest` from an `HTTPRequest` using `RequestBuilder`:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
let urlRequest = try RequestBuilder.build(request: request)
|
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
|
### 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.
|
||||||
|
|
@ -112,14 +151,14 @@ This enum makes sense of the 3 parameters of `URLSession`'s completion block. It
|
||||||
```swift
|
```swift
|
||||||
let task = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
|
let task = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
|
||||||
let httpResponse = HTTPResponse(response: response, data: data, error: error)
|
let httpResponse = HTTPResponse(response: response, data: data, error: error)
|
||||||
|
|
||||||
switch httpResponse {
|
switch httpResponse {
|
||||||
case .success(let httpURLResponse, let data):
|
case let .success(httpURLResponse, data):
|
||||||
print("Success: \(httpURLResponse.statusCode)")
|
print("Success: \(httpURLResponse.statusCode)")
|
||||||
if let data = data {
|
if let data = data {
|
||||||
print("Response: \(String(data: data, encoding: .utf8) ?? "")")
|
print("Response: \(String(data: data, encoding: .utf8) ?? "")")
|
||||||
}
|
}
|
||||||
case .failure(let error, let httpURLResponse, let data):
|
case let .failure(error, httpURLResponse, data):
|
||||||
print("Failed: \(error)")
|
print("Failed: \(error)")
|
||||||
if let httpURLResponse = httpURLResponse {
|
if let httpURLResponse = httpURLResponse {
|
||||||
print("Status: \(httpURLResponse.statusCode)")
|
print("Status: \(httpURLResponse.statusCode)")
|
||||||
|
|
@ -142,56 +181,163 @@ The response provides convenient properties:
|
||||||
URL-encoded form data encoder adapted from [Alamofire][]:
|
URL-encoded form data encoder adapted from [Alamofire][]:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
let body = FormEncoder.encode(["email": "bowie@example.net", "password": "MajorTom"])
|
let body = FormEncoder.encode(["email": "trent@example.net", "password": "CloserToGod"])
|
||||||
// => "email=bowie%40example.net&password=MajorTom"
|
// => "email=trent%40example.net&password=CloserToGod"
|
||||||
```
|
```
|
||||||
|
|
||||||
[Alamofire]: https://github.com/Alamofire/Alamofire
|
[Alamofire]: https://github.com/Alamofire/Alamofire
|
||||||
|
|
||||||
|
### Multipart Form Encoding
|
||||||
|
|
||||||
|
Create an encoder and then add parts to it as needed:
|
||||||
|
|
||||||
|
```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"),
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
```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
|
### Complete Example
|
||||||
|
|
||||||
Here's how everything comes together:
|
Here's a realistic example with error handling:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
import Osiris
|
import Osiris
|
||||||
|
|
||||||
// Create an HTTP request
|
struct ArtistProfile: Codable {
|
||||||
let url = URL(string: "https://httpbin.org/post")!
|
let name: String
|
||||||
let request = HTTPRequest(method: .post, url: url)
|
let email: String
|
||||||
|
let genre: String
|
||||||
// Add multipart form data
|
}
|
||||||
let encoder = MultipartFormEncoder()
|
|
||||||
let formData = try encoder.encodeData(parts: [
|
struct UpdateProfileRequest: Codable {
|
||||||
.text("John Doe", name: "name"),
|
let name: String
|
||||||
.text("john@example.net", name: "email"),
|
let email: String
|
||||||
])
|
let genre: String
|
||||||
|
}
|
||||||
// Build URLRequest
|
|
||||||
var urlRequest = try RequestBuilder.build(request: request)
|
func updateProfile(name: String, email: String, genre: String) async throws -> ArtistProfile {
|
||||||
urlRequest.httpBody = formData.data
|
let url = URL(string: "https://beats.example.net/profile")!
|
||||||
urlRequest.addValue(formData.contentType, forHTTPHeaderField: "Content-Type")
|
let updateRequest = UpdateProfileRequest(name: name, email: email, genre: genre)
|
||||||
|
|
||||||
// Make the request
|
// Use Codable body instead of dictionary
|
||||||
let task = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
|
let request = try HTTPRequest.put(url, body: updateRequest)
|
||||||
let httpResponse = HTTPResponse(response: response, data: data, error: error)
|
|
||||||
|
// URLSession extension handles status checking and JSON decoding
|
||||||
switch httpResponse {
|
return try await URLSession.shared.perform(request)
|
||||||
case .success(let httpURLResponse, let data):
|
}
|
||||||
print("Upload successful: \(httpURLResponse.statusCode)")
|
|
||||||
case .failure(let error, _, _):
|
// For varied data structures, dictionaries are still available as an escape hatch:
|
||||||
print("Upload failed: \(error)")
|
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)")
|
||||||
}
|
}
|
||||||
task.resume()
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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
|
## Credits
|
||||||
|
|
||||||
Originally created by [@samsonjs][] for [1 Second Everyday][1SE]. `FormEncoder.swift` was adapted from [Alamofire][].
|
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
|
[Alamofire]: https://github.com/Alamofire/Alamofire
|
||||||
[samsonjs]: https://github.com/samsonjs
|
[@samsonjs]: https://github.com/samsonjs
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
private let log = Logger(subsystem: "co.1se.Osiris", category: "Service")
|
private let log = Logger(subsystem: "net.samhuri.Osiris", category: "Service")
|
||||||
|
|
||||||
enum ServiceError: Error {
|
enum ServiceError: Error {
|
||||||
case malformedRequest(HTTPRequest)
|
case malformedRequest(HTTPRequest)
|
||||||
|
|
@ -32,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
|
||||||
}
|
}
|
||||||
|
|
@ -87,32 +86,32 @@ final class Service {
|
||||||
|
|
||||||
// MARK: - Requests
|
// MARK: - Requests
|
||||||
|
|
||||||
fileprivate func deleteRequest(path: String, parameters: [String: any Sendable]? = 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 Sendable]) -> 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 Sendable]) -> 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 Sendable]) -> 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 Sendable]) -> 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 Sendable]) -> 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 Sendable]? = nil) -> HTTPRequest {
|
fileprivate func newRequest(method: HTTPMethod, path: String, contentType: HTTPContentType = .none, parameters: [String: any Sendable]? = nil) -> HTTPRequest {
|
||||||
|
|
@ -123,7 +122,7 @@ final class Service {
|
||||||
fileprivate func newRequest(method: HTTPMethod, url: URL, contentType: HTTPContentType = .none, parameters: [String: any Sendable]? = 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)
|
||||||
}
|
}
|
||||||
|
|
@ -133,28 +132,11 @@ 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) async throws -> 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)")
|
|
||||||
throw ServiceError.malformedRequest(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
let start = Date()
|
|
||||||
let (data, response) = try await urlSession.data(for: urlRequest)
|
|
||||||
let httpResponse = HTTPResponse(response: response, data: data, error: nil)
|
|
||||||
|
|
||||||
let end = Date()
|
|
||||||
let duration = end.timeIntervalSince1970 - start.timeIntervalSince1970
|
|
||||||
logRequest(request, response: httpResponse, duration: duration)
|
|
||||||
|
|
||||||
return httpResponse
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scrubParameters(_ parameters: [String: any Sendable], for url: URL) -> [String: any Sendable] {
|
private func scrubParameters(_ parameters: [String: any Sendable], for url: URL) -> [String: any Sendable] {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +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.
|
||||||
|
|
@ -30,12 +30,12 @@ extension NSNumber {
|
||||||
/// "active": true,
|
/// "active": true,
|
||||||
/// "preferences": ["color": "blue", "theme": "dark"]
|
/// "preferences": ["color": "blue", "theme": "dark"]
|
||||||
/// ]
|
/// ]
|
||||||
///
|
///
|
||||||
/// let encoded = FormEncoder.encode(parameters)
|
/// 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"
|
/// // 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 {
|
public final class FormEncoder: CustomStringConvertible {
|
||||||
|
|
||||||
/// Encodes a dictionary of parameters into a URL-encoded form string.
|
/// Encodes a dictionary of parameters into a URL-encoded form string.
|
||||||
///
|
///
|
||||||
/// The encoding follows these rules:
|
/// The encoding follows these rules:
|
||||||
|
|
@ -119,7 +119,7 @@ public final class FormEncoder: CustomStringConvertible {
|
||||||
let escaped = string.addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) ?? string
|
let escaped = string.addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) ?? string
|
||||||
return escaped
|
return escaped
|
||||||
}
|
}
|
||||||
|
|
||||||
public var description: String {
|
public var description: String {
|
||||||
"FormEncoder"
|
"FormEncoder"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,23 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Content types that can be automatically handled by HTTPRequest.
|
/// Content types that can be automatically handled by HTTPRequest.
|
||||||
public enum HTTPContentType: Sendable, CustomStringConvertible {
|
public enum HTTPContentType: Sendable, Equatable, CustomStringConvertible {
|
||||||
|
|
||||||
/// application/x-www-form-urlencoded
|
/// application/x-www-form-urlencoded
|
||||||
case formEncoded
|
case formEncoded
|
||||||
|
|
||||||
/// No specific content type
|
/// No specific content type
|
||||||
case none
|
case none
|
||||||
|
|
||||||
/// application/json
|
/// application/json
|
||||||
case json
|
case json
|
||||||
|
|
||||||
/// multipart/form-data (set automatically when parts are added)
|
/// multipart/form-data (set automatically when parts are added)
|
||||||
case multipart
|
case multipart
|
||||||
|
|
||||||
|
/// Custom content type with arbitrary MIME type
|
||||||
|
case custom(String)
|
||||||
|
|
||||||
public var description: String {
|
public var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .formEncoded:
|
case .formEncoded:
|
||||||
|
|
@ -31,6 +34,8 @@ public enum HTTPContentType: Sendable, CustomStringConvertible {
|
||||||
return "application/json"
|
return "application/json"
|
||||||
case .multipart:
|
case .multipart:
|
||||||
return "multipart/form-data"
|
return "multipart/form-data"
|
||||||
|
case .custom(let mimeType):
|
||||||
|
return mimeType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
50
Sources/Osiris/HTTPError.swift
Normal file
50
Sources/Osiris/HTTPError.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,7 @@ public enum HTTPMethod: String, Sendable, CustomStringConvertible {
|
||||||
var string: String {
|
var string: String {
|
||||||
rawValue.uppercased()
|
rawValue.uppercased()
|
||||||
}
|
}
|
||||||
|
|
||||||
public var description: String {
|
public var description: String {
|
||||||
string
|
string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,39 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private let log = Logger(subsystem: "co.1se.Osiris", category: "HTTPRequest")
|
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.
|
/// A structure representing an HTTP request with support for various content types and multipart forms.
|
||||||
///
|
///
|
||||||
|
|
@ -27,11 +54,10 @@ private let log = Logger(subsystem: "co.1se.Osiris", category: "HTTPRequest")
|
||||||
/// parameters: ["page": "1", "limit": "10"]
|
/// parameters: ["page": "1", "limit": "10"]
|
||||||
/// )
|
/// )
|
||||||
///
|
///
|
||||||
/// // POST with JSON parameters
|
/// // POST with JSON body
|
||||||
/// let jsonRequest = HTTPRequest.post(
|
/// let jsonRequest = HTTPRequest.postJSON(
|
||||||
/// URL(string: "https://api.example.net/users")!,
|
/// URL(string: "https://api.example.net/users")!,
|
||||||
/// contentType: .json,
|
/// body: ["name": "Chali 2na", "email": "chali@example.net"]
|
||||||
/// parameters: ["name": "Jane", "email": "jane@example.net"]
|
|
||||||
/// )
|
/// )
|
||||||
///
|
///
|
||||||
/// // DELETE with query parameters
|
/// // DELETE with query parameters
|
||||||
|
|
@ -41,47 +67,173 @@ private let log = Logger(subsystem: "co.1se.Osiris", category: "HTTPRequest")
|
||||||
/// )
|
/// )
|
||||||
///
|
///
|
||||||
/// // Multipart form with file upload
|
/// // Multipart form with file upload
|
||||||
/// var multipartRequest = HTTPRequest.post(URL(string: "https://api.example.net/upload")!)
|
/// let uploadURL = URL(string: "https://api.example.net/upload")!
|
||||||
/// multipartRequest.parts = [
|
/// let multipartRequest = HTTPRequest.postMultipart(uploadURL, parts: [
|
||||||
/// .text("Jane Doe", name: "name"),
|
/// .text("Trent Reznor", name: "name"),
|
||||||
/// .data(imageData, name: "avatar", type: "image/jpeg", filename: "avatar.jpg")
|
/// .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 {
|
public struct HTTPRequest: Sendable, CustomStringConvertible {
|
||||||
|
|
||||||
/// The HTTP method for this request.
|
/// The HTTP method for this request.
|
||||||
public var method: HTTPMethod
|
public var method: HTTPMethod
|
||||||
|
|
||||||
/// The target URL for this request.
|
/// The target URL for this request.
|
||||||
public var url: URL
|
public var url: URL
|
||||||
|
|
||||||
/// The content type for the request body.
|
/// The body content for this request.
|
||||||
public var contentType: HTTPContentType
|
public var body: HTTPRequestBody
|
||||||
|
|
||||||
/// Parameters to be encoded according to the content type.
|
|
||||||
public var parameters: [String: any Sendable]?
|
|
||||||
|
|
||||||
/// Additional HTTP headers for the request.
|
/// Additional HTTP headers for the request.
|
||||||
public var headers: [String: String] = [:]
|
public var headers: [String: String] = [:]
|
||||||
|
|
||||||
/// Multipart form parts (automatically sets contentType to .multipart when non-empty).
|
// MARK: - Deprecated Properties
|
||||||
public var parts: [MultipartFormEncoder.Part] = [] {
|
|
||||||
didSet {
|
/// Parameters to be encoded according to the content type.
|
||||||
if !parts.isEmpty { contentType = .multipart }
|
@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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a new HTTP request.
|
/// 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:
|
/// - Parameters:
|
||||||
/// - method: The HTTP method to use
|
/// - method: The HTTP method to use
|
||||||
/// - url: The target URL
|
/// - url: The target URL
|
||||||
/// - contentType: The content type for encoding parameters
|
/// - contentType: The content type for encoding parameters
|
||||||
/// - parameters: Optional parameters to include in the request body
|
/// - parameters: Optional parameters to include in the request body
|
||||||
|
@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) {
|
public init(method: HTTPMethod, url: URL, contentType: HTTPContentType = .none, parameters: [String: any Sendable]? = nil) {
|
||||||
self.method = method
|
self.method = method
|
||||||
self.url = url
|
self.url = url
|
||||||
self.contentType = contentType
|
|
||||||
self.parameters = parameters
|
// 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.
|
/// Creates a GET request.
|
||||||
|
|
@ -90,27 +242,46 @@ public struct HTTPRequest: Sendable, CustomStringConvertible {
|
||||||
/// - parameters: Optional parameters to include as query string
|
/// - parameters: Optional parameters to include as query string
|
||||||
/// - Returns: A configured HTTPRequest
|
/// - Returns: A configured HTTPRequest
|
||||||
public static func get(_ url: URL, parameters: [String: any Sendable]? = nil) -> HTTPRequest {
|
public static func get(_ url: URL, parameters: [String: any Sendable]? = nil) -> HTTPRequest {
|
||||||
HTTPRequest(method: .get, url: url, contentType: .none, parameters: parameters)
|
let body: HTTPRequestBody = parameters.map { .formParameters($0) } ?? .none
|
||||||
|
return HTTPRequest(uncheckedMethod: .get, url: url, body: body)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a PUT request.
|
/// Creates a PUT request with a URL-encoded form body.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - url: The target URL
|
/// - url: The target URL
|
||||||
/// - contentType: The content type for encoding parameters
|
/// - parameters: Parameters to encode as URL-encoded form body
|
||||||
/// - parameters: Optional parameters to include in the request body
|
|
||||||
/// - Returns: A configured HTTPRequest
|
/// - Returns: A configured HTTPRequest
|
||||||
public static func put(_ url: URL, contentType: HTTPContentType = .none, parameters: [String: any Sendable]? = nil) -> HTTPRequest {
|
public static func putForm(_ url: URL, parameters: [String: any Sendable]? = nil) -> HTTPRequest {
|
||||||
HTTPRequest(method: .put, url: url, contentType: contentType, parameters: parameters)
|
let body: HTTPRequestBody = parameters.map { .formParameters($0) } ?? .none
|
||||||
|
return HTTPRequest(uncheckedMethod: .put, url: url, body: body)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a POST request.
|
/// Creates a PUT request with a JSON body.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - url: The target URL
|
/// - url: The target URL
|
||||||
/// - contentType: The content type for encoding parameters
|
/// - body: Dictionary to encode as JSON body
|
||||||
/// - parameters: Optional parameters to include in the request body
|
|
||||||
/// - Returns: A configured HTTPRequest
|
/// - Returns: A configured HTTPRequest
|
||||||
public static func post(_ url: URL, contentType: HTTPContentType = .none, parameters: [String: any Sendable]? = nil) -> HTTPRequest {
|
public static func putJSON(_ url: URL, body: [String: any Sendable]) -> HTTPRequest {
|
||||||
HTTPRequest(method: .post, url: url, contentType: contentType, parameters: parameters)
|
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.
|
/// Creates a DELETE request.
|
||||||
|
|
@ -119,11 +290,246 @@ public struct HTTPRequest: Sendable, CustomStringConvertible {
|
||||||
/// - parameters: Optional parameters to include as query string
|
/// - parameters: Optional parameters to include as query string
|
||||||
/// - Returns: A configured HTTPRequest
|
/// - Returns: A configured HTTPRequest
|
||||||
public static func delete(_ url: URL, parameters: [String: any Sendable]? = nil) -> HTTPRequest {
|
public static func delete(_ url: URL, parameters: [String: any Sendable]? = nil) -> HTTPRequest {
|
||||||
HTTPRequest(method: .delete, url: url, contentType: .none, parameters: parameters)
|
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)
|
#if canImport(UIKit)
|
||||||
|
|
||||||
/// Adds a JPEG image to the multipart form (iOS/tvOS only).
|
/// Adds a JPEG image to the multipart form (iOS/tvOS only).
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - name: The form field name
|
/// - name: The form field name
|
||||||
|
|
@ -135,20 +541,27 @@ public struct HTTPRequest: Sendable, CustomStringConvertible {
|
||||||
log.error("Cannot compress image as JPEG data for parameter \(name) (\(filename ?? ""))")
|
log.error("Cannot compress image as JPEG data for parameter \(name) (\(filename ?? ""))")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
parts.append(
|
|
||||||
.data(data, name: name, type: "image/jpeg", filename: filename ?? "image.jpeg")
|
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
|
#endif
|
||||||
|
|
||||||
/// Adds a header to this request.
|
/// Adds a header to this request. Deprecated in favour of directly modifying the ``headers`` dictionary.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - name: The header name
|
/// - name: The header name
|
||||||
/// - value: The header value
|
/// - value: The header value
|
||||||
|
@available(*, deprecated, message: "Modify the headers dictionary directly on this request instead.")
|
||||||
public mutating func addHeader(name: String, value: String) {
|
public mutating func addHeader(name: String, value: String) {
|
||||||
headers[name] = value
|
headers[name] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
public var description: String {
|
public var description: String {
|
||||||
"<HTTPRequest \(method) \(url)>"
|
"<HTTPRequest \(method) \(url)>"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,46 +8,57 @@ import Foundation
|
||||||
|
|
||||||
/// Specific errors for HTTP request processing.
|
/// Specific errors for HTTP request processing.
|
||||||
public enum HTTPRequestError: Error, LocalizedError, CustomStringConvertible {
|
public enum HTTPRequestError: Error, LocalizedError, CustomStringConvertible {
|
||||||
|
|
||||||
/// An HTTP error occurred (non-2xx status code).
|
/// An HTTP error occurred (non-2xx status code).
|
||||||
case http
|
case http
|
||||||
|
|
||||||
/// An unknown error occurred (typically when URLResponse isn't HTTPURLResponse).
|
/// An unknown error occurred (typically when URLResponse isn't HTTPURLResponse).
|
||||||
case unknown
|
case unknown
|
||||||
|
|
||||||
|
/// Invalid request body for the HTTP method.
|
||||||
|
case invalidRequestBody
|
||||||
|
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .http:
|
case .http:
|
||||||
return "HTTP request failed with non-2xx status code"
|
return "HTTP request failed with non-2xx status code"
|
||||||
case .unknown:
|
case .unknown:
|
||||||
return "An unknown error occurred"
|
return "An unknown error occurred"
|
||||||
|
case .invalidRequestBody:
|
||||||
|
return "GET and DELETE requests cannot have a body"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var failureReason: String? {
|
public var failureReason: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .http:
|
case .http:
|
||||||
return "The server returned an error status code"
|
return "The server returned an error status code"
|
||||||
case .unknown:
|
case .unknown:
|
||||||
return "An unexpected error occurred during the request"
|
return "An unexpected error occurred during the request"
|
||||||
|
case .invalidRequestBody:
|
||||||
|
return "The HTTP method does not support a request body"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var recoverySuggestion: String? {
|
public var recoverySuggestion: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .http:
|
case .http:
|
||||||
return "Check the server response for error details"
|
return "Check the server response for error details"
|
||||||
case .unknown:
|
case .unknown:
|
||||||
return "Check network connectivity and try again"
|
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 {
|
public var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .http:
|
case .http:
|
||||||
"HTTPRequestError.http"
|
"HTTPRequestError.http"
|
||||||
case .unknown:
|
case .unknown:
|
||||||
"HTTPRequestError.unknown"
|
"HTTPRequestError.unknown"
|
||||||
|
case .invalidRequestBody:
|
||||||
|
"HTTPRequestError.invalidRequestBody"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
private let log = Logger(subsystem: "co.1se.Osiris", category: "HTTPResponse")
|
private let log = Logger(subsystem: "net.samhuri.Osiris", category: "HTTPResponse")
|
||||||
|
|
||||||
/// A response from an HTTP request that simplifies URLSession's completion handler parameters.
|
/// A response from an HTTP request that simplifies URLSession's completion handler parameters.
|
||||||
///
|
///
|
||||||
|
|
@ -20,22 +20,22 @@ private let log = Logger(subsystem: "co.1se.Osiris", category: "HTTPResponse")
|
||||||
/// ```swift
|
/// ```swift
|
||||||
/// let task = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
|
/// let task = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
|
||||||
/// let httpResponse = HTTPResponse(response: response, data: data, error: error)
|
/// let httpResponse = HTTPResponse(response: response, data: data, error: error)
|
||||||
///
|
///
|
||||||
/// switch httpResponse {
|
/// switch httpResponse {
|
||||||
/// case .success(let httpURLResponse, let data):
|
/// case let .success(httpURLResponse, data):
|
||||||
/// print("Success: \(httpURLResponse.statusCode)")
|
/// print("Success: \(httpURLResponse.statusCode)")
|
||||||
/// // Handle successful response
|
/// // Handle successful response
|
||||||
/// case .failure(let error, let httpURLResponse, let data):
|
/// case let .failure(error, httpURLResponse, data):
|
||||||
/// print("Failed: \(error)")
|
/// print("Failed: \(error)")
|
||||||
/// // Handle error response
|
/// // Handle error response
|
||||||
/// }
|
/// }
|
||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
public enum HTTPResponse: CustomStringConvertible {
|
public enum HTTPResponse: CustomStringConvertible {
|
||||||
|
|
||||||
/// A successful response (2xx status code) with the HTTP response and optional body data.
|
/// A successful response (2xx status code) with the HTTP response and optional body data.
|
||||||
case success(HTTPURLResponse, Data?)
|
case success(HTTPURLResponse, Data?)
|
||||||
|
|
||||||
/// A failed response with the error, optional HTTP response, and optional body data.
|
/// A failed response with the error, optional HTTP response, and optional body data.
|
||||||
case failure(Error, HTTPURLResponse?, Data?)
|
case failure(Error, HTTPURLResponse?, Data?)
|
||||||
|
|
||||||
|
|
@ -125,7 +125,7 @@ public enum HTTPResponse: CustomStringConvertible {
|
||||||
return [:]
|
return [:]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var description: String {
|
public var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case let .success(response, data):
|
case let .success(response, data):
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,13 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension MultipartFormEncoder {
|
extension MultipartFormEncoder {
|
||||||
|
|
||||||
/// Contains the encoded multipart form data for in-memory storage.
|
/// Contains the encoded multipart form data for in-memory storage.
|
||||||
public struct BodyData: CustomStringConvertible {
|
public struct BodyData: CustomStringConvertible {
|
||||||
|
|
||||||
/// The content type header value including boundary.
|
/// The content type header value including boundary.
|
||||||
public let contentType: String
|
public let contentType: String
|
||||||
|
|
||||||
/// The encoded form data.
|
/// The encoded form data.
|
||||||
public let data: Data
|
public let data: Data
|
||||||
|
|
||||||
|
|
@ -38,7 +38,7 @@ extension MultipartFormEncoder {
|
||||||
public var contentLength: Int {
|
public var contentLength: Int {
|
||||||
data.count
|
data.count
|
||||||
}
|
}
|
||||||
|
|
||||||
public var description: String {
|
public var description: String {
|
||||||
"<BodyData size=\(contentLength)>"
|
"<BodyData size=\(contentLength)>"
|
||||||
}
|
}
|
||||||
|
|
@ -46,19 +46,34 @@ extension MultipartFormEncoder {
|
||||||
|
|
||||||
/// Contains the encoded multipart form data written to a file for streaming.
|
/// Contains the encoded multipart form data written to a file for streaming.
|
||||||
public struct BodyFile: CustomStringConvertible {
|
public struct BodyFile: CustomStringConvertible {
|
||||||
|
|
||||||
/// The content type header value including boundary.
|
/// The content type header value including boundary.
|
||||||
public let contentType: String
|
public let contentType: String
|
||||||
|
|
||||||
/// The URL of the temporary file containing the encoded data.
|
/// The URL of the temporary file containing the encoded data.
|
||||||
public let url: URL
|
public let url: URL
|
||||||
|
|
||||||
/// The length of the encoded data in bytes.
|
/// The length of the encoded data in bytes.
|
||||||
public let contentLength: Int64
|
public let contentLength: Int64
|
||||||
|
|
||||||
public var description: String {
|
public var description: String {
|
||||||
"<BodyFile file=\(url.lastPathComponent) size=\(contentLength)>"
|
"<BodyFile file=\(url.lastPathComponent) size=\(contentLength)>"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes the temporary file from disk.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents a single part in a multipart form.
|
/// Represents a single part in a multipart form.
|
||||||
|
|
@ -66,16 +81,16 @@ extension MultipartFormEncoder {
|
||||||
|
|
||||||
/// The content types supported in multipart forms.
|
/// The content types supported in multipart forms.
|
||||||
public enum Content: Equatable, Sendable, CustomStringConvertible {
|
public enum Content: Equatable, Sendable, CustomStringConvertible {
|
||||||
|
|
||||||
/// Plain text content.
|
/// Plain text content.
|
||||||
case text(String)
|
case text(String)
|
||||||
|
|
||||||
/// Binary data with MIME type and filename.
|
/// Binary data with MIME type and filename.
|
||||||
case binaryData(Data, type: String, filename: String)
|
case binaryData(Data, type: String, filename: String)
|
||||||
|
|
||||||
/// Binary data from a file with size, MIME type and filename.
|
/// Binary data from a file with size, MIME type and filename.
|
||||||
case binaryFile(URL, size: Int64, type: String, filename: String)
|
case binaryFile(URL, size: Int64, type: String, filename: String)
|
||||||
|
|
||||||
public var description: String {
|
public var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case let .text(value):
|
case let .text(value):
|
||||||
|
|
@ -91,7 +106,7 @@ extension MultipartFormEncoder {
|
||||||
|
|
||||||
/// The form field name for this part.
|
/// The form field name for this part.
|
||||||
public let name: String
|
public let name: String
|
||||||
|
|
||||||
/// The content of this part.
|
/// The content of this part.
|
||||||
public let content: Content
|
public let content: Content
|
||||||
|
|
||||||
|
|
@ -130,7 +145,7 @@ extension MultipartFormEncoder {
|
||||||
}
|
}
|
||||||
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 {
|
public var description: String {
|
||||||
"<Part name=\(name) content=\(content)>"
|
"<Part name=\(name) content=\(content)>"
|
||||||
}
|
}
|
||||||
|
|
@ -151,30 +166,30 @@ extension MultipartFormEncoder {
|
||||||
/// .text("jane@example.net", name: "email"),
|
/// .text("jane@example.net", name: "email"),
|
||||||
/// .data(imageData, name: "avatar", type: "image/jpeg", filename: "avatar.jpg")
|
/// .data(imageData, name: "avatar", type: "image/jpeg", filename: "avatar.jpg")
|
||||||
/// ]
|
/// ]
|
||||||
///
|
///
|
||||||
/// // Encode to memory (< 50MB)
|
/// // Encode to memory (< 50MB)
|
||||||
/// let bodyData = try encoder.encodeData(parts: parts)
|
/// let bodyData = try encoder.encodeData(parts: parts)
|
||||||
///
|
///
|
||||||
/// // Or encode to file for streaming
|
/// // Or encode to file for streaming
|
||||||
/// let bodyFile = try encoder.encodeFile(parts: parts)
|
/// let bodyFile = try encoder.encodeFile(parts: parts)
|
||||||
/// ```
|
/// ```
|
||||||
public final class MultipartFormEncoder: CustomStringConvertible {
|
public final class MultipartFormEncoder: CustomStringConvertible {
|
||||||
|
|
||||||
/// Errors that can occur during multipart encoding.
|
/// Errors that can occur during multipart encoding.
|
||||||
public enum Error: Swift.Error, CustomStringConvertible {
|
public enum Error: Swift.Error, CustomStringConvertible {
|
||||||
|
|
||||||
/// The specified file cannot be read or is invalid.
|
/// The specified file cannot be read or is invalid.
|
||||||
case invalidFile(URL)
|
case invalidFile(URL)
|
||||||
|
|
||||||
/// The output file cannot be created or written to.
|
/// 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.
|
/// 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.
|
/// The total data size exceeds the 50MB limit for in-memory encoding.
|
||||||
case tooMuchDataForMemory
|
case tooMuchDataForMemory
|
||||||
|
|
||||||
public var description: String {
|
public var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case let .invalidFile(url):
|
case let .invalidFile(url):
|
||||||
|
|
@ -359,7 +374,7 @@ public final class MultipartFormEncoder: CustomStringConvertible {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var description: String {
|
public var description: String {
|
||||||
"<MultipartFormEncoder boundary=\(boundary)>"
|
"<MultipartFormEncoder boundary=\(boundary)>"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,32 +6,32 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
private let log = Logger(subsystem: "co.1se.Osiris", category: "RequestBuilder")
|
private let log = Logger(subsystem: "net.samhuri.Osiris", category: "RequestBuilder")
|
||||||
|
|
||||||
/// Errors that can occur when building URLRequest from HTTPRequest.
|
/// Errors that can occur when building URLRequest from ``HTTPRequest``.
|
||||||
public enum RequestBuilderError: Error {
|
public enum RequestBuilderError: Error {
|
||||||
|
|
||||||
/// The form data could not be encoded properly.
|
/// The form data could not be encoded properly.
|
||||||
case invalidFormData(HTTPRequest)
|
case invalidFormData(HTTPRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts HTTPRequest instances to URLRequest for use with URLSession.
|
/// Converts ``HTTPRequest`` instances to URLRequest for use with URLSession.
|
||||||
///
|
///
|
||||||
/// RequestBuilder handles the encoding of different content types including JSON,
|
/// ``RequestBuilder`` handles the encoding of different content types including JSON,
|
||||||
/// form-encoded parameters, and multipart forms. For multipart forms, it encodes
|
/// form-encoded parameters, and multipart forms. For multipart forms, it encodes
|
||||||
/// everything in memory, so consider using the MultipartFormEncoder directly for
|
/// everything in memory, so consider using the ``MultipartFormEncoder`` directly to
|
||||||
/// large files that should be streamed.
|
/// encode large files to disk for streaming.
|
||||||
///
|
///
|
||||||
/// ## Usage
|
/// ## Usage
|
||||||
///
|
///
|
||||||
/// ```swift
|
/// ```swift
|
||||||
/// let httpRequest = HTTPRequest.post(
|
/// let httpRequest = HTTPRequest.postJSON(
|
||||||
/// URL(string: "https://api.example.net/users")!,
|
/// URL(string: "https://trails.example.net/riders")!,
|
||||||
/// contentType: .json,
|
/// body: ["name": "Trent Reznor", "email": "trent@example.net", "bike": "Santa Cruz Nomad"]
|
||||||
/// parameters: ["name": "Jane", "email": "jane@example.net"]
|
|
||||||
/// )
|
/// )
|
||||||
///
|
///
|
||||||
/// let urlRequest = try RequestBuilder.build(request: httpRequest)
|
/// let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
/// let task = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
|
/// let task = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
|
||||||
/// let httpResponse = HTTPResponse(response: response, data: data, error: error)
|
/// let httpResponse = HTTPResponse(response: response, data: data, error: error)
|
||||||
|
|
@ -39,8 +39,8 @@ public enum RequestBuilderError: Error {
|
||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
public final class RequestBuilder {
|
public final class RequestBuilder {
|
||||||
|
|
||||||
/// Converts an HTTPRequest to a URLRequest ready for use with URLSession.
|
/// 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:
|
/// This method handles encoding of parameters according to the request's method and content type:
|
||||||
/// - **GET/DELETE**: Parameters are encoded as query string parameters
|
/// - **GET/DELETE**: Parameters are encoded as query string parameters
|
||||||
|
|
@ -49,94 +49,132 @@ public final class RequestBuilder {
|
||||||
/// - `.multipart`: Parts are encoded as multipart/form-data (in memory)
|
/// - `.multipart`: Parts are encoded as multipart/form-data (in memory)
|
||||||
/// - `.none`: Falls back to form encoding for compatibility
|
/// - `.none`: Falls back to form encoding for compatibility
|
||||||
///
|
///
|
||||||
/// - Parameter request: The HTTPRequest to convert
|
/// - Parameter request: The ``HTTPRequest`` to convert
|
||||||
/// - Returns: A URLRequest ready for URLSession
|
/// - Returns: A URLRequest ready for URLSession
|
||||||
/// - Throws: `RequestBuilderError.invalidFormData` if form encoding fails, if GET/DELETE
|
/// - Throws: ``RequestBuilderError/invalidFormData(_:)`` if form encoding fails, if GET/DELETE
|
||||||
/// requests contain multipart parts, or various encoding errors from JSONSerialization
|
/// requests contain multipart parts, or various encoding errors from JSONSerialization
|
||||||
/// or MultipartFormEncoder
|
/// or ``MultipartFormEncoder``
|
||||||
///
|
///
|
||||||
/// - Warning: Multipart requests are encoded entirely in memory. For large files,
|
/// - Warning: Multipart requests are encoded entirely in memory. For large files,
|
||||||
/// consider using MultipartFormEncoder.encodeFile() directly
|
/// consider using ``MultipartFormEncoder/encodeFile(parts:to:)`` to encode to disk first
|
||||||
public class func build(request: HTTPRequest) throws -> URLRequest {
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle parameters based on HTTP method
|
// Handle body content based on HTTP method and body type
|
||||||
if request.method == .get || request.method == .delete, let params = request.parameters {
|
switch request.body {
|
||||||
// Validate that GET and DELETE requests don't want request bodies, which we don't support.
|
case .none:
|
||||||
guard request.contentType != .multipart, request.parts.isEmpty else {
|
break
|
||||||
throw RequestBuilderError.invalidFormData(request)
|
case let .formParameters(params):
|
||||||
|
if request.method == .get || request.method == .delete {
|
||||||
|
try encodeQueryParameters(to: &result, parameters: params)
|
||||||
|
} else {
|
||||||
|
try encodeFormParameters(to: &result, request: request, parameters: params)
|
||||||
}
|
}
|
||||||
try encodeQueryParameters(to: &result, parameters: params)
|
case let .jsonParameters(params):
|
||||||
} else if !request.parts.isEmpty || request.contentType == .multipart {
|
if request.method == .get || request.method == .delete {
|
||||||
if request.contentType != .multipart {
|
try encodeQueryParameters(to: &result, parameters: params)
|
||||||
log.info("Encoding request as multipart, overriding its content type of \(request.contentType)")
|
} else {
|
||||||
|
try encodeJSONParameters(to: &result, parameters: params)
|
||||||
}
|
}
|
||||||
try encodeMultipartContent(to: &result, request: request)
|
case let .data(data, contentType):
|
||||||
} else if let params = request.parameters {
|
result.httpBody = data
|
||||||
try encodeParameters(to: &result, request: request, parameters: params)
|
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, request: HTTPRequest) throws {
|
private class func encodeMultipartContent(to urlRequest: inout URLRequest, parts: [MultipartFormEncoder.Part]) throws {
|
||||||
let encoder = MultipartFormEncoder()
|
let encoder = MultipartFormEncoder()
|
||||||
let body = try encoder.encodeData(parts: request.parts)
|
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.contentType, forHTTPHeaderField: "Content-Type")
|
||||||
urlRequest.addValue("\(body.contentLength)", forHTTPHeaderField: "Content-Length")
|
urlRequest.addValue("\(body.contentLength)", forHTTPHeaderField: "Content-Length")
|
||||||
urlRequest.httpBody = body.data
|
urlRequest.httpBody = body.data
|
||||||
}
|
}
|
||||||
|
|
||||||
private class func encodeParameters(to urlRequest: inout URLRequest, request: HTTPRequest, parameters: [String: any Sendable]) throws {
|
|
||||||
switch request.contentType {
|
|
||||||
case .json:
|
|
||||||
try encodeJSONParameters(to: &urlRequest, parameters: parameters)
|
|
||||||
|
|
||||||
case .none:
|
|
||||||
log.warning("Cannot serialize parameters without a content type, falling back to form encoding")
|
|
||||||
fallthrough
|
|
||||||
case .formEncoded:
|
|
||||||
try encodeFormParameters(to: &urlRequest, request: request, parameters: parameters)
|
|
||||||
|
|
||||||
case .multipart:
|
|
||||||
try encodeMultipartContent(to: &urlRequest, request: request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class func encodeJSONParameters(to urlRequest: inout URLRequest, parameters: [String: any Sendable]) throws {
|
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.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: parameters, options: [])
|
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: parameters, options: [])
|
||||||
}
|
}
|
||||||
|
|
||||||
private class func encodeFormParameters(to urlRequest: inout URLRequest, request: HTTPRequest, parameters: [String: any Sendable]) throws {
|
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")
|
urlRequest.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
guard let formData = FormEncoder.encode(parameters).data(using: .utf8) else {
|
guard let formData = FormEncoder.encode(parameters).data(using: .utf8) else {
|
||||||
throw RequestBuilderError.invalidFormData(request)
|
throw RequestBuilderError.invalidFormData(request)
|
||||||
}
|
}
|
||||||
urlRequest.httpBody = formData
|
urlRequest.httpBody = formData
|
||||||
}
|
}
|
||||||
|
|
||||||
private class func encodeQueryParameters(to urlRequest: inout URLRequest, parameters: [String: any Sendable]) throws {
|
private class func encodeQueryParameters(to urlRequest: inout URLRequest, parameters: [String: any Sendable]) throws {
|
||||||
guard let url = urlRequest.url else {
|
guard let url = urlRequest.url else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||||
let newQueryItems = parameters.compactMap { (key, value) -> URLQueryItem? in
|
let newQueryItems = parameters.compactMap { (key, value) -> URLQueryItem? in
|
||||||
URLQueryItem(name: key, value: String(describing: value))
|
URLQueryItem(name: key, value: String(describing: value))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let existingQueryItems = components?.queryItems {
|
if let existingQueryItems = components?.queryItems {
|
||||||
components?.queryItems = existingQueryItems + newQueryItems
|
components?.queryItems = existingQueryItems + newQueryItems
|
||||||
} else {
|
} else if !newQueryItems.isEmpty {
|
||||||
components?.queryItems = newQueryItems
|
components?.queryItems = newQueryItems
|
||||||
}
|
}
|
||||||
|
|
||||||
urlRequest.url = components?.url ?? url
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
70
Sources/Osiris/URLSession+Codable.swift
Normal file
70
Sources/Osiris/URLSession+Codable.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import XCTest
|
|
||||||
|
|
||||||
import OsirisTests
|
|
||||||
|
|
||||||
var tests = [XCTestCaseEntry]()
|
|
||||||
tests += OsirisTests.allTests()
|
|
||||||
XCTMain(tests)
|
|
||||||
|
|
@ -13,44 +13,44 @@ class FormEncoderTests: XCTestCase {
|
||||||
let result = FormEncoder.encode([:])
|
let result = FormEncoder.encode([:])
|
||||||
XCTAssertEqual(result, "")
|
XCTAssertEqual(result, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEncodeSingleStringValue() {
|
func testEncodeSingleStringValue() {
|
||||||
let parameters = ["name": "Jane Doe"]
|
let parameters = ["name": "Jane Doe"]
|
||||||
let result = FormEncoder.encode(parameters)
|
let result = FormEncoder.encode(parameters)
|
||||||
XCTAssertEqual(result, "name=Jane%20Doe")
|
XCTAssertEqual(result, "name=Jane%20Doe")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEncodeMultipleStringValues() {
|
func testEncodeMultipleStringValues() {
|
||||||
let parameters = ["name": "John", "email": "john@example.net"]
|
let parameters = ["name": "John", "email": "john@example.net"]
|
||||||
let result = FormEncoder.encode(parameters)
|
let result = FormEncoder.encode(parameters)
|
||||||
// Keys should be sorted alphabetically
|
// Keys should be sorted alphabetically
|
||||||
XCTAssertEqual(result, "email=john%40example.net&name=John")
|
XCTAssertEqual(result, "email=john%40example.net&name=John")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEncodeIntegerValue() {
|
func testEncodeIntegerValue() {
|
||||||
let parameters = ["age": 30]
|
let parameters = ["age": 30]
|
||||||
let result = FormEncoder.encode(parameters)
|
let result = FormEncoder.encode(parameters)
|
||||||
XCTAssertEqual(result, "age=30")
|
XCTAssertEqual(result, "age=30")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEncodeBooleanValues() {
|
func testEncodeBooleanValues() {
|
||||||
let parameters = ["active": true, "verified": false]
|
let parameters = ["active": true, "verified": false]
|
||||||
let result = FormEncoder.encode(parameters)
|
let result = FormEncoder.encode(parameters)
|
||||||
XCTAssertEqual(result, "active=1&verified=0")
|
XCTAssertEqual(result, "active=1&verified=0")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEncodeNSNumberBooleanValues() {
|
func testEncodeNSNumberBooleanValues() {
|
||||||
let parameters = ["active": NSNumber(value: true), "verified": NSNumber(value: false)]
|
let parameters = ["active": NSNumber(value: true), "verified": NSNumber(value: false)]
|
||||||
let result = FormEncoder.encode(parameters)
|
let result = FormEncoder.encode(parameters)
|
||||||
XCTAssertEqual(result, "active=1&verified=0")
|
XCTAssertEqual(result, "active=1&verified=0")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEncodeNSNumberIntegerValues() {
|
func testEncodeNSNumberIntegerValues() {
|
||||||
let parameters = ["count": NSNumber(value: 42)]
|
let parameters = ["count": NSNumber(value: 42)]
|
||||||
let result = FormEncoder.encode(parameters)
|
let result = FormEncoder.encode(parameters)
|
||||||
XCTAssertEqual(result, "count=42")
|
XCTAssertEqual(result, "count=42")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEncodeNestedDictionary() {
|
func testEncodeNestedDictionary() {
|
||||||
let personData: [String: any Sendable] = ["name": "Jane", "age": 30]
|
let personData: [String: any Sendable] = ["name": "Jane", "age": 30]
|
||||||
let parameters: [String: any Sendable] = ["person": personData]
|
let parameters: [String: any Sendable] = ["person": personData]
|
||||||
|
|
@ -60,13 +60,13 @@ class FormEncoderTests: XCTestCase {
|
||||||
let expected2 = "person%5Bname%5D=Jane&person%5Bage%5D=30"
|
let expected2 = "person%5Bname%5D=Jane&person%5Bage%5D=30"
|
||||||
XCTAssertTrue(result == expected1 || result == expected2, "Result '\(result)' doesn't match either expected format")
|
XCTAssertTrue(result == expected1 || result == expected2, "Result '\(result)' doesn't match either expected format")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEncodeArray() {
|
func testEncodeArray() {
|
||||||
let parameters = ["tags": ["swift", "ios", "mobile"]]
|
let parameters = ["tags": ["swift", "ios", "mobile"]]
|
||||||
let result = FormEncoder.encode(parameters)
|
let result = FormEncoder.encode(parameters)
|
||||||
XCTAssertEqual(result, "tags%5B%5D=swift&tags%5B%5D=ios&tags%5B%5D=mobile")
|
XCTAssertEqual(result, "tags%5B%5D=swift&tags%5B%5D=ios&tags%5B%5D=mobile")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEncodeComplexNestedStructure() {
|
func testEncodeComplexNestedStructure() {
|
||||||
let preferences: [String: any Sendable] = ["theme": "dark", "notifications": true]
|
let preferences: [String: any Sendable] = ["theme": "dark", "notifications": true]
|
||||||
let tags: [any Sendable] = ["rockstar", "swiftie"]
|
let tags: [any Sendable] = ["rockstar", "swiftie"]
|
||||||
|
|
@ -76,7 +76,7 @@ class FormEncoderTests: XCTestCase {
|
||||||
"tags": tags
|
"tags": tags
|
||||||
]
|
]
|
||||||
let parameters: [String: any Sendable] = ["person": personData]
|
let parameters: [String: any Sendable] = ["person": personData]
|
||||||
|
|
||||||
let result = FormEncoder.encode(parameters)
|
let result = FormEncoder.encode(parameters)
|
||||||
// The actual order depends on how the dictionary is sorted, so let's test the components
|
// 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%5Bname%5D=Jane"))
|
||||||
|
|
@ -85,38 +85,38 @@ class FormEncoderTests: XCTestCase {
|
||||||
XCTAssertTrue(result.contains("person%5Btags%5D%5B%5D=rockstar"))
|
XCTAssertTrue(result.contains("person%5Btags%5D%5B%5D=rockstar"))
|
||||||
XCTAssertTrue(result.contains("person%5Btags%5D%5B%5D=swiftie"))
|
XCTAssertTrue(result.contains("person%5Btags%5D%5B%5D=swiftie"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEncodeSpecialCharacters() {
|
func testEncodeSpecialCharacters() {
|
||||||
let parameters = ["message": "Hello & welcome to Abbey Road Studios! 100% music magic guaranteed."]
|
let parameters = ["message": "Hello & welcome to Abbey Road Studios! 100% music magic guaranteed."]
|
||||||
let result = FormEncoder.encode(parameters)
|
let result = FormEncoder.encode(parameters)
|
||||||
XCTAssertEqual(result, "message=Hello%20%26%20welcome%20to%20Abbey%20Road%20Studios%21%20100%25%20music%20magic%20guaranteed.")
|
XCTAssertEqual(result, "message=Hello%20%26%20welcome%20to%20Abbey%20Road%20Studios%21%20100%25%20music%20magic%20guaranteed.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEncodeUnicodeCharacters() {
|
func testEncodeUnicodeCharacters() {
|
||||||
let parameters = ["emoji": "🚀👨💻", "chinese": "你好"]
|
let parameters = ["emoji": "🚀👨💻", "chinese": "你好"]
|
||||||
let result = FormEncoder.encode(parameters)
|
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")
|
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() {
|
func testKeysAreSortedAlphabetically() {
|
||||||
let parameters = ["zebra": "z", "alpha": "a", "beta": "b"]
|
let parameters = ["zebra": "z", "alpha": "a", "beta": "b"]
|
||||||
let result = FormEncoder.encode(parameters)
|
let result = FormEncoder.encode(parameters)
|
||||||
XCTAssertEqual(result, "alpha=a&beta=b&zebra=z")
|
XCTAssertEqual(result, "alpha=a&beta=b&zebra=z")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEncodeDoubleValue() {
|
func testEncodeDoubleValue() {
|
||||||
let parameters = ["price": 19.99]
|
let parameters = ["price": 19.99]
|
||||||
let result = FormEncoder.encode(parameters)
|
let result = FormEncoder.encode(parameters)
|
||||||
XCTAssertEqual(result, "price=19.99")
|
XCTAssertEqual(result, "price=19.99")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEncodeNilValuesAsStrings() {
|
func testEncodeNilValuesAsStrings() {
|
||||||
// Swift's Any type handling - nil values become "<null>" strings
|
// Swift's Any type handling - nil values become "<null>" strings
|
||||||
let parameters = ["optional": NSNull()]
|
let parameters = ["optional": NSNull()]
|
||||||
let result = FormEncoder.encode(parameters)
|
let result = FormEncoder.encode(parameters)
|
||||||
XCTAssertEqual(result, "optional=%3Cnull%3E")
|
XCTAssertEqual(result, "optional=%3Cnull%3E")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRFC3986Compliance() {
|
func testRFC3986Compliance() {
|
||||||
// Test that reserved characters are properly encoded according to RFC 3986
|
// Test that reserved characters are properly encoded according to RFC 3986
|
||||||
let parameters = ["reserved": "!*'();:@&=+$,/?#[]"]
|
let parameters = ["reserved": "!*'();:@&=+$,/?#[]"]
|
||||||
|
|
@ -124,14 +124,14 @@ class FormEncoderTests: XCTestCase {
|
||||||
// According to the implementation, ? and / are NOT encoded per RFC 3986 Section 3.4
|
// 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")
|
XCTAssertEqual(result, "reserved=%21%2A%27%28%29%3B%3A%40%26%3D%2B%24%2C/?%23%5B%5D")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testURLQueryAllowedCharacters() {
|
func testURLQueryAllowedCharacters() {
|
||||||
// Test characters that should NOT be encoded
|
// Test characters that should NOT be encoded
|
||||||
let parameters = ["allowed": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"]
|
let parameters = ["allowed": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"]
|
||||||
let result = FormEncoder.encode(parameters)
|
let result = FormEncoder.encode(parameters)
|
||||||
XCTAssertEqual(result, "allowed=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
|
XCTAssertEqual(result, "allowed=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMixedDataTypes() {
|
func testMixedDataTypes() {
|
||||||
let array: [any Sendable] = [1, 2, 3]
|
let array: [any Sendable] = [1, 2, 3]
|
||||||
let nested: [String: any Sendable] = ["key": "nested_value"]
|
let nested: [String: any Sendable] = ["key": "nested_value"]
|
||||||
|
|
@ -143,7 +143,7 @@ class FormEncoderTests: XCTestCase {
|
||||||
"array": array,
|
"array": array,
|
||||||
"nested": nested
|
"nested": nested
|
||||||
]
|
]
|
||||||
|
|
||||||
let result = FormEncoder.encode(parameters)
|
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"
|
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)
|
XCTAssertEqual(result, expected)
|
||||||
|
|
@ -152,29 +152,29 @@ class FormEncoderTests: XCTestCase {
|
||||||
|
|
||||||
// Test the NSNumber extension
|
// Test the NSNumber extension
|
||||||
class NSNumberBoolExtensionTests: XCTestCase {
|
class NSNumberBoolExtensionTests: XCTestCase {
|
||||||
|
|
||||||
func testNSNumberIsBoolForBooleans() {
|
func testNSNumberIsBoolForBooleans() {
|
||||||
let trueNumber = NSNumber(value: true)
|
let trueNumber = NSNumber(value: true)
|
||||||
let falseNumber = NSNumber(value: false)
|
let falseNumber = NSNumber(value: false)
|
||||||
|
|
||||||
XCTAssertTrue(trueNumber.isBool)
|
XCTAssertTrue(trueNumber.isBool)
|
||||||
XCTAssertTrue(falseNumber.isBool)
|
XCTAssertTrue(falseNumber.isBool)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNSNumberIsBoolForIntegers() {
|
func testNSNumberIsBoolForIntegers() {
|
||||||
let intNumber = NSNumber(value: 42)
|
let intNumber = NSNumber(value: 42)
|
||||||
let zeroNumber = NSNumber(value: 0)
|
let zeroNumber = NSNumber(value: 0)
|
||||||
let oneNumber = NSNumber(value: 1)
|
let oneNumber = NSNumber(value: 1)
|
||||||
|
|
||||||
XCTAssertFalse(intNumber.isBool)
|
XCTAssertFalse(intNumber.isBool)
|
||||||
XCTAssertFalse(zeroNumber.isBool)
|
XCTAssertFalse(zeroNumber.isBool)
|
||||||
XCTAssertFalse(oneNumber.isBool)
|
XCTAssertFalse(oneNumber.isBool)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNSNumberIsBoolForDoubles() {
|
func testNSNumberIsBoolForDoubles() {
|
||||||
let doubleNumber = NSNumber(value: 3.14)
|
let doubleNumber = NSNumber(value: 3.14)
|
||||||
let zeroDouble = NSNumber(value: 0.0)
|
let zeroDouble = NSNumber(value: 0.0)
|
||||||
|
|
||||||
XCTAssertFalse(doubleNumber.isBool)
|
XCTAssertFalse(doubleNumber.isBool)
|
||||||
XCTAssertFalse(zeroDouble.isBool)
|
XCTAssertFalse(zeroDouble.isBool)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
146
Tests/OsirisTests/HTTPRequestCodableTests.swift
Normal file
146
Tests/OsirisTests/HTTPRequestCodableTests.swift
Normal 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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,54 +9,64 @@
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
class HTTPRequestErrorTests: XCTestCase {
|
class HTTPRequestErrorTests: XCTestCase {
|
||||||
|
|
||||||
func testHTTPError() {
|
func testHTTPError() {
|
||||||
let error = HTTPRequestError.http
|
let error = HTTPRequestError.http
|
||||||
XCTAssertEqual(error.localizedDescription, "HTTP request failed with non-2xx status code")
|
XCTAssertEqual(error.localizedDescription, "HTTP request failed with non-2xx status code")
|
||||||
XCTAssertEqual(error.failureReason, "The server returned an error status code")
|
XCTAssertEqual(error.failureReason, "The server returned an error status code")
|
||||||
XCTAssertEqual(error.recoverySuggestion, "Check the server response for error details")
|
XCTAssertEqual(error.recoverySuggestion, "Check the server response for error details")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testUnknownError() {
|
func testUnknownError() {
|
||||||
let error = HTTPRequestError.unknown
|
let error = HTTPRequestError.unknown
|
||||||
XCTAssertEqual(error.localizedDescription, "An unknown error occurred")
|
XCTAssertEqual(error.localizedDescription, "An unknown error occurred")
|
||||||
XCTAssertEqual(error.failureReason, "An unexpected error occurred during the request")
|
XCTAssertEqual(error.failureReason, "An unexpected error occurred during the request")
|
||||||
XCTAssertEqual(error.recoverySuggestion, "Check network connectivity and try again")
|
XCTAssertEqual(error.recoverySuggestion, "Check network connectivity and try again")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testErrorDescriptionIsNeverNil() {
|
func testErrorDescriptionIsNeverNil() {
|
||||||
let allErrors: [HTTPRequestError] = [
|
let allErrors: [HTTPRequestError] = [
|
||||||
.http,
|
.http,
|
||||||
.unknown
|
.unknown,
|
||||||
|
.invalidRequestBody
|
||||||
]
|
]
|
||||||
|
|
||||||
for error in allErrors {
|
for error in allErrors {
|
||||||
XCTAssertNotNil(error.errorDescription)
|
XCTAssertNotNil(error.errorDescription)
|
||||||
XCTAssertFalse(error.errorDescription!.isEmpty)
|
XCTAssertFalse(error.errorDescription!.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFailureReasonIsNeverNil() {
|
func testFailureReasonIsNeverNil() {
|
||||||
let allErrors: [HTTPRequestError] = [
|
let allErrors: [HTTPRequestError] = [
|
||||||
.http,
|
.http,
|
||||||
.unknown
|
.unknown,
|
||||||
|
.invalidRequestBody
|
||||||
]
|
]
|
||||||
|
|
||||||
for error in allErrors {
|
for error in allErrors {
|
||||||
XCTAssertNotNil(error.failureReason)
|
XCTAssertNotNil(error.failureReason)
|
||||||
XCTAssertFalse(error.failureReason!.isEmpty)
|
XCTAssertFalse(error.failureReason!.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRecoverySuggestionIsNeverNil() {
|
func testRecoverySuggestionIsNeverNil() {
|
||||||
let allErrors: [HTTPRequestError] = [
|
let allErrors: [HTTPRequestError] = [
|
||||||
.http,
|
.http,
|
||||||
.unknown
|
.unknown,
|
||||||
|
.invalidRequestBody
|
||||||
]
|
]
|
||||||
|
|
||||||
for error in allErrors {
|
for error in allErrors {
|
||||||
XCTAssertNotNil(error.recoverySuggestion)
|
XCTAssertNotNil(error.recoverySuggestion)
|
||||||
XCTAssertFalse(error.recoverySuggestion!.isEmpty)
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,161 +6,405 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
@testable import Osiris
|
@testable import Osiris
|
||||||
|
import UniformTypeIdentifiers
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
class HTTPRequestTests: XCTestCase {
|
class HTTPRequestTests: XCTestCase {
|
||||||
let baseURL = URL(string: "https://api.example.net")!
|
let baseURL = URL(string: "https://api.example.net")!
|
||||||
|
|
||||||
func testHTTPRequestInitialization() {
|
func testHTTPRequestInitialization() {
|
||||||
let request = HTTPRequest(method: .get, url: baseURL)
|
|
||||||
XCTAssertEqual(request.method, .get)
|
|
||||||
XCTAssertEqual(request.url, baseURL)
|
|
||||||
XCTAssertEqual(request.contentType, .none)
|
|
||||||
XCTAssertNil(request.parameters)
|
|
||||||
XCTAssertTrue(request.headers.isEmpty)
|
|
||||||
XCTAssertTrue(request.parts.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testHTTPRequestWithParameters() {
|
|
||||||
let params = ["key": "value", "number": 42] as [String: any Sendable]
|
|
||||||
let request = HTTPRequest(method: .post, url: baseURL, contentType: .json, parameters: params)
|
|
||||||
|
|
||||||
XCTAssertEqual(request.method, .post)
|
|
||||||
XCTAssertEqual(request.contentType, .json)
|
|
||||||
XCTAssertNotNil(request.parameters)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testGETConvenience() {
|
|
||||||
let request = HTTPRequest.get(baseURL)
|
let request = HTTPRequest.get(baseURL)
|
||||||
XCTAssertEqual(request.method, .get)
|
XCTAssertEqual(request.method, .get)
|
||||||
XCTAssertEqual(request.url, baseURL)
|
XCTAssertEqual(request.url, baseURL)
|
||||||
XCTAssertEqual(request.contentType, .none)
|
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() {
|
func testPOSTConvenience() {
|
||||||
let params = ["name": "Jane"]
|
let params = ["name": "Trent"]
|
||||||
let request = HTTPRequest.post(baseURL, contentType: .json, parameters: params)
|
let request = HTTPRequest.postJSON(baseURL, body: params)
|
||||||
|
|
||||||
XCTAssertEqual(request.method, .post)
|
XCTAssertEqual(request.method, HTTPMethod.post)
|
||||||
XCTAssertEqual(request.contentType, .json)
|
XCTAssertEqual(request.contentType, HTTPContentType.json)
|
||||||
XCTAssertNotNil(request.parameters)
|
if case .jsonParameters(let bodyParams) = request.body {
|
||||||
|
XCTAssertEqual(bodyParams.count, 1)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected jsonParameters body")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPUTConvenience() {
|
func testPUTConvenience() {
|
||||||
let params = ["name": "Jane"]
|
let params = ["name": "Trent"]
|
||||||
let request = HTTPRequest.put(baseURL, contentType: .formEncoded, parameters: params)
|
let request = HTTPRequest.putForm(baseURL, parameters: params)
|
||||||
|
|
||||||
XCTAssertEqual(request.method, .put)
|
XCTAssertEqual(request.method, HTTPMethod.put)
|
||||||
XCTAssertEqual(request.contentType, .formEncoded)
|
XCTAssertEqual(request.contentType, HTTPContentType.formEncoded)
|
||||||
XCTAssertNotNil(request.parameters)
|
if case .formParameters(let bodyParams) = request.body {
|
||||||
|
XCTAssertEqual(bodyParams.count, 1)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected formParameters body")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDELETEConvenience() {
|
func testDELETEConvenience() {
|
||||||
let request = HTTPRequest.delete(baseURL)
|
let request = HTTPRequest.delete(baseURL)
|
||||||
XCTAssertEqual(request.method, .delete)
|
XCTAssertEqual(request.method, HTTPMethod.delete)
|
||||||
XCTAssertEqual(request.url, baseURL)
|
XCTAssertEqual(request.url, baseURL)
|
||||||
XCTAssertEqual(request.contentType, .none)
|
XCTAssertEqual(request.contentType, HTTPContentType.none)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMultipartPartsAutomaticallySetContentType() {
|
func testMultipartPartsAutomaticallySetContentType() {
|
||||||
var request = HTTPRequest.post(baseURL)
|
let parts = [MultipartFormEncoder.Part.text("value", name: "field")]
|
||||||
XCTAssertEqual(request.contentType, .none)
|
let request = HTTPRequest.postMultipart(baseURL, parts: parts)
|
||||||
|
|
||||||
request.parts = [.text("value", name: "field")]
|
XCTAssertEqual(request.contentType, HTTPContentType.multipart)
|
||||||
XCTAssertEqual(request.contentType, .multipart)
|
if case .multipart(let bodyParts) = request.body {
|
||||||
|
XCTAssertEqual(bodyParts.count, 1)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected multipart body")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
func testAddMultipartJPEG() {
|
func testAddMultipartJPEG() {
|
||||||
var request = HTTPRequest.post(baseURL)
|
var request = HTTPRequest.post(baseURL)
|
||||||
|
|
||||||
// Create a simple 1x1 pixel image
|
// Create a simple 1x1 pixel image
|
||||||
let size = CGSize(width: 1, height: 1)
|
let size = CGSize(width: 1, height: 1)
|
||||||
UIGraphicsBeginImageContext(size)
|
UIGraphicsBeginImageContext(size)
|
||||||
let image = UIGraphicsGetImageFromCurrentImageContext()!
|
let image = UIGraphicsGetImageFromCurrentImageContext()!
|
||||||
UIGraphicsEndImageContext()
|
UIGraphicsEndImageContext()
|
||||||
|
|
||||||
request.addMultipartJPEG(name: "avatar", image: image, quality: 0.8, filename: "test.jpg")
|
request.addMultipartJPEG(name: "avatar", image: image, quality: 0.8, filename: "test.jpg")
|
||||||
|
|
||||||
XCTAssertEqual(request.parts.count, 1)
|
XCTAssertEqual(request.contentType, HTTPContentType.multipart)
|
||||||
XCTAssertEqual(request.contentType, .multipart)
|
|
||||||
|
if case .multipart(let parts) = request.body {
|
||||||
let part = request.parts.first!
|
XCTAssertEqual(parts.count, 1)
|
||||||
XCTAssertEqual(part.name, "avatar")
|
let part = parts.first!
|
||||||
|
XCTAssertEqual(part.name, "avatar")
|
||||||
if case let .binaryData(_, type, filename) = part.content {
|
|
||||||
XCTAssertEqual(type, "image/jpeg")
|
if case let .binaryData(_, type, filename) = part.content {
|
||||||
XCTAssertEqual(filename, "test.jpg")
|
XCTAssertEqual(type, "image/jpeg")
|
||||||
|
XCTAssertEqual(filename, "test.jpg")
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected binary data content")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
XCTFail("Expected binary data content")
|
XCTFail("Expected multipart body")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAddMultipartJPEGWithInvalidQuality() {
|
func testAddMultipartJPEGWithInvalidQuality() {
|
||||||
var request = HTTPRequest.post(baseURL)
|
var request = HTTPRequest.post(baseURL)
|
||||||
|
|
||||||
// Create a valid image
|
// Create a valid image
|
||||||
let size = CGSize(width: 1, height: 1)
|
let size = CGSize(width: 1, height: 1)
|
||||||
UIGraphicsBeginImageContext(size)
|
UIGraphicsBeginImageContext(size)
|
||||||
let image = UIGraphicsGetImageFromCurrentImageContext()!
|
let image = UIGraphicsGetImageFromCurrentImageContext()!
|
||||||
UIGraphicsEndImageContext()
|
UIGraphicsEndImageContext()
|
||||||
|
|
||||||
// Test with extreme quality values that might cause issues
|
// Test with extreme quality values that might cause issues
|
||||||
request.addMultipartJPEG(name: "avatar1", image: image, quality: -1.0)
|
request.addMultipartJPEG(name: "avatar1", image: image, quality: -1.0)
|
||||||
request.addMultipartJPEG(name: "avatar2", image: image, quality: 2.0)
|
request.addMultipartJPEG(name: "avatar2", image: image, quality: 2.0)
|
||||||
|
|
||||||
// The method should handle extreme quality values gracefully
|
// The method should handle extreme quality values gracefully
|
||||||
// Either by clamping them or by having jpegData handle them
|
// Either by clamping them or by having jpegData handle them
|
||||||
XCTAssertTrue(request.parts.count >= 0) // Should not crash
|
if case .multipart(let parts) = request.body {
|
||||||
|
XCTAssertTrue(parts.count >= 0) // Should not crash
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
func testHTTPRequestPATCHConvenience() {
|
func testHTTPRequestPATCHConvenience() {
|
||||||
let params = ["status": "active"]
|
let params = ["status": "active"]
|
||||||
let request = HTTPRequest(method: .patch, url: baseURL, contentType: .json, parameters: params)
|
let request = HTTPRequest.patchJSON(baseURL, body: params)
|
||||||
|
|
||||||
XCTAssertEqual(request.method, .patch)
|
XCTAssertEqual(request.method, HTTPMethod.patch)
|
||||||
XCTAssertEqual(request.contentType, .json)
|
XCTAssertEqual(request.contentType, HTTPContentType.json)
|
||||||
XCTAssertNotNil(request.parameters)
|
if case .jsonParameters(let bodyParams) = request.body {
|
||||||
|
XCTAssertEqual(bodyParams.count, 1)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected jsonParameters body")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testHTTPRequestWithMultipleHeaders() {
|
|
||||||
var request = HTTPRequest.get(baseURL)
|
|
||||||
request.addHeader(name: "Authorization", value: "Bearer token123")
|
|
||||||
request.addHeader(name: "User-Agent", value: "Osiris/2.0")
|
|
||||||
request.addHeader(name: "Accept", value: "application/json")
|
|
||||||
|
|
||||||
XCTAssertEqual(request.headers["Authorization"], "Bearer token123")
|
|
||||||
XCTAssertEqual(request.headers["User-Agent"], "Osiris/2.0")
|
|
||||||
XCTAssertEqual(request.headers["Accept"], "application/json")
|
|
||||||
XCTAssertEqual(request.headers.count, 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testHTTPRequestOverwriteHeaders() {
|
|
||||||
var request = HTTPRequest.get(baseURL)
|
|
||||||
request.addHeader(name: "Accept", value: "application/xml")
|
|
||||||
request.addHeader(name: "Accept", value: "application/json") // Should overwrite
|
|
||||||
|
|
||||||
XCTAssertEqual(request.headers["Accept"], "application/json")
|
|
||||||
XCTAssertEqual(request.headers.count, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testHTTPRequestWithEmptyMultipartParts() {
|
func testHTTPRequestWithEmptyMultipartParts() {
|
||||||
var request = HTTPRequest.post(baseURL)
|
let request = HTTPRequest.postMultipart(baseURL, parts: [])
|
||||||
request.parts = [] // Empty parts array
|
|
||||||
|
XCTAssertEqual(request.contentType, HTTPContentType.multipart) // Multipart even with empty parts
|
||||||
XCTAssertEqual(request.contentType, .none) // Should not be set to multipart
|
if case .multipart(let parts) = request.body {
|
||||||
XCTAssertTrue(request.parts.isEmpty)
|
XCTAssertTrue(parts.isEmpty)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected multipart body")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testHTTPRequestMultipartPartsResetContentType() {
|
func testHTTPRequestBodyTypeDeterminesContentType() {
|
||||||
var request = HTTPRequest.post(baseURL, contentType: .json)
|
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)
|
XCTAssertEqual(request.contentType, .json)
|
||||||
|
if case .jsonParameters(let bodyParams) = request.body {
|
||||||
request.parts = [.text("test", name: "field")]
|
XCTAssertEqual(bodyParams.count, 1)
|
||||||
XCTAssertEqual(request.contentType, .multipart) // Should be automatically changed
|
} else {
|
||||||
|
XCTFail("Expected jsonParameters body")
|
||||||
request.parts = [] // Clear parts
|
}
|
||||||
XCTAssertEqual(request.contentType, .multipart) // Should remain multipart
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ class HTTPResponseTests: XCTestCase {
|
||||||
let url = URL(string: "https://api.example.net")!
|
let url = URL(string: "https://api.example.net")!
|
||||||
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!
|
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!
|
||||||
let data = Data("{}".utf8)
|
let data = Data("{}".utf8)
|
||||||
|
|
||||||
let response = HTTPResponse(response: httpResponse, data: data, error: nil)
|
let response = HTTPResponse(response: httpResponse, data: data, error: nil)
|
||||||
|
|
||||||
if case let .success(urlResponse, responseData) = response {
|
if case let .success(urlResponse, responseData) = response {
|
||||||
XCTAssertEqual(urlResponse.statusCode, 200)
|
XCTAssertEqual(urlResponse.statusCode, 200)
|
||||||
XCTAssertEqual(responseData, data)
|
XCTAssertEqual(responseData, data)
|
||||||
|
|
@ -23,14 +23,14 @@ class HTTPResponseTests: XCTestCase {
|
||||||
XCTFail("Expected success response")
|
XCTFail("Expected success response")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFailureResponseWithError() {
|
func testFailureResponseWithError() {
|
||||||
let url = URL(string: "https://api.example.net")!
|
let url = URL(string: "https://api.example.net")!
|
||||||
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||||
let error = NSError(domain: "test", code: 1, userInfo: nil)
|
let error = NSError(domain: "test", code: 1, userInfo: nil)
|
||||||
|
|
||||||
let response = HTTPResponse(response: httpResponse, data: nil, error: error)
|
let response = HTTPResponse(response: httpResponse, data: nil, error: error)
|
||||||
|
|
||||||
if case let .failure(responseError, urlResponse, responseData) = response {
|
if case let .failure(responseError, urlResponse, responseData) = response {
|
||||||
XCTAssertEqual((responseError as NSError).domain, "test")
|
XCTAssertEqual((responseError as NSError).domain, "test")
|
||||||
XCTAssertEqual(urlResponse?.statusCode, 200)
|
XCTAssertEqual(urlResponse?.statusCode, 200)
|
||||||
|
|
@ -39,14 +39,14 @@ class HTTPResponseTests: XCTestCase {
|
||||||
XCTFail("Expected failure response")
|
XCTFail("Expected failure response")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFailureResponseWithHTTPError() {
|
func testFailureResponseWithHTTPError() {
|
||||||
let url = URL(string: "https://api.example.net")!
|
let url = URL(string: "https://api.example.net")!
|
||||||
let httpResponse = HTTPURLResponse(url: url, statusCode: 404, httpVersion: nil, headerFields: nil)!
|
let httpResponse = HTTPURLResponse(url: url, statusCode: 404, httpVersion: nil, headerFields: nil)!
|
||||||
let data = Data("Not Found".utf8)
|
let data = Data("Not Found".utf8)
|
||||||
|
|
||||||
let response = HTTPResponse(response: httpResponse, data: data, error: nil)
|
let response = HTTPResponse(response: httpResponse, data: data, error: nil)
|
||||||
|
|
||||||
if case let .failure(error, urlResponse, responseData) = response {
|
if case let .failure(error, urlResponse, responseData) = response {
|
||||||
XCTAssertTrue(error is HTTPRequestError)
|
XCTAssertTrue(error is HTTPRequestError)
|
||||||
XCTAssertEqual(urlResponse?.statusCode, 404)
|
XCTAssertEqual(urlResponse?.statusCode, 404)
|
||||||
|
|
@ -55,114 +55,114 @@ class HTTPResponseTests: XCTestCase {
|
||||||
XCTFail("Expected failure response")
|
XCTFail("Expected failure response")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testResponseWithoutHTTPURLResponse() {
|
func testResponseWithoutHTTPURLResponse() {
|
||||||
let response = HTTPResponse(response: nil, data: nil, error: nil)
|
let response = HTTPResponse(response: nil, data: nil, error: nil)
|
||||||
|
|
||||||
if case let .failure(error, _, _) = response {
|
if case let .failure(error, _, _) = response {
|
||||||
XCTAssertTrue(error is HTTPRequestError)
|
XCTAssertTrue(error is HTTPRequestError)
|
||||||
} else {
|
} else {
|
||||||
XCTFail("Expected failure response")
|
XCTFail("Expected failure response")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDataProperty() {
|
func testDataProperty() {
|
||||||
let data = Data("test".utf8)
|
let data = Data("test".utf8)
|
||||||
let url = URL(string: "https://api.example.net")!
|
let url = URL(string: "https://api.example.net")!
|
||||||
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||||
|
|
||||||
let successResponse = HTTPResponse(response: httpResponse, data: data, error: nil)
|
let successResponse = HTTPResponse(response: httpResponse, data: data, error: nil)
|
||||||
XCTAssertEqual(successResponse.data, data)
|
XCTAssertEqual(successResponse.data, data)
|
||||||
|
|
||||||
let httpErrorResponse = HTTPURLResponse(url: url, statusCode: 400, httpVersion: nil, headerFields: nil)!
|
let httpErrorResponse = HTTPURLResponse(url: url, statusCode: 400, httpVersion: nil, headerFields: nil)!
|
||||||
let failureResponse = HTTPResponse(response: httpErrorResponse, data: data, error: nil)
|
let failureResponse = HTTPResponse(response: httpErrorResponse, data: data, error: nil)
|
||||||
XCTAssertEqual(failureResponse.data, data)
|
XCTAssertEqual(failureResponse.data, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testStatusProperty() {
|
func testStatusProperty() {
|
||||||
let url = URL(string: "https://api.example.net")!
|
let url = URL(string: "https://api.example.net")!
|
||||||
let httpResponse = HTTPURLResponse(url: url, statusCode: 201, httpVersion: nil, headerFields: nil)!
|
let httpResponse = HTTPURLResponse(url: url, statusCode: 201, httpVersion: nil, headerFields: nil)!
|
||||||
|
|
||||||
let response = HTTPResponse(response: httpResponse, data: nil, error: nil)
|
let response = HTTPResponse(response: httpResponse, data: nil, error: nil)
|
||||||
XCTAssertEqual(response.status, 201)
|
XCTAssertEqual(response.status, 201)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testHeadersProperty() {
|
func testHeadersProperty() {
|
||||||
let url = URL(string: "https://api.example.net")!
|
let url = URL(string: "https://api.example.net")!
|
||||||
let headers = ["Content-Type": "application/json", "X-Custom": "value"]
|
let headers = ["Content-Type": "application/json", "X-Custom": "value"]
|
||||||
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: headers)!
|
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: headers)!
|
||||||
|
|
||||||
let response = HTTPResponse(response: httpResponse, data: nil, error: nil)
|
let response = HTTPResponse(response: httpResponse, data: nil, error: nil)
|
||||||
XCTAssertEqual(response.headers["Content-Type"] as? String, "application/json")
|
XCTAssertEqual(response.headers["Content-Type"] as? String, "application/json")
|
||||||
XCTAssertEqual(response.headers["X-Custom"] as? String, "value")
|
XCTAssertEqual(response.headers["X-Custom"] as? String, "value")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBodyStringProperty() {
|
func testBodyStringProperty() {
|
||||||
let url = URL(string: "https://api.example.net")!
|
let url = URL(string: "https://api.example.net")!
|
||||||
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||||
let data = Data("Hello, World!".utf8)
|
let data = Data("Hello, World!".utf8)
|
||||||
|
|
||||||
let response = HTTPResponse(response: httpResponse, data: data, error: nil)
|
let response = HTTPResponse(response: httpResponse, data: data, error: nil)
|
||||||
XCTAssertEqual(response.bodyString, "Hello, World!")
|
XCTAssertEqual(response.bodyString, "Hello, World!")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBodyStringPropertyWithNoData() {
|
func testBodyStringPropertyWithNoData() {
|
||||||
let url = URL(string: "https://api.example.net")!
|
let url = URL(string: "https://api.example.net")!
|
||||||
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||||
|
|
||||||
let response = HTTPResponse(response: httpResponse, data: nil, error: nil)
|
let response = HTTPResponse(response: httpResponse, data: nil, error: nil)
|
||||||
XCTAssertEqual(response.bodyString, "")
|
XCTAssertEqual(response.bodyString, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDictionaryFromJSONProperty() {
|
func testDictionaryFromJSONProperty() {
|
||||||
let url = URL(string: "https://api.example.net")!
|
let url = URL(string: "https://api.example.net")!
|
||||||
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||||
let json = ["name": "John", "age": 30] as [String: any Sendable]
|
let json = ["name": "John", "age": 30] as [String: any Sendable]
|
||||||
let data = try! JSONSerialization.data(withJSONObject: json)
|
let data = try! JSONSerialization.data(withJSONObject: json)
|
||||||
|
|
||||||
let response = HTTPResponse(response: httpResponse, data: data, error: nil)
|
let response = HTTPResponse(response: httpResponse, data: data, error: nil)
|
||||||
let dictionary = response.dictionaryFromJSON
|
let dictionary = response.dictionaryFromJSON
|
||||||
|
|
||||||
XCTAssertEqual(dictionary["name"] as? String, "John")
|
XCTAssertEqual(dictionary["name"] as? String, "John")
|
||||||
XCTAssertEqual(dictionary["age"] as? Int, 30)
|
XCTAssertEqual(dictionary["age"] as? Int, 30)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDictionaryFromJSONPropertyWithInvalidJSON() {
|
func testDictionaryFromJSONPropertyWithInvalidJSON() {
|
||||||
let url = URL(string: "https://api.example.net")!
|
let url = URL(string: "https://api.example.net")!
|
||||||
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||||
let data = Data("invalid json".utf8)
|
let data = Data("invalid json".utf8)
|
||||||
|
|
||||||
let response = HTTPResponse(response: httpResponse, data: data, error: nil)
|
let response = HTTPResponse(response: httpResponse, data: data, error: nil)
|
||||||
let dictionary = response.dictionaryFromJSON
|
let dictionary = response.dictionaryFromJSON
|
||||||
|
|
||||||
XCTAssertTrue(dictionary.isEmpty)
|
XCTAssertTrue(dictionary.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDictionaryFromJSONPropertyWithNoData() {
|
func testDictionaryFromJSONPropertyWithNoData() {
|
||||||
let url = URL(string: "https://api.example.net")!
|
let url = URL(string: "https://api.example.net")!
|
||||||
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||||
|
|
||||||
let response = HTTPResponse(response: httpResponse, data: nil, error: nil)
|
let response = HTTPResponse(response: httpResponse, data: nil, error: nil)
|
||||||
XCTAssertEqual(response.bodyString, "")
|
XCTAssertEqual(response.bodyString, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDictionaryFromJSONPropertyWithNonDictionaryJSON() {
|
func testDictionaryFromJSONPropertyWithNonDictionaryJSON() {
|
||||||
let url = URL(string: "https://api.example.net")!
|
let url = URL(string: "https://api.example.net")!
|
||||||
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||||
let arrayJSON = try! JSONSerialization.data(withJSONObject: ["item1", "item2", "item3"])
|
let arrayJSON = try! JSONSerialization.data(withJSONObject: ["item1", "item2", "item3"])
|
||||||
|
|
||||||
let response = HTTPResponse(response: httpResponse, data: arrayJSON, error: nil)
|
let response = HTTPResponse(response: httpResponse, data: arrayJSON, error: nil)
|
||||||
let dictionary = response.dictionaryFromJSON
|
let dictionary = response.dictionaryFromJSON
|
||||||
|
|
||||||
XCTAssertTrue(dictionary.isEmpty)
|
XCTAssertTrue(dictionary.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testUnderlyingResponseProperty() {
|
func testUnderlyingResponseProperty() {
|
||||||
let url = URL(string: "https://api.example.net")!
|
let url = URL(string: "https://api.example.net")!
|
||||||
let httpResponse = HTTPURLResponse(url: url, statusCode: 201, httpVersion: "HTTP/1.1", headerFields: ["Server": "nginx"])!
|
let httpResponse = HTTPURLResponse(url: url, statusCode: 201, httpVersion: "HTTP/1.1", headerFields: ["Server": "nginx"])!
|
||||||
|
|
||||||
let response = HTTPResponse(response: httpResponse, data: nil, error: nil)
|
let response = HTTPResponse(response: httpResponse, data: nil, error: nil)
|
||||||
|
|
||||||
if case let .success(underlyingResponse, _) = response {
|
if case let .success(underlyingResponse, _) = response {
|
||||||
XCTAssertEqual(underlyingResponse.statusCode, 201)
|
XCTAssertEqual(underlyingResponse.statusCode, 201)
|
||||||
XCTAssertEqual(underlyingResponse.allHeaderFields["Server"] as? String, "nginx")
|
XCTAssertEqual(underlyingResponse.allHeaderFields["Server"] as? String, "nginx")
|
||||||
|
|
@ -170,41 +170,41 @@ class HTTPResponseTests: XCTestCase {
|
||||||
XCTFail("Expected success response")
|
XCTFail("Expected success response")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testResponseStringDescription() {
|
func testResponseStringDescription() {
|
||||||
let url = URL(string: "https://api.example.net")!
|
let url = URL(string: "https://api.example.net")!
|
||||||
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||||
let data = Data("test response".utf8)
|
let data = Data("test response".utf8)
|
||||||
|
|
||||||
let successResponse = HTTPResponse(response: httpResponse, data: data, error: nil)
|
let successResponse = HTTPResponse(response: httpResponse, data: data, error: nil)
|
||||||
let description = String(describing: successResponse)
|
let description = String(describing: successResponse)
|
||||||
XCTAssertTrue(description.contains("success"))
|
XCTAssertTrue(description.contains("success"))
|
||||||
|
|
||||||
let failureResponse = HTTPResponse(response: httpResponse, data: data, error: HTTPRequestError.http)
|
let failureResponse = HTTPResponse(response: httpResponse, data: data, error: HTTPRequestError.http)
|
||||||
let failureDescription = String(describing: failureResponse)
|
let failureDescription = String(describing: failureResponse)
|
||||||
XCTAssertTrue(failureDescription.contains("failure"))
|
XCTAssertTrue(failureDescription.contains("failure"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testResponseWithDifferentStatusCodes() {
|
func testResponseWithDifferentStatusCodes() {
|
||||||
let url = URL(string: "https://api.example.net")!
|
let url = URL(string: "https://api.example.net")!
|
||||||
|
|
||||||
// Test various 2xx success codes
|
// Test various 2xx success codes
|
||||||
for statusCode in [200, 201, 202, 204, 206] {
|
for statusCode in [200, 201, 202, 204, 206] {
|
||||||
let httpResponse = HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil)!
|
let httpResponse = HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil)!
|
||||||
let response = HTTPResponse(response: httpResponse, data: nil, error: nil)
|
let response = HTTPResponse(response: httpResponse, data: nil, error: nil)
|
||||||
|
|
||||||
if case .success = response {
|
if case .success = response {
|
||||||
// Expected
|
// Expected
|
||||||
} else {
|
} else {
|
||||||
XCTFail("Status code \(statusCode) should be success")
|
XCTFail("Status code \(statusCode) should be success")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test various error status codes
|
// Test various error status codes
|
||||||
for statusCode in [300, 400, 401, 404, 500, 503] {
|
for statusCode in [300, 400, 401, 404, 500, 503] {
|
||||||
let httpResponse = HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil)!
|
let httpResponse = HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil)!
|
||||||
let response = HTTPResponse(response: httpResponse, data: nil, error: nil)
|
let response = HTTPResponse(response: httpResponse, data: nil, error: nil)
|
||||||
|
|
||||||
if case .failure = response {
|
if case .failure = response {
|
||||||
// Expected
|
// Expected
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -212,25 +212,25 @@ class HTTPResponseTests: XCTestCase {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testResponseWithBinaryData() {
|
func testResponseWithBinaryData() {
|
||||||
let url = URL(string: "https://api.example.net")!
|
let url = URL(string: "https://api.example.net")!
|
||||||
let httpResponse = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
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 binaryData = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) // PNG header
|
||||||
|
|
||||||
let response = HTTPResponse(response: httpResponse, data: binaryData, error: nil)
|
let response = HTTPResponse(response: httpResponse, data: binaryData, error: nil)
|
||||||
|
|
||||||
XCTAssertEqual(response.data, binaryData)
|
XCTAssertEqual(response.data, binaryData)
|
||||||
// bodyString should handle binary data gracefully - it will be empty since this isn't valid UTF-8
|
// bodyString should handle binary data gracefully - it will be empty since this isn't valid UTF-8
|
||||||
let bodyString = response.bodyString
|
let bodyString = response.bodyString
|
||||||
XCTAssertTrue(bodyString.isEmpty) // Binary data that isn't valid UTF-8 returns empty string
|
XCTAssertTrue(bodyString.isEmpty) // Binary data that isn't valid UTF-8 returns empty string
|
||||||
}
|
}
|
||||||
|
|
||||||
func testResponseStatusPropertyEdgeCases() {
|
func testResponseStatusPropertyEdgeCases() {
|
||||||
// Test with no HTTP response - creates dummy HTTPURLResponse with status 0
|
// Test with no HTTP response - creates dummy HTTPURLResponse with status 0
|
||||||
let responseNoHTTP = HTTPResponse(response: nil, data: nil, error: nil)
|
let responseNoHTTP = HTTPResponse(response: nil, data: nil, error: nil)
|
||||||
XCTAssertEqual(responseNoHTTP.status, 0)
|
XCTAssertEqual(responseNoHTTP.status, 0)
|
||||||
|
|
||||||
// Test with URLResponse that's not HTTPURLResponse - creates dummy HTTPURLResponse with status 0
|
// Test with URLResponse that's not HTTPURLResponse - creates dummy HTTPURLResponse with status 0
|
||||||
let url = URL(string: "file:///test.txt")!
|
let url = URL(string: "file:///test.txt")!
|
||||||
let fileResponse = URLResponse(url: url, mimeType: "text/plain", expectedContentLength: 10, textEncodingName: nil)
|
let fileResponse = URLResponse(url: url, mimeType: "text/plain", expectedContentLength: 10, textEncodingName: nil)
|
||||||
|
|
|
||||||
309
Tests/OsirisTests/ReadmeExampleTests.swift
Normal file
309
Tests/OsirisTests/ReadmeExampleTests.swift
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -9,154 +9,152 @@
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
class RequestBuilderTests: XCTestCase {
|
class RequestBuilderTests: XCTestCase {
|
||||||
let baseURL = URL(string: "https://api.example.net/users")!
|
let baseURL = URL(string: "https://api.example.net/riders")!
|
||||||
|
|
||||||
func testBuildBasicGETRequest() throws {
|
func testBuildBasicGETRequest() throws {
|
||||||
let httpRequest = HTTPRequest.get(baseURL)
|
let httpRequest = HTTPRequest.get(baseURL)
|
||||||
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
XCTAssertEqual(urlRequest.url, baseURL)
|
XCTAssertEqual(urlRequest.url, baseURL)
|
||||||
XCTAssertEqual(urlRequest.httpMethod, "GET")
|
XCTAssertEqual(urlRequest.httpMethod, "GET")
|
||||||
XCTAssertNil(urlRequest.httpBody)
|
XCTAssertNil(urlRequest.httpBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBuildBasicPOSTRequest() throws {
|
func testBuildBasicPOSTRequest() throws {
|
||||||
let httpRequest = HTTPRequest.post(baseURL)
|
let httpRequest = HTTPRequest.post(baseURL)
|
||||||
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
XCTAssertEqual(urlRequest.url, baseURL)
|
XCTAssertEqual(urlRequest.url, baseURL)
|
||||||
XCTAssertEqual(urlRequest.httpMethod, "POST")
|
XCTAssertEqual(urlRequest.httpMethod, "POST")
|
||||||
XCTAssertNil(urlRequest.httpBody)
|
XCTAssertNil(urlRequest.httpBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBuildRequestWithHeaders() throws {
|
func testBuildRequestWithHeaders() throws {
|
||||||
var httpRequest = HTTPRequest.get(baseURL)
|
var httpRequest = HTTPRequest.get(baseURL)
|
||||||
httpRequest.headers = ["Authorization": "Bearer token", "X-Custom": "value"]
|
httpRequest.headers = ["Authorization": "Bearer token", "X-Custom": "value"]
|
||||||
|
|
||||||
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Authorization"), "Bearer token")
|
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Authorization"), "Bearer token")
|
||||||
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "X-Custom"), "value")
|
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "X-Custom"), "value")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBuildJSONRequest() throws {
|
func testBuildJSONRequest() throws {
|
||||||
let parameters = ["name": "Jane", "age": 30] as [String: any Sendable]
|
let parameters = ["name": "Jane", "age": 30] as [String: any Sendable]
|
||||||
let httpRequest = HTTPRequest.post(baseURL, contentType: .json, parameters: parameters)
|
let httpRequest = HTTPRequest.postJSON(baseURL, body: parameters)
|
||||||
|
|
||||||
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Content-Type"), "application/json")
|
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Content-Type"), "application/json")
|
||||||
XCTAssertNotNil(urlRequest.httpBody)
|
XCTAssertNotNil(urlRequest.httpBody)
|
||||||
|
|
||||||
// Verify the JSON content
|
// Verify the JSON content
|
||||||
let bodyData = urlRequest.httpBody!
|
let bodyData = urlRequest.httpBody!
|
||||||
let decodedJSON = try JSONSerialization.jsonObject(with: bodyData) as! [String: any Sendable]
|
let decodedJSON = try JSONSerialization.jsonObject(with: bodyData) as! [String: any Sendable]
|
||||||
XCTAssertEqual(decodedJSON["name"] as? String, "Jane")
|
XCTAssertEqual(decodedJSON["name"] as? String, "Jane")
|
||||||
XCTAssertEqual(decodedJSON["age"] as? Int, 30)
|
XCTAssertEqual(decodedJSON["age"] as? Int, 30)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBuildFormEncodedRequest() throws {
|
func testBuildFormEncodedRequest() throws {
|
||||||
let parameters = ["email": "john@example.net", "password": "TaylorSwift1989"]
|
let parameters = ["email": "john@example.net", "password": "TaylorSwift1989"]
|
||||||
let httpRequest = HTTPRequest.post(baseURL, contentType: .formEncoded, parameters: parameters)
|
let httpRequest = HTTPRequest.postForm(baseURL, parameters: parameters)
|
||||||
|
|
||||||
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Content-Type"), "application/x-www-form-urlencoded")
|
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Content-Type"), "application/x-www-form-urlencoded")
|
||||||
XCTAssertNotNil(urlRequest.httpBody)
|
XCTAssertNotNil(urlRequest.httpBody)
|
||||||
|
|
||||||
let bodyString = String(data: urlRequest.httpBody!, encoding: .utf8)!
|
let bodyString = String(data: urlRequest.httpBody!, encoding: .utf8)!
|
||||||
XCTAssertTrue(bodyString.contains("email=john%40example.net"))
|
XCTAssertTrue(bodyString.contains("email=john%40example.net"))
|
||||||
XCTAssertTrue(bodyString.contains("password=TaylorSwift1989"))
|
XCTAssertTrue(bodyString.contains("password=TaylorSwift1989"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Testing .none content type with parameters would trigger an assertion failure
|
// Note: Testing .none content type with parameters would trigger an assertion failure
|
||||||
// This is by design - developers should specify an appropriate content type
|
// This is by design - developers should specify an appropriate content type
|
||||||
|
|
||||||
func testBuildMultipartRequest() throws {
|
func testBuildMultipartRequest() throws {
|
||||||
var httpRequest = HTTPRequest.post(baseURL)
|
let parts = [
|
||||||
httpRequest.parts = [
|
MultipartFormEncoder.Part.text("Jane Doe", name: "name"),
|
||||||
.text("Jane Doe", name: "name"),
|
MultipartFormEncoder.Part.data(Data("test".utf8), name: "file", type: "text/plain", filename: "test.txt")
|
||||||
.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 urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
let contentType = urlRequest.value(forHTTPHeaderField: "Content-Type")
|
let contentType = urlRequest.value(forHTTPHeaderField: "Content-Type")
|
||||||
XCTAssertNotNil(contentType)
|
XCTAssertNotNil(contentType)
|
||||||
XCTAssertTrue(contentType!.hasPrefix("multipart/form-data; boundary="))
|
XCTAssertTrue(contentType!.hasPrefix("multipart/form-data; boundary="))
|
||||||
|
|
||||||
let contentLength = urlRequest.value(forHTTPHeaderField: "Content-Length")
|
let contentLength = urlRequest.value(forHTTPHeaderField: "Content-Length")
|
||||||
XCTAssertNotNil(contentLength)
|
XCTAssertNotNil(contentLength)
|
||||||
XCTAssertGreaterThan(Int(contentLength!)!, 0)
|
XCTAssertGreaterThan(Int(contentLength!)!, 0)
|
||||||
|
|
||||||
XCTAssertNotNil(urlRequest.httpBody)
|
XCTAssertNotNil(urlRequest.httpBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBuildRequestWithInvalidFormData() throws {
|
func testBuildRequestWithInvalidFormData() throws {
|
||||||
// Create a parameter that would cause UTF-8 encoding to fail
|
// Create a parameter that would cause UTF-8 encoding to fail
|
||||||
// FormEncoder.encode() returns a String, but String.data(using: .utf8) could theoretically 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.
|
// 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,
|
// Since FormEncoder is quite robust and UTF-8 encoding rarely fails,
|
||||||
// we'll test this by creating a subclass that can force the failure
|
// we'll test this by creating a subclass that can force the failure
|
||||||
// But for now, we'll document this edge case exists
|
// But for now, we'll document this edge case exists
|
||||||
XCTAssertNoThrow(try RequestBuilder.build(request: HTTPRequest.post(baseURL, contentType: .formEncoded, parameters: ["test": "value"])))
|
XCTAssertNoThrow(try RequestBuilder.build(request: HTTPRequest.postForm(baseURL, parameters: ["test": "value"])))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBuildRequestWithAllHTTPMethods() throws {
|
func testBuildRequestWithAllHTTPMethods() throws {
|
||||||
let methods: [HTTPMethod] = [.get, .post, .put, .patch, .delete]
|
let methods: [HTTPMethod] = [.get, .post, .put, .patch, .delete]
|
||||||
|
|
||||||
for method in methods {
|
for method in methods {
|
||||||
let httpRequest = HTTPRequest(method: method, url: baseURL)
|
let httpRequest = try HTTPRequest(method: method, url: baseURL)
|
||||||
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
XCTAssertEqual(urlRequest.httpMethod, method.string)
|
XCTAssertEqual(urlRequest.httpMethod, method.string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBuildRequestPreservesURL() throws {
|
func testBuildRequestPreservesURL() throws {
|
||||||
let complexURL = URL(string: "https://api.example.net/users?page=1#section")!
|
let complexURL = URL(string: "https://api.example.net/riders?page=1#section")!
|
||||||
let httpRequest = HTTPRequest.get(complexURL)
|
let httpRequest = HTTPRequest.get(complexURL)
|
||||||
|
|
||||||
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
XCTAssertEqual(urlRequest.url, complexURL)
|
XCTAssertEqual(urlRequest.url, complexURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMultipleHeadersWithSameName() throws {
|
func testMultipleHeadersWithSameName() throws {
|
||||||
var httpRequest = HTTPRequest.get(baseURL)
|
var httpRequest = HTTPRequest.get(baseURL)
|
||||||
httpRequest.headers = ["Accept": "application/json"]
|
httpRequest.headers = ["Accept": "application/json"]
|
||||||
|
|
||||||
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Accept"), "application/json")
|
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Accept"), "application/json")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBuildRequestWithEmptyMultipartParts() throws {
|
func testBuildRequestWithEmptyMultipartParts() throws {
|
||||||
var httpRequest = HTTPRequest.post(baseURL)
|
let httpRequest = HTTPRequest.postMultipart(baseURL, parts: [])
|
||||||
httpRequest.parts = []
|
|
||||||
httpRequest.contentType = .multipart // Explicitly set to multipart
|
|
||||||
|
|
||||||
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
let contentType = try XCTUnwrap(urlRequest.value(forHTTPHeaderField: "Content-Type"))
|
let contentType = try XCTUnwrap(urlRequest.value(forHTTPHeaderField: "Content-Type"))
|
||||||
XCTAssertTrue(contentType.hasPrefix("multipart/form-data; boundary="))
|
XCTAssertTrue(contentType.hasPrefix("multipart/form-data; boundary="))
|
||||||
XCTAssertNotNil(urlRequest.httpBody)
|
XCTAssertNotNil(urlRequest.httpBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBuildRequestWithLargeMultipartData() throws {
|
func testBuildRequestWithLargeMultipartData() throws {
|
||||||
var httpRequest = HTTPRequest.post(baseURL)
|
|
||||||
let largeData = Data(repeating: 65, count: 1024 * 1024) // 1MB of 'A' characters
|
let largeData = Data(repeating: 65, count: 1024 * 1024) // 1MB of 'A' characters
|
||||||
httpRequest.parts = [
|
let parts = [
|
||||||
.data(largeData, name: "largefile", type: "application/octet-stream", filename: "large.bin")
|
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)
|
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
XCTAssertNotNil(urlRequest.httpBody)
|
XCTAssertNotNil(urlRequest.httpBody)
|
||||||
XCTAssertGreaterThan(urlRequest.httpBody!.count, 1024 * 1024)
|
XCTAssertGreaterThan(urlRequest.httpBody!.count, 1024 * 1024)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBuildRequestWithSpecialCharactersInHeaders() throws {
|
func testBuildRequestWithSpecialCharactersInHeaders() throws {
|
||||||
var httpRequest = HTTPRequest.get(baseURL)
|
var httpRequest = HTTPRequest.get(baseURL)
|
||||||
httpRequest.headers = [
|
httpRequest.headers = [
|
||||||
|
|
@ -164,42 +162,42 @@ class RequestBuilderTests: XCTestCase {
|
||||||
"X-Unicode": "🚀 rocket emoji",
|
"X-Unicode": "🚀 rocket emoji",
|
||||||
"X-Empty": ""
|
"X-Empty": ""
|
||||||
]
|
]
|
||||||
|
|
||||||
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "X-Custom-Header"), "value with spaces and symbols: !@#$%")
|
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-Unicode"), "🚀 rocket emoji")
|
||||||
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "X-Empty"), "")
|
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "X-Empty"), "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBuildRequestWithNilParameters() throws {
|
func testBuildRequestWithNilParameters() throws {
|
||||||
let httpRequest = HTTPRequest.post(baseURL, contentType: .json, parameters: nil)
|
let httpRequest = HTTPRequest.post(baseURL)
|
||||||
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
// RequestBuilder may not set Content-Type if there are no parameters to encode
|
// RequestBuilder may not set Content-Type if there are no parameters to encode
|
||||||
XCTAssertNil(urlRequest.httpBody)
|
XCTAssertNil(urlRequest.httpBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBuildRequestWithEmptyParameters() throws {
|
func testBuildRequestWithEmptyParameters() throws {
|
||||||
let httpRequest = HTTPRequest.post(baseURL, contentType: .json, parameters: [:])
|
let httpRequest = HTTPRequest.postJSON(baseURL, body: [:])
|
||||||
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Content-Type"), "application/json")
|
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Content-Type"), "application/json")
|
||||||
XCTAssertNotNil(urlRequest.httpBody)
|
XCTAssertNotNil(urlRequest.httpBody)
|
||||||
|
|
||||||
let bodyString = String(data: urlRequest.httpBody!, encoding: .utf8)!
|
let bodyString = String(data: urlRequest.httpBody!, encoding: .utf8)!
|
||||||
XCTAssertEqual(bodyString, "{}")
|
XCTAssertEqual(bodyString, "{}")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBuildRequestSetsContentType() throws {
|
func testBuildRequestSetsContentType() throws {
|
||||||
let httpRequest = HTTPRequest.post(baseURL, contentType: .json, parameters: ["test": "value"])
|
let httpRequest = HTTPRequest.postJSON(baseURL, body: ["test": "value"])
|
||||||
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
// RequestBuilder should set the correct content type when there are parameters to encode
|
// RequestBuilder should set the correct content type when there are parameters to encode
|
||||||
let contentType = urlRequest.value(forHTTPHeaderField: "Content-Type")
|
let contentType = urlRequest.value(forHTTPHeaderField: "Content-Type")
|
||||||
XCTAssertTrue(contentType?.contains("application/json") == true)
|
XCTAssertTrue(contentType?.contains("application/json") == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBuildRequestWithComplexJSONParameters() throws {
|
func testBuildRequestWithComplexJSONParameters() throws {
|
||||||
let nestedData: [String: any Sendable] = ["theme": "dark", "notifications": true]
|
let nestedData: [String: any Sendable] = ["theme": "dark", "notifications": true]
|
||||||
let arrayData: [any Sendable] = ["rock", "pop", "jazz"]
|
let arrayData: [any Sendable] = ["rock", "pop", "jazz"]
|
||||||
|
|
@ -211,86 +209,149 @@ class RequestBuilderTests: XCTestCase {
|
||||||
"genres": arrayData
|
"genres": arrayData
|
||||||
] as [String: any Sendable]
|
] as [String: any Sendable]
|
||||||
]
|
]
|
||||||
|
|
||||||
let httpRequest = HTTPRequest.post(baseURL, contentType: .json, parameters: complexParams)
|
let httpRequest = HTTPRequest.postJSON(baseURL, body: complexParams)
|
||||||
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
XCTAssertNotNil(urlRequest.httpBody)
|
XCTAssertNotNil(urlRequest.httpBody)
|
||||||
let jsonObject = try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any]
|
let jsonObject = try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any]
|
||||||
let person = jsonObject["person"] as! [String: Any]
|
let person = jsonObject["person"] as! [String: Any]
|
||||||
XCTAssertEqual(person["name"] as? String, "David Bowie")
|
XCTAssertEqual(person["name"] as? String, "David Bowie")
|
||||||
XCTAssertEqual(person["age"] as? Int, 69)
|
XCTAssertEqual(person["age"] as? Int, 69)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBuildRequestWithNoneContentTypeFallsBackToFormEncoding() throws {
|
func testBuildRequestWithExplicitFormEncoding() throws {
|
||||||
// Test the .none content type fallthrough case with a warning
|
// Test explicit form encoding
|
||||||
let httpRequest = HTTPRequest.post(baseURL, contentType: .none, parameters: ["email": "freddie@example.net", "band": "Queen"])
|
let httpRequest = HTTPRequest.postForm(baseURL, parameters: ["email": "freddie@example.net", "band": "Queen"])
|
||||||
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
// Should fall back to form encoding and log a warning
|
|
||||||
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Content-Type"), "application/x-www-form-urlencoded")
|
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Content-Type"), "application/x-www-form-urlencoded")
|
||||||
XCTAssertNotNil(urlRequest.httpBody)
|
XCTAssertNotNil(urlRequest.httpBody)
|
||||||
|
|
||||||
let bodyString = String(data: urlRequest.httpBody!, encoding: .utf8)!
|
let bodyString = String(bytes: urlRequest.httpBody!, encoding: .utf8)
|
||||||
XCTAssertTrue(bodyString.contains("email=freddie%40example.net"))
|
XCTAssertTrue(bodyString?.contains("email=freddie%40example.net") ?? false)
|
||||||
XCTAssertTrue(bodyString.contains("band=Queen"))
|
XCTAssertTrue(bodyString?.contains("band=Queen") ?? false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBuildGETRequestWithQueryParameters() throws {
|
func testBuildGETRequestWithQueryParameters() throws {
|
||||||
let httpRequest = HTTPRequest.get(baseURL, parameters: ["name": "John Doe", "email": "john@example.net"])
|
let httpRequest = HTTPRequest.get(baseURL, parameters: ["name": "Neko Case", "email": "neko@example.net"])
|
||||||
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
XCTAssertEqual(urlRequest.httpMethod, "GET")
|
XCTAssertEqual(urlRequest.httpMethod, "GET")
|
||||||
XCTAssertNil(urlRequest.httpBody)
|
XCTAssertNil(urlRequest.httpBody)
|
||||||
XCTAssertNil(urlRequest.value(forHTTPHeaderField: "Content-Type"))
|
XCTAssertNil(urlRequest.value(forHTTPHeaderField: "Content-Type"))
|
||||||
|
|
||||||
let urlString = urlRequest.url?.absoluteString ?? ""
|
let urlString = urlRequest.url?.absoluteString ?? ""
|
||||||
XCTAssertTrue(urlString.contains("name=John%20Doe"), "URL should contain encoded name parameter")
|
XCTAssertTrue(urlString.contains("name=Neko%20Case"), "URL should contain encoded name parameter")
|
||||||
XCTAssertTrue(urlString.contains("email=john@example.net"), "URL should contain email parameter")
|
XCTAssertTrue(urlString.contains("email=neko@example.net"), "URL should contain email parameter")
|
||||||
XCTAssertTrue(urlString.contains("?"), "URL should contain query separator")
|
XCTAssertTrue(urlString.contains("?"), "URL should contain query separator")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBuildDELETERequestWithQueryParameters() throws {
|
func testBuildDELETERequestWithQueryParameters() throws {
|
||||||
let httpRequest = HTTPRequest.delete(baseURL, parameters: ["id": "123", "confirm": "true"])
|
let httpRequest = HTTPRequest.delete(baseURL, parameters: ["id": "123", "confirm": "true"])
|
||||||
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
XCTAssertEqual(urlRequest.httpMethod, "DELETE")
|
XCTAssertEqual(urlRequest.httpMethod, "DELETE")
|
||||||
XCTAssertNil(urlRequest.httpBody)
|
XCTAssertNil(urlRequest.httpBody)
|
||||||
XCTAssertNil(urlRequest.value(forHTTPHeaderField: "Content-Type"))
|
XCTAssertNil(urlRequest.value(forHTTPHeaderField: "Content-Type"))
|
||||||
|
|
||||||
let urlString = urlRequest.url?.absoluteString ?? ""
|
let urlString = urlRequest.url?.absoluteString ?? ""
|
||||||
XCTAssertTrue(urlString.contains("id=123"))
|
XCTAssertTrue(urlString.contains("id=123"))
|
||||||
XCTAssertTrue(urlString.contains("confirm=true"))
|
XCTAssertTrue(urlString.contains("confirm=true"))
|
||||||
XCTAssertTrue(urlString.contains("?"))
|
XCTAssertTrue(urlString.contains("?"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBuildGETRequestWithExistingQueryString() throws {
|
func testBuildGETRequestWithExistingQueryString() throws {
|
||||||
let urlWithQuery = URL(string: "https://api.example.net/users?existing=param")!
|
let urlWithQuery = URL(string: "https://api.example.net/riders?existing=param")!
|
||||||
let httpRequest = HTTPRequest.get(urlWithQuery, parameters: ["new": "value"])
|
let httpRequest = HTTPRequest.get(urlWithQuery, parameters: ["new": "value"])
|
||||||
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
let urlString = urlRequest.url?.absoluteString ?? ""
|
let urlString = urlRequest.url?.absoluteString ?? ""
|
||||||
XCTAssertTrue(urlString.contains("existing=param"))
|
XCTAssertTrue(urlString.contains("existing=param"))
|
||||||
XCTAssertTrue(urlString.contains("new=value"))
|
XCTAssertTrue(urlString.contains("new=value"))
|
||||||
XCTAssertTrue(urlString.contains("&"))
|
XCTAssertTrue(urlString.contains("&"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBuildGETRequestWithMultipartThrowsError() throws {
|
func testBuildGETRequestWithFormParameters() throws {
|
||||||
var httpRequest = HTTPRequest.get(baseURL, parameters: ["name": "value"])
|
let httpRequest = HTTPRequest.get(baseURL, parameters: ["name": "value"])
|
||||||
httpRequest.contentType = HTTPContentType.multipart
|
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
XCTAssertThrowsError(try RequestBuilder.build(request: httpRequest)) { error in
|
// GET parameters should be encoded as query string
|
||||||
XCTAssertTrue(error is RequestBuilderError)
|
XCTAssertTrue(urlRequest.url?.query?.contains("name=value") ?? false)
|
||||||
}
|
XCTAssertNil(urlRequest.httpBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBuildDELETERequestWithPartsThrowsError() throws {
|
func testBuildDELETERequestWithParameters() throws {
|
||||||
var httpRequest = HTTPRequest.delete(baseURL, parameters: ["id": "123"])
|
let httpRequest = HTTPRequest.delete(baseURL, parameters: ["id": "123"])
|
||||||
httpRequest.parts = [MultipartFormEncoder.Part.text("value", name: "test")]
|
let urlRequest = try RequestBuilder.build(request: httpRequest)
|
||||||
|
|
||||||
XCTAssertThrowsError(try RequestBuilder.build(request: httpRequest)) { error in
|
// DELETE parameters should be encoded as query string
|
||||||
XCTAssertTrue(error is RequestBuilderError)
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
48
Tests/OsirisTests/TestModels.swift
Normal file
48
Tests/OsirisTests/TestModels.swift
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import XCTest
|
|
||||||
|
|
||||||
#if !canImport(ObjectiveC)
|
|
||||||
public func allTests() -> [XCTestCaseEntry] {
|
|
||||||
return [
|
|
||||||
testCase(MultipartFormEncoderTests.allTests),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
Loading…
Reference in a new issue