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.
urlSession- An object of the typeURLSessionwhich will default toURLSession.default. We are using dependency injection to be able to mock and inject the mocked value during the unit testingbaseURLString- 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 objectWe 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"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,
- Errors on the client-side before dispatching the request
- Errors on the client-side after receiving the server response with the error response code
- 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,
- 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
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,
- The service is bound to the
Postmodel. In the future, if we need to download an object of a differentDecodabletype, sayComment, we will need anothergetPublisherForResponsefunction - Even if we address the first problem, we can only get a single
Decodableobject from this API. If we need to get an array ofDecodableobjects, we need to add that support in the form of another API.
We will address both these issues with changes in the following section
- We will make
getPublisherForResponsefunction generic where it will return any object withAnyPublisheras long as that object conforms to aDecodableprotocol
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.
- We will introduce another similar function which will return an array of
Decodableobjects, 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 laborumExtras
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
AnyPublisherobject with an empty array as an output
func nonFailablePublisherWithEmptyArrayOutput() -> AnyPublisher<[String], Never> {
return Just([])
.eraseToAnyPublisher()
}
- Returning non-failable
AnyPublisherobject with an object as an output
func nonFailablePublisherWithObjectOutput() -> AnyPublisher<String, Never> {
return Just("Placeholder")
.eraseToAnyPublisher()
}
- Returning failable
AnyPublisherobject with failure and the custom error object
func failablePublisherWithFailureAndCustomErrorObject() -> AnyPublisher<String, NetworkServiceError> {
return Fail(error: NetworkServiceError.invalidURL)
.eraseToAnyPublisher()
}
- Returning failable
AnyPublisherwith no error and an empty array as an output
func failablePublisherWithNoErrorAndEmptyArrayOutput() -> AnyPublisher<[String], NetworkServiceError> {
return Just([])
.setFailureType(to: NetworkServiceError.self)
.eraseToAnyPublisher()
}
- Returning failable
AnyPublisherwith 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 withJustAPI, we still need to set its failure type usingsetFailureTypeAPI
- Returning failable
AnyPublisherobject with empty output
func failablePublisherWithEmptyOutput() -> AnyPublisher<Void, Never> {
return Just(())
.eraseToAnyPublisher()
}
- Returning non-failable
AnyPublisherobject with empty output
func nonFailablePublisherWithEmptyOutput() -> AnyPublisher<Void, NetworkServiceError> {
return Just(())
.setFailureType(to: NetworkServiceError.self)
.eraseToAnyPublisher()
}
Adding Delay to the Publisher
Before you return the publisher, it's also possible to add a deliberate delay so that it returns the output value (Or error) after certain delay. The purpose of this API is to delay the execution for business reason or to simulate the network delay.
Delay can be directly added to the network service just before returning the publisher.
return urlSession.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: Post.self, decoder: JSONDecoder())
.delay(for: .seconds(2), scheduler: RunLoop.main)
.eraseToAnyPublisher()
There are two parameters we need to specify
- Amount of delay - It's a
Stridetype that allows us to specify delay in terms of number seconds or milliseconds - Scheduler - Specifies the scheduler on which to enforce the delay. Usually it's
RunLoop.mainsince any output from publisher are supposed to run on main thread, but you can still specify your custom scheduler
Summary
To summarise, 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.