Skip to content

A lightweight, zero-dependency Swift networking library designed for type-safe HTTP requests using modern Swift concurrency

License

Notifications You must be signed in to change notification settings

otaviocc/MicroClient

Repository files navigation

MicroClient

codecov Check Runs Mastodon Follow

A lightweight, zero-dependency Swift networking library designed for type-safe HTTP requests using modern Swift concurrency.

Features

  • 🔒 Type-safe: Compile-time safety with generic request/response models
  • Modern: Built with Swift Concurrency (async/await)
  • 🪶 Lightweight: Zero dependencies, minimal footprint
  • ⚙️ Configurable: Global defaults with per-request customization
  • 🔄 Interceptors: Request and response middleware support with 13+ built-in interceptors for common use cases
  • 🔁 Automatic Retries: Built-in support for request retries
  • 🪵 Advanced Logging: Customizable logging for requests and responses
  • 📱 Cross-platform: Supports macOS 12+ and iOS 15+

Requirements

  • Swift 6.0+
  • macOS 12.0+ / iOS 15.0+

Installation

Swift Package Manager

Add MicroClient to your project using Xcode's package manager or by adding it to your Package.swift:

dependencies: [
    .package(url: "https://github.com/otaviocc/MicroClient", from: "0.0.17")
]

Quick Start

1. Create a Configuration

import MicroClient

let configuration = NetworkConfiguration(
    session: .shared,
    defaultDecoder: JSONDecoder(),
    defaultEncoder: JSONEncoder(),
    baseURL: URL(string: "https://api.example.com")!
)

let client = NetworkClient(configuration: configuration)

2. Define Your Models

struct User: Codable {
    let id: Int
    let name: String
    let email: String
}

struct CreateUserRequest: Encodable {
    let name: String
    let email: String
}

3. Make Requests

// GET request
let getUserRequest = NetworkRequest<VoidRequest, User>(
    path: "/users/123",
    method: .get
)

let userResponse = try await client.run(getUserRequest)
let user = userResponse.value

// POST request with body
let createUserRequest = NetworkRequest<CreateUserRequest, User>(
    path: "/users",
    method: .post,
    body: CreateUserRequest(name: "John Doe", email: "john@example.com")
)

let newUserResponse = try await client.run(createUserRequest)

// Authentication (using built-in interceptors)
let authenticatedConfig = NetworkConfiguration(
    session: .shared,
    defaultDecoder: JSONDecoder(),
    defaultEncoder: JSONEncoder(),
    baseURL: URL(string: "https://api.example.com")!,
    interceptors: [
        BearerAuthorizationInterceptor { await getAuthToken() },
        APIKeyInterceptor(apiKey: "your-api-key")
    ]
)

Architecture

MicroClient is built around four core components that work together:

NetworkClient

The main client interface providing an async/await API:

public protocol NetworkClientProtocol {
    func run<RequestModel, ResponseModel>(
        _ networkRequest: NetworkRequest<RequestModel, ResponseModel>
    ) async throws -> NetworkResponse<ResponseModel>
}

NetworkRequest

Type-safe request definitions with generic constraints:

public struct NetworkRequest<RequestModel, ResponseModel>
where RequestModel: Encodable & Sendable, ResponseModel: Decodable & Sendable {
    public let path: String?
    public let method: HTTPMethod
    public let queryItems: [URLQueryItem]
    public let formItems: [URLFormItem]?
    public let baseURL: URL?
    public let body: RequestModel?
    public let decoder: JSONDecoder?
    public let encoder: JSONEncoder?
    public let additionalHeaders: [String: String]?
    public let retryStrategy: RetryStrategy?
    public let interceptors: [NetworkRequestInterceptor]?
    public let responseInterceptors: [NetworkResponseInterceptor]?
}

NetworkResponse

Wraps decoded response with original URLResponse metadata:

public struct NetworkResponse<ResponseModel> {
    public let value: ResponseModel
    public let response: URLResponse
}

