Writing your network layer in Swift: protocol-oriented approach

Original author: Malcolm Kumwenda
  • Transfer
  • Tutorial


Now almost 100% of applications use networking, so everyone is faced with the organization and use of the network layer. There are two main approaches to solving this problem, it is either using third-party libraries, or your own implementation of the network layer. In this article we will consider the second option, and try to implement a network layer using all the latest features of the language, using protocols and enumerations. This will save the project from unnecessary dependencies in the form of additional libraries. Those who have ever seen Moya will immediately recognize a lot of similar details in implementation and use, the way it is, only this time we will do it ourselves without touching Moya and Alamofire.


In this guide, we will look at how to implement a network layer on pure Swift, without using any third-party libraries. Once you review this article, your code will become

  • protocol oriented
  • easy to use
  • easy to use
  • type safe
  • for endpoints enums will be used


Below is an example of how the use of our network layer will look after its implementation:



By simply writing router.request (. And using all the power of enumerations, we will see all the possible queries and their parameters. First, a little about the project structure Whenever you create something- something new, and in order to be able to figure everything out easily in the future, it’s very important to organize and structure everything correctly.I hold the belief that a properly organized folder structure is an important detail in building the architecture . zheniya To have all been correctly placed in folders, let's create them ahead of time it will look like a common folder structure in the project:. EndpointType Protocol









First of all, we need to define our EndPointType protocol. This protocol will contain all the necessary information to configure the request. What is a request (endpoint)? In essence, this is a URLRequest with all related components, such as headers, request parameters, request body. The EndPointType protocol is the most important part of our network layer implementation. Let's create a file and name it EndPointType . Put this file in the Service folder (not in the EndPoint folder, why - it will be clear a little later)



HTTP Protocols

Our EndPointType contains several protocols that we need to create a request. Let's see what these protocols are.

HTTPMethod

Create a file, name it HTTPMethod and put it in the Service folder. This listing will be used to set the HTTP method of our request.



HTTPTask
Create a file, name it HTTPTask and place it in the Service folder. HTTPTask is responsible for configuring the parameters of a specific request. You can add as many different query options to it as you need, but I, in turn, am going to make regular queries, queries with parameters, queries with parameters and headers, so I will only do these three types of queries.



In the next section, we will discuss Parameters and how we will work with them

HTTPHeaders

HTTPHeadersit's just typealias for a dictionary. You can create it at the top of your HTTPTask file.

public typealias HTTPHeaders = [String:String]


Parameters & Encoding

Create a file, name it ParameterEncoding and put it in the Encoding folder. Create typealias for Parameters , it will again be a regular dictionary. We do this to make the code look more understandable and readable.

public typealias Parameters = [String:Any]


Next, define a ParameterEncoder protocol with a single encode function. The encode method has two parameters: inout URLRequest and Parameters . INOUT is a Swift keyword that defines a function parameter as a reference. Typically, parameters are passed to the function as values. When you write inout before a function parameter in a call, you define this parameter as a reference type. To learn more about inout arguments, you can follow this link. In short, inout allows you to change the value of the variable itself, which was passed to the function, and not just get its value in the parameter and work with it inside the function. ParameterEncoder Protocolwill be implemented in JSONParameterEncoder and in URLPameterEncoder .

public protocol ParameterEncoder {
 static func encode(urlRequest: inout URLRequest, with parameters: Parameters) throws
}


ParameterEncoder contains a single function whose task is to encode parameters. This method may throw an error that needs to be handled, so we use throw.

It may also be useful to produce not standard errors, but customized ones. It is always quite difficult to decrypt what Xcode gives you. When you have all the errors customized and described, you always know exactly what happened. To do this, let's define an enumeration that inherits from Error .



Create a file, name it URLParameterEncoder and put it in the Encoding folder.



This code takes a list of parameters, converts and formats them for use as URL parameters. As you know, some characters are not allowed in the URL. Parameters are also separated by the "&" symbol, so we must take care of this. We must also set the default value for the headers if they are not set in the request.

This is the part of the code that is supposed to be covered by unit tests. Building a URL request is the key, otherwise we can provoke many unnecessary errors. If you use the open API, you obviously will not want to use the full possible volume of requests for failed tests. If you want to know more about Unit tests, you can start with this article.

JSONParameterEncoder

Create a file, name itJSONParameterEncoder and put in the Encoding folder.



Everything is the same as in the case of URLParameter , just here we will convert the parameters for JSON and again add the parameters defining the encoding "application / json" to the header.

NetworkRouter

Create a file, name it NetworkRouter and put it in the Service folder. Let's start by defining typealias for closure.

public typealias NetworkRouterCompletion = (_ data: Data?,_ response: URLResponse?,_ error: Error?)->()


Next, we define the NetworkRouter protocol .



In NetworkRouter have EndPoint , which he uses to request and as soon as a request is completed, the result of this request is transmitted to the circuit NetworkRouterCompletion . The protocol also has a cancel function , which can be used to interrupt long-term load and unload requests. We also used associatedtype here because we want our Router to support any type of EndPointType . Without the use of associatedtype, the router would have to have some specific type implementing EndPointType. If you want to know more about associatedtype, you can read this article .

Router

Create a file, name it Router and put it in the Service folder. We declare a private variable of type URLSessionTask . All work will be on it. We make it private because we don’t want anyone outside to be able to change it.



Request

Here we create URLSession using URLSession.shared , this is the easiest way to create. But remember that this method is not the only one. You can use more complex URLSession configurations that can change its behavior. More about this in this article .

The request is created by calling the buildRequest function . The function call is wrapped in do-try-catch, because the encoding functions inside buildRequest may throw exceptions. Response , data, and error are passed to completion .



Build Request

We create our request using the buildRequest function . This function is responsible for all the vital work in our network layer. Essentially converts EndPointType to URLRequest . And as soon as EndPoint turns into a request, we can pass it to session. A lot of things are happening here, so let's take a look at the methods. First, let 's examine the buildRequest method :

1. We initialize the URLRequest request variable . We set our base URL in it and add the path of the specific request that will be used to it.

2. Assign request.httpMethod the http method from our EndPoint .

3. We create a do-try-catch block, because our encoders may throw an error. By creating one large do-try-catch block, we eliminate the need to create a separate block for each try.

4. In switch, check route.task .

5. Depending on the type of task, we call the corresponding encoder.



Configure Parameters

Create the configureParameters function in the Router.



This function is responsible for converting our query parameters. Since our API assumes the use of bodyParameters in the form of JSON and URLParameters converted to the URL format, we simply pass the appropriate parameters to the corresponding conversion functions, which we described at the beginning of the article. If you use an API that includes various types of encodings, then in this case I would recommend adding HTTPTaskoptional enumeration with encoding type. This listing should contain all possible types of encodings. After that, in configureParameters add one more argument with this enumeration. Depending on its value, switch using switch and make the encoding you need.

Add Additional Headers

Create the addAdditionalHeaders function in the Router.



Just add all the necessary headers to the request.

Cancel

The cancel function will look quite simple:



Usage example

Now let's try to use our network layer with a real example. We will connect to TheMovieDB to receive data for our application.

MovieEndPoint

Create a MovieEndPoint file and place it in the EndPoint folder. MovieEndPoint is the same as
TargetType in Moya. Here we implement our own EndPointType instead. An article describing how to use Moya for a similar example can be found at this link .

import Foundation
enum NetworkEnvironment {
    case qa
    case production
    case staging
}
public enum MovieApi {
    case recommended(id:Int)
    case popular(page:Int)
    case newMovies(page:Int)
    case video(id:Int)
}
extension MovieApi: EndPointType {
    var environmentBaseURL : String {
        switch NetworkManager.environment {
        case .production: return "https://api.themoviedb.org/3/movie/"
        case .qa: return "https://qa.themoviedb.org/3/movie/"
        case .staging: return "https://staging.themoviedb.org/3/movie/"
        }
    }
    var baseURL: URL {
        guard let url = URL(string: environmentBaseURL) else { fatalError("baseURL could not be configured.")}
        return url
    }
    var path: String {
        switch self {
        case .recommended(let id):
            return "\(id)/recommendations"
        case .popular:
            return "popular"
        case .newMovies:
            return "now_playing"
        case .video(let id):
            return "\(id)/videos"
        }
    }
    var httpMethod: HTTPMethod {
        return .get
    }
    var task: HTTPTask {
        switch self {
        case .newMovies(let page):
            return .requestParameters(bodyParameters: nil,
                                      urlParameters: ["page":page,
                                                      "api_key":NetworkManager.MovieAPIKey])
        default:
            return .request
        }
    }
    var headers: HTTPHeaders? {
        return nil
    }
}


MovieModel

To parse the MovieModel and JSON data model into the model, the Decodable protocol is used. Place this file in the Model folder .

Note : for a more detailed acquaintance with the Codable, Decodable, and Encodable protocols, you can read my other article , which describes in detail all the features of working with them.

import Foundation
struct MovieApiResponse {
    let page: Int
    let numberOfResults: Int
    let numberOfPages: Int
    let movies: [Movie]
}
extension MovieApiResponse: Decodable {
    private enum MovieApiResponseCodingKeys: String, CodingKey {
        case page
        case numberOfResults = "total_results"
        case numberOfPages = "total_pages"
        case movies = "results"
    }
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: MovieApiResponseCodingKeys.self)
        page = try container.decode(Int.self, forKey: .page)
        numberOfResults = try container.decode(Int.self, forKey: .numberOfResults)
        numberOfPages = try container.decode(Int.self, forKey: .numberOfPages)
        movies = try container.decode([Movie].self, forKey: .movies)
    }
}
struct Movie {
    let id: Int
    let posterPath: String
    let backdrop: String
    let title: String
    let releaseDate: String
    let rating: Double
    let overview: String
}
extension Movie: Decodable {
    enum MovieCodingKeys: String, CodingKey {
        case id
        case posterPath = "poster_path"
        case backdrop = "backdrop_path"
        case title
        case releaseDate = "release_date"
        case rating = "vote_average"
        case overview
    }
    init(from decoder: Decoder) throws {
        let movieContainer = try decoder.container(keyedBy: MovieCodingKeys.self)
        id = try movieContainer.decode(Int.self, forKey: .id)
        posterPath = try movieContainer.decode(String.self, forKey: .posterPath)
        backdrop = try movieContainer.decode(String.self, forKey: .backdrop)
        title = try movieContainer.decode(String.self, forKey: .title)
        releaseDate = try movieContainer.decode(String.self, forKey: .releaseDate)
        rating = try movieContainer.decode(Double.self, forKey: .rating)
        overview = try movieContainer.decode(String.self, forKey: .overview)
    }
}


NetworkManager

Create a NetworkManager file in the Manager folder. At the moment, NetworkManager contains only two static properties: an API key and an enumeration that describes the type of server to connect to. NetworkManager also contains a Router that is of type MovieApi .



Network Response

Create a NetworkResponse enumeration in NetworkManager.



We use this enumeration when processing responses to requests and we will display the corresponding message.

Result

Create a listing of Result in NetworkManager.



We use Resultin order to determine whether the request was successful or not. If not, then we will return an error message with the reason.

Processing response to a request

Create the handleNetworkResponse function . This function takes one argument, such as an HTTPResponse, and returns Result.



In this function, depending on the received statusCode from HTTPResponse, we return an error message or a sign of a successful request. Typically, a code in the range of 200..299 means success.

Making a network request

So, we have done everything to start using our network layer, let's try to make a request.

We will request a list of new films. Create a function and name it getNewMovies .



Let's take it in steps:

1. We define the getNewMovies method with two arguments: the pagination page number and completion handler, which returns an optional array of Movie models , or an optional error.

2. Call Router . We pass the page number and process completion in the closure.

3. URLSession returns an error if there is no network or it was not possible to make a request for any reason. Please note that this is not an API error, such errors occur on the client and usually occur due to the poor quality of the Internet connection.

4. We need to cast our response to HTTPURLResponsebecause we need to access the statusCode property .

5. We declare result and initialize it using the handleNetworkResponse

6. method . Success means that the request was successful and we received the expected response. Then we check whether the data came with the answer, and if not, then we simply end the method via return.

7. If the answer comes with data, then it is necessary to parse the received data into the model. After that, we pass the resulting array of models to completion.

8. In case of an error, just pass the error to completion .

That's it, this is how our own network layer works on pure Swift, without using any dependencies in the form of third-party pods and libraries. In order to make a test api-request for obtaining a list of films, create a MainViewController with the NetworkManager property and call the getNewMovies method through it .

 class MainViewController: UIViewController {
    var networkManager: NetworkManager!
    init(networkManager: NetworkManager) {
        super.init(nibName: nil, bundle: nil)
        self.networkManager = networkManager
    }
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .green
        networkManager.getNewMovies(page: 1) { movies, error in
            if let error = error {
                print(error)
            }
            if let movies = movies {
                print(movies)
            }
        }
    }
}


A small bonus

Have you had situations in Xcode when you did not understand what kind of placeholder is used in a particular place? For example, look at the code that we have just written for Router . We determined the



NetworkRouterCompletion ourselves, but even in this case it is easy to forget what type it is and how to use it. But our beloved Xcode took care of everything, and it's enough to just double-click on the placeholder and Xcode will substitute the desired type.



Conclusion

Now we have an implementation of a protocol-oriented network layer, which is very simple to use and which can always be customized to your needs. We understood its functionality and how all mechanisms work.

You can find the source code in this repository .

Only registered users can participate in the survey. Please come in.

And how do you usually organize your network layer?

  • 52.1% Alamofire 36
  • 11.5% Moya 8
  • 1.4% Another library 1
  • 34.7% Own network layer without using third-party libraries 24

Also popular now: