This is the 29th day of my participation in the More Text Challenge. For more details, see more Text Challenge

preface

Most of our apps rely on network calls for data, thanks to URLSession and Codable, which make it easy to call REST APIs; But we still need to write a lot of code to handle asynchronous callbacks, JSON parsing, HTTP error handling, and so on;

Of course, we can use a powerful network library like Alamofire, because it is powerful, so it has to be multi-functional, multi-purpose, so that people can use it whenever they want, but also includes a lot of features that we will never use.

With this in mind, I’m going to write a simple network library dedicated to REST API data requests.

The Request

We define a network request protocol that includes the common properties we need;

  • Path or URL
  • The HTTP Method (GET, POST, PUT, DELETE)
  • The request Body
  • headers

import Foundation
import Combine

public enum HTTPMethod: String {
    case get     = "GET"
    case post    = "POST"
    case put     = "PUT"
    case delete  = "DELETE"
}

public protocol Request {
    var path: String { get }
    var method: HTTPMethod { get }
    var contentType: String { get }
    var body: [String: Any]? { get }
    var headers: [String: String]? { get }
    associatedtype ReturnType: Codable
}
Copy the code

In addition to defining the attribute, we added an association type: associatedType, which is used as a placeholder when we implement the protocol

With the protocol defined, we set some defaults in the extension. By default, requests use the GET method, content-type: Application/JSON type, and its body, headers, and Query parameters are null.

extension Request {
    // Defaults
    var method: String { return .get }
    var contentType: String { return"Application/Json "}var queryParams: [String: String]? { return nil }
    var body: [String: Any]? { return nil }
    var headers: [String: String]? { return nil}}Copy the code

Since we use URLSession to perform all network calls, we need to write a practical method that converts custom request types to normal URL request objects

Two methods: requestBodyFrom serializes the dictionary object and asURLRequest converts it into a URLRequest object

extension Request {
    /// Serializes an HTTP dictionary to a JSON Data Object
    /// - Parameter params: HTTP Parameters dictionary
    /// - Returns: Encoded JSON
    private func requestBodyFrom(params: [String: Any]?) -> Data? {
        guard let params = params else { return nil }
        guard let httpBody = try? JSONSerialization.data(withJSONObject: params, options: []) else {
            return nil
        }
        return httpBody
    }
    /// Transforms a Request into a standard URL request
    /// - Parameter baseURL: API Base URL to be used
    /// - Returns: A ready to use URLRequest
    func asURLRequest(baseURL: String) -> URLRequest? {
        guard var urlComponents = URLComponents(string: baseURL) else { return nil }
        urlComponents.path = "\(urlComponents.path)\(path)"
        guard let finalURL = urlComponents.url else { return nil }
        var request = URLRequest(url: finalURL)
        request.httpMethod = method.rawValue
        request.httpBody = requestBodyFrom(params: body)
        request.allHTTPHeaderFields = headers
        return request
    }
}
Copy the code

With this protocol, it is very easy to define a network request object

// Model
struct Todo: Codable {
   var title: String
   var completed: Bool
}
// Request
struct FindTodos: Request {
     typealias ReturnType = [Todo]
     var path: String = "/todos"
}
Copy the code

/todo will be converted to a GET request, returning a list of Todo items

The Dispatcher

The request is ready to go, but we still need to call the network function to get the data and parse it at the same time, here we will use Combine and Codable. The first step is to define an enumeration to hold the error code.

enum NetworkRequestError: LocalizedError.Equatable {
    case invalidRequest
    case badRequest
    case unauthorized
    case forbidden
    case notFound
    case error4xx(_ code: Int)
    case serverError
    case error5xx(_ code: Int)
    case decodingError
    case urlSessionFailed(_ error: URLError)
    case unknownError
}
Copy the code

Write scheduling functions. By using generics, we can define the return type and return a Publisher to pass the output of the request to its subscribers.

Our NetworkDispatcher will receive a URL request, request it over the network and parse the returned JSON data for us.

NetworkDispatcher.swiftstruct NetworkDispatcher {
    let urlSession: URLSession!
    public init(urlSession: URLSession = .shared) {
        self.urlSession = urlSession
    }
    /// Dispatches an URLRequest and returns a publisher
    /// - Parameter request: URLRequest
    /// - Returns: A publisher with the provided decoded data or an error
    func dispatch<ReturnType: Codable> (request: URLRequest) -> AnyPublisher<ReturnType.NetworkRequestError> {
        return urlSession
            .dataTaskPublisher(for: request)
            // Map on Request response
            .tryMap({ data, response in
                // If the response is invalid, throw an error
                if let response = response as? HTTPURLResponse.!(200.299).contains(response.statusCode) {
                    throw httpError(response.statusCode)
                }
                // Return Response data
                return data
            })
            // Decode data using our ReturnType
            .decode(type: ReturnType.self, decoder: JSONDecoder())
            // Handle any decoding errors
            .mapError { error in
                handleError(error)
            }
            // And finally, expose our publisher
            .eraseToAnyPublisher()
    }
}
Copy the code

We execute the request using URLSession’s dataTaskPublisher, then map the response, correctly handle the error, and continue parsing the returned JSON data if the request completes successfully;

