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

Swift clip: Managing URLs and endpoints

Published on 31 Mar 2020

In this video, we’ll take a look at three different ways to manage the URLs and server endpoints that a Swift app is communicating with, and the sort of pros and cons that come with each approach.

Sample code

When getting started with a project, it’s very common to simply construct all URLs inline, like this:

func loadArticle(withID id: Article.ID,
                 using session: URLSession = .shared) {
    let url = URL(string: "https://api.myapp.com/articles/\(id)")!                 
    
    let task = session.dataTask(with: url) {
        data, response, error in
        ...
    }

    task.resume()
}

To make things a bit more organized, we can instead move our URLs into static factory methods and computed properties that we’ll add by extending URL:

extension URL {
    static var recommendations: URL {
        URL(string: "https://api.myapp.com/recommendations")!
    }

    static func article(withID id: Article.ID) -> URL {
        URL(string: "https://api.myapp.com/articles/\(id)")!
    }
}

The above approach enables us to use this really neat dot-syntax when constructing a URL:

func loadArticle(withID id: Article.ID,
                 using session: URLSession = .shared) {
    let task = session.dataTask(with: .article(withID: id)) {
        data, response, error in
        ...
    }

    task.resume()
}

To reduce code duplication within our URL extension, we can also create a private utility method that lets us construct a URL for any given endpoint:

extension URL {
    static var recommendations: URL {
        makeForEndpoint("recommendations")
    }

    static func article(withID id: Article.ID) -> URL {
        makeForEndpoint("articles/\(id)")
    }
}

private extension URL {
    static func makeForEndpoint(_ endpoint: String) -> URL {
        URL(string: "https://api.myapp.com/\(endpoint)")!
    }
}

An arguably even more organized approach would be to define a dedicated Endpoint enum, with cases for each of the server endpoints that our app calls:

enum Endpoint {
    case recommendations
    case article(id: Article.ID)
    case search(query: String, maxResultCount: Int = 100)
}

We’ll then need to implement a way to convert an instance of the above enum into an actual URL value, which could be done through a computed property:

extension Endpoint {
    var url: URL {
        switch self {
        case .recommendations:
            return .makeForEndpoint("recommendations")
        case .article(let id):
            return .makeForEndpoint("articles/\(id)")
        case .search(let query, let count):
            return .makeForEndpoint("search/\(query)?count=\(count)")
        }
    }
}

When performing our network requests, we’ll now need to first construct an Endpoint instance, and then convert it into a URL — like this:

func loadArticle(withID id: Article.ID,
                 using session: URLSession = .shared) {
    let endpoint = Endpoint.article(id: id)

    let task = session.dataTask(with: endpoint.url) {
        data, response, error in
        ...
    }

    task.resume()
}

To make the above task a bit easier, we could instead extend URLSession with a convenience API that accepts an Endpoint to call, and then auto-starts a URLSessionDataTask for us:

extension URLSession {
    typealias Handler = (Data?, URLResponse?, Error?) -> Void

    @discardableResult
    func request(
        _ endpoint: Endpoint,
        then handler: @escaping Handler
    ) -> URLSessionDataTask {
        let task = dataTask(
            with: endpoint.url,
            completionHandler: handler
        )

        task.resume()
        return task
    }
}

One way to further improve the above convenience API would be to make it use a Result type, rather than multiple optionals.

With the above in place, we can now heavily simplify our networking code:

func loadArticle(withID id: Article.ID,
                 using session: URLSession = .shared) {
    session.request(.article(id: id)) {
        data, response, error in
        ...
    }
}

However, the enum-based approach requires us to define all of our app’s endpoints in a single place, which might not be practical (especially if we’re looking to extract our networking code into a separate module). So let’s also explore using a struct instead:

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

Just like our enum-based Endpoint type, our struct will also need a computed property for converting an endpoint instance into a URL — only this time we’ll use URLComponents to do that, and we’ll also add a precondition to get a better error message in case we encountered an invalid URL:

extension Endpoint {
    var url: URL {
        var components = URLComponents()
        components.scheme = "https"
        components.host = "api.myapp.com"
        components.path = "/" + path
        components.queryItems = queryItems

        guard let url = components.url else {
            preconditionFailure(
                "Invalid URL components: \(components)"
            )
        }

        return url
    }
}

With the above in place, we can now define each of our endpoints using static methods and properties — which will still let us use the same nice dot-syntax at our call sites, but with the added flexibility that endpoints can now be defined away from the declaration of Endpoint itself:

extension Endpoint {
    static var recommendations: Self {
        Endpoint(path: "recommendations")
    }

    static func article(withID id: Article.ID) -> Self {
        Endpoint(path: "articles/\(id)")
    }

    static func search(for query: String,
                       maxResultCount: Int = 100) -> Self {
        Endpoint(
            path: "search/\(query)",
            queryItems: [URLQueryItem(
                name: "count",
                value: String(maxResultCount)
            )]
        )
    }
}