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: Middleware support with 9+ 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]?
}

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]
}

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.
)

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: An 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.