iOS: Everything you have to consider when building a network layer
When you are working on a huge app, something you certainly deal with is an API. API`s are like a bridge that connects some web application to your front-end app, allowing you to fetch some remote data from that system, or update some data in a secure way, add new data or even delete data. The interface with that API depends on the contract its developer established to allow the interactions. When you have an iOS app, all the contexts(scenes/screens) interact with an API making requests through endpoints, which includes a path, headers, body and an HTTP method. But as good software engineers, we prioritize cutting replicated code at maximum and relying some specific tasks to a single part of the application, whose actions can be customized by injecting some kinds of dependencies and custom data. I am talking about a network layer, which is responsible for interacting with your back-end.
This is how it is supposed to work:
As you can see, what our network layer is supposed to do is receive an endpoint, that is some object englobing an URL path, some headers, body and an HTTP method(get, put, post, delete) and transform it into a URL Request object that will be consumed by the URLSession instance(Swift dependency for making API requests natively). Then, when the task is done, the backend returns a result to the network class three pieces of data:
- Data: an object from the type
Datathat can be parsed to a customized object(
- Response: object returned from the back-end that shows us some metadata like the status code(which you might use for some customized responses)
- Error: an error object from the type
Errorthat you can use to customize a back-end error to display in your screen.
With those pieces of data, you can customize your response to your screen. Remembering you that the Network layer maps all the possible types of error or results that the back-end can bring to your app(or module). Customizing alerts and mapping possible input errors are tasks for classes linked to specific contexts. Network is generic.
Building a Network layer: good practices
Now let's discuss how to build a reliable Network class that is responsible to make API calls using any endpoint, no matter the specific context. We will make it in a way that allows testing for any class that calls it and covering all kinds of result that it can face.
First things first, create a new project or playground in your Xcode, and create a new class called API.swift. Good, so now you are about to write the following lines:
What do we have in this class? As you can see, we defined a completion type that is nothing more than a closure taking a Result type, which is an enum containing a generic type T, which is a
Decodable associated to the Success case and a customized
Error type, associated with the failure case.
In the following lines we defined a new class we called API, which takes a generic type as a
Decodable . The following method
request only receives a completion to be called with the Result of our back-end call, which works as described above.
We also defined a custom enum for all the possible errors we receive in our method. We will detect all of them as we continue our project.
Injecting the endpoint
As we described, the API class is responsible for taking an endpoint and making a request to the network very generically. Knowing that, let's first inject our URL to the instance specifying what we are going to request:
Now our class recognizes some URL path for the API call . But that's far from enough since we need to parse that String to an URL object as needed by the URL session:
There is something wrong in our code and I give you 5 minutes to figure out. Did you find out? Well, if you noticed, when we are trying to parse our endpoint into an URL, the result for that is an optional, which means there is no guarantee we will have an object for that URL, we only have an optional. That's not the problem because we are dealing with optionals every time in Swift, but in fact we are trying to get a non-optional URL through a
guard let else statement and if we don't achieve our result we are just returning to the previous scope without sending any response to the user. If the screen is loading and an invalid URL is sent, the front-end doesn't get anything back and the screen will be loading forever..
With that in mind, I strongly recommend you handle every type of response in the network layer(and in other classes too). It's very important to map all the paths your flow can iterate and figure out what answer the app will have no matter what happens. So, we are going to enumerate our first error in there:
Now we are handling our first error. Let's proceed with caution with our class.
Making the API call and handling errors from the back
Previously, we were handling an error that could be triggered by the front-end itself. But as any application, the back-end could return to us some custom errors according to the HTTP protocol. You can see bellow how we catch the data from the back. One of the three objects is an error.
You may be asking, we have plenty of objects we can evaluate to define our result, how should we know what exactly are we getting? Well, I agree with you, but I think in the following way: It doesn't matter if we got data from the back-end, if our request was succeed, there can be no error. We have to check anything our API is complaining, so let's check which custom errors we see.
Now we created a new case inside our enum handling any error that was previously customized by the back-end. With this, we can check the error object, and if it's not nil we can send a generic error associated with the object that was sent to us.
Checking the data
Ok, we handled the errors sent by the back. But we have to make sure we are getting some data at the same way. Since our success flow is to receive some data as a response, we will check the data with a
guard let else statement and send a new error in case there is nothing in there:
Now we are checking if there is some data sent to us, and if there is no data, we send a new case of error describing that. Take a note that every time we execute a completion we follow it with a
return command since we want to end the flow.
Now we got some data in the main flow, but there is something you maybe don’t have in mind: the data we got is a
Data representation of an object sent by the API, better saying, it comes from a JSON that should be mapped in a Swift
Decodable , that is our generic type
T . Something all developers should think before building API calls is which contract to follow. Is our front-end model in agreement with the API model? Build a business model that can parse the data you are receiving.
To parse the data, we need a dependency called
JSONDecoder . Its job is to take a
Data object and try to parse it into a new Swift class/struct that maps all the fields from the JSON it was before. If the parse action doesn't succeed, an exception is thrown and we of course need to handle a new error. First, let's inject our decoder into our class:
Now let's try to decode the data:
See that we already have our success flow, decoding the data into a
Decodable model and executing a completion with data from a successful result. But we have no guarantee our parse will be successful, maybe our business model is not in total agreement with the back-end contract. So, it may throw an exception that we will handle as a new error case:
Since the parse error is thrown by the
JSONDecoder task, we will associate it with our new case.
And let's return our correct URLSession in the end of our scope and resume it in order to make our API call work:
Now we have all the possible errors handled in our network flow and the user will get all possible results that our task is getting. It's now a responsability to the other developers to know all possible errors the Network layer is sending and display it according to the context. But our application is far from the end. We still need to make our API mockable aiming on the Service layers testability(or any class that communicates with our Network as a dependency).
Creating a contract to our API class
I will not cover the dependency injection topic deeply in this article, but as good developers, we need to provide a contract for every concrete class we build. This way we can mock all dependencies we have and write scalable tests. Let's start:
With that, we can mock our entire class to simulate all the possible answers the we need to test in other classes that rely on it.
In this article we provided a great vision on how to build a generic layer to access data from the Web and which kinds of errors and results we should face. We also described how the Service layers can retrieve the response with the native
Result enum from the Swift language and how to customize those errors. Besides, we even showed how to prepare this layer to be mocked for a unit test. I hope you enjoyed ;)