Building a Modern Networking Layer in Swift

Fetching a List of Episodes

We start this series by making a simple GET request to the mock API to fetch the list of episodes. We won't be using a third party library. One of the goals of this series is to show you how to build a modern networking layer that relies on Foundation's URLSession API. It is simpler than you might think

A Traditional, Imperative Approach

Fire up Xcode and open the starter project of this episode. Open EpisodesViewModel.swift and navigate to the fetchEpisodes() method. The view model currently loads the list of episodes from a JSON file included in the application bundle. Remove the contents of the fetchEpisodes() method.

To perform a request, we need a URLRequest object. We create a mutable URLRequest object, passing the initializer the URL of the endpoint that returns the list of episodes. We use a string literal to create the URL. Don't worry about this for now. We improve the implementation later in this series. I try to avoid string literals as much as possible. I promise you that the final result is elegant and less prone to typos.

private func fetchEpisodes() {
    var request = URLRequest(url: URL(string: "https://cocoacasts-mock-api.herokuapp.com/api/v1/episodes")!)
}

Remember that the mock API requires that every request has a header with name X-API-TOKEN. We invoke the addValue(_:forHTTPHeaderField:) method on the request, passing in the API token and the name of the header, X-API-TOKEN.

private func fetchEpisodes() {
    var request = URLRequest(url: URL(string: "https://cocoacasts-mock-api.herokuapp.com/api/v1/episodes")!)

    request.addValue("1772bb7bc78941e2b51c9c67d17ee76e", forHTTPHeaderField: "X-API-TOKEN")
}

Before we send the request to the mock API, we set isFetching to true to update the user interface. The episodes view displays a progress view as long as the request is in flight.

private func fetchEpisodes() {
    var request = URLRequest(url: URL(string: "https://cocoacasts-mock-api.herokuapp.com/api/v1/episodes")!)

    request.addValue("1772bb7bc78941e2b51c9c67d17ee76e", forHTTPHeaderField: "X-API-TOKEN")

    isFetching = true
}

To send the request, we create a data task. We ask the shared URL session for a data task by invoking its dataTask(with:completion:) method, passing in the request and a closure. The closure is invoked when the request completes, successfully or unsuccessfully. The closure accepts an optional Data object, an optional URLResponse object, and an optional Error object. We invoke resume() on the data task to send the request to the mock API. This is a step that is easily overlooked.

private func fetchEpisodes() {
    var request = URLRequest(url: URL(string: "https://cocoacasts-mock-api.herokuapp.com/api/v1/episodes")!)

    request.addValue("1772bb7bc78941e2b51c9c67d17ee76e", forHTTPHeaderField: "X-API-TOKEN")

    isFetching = true

    URLSession.shared.dataTask(with: request) { data, response, error in
        
    }.resume()
}

Because the result is reflected in the user interface, we need to make sure the response of the request is handled on the main thread. We dispatch response handling to the main thread using Grand Central Dispatch.

private func fetchEpisodes() {
    var request = URLRequest(url: URL(string: "https://cocoacasts-mock-api.herokuapp.com/api/v1/episodes")!)

    request.addValue("1772bb7bc78941e2b51c9c67d17ee76e", forHTTPHeaderField: "X-API-TOKEN")

    isFetching = true

    URLSession.shared.dataTask(with: request) { data, response, error in
        DispatchQueue.main.async {
            
        }
    }.resume()
}

Before we handle the response, we create a capture list to weakly reference self in the completion handler of the data task and set isFetching to false to hide the progress view. While it isn't strictly necessary to weakly reference the view model in this example, I tend to weakly reference self to prevent the completion handler from holding on to self for too long.

private func fetchEpisodes() {
    var request = URLRequest(url: URL(string: "https://cocoacasts-mock-api.herokuapp.com/api/v1/episodes")!)

    request.addValue("1772bb7bc78941e2b51c9c67d17ee76e", forHTTPHeaderField: "X-API-TOKEN")

    isFetching = true

    URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
        DispatchQueue.main.async {
            self?.isFetching = false
        }
    }.resume()
}

