Skip to content

Networking is a lightweight and powerful HTTP network framework written in Swift

License

Notifications You must be signed in to change notification settings

brillcp/Networking

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

275 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

network-header workflow release swift platforms spm license stars

Networking is a lightweight and powerful HTTP network framework written in Swift by Viktor GidlΓΆf. 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 πŸ“²

  • 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 Encodable request 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:

enum GitHubUserRequest: 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 }
}
  1. Define what endpoint type the request should use. More about endpoint types below.
  2. Define what type of encoding the request will use.
  3. Define the HTTP method to use.

The EndpointType can be defined as an enum that contains all the possible endpoints for an API:

enum Endpoint {
    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 serverConfig = ServerConfig(baseURL: "https://api.github.com")
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:

let model = try await networkService.request(user, printJSONResponse: true)

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:

final class TokenProvider {
    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:

enum AuthenticatedRequest: Requestable {
    // ...
    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:

let server = ServerConfig(baseURL: "https://api.github.com", tokenProvider: TokenProvider())

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:

struct JWTRefreshInterceptor: NetworkInterceptor {
    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 tokenProvider = TokenProvider()
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:

enum Request: Requestable {
    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 { .query }      // Encode parameters in the url: `.../users?page=1&username=viktor`
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:

enum PostRequest: Requestable {
    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:

struct CreateUser: Codable, Sendable {
    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 body property (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:

struct User: Codable {
    let name: String
    let age: Int
}

let user = User(name: "GΓΌnther", age: 69)
let parameters = user.asParameters()
print(parameters) // ["name": "GΓΌnther", "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 usersRequest = ...
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:

let request = GitHubUserRequest.user("brillcp")

// 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:

struct AuthInterceptor: 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:

let service = Network.Service(server: serverConfig, interceptors: [AuthInterceptor()])

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:

enum UploadRequest: Requestable {
    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:

let retryPolicy = RetryPolicy(
    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:

let service = Network.Service(server: serverConfig, interceptors: [AuthInterceptor(), retryPolicy])

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 url = URL(string: "https://example.com/file.zip")!

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 uploader = try await networkService.uploader(for: uploadRequest)
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.