the Quest for a simple network manager

Many, if not most, of the apps I’ve worked on require some sort of network manager and all those that do will connect to one and the same api; often a proxy between the app and several other backend services; either being it in-house at a client or for one of my personal apps.

There are many different ways of introducing network support in your app; there are third party libraries that you can use which offer loads of different options (one of those that come to mind is Alamofire) or you can make one yourself. It is easier than you think.

The network manager

In many cases the most common network requests are GET and POST requests; on top of that, some apps fetch images asynchronous as well. In this article we are going to focus on the former two. These requests don’t require a full blown network library (which by the way will add to your apps binary size and also increase the startup time of your app). So what should you use instead? Well, the answer is simple, URL Loading System. This is what most, if not all, of those 3rd party libraries use underneath.

If you are used to using e.g. Alamofire, using URLSession and URLRequests might frighten you a little but there is no reason for fear. To make a simple GET requests, all you need is a few lines of code. For the example below I’ve setup a mockAPI at https://mockapi.io so you can tryout the code directly in a playground.

import Foundation

/// This is the object we're looking to get from the endpoint
struct User: Codable {
    let id: String
    let createdAt: Date
    let name: String
    let avatar: URL
}

/// This is our network class, it will handle all our requests
class NetworkManager {

    /// These are the errors this class might return
    enum ManagerErrors: Error {
        case invalidResponse
        case invalidStatusCode(Int)
    }

    /// The request method you like to use
    enum HttpMethod: String {
        case get
        case post

        var method: String { rawValue.uppercased() }
    }

    /// Request data from an endpoint
    /// - Parameters:
    ///   - url: the URL
    ///   - httpMethod: The HTTP Method to use, either get or post in this case
    ///   - completion: The completion closure, returning a Result of either the generic type or an error
    func request<T: Decodable>(fromURL url: URL, httpMethod: HttpMethod = .get, completion: @escaping (Result<T, Error>) -> Void) {

        // Because URLSession returns on the queue it creates for the request, we need to make sure we return on one and the same queue.
        // You can do this by either create a queue in your class (NetworkManager) which you return on, or return on the main queue.
        let completionOnMain: (Result<T, Error>) -> Void = { result in
            DispatchQueue.main.async {
                completion(result)
            }
        }

        // Create the request. On the request you can define if it is a GET or POST request, add body and more.
        var request = URLRequest(url: url)
        request.httpMethod = httpMethod.method

        let urlSession = URLSession.shared.dataTask(with: request) { data, response, error in
            // First check if we got an error, if so we are not interested in the response or data.
            // Remember, and 404, 500, 501 http error code does not result in an error in URLSession, it
            // will only return an error here in case of e.g. Network timeout.
            if let error = error {
                completionOnMain(.failure(error))
                return
            }

            // Lets check the status code, we are only interested in results between 200 and 300 in statuscode. If the statuscode is anything
            // else we want to return the error with the statuscode that was returned. In this case, we do not care about the data.
            guard let urlResponse = response as? HTTPURLResponse else { return completionOnMain(.failure(ManagerErrors.invalidResponse)) }
            if !(200..<300).contains(urlResponse.statusCode) {
                return completionOnMain(.failure(ManagerErrors.invalidStatusCode(urlResponse.statusCode)))
            }

            // Now that all our prerequisites are fullfilled, we can take our data and try to translate it to our generic type of T.
            guard let data = data else { return }
            do {
                let users = try JSONDecoder().decode(T.self, from: data)
                completionOnMain(.success(users))
            } catch {
                debugPrint("Could not translate the data to the requested type. Reason: \(error.localizedDescription)")
                completionOnMain(.failure(error))
            }
        }

        // Start the request
        urlSession.resume()
    }
}

// Create the URL to fetch
guard let url = URL(string: "https://60c86ffcafc88600179f70e2.mockapi.io/api/getRequest") else { fatalError("Invalid URL") }

// Create the network manager
let networkManager = NetworkManager()

// Request data from the backend
networkManager.request(fromURL: url) { (result: Result<[User], Error>) in
    switch result {
    case .success(let users):
        debugPrint("We got a successful result with \(users.count) users.")
    case .failure(let error):
        debugPrint("We got a failure trying to get the users. The error we got was: \(error.localizedDescription)")
    }
 }

Most of the code above might seem like a lot, but it really is not, it is the documentation I’ve added that makes it look like there is a lot. So what does it do?

The code above will try to make a GET request towards the url https://60c86ffcafc88600179f70e2.mockapi.io/api/getRequest. If it succeeds it will return a Result with type [User], and if it fails it will return a Result with type Error. Yes, I added Result with capital R here.. this is because I am referencing a type of object that is returned, one that is really handy to use in Swift.

So what if you want to make a POST request? Just add the method parameter to the request type.

What if I just want Data?

This is easy too, you don’t need to create yet another request method… it works with the one you’ve already added. There are only 3 lines of code you need to add just after the guard let data = data part and before the Do / Catch block; those lines are the following:

if let data = data as? T {
    completionOnMain(.success(data))
    return
}

and then you can call your request like so:

// Request data from the backend
networkManager.request(fromURL: url) { (result: Result<Data, Error>) in
    switch result {
    case .success(let data):
        debugPrint("We got a successful result with data of \(data.count) bytes.")
    case .failure(let error):
        debugPrint("We got a failure trying to get the data. The error we got was: \(error.localizedDescription)")
    }
 }
 

So what did we do? Well, out of the box, Data already conforms to Decodable; however this requires the content of Data to be in a JSON format. However, since Data already conforms to Codable we do not have to function overload the request method with a different type.. we can just use the one with the T: Decodable generic type instead. However, before we decode we need to check if the result data we get back conforms to the type we actually are looking for (In this case Data), and since we’re just looking for Data and data will conform to Data, we can just return that object directly.

This is how simple it is to make GET or POST requests, without pulling in a whole network library. Keep it simple, and use the power of Swift with Generics, Result and URL Loading System.