A lightweight, zero-dependency Swift networking library designed for type-safe HTTP requests using modern Swift concurrency.
- 🔒 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+
- Swift 6.0+
- macOS 12.0+ / iOS 15.0+
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")
]
import MicroClient
let configuration = NetworkConfiguration(
session: .shared,
defaultDecoder: JSONDecoder(),
defaultEncoder: JSONEncoder(),
baseURL: URL(string: "https://api.example.com")!
)
let client = NetworkClient(configuration: configuration)
struct User: Codable {
let id: Int
let name: String
let email: String
}
struct CreateUserRequest: Encodable {
let name: String
let email: String
}
// 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")
]
)
MicroClient is built around four core components that work together:
The main client interface providing an async/await API:
public protocol NetworkClientProtocol {
func run<RequestModel, ResponseModel>(
_ networkRequest: NetworkRequest<RequestModel, ResponseModel>
) async throws -> NetworkResponse<ResponseModel>
}
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]?
}
Wraps decoded response with original URLResponse metadata:
public struct NetworkResponse<ResponseModel> {
public let value: ResponseModel
public let response: URLResponse
}
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]
}
Configure automatic retries for failed requests.
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)
)
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
)
Enable detailed logging for requests and responses.
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
)
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
)
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.
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"))
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
}
}
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)
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.
)
Override global configuration per request:
let customDecoder = JSONDecoder()
customDecoder.dateDecodingStrategy = .iso8601
let request = NetworkRequest<VoidRequest, TimestampedResponse>(
path: "/events",
method: .get,
decoder: customDecoder
)
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")
]
)
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")
]
)
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)")
}
MicroClient is designed with testing in mind. The protocol-based architecture makes it easy to create mocks.
swift build
swift test
SwiftLint is integrated and run during build.
MicroClient is available under the MIT license. See the LICENSE file for more info.