Articles and podcasts about Swift development, by John Sundell.

Genius Scan SDK

Presented by the Genius Scan SDK

This video has been archived, as it was published several years ago, so some of its information might now be outdated. For more recent articles, please visit the main article feed.

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)
            )]
        )
    }
}