Contents

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 use

Or 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 failures
  • DecodingError: Response body decoding failures
  • CancellationError: 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 formatter

StringEncoder / StringDecoder

.utf8      // UTF-8 encoding (default)
.ascii     // ASCII encoding
.unicode   // Unicode encoding

PropertyListEncoder / 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.

👤 Author

Zine Essafi BEN ALI

💬 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