Building a Network Service in Swift Using the Combine Framework

Building a Network Service in Swift Using the Combine Framework

Hello readers! Welcome to another blog post. Today we are going to see how to build a generic network service using Apple's Combine framework.

Making network calls from the app is the bread and butter of any standard iOS application. Today, we will see how to build a network service using Combine and Codable frameworks that can virtually fetch and decode any JSON data from the server.

Building a Combine Network Service

To build a Combine network service, we will start with a class named CombineNetworkService. It will take two parameters in the initializer.

  1. urlSession - An object of the type URLSession which will default to URLSession.default. We are using dependency injection to be able to mock and inject the mocked value during the unit testing
  2. baseURLString - Represents the base URL of the remote endpoint. This is also injected so that during testing or integration tests, we can quickly point the app to appropriate testing or a staging server leaving everything as is

import Foundation

final class CombineNetworkService {
    
    let urlSession: URLSession
    let baseURLString: String
    
    init(urlSession: URLSession = .shared, baseURLString: String) {
        self.urlSession = urlSession
        self.baseURLString = baseURLString
    }
}

Next, we will add a function that takes a dictionary representing URL parameters and the URL path. We will use the URLSession object in this function and its dataTaskPublisher API to download the network data using Combine framework. This function will return the AnyPublisher containing Decodable object as data.

For the sake of simplicity, we will assume, it returns no error. In case we encounter the error state, we will return the placeholder dummy comment object back to the client.


import Combine

final class CombineNetworkService {
....
.. .

    private let dummyPost = Post(userId: 0, id: 0, title: "No Title", body: "No Body")

..

    func getPublisherForResponse(endpoint: String, queryParameters: [String: String]) -> AnyPublisher<Post, Never> {

        let queryItems = queryParameters.map { URLQueryItem(name: $0, value: $1) }
        
        let urlComponents = NSURLComponents(string: baseURLString + endpoint)
        urlComponents?.queryItems = queryItems
        
        guard let url = urlComponents?.url else {
            return Just<Post>(dummyPost).eraseToAnyPublisher()
        }
        
        return urlSession.dataTaskPublisher(for: url)
            .map { $0.data }
            .decode(type: Post.self, decoder: JSONDecoder())
            .catch({ error in
                Just<Post>(self.dummyPost).eraseToAnyPublisher()
            })
            .eraseToAnyPublisher()
    }

}
We will add error handling in the later part of this tutorial replacing the dummy placeholder Post object

We will use https://jsonplaceholder.typicode.com/posts/1 endpoint to download the post associated with the given post id and this is how our Decodable model will look like.


struct Post: Decodable {
    let userId: Int
    let id: Int
    let title: String
    let body: String
}

Calling an API with combine

Now that our foundation is ready, let's call an API with CombineNetworkService object. We will pass endpoint URL and query parameters. The API will return AnyPublisher type containing Post object and no error even if something goes wrong. (Because we replace the caught error with a dummy Post object)

We will create our AnyCancellable object and store it in Set with AnyCancellable types. We will use this publisher to observe any incoming values and process them once they're ready.


import UIKit
import Combine

class ViewController: UIViewController {

    var anyCancellables = Set<AnyCancellable>()
    
    private let baseURLString = "https://jsonplaceholder.typicode.com/"
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        CombineNetworkService(baseURLString: baseURLString).getPublisherForResponse(endpoint: "posts/1", queryParameters: [:]).sink { completion in
        
            // We will ignore it for now
            
        } receiveValue: { post in
        
            print("Post title is \(post.title)")
            
        }.store(in: &anyCancellables)
    }
}

Output:


(lldb) po post.title
"sunt aut facere repellat provident occaecati excepturi optio reprehenderit"
💡
So this forms the basis of our network service which downloads the simple JSON data from server and we assume there are no errors that can happen during the network call

Handling Errors

So far, we have done a decent job of fetching API data with URL parameters using Codable models assuming there will be no error. However, real-world conditions are often riddled with network and client-side errors. In this section, we will see how to handle errors when it comes to building a network service with the Combine framework.

There are three places where we may encounter errors,

  1. Errors on the client-side before dispatching the request
  2. Errors on the client-side after receiving the server response with the error response code
  3. Unknown errors are automatically thrown during processing requests. This includes network failures or failure to decode the incoming JSON response for the given model

Let's take a look at them one by one,

  1. Errors on the client-side before dispatching the request

These kinds of errors originate on the client-side and they're reported before sending the request. For example, you're trying to form an URL from the given string or get a valid URL component from the NSURLComponents object and you get a nil value. If such a case arises, you are going to return an error back to the caller.

2. Errors on the client-side after receiving the server response with the error response code

These kinds of errors are also manually thrown from the client-side. If the client receives the response from the server but sees that the HTTP response code belongs to one of the error conditions, it can decode the error response from the server (If it already exists and the client knows how to decode it) and send it in the form of a thrown error back to the caller. The caller can then catch, parse and display the error back to the user.

