Type-safe Swift HTTP networking
Type-safe endpoints with async/await, retry policies, middleware, and queued uploads/downloads. Zero dependencies.
dependencies: [
.package(url: "https://github.com/vetrek/SmartNet.git", from: "2.4.0")
]struct User: Codable {
let id: Int
let name: String
}
let client = ApiClient(config: NetworkConfiguration(
baseURL: URL(string: "https://api.example.com")!,
headers: ["Accept": "application/json"],
requestTimeout: 30
))
let user: User = try await client.request(with: Endpoint(path: "users/1"))Every request also has a Combine publisher overload and a completion-closure overload, for code that isn't async yet.
let search = Endpoint<[User]>(
path: "users",
queryParameters: QueryParameters(parameters: ["name": "John", "limit": 10])
)
struct CreateUser: Encodable {
let name: String
}
let create = Endpoint<User>(
path: "users",
method: .post,
body: HTTPBody(encodable: CreateUser(name: "John"))
)Requests are not retried unless you set a policy, globally or per endpoint:
let config = NetworkConfiguration(
baseURL: URL(string: "https://api.example.com")!,
retryPolicy: .exponential(maxRetries: 5, conditions: [.timeout, .serverError])
)
let endpoint = Endpoint<User>(path: "users/1").retry(.immediate())| Policy | Delays | Default retries |
|---|---|---|
.exponential() |
1s, 2s, 4s… with jitter | 3 |
.linear() |
1s, 2s, 3s… | 3 |
.immediate() |
none | 1 |
.none |
no retries (default) | 0 |
Conditions: .timeout, .connectionLost, .networkFailure, .serverError (5xx), .rateLimited (429, honours Retry-After), .dnsFailure. The default set is the first four.
Modify requests and inspect responses for matching paths:
client.addMiddleware(
ApiClient.Middleware(
pathMatcher: .contains("/"),
preRequestCallback: { request in
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
},
postResponseCallback: { data, response, error in
.next
}
)
)Throw from preRequestCallback to cancel the request. Return .retryRequest from postResponseCallback to send it again; the retry policy's limit still applies.
| Matcher | Matches |
|---|---|
.contains("/") |
every path |
.contains("users") |
any path with a users segment, e.g. /api/users/123 |
.exact("/users") |
/users only |
.wildcard("/users/*") |
/users/123, not /users/123/posts |
.glob("/api/**") |
/api, /api/v1/users |
.regex("^/users/\\d+$") |
/users/123 (returns nil for an invalid pattern) |
let file = try await client.download(with: DownloadEndpoint(path: "files/report.pdf"))
print(file.localURL)To track progress, pause or cancel, keep the returned task:
let upload = try client.upload(
with: MultipartFormEndpoint<String>(
path: "upload",
form: MultipartFormData {
TextField("name", value: "avatar")
FileField("file", data: imageData, fileName: "photo.jpg")
}
)
)
.progress { progress in print(progress.fractionCompleted) }
.response { response in print(response.result) }
let download = client.download(url: URL(string: "https://example.com/file.zip")!)?
.downloadProgress { progress, _ in print(progress.fractionCompleted) }
.response { response in
if case .success(let file) = response.result {
print(file.localURL)
}
}How transfers run:
- Queued. Downloads run up to the session's
httpMaximumConnectionsPerHostat once (6 by default). Uploads run one fewer, so API requests to the same host always have a free connection. Extra transfers wait in.waitingStart, and their request timeout only starts when they do. - Pause and resume.
pause()frees the transfer's slot.resume()continues from resume data when the server supports it, otherwise restarts, and goes to the front of the queue. - Cancel.
cancel()ends in.cancelledand delivers a cancellation error toresponse.client.destroy()cancels every running and queued transfer. - State.
stateis already final (.completed,.erroror.cancelled) whenresponseruns.
let stream = client.serverSentEvents(with: Endpoint<SSEEvent>(path: "events"), idleTimeout: 45)
for try await event in stream {
print(event.event, event.data)
}- Reconnects on close, transport errors and idle timeouts, sending
Last-Event-ID. Backoff comes fromendpoint.retryPolicy(default: unlimited exponential, 30s cap); a serverretry:is the minimum delay. - Auth. Headers and pre-request middlewares are re-applied on every connect. On a non-2xx response, post-response middlewares run first; returning
.retryRequest(e.g. after a token refresh) reconnects immediately. - Idle timeout. No bytes (heartbeat comments included) for
idleTimeoutseconds triggers a reconnect. - Ends when the consuming task is cancelled or the server answers 204. Throws on non-retryable statuses, a non
text/event-streamresponse, or exhausted retries.
Every failure is a NetworkError:
do {
let user: User = try await client.request(with: endpoint)
} catch let error as NetworkError {
switch error {
case .error(let statusCode, _):
print("HTTP \(statusCode)")
case .timeout, .connectionLost:
print("Network problem")
case .parsingFailed:
print("Decoding failed: \(error)")
default:
print(error)
}
}SmartNet is available under the MIT license.