It's time to break up with your networking library for URLSession

Dear {INSERT NETWORKING LIBRARY NAME HERE},

There is no simple way to say this, but our time together is over. You have caused me too many headaches. From countless memory leaks to upgrade issues, I simply can't do it anymore.

There was a time when your troubles were worth it. Back in the day when you handled all of the communication with NSURLConnection, things were great. It was easy to justify the effort involved in your upkeep. You had to coordinate with NSURLConnection's many delegate methods to simply receive a response and response body. Working with NSURLConnection was not fun. I was happy to give those responsibilities to you.

You filled a clear void. You and many of your cousins were among the top charts of popular iOS 3rd party frameworks. All of you came with a laundry-list of functionality out of the box. However, none of you are free.

Integrating you into my app has negative side-effects like increasing either my app's startup time or size. Furthermore, delegating my entire networking layer to you has opened me up to all of your vulnerabilities and prevented me from customizing components of my networking layer.

Over time, Apple has improved the APIs for making HTTP and HTTPS requests. We no longer have to deal with NSURLConnection. I now have NSURLSession at my disposal, which is much easier to use. On top of that, I don't even have to worry about encoding or decoding Data anymore with the introduction of Codable in Swift.

At this point, you are dragging me down and holding me back more than anything. I took a little test drive with building my own networking layer and decided that it is time to make the switch and remove you from my project.

I hope you understand. I included some examples from my networking layer below to help you understand where I am coming from.

All the best,

Tim


Here is a basic Swift API client:

enum APIError: Error {
    case invalidURL
    case requestFailed
}

struct APIClient {

    typealias APIClientCompletion = (HTTPURLResponse?, Data?, APIError?) -> Void

    private let session = URLSession.shared
    private let baseURL = URL(string: "https://jsonplaceholder.typicode.com")

    func request(method: String, path: String, _ completion: @escaping APIClientCompletion) {
        guard let url = baseURL?.appendingPathComponent(path) else {
            completion(nil, nil, .invalidURL); return
        }

        var request = URLRequest(url: url)
        request.httpMethod = method

        let task = session.dataTask(with: url) { (data, response, error) in
            guard let httpResponse = response as? HTTPURLResponse else {
                completion(nil, nil, .requestFailed); return
            }
            completion(httpResponse, data, nil)
        }
        task.resume()
    }
}

In less than 30 lines of code, we were able to get a decent API client off the ground and working, error handling included. Here is an example of this client in action:

APIClient().request(method: "get", path: "todos/1") { (_, data, _) in
    if let data = data, let result = String(data: data, encoding: .utf8) {
        print(result)
    }
}

For simple use cases, the example above may be all that is needed. However, many applications include an abundance of network requests of varying complexities.

For example, using the example above, you wouldn't be able to make a request with query parameters (e.g. /todos/?hello=world) or with a request body. Furthermore, given that Swift thrives on its strongly-typed nature, using a String type for the method parameter is error-prone and repetitive.

Let's take a look at Swift API client that has better support for making requests:

enum HTTPMethod: String {
    case get = "GET"
    case put = "PUT"
    case post = "POST"
    case delete = "DELETE"
    case head = "HEAD"
    case options = "OPTIONS"
    case trace = "TRACE"
    case connect = "CONNECT"
}

struct HTTPHeader {
    let field: String
    let value: String
}

class APIRequest {
    let method: HTTPMethod
    let path: String
    var queryItems: [URLQueryItem]?
    var headers: [HTTPHeader]?
    var body: Data?

    init(method: HTTPMethod, path: String) {
        self.method = method
        self.path = path
    }
}

enum APIError: Error {
    case invalidURL
    case requestFailed
}

struct APIClient {

    typealias APIClientCompletion = (HTTPURLResponse?, Data?, APIError?) -> Void

    private let session = URLSession.shared
    private let baseURL = URL(string: "https://jsonplaceholder.typicode.com")!

    func request(_ request: APIRequest, _ completion: @escaping APIClientCompletion) {

        var urlComponents = URLComponents()
        urlComponents.scheme = baseURL.scheme
        urlComponents.host = baseURL.host
        urlComponents.path = baseURL.path
        urlComponents.queryItems = request.queryItems

        guard let url = urlComponents.url?.appendingPathComponent(request.path) else {
            completion(nil, nil, .invalidURL); return
        }

        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = request.method.rawValue
        urlRequest.httpBody = request.body

        request.headers?.forEach { urlRequest.addValue($0.value, forHTTPHeaderField: $0.field) }

        let task = session.dataTask(with: url) { (data, response, error) in
            guard let httpResponse = response as? HTTPURLResponse else {
                completion(nil, nil, .requestFailed); return
            }
            completion(httpResponse, data, nil)
        }
        task.resume()
    }
}

There are a few changes here. The biggest change is that the APIClient now accepts an APIRequest object. All of the data provided in APIRequest is then transformed into a URLRequest. Here is an example request:

