How to design and implement great APIs using Swift on iOS

Swift iOS Networking API Jan 31, 2021 · 8 min read

A networking component is a very important part of mobile app development because it is where our apps come to life. I have used a very similar implementation for the networking layer of different types of apps I developed. Now, I want to share my experience with others and get feedback.

API Documentation

Suppose, the backend developers we work with have given documentation on how to communicate with the backend API like below. I am just providing the Request part and omitting the Response one for simplicity. Of course, you may have a lot more endpoints in your case. But, the solution is the same or at least very similar.

  • Authentication: POST /v1/auth
    • Body parameters
      • email [string] - Required
      • password [string] - Required
  • Create an article: POST /v1/articles
    • Body parameters
      • title [string] - Required
      • summary [string] - Optional
      • content [string] - Required
  • Delete an article: DELETE /v1/articles/{id}
    • Path parameters
      • id [integer] - Required
  • Get an article: GET /v2/articles/{id}
    • Path parameters
      • id [integer] - Required
  • Get articles: GET /v2/articles?page={page}
    • Query parameters
      • page [integer] - Optional, defaults to 1
  • Update an article: PUT /v1/articles/{id}
    • Path parameters
      • id [integer] - Required
    • Body parameters
      • title [string] - Required
      • summary [string] - Optional
      • content [string] - Required

API Design & Implementation

We can represent an article with either a struct or class in Swift like below. I am using a struct for simplicity. It might even be a proper way of implementation in your use case. The Article struct can be either an entity or just a DTO (a.k.a Data Transfer Object) if we want more separation of concerns and make our networking layer as a standalone repository and Swift Package. Also, you might want to use a UUID type for the id property and make it required depending on your app's requirements. The reason for making it required is that a UUID generated on client devices is unique so you don't depend on the backend API for a unique id generation. It also enables backend developers to easily use multiple database instances, say, with Docker and Kubernetes to be able to easily scale the database in the future. But, our current implementation of the unique id generation depends the backend API because it is of Int type and auto-incremented by the backend database for each object and object type. So, we are sticking with the Int type for the id property in our example.

struct Article: Codable {
    var id: Int?
    var title: String
    var summary: String?
    var content: String
}

According to the API documentation, we only have endpoints with 4 HTTP methods and 2 API versions.

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

enum APIVersion: String {
    case v1
    case v2
}

Here is a very interesting part of what we can do with an enum. Basically, we are able to represent everything about endpoints just by using an enum. As for the auth route, you might want to create a User class and use its instance as a parameter instead. Additionally, you might have to provide instances of an App class with appID and appSecret properties and a Device class with the current device information as additional parameters for security reasons. I am just keeping the example as simple as possible.

import Foundation

enum Route {
    case auth(email: String, password: String)
    case createArticle(_ article: Article)
    case deleteArticleByID(_ id: Int)
    case getArticleByID(_ id: Int)
    case getArticles(page: Int = 1)
    case updateArticle(_ article: Article)

    var path: String {
        switch self {
        case .auth:
            return "auth"
        case .createArticle,
             .getArticles:
            return "articles"
        case .deleteArticleByID(let id),
             .getArticleByID(let id):
            return "articles/\(id)"
        case .updateArticle(let article):
            return "articles/\(article.id ?? 0)"
        }
    }

    var httpMethod: HTTPMethod {
        switch self {
        case .auth,
             .createArticle:
            return .post
        case .deleteArticleByID:
            return .delete
        case .getArticleByID,
             .getArticles:
            return .get
        case .updateArticle:
            return .put
        }
    }

    var apiVersion: APIVersion {
        switch self {
        case .getArticleByID,
             .getArticles:
            return .v2
        default:
            return .v1
        }
    }

    var requiresAuthentication: Bool {
        switch self {
        case .auth:
            return false
        default:
            return true
        }
    }

    var queryItems: Set<URLQueryItem>? {
        switch self {
        case .getArticles(let page):
            return Set<URLQueryItem>(arrayLiteral: URLQueryItem(name: "page", value: "\(page)"))
        default:
            return nil
        }
    }

    func serializeBody() throws -> Data? {
        switch self {
        case .auth(let email, let password):
            let dictionary: [String: Any] = [
                "email": email,
                "password": password
            ]

            return try JSONSerialization.data(withJSONObject: dictionary)
        case .createArticle(let article),
             .updateArticle(let article):
            return try JSONEncoder().encode(article)
        default:
            return nil
        }
    }

    func deserialize<T: Decodable>(body: Data, toObjectOfType: T.Type) throws -> T {
        // You may add a `switch` statement and customize your deserialization for each route
        try JSONDecoder().decode(T.self, from: body)
    }
}

To be able to save an access token generated by the backend API when we authenticate with the auth request and reuse it for subsequent authenticated calls to the secured part of the backend API, we can create Token struct and TokenStorage classes like below. The Token struct can conform to Codable or NSCoding based on how you serialize an access token in the Keychain.

struct Token {
    enum `Type`: String {
        case basic = "Basic"
        case bearer = "Bearer"
    }

    var type: Type
    var value: String
}

final class TokenStorage {
    func setToken(_ token: Token) {
        // Save a `token` in the Keychain
    }

    func getToken() -> Token {
        // Retrieve a `token` from the Keychain
    }
}

Here is how our APIClient looks like. We are providing sensible defaults for the init to simplify the API. Also, we are returning an instance of URLSessionDataTask for the makeRequest methods to be able to cancel a request when needed. Last but not least, we are separating the createURL and createRequest methods from the makeRequest methods to be able to create and use them independently and cover them with unit tests.

import Foundation

final class APIClient {
    let baseURL: URL
    let session: URLSession
    let tokenStorage: TokenStorage

    init(baseURL: URL, session: URLSession = .init(configuration: .default), tokenStorage: TokenStorage = .init()) {
        self.baseURL = baseURL
        self.session = session
        self.tokenStorage = tokenStorage
    }

    @discardableResult
    func makeRequest(toRoute route: Route, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) throws -> URLSessionDataTask {
        let request = try createRequest(forRoute: route)
        return makeRequest(request, completionHandler: completionHandler)
    }

    @discardableResult
    func makeRequest(_ request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) throws -> URLSessionDataTask {
        let task = session.dataTask(with: request, completionHandler: completionHandler)
        task.resume()

        return task
    }

    func createURL(forRoute route: Route) -> URL {
        let url = baseURL
            .appendingPathComponent(route.apiVersion.rawValue)
            .appendingPathComponent(route.path)
        var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)

        if let queryItems = route.queryItems {
            urlComponents?.queryItems = Array(queryItems)
        }

        // It is almost always better to avoid using `!`. Sometimes, you have to use it. When you do so,
        // make sure you cover those places with tests because it will crash your app.
        return urlComponents!.url!
    }

    func createRequest(forRoute route: Route) throws -> URLRequest {
        // Create a URL
        let url = createURL(forRoute: route)

        // Create a URLRequest
        var request = URLRequest(url: url)
        request.httpMethod = route.httpMethod.rawValue
        request.httpBody = try route.serializeBody()

        // Check if an endpoint requires an access token to be provided and set it if it does
        if route.requiresAuthentication {
            // Retrieve a `token` from the Keychain
            let token = tokenStorage.getToken()

            // Set the `Authorization` header
            request.setValue("\(token.type.rawValue) \(token.value)", forHTTPHeaderField: "Authorization")
        }

        return request
    }
}

Usage

Here is how we can make an API request to the articles endpoint to create a new article. You may want to create and move the error handling and response handling to ErrorHandler and ResponseHandler classes respectively because it is highly likely that you handle them the same or very similar way for all or most of the endpoints.

let article = Article(title: "Title", summary: "Summary", content: "Content")
let baseURL = URL(string: "http://api.domain.com")!
let route: Route = .createArticle(article)
let apiClient = APIClient(baseURL: baseURL)
try apiClient.makeRequest(toRoute: route, completionHandler: { (data, response, error) in
    if let error = error {
        // Handle an error
        print("Error: \(error)")
    } else {
        // Handle a response
        if let response = response as? HTTPURLResponse {
            // Process the `data` based on the HTTP `statusCode`
            switch response.statusCode {
            case 200...299:
                if let data = data {
                    do {
                        let object = try route.deserialize(body: data, toObjectOfType: Article.self)
                        // Process the `object`, for example, set the article's unique `id` generated from the backend API, etc
                    } catch {
                        print("Error: \(error)")
                    }
                }
                print("Success")
            case 300...399:
                print("Redirection")
            case 400...499:
                print("Client error")
            case 500...599:
                print("Server error")
            default:
                print("Unknown status code: \(response.statusCode)")
            }
        } else {
            // Handle a case when a `response` is not of `HTTPURLResponse` type
        }
    }
})

If we want to make changes to a request for a route before making an API call, we can create a request first with the createRequest method, mutate it, and then call the makeRequest method by providing the request variable as an argument.

let baseURL = URL(string: "http://api.domain.com")!
let route: Route = .getArticles()
let apiClient = APIClient(baseURL: baseURL)
let request = try apiClient.createRequest(forRoute: route)
try apiClient.makeRequest(request, completionHandler: { (data, response, error) in
    // Handle with the `ErrorHandler` and `ResponseHandler` classes mentioned earlier
})

Conclusion

It is important to note that this is not a complete solution, but just a template you can start with. What is interesting is we can easily write unit tests for the code with 100% code coverage. For small to mid-size projects, I think this can be one of the best solutions out there. For big projects with multiple apps, we may have to make it a standalone repository and Swift Package to be able to reuse it in multiple apps. Also, we can create nested routes for different types of objects when the API starts getting more complex with more endpoints. Well, it all depends on your creativity though based on your API requirements. But, always keep in mind that the rule of thumb is to make it as simple as possible and start with concrete types instead of trying to use protocols for abstraction. You can't abstract out if you don't know the concrete use cases. If you try to do so from the start, you will most likely get it wrong.