HTTPClient#
A small, dependency-injectable HTTP client for Swift.
HTTPClient wraps URLSession and HTTPTypes in a swift-dependencies
client, so features can make requests through a narrow interface and tests can
replace the network with a closure.
import Dependencies
import HTTPClient
import HTTPTypes
struct User: Codable, Equatable {
var id: Int
var name: String
}
@Dependency(\.httpClient) var httpClient
let user: User = try await withDependencies {
$0.hostURL = "api.example.com"
} operation: {
try await httpClient.get("/users/1")
}
Installation#
Add the package to your Swift package dependencies:
.package(url: "https://tangled.org/woody.fm/swift-dependencies-http-client", branch: "main")
Then add HTTPClient to the targets that need it:
.target(
name: "YourFeature",
dependencies: [
"HTTPClient",
]
)
Requests#
Use absolute URLs when the endpoint is already known:
let users: [User] = try await httpClient.get(
URL(string: "https://api.example.com/users")!,
queryItems: [
URLQueryItem(name: "page", value: "1"),
]
)
Or inject a base host once and use path-based endpoints in the rest of your feature code:
try await withDependencies {
$0.hostURL = "api.example.com/v1"
$0.requestHeaders[.accept] = "application/json"
} operation: {
let user: User = try await httpClient.get("/users/1")
let created: User = try await httpClient.post("/users", data: user)
}
The path builder is intentionally forgiving about leading and trailing slashes,
so "users", "/users", "users/", and "/users/" all resolve the same way
against the configured host.
Interceptors#
Request, response, and error interceptors are dependency values. Use them to attach headers, log responses, refresh credentials, or retry a failed request.
try await withDependencies {
$0.requestInterceptors = [
RequestInterceptor { request, _ in
request.headerFields[.authorization] = "Bearer \(token)"
}
]
} operation: {
try await feature.load()
}
Error interceptors can either return data to recover the request or return
nil to pass handling to the next interceptor:
$0.errorInterceptors = [
ErrorInterceptor { request, status, _, _, retry in
guard status == .unauthorized else { return nil }
var request = request
request.headerFields[.authorization] = "Bearer \(try await refreshToken())"
return try await retry(request)
}
]
Testing#
For feature tests, inject a test client that asserts the outgoing request and returns the response body your feature expects:
let client: HTTPClient = .testClient { body, path, method in
#expect(path == "/users/1")
#expect(method == .get)
return #"{"id":1,"name":"Alice"}"#
}
let user: User = try await withDependencies {
$0.hostURL = "api.example.com"
$0.httpClient = client
} operation: {
try await model.loadUser(id: 1)
}
#expect(user == User(id: 1, name: "Alice"))
For lower-level tests, override dataForURL to exercise the live client while
still avoiding the network:
try await withDependencies {
$0.hostURL = "api.example.com"
$0.dataForURL = { request, body in
#expect(request.method == .post)
return (
#"{"id":1,"name":"Alice"}"#.data(using: .utf8)!,
HTTPResponse(status: .ok)
)
}
} operation: {
let user: User = try await HTTPClient.liveValue.post(
"/users",
data: ["name": "Alice"]
)
}
Form Encoding#
FormURLEncoder encodes flat Encodable values as
application/x-www-form-urlencoded data. It is available directly and as a
dependency value:
struct TokenRequest: Encodable {
var grantType: String
var code: String
enum CodingKeys: String, CodingKey {
case grantType = "grant_type"
case code
}
}
let body = try FormURLEncoder().encode(
TokenRequest(grantType: "authorization_code", code: code)
)
Requirements#
This package currently targets Swift 6.1 or later, iOS 17 or later, and macOS 14 or later.