zino-88/apiclientcore
A lightweight abstraction layer designed to help you build HTTP clients quickly and cleanly — without reinventing networking.
📖 Overview
Instead of rewriting request logic, you simply define your endpoints (path, HTTP method, query parameters, body, headers…) and initialize a reusable APIClient with a base URL and configuration. Out of the box, it uses URLSession.shared, but you can easily customize the configuration or inject your own transport for testing or advanced behavior.
✨ Key Features
- ✅ Type-Safe: Protocol-oriented design with full type safety
- ⚡️ Modern Swift: Built with async/await and Sendable support
- 🔌 Extensible: Pluggable transport layer (URLSession, Alamofire, or custom)
- 🎯 Declarative: Define endpoints as simple structs or enums
- 🛠 Flexible Encoding/Decoding: JSON, PropertyList, String, or custom encoders/decoders
- 📦 Zero Dependencies: Core implementation uses only Foundation
- 🧪 Testable: Easy to mock and test with custom transports (unit tests included)
- 🔒 Thread-Safe: Immutable-by-default design with Sendable conformance
🧩 Requirements
- iOS 16.0+
- Swift 6.0+
- Xcode 16.0+ (required for Swift 6.0 support)
🧰 Installation
Swift Package Manager
Add APIClientCore to your project via Xcode:
File > Add Package Dependencies...
Enter the repository URL: https://github.com/zino-88/APIClientCore.git
Select the version you want to useOr add it to your Package.swift:
dependencies: [
.package(url: "https://github.com/zino-88/APIClientCore.git", from: "1.0.0")
]Then add it to your target:
.target(
name: "YourTarget",
dependencies: ["APIClientCore"]
)🚀 Quick Start
1. Define Your Models
struct User: Codable {
let id: Int
let name: String
let email: String
}
struct UserInput: Codable, Sendable {
let name: String
let email: String
}2. Define Your Endpoints
enum UserEndpoint: APIEndpoint {
case list
case detail(id: Int)
case create(UserInput)
case update(id: Int, UserInput)
case delete(id: Int)
var path: String {
switch self {
case .list:
return "users"
case .detail(let id), .update(let id, _), .delete(let id):
return "users/\(id)"
case .create:
return "users"
}
}
var method: HTTPMethod {
switch self {
case .list, .detail:
return .get
case .create:
return .post
case .update:
return .put
case .delete:
return .delete
}
}
var body: (any RequestBody)? {
switch self {
case .create(let input), .update(_, let input):
return input
default:
return nil
}
}
}3. Use the Client
let client = DefaultAPIClient(
baseURL: URL(string: "https://api.example.com")!
)
// Fetch users
let users: [User] = try await client.request(UserEndpoint.list)
// Create a user
let newUser: User = try await client.request(
UserEndpoint.create(
UserInput(name: "John Doe", email: "john@example.com")
)
)
// Get user details
let user: User = try await client.request(UserEndpoint.detail(id: 42))⚙️ Advanced Usage
Custom Configuration
let config = APIConfiguration(
transport: URLSessionTransport(session: .ephemeral),
defaultHeaders: [
"Authorization": "Bearer \(token)",
"X-API-Version": "v2"
]
)
let client = DefaultAPIClient(
baseURL: URL(string: "https://api.example.com")!,
configuration: config
)Query Parameters
enum SearchEndpoint: APIEndpoint {
case search(query: String, page: Int, limit: Int)
var path: String { "search" }
var queryParameters: [String: any QueryParameterValue] {
switch self {
case .search(let query, let page, let limit):
return [
"q": query,
"page": page,
"limit": limit
]
}
}
}
// Calls: GET /search?limit=20&page=1&q=swift
let results: SearchResults = try await client.request(
SearchEndpoint.search(query: "swift", page: 1, limit: 20)
)Custom Headers per Endpoint
enum AuthEndpoint: APIEndpoint {
case login(username: String, password: String)
var path: String { "auth/login" }
var method: HTTPMethod { .post }
var headers: [String: String] {
["X-Client-Version": "1.0.0"]
}
var body: (any RequestBody)? {
switch self {
case .login(let username, let password):
return ["username": username, "password": password]
}
}
}Custom Encoders/Decoders
enum MyEndpoint: APIEndpoint {
case snakeCaseEndpoint
case timestampEndpoint
var path: String {
switch self {
case .snakeCaseEndpoint: return "snake-case"
case .timestampEndpoint: return "timestamps"
}
}
var encoder: any DataEncoder {
switch self {
case .snakeCaseEndpoint:
return JSONEncoder.snakeCase
case .timestampEndpoint:
return JSONEncoder.timestamp
}
}
var decoder: any DataDecoder {
switch self {
case .snakeCaseEndpoint:
return JSONDecoder.snakeCase
case .timestampEndpoint:
return JSONDecoder.timestamp
}
}
}Working with Raw Data
// Get raw response data
let (data, response) = try await client.requestData(
from: ImageEndpoint.download(id: "123")
)
let image = UIImage(data: data)⚠️ Error Handling
APIClientCore defines one custom error type: HTTPStatusError (thrown when the HTTP status code is not in the 200–299 range). All other errors come from the underlying transport layer.
When using URLSessionTransport (the default), you'll encounter standard Foundation errors:
URLError: Network-related errors (timeout, no connection, etc.)EncodingError: Request body encoding failuresDecodingError: Response body decoding failuresCancellationError: When the task is cancelled
do {
let user: User = try await client.request(UserEndpoint.detail(id: 999))
} catch let error as HTTPStatusError {
print("HTTP \(error.statusCode): \(error.localizedDescription)")
if let data = error.data {
// Parse error response body
let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: data)
print("Server message: \(errorResponse?.message ?? "")")
}
} catch {
print("Request failed: \(error)")
}🧩 Integration Patterns
1. Direct Protocol Conformance (Recommended)
final class MyAPIClient: APIClient {
let baseURL: URL
let configuration: APIConfiguration
init(baseURL: URL, authToken: String) {
self.baseURL = baseURL
self.configuration = APIConfiguration(
defaultHeaders: ["Authorization": "Bearer \(authToken)"]
)
}
func fetchUsers() async throws -> [User] {
try await request(UserEndpoint.list)
}
}2. Composition
final class UserService {
private let client: DefaultAPIClient
init(baseURL: URL) {
self.client = DefaultAPIClient(baseURL: baseURL)
}
func getUser(id: Int) async throws -> User {
try await client.request(UserEndpoint.detail(id: id))
}
func createUser(name: String, email: String) async throws -> User {
let request = CreateUserRequest(name: name, email: email)
return try await client.request(UserEndpoint.create(request))
}
}3. Inheritance
final class AuthenticatedAPIClient: DefaultAPIClient {
private let authManager: AuthManager
init(baseURL: URL, authManager: AuthManager) {
self.authManager = authManager
let config = APIConfiguration(
defaultHeaders: ["Authorization": "Bearer \(authManager.token)"]
)
super.init(baseURL: baseURL, configuration: config)
}
func refreshTokenIfNeeded() async throws {
// Custom authentication logic
}
}🧱 Architecture
Core Components
- APIClient – Main protocol defining the client interface
- DefaultAPIClient – Ready-to-use implementation
- APIEndpoint – Protocol for defining API endpoints
- APIConfiguration – Immutable configuration (transport, headers)
- HTTPTransport – Abstraction for the networking layer
- DataEncoder/DataDecoder – Abstractions for encoding/decoding
Transport Layer
The transport layer is fully pluggable. You can use:
- URLSessionTransport (default)
- Custom Transport implementing
HTTPTransport
Example:
struct AlamofireTransport: HTTPTransport {
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
// Implementation using Alamofire
}
}
let config = APIConfiguration(transport: AlamofireTransport())🧪 Testing
struct MockTransport: HTTPTransport {
let mockData: Data
let mockStatusCode: Int
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let response = HTTPURLResponse(
url: request.url!,
statusCode: mockStatusCode,
httpVersion: nil,
headerFields: nil
)!
return (mockData, response)
}
}
let mockConfig = APIConfiguration(
transport: MockTransport(
mockData: try JSONEncoder().encode(mockUser),
mockStatusCode: 200
)
)
let testClient = DefaultAPIClient(
baseURL: URL(string: "https://test.com")!,
configuration: mockConfig
)🧰 Convenience Extensions
JSONEncoder / JSONDecoder
.snakeCase // Convert to/from snake_case
.iso8601 // ISO 8601 date format
.timestamp // Unix timestamp
.millisecondsTimestamp // Milliseconds timestamp
.snakeCaseISO8601 // Combined strategies
.dateFormatted(formatter) // Custom date formatterStringEncoder / StringDecoder
.utf8 // UTF-8 encoding (default)
.ascii // ASCII encoding
.unicode // Unicode encodingPropertyListEncoder / PropertyListDecoder
.binary // Binary format
.xml // XML format⚠️ Known Limitations
The current version focuses on the core async/await API. The following features are not yet implemented:
- Download/Upload convenience methods
- Closure-based API for non-async contexts
- Combine support (
AnyPublisher)
These may be added in future releases based on community feedback.
🪪 License
MIT License — See LICENSE file for details.
🤝 Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
💬 Support
For issues, feature requests, or questions, please open an issue on GitHub.
Package Metadata
Repository: zino-88/apiclientcore
Default branch: main
README: README.md