NetworkConfiguration

Centralized configuration with override capability:

public struct NetworkConfiguration: Sendable {
    public let session: URLSessionProtocol
    public let defaultDecoder: JSONDecoder
    public let defaultEncoder: JSONEncoder
    public let baseURL: URL
    public let retryStrategy: RetryStrategy
    public let logger: NetworkLogger?
    public let logLevel: NetworkLogLevel
    public let interceptors: [NetworkRequestInterceptor]
    public let responseInterceptors: [NetworkResponseInterceptor]
}

Advanced Usage

Automatic Retries

Configure automatic retries for failed requests.

Global Configuration

Set a default retry strategy for all requests in NetworkConfiguration:

let configuration = NetworkConfiguration(
    session: .shared,
    defaultDecoder: JSONDecoder(),
    defaultEncoder: JSONEncoder(),
    baseURL: URL(string: "https://api.example.com")!,
    retryStrategy: .retry(count: 3)
)

Per-Request Override

Override the global retry strategy for a specific request:

let request = NetworkRequest<VoidRequest, User>(
    path: "/users/123",
    method: .get,
    retryStrategy: .none // This request will not be retried
)

Advanced Logging

Enable detailed logging for requests and responses.

Default Logger

Use the built-in StdoutLogger to print logs to the console:

let configuration = NetworkConfiguration(
    session: .shared,
    defaultDecoder: JSONDecoder(),
    defaultEncoder: JSONEncoder(),
    baseURL: URL(string: "https://api.example.com")!,
    logger: StdoutLogger(),
    logLevel: .debug // Log debug, info, warning, and error messages
)

Custom Logger

Provide your own logger by conforming to the NetworkLogger protocol:

struct MyCustomLogger: NetworkLogger {
    func log(level: NetworkLogLevel, message: String) {
        // Integrate with your preferred logging framework
        print("[\(level)] - \(message)")
    }
}

let configuration = NetworkConfiguration(
    session: .shared,
    defaultDecoder: JSONDecoder(),
    defaultEncoder: JSONEncoder(),
    baseURL: URL(string: "https://api.example.com")!,
    logger: MyCustomLogger(),
    logLevel: .info
)

Request Interceptors

Modify requests before they are sent by creating a chain of objects that conform to the NetworkRequestInterceptor protocol. This is useful for cross-cutting concerns like adding authentication tokens, logging, or caching headers.

Built-in Interceptors

MicroClient provides several built-in interceptors for common use cases:

// API Key Authentication
APIKeyInterceptor(apiKey: "your-api-key", headerName: "X-API-Key") // default header name

// Bearer Token Authentication
BearerAuthorizationInterceptor { await getToken() } // async token provider

// Basic Authentication
BasicAuthInterceptor(username: "user", password: "pass") // static credentials
BasicAuthInterceptor { await getCredentials() } // dynamic credentials

// Content Type header
ContentTypeInterceptor(contentType: "application/json") // default
ContentTypeInterceptor(contentType: "application/xml") // custom

// Accept header
AcceptHeaderInterceptor(acceptType: "application/json") // default
AcceptHeaderInterceptor(acceptType: "application/xml") // custom

// User Agent header
UserAgentInterceptor(appName: "MyApp", version: "1.0") // generates "MyApp/1.0 (iOS)"
UserAgentInterceptor(customUserAgent: "Custom/1.0") // fully custom

// Request ID for tracking
RequestIDInterceptor(headerName: "X-Request-ID") // default header name

// Custom timeouts
TimeoutInterceptor(timeout: 30.0) // 30 seconds

// Cache control
CacheControlInterceptor(policy: .noCache)
CacheControlInterceptor(policy: .maxAge(seconds: 3600))
CacheControlInterceptor(policy: .noStore)
CacheControlInterceptor(policy: .custom("private, must-revalidate"))

1. Create a Custom Interceptor

