top of page
Search

Re-thinking Network Encapsulation in Swift

Updated: Apr 15, 2022

Networking is an essential part of mobile applications. It's often integrated early on in the development lifecycle and becomes a large dependency throughout your codebase. Back in the day, my go-to was incorporate AFNetworking, a durable third-party networking library built on top of NSURLSession, written in Objective-C.


When we started to migrate over to Swift we were offered the opportunity to re-write portions of our codebase. The networking layer was our top candidate as we are heavily API-centric, constantly putting, posting, getting, deleting data, in various formats including JSON and XML. At this point we had the option to utilize a third-party library, Alamofire, the Swift counterpart to AFNetworking or building this from scratch. We chose the latter.


Our first thought was how we wanted to architect our networking infrastructure. I believe that was the greatest issue. We had many different internal APIs, some that we interacted with directly, others through API proxies, each with their own authentication requirements. Regardless of these requirements, they could have been compartmentalized much better. We had APIs for user profiles, fetching user's favorites, retrieving data on a screen-to-screen basis and we realized that we could encapsulate these networks by their purpose, sort of like containers of network requests.


With that, we decided to architect our networking layer like that. A network manager compartmentalized by specific purposes, so for example, a network manager dedicated just for managing a record store's inventory. We came up with a few requirements:


  • Containers must be stateless

  • Ability to group requests by domain

  • Ease of extending functionality based on associated types

  • Blueprint-like layout, clearly organized, declarative syntax

  • Clean success and error handling

  • Type-safe return types


From there we began coding. We wanted this service to be responsible for fetching and creating a record. So we drew up a prototype in which a call site could ideally use the service like so:



let request = FetchRecordEndpoint.createRecord(id: "wz53ca7ba311ea")
let manager = NetworkManager<FetchRecordEndpoint>()
manager.request(request) { result in
    switch result {
    case .success(let record):
        break
    case .failure(let error):
        break
    }
}

Basically, a generic network manager implementation accepts a network request of which acts an instruction set, respecting the guidelines of a certain blueprint based upon a mutual contract between the application and the backend API.


The blueprint that lays out our record inventory create and fetch endpoints needs to be clearly defined and housed under the same service.



struct Record: NetworkEncodable {}
​
typealias FetchRecordEndpoint = RecordInventoryService<Record>
​
enum RecordInventoryService<Value>: NetworkRequest {
    typealias ValueType = Value
    typealias ErrorType = NetworkError
​
    // Available Endpoints for this service
    case createRecord(record: Record) // Creates a new record
    case fetchRecords(id: String)     // Fetches a record
    
    // The base URL
    var baseURL: String { 
        return "records"
    }

    // The specific path
    var pathURL: String {
        switch self {
        case .createRecord(_):
            return "create"
        case .fetchMessages(let id):
            return "fetch"
        }
    }

    // The request type.
    var requestType: RequestType {
        switch self {
        case .createRecord(_):
            return .POST
        case .fetchMessages(_):
            return .GET
        }
    }

    var body: NetworkEncodable? {
        switch self {
        case .createRecord(let record):
            return record
        case .fetchMessages(_):
            return nil
        }
    }
}

As for handling the network, the Network Request protocol represents a group of endpoints where the Network Manager is responsible for processing the requests.


The following illustrates a method for consuming those previously mentioned blueprints containing instructions for each endpoint, processes and sends the request, handles any errors and parses the response. Network Encodable is a type that is intended to normalize the data encoding so we can streamline the processing of the request bodies.



protocol NetworkEncodable {
    func encode() throws -> Data?
}
​
extension NetworkEncodable where Self:Encodable {
    func encode() throws -> Data? {
        return try JSONEncoder().encode(self)
    }
}
​
extension String: NetworkEncodable {
    func encode() throws -> Data? {
        return data(using: .utf8)
    }
}
​
extension Dictionary: NetworkEncodable {
    func encode() throws -> Data? {
        return try JSONSerialization.data(withJSONObject: self, options: .prettyPrinted)
    }
}
​
enum RequestType: String {
    case GET = "GET", POST = "POST", PUT = "PUT", DELETE = "DELETE"
}
​
enum NetworkError: Error {
    case decoding
    case encoding
    case connection
    case invalidUrl
    case invalidResponse
}
​
protocol NetworkRequest {
    associatedtype ValueType
    associatedtype ErrorType: Error
    typealias ResultType = Result<ValueType, ErrorType>
    
    var baseURL: String { get }
    var pathURL: String { get }
    var body: NetworkEncodable? { get }
    var requestType: RequestType { get }
    
    func handleResponse(_ data: Data) -> ResultType
    func handleError(_ error: Error?) -> ResultType
}
​
protocol NetworkManagerProtocol {
    associatedtype RequestType: NetworkRequest
    
    @discardableResult
    func request(_ request: RequestType, in session: URLSession, completion: @escaping (RequestType.ResultType) -> ()) -> URLSessionDataTask?
}
​
class NetworkManager<RequestType: NetworkRequest>: NetworkManagerProtocol {
    
    private var tasks = [URLSessionDataTask]()
​
    @discardableResult
    func request(_ request: RequestType, in session: URLSession = URLSession.shared, completion: @escaping (RequestType.ResultType) -> ()) -> URLSessionDataTask? {
        var task: URLSessionDataTask?
        do {
            let builtRequest = try self.buildRequest(from: request)
            let sessionTask = session.dataTask(with: builtRequest) { (data, response, error) in
                if let data = data {
                    completion(request.handleResponse(data))
                } else {
                    completion(request.handleError(error))
                }
            }
            tasks.append(sessionTask)
            task = sessionTask
            task?.resume()
        } catch let error {
            completion(request.handleError(error))
        }
        return task
    }
    
    private func buildRequest(from route: RequestType) throws -> URLRequest {
        var urlComponents = URLComponents()
        urlComponents.scheme = "https"
        urlComponents.host = route.baseURL
        urlComponents.path = route.pathURL
        
        guard let url = urlComponents.url else {
            throw NetworkError.invalidUrl
        }
        var request = URLRequest(url: url)
        request.httpMethod = route.requestType.rawValue
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
​
        // Add post body
        if let body = route.body {
            do {
                let httpBody = try body.encode()
                request.httpBody = httpBody
            } catch {
                throw NetworkError.encoding
            }
        }
        return request
    }
}
    

This is just one of many ways to achieve a native network manager implementation. It has scaled out nicely in various applications and has worked seamlessly within our unit tests.


14 views0 comments

Recent Posts

See All

Comments


bottom of page