We can consider the request a failure if error has a value or if the status code of the response isn't equal to 200. In that scenario, we print an error message to the console. We improve error handling in the next episode.

private func fetchEpisodes() {
    var request = URLRequest(url: URL(string: "https://cocoacasts-mock-api.herokuapp.com/api/v1/episodes")!)

    request.addValue("1772bb7bc78941e2b51c9c67d17ee76e", forHTTPHeaderField: "X-API-TOKEN")

    isFetching = true

    URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
        DispatchQueue.main.async {
            self?.isFetching = false

            if error != nil || (response as? HTTPURLResponse)?.statusCode != 200 {
                print("Unable to Fetch Episodes")
            }
        }
    }.resume()
}

If the request isn't a failure, we safely unwrap the value of the data parameter using an else if clause. The Data object is converted to an array of Episode objects using a JSONDecoder instance. In the body of the else if clause, we assign the array of episodes to the episodes property.

private func fetchEpisodes() {
    var request = URLRequest(url: URL(string: "https://cocoacasts-mock-api.herokuapp.com/api/v1/episodes")!)

    request.addValue("1772bb7bc78941e2b51c9c67d17ee76e", forHTTPHeaderField: "X-API-TOKEN")

    isFetching = true

    URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
        DispatchQueue.main.async {
            self?.isFetching = false

            if error != nil || (response as? HTTPURLResponse)?.statusCode != 200 {
                print("Unable to Fetch Episodes")
            } else if let data = data, let episodes = try? JSONDecoder().decode([Episode].self, from: data) {
                self?.episodes = episodes
            }
        }
    }.resume()
}

We have a basic but working implementation to fetch the list of episodes from the mock API. Build and run the application in a simulator to see the result.

Fetching the List of Episodes

A More Modern, Reactive Approach

The current implementation is what I refer to as a traditional, imperative approach. We can modernize the implementation by making it more reactive using the Combine framework. We no longer ask the shared URL session for a data task, we ask it for a data task publisher instead. A data task publisher is nothing more than a publisher that emits the result of the request. It completes with an error if the request fails. The dataTaskPublisher(for:) method accepts a URLRequest object as its only argument.

private func fetchEpisodes() {
    var request = URLRequest(url: URL(string: "https://cocoacasts-mock-api.herokuapp.com/api/v1/episodes")!)

    request.addValue("1772bb7bc78941e2b51c9c67d17ee76e", forHTTPHeaderField: "X-API-TOKEN")

    isFetching = true

    URLSession.shared.dataTaskPublisher(for: request)
}

We can leverage the decode operator to decode the response of the request. Because the decode operator expects the Output type of the upstream publisher to be Data, we first apply the map operator to transform the Output type of the data task publisher to Data. The Combine framework defines a few variants of the map operator. One variant accepts a key path. We apply the map operator to the data task publisher, passing in data as the key path.

private func fetchEpisodes() {
    var request = URLRequest(url: URL(string: "https://cocoacasts-mock-api.herokuapp.com/api/v1/episodes")!)

    request.addValue("1772bb7bc78941e2b51c9c67d17ee76e", forHTTPHeaderField: "X-API-TOKEN")

    isFetching = true

    URLSession.shared.dataTaskPublisher(for: request)
        .map(\.data)
}

Applying the decode operator is straightforward. The decode(type:decoder:) method accepts the type of the value to decode and an object conforming to the TopLevelDecoder protocol. Because we are handling a JSON response, we pass a JSONDecoder instance to the decode operator.