3. Unknown errors automatically thrown during processing requests

Unlike the first two categories, these kinds of errors are outside the control of the client. They might be thrown from somewhere else and all client has to do is to convert them to a known error type.

For example, when the internet is down, connection time outs or there is an error decoding the incoming JSON response. In addition to these cases, some unknown errors might arise which are outside of the client's control, although they can catch and display them to users with the appropriate error message.


Now that we know what kind of errors we will be handling, let's code them one by one. First off, we will make a new error enum NetworkServiceError conforming to Error protocol and list all the above error conditions under it. We will also add a user presentable error message for each of the above cases.


enum NetworkServiceError: Error {
    case invalidURL
    case decodingError(String)
    case genericError(String)
    case invalidResponseCode(Int)
    
    var errorMessageString: String {
        switch self {
        case .invalidURL:
            return "Invalid URL encountered. Can't proceed with the request"
        case .decodingError:
            return "Encountered an error while decoding incoming server response. The data couldn’t be read because it isn’t in the correct format."
        case .genericError(let message):
            return message
        case .invalidResponseCode(let responseCode):
            return "Invalid response code encountered from the server. Expected 200, received \(responseCode)"
        }
    }
}

We will also modify the getPublisherForResponse function to be able to handle the error state by throwing an error,


func getPublisherForResponse(endpoint: String, queryParameters: [String: String]) -> AnyPublisher<Post, NetworkServiceError> {

    let queryItems = queryParameters.map { URLQueryItem(name: $0, value: $1) }
    
    let urlComponents = NSURLComponents(string: baseURLString + endpoint)
    urlComponents?.queryItems = queryItems
    
    guard let url = urlComponents?.url else {
        return Fail(error: NetworkServiceError.invalidURL).eraseToAnyPublisher()
    }
    
    return urlSession.dataTaskPublisher(for: url)
        .tryMap { (data, response) -> Data in
            if let httpResponse = response as? HTTPURLResponse {
                guard (200..<300) ~= httpResponse.statusCode else {
                    throw NetworkServiceError.invalidResponseCode(httpResponse.statusCode)
                }
            }
            return data
        }
        .decode(type: Post.self, decoder: JSONDecoder())
        .mapError { error -> NetworkServiceError in
            if let decodingError = error as? DecodingError {
                return NetworkServiceError.decodingError((decodingError as NSError).debugDescription)
            }
            return NetworkServiceError.genericError(error.localizedDescription)
        }
        .eraseToAnyPublisher()
}

This is a brief overview of our changes,

Catching and Reporting Errors at the Call site

Now that we have integrated error reporting to the web service, let's add support to catch and display errors at the call site. For this, we will use the sink API on AnyPublisherobject. As values are updated, we will receive them in the receiveValue closure and once it completes, we will get the callback in receiveCompletion closure where we can put additional checks cleanup after the subscription ends.

If there is an error in the receiveCompletion, we will show it to the user.


class ViewController: UIViewController {

    var anyCancellables = Set<AnyCancellable>()
    
    private let baseURLString = "https://jsonplaceholder.typicode.com/"

    override func viewDidLoad() {
        super.viewDidLoad()

        CombineNetworkService(baseURLString: baseURLString).getPublisherForResponse(endpoint: "posts/1", queryParameters: [:]).sink { [weak self] completion in
            if case let .failure(error) = completion {
                self?.showError(with: error.errorMessageString)
            } else if case .finished = completion {
                print("Data successfully downloaded")
            }
        } receiveValue: { post in
            print("Post title is \(post.title)")
        }.store(in: &anyCancellables)
    }

    func showError(with message: String) {

    }
}

I Don't Want to Catch Errors or the Exception State

If you don't want to catch errors in the app, Combine framework provides an alternative. You can replace the error handling logic with just one piece of code.

replaceError(with: <dummy_value>)

You can call this at any point on your subscriber. If any error is thrown in the processing, it will silence the error and return the placeholder Post object back to the caller - For example, returning a dummy Post object as we saw above.

With this, our API becomes too bit shorter,


final class CombineNetworkService {
    
    private let dummyPost = Post(userId: 0, id: 0, title: "No Title", body: "No Body")
    
    .....
    ...
    .
    
    func getPublisherForResponse(endpoint: String, queryParameters: [String: String]) -> AnyPublisher<Post, Never> {
    
    ....
    ..
    
        return urlSession.dataTaskPublisher(for: url)
                .map { $0.data }
                .decode(type: Post.self, decoder: JSONDecoder())
                .replaceError(with: dummyPost)
                .eraseToAnyPublisher()
    
    }
Please note that since this function never throws an error, the error type of Publisher has been replaced with Never
💡
So now our Combine network service is ready to use in Swift and the iOS application. We can use it to download JSON and convert it into any Decodable model object. Can we do better? Yes, of course. Let's see how in the next section.

Next Steps

Although our network service is built using Combine is ready, there are a couple of major problems with it,

