Swift networking using Combine

Swift networking using Combine

Introducing Combine

Introduced in 2019, Combine provides a declarative Swift API for processing values over time and it offers a huge benefit when it comes to asynchronous programming. Combine makes it easy to chain together operations such as transforming values, filtering out values, and even combining values from multiple publishers thanks to built-in operators such as map, filter, reduce, and combine.

Combine and SwiftUI can be used together to create beautiful, modern user interfaces. With Combine, you can easily create bindings between your state and your UI elements, making it easier to keep your data up-to-date with minimal effort. Combine also makes it easy to perform asynchronous tasks, such as fetching data from an API or downloading files, without blocking the main thread.

Publishers, Operators and Subscribers

There are three key concepts in Combine that we need to understand:

Filtering events emitted by a publisher]
  • Publishers
    A publisher emits elements to one or more Subscribers - a UITextField changing its contents as the user types.
  • Operators
    Operators are higher order functions that help us manipulate the elements emitted by a Publisher - validating the text the user input.
  • Subscribers
    Subscribers are instances that are interested in elements emitted by a Publisher - presenting an error if the input is invalid.

If you want to find out more about Combine I recommend cheking the docs.

URLSessionDataPublisher

The addition of Combine support to URLSession makes it easier for iOS developers to create, manage, and monitor network requests. Specifically, URLSessionDataPublisher allows developers to publish request data and manage data flow with Combine operators like map, filter, and reduce.

This allows developers to create asynchronous requests with a type-safe, declarative syntax. In addition, Combine support makes it easier to integrate network requests with other flows, such as user input or other events.

Creating a URLSessionDataPublisher is pretty straightforward:

let url = URL(string: "https://api.twitter.com/1.1/statuses/user_timeline?screen_name=cyupa89")!
let publisher = URLSession.shared.dataTaskPublisher(for: url)

As mentioned earlier, the sink method allows us to create a subscription to a publisher that will then receive values from it.

let subscription = publisher.sink(
    receiveCompletion: { completion in
        // Will be called once, when the publisher has completed.
        // The completion itself can either be successful, or not.
        switch completion {
          case .failure(let error):
            print(error)
          case .finished:
            print("Finished successfuly")
        }
    },
    receiveValue: { value in
        // Will be called each time a new value is received.
        // In our case these should be a set of tweets.
        print(value)
    }
)

Once the subscription is created, the publisher will start emitting values, and the closure we provided to the sink method will be called for each one.

When we’re done receiving values, we can cancel the subscription to stop the publisher from emitting any more values. This is done using the cancel method on the subscription:

subscription.cancel()

Mapping responses with Codable

Combine offers a variety of useful operators that can be used to transform a JSON response into a model representation.

For instance, operators such as map or tryMap and decode can help you streamline fetching your data with just a couple lines of code:

let tweetsPublisher = publisher.map(\.data)
                               .decode(type: Tweet.self, decoder: JSONDecoder())
                               .receive(on: DispatchQueue.main)

The map operator can be used to transform the data that is returned by a DataTaskPublisher. It takes a closure with two parameters - data, which is a Data instance, and a URLResponse instance called response. This closure can be used to modify the data before it is returned to the subscriber.

The tryMap operator works similarly, but it allows the closure to throw an error, which can be handled by the subscriber.

We return the data instance from that closure and pass it on to the decode operator that takes a type and a decoder to transform the fetched data into a model instance.

Last but not least, we want to return this instance on the main queue using the receive operator.

struct HTTPResponse {
    let value: Tweet?
    let response: URLResponse
}

let tweetsPublisher = publisher.retry(3)
                               .tryMap { result -> HTTPResponse in
                                  let tweet = try decoder.decode(Tweet.self, from: result.data)                                  
                                  return HTTPResponse(value: tweet, response: result.response)
                               }
                               .receive(on: DispatchQueue.main)
                               .eraseToAnyPublisher()

By encapsulating the response in a HTTPResponse we can also ensure that the response is structured in a consistent and predictable way. This makes it easier for developers to understand and work with the response, and makes it easier to debug any potential issues.

Thanks to the retry operator we can set a maximum number of attempts we want to retry a request, as well as a delay between attempts.

The last bit, .eraseToAnyPublisher() is used to hide the publisher type to the caller and expose it as an AnyPublisher type. This way, we could change our internal implementation without affecting any of our callers.

Make it generic

It wouldn't be a useful example for making network requests if we couldn't make it a generic one that works with any type that we want.

struct HTTPResponse<T> {
    let value: T
    let response: URLResponse
}

struct HTTPClient {
    let session: URLSession

    func perform<T: Decodable>(_ request: URLRequest, _ decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher<HTTPResponse<T>, Error> {
        return session.dataTaskPublisher(for: request)
            .retry(3)
            .tryMap { result -> HTTPResponse<T> in
                let tweet = try decoder.decode(T, from: result.data)                                  
                return HTTPResponse(value: tweet, response: result.response)
            }
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
}

This way, we can handle any type of data in a type-safe manner. If you are interested how you can structure your calls by separating them into endpoints and build requests in a structured manner, check this article.

Then, you could be making requests by writing something along the lines:

struct TwitterAPI {
  let client: HTTPClient
  
  func getTweetsFor(screenName: String) -> AnyPublisher<Tweet, Error> {
    let request = TimelineEndpoint.getFor(screenName: screenName).urlRequest
    return client.perform(request)
                  .map(\.value)
                  .eraseToAnyPublisher()
  }
}