private func fetchEpisodes() {
    var request = URLRequest(url: URL(string: "https://cocoacasts-mock-api.herokuapp.com/api/v1/episodes")!)

    request.addValue("1772bb7bc78941e2b51c9c67d17ee76e", forHTTPHeaderField: "X-API-TOKEN")

    isFetching = true

    URLSession.shared.dataTaskPublisher(for: request)
        .map(\.data)
        .decode(type: [Episode].self, decoder: JSONDecoder())
}

As I mentioned earlier, we need to make sure the response of the request is handled on the main thread. The Combine framework makes this trivial with the receive operator. We apply the receive operator, passing in a reference to the main dispatch queue.

private func fetchEpisodes() {
    var request = URLRequest(url: URL(string: "https://cocoacasts-mock-api.herokuapp.com/api/v1/episodes")!)

    request.addValue("1772bb7bc78941e2b51c9c67d17ee76e", forHTTPHeaderField: "X-API-TOKEN")

    isFetching = true

    URLSession.shared.dataTaskPublisher(for: request)
        .map(\.data)
        .decode(type: [Episode].self, decoder: JSONDecoder())
        .receive(on: DispatchQueue.main)
}

The last step is handling the result of the request by invoking the sink(receiveCompletion:receiveValue:) method. The method accepts a closure to handle the completion event of the publisher and a closure to handle the value the publisher emits. The value is the list of episodes the view model fetches from the mock API.

In the completion handler, we set isFetching to false and we use a switch statement to determine whether the request was successful or unsuccessful. We print the error to the console if the request failed.

In the value handler, we assign the array of Episode objects to the episodes property. Last but not least, we store the AnyCancellable instance returned by the sink(receiveCompletion:receiveValue:) method in the view model's subscriptions property.

private func fetchEpisodes() {
    var request = URLRequest(url: URL(string: "https://cocoacasts-mock-api.herokuapp.com/api/v1/episodes")!)

    request.addValue("1772bb7bc78941e2b51c9c67d17ee76e", forHTTPHeaderField: "X-API-TOKEN")

    isFetching = true

    URLSession.shared.dataTaskPublisher(for: request)
        .map(\.data)
        .decode(type: [Episode].self, decoder: JSONDecoder())
        .receive(on: DispatchQueue.main)
        .sink(receiveCompletion: { [weak self] completion in
            self?.isFetching = false

            switch completion {
            case .finished:
                ()
            case .failure(let error):
                print("Unable to Fetch Episodes \(error)")
            }
        }, receiveValue: { [weak self] episodes in
            self?.episodes = episodes
        }).store(in: &subscriptions)
}

Build and run the application one more time. The result should be identical. You may be wondering what the benefit is of using a reactive approach. The benefits become more evident as the series progresses, but let me show you a simple advantage of the updated, reactive approach.

Let's say we want to retry the request once. In other words, if the request fails we retry it again. Networks are unreliable and it is possible that a failure is transient. To make this change we simply apply the retry operator to the data task publisher. That's it. This would be much more complex using an imperative approach.

private func fetchEpisodes() {
    var request = URLRequest(url: URL(string: "https://cocoacasts-mock-api.herokuapp.com/api/v1/episodes")!)

    request.addValue("1772bb7bc78941e2b51c9c67d17ee76e", forHTTPHeaderField: "X-API-TOKEN")

    isFetching = true

    URLSession.shared.dataTaskPublisher(for: request)
        .retry(1)
        .map(\.data)
        .decode(type: [Episode].self, decoder: JSONDecoder())
        .receive(on: DispatchQueue.main)
        .sink(receiveCompletion: { [weak self] completion in
            self?.isFetching = false

            switch completion {
            case .finished:
                ()
            case .failure(let error):
                print("Unable to Fetch Episodes \(error)")
            }
        }, receiveValue: { [weak self] episodes in
            self?.episodes = episodes
        }).store(in: &subscriptions)
}

What's Next?

In this episode, we created a starting point for the networking layer. In the next episode, we focus on error handling. Error handling shouldn't be an afterthought and that is why we focus on error handling early in this series.