Skip to content
intermediate Phase 5 · Networking & Data

REST API Integration

Build a complete API client: endpoints, request/response models, error handling, and retry logic.

1h
4 problems
Topic Progress 0%

API Client Architecture

Why a Dedicated API Client?

A well-designed API client centralizes all networking logic. It provides a single place to handle base URLs, authentication headers, request formatting, response parsing, and error handling.

Protocol-Based Design

Define an API client protocol:

protocol APIClient {
    func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T
}

Concrete Implementation

final class LiveAPIClient: APIClient {
    private let session: URLSession
    private let decoder: JSONDecoder
    private let baseURL: URL

    init(baseURL: URL, session: URLSession = .shared) {
        self.baseURL = baseURL
        self.session = session
        self.decoder = JSONDecoder()
        self.decoder.keyDecodingStrategy = .convertFromSnakeCase
        self.decoder.dateDecodingStrategy = .iso8601
    }

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

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

        guard (200...299).contains(httpResponse.statusCode) else {
            throw APIError.httpError(statusCode: httpResponse.statusCode, data: data)
        }

        return try decoder.decode(T.self, from: data)
    }
}

Dependency Injection

Inject the API client into your services:

final class UserService {
    private let apiClient: APIClient

    init(apiClient: APIClient = LiveAPIClient(baseURL: URL(string: "https://api.example.com")!)) {
        self.apiClient = apiClient
    }

    func getUsers() async throws -> [User] {
        try await apiClient.request(.users)
    }
}

This makes testing easy - you can inject a mock client.

Endpoint Design

Enum-Based Endpoints

Define all API endpoints as an enum for type safety:

enum Endpoint {
    case users
    case user(id: Int)
    case posts(userId: Int)
    case createUser(name: String, email: String)
    case updateUser(id: Int, name: String)
    case deleteUser(id: Int)
}

URL Construction

Build URLs from endpoints:

extension Endpoint {
    func urlRequest(base: URL) throws -> URLRequest {
        var components = URLComponents(url: base.appendingPathComponent(path), resolvingAgainstBaseURL: false)

        if let queryItems = queryItems {
            components?.queryItems = queryItems.map { URLQueryItem(name: $0.key, value: $0.value) }
        }

        guard let url = components?.url else {
            throw APIError.invalidURL
        }

        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue

        if let body = body {
            request.httpBody = try JSONEncoder().encode(body)
            request.addValue("application/json", forHTTPHeaderField: "Content-Type")
        }

        request.addValue("application/json", forHTTPHeaderField: "Accept")
        request.timeoutInterval = 30

        return request
    }

    private var path: String {
        switch self {
        case .users: return "/users"
        case .user(let id): return "/users/\(id)"
        case .posts(let userId): return "/users/\(userId)/posts"
        case .createUser: return "/users"
        case .updateUser(let id, _): return "/users/\(id)"
        case .deleteUser(let id): return "/users/\(id)"
        }
    }

    private var method: HTTPMethod {
        switch self {
        case .users, .user, .posts: return .get
        case .createUser: return .post
        case .updateUser: return .put
        case .deleteUser: return .delete
        }
    }
}

Request Bodies

For POST and PUT requests, create request body structs:

struct CreateUserBody: Encodable {
    let name: String
    let email: String
}

struct UpdateUserBody: Encodable {
    let name: String
}

HTTP Method Enum

enum HTTPMethod: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case patch = "PATCH"
    case delete = "DELETE"
}

Error Handling and Retry

API Error Types

Define comprehensive error types:

enum APIError: Error, LocalizedError {
    case invalidURL
    case invalidResponse
    case decodingError(Error)
    case httpError(statusCode: Int, data: Data)
    case networkError(Error)
    case rateLimited(retryAfter: TimeInterval)
    case unauthorized
    case notFound
    case serverError

