A declarative, SwiftUI-style HTTP networking library for Swift. Define requests as composable types, configure clients once, and let the type system carry your decoded responses.
- Installation
- Quick Start
- Defining Requests
- Response Types — Important
- Request Modifiers
- Sending Requests
- Configuration
- Client Modifiers
- Testing
- Advanced Topics
- License
dependencies: [
.package(url: "https://github.com/AndyHeardApps/SwiftNetworking", from: "1.0.0")
]Add "SwiftNetworking" to your target's dependencies.
import SwiftNetworking
// 1. Define a request
struct GetUser: Request {
let id: Int
var body: some Request<User> {
Endpoint()
.path {
"users"
id
}
.method(.get)
.decode(User.self)
}
}
// 2. Build a client
let client = SessionClient(URLSessionNetworkSession())
.baseURL(#url("https://api.example.com"))
// 3. Send it
let user = try await client.send(GetUser(id: 42)).contentConform to Request and implement a body property that chains modifiers on Endpoint(). The shape mirrors SwiftUI's View protocol.
struct CreatePost: Request {
let title: String
let content: String
var body: some Request<Post> {
Endpoint()
.method(.post)
.path("posts")
.body(["title": title, "content": content])
.decode(Post.self)
}
}struct DeletePost: Request {
let id: Int
var body: some Request<Void> {
Endpoint()
.method(.delete)
.path {
"posts"
id
}
.decodeVoid()
}
}Any request can delegate to another:
struct PutPost: Request {
let title: String
let content: String
var body: some Request<Post> {
CreatePost(
title: title,
content: content
)
.method(.put)
}
}Always annotate body with some Request<YourType> when you expect a decoded response. This is not compiler-enforced — omitting the type parameter silently discards the decoded type and Client.send(_:) returns Data instead.
// Correct: the decoded User flows through to the call site
struct GetUser: Request {
var body: some Request<User> {
Endpoint()
.path("users/1")
.decode(User.self)
}
}
// Wrong: Output is lost, so send(_:) returns Data, not User
struct GetUser: Request {
var body: some Request {
Endpoint()
.path("users/1")
.decode(User.self)
}
}The rule is simple: if you call .decode(T.self), write some Request<T> as the return type.
For requests that return nothing, use some Request<Void> with .decodeVoid(). For raw bytes, omit the decode modifier and write some Request<Data> (or just some Request — the default Output is Data).
Modifiers are chained on Endpoint() (or any Request) to describe the full HTTP interaction.
Endpoint()
.baseURL(#url("https://api.example.com"))
.path {
"users"
id
"posts"
}
.method(.get)Endpoint()
.headers {
Headers()
.accept(.json)
.contentType(.json)
}
.headers(["X-App-Version": 2])Endpoint()
.path("search")
.query([
"query": term,
"limit": 20,
])// Encodable model
Endpoint().body(newPost)
// JSON dictionary
Endpoint().body(["title": "Hello", "body": "World"])
// Multipart form data
Endpoint().body {
FormData.Item(name: "title", "My Photo")
FormData.Item(name: "file", fileURL: imageURL, mimeType: .imageJPEG)
}
// Form URL-encoded
Endpoint().body(FormURLEncoded(["username": "alice", "password": "secret"]))
// Upload from disk (enables upload task with progress)
Endpoint().upload(file: videoURL)Endpoint().decode(User.self) // Decodable → User
Endpoint().decodeOptional(User.self) // Decodable → User? (200 or 204)
Endpoint().decodeVoid() // ignore body
Endpoint().decodeString() // String (UTF-8)Endpoint()
.cachePolicy(.reloadIgnoringLocalCacheData)
.timeout(.seconds(30))
.resourceTimeout(.seconds(300))Endpoint().response(.data.assertSuccess()) // throws on non-2xx
Endpoint().response(.data.assert(statusCode: .ok)) // throws unless exactly 200
Endpoint().response(.data.assert(statusCodeIn: 200...299))Create a SessionClient and use send(_:) for a direct response or task(_:) when you need progress or cancellation.
let client = SessionClient(URLSessionNetworkSession())
.baseURL(#url("https://api.example.com"))let user = try await client.send(GetUser(id: 1)).contentlet operation = client.task(GetUser(id: 1))
// Observe progress (useful for uploads/downloads)
for await progress in operation.progress {
progressBar.value = progress.fractionCompleted
}
// Cancel early
operation.cancel()
// Await the value
let user = try await operation.value.content// Process a temporary file (deleted after the closure returns)
let byteCount = try await client.download(DownloadReport(id: 99)) { fileURL in
try Data(contentsOf: fileURL).count
}
// Or move to a specific destination
let savedURL = try await client.download(DownloadReport(id: 99), to: destinationURL).content
// Track progress with a download task
let operation = client.downloadTask(DownloadReport(id: 99), to: destinationURL)
for await progress in operation.progress {
print(progress.fractionCompleted)
}
let fileURL = try await operation.value.contentConfigure a client once with defaults that apply to every request it sends. Request-level modifiers override client defaults.
let client = SessionClient(URLSessionNetworkSession())
.baseURL(#url("https://api.example.com"))
.defaultHeaders(Headers().accept(.json))
.timeout(.seconds(30))
.cachePolicy(.reloadIgnoringLocalCacheData)
.redirects(.follow)extension ConfigurationValues {
@ConfigEntry var apiVersion: String = "v1"
}
let client = SessionClient(URLSessionNetworkSession())
.configuration(\.apiVersion, "v2")Read the value inside a modifier using the @Config property wrapper:
struct APIVersionModifier: RequestModifier {
@Config(\.apiVersion) var version
func body(_ content: consuming RequestModifierContent) -> some Request {
content.path(version)
}
}Stack behaviour onto any client by chaining modifiers.
let client = SessionClient(URLSessionNetworkSession())
.baseURL(#url("https://api.example.com"))
.authenticated(by: MyAuthProvider())Implement AuthenticationProvider for custom schemes:
struct BearerTokenProvider: AuthenticationProvider {
let token: String
func authenticate(_ request: PreparedRequest) throws(SwiftNetworkingError) -> PreparedRequest {
var authenticated = request
authenticated.headers = (authenticated.headers ?? Headers())
.authorization(.bearer(token: token))
return authenticated
}
func refresh() async throws(SwiftNetworkingError) {}
}For OAuth 2.0 with automatic token refresh, use the built-in OAuthAuthenticationProvider. See Authentication.
let client = SessionClient(URLSessionNetworkSession())
.retry(RetryPolicy(maxRetries: 3, backoff: .exponential(base: .seconds(1))))let client = SessionClient(URLSessionNetworkSession())
.logging([.request, .response]) // log request/response lines
.logging(.all) // log everything including bodies
.logging(.all, redacting: [.authorization]) // redact sensitive headerslet client = SessionClient(URLSessionNetworkSession())
.waitingForConnectivity(ConnectionMonitor())SwiftNetworkingTesting provides MockNetworkSession — a drop-in replacement for URLSessionNetworkSession that records every request, returns pre-configured stub responses, and integrates with swift-testing for built-in assertion helpers.
import SwiftNetworkingTesting
let session = MockNetworkSession()
let client = SessionClient(session)
session.stub(.method(.get) && .host("api.example.com"), .json(User(id: 1, name: "Alice")))
let user = try await client.send(GetUser(id: 1)).content
session.expectRequest(.method(.get))See Testing for the full guide including stubs, matchers, assertions, WebSocket and SSE testing, and strict mode.
| Topic | Description |
|---|---|
| Authentication | OAuth 2.0, custom auth providers, per-request auth |
| WebSocket | Full-duplex WebSocket connections with typed messages |
| Server-Sent Events | SSE streams with automatic parsing |
| Resumable Uploads | Chunked multi-part uploads with resume support |
| Security | Certificate pinning, Keychain storage |
| Connection Monitoring | Network reachability observation |
| Testing | MockNetworkSession, stubs, matchers, and assertions |
SwiftNetworking is available under the Apache 2.0 license. See the LICENSE file for details.