Defines a function to handle errors, first: httpError, which handles HTTP errors returned. Second: handleError handles the errors that occur in JSON Decoding

NetworkDispatcher.swiftextension NetworkDispatcher {
/// Parses a HTTP StatusCode and returns a proper error
    /// - Parameter statusCode: HTTP status code
    /// - Returns: Mapped Error
    private func httpError(_ statusCode: Int) -> NetworkRequestError {
        switch statusCode {
        case 400: return .badRequest
        case 401: return .unauthorized
        case 403: return .forbidden
        case 404: return .notFound
        case 402.405.499: return .error4xx(statusCode)
        case 500: return .serverError
        case 501.599: return .error5xx(statusCode)
        default: return .unknownError
        }
    }
    /// Parses URLSession Publisher errors and return proper ones
    /// - Parameter error: URLSession publisher error
    /// - Returns: Readable NetworkRequestError
    private func handleError(_ error: Error) -> NetworkRequestError {
        switch error {
        case is Swift.DecodingError:
            return .decodingError
        case let urlError as URLError:
            return .urlSessionFailed(urlError)
        case let error as NetworkRequestError:
            return error
        default:
            return .unknownError
        }
    }
}
Copy the code

APIClient

Now that we have both our Request and Dispatcher, let’s create a type to wrap our API Calls.

Now that we have Request and Dispatche, create an object to wrap our API Request;

Our APIClient will receive a NetworkDispatcher and a BaseUrl and will provide a centralized request method. This method will receive a Request, turn it into a URL Request, and pass it to the provided Dispatcher.

APIClient.swiftstruct APIClient {
    var baseURL: String!
    var networkDispatcher: NetworkDispatcher!
    init(baseURL: String.networkDispatcher: NetworkDispatcher = NetworkDispatcher()) {
        self.baseURL = baseURL
        self.networkDispatcher = networkDispatcher
    }
    /// Dispatches a Request and returns a publisher
    /// - Parameter request: Request to Dispatch
    /// - Returns: A publisher containing decoded data or an error
    func dispatch<R: Request> (_ request: R) -> AnyPublisher<R.ReturnType.NetworkRequestError> {
        guard let urlRequest = request.asURLRequest(baseURL: baseURL) else {
            return Fail(outputType: R.ReturnType.self, failure: NetworkRequestError.badRequest).eraseToAnyPublisher()
        }
        typealias RequestPublisher = AnyPublisher<R.ReturnType.NetworkRequestError>
        let requestPublisher: RequestPublisher = networkDispatcher.dispatch(request: urlRequest)
        return requestPublisher.eraseToAnyPublisher()
    }
}
Copy the code

The Publisher returned responds either to request errors or parsing errors, and we can customize our own NetworkDispatcher, which is easy to test.

Performing a request

At this point, if we want to perform a network request, we can do this:

private var cancellables = [AnyCancellable] ()let dispatcher = NetworkDispatcher(a)let apiClient = APIClient(baseURL: "https://jsonplaceholder.typicode.com")
apiClient.dispatch(FindTodos())
    .sink(receiveCompletion: { _ in },
          receiveValue: { value in
            print(value)
        })
    .store(in: &cancellables)
Copy the code

In this case, we’re performing a simple GET request, but you can customize your request object by adding additional parameters to your request

For example, if we want to add a Todo, we can do this:

// Our Add Request
struct AddTodo: Request {
     typealias ReturnType = [Todo]
     var path: String = "/todos"
     var method: HTTPMethod = .post
    var body: [String: Any]
    init(body: [String: Any]) {
        self.body = body
    }
}
let todo: [String: Any] = ["title": "Test Todo"."completed": true]
apiClient.dispatch(AddTodo(body: todo))
    .sink(receiveCompletion: { result in
        // Do something after adding...
        },
        receiveValue: { _ in })
    .store(in: &cancellables)
Copy the code

In this case, we’re building the body from a simple dictionary, but to make things easier, let’s extend Encoable and add a method to convert Encoable Type to asDictionary.

extension Encodable {
    var asDictionary: [String: Any] {
        guard let data = try? JSONEncoder().encode(self) else { return[:]}guard let dictionary = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] else {
            return[:]}return dictionary
    }
}
Copy the code

With this, you can write your request like this:

let otherTodo: Todo = Todo(title: "Test", completed: true)
apiClient.dispatch(AddTodo(body: otherTodo.asDictionary))
    .sink(receiveCompletion: { result in
        // Do something after adding...
        },
        receiveValue: { _ in })
    .store(in: &cancellables)
Copy the code

conclusion

Thanks to Apple’s Combine and Codable, we were able to write a very simple web client. Request types are extensible, easy to maintain, and both our network scheduler and application programming interface clients are easy to test and extremely simple to use.

Of course you can extend some additional features such as authentication, caching, and more detailed logging to make it more robust and usable!

Danielbernal. Co /writing-a-n…

I suggest you learn how to create a common network API in Swift from yesterday’s article, and you will have a deeper understanding of Swift network requests!

If it helps, like it and go to ❤️

Continue on Swift Road tomorrow!