Networking is a lightweight and powerful HTTP network framework written in Swift by Viktor Gidlof. It uses async/await and URLSession for network calls and can be used as a network layer for any REST API on iOS, macOS, watchOS and tvOS.
- Features
- Requirements
- Usage
- Logging
- Advanced usage
- Authentication
- JWT token refresh
- Adding parameters
- Parameter encoding
- Making POST requests
- Encodable bodies
- Converting data models
- Check HTTP status codes
- Full response metadata
- Interceptors
- Multipart uploads
- Retry policy
- Download progress
- Upload progress
- Authentication
- Installation
- Swift Package Manager
- Sample code
- Contribution
- License
Features
- Easy to build server configurations and requests for any REST API
- Clear request and response logging
- URL query, JSON, and form-encoded parameter encoding
- Type-safe
Encodablerequest bodies - Authentication with Basic and Bearer token
- Automatic JWT token refresh via interceptors
- Full response metadata (status code + headers) via
HTTP.Response - Request interceptors and middleware
- Multipart form data uploads
- Retry policy with exponential backoff
- Download files with progress
- Upload files with progress
- Simple and clean syntax
- Swift 6 concurrency support
Requirements
| Platform | Min. Swift Version | Installation |
|---|---|---|
| iOS 16.4+ | 5.9 | Swift Package Manager |
| macOS 10.15+ | 5.9 | Swift Package Manager |
| tvOS 13.0+ | 5.9 | Swift Package Manager |
| watchOS 6.0+ | 5.9 | Swift Package Manager |
Usage
Networking is built around three core components:
The Network.Service is the main component of the framework that makes the actual requests to a backend.
It is initialized with a server configuration that determines the API base url and any custom HTTP headers based on request parameters.
Start by creating a requestable object. Typically an enum that conforms to Requestable:
case user(String)
// 1.
var endpoint: EndpointType {
switch self {
case .user(let username):
return Endpoint.user(username)
}
}
// 2.
var encoding: Request.Encoding { .query }
// 3.
var httpMethod: HTTP.Method { .get }
}
- Define what endpoint type the request should use. More about endpoint types below.
- Define what type of encoding the request will use.
- Define the HTTP method to use.
The EndpointType can be defined as an enum that contains all the possible endpoints for an API:
case user(String)
case repos(String)
// ...
}
extension Endpoint: EndpointType {
var path: String {
switch self {
case .user(let username):
return "users/\(username)"
case .repos(let username):
return "users/\(username)/repos"
// ...
}
}
}
Then simply create a server configuration and a new network service and make a request:
let networkService = Network.Service(server: serverConfig)
let user = GitHubUserRequest.user("brillcp")
do {
let result: GitHubUser = try await networkService.request(user)
// Handle the data
catch {
// Handle error
}
Logging
Every request is logged to the console by default. This is an example of an outgoing request log:
Outgoing request to api.github.com @ 2022-12-05 16:58:25 +0000
GET /users/brillcp?foo=bar
Header: {
Content-Type: application/json
}
Body: {}
Parameters: {
foo=bar
}
This is how the incoming responses are logged:
Incoming response from api.github.com @ 2022-12-05 16:58:32 +0000
~ /users/brillcp?foo=bar
Status-Code: 200
Localized Status-Code: no error
Content-Type: application/json; charset=utf-8
There is also a way to log the pure JSON response for requests in the console. By passing printJSONResponse: true when making a request, the response JSON will be logged in the console. That way it is easy to debug when modeling an API:
Advanced usage
Authentication
Some times an API requires that requests are authenticated. Networking currently supports basic authentication and bearer token authentication.
It involves creating a server configuration with a token provider object. The TokenProvider object can be any type of data storage, UserDefaults, Keychain, CoreData or other.
The point of the token provider is to persist an authentication token on the device and then use that token to authenticate requests.
The following implementation demonstrates how a bearer token can be retrieved from the device using UserDefaults, but as mentioned, it can be any persistant storage:
private static let tokenKey = "com.example.ios.jwt.key"
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
}
extension TokenProvider: TokenProvidable {
var token: Result<String, TokenProvidableError> {
guard let token = defaults.string(forKey: Self.tokenKey) else { return .failure(.missing) }
return .success(token)
}
func setToken(_ token: String) {
defaults.set(token, forKey: Self.tokenKey)
}
func reset() {
defaults.set(nil, forKey: Self.tokenKey)
}
}
In order to use this authentication token just implement the authorization property on the requests that require authentication:
// ...
var authorization: Authorization { .bearer }
}
This will automatically add a "Authorization: Bearer [token]" HTTP header to the request before sending it. Then just provide the token provider object when initializing a server configuration:
JWT token refresh
When a JWT expires the server responds with a 401 Unauthorized. You can use an interceptor to automatically refresh the token and retry the request. The framework re-builds the request on each retry attempt, so the refreshed token from your TokenProvider is picked up automatically:
let tokenProvider: TokenProvider
func retry(_ request: URLRequest, dueTo error: Network.Service.NetworkError, attemptCount: Int) async throws -> Bool {
// Only retry once on 401
guard case .badServerResponse(.unauthorized, _) = error, attemptCount == 0 else {
return false
}
// Call your refresh endpoint
let newToken = try await refreshToken()
tokenProvider.setToken(newToken)
// Return true -- the request is rebuilt with the new token and retried
return true
}
private func refreshToken() async throws -> String {
let url = URL(string: "https://api.example.com/auth/refresh")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
let (data, _) = try await URLSession.shared.data(for: request)
let response = try JSONDecoder().decode(TokenResponse.self, from: data)
return response.accessToken
}
}
Pass it as an interceptor when creating the service:
let server = ServerConfig(baseURL: "https://api.example.com", tokenProvider: tokenProvider)
let service = Network.Service(server: server, interceptors: [JWTRefreshInterceptor(tokenProvider: tokenProvider)])
Adding parameters
Adding parameters to a request is done by implementing the parameters property on a request:
case getData(String)
// ...
var parameters: HTTP.Parameters {
switch self {
case .getData(let username):
return [
"page": 1,
"username": username
]
}
}
}
Parameter encoding
Depedning on the encoding method, the parameters will either be encoded in the url query, in the HTTP body as JSON or as a string.
The encoding property on a request will encode the given parameters either in the url query or the HTTP body.
var encoding: Request.Encoding { .json } // Encode parameters as JSON in the HTTP body: `{"page":"1,"name":"viktor"}"`
var encoding: Request.Encoding { .body } // Encode parameters as a string in the HTTP body: `"page=1&name=viktor"`
var encoding: Request.Encoding { .multipart } // Encode using multipart/form-data (see Multipart uploads below)
Making POST requests
Making post requests to a backend API is done by setting the httpMethod property to .post and provide parameters:
case postData(String)
// ...
var httpMethod: HTTP.Method { .post }
var parameters: HTTP.Parameters {
switch self {
case .postData(let username):
return ["page": 1, "username": username]
}
}
}
Encodable bodies
For type-safe request bodies, use the body property instead of parameters. This encodes a Codable struct directly as JSON in the HTTP body:
let name: String
let age: Int
}
enum UserRequest: Requestable {
case create(CreateUser)
var endpoint: EndpointType { Endpoint.users }
var encoding: Request.Encoding { .json }
var httpMethod: HTTP.Method { .post }
var body: (any Encodable & Sendable)? {
switch self {
case .create(let user):
return user
}
}
}
When body is provided with .json encoding, it takes priority over parameters.
Converting data models
Deprecated: Prefer using the
bodyproperty (see Encodable bodies) for sending data models in requests.
If you have a custom data model that conforms to Codable you can use .asParameters() to convert the data model object to HTTP Parameters:
let name: String
let age: Int
}
let user = User(name: "Gunther", age: 69)
let parameters = user.asParameters()
print(parameters) // ["name": "Gunther", "age": "69"]
Check HTTP status codes
Sometimes it can be useful to just check for a HTTP status code when a response comes back. Use response to send a request and get back the status code in the response:
let responseCode = try await networkService.response(usersRequest)
print(responseCode == .ok)
Networking supports all the status codes defined in the HTTP protocol, see here.
Full response metadata
Use send() to get the full response including the decoded body, HTTP status code, and response headers:
// Decoded model with metadata
let response: HTTP.Response<GitHubUser> = try await networkService.send(request)
print(response.body) // The decoded GitHubUser
print(response.statusCode) // .ok
print(response.headers) // ["Content-Type": "application/json", ...]
// Raw data with metadata
let dataResponse: HTTP.Response<Data> = try await networkService.send(request)
print(dataResponse.body) // Raw Data
print(dataResponse.statusCode) // .ok
Interceptors
Interceptors allow you to adapt outgoing requests and control retry behavior. Create a type conforming to NetworkInterceptor:
func adapt(_ request: URLRequest) async throws -> URLRequest {
var request = request
let token = try await fetchToken()
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
return request
}
func retry(_ request: URLRequest, dueTo error: Network.Service.NetworkError, attemptCount: Int) async throws -> Bool {
// Retry once on 401 after refreshing the token
if case .badServerResponse(.unauthorized, _) = error, attemptCount == 0 {
try await refreshToken()
return true
}
return false
}
}
Pass interceptors when creating the network service:
Interceptors are called in order. adapt runs before each request attempt, and retry is consulted when a request fails.
Multipart uploads
For file uploads and mixed content, use multipart form data encoding:
case avatar(Data)
var endpoint: EndpointType { Endpoint.upload }
var encoding: Request.Encoding { .multipart }
var httpMethod: HTTP.Method { .post }
var multipartBody: MultipartFormData? {
switch self {
case .avatar(let imageData):
var form = MultipartFormData()
form.append(value: "profile", name: "type")
form.append(data: imageData, name: "file", fileName: "avatar.jpg", mimeType: "image/jpeg")
return form
}
}
}
The MultipartFormData builder handles boundary generation and encoding automatically.
Retry policy
RetryPolicy is a built-in interceptor that retries failed requests with exponential backoff:
maxRetryCount: 3,
retryableStatusCodes: RetryPolicy.defaultRetryableStatusCodes, // 408, 429, 500, 502, 503, 504
retryOnNetworkError: true,
baseDelay: 1.0 // seconds, doubles on each retry
)
let service = Network.Service(server: serverConfig, interceptors: [retryPolicy])
You can combine it with other interceptors:
Download progress
You can download files and track progress asynchronously using the Downloader. Call start() to get a DownloadHandle with progress stream, completion task, and cancellation:
let downloader = networkService.downloader(url: url)
let handle = await downloader.start()
// Track download progress
for await progress in handle.progress {
print("Download progress: \(progress * 100)%")
}
do {
// Await the final file URL
let fileURL = try await handle.finished.value
print("Download completed at: \(fileURL)")
} catch {
print("Download failed: \(error)")
}
// Cancel if needed
handle.cancel()
Upload progress
For uploads that need progress tracking, use the Uploader. Build it from a Requestable and call start() to get an UploadHandle:
let handle = await uploader.start()
// Track upload progress
for await progress in handle.progress {
print("Upload progress: \(progress * 100)%")
}
do {
// Await the server response
let responseData = try await handle.finished.value
print("Upload completed: \(String(data: responseData, encoding: .utf8) ?? "")")
} catch {
print("Upload failed: \(error)")
}
// Cancel if needed
handle.cancel()
The Uploader uses URLSessionUploadTask under the hood and reports byte-level progress via its delegate. The request must use .multipart encoding with a multipartBody (see Multipart uploads).
Installation
Swift Package Manager
The Swift Package Manager is a tool for automating the distribution of Swift code and is integrated into the swift compiler. Once you have your Swift package set up, adding Networking as a dependency is as easy as adding it to the dependencies value of your Package.swift.
dependencies: [
.package(url: "https://github.com/brillcp/Networking.git", .upToNextMajor(from: "0.9.14"))
]
Sample code
The sample project is a small application that demonstrates some of the functionality of the framework. Start by cloning the repo:
git clone https://github.com/brillcp/Networking.git
Open Networking-Example.xcodeproj and run.
Contribution
-
Create an issue if you:
- Are struggling or have any questions
- Want to improve the framework
-
Create a PR if you:
- Find a bug
- Find a documentation typo
License
Networking is released under the MIT license. See LICENSE for more details.