Hitting an API is probably one of the most common things you do in Swift and when writing iOS apps. Designing a reusable and robust networking layer can help ensure errors are reduced and your apps are easy to maintain.

Endpoints

We first need a way to represent each endpoint our application will use. Every endpoint will have a few key properties so we can make a protocol. We can also make some enums to make various parts more safe to use. We’ll start with the HTTP method.

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

Then we can represent the scheme.

enum Scheme: String {
    case http
    case https
}

And now we can make our protocol which states which properties every endpoint needs.

protocol API {
    var scheme: Scheme { get }
    var baseURL: String { get }
    var path: String { get }
    var parameters: [URLQueryItem] { get }
    var method: Method { get }
}

Each endpoint needs a way to construct a URL from all their properties. Each API will use the same logic so we can make a protocol extension so that every implementation has the method to build the URL. We’ll make use of Swift’s URLComponents to make building the URL safer.

extension API {
    func buildURLComponents() -> URLComponents {
        var components = URLComponents()
        components.scheme = scheme.rawValue
        components.host = baseURL
        components.path = path
        components.queryItems = parameters
        return components
    }
}

Creating an API

Now we can use this structure to easily make an API. I’ll represent it with an enum so each enum case could be a different endpoint for this API. And then each API could be a different enum for larger applications or use cases. I’m just going to use the JSON placeholder API.

enum PlaceholderAPI: API {
    case posts
    case todos

    var scheme: Scheme {
        .https
    }

    var baseURL: String {
        "jsonplaceholder.typicode.com"
    }

    var path: String {
        switch self {
        case .posts:
            "/posts"
        case .todos:
            "/todos"
        }
    }

    var parameters: [URLQueryItem] {
        switch self {
        case .posts, .todos:
            []
        }
    }

    var method: Method {
        switch self {
        case .posts, .todos:
            .get
        }
    }
}

Creating the Networking Service

We’ve got an API but how do we make requests? We’ll make a service class to handle all networking requests. This will use the default URLSession.

To start we will make a protocol. This means when testing, we can easily mock the service if needed. Our networking service only really needs one method: a way to make a request. We will make it generic too so it can handle all different types as long as they are Codable.

protocol NetworkService {
    func request<T: Codable>(endpoint: API) async throws -> T
}

Next, we want a nice way to handle errors from our network service. An enum can help represent the various errors that may happen when sending network requests.

enum NetworkServiceError: Error {
    case invalidURL
    case invalidResponse
    case clientError(Int)
    case serverError(Int)
    case unexpectedStatusCode(Int)
}

Now we can start building out our networking service. We will start by injecting a URL session and JSON decoder into the initialiser.

final class DefaultNetworkService: NetworkService {

    private let session: URLSession
    private let decoder: JSONDecoder

    init(session: URLSession = .shared, decoder: JSONDecoder = .init()) {
        self.session = session
        self.decoder = decoder
    }

    func request<T: Codable>(endpoint: any API) async throws -> T {
    }
}

And finally we can implement the logic to make a request. We first build the URL components, then add the http method. Next we make the request and finally we check the status code and decode the data.

func request<T: Codable>(endpoint: any API) async throws -> T {
    let components = endpoint.buildURLComponents()
    guard let url = components.url else { throw NetworkServiceError.invalidURL }

    var request = URLRequest(url: url)
    request.httpMethod = endpoint.method.value

    let (data, response) = try await session.data(for: request)

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

    let statusCode = httpResponse.statusCode
    switch statusCode {
    case 200...299:
        return try decoder.decode(T.self, from: data)
    case 400...499:
        throw NetworkServiceError.clientError(statusCode)
    case 500...599:
        throw NetworkServiceError.serverError(statusCode)
    default:
        throw NetworkServiceError.unexpectedStatusCode(statusCode)
    }
}

Using our Networking Service

We can now use the networking service. We will start by making a model for the data.

struct Post: Codable, Identifiable {
    let userId: Int
    let id: Int
    let title: String
    let body: String
}

And then we can use our service anywhere we want. I’m going to use it in a view model for this example.

@Observable
class ViewModel {

    var posts: [Post] = []

    private let networking: NetworkService

    init(networking: NetworkService = DefaultNetworkService()) {
        self.networking = networking
    }

    func fetchPosts() async {
        do {
            posts = try await networking.request(endpoint: PlaceholderAPI.posts)
        } catch {
            print("Error fetching posts: \(error.localizedDescription)")
        }
    }
}

Conclusion

Thanks for reading. Hopefully this has given you some inspiration on how to structure your application’s networking.