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 typeURLSession
which 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
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"
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 AnyPublisher
object. 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
Post
model. In the future, if we need to download an object of a differentDecodable
type, sayComment
, we will need anothergetPublisherForResponse
function - 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 ofDecodable
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
- We will make
getPublisherForResponse
function generic where it will return any object withAnyPublisher
as long as that object conforms to aDecodable
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.
- 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 withJust
API, we still need to set its failure type usingsetFailureType
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()
}
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
Stride
type 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.main
since 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.