A HTTP client built with and for PointFree's swift-dependencies
0

Configure Feed

Select the types of activity you want to include in your feed.

15 5 0

Clone this repository

https://git.vm.fail/woody.fm/swift-dependencies-http-client https://git.vm.fail/did:plc:2yhgaxmbs7t3btohcpoewc73
ssh://git@knot1.tangled.sh:2222/woody.fm/swift-dependencies-http-client ssh://git@knot1.tangled.sh:2222/did:plc:2yhgaxmbs7t3btohcpoewc73

For self-hosted knots, clone URLs may differ based on your setup.


README.md

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.