let request = APIRequest(method: .post, path: "posts")
request.queryItems = [URLQueryItem(name: "hello", value: "world")]
request.headers = [HTTPHeader(field: "Content-Type", value: "application/json")]
request.body = Data() // example post body

APIClient().request(request) { (_, data, _) in
    if let data = data, let result = String(data: data, encoding: .utf8) {
        print(result)
    }
}

We are now setup to making more powerful API requests. However, there is still some room for bigger improvements. Swift 4 introduced JSONEncoder and JSONDecoder which make it extremely easy to encode and decode JSON data from and to Swift object types.

Additionally, consumers of APIClient's response shouldn't need to interact directly with the underlying types that APIClient is currently returning (i.e. HTTPURLResponse and Data).

Let's take a look at an example that improves upon APIClient even further:

enum HTTPMethod: String {
    case get = "GET"
    case put = "PUT"
    case post = "POST"
    case delete = "DELETE"
    case head = "HEAD"
    case options = "OPTIONS"
    case trace = "TRACE"
    case connect = "CONNECT"
}

struct HTTPHeader {
    let field: String
    let value: String
}

class APIRequest {
    let method: HTTPMethod
    let path: String
    var queryItems: [URLQueryItem]?
    var headers: [HTTPHeader]?
    var body: Data?

    init(method: HTTPMethod, path: String) {
        self.method = method
        self.path = path
    }

    init<Body: Encodable>(method: HTTPMethod, path: String, body: Body) throws {
        self.method = method
        self.path = path
        self.body = try JSONEncoder().encode(body)
    }
}

struct APIResponse<Body> {
    let statusCode: Int
    let body: Body
}

extension APIResponse where Body == Data? {
    func decode<BodyType: Decodable>(to type: BodyType.Type) throws -> APIResponse<BodyType> {
        guard let data = body else {
            throw APIError.decodingFailure
        }
        let decodedJSON = try JSONDecoder().decode(BodyType.self, from: data)
        return APIResponse<BodyType>(statusCode: self.statusCode,
                                     body: decodedJSON)
    }
}

enum APIError: Error {
    case invalidURL
    case requestFailed
    case decodingFailure
}

enum APIResult<Body> {
    case success(APIResponse<Body>)
    case failure(APIError)
}

struct APIClient {

    typealias APIClientCompletion = (APIResult<Data?>) -> Void

    private let session = URLSession.shared
    private let baseURL = URL(string: "https://jsonplaceholder.typicode.com")!

    func perform(_ request: APIRequest, _ completion: @escaping APIClientCompletion) {

        var urlComponents = URLComponents()
        urlComponents.scheme = baseURL.scheme
        urlComponents.host = baseURL.host
        urlComponents.path = baseURL.path
        urlComponents.queryItems = request.queryItems

        guard let url = urlComponents.url?.appendingPathComponent(request.path) else {
            completion(.failure(.invalidURL)); return
        }

        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = request.method.rawValue
        urlRequest.httpBody = request.body

        request.headers?.forEach { urlRequest.addValue($0.value, forHTTPHeaderField: $0.field) }

        let task = session.dataTask(with: url) { (data, response, error) in
            guard let httpResponse = response as? HTTPURLResponse else {
                completion(.failure(.requestFailed)); return
            }
            completion(.success(APIResponse<Data?>(statusCode: httpResponse.statusCode, body: data)))
        }
        task.resume()
    }
}

It is now extremely easy to include JSON data in requests in addition to decoding JSON data into strongly-typed Swift objects. Additionally, there is a clear contract now between the APIClient and its consumers as to when a request is successful as well as providing informative errors.

Let's take a look at an example of using the latest iteration of APIClient:

struct Post: Decodable {
    let userId: Int
    let id: Int
    let title: String
    let body: String
}

let request = APIRequest(method: .get, path: "posts")

APIClient().perform(request) { (result) in
    switch result {
    case .success(let response):
        if let response = try? response.decode(to: [Post].self) {
            let posts = response.body
            print("Received posts: \(posts.first?.title ?? "")")
        } else {
            print("Failed to decode response")
        }
    case .failure:
        print("Error perform network request")
    }
}

This example demonstrates how easy it is to perform an API request and make use of its result. This last example may seem similar to common interfaces of 3rd party libraries. There isn't anything inherently wrong with this. The under-the-hood benefits are truly the biggest win here.

APIClient has grown over the course of these examples, but all send and done, it is still less than 100 hundred lines. Furthermore, this fully-featured system is not reliant on any 3rd party code. I'll dive into this topic more in the future, but this is extremely valuable because it gives you more control over your code. As an app changes and requirements change, which is guaranteed to happen, having a flexible APIClient will pay off.


If you're interested in diving deeper into the examples above, I published an Xcode playground on Github here.

Show Comments