Swift API Client Strategy

This document outlines a comprehensive strategy for creating an API client in Swift using async/await. The approach is designed to be modular, reusable, and easy to extend for various API endpoints.

Components

  1. Endpoint Protocol: Defines the base URL and path for each API endpoint.
  2. NetworkManaging Protocol: Defines the contract for network operations.
  3. NetworkManager Class: Implements the NetworkManaging protocol to handle network requests.
  4. Error Handling: Manages errors that may occur during network requests.

Implementation

1. Endpoint Protocol

Define a protocol to represent API endpoints. Each endpoint will have a base URL and a path.

protocol Endpoint {
    var base: String { get }
    var path: String { get }
}

2. NetworkManaging Protocol

Define a protocol for network operations.

protocol NetworkManaging {
    func fetch<T: Decodable>(from endpoint: Endpoint) async throws -> T
}

3. NetworkManager Class

Implement the NetworkManaging protocol in the NetworkManager class.

final class NetworkManager: NetworkManaging {
    static let shared = NetworkManager()
    private let session: URLSession

    private init(session: URLSession = .shared) {
        self.session = session
    }

    func fetch<T: Decodable>(from endpoint: Endpoint) async throws -> T {
        let request = try endpoint.urlRequest()
        let (data, response) = try await session.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.invalidResponse
        }

        try validateResponse(httpResponse)

        do {
            let decoder = JSONDecoder()
            return try decoder.decode(T.self, from: data)
        } catch {
            throw NetworkError.decodingFailed
        }
    }

    private func validateResponse(_ response: HTTPURLResponse) throws {
        switch response.statusCode {
        case 200...299:
            return
        case 400...499:
            throw NetworkError.clientError(response.statusCode)
        case 500...599:
            throw NetworkError.serverError(response.statusCode)
        default:
            throw NetworkError.unknownError(response.statusCode)
        }
    }
}

enum NetworkError: Error {
    case invalidResponse
    case decodingFailed
    case clientError(Int)
    case serverError(Int)
    case unknownError(Int)
}

6. Example Usage

Here’s an example of how to use the API client with a specific endpoint and data model.

struct ExampleEndpoint: Endpoint {
    var base: String { return "https://api.example.com" }
    var path: String { return "/data" }
}

struct ExampleData: Decodable {
    let id: Int
    let name: String
}

extension Request where Response == ExampleData {
    static func fetchData() -> Self {
        Request(
            url: URL(string: "https://api.example.com/data")!,
            method: .get([])
        )
    }
}

let request: Request<ExampleData> = .fetchData()
let response = try await URLSession.shared.decode(request)

This strategy provides a flexible and reusable framework for building API clients in Swift. You can extend it by adding more specific endpoints and data models as needed.

Case Study: Gutendex API

In this case study, we will apply the API client strategy to the Gutendex API, a JSON web API for Project Gutenberg ebook metadata.

Components

  1. Endpoint Protocol: Define the base URL and path for the Gutendex API.
  2. HttpMethod Enum: Use the existing HttpMethod enum to represent HTTP methods.
  3. Request Struct: Create requests for the Gutendex API endpoints.
  4. URLSession Extension: Use the existing URLSession extension for type-safe decoding.
  5. Error Handling: Handle potential errors during network requests.

Implementation

1. Endpoint Protocol

Define the Gutendex API endpoint.

struct GutendexEndpoint: Endpoint {
    var base: String { return "https://gutendex.com" }
    var path: String { return "/books" }
}

2. Request Struct

Create a request for fetching a list of books.

extension Request where Response == BookList {
    static func fetchBooks(queryItems: [URLQueryItem] = []) -> Self {
        let url = URL(string: "https://gutendex.com/books")!
        return Request(url: url, method: .get(queryItems))
    }
}

3. Data Models

Define the data models for the Gutendex API response.

struct BookList: Decodable {
    let count: Int
    let next: String?
    let previous: String?
    let results: [Book]
}

struct Book: Decodable {
    let id: Int
    let title: String
    let subjects: [String]
    let authors: [Person]
    let translators: [Person]
    let bookshelves: [String]
    let languages: [String]
    let copyright: Bool?
    let mediaType: String
    let formats: [String: String]
    let downloadCount: Int
}

struct Person: Decodable {
    let birthYear: Int?
    let deathYear: Int?
    let name: String
}

4. Example Usage

Fetch a list of books using the Gutendex API client.

let request: Request<BookList> = .fetchBooks(queryItems: [URLQueryItem(name: "languages", value: "en")])
let bookList = try await URLSession.shared.decode(request)
print(bookList.results)

This case study demonstrates how to apply the API client strategy to a real-world API, providing a structured and reusable approach to interacting with the Gutendex API.