  1. The service is bound to the Post model. In the future, if we need to download an object of a different Decodable type, say Comment, we will need another getPublisherForResponse function
  2. Even if we address the first problem, we can only get a single Decodable object from this API. If we need to get an array of Decodable objects, we need to add that support in the form of another API.

We will address both these issues with changes in the following section


  1. We will make getPublisherForResponse function generic where it will return any object with AnyPublisher as long as that object conforms to a Decodable protocol

Since this function is now a generic function, we need to specify the Decodable type we are expecting at the call site.

You can replace Post type above with any Decodable type you're expecting from the network service given the endpoint and the query parameters passed to the API.

  1. We will introduce another similar function which will return an array of Decodable objects, given the endpoint and the query parameters

This function will be very similar to the existing getPublisherForResponse function except it returns an array of Decodable objects instead of a single Decodable object. There are only a couple of places we need to make changes in to make it able to handle the array type.

We will call this API in a similar fashion we did for the first single Decodable object version. We will use the endpoint https://jsonplaceholder.typicode.com/posts/1/comments with the new Decodable type Comment and replace the single Post object with an array of Comment objects.

This endpoint returns an array of Comment objects which we will decode on the client-side.


struct Comment: Decodable {
    let postId: Int
    let id: Int
    let name: String
    let email: String
}


CombineNetworkService(baseURLString: baseURLString).getPublisherForArrayResponse(endpoint: "posts/1/comments", queryParameters: [:]).sink { _ in
        // no-op
} receiveValue: { (comments: [Comment]) in
    print("The name of first comment is - \(comments[0].name)")
}.store(in: &anyCancellables)

Output:


The name of first comment is - id labore ex et quam laborum

Extras

Before I conclude the post, I want to include the extra Combine material useful in certain use cases. It involves the cases when you want to hardcode fallback values or mock the Publisher objects in unit tests.

Please note the use of  eraseToAnyPublisher() API for type erasure of previously returned publisher output

Before we get started, here's how our custom error object looks like. In case an error happens, the publisher will return this error object back to the caller.


enum NetworkServiceError: Error {
    case invalidURL
    case decodingError(String)
    case genericError(String)
    case invalidResponseCode(Int)
}

  • Returning non-failable AnyPublisher object with an empty array as an output

func nonFailablePublisherWithEmptyArrayOutput() -> AnyPublisher<[String], Never> {
    return Just([])
        .eraseToAnyPublisher()
}

  • Returning non-failable AnyPublisher object with an object as an output

func nonFailablePublisherWithObjectOutput() -> AnyPublisher<String, Never> {
    return Just("Placeholder")
        .eraseToAnyPublisher()
}

  • Returning failable AnyPublisher object with failure and the custom error object

func failablePublisherWithFailureAndCustomErrorObject() -> AnyPublisher<String, NetworkServiceError> {
    return Fail(error: NetworkServiceError.invalidURL)
        .eraseToAnyPublisher()
}

  • Returning failable AnyPublisher with no error and an empty array as an output

func failablePublisherWithNoErrorAndEmptyArrayOutput() -> AnyPublisher<[String], NetworkServiceError> {
    return Just([])
        .setFailureType(to: NetworkServiceError.self)
        .eraseToAnyPublisher()
}

  • Returning failable AnyPublisher with no error and an object as an output

func failablePublisherWithNoErrorAndObjectOutput() -> AnyPublisher<String, NetworkServiceError> {
    return Just("Placeholder")
        .setFailureType(to: NetworkServiceError.self)
        .eraseToAnyPublisher()
}

Please note - In case of failable publisher object, even if we're only returning the object output with Just API, we still need to set its failure type using setFailureType API
  • Returning failable AnyPublisher object with empty output

func failablePublisherWithEmptyOutput() -> AnyPublisher<Void, Never> {
    return Just(())
        .eraseToAnyPublisher()
}

  • Returning non-failable AnyPublisher object with empty output

func nonFailablePublisherWithEmptyOutput() -> AnyPublisher<Void, NetworkServiceError> {
    return Just(())
        .setFailureType(to: NetworkServiceError.self)
        .eraseToAnyPublisher()
}

Summary

To summarize, we successfully built the network service in Swift using the Combine framework that handles both a single Decodable object and an array of Decodable objects. This is a base implementation, and of course, there is a scope for improvement and extending this base network service.

I hope this blog post clears your doubts about getting started with the Combine framework to build the network stack.

The full source code from this tutorial is available on Github in this repository. Feel free to make a pull request if you think it can be further improved. Looking forward to it.

As usual, if you have any comments, questions, or concerns about this post, feel free to reach out to me on Twitter @jayeshkawli.