    var errorDescription: String? {
        switch self {
        case .invalidURL: return "Invalid URL"
        case .invalidResponse: return "Invalid server response"
        case .decodingError(let error): return "Decoding failed: \(error.localizedDescription)"
        case .httpError(let code, _): return "HTTP error \(code)"
        case .networkError(let error): return "Network error: \(error.localizedDescription)"
        case .rateLimited(let retry): return "Rate limited. Retry after \(retry)s"
        case .unauthorized: return "Unauthorized"
        case .notFound: return "Resource not found"
        case .serverError: return "Server error"
        }
    }
}

Retry with Exponential Backoff

func requestWithRetry<T: Decodable>(_ endpoint: Endpoint, maxRetries: Int = 3) async throws -> T {
    var lastError: Error?

    for attempt in 0..<maxRetries {
        do {
            return try await apiClient.request(endpoint)
        } catch APIError.httpError(let statusCode, _) where (500...599).contains(statusCode) {
            lastError = APIError.serverError
            let delay = pow(2.0, Double(attempt))
            try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
        } catch APIError.rateLimited(let retryAfter) {
            lastError = APIError.rateLimited(retryAfter: retryAfter)
            try await Task.sleep(nanoseconds: UInt64(retryAfter * 1_000_000_000))
        } catch {
            throw error
        }
    }

    throw lastError ?? APIError.serverError
}

Result Type Handling

Use Result for callback-based error handling:

func fetchUsers() async -> Result<[User], APIError> {
    do {
        let users: [User] = try await apiClient.request(.users)
        return .success(users)
    } catch let error as APIError {
        return .failure(error)
    } catch {
        return .failure(.networkError(error))
    }
}

Network Monitor Integration

Check connectivity before making requests:

import Network

final class NetworkMonitor {
    static let shared = NetworkMonitor()
    private let monitor = NWPathMonitor()
    private(set) var isConnected = true

    func startMonitoring() {
        monitor.pathUpdateHandler = { [weak self] path in
            self?.isConnected = path.status == .satisfied
        }
        monitor.start(queue: DispatchQueue(label: "NetworkMonitor"))
    }
}

// Usage in API client
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
    guard NetworkMonitor.shared.isConnected else {
        throw APIError.networkError(NSError(domain: "No connection", code: -1))
    }
    // make request
}

Quiz

1. What is the benefit of an enum-based Endpoint design?

Question 1 options

2. What is exponential backoff?

Question 2 options

3. Why use protocol-based API client design?

Question 3 options

4. Which HTTP status codes typically trigger a retry?

Question 4 options

Flashcards

Question

Why use an enum for API endpoints?

Answer

Enum-based endpoints provide type-safe URL construction and exhaustive switch handling for all routes.

Question

What is the purpose of an API client protocol?

Answer

It enables dependency injection, making it easy to swap real implementations with mocks for testing.

Question

When should you retry a failed request?

Answer

Retry on 5xx server errors (transient) and rate limiting. Do not retry on 4xx client errors (permanent).

Question

What is exponential backoff?

Answer

A retry strategy where the delay doubles each attempt (1s, 2s, 4s) to prevent server overload.

Revision Notes

Key Takeaways

  • 1. Protocol-based API clients enable testing and dependency injection
  • 2. Enum-based endpoints provide compile-time type safety for all routes
  • 3. Exponential backoff prevents server overload during retries
  • 4. Custom APIError enums provide structured, user-friendly error messages

Interview Tips

  • Explain the benefit of protocol-based API client design for testing
  • Know how to implement enum-based endpoint routing
  • Describe when to retry vs when to fail immediately
  • Discuss exponential backoff and rate limit handling

Cheat Sheet

APIClient protocol for dependency injection. Enum-based endpoints for type-safe routing. JSONDecoder with keyDecodingStrategy for snake_case. Retry with exponential backoff on 5xx errors. NetworkMonitor for connectivity checks. Result type for error handling without throwing.