First, define a struct or class that conforms to NetworkRequestInterceptor and implement the intercept method.

// An interceptor for adding a static API key to every request.
struct APIKeyInterceptor: NetworkRequestInterceptor {
    let apiKey: String

    func intercept(_ request: URLRequest) async throws -> URLRequest {
        var mutableRequest = request
        mutableRequest.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
        return mutableRequest
    }
}

// An interceptor that asynchronously refreshes an auth token.
struct CustomAuthTokenInterceptor: NetworkRequestInterceptor {
    let tokenProvider: @Sendable () async -> String?

    func intercept(_ request: URLRequest) async throws -> URLRequest {
        // Asynchronously get a fresh token.
        let token = await tokenProvider()

        var mutableRequest = request
        if let token = token {
            mutableRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }
        return mutableRequest
    }
}

2. Configure the Client

Add instances of your interceptors to the NetworkConfiguration. They will be executed in the order they appear in the array.

let configuration = NetworkConfiguration(
    session: .shared,
    defaultDecoder: JSONDecoder(),
    defaultEncoder: JSONEncoder(),
    baseURL: URL(string: "https://api.example.com")!,
    interceptors: [
        APIKeyInterceptor(apiKey: "my-secret-key"),
        BearerAuthorizationInterceptor(tokenProvider: myTokenProvider)
    ]
)

let client = NetworkClient(configuration: configuration)

3. Per-Request Override (Optional)

You can also provide a specific set of interceptors for an individual request. This will override the interceptors set in the global configuration.

struct OneTimeHeaderInterceptor: NetworkRequestInterceptor {
    func intercept(_ request: URLRequest) async throws -> URLRequest {
        var mutableRequest = request
        mutableRequest.setValue("true", forHTTPHeaderField: "X-Special-Request")
        return mutableRequest
    }
}

let request = NetworkRequest<VoidRequest, User>(
    path: "/users/123",
    method: .get,
    interceptors: [OneTimeHeaderInterceptor()] // This interceptor runs instead of the global ones.
)

Response Interceptors

Process responses after they are received and decoded by creating a chain of objects that conform to the NetworkResponseInterceptor protocol. This is useful for logging, metrics collection, validation, and handling rate limiting.

Built-in Response Interceptors

MicroClient provides several built-in response interceptors:

// Response Logging - Log response details
ResponseLoggingInterceptor(
    logger: StdoutLogger(),
    logLevel: .debug
)

// Metrics Collection - Collect response metrics
struct MyMetricsCollector: MetricsCollector {
    func collect(_ metrics: ResponseMetrics) async {
        // Track status codes, response sizes, timing, etc.
        print("Status: \(metrics.statusCode ?? 0), Size: \(metrics.responseSize) bytes")
    }
}
MetricsCollectionInterceptor(collector: MyMetricsCollector())

// Retry-After Handling - Handle rate limiting (429, 503)
RetryAfterInterceptor() // Throws RetryAfterError with timing information

// Custom Status Code Validation - Flexible validation beyond 200-299
StatusCodeValidationInterceptor(acceptableStatusCodes: [200, 201, 304])
StatusCodeValidationInterceptor(acceptableRange: 200...299)
StatusCodeValidationInterceptor(ranges: [200...299, 304...304])

Configure Response Interceptors

Add response interceptors to your configuration:

let configuration = NetworkConfiguration(
    session: .shared,
    defaultDecoder: JSONDecoder(),
    defaultEncoder: JSONEncoder(),
    baseURL: URL(string: "https://api.example.com")!,
    responseInterceptors: [
        ResponseLoggingInterceptor(logger: StdoutLogger()),
        MetricsCollectionInterceptor(collector: myMetricsCollector),
        RetryAfterInterceptor()
    ]
)

Create Custom Response Interceptors

Implement the NetworkResponseInterceptor protocol:

struct CustomValidationInterceptor: NetworkResponseInterceptor {
    func intercept<ResponseModel>(
        _ response: NetworkResponse<ResponseModel>,
        _ data: Data
    ) async throws -> NetworkResponse<ResponseModel> {
        // Access the decoded response
        let httpResponse = response.response as? HTTPURLResponse

        // Perform custom validation
        if let serverVersion = httpResponse?.value(forHTTPHeaderField: "X-API-Version"),
           serverVersion != "2.0" {
            throw CustomError.unsupportedAPIVersion
        }

        // Return the response (modified or unmodified)
        return response
    }
}

Per-Request Override

Override response interceptors for specific requests:

let request = NetworkRequest<VoidRequest, User>(
    path: "/users/123",
    method: .get,
    responseInterceptors: [
        StatusCodeValidationInterceptor(acceptableStatusCodes: [200, 304])
    ]
)

Handling Rate Limiting

Use RetryAfterInterceptor to handle 429 (Too Many Requests) and 503 (Service Unavailable) responses:

do {
    let response = try await client.run(request)
    // Success
} catch let error as RetryAfterError {
    if let seconds = error.retryAfterSeconds {
        print("Rate limited. Retry after \(seconds) seconds")
        try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
        // Retry the request
    } else if let date = error.retryAfterDate {
        print("Rate limited. Retry after \(date)")
    }
}

Custom Encoders/Decoders

Override global configuration per request:

let customDecoder = JSONDecoder()
customDecoder.dateDecodingStrategy = .iso8601

let request = NetworkRequest<VoidRequest, TimestampedResponse>(
    path: "/events",
    method: .get,
    decoder: customDecoder
)

Form Data

Send form-encoded data:

let request = NetworkRequest<VoidRequest, LoginResponse>(
    path: "/auth/login",
    method: .post,
    formItems: [
        URLFormItem(name: "username", value: "user"),
        URLFormItem(name: "password", value: "pass")
    ]
)

Query Parameters

Add query parameters to requests:

let request = NetworkRequest<VoidRequest, SearchResults>(
    path: "/search",
    method: .get,
    queryItems: [
        URLQueryItem(name: "q", value: "swift"),
        URLQueryItem(name: "limit", value: "10")
    ]
)

Error Handling

MicroClient provides structured error handling through the NetworkClientError enum, giving you detailed information on what went wrong.

do {
    let response = try await client.run(request)
    // Handle success
} catch let error as NetworkClientError {
    switch error {
    case .malformedURL:
        print("Error: The URL for the request was invalid.")

    case .transportError(let underlyingError):
        print("Error: A network transport error occurred: \(underlyingError.localizedDescription)")

    case .unacceptableStatusCode(let statusCode, _, let data):
        print("Error: Server returned an unacceptable status code: \(statusCode).")
        if let data = data, let errorBody = String(data: data, encoding: .utf8) {
            print("Server response: \(errorBody)")
        }

    case .decodingError(let underlyingError):
        print("Error: Failed to decode the response: \(underlyingError.localizedDescription)")

    case .encodingError(let underlyingError):
        print("Error: Failed to encode the request body: \(underlyingError.localizedDescription)")

    case .interceptorError(let underlyingError):
        print("Error: A request interceptor failed: \(underlyingError.localizedDescription)")

    case .responseInterceptorError(let underlyingError):
        print("Error: A response interceptor failed: \(underlyingError.localizedDescription)")

    case .unknown(let underlyingError):
        if let underlyingError = underlyingError {
            print("An unknown error occurred: \(underlyingError.localizedDescription)")
        } else {
            print("An unknown error occurred.")
        }
    }
} catch {
    // Handle any other errors
    print("An unexpected error occurred: \(error.localizedDescription)")
}

Testing

MicroClient is designed with testing in mind. The protocol-based architecture makes it easy to create mocks.

Development

Building

swift build

Testing

swift test

Linting

SwiftLint is integrated and run during build.

License

MicroClient is available under the MIT license. See the LICENSE file for more info.