What’s the first service you usually implement in an app?
It’s either networking or storage, right? And when it comes to networking, what are your choices? Most people either operate on URLSession directly, leveraging its multiple APIs, or add a 3rd party library like Alamofire.
But there is also a third option: implementing your own networking client.
When I was starting a new project a couple of years ago, I decided to give it a try. Since then, I have managed to reuse the core concepts of that network client in many applications, trying to perfect their design in each iteration.
Recently, with the great help from my friend Kemal, the Networking Client has been made open-source: https://github.com/netguru/ng-ios-network-module, and a recommended option to implement the networking in new iOS apps developed at Netguru.
You might ask: why should I even bother? Alamofire would definitely allow me to execute every kind of url request and parse every possible response without an issue.
And you’ll be correct. Alamofire is a great library. However, it’s also worth asking why nearly all of the top projects rely on custom, home-made networking components. When you think of it, the answer is simple. The team has to be in control of all the project dependencies, especially when these dependencies are many and complex.
Have you ever considered writing your own networking client? Or if you already use one, making it open-source? If so, I hope this 2-part guide will be of some help.
In this post, we’ll take a look at how to create a networking module, define its basic components and their purposes. That should be enough to get you started.
In the second part, we’ll explore e.g. extending the module functionalities, exposing additional APIs, and making it open-source.
Without further ado, let’s roll!
Where to start?
Unless you have a strong reason not to, it’s highly recommended to use Swift Package Manager to package your networking client. SPM is built into Xcode and can be used alongside other dependency providers like Cocoapods.
The first order of business will be to create a package and name basic targets:
…
let package = Package(
…
products: [
.library(
name: "NgNetworkModule",
targets: ["NgNetworkModule"]
)
],
dependencies: [],
targets: [
.target(
name: "NgNetworkModule",
dependencies: []
),
.testTarget(
name: "NgNetworkModuleTests",
dependencies: ["NgNetworkModule"]
)
]
)
What’s worth noticing, there are no external dependencies. And that’s a good thing. Whenever possible, we should avoid 3rd party dependencies. Foundation framework will provide us all the tools we’d ever need
Before we get to coding however, let’s write down the components a typical networking client should consist of:
- A request builder – a component responsible for composing a URLRequest from an abstraction describing it (containing e.g. request method, url, etc.)
- A network session – to execute requests on
- A network response parser – to determine if a request succeeded and decode received data
- A network error – to describe different HTTP errors in more readable and structured way
- A single point of entry into the networking client, providing user-facing APIs
In addition, we should also think of a way to allow users to extend the networking client functionality. We will come back to this topic in the 2nd part of this post, so stay tuned!
Composing URL Requests
Let’s start by defining an abstraction describing a network request:
protocol NetworkRequest {
…
var path: String { get }
var method: NetworkRequestType { get }
var additionalHeaderFields: [String: String] { get }
var parameters: [String: String]? { get }
var body: Encodable? { get }
…
}
It looks great but didn’t you forget something? How will this protocol be consumed? Will it only be accessed internally or outside of the module as well
Yes…
public protocol NetworkRequest { … }
Much better now. Don’t beat yourself up if you forget about the public keyword before the APIs you wish to expose. Aside from internal and private, we rarely use other access modifiers ;).
It’s also worth making lives of the users easier by preparing default implementations for all the optional request fields:
public extension NetworkRequest {
var parameters: [String: String]? {
nil
}
var body: Encodable? {
nil
}
…
}
Ok, we have an abstraction describing a request – let’s define an object to turn that abstraction into a “usable” request:
public protocol RequestBuilder: AnyObject {
func build(request: NetworkRequest) -> URLRequest?
}
And some implementation details (full listing):
public final class DefaultRequestBuilder: RequestBuilder {
private let baseURL: URL
public init(baseURL: URL) {
self.baseURL = baseURL
}
public func build(request: NetworkRequest) -> URLRequest? {
guard let url = composeURL(request: request) else {
return nil
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method.value
urlRequest.httpBody = request.encodedBody
…
append(additionalHeaderFields: request.additionalHeaderFields, toRequest: &urlRequest)
return urlRequest
}
}
As you can see, we created a neat object that turns a description of the network request into a URLRequest we can send via URLSession. A picture-perfect example of the Single Responsibility Principle in action. And as we all know, having “responsibles” is very important part of life:
Describing errors
Naturally, things can go wrong. E.g. a base URL path might be nil, request parameters invalid, etc. In such a case, a URLRequest will simply fail to build, and the user should be notified immediately. To do that, let’s introduce another part of the module: a network error description:
public enum NetworkError: Error, Equatable {
/// Network errors associated with sending a request:
case notFound
case forbidden
case unauthorized
case invalidRequest(code: Int, message: String?)
case serverError(code: Int, message: String?)
case custom(code: Int, message: String?)
case cancelled
/// Network errors associated with parting a response:
case requestParsingFailed
case responseParsingFailed
case noResponseData
/// Other errors:
case unknown
}
But wait… Standard errors returned by the URLSession do not look like this… How can I translate a generic Error to the enum cases you described? The answer is simple. Let’s make an initializer accepting: the error code and localized message:
public enum NetworkError: Error, Equatable {
public init?(code: Int, message: String?) {
switch code {
case 200...399:
return nil
case 404:
self = .notFound
case 403:
self = .forbidden
case 401:
self = .unauthorized
case 400...499:
self = .invalidRequest(code: code, message: message)
case 500...599:
self = .serverError(code: code, message: message)
…
default:
self = .custom(code: code, message: message)
}
}
}
Additionally, you may consider creating convenience initializers utilizing other basic networking types: NSError, HTTPURLResponse, etc.:
public init(error: NSError) {
self = NetworkError(code: error.code, message: error.localizedDescription) ?? .unknown
}
As a result, we now have a way to describe almost all the issues that might arise while using our networking client:
- Before sending a request – e.g. requestParsingFailed
- After receiving an answer – e.g. notFound, unauthorised or forbidden errors.
- When we tried to parse the data received from the backend – e.g. responseParsingFailed or noResponseData errors.
- And the unknown error to cover other issues
Putting it all together
Cool! We have means to build a request and describe errors. Now, let’s define the heart of the networking client:
public protocol NetworkModule: AnyObject {
@discardableResult func perform(
request: NetworkRequest,
completion: ((Result<NetworkResponse, NetworkError>) -> Void)?
) -> URLSessionTask?
@discardableResult func perform(
urlRequest: URLRequest,
completion: ((Result<NetworkResponse, NetworkError>) -> Void)?
) -> URLSessionTask
}
According to a well-known rule, a good API should be tolerant of input and precise about output. TLDR: we should accept a variety of input data, but be very strict on what we return to the user. In this case, we accept either a URLRequest, or NetworkRequest, returning a well-proven Result object in a callback. It will contain either a network response (wrapped in an abstraction for improved readability), or a NetworkError.
Naturally, if you provide the expected response data type, the NetworkModule can automatically decode it for you:
public extension NetworkModule {
@discardableResult func performAndDecode<T: Decodable>(
request: NetworkRequest,
responseType: T.Type,
decoder: JSONDecoder = JSONDecoder(),
completion: ((Result<T, NetworkError>) -> Void)?
) -> URLSessionTask? {
perform(request: request) { result in
switch result {
case let .success(response):
completion?(response.decoded(into: responseType, decoder: decoder))
case let .failure(error):
completion?(.failure(error))
}
}
}
@discardableResult func performAndDecode<T: Decodable>(
urlRequest: URLRequest,
responseType: T.Type,
decoder: JSONDecoder = JSONDecoder(),
completion: ((Result<T, NetworkError>) -> Void)?
) -> URLSessionTask {
perform(urlRequest: urlRequest) { result in
switch result {
case let .success(response):
completion?(response.decoded(into: responseType, decoder: decoder))
case let .failure(error):
completion?(.failure(error))
}
}
}
}
As you can see, we’re just reusing the methods we already defined in the NetworkModule protocol. On top of that, we stay true to being tolerant of the API input, allowing users to choose their preferred request type and provide their own decoder for the response.
Finally let’s take a look at the NetworkModule implementation:
public init(
requestBuilder: RequestBuilder,
urlSession: NetworkSession = URLSession.shared,
actions: [NetworkModuleAction] = [],
completionExecutor: AsynchronousOperationsExecutor = MainQueueOperationsExecutor()
) {
self.requestBuilder = requestBuilder
self.urlSession = urlSession
self.actions = actions
self.completionExecutor = completionExecutor
}
As you can see, the only component we have to provide is the Request Builder. However, if we want to execute requests on a specific URLSession, or receive a response on a background queue, you can do so. All it takes is passing a proper object in the Network Module constructor. We relied heavily on these features to execute the Unit Tests suite without making actual network calls ;).
The Network Module Actions and their importance will be described in detail in part 2 of this post.
Ok, let’s see how a request is executed. First, we need to build an URLRequest from a provided description. If we can’t, there’s nothing to execute
@discardableResult public func perform(request: NetworkRequest, completion: … ) -> URLSessionTask? {
guard let urlRequest = requestBuilder.build(request: request) else {
execute(completionCallback: completion, result: .failure(.requestParsingFailed))
return nil
}
return perform(urlRequest: urlRequest, withOptionalContext: request, completion: completion)
}
When this is done, we can start a URLSession data task and wait for a response. Once it arrives, we try to figure out its status. Ultimately, unless the request has been canceled, we should notify the caller of the request result:
@discardableResult func perform(urlRequest: URLRequest, … ) -> URLSessionTask {
var urlRequest = urlRequest
…
let task = urlSession.dataTask(with: urlRequest) { [weak self] data, response, error in
if let error = error as NSError? {
self?.handle(error: error, completion: completion)
} else if let response = response as? HTTPURLResponse {
self?.handle(response: response, data: data, request: networkRequest, completion: completion)
} else {
self?.execute(completionCallback: completion, result: .failure(NetworkError.unknown))
}
}
task.resume()
return task
}
and:
func handle(response: HTTPURLResponse, data: Data?, ...) {
if let networkError = response.toNetworkError() {
execute(completionCallback: completion, result: .failure(networkError))
return
}
let networkResponse = NetworkResponse(data: data, networkResponse: response)
execute(completionCallback: completion, result: .success(networkResponse))
}
func handle(error: NSError, …) {
let networkError = NetworkError(error: error)
guard networkError != .cancelled else {
return
}
execute(completionCallback: completion, result: .failure(networkError))
}
And that’s really it? Yes! Networking on iOS can get complicated only if you allow it!
The story so far
To wrap up, let’s take a look at a networking client creation to-do list:
- Setting up SPM module and defining its targets
- Implementing URLRequest composition
- Describing and handling various network errors
- Creating a single point of entry for the user – the NetworkModule.
As you were able to see, all the basic components are responsible for specific tasks (e.g. composing a URL Request), clear, readable and fully testable. The networking client offers a variety of ways to execute network requests and process the response. Finally, there’s almost no code duplication – all the functionalities added in extensions rely on the primary Network Module APIs.
Surely, that can’t be all, can it? You must have many questions:
- Why did we use this awful, callback-based API?
- How can users extend the NetworkModule and modify its default behavior?
- Are there any formal requirements a library has to meet before being open-sourced?
Unfortunately, this post is far too long already without even starting to address these topics
If you’d like to know, hop right into part 2!
Don’t miss any new blogposts!
By subscribing, you agree with our privacy policy and our terms and conditions.
Disclaimer: All memes used in the post were made for fun and educational purposes only
They were not meant to offend anyone
All copyrights belong to their respective owners
The thumbnail was generated with text2image
The post How to implement universal networking module in Swift (pt.1) first appeared on Swift and Memes.