Skip to content

Best Practices and a Complete Example

Husnain Ali edited this page Sep 27, 2026 · 3 revisions

Best Practices and a Complete Example

Best practices

  • Define one typed Endpoint per API operation; decode straight into your models.
  • Inject the NetworkClient (or a wrapper protocol) so features are unit-testable.
  • Never hardcode tokens or base URLs; use NetworkEnvironment and TokenStorage.
  • Use KeychainTokenStorage in production; keep InMemoryTokenStorage for tests.
  • Pin the public key, not the certificate, and always deploy two pins so a rotation is not an outage.
  • Do not blindly enable retryNonIdempotent; make each POST opt in only when it is genuinely safe to repeat.
  • Set skipRequestQueue = true on the token-refresh endpoint.
  • Keep sensitive fields out of logs by adding them to redactedBodyKeys and redactedQueryItems.
  • Hop progress and completion callbacks to @MainActor before touching UI state.
  • Treat NetworkError.sessionExpired as your single "log the user out" signal.
  • Call await client.clearCache() when the signed-in user changes. See Caching.

Complete example

A login, an authenticated call, an automatic refresh on expiry, and typed error handling.

import SwiftNetworkKit

// MARK: Models

struct Credentials: Encodable { let email: String; let password: String }
struct Session: Decodable { let accessToken: String; let refreshToken: String; let expiresIn: Int }
struct User: Decodable { let id: Int; let name: String; let email: String }

// MARK: Endpoints

struct LogIn: Endpoint {
    typealias Response = Session
    let credentials: Credentials
    var method: HTTPMethod { .post }
    var path: String { "/auth/login" }
    var body: RequestBody? { .json(credentials) }
}

struct RefreshSession: Endpoint {
    typealias Response = Session
    let refreshToken: String
    var method: HTTPMethod { .post }
    var path: String { "/auth/refresh" }
    var body: RequestBody? { .formURLEncoded(["refresh_token": refreshToken]) }
    var skipRequestQueue: Bool { true }          // must not deadlock behind a full queue
}

struct GetMe: Endpoint {
    typealias Response = User
    var path: String { "/me" }
    var authentication: AuthRequirement { .required }
}

// MARK: Wiring

@MainActor
final class AccountService {
    private let client: NetworkClient
    private let storage = KeychainTokenStorage(service: "com.acme.app")

    init(environment: NetworkEnvironment) {
        var config = NetworkConfiguration(environment: environment)
        config.tokenStorage = storage
        config.retry = .default
        config.metrics = InMemoryMetrics()
        if environment.kind == .production {
            config.sslPinning = .publicKeys(["sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="], hosts: ["api.acme.com"])
        }

        // capture nothing that would retain self; the refresh handler is @Sendable
        let refreshClient = NetworkClient(configuration: config)
        self.client = NetworkClient(
            configuration: config,
            refresh: { storage in
                let current = try await storage.tokenPair()
                let renewed = try await refreshClient.request(RefreshSession(refreshToken: current.refreshToken ?? ""))
                return TokenPair(
                    accessToken: renewed.accessToken,
                    refreshToken: renewed.refreshToken,
                    expiryDate: Date().addingTimeInterval(TimeInterval(renewed.expiresIn))
                )
            },
            onSessionExpired: { await AppRouter.shared.logout() }
        )
    }

    func logIn(email: String, password: String) async throws {
        let session = try await client.request(LogIn(credentials: .init(email: email, password: password)))
        try await storage.setTokenPair(TokenPair(
            accessToken: session.accessToken,
            refreshToken: session.refreshToken,
            expiryDate: Date().addingTimeInterval(TimeInterval(session.expiresIn))
        ))
    }

    /// If the access token has expired, this call triggers exactly one refresh, waits for it,
    /// retries once, and returns the user. Concurrent callers share that single refresh.
    func currentUser() async throws -> User {
        do {
            return try await client.request(GetMe())
        } catch let error as NetworkError where error.code == .sessionExpired {
            throw AppError.mustReauthenticate            // refresh itself failed
        }
    }
}

Clone this wiki locally