Articles, podcasts and news about Swift development, by John Sundell.

Creating generic networking APIs in Swift

Published on 20 Sep 2020
Basics article available: Networking

When implementing an app’s networking layer, there’s often a number of different server endpoints that we need to support, and while each of those endpoints might return different kinds of models and data, the underlying logic used to call them tends to remain quite similar, at least within a single code base.

So this week, let’s take a look at few different techniques that can help us share as much of that common networking logic as possible — while also utilizing Swift’s advanced type system to make our networking code more robust and easier to validate.

Modeling shared structures

When working with certain web APIs, especially those that are following a REST-like design, it’s incredibly common to receive JSON responses that contain their actual data nested under a key that’s common among all endpoints. For example, the following JSON uses result as such a top-level key:

{
    "result": {
        "id": "D4F28578-51BD-40F4-A8BD-387668E06EF8",
        "name": "John Sundell",
        "twitterHandle": "johnsundell",
        "gitHubUsername": "johnsundell"
    }
}

Now the question is, what would be an elegant way to handle the above kind of situation on the client side, especially when using Codable to decode our JSON responses into actual Swift model types?

One option would be to wrap the model that we’re looking to extract into a dedicated response type that we can then decode directly from our downloaded Data. For example, let’s say that the above JSON represents a User model — which might lead us to create the following nested NetworkResponse wrapper for decoding such a response:

struct User: Identifiable, Codable {
    let id: UUID
    var name: String
    var twitterHandle: String
    var gitHubUsername: String
}

extension User {
    struct NetworkResponse: Codable {
        var result: User
    }
}

With the above in place, we can now load and decode a User instance like this (using the Combine-powered version of Foundation’s URLSession API):

struct UserLoader {
    var urlSession = URLSession.shared

    func loadUser(withID id: User.ID) -> AnyPublisher<User, Error> {
        urlSession.dataTaskPublisher(for: resolveURL(forID: id))
            .map(\.data)
            .decode(type: User.NetworkResponse.self, decoder: JSONDecoder())
            .map(\.result)
            .eraseToAnyPublisher()
    }
}

While there’s certainly nothing wrong with the above code, always having to create dedicated NetworkResponse wrappers for each of our models would likely lead to a fair amount of duplication — since that’d also require us to write the above kind of network request code multiple times as well. So let’s see if we can come up with a more generic, reusable abstraction instead.

Given that each of our network responses follow the same structure, let’s start by creating a generic NetworkResponse type that we’ll be able to use when loading any of our models:

struct NetworkResponse<Wrapped: Decodable>: Decodable {
    var result: Wrapped
}

We now no longer need to create and maintain separate wrapper types for each kind of request, but can instead specialize the above type for each concrete use case — like this:

struct UserLoader {
    var urlSession = URLSession.shared

    func loadUser(withID id: User.ID) -> AnyPublisher<User, Error> {
        urlSession.dataTaskPublisher(for: resolveURL(forID: id))
            .map(\.data)
            .decode(type: NetworkResponse<User>.self, decoder: JSONDecoder())
            .map(\.result)
            .eraseToAnyPublisher()
    }
}

While the above is definitely an improvement, the real power of modeling things like network requests using generic types is that we can keep creating utilities and convenience APIs that let us work at much higher level when performing such tasks.

For example, apart from the return type of the above loadUser method, there’s really nothing that’s User-specific about its internal logic — in fact, we would probably write more or less the exact same code when loading any of our app’s models — so let’s extract that logic into a shared abstraction instead:

extension URLSession {
    func publisher<T: Decodable>(
        for url: URL,
        responseType: T.Type = T.self,
        decoder: JSONDecoder = .init()
    ) -> AnyPublisher<T, Error> {
        dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: NetworkResponse<T>.self, decoder: decoder)
            .map(\.result)
            .eraseToAnyPublisher()
    }
}

Note how we’re using default arguments for both our responseType and decoder parameters — which is especially useful for the former of the two, since that’ll enable the compiler to automatically infer the generic type T from the surrounding context of each call site.

So if we go back to our UserLoader type from before, we can now perform all of its required networking with just one line of code — since the compiler is able to infer that we’re looking to load and decode a User model based on our loadUser method’s return type:

struct UserLoader {
    var urlSession = URLSession.shared

    func loadUser(withID id: User.ID) -> AnyPublisher<User, Error> {
        urlSession.publisher(for: resolveURL(forID: id))
    }
}

That’s quite an improvement! So much so that we might now start to ask ourselves whether we really need dedicated types for loading each of our models — since we could just as well create a completely generic ModelLoader that uses our above URLSession extension, as well as a closure that resolves which URL to use for a given model ID, to enable us to load any model within our app — like this:

struct ModelLoader<Model: Identifiable & Decodable> {
    var urlSession = URLSession.shared
    var urlResolver: (Model.ID) -> URL

    func loadModel(withID id: Model.ID) -> AnyPublisher<Model, Error> {
        urlSession.publisher(for: urlResolver(id))
    }
}

The above ModelLoader type might of course not cover all of our networking needs, but at least it could let us unify the way we load each of our main data models.

Type-based validation

Besides reducing boilerplate, creating generic APIs can also provide a way to make code more strongly typed, and can enable us to use Swift’s advanced type system to validate parts of our code at compile time.

To take a look at how that could be done within the context of networking in particular, let’s say that we’re working on an app that defines its various network requests using a Endpoint struct — such as this one from the “Managing URLs and endpoints” episode of Swift Clips:

struct Endpoint {
    var path: String
    var queryItems = [URLQueryItem]()
}

Especially when working on an app that calls many different endpoints, using a dedicated type to represent those endpoints is already a big step forward in terms of type safety and convenience — however, many apps also divide their endpoints up into different scopes or kinds. For instance, certain endpoints might only be valid to call once the user has logged in, some endpoints might require elevated permissions, while some endpoints might not require any authentication at all.

However, as our above Endpoint type is currently implemented, the Swift compiler can’t help us validate whether we’re actually allowed to call a given endpoint within a certain situation, and it also doesn’t provide any way for us to attach contextual data (such as access tokens or other authentication headers) to the requests that we’ll make. So let’s see if we can address both of those two issues using generics.

Let’s start by creating an EndpointKind protocol with two requirements — an associated type that defines what RequestData that’s required to perform a given kind of request, as well as a method for preparing a URLRequest instance using that required data:

protocol EndpointKind {
    associatedtype RequestData
    
    static func prepare(_ request: inout URLRequest,
                        with data: RequestData)
}

To learn more about the inout keyword that’s used above, and how it relates to value types, check out “Utilizing value semantics in Swift”.

Next, let’s use the above protocol to implement concrete types for each of the different kinds of endpoints that our app is calling. In this particular case we’ll simply define one type for our public endpoints, and one for our private ones — like this:

enum EndpointKinds {
    enum Public: EndpointKind {
        static func prepare(_ request: inout URLRequest,
                            with _: Void) {
            // Here we can do things like assign a custom cache
            // policy for loading our publicly available data.
            // In this example we're telling URLSession not to
            // use any locally cached data for these requests:
            request.cachePolicy = .reloadIgnoringLocalCacheData
        }
    }

    enum Private: EndpointKind {
        static func prepare(_ request: inout URLRequest,
                            with token: AccessToken) {
            // For our private endpoints, we'll require an
            // access token to be passed, which we then use to
            // assign an Authorization header to each request:
            request.addValue("Bearer \(token.rawValue)",
                forHTTPHeaderField: "Authorization"
            )
        }
    }
}

Note how we’re using Void as our Public type’s RequestData, since it doesn’t require any specific data to be passed when making a request. We then ignore that parameter within that type’s prepare method by using an underscore as its internal parameter label.

With the above pieces in place, let’s now add two generic types to our Endpoint struct from before — one that tells us what EndpointKind that a given instance belongs to, and one that defines what Response type that each endpoint response should be decoded into:

struct Endpoint<Kind: EndpointKind, Response: Decodable> {
    var path: String
    var queryItems = [URLQueryItem]()
}

At this point, we’re sort of using the above Kind and Response types as phantom types, since they’re not used to store any form of data within our Endpoint struct. For more on that topic, check out “Phantom types in Swift”.

Next up, we’ll need a way to convert Endpoint values into URLRequest instances — which could be done by combining Foundation’s URLComponents API with the prepare method that we defined earlier within our EndpointKind protocol:

extension Endpoint {
    func makeRequest(with data: Kind.RequestData) -> URLRequest? {
        var components = URLComponents()
        components.scheme = "https"
        components.host = "api.myapp.com"
        components.path = "/" + path
        components.queryItems = queryItems.isEmpty ? nil : queryItems

        // If either the path or the query items passed contained
        // invalid characters, we'll get a nil URL back:
        guard let url = components.url else {
            return nil
        }

        var request = URLRequest(url: url)
        Kind.prepare(&request, with: data)
        return request
    }
}

Now that we’re no longer using raw URLs to perform our various requests, let’s once again extend URLSession with a convenience API for making it really easy to perform requests using our new, generic Endpoint type. We’ll use a very similar approach as when building our earlier NetworkResponse-based extension — only this time we’ll use our generic types to ensure that the correct request and response types are always used for each endpoint:

extension URLSession {
    func publisher<K, R>(
        for endpoint: Endpoint<K, R>,
        using requestData: K.RequestData,
        decoder: JSONDecoder = .init()
    ) -> AnyPublisher<R, Error> {
        guard let request = endpoint.makeRequest(with: requestData) else {
            return Fail(
                error: InvalidEndpointError(endpoint: endpoint)
            ).eraseToAnyPublisher()
        }

        return dataTaskPublisher(for: request)
            .map(\.data)
            .decode(type: NetworkResponse<R>.self, decoder: decoder)
            .map(\.result)
            .eraseToAnyPublisher()
    }
}

The power of having a completely type-safe networking pipeline and a generic Endpoint struct is that we can now use generic type constraints when defining our app’s various endpoints — which in turn makes it crystal clear both what kind of data that’s required to perform each request, as well as what the resulting response type will be.

Here’s how we could define two such type-constrained APIs using static factory methods — one for a public endpoint, and one for a private one:

extension Endpoint where Kind == EndpointKinds.Public, Response == [Item] {
    static var featuredItems: Self {
        Endpoint(path: "featured")
    }
}

extension Endpoint where Kind == EndpointKinds.Private,
                         Response == SearchResults {
    static func search(for query: String) -> Self {
        Endpoint(path: "search", queryItems: [
            URLQueryItem(name: "q", value: query)
        ])
    }
}

Although making our network requests more strongly typed did require us to build a fair amount of underlying infrastructure, actually making requests is now simpler than ever — as the compiler will automatically validate that we’re passing the correct request data for each given endpoint, and that our return types match our network calls — all while giving us a really nice and concise syntax at each call site:

struct SearchResultsLoader {
    var urlSession = URLSession.shared
    var userSession: UserSession

    func loadResults(
        matching query: String
    ) -> AnyPublisher<SearchResults, Error> {
        urlSession.publisher(
            for: .search(for: query),
            using: userSession.accessToken
        )
    }
}

Of course, we could keep extending the above networking system even further, for example in order to support different HTTP methods (such as POST and PUT), various kinds of payloads, more granular error handling, and so on — so we’ll likely return to the topic of networking again in future articles.

Conclusion

When deployed strategically, generics can not only enable us to get rid of common sources of boilerplate, but can also help us improve certain parts of our code base with stronger typing and more rigid compile-time validation. However, it’s also always important to keep in mind that using generics can also make certain code more complex and harder to maintain — so striking a nice balance between simplicity and power really becomes key.

For example, some projects might not require a completely generic EndpointKind system, while others might not benefit much from associating each Endpoint with a generic Response type — even though both of those techniques can be incredibly useful to keep in mind as a project grows, or when handling a larger number of endpoints.

What do you think? Feel free to send me any questions, feedback or comments that you have, either via Twitter or email.

Thanks for reading! 🚀