- Swift 100%
| .github/workflows | ||
| Sources/MicroClient | ||
| Tests/MicroClientTests | ||
| .gitignore | ||
| .swiftformat | ||
| .swiftlint.yml | ||
| CLAUDE.md | ||
| LICENSE | ||
| Package.swift | ||
| README.md | ||
MicroClient
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
- ❌ Task Cancellation: Full support for Swift structured concurrency cancellation
- 📱 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")
]
)
Task Cancellation
MicroClient fully supports Swift's structured concurrency cancellation, allowing you to cancel in-flight network requests. When a task is cancelled, the client will immediately stop processing and throw a CancellationError.
Basic Cancellation
let task = Task {
let request = NetworkRequest<VoidRequest, User>(
path: "/users/123",
method: .get
)
return try await client.run(request)
}
// Cancel the request
task.cancel()
do {
let response = try await task.value
} catch is CancellationError {
print("Request was cancelled")
}
Cancellation with Timeout
let task = Task {
let request = NetworkRequest<VoidRequest, LargeDataset>(
path: "/data",
method: .get
)
return try await client.run(request)
}
Task {
try await Task.sleep(nanoseconds: 5_000_000_000) // 5 seconds
task.cancel()
}
do {
let response = try await task.value
} catch is CancellationError {
print("Request timed out and was cancelled")
}
Cancellation During Retries
Task cancellation works seamlessly with automatic retries. If a task is cancelled during retry attempts, the client will stop retrying and immediately throw a CancellationError:
let request = NetworkRequest<VoidRequest, User>(
path: "/users/123",
method: .get,
retryStrategy: .retry(count: 5)
)
let task = Task {
try await client.run(request)
}
// Cancel during retry attempts
task.cancel()
do {
try await task.value
} catch is CancellationError {
print("Request cancelled during retries")
}
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.