¿Cómo crear un APIClient en Swift desde cero?

¿Cómo crear un APIClient en Swift desde cero?

Hoy aprenderemos a crear peticiones HTTP en Swift con nuestro propio APIClient, creado desde cero. Si quieres ver todos los componentes que entran en juego, lee el post.

SwiftBeta

¿Quieres crear tu propio APIClient en Swift? Hoy vamos a crear uno desde cero con 4 componentes:

  • Endpoint
  • Router
  • Parser
  • Requester

La idea principal de hoy es entender los diferentes comportamientos que podemos tener en nuestro APIClient, cada uno con su propia responsabilidad.

En este caso utilizaremos un APIClient para hacer peticiones HTTP a nuestro backend y recibir información. Esta información la transformaremos de JSON a nuestro objeto de dominio.

No usaremos ningún código de terceros (como por ejemplo, Alamofire). ¿Por qué? para entender cómo funcionan y se conectan estos distintos componentes. El resultado final será declarar nuestra API de la siguiente manera:

final class API {
    let requester: RequesterProtocol
    let parser: ParserProtocol
    let router: RouterProtocol
    
    init(requester: RequesterProtocol = Requester(),
         parser: ParserProtocol = Parser(),
         router: RouterProtocol = Router()) {
        self.requester = requester
        self.parser = parser
        self.router = router
    }
}

Pero no nos adelantemos. Podemos hacer una petición HTTP a nuestro backend con el código que aparece a continuación:

struct ConversationsDataModel: Decodable {
    let ok: Bool
    let conversations: [ConversationDataModel]
}

struct ConversationDataModel: Decodable {
    let id: String
    let name: String
    let creator: String
}

final class APIClient {
    func getSlackConversations(completionBlock: @escaping (ConversationsDataModel) -> ()) {
        let url = URL(string: "https://slack.com/api/conversations.list?token=xoxb-1314965010803-1498101718961-mXey0PS6v41QfztMvJTJhQKb")!
        let task = URLSession.shared.dataTask(with: url) {
            data, response, error in
            let dataModel = try! JSONDecoder().decode(ConversationsDataModel.self, from: data!)
            completionBlock(dataModel)
        }
        task.resume()
    }
}

let apiClient = APIClient()
apiClient.getSlackConversations {
    print("DataModel \($0)")
}

Sin embargo, si trabajas en una app que hace muchas peticiones HTTP, el problema principal es que puede que no sea escalable, mantenible o fácilmente testeable. Algunos ejemplos de poco escalable o poco mantenible:

  • Cuando tu base URL está siempre apuntando al mismo hosts nos aporta poca customización, lo que queremos es flexibilidad para apuntar a un docker, producción, beta, etc.
  • El token está hardcodeado en cada petición HTTP.
  • El método que hace la llamada también se encarga de crear la URL, parsear a un modelo concreto, etc.
  • etc

La idea es separar la lógica y crear un componente con una responsabilidad cada uno, en lugar de usar una misma clase que haga de todo.

Al final lo que vamos es hacer es lo que se muestra en la siguiente imagen:

Vamos a crear un APIClient que:

  • Enviará peticiones HTTP a nuestro backend
  • Recibirá la información de Backend (nuestros servidores) para transformarla en datos que nuestra aplicación entienda, para ello, transformaremos el JSON a un modelo nuestro de dominio.

Para crear un token y poderlo utilizar en tu código te aconsejo que lo crees siguiendo estos simples pasos:

¿Cómo crear una App en Slack?
Muchas veces queremos hacer pruebas con herramientas que usamos en nuestro día a día. En este caso te ayudo a crear tu primera app en Slack para así generar un token y llamar a su API

Endpoint

Vamos a empezar por lo básico, un endpoint. ¿Qué necesitamos de un endpoint? un path, un httpMethod y algunos parámetros.

Vamos a ver un ejemplo del endpoint que crearemos a continuación:

El path son las distintas rutas que podemos tener, partiendo de la baseUrl que en este caso será https://slack.com/api

Vamos a empezar a escribir código, lo primero que vamos hacer es crear una interfaz (también llamado protocolo o contrato) con todos los requerimientos que queremos en un endpoint:

protocol Endpoint {
    var path: String { get }
    var method: HTTPMethod { get }
    var parameters: [String: AnyObject]? { get }    
}

Vamos añadir una implementación por defecto que conforme este protocolo:

extension Endpoint {
    var path: String { "" }
    var method: HTTPMethod { .GET }
    var parameters: [String: AnyObject]? { nil }
}

Si intentas compilar ahora tendrás un error porque aún no hemos creado el tipo HTTPMethod, vamos a hacerlo:

enum HTTPMethod: String {
    case POST
    case GET
}

En este post solo vamos a crear un endpoint para centrarnos en la responsabilidad de cada componente. A nuestro endpoint lo llamaremos GetConversationsEndpoint y haremos que conforme el protocolo Endpoint. Al conformar el protocolo tendremos que rellenar los requerimientos de path, method y parameters.

struct GetConversationsEndpoint: Endpoint {
    var path: String = "/conversations.list"
    var method: HTTPMethod = .GET
    var parameters: [String : AnyObject]? = nil
}

Te puedes preguntar, ¿por qué el path de /conversations.list no tiene una base URL? (Spoiler: lo veremos más abajo, es responsabilidad de otro componente). Finalmente, creamos un enum llamado SlackEndpoints con el único objetivo de  agrupar endpoints.

enum SlackEndpoints {
    case getConversations
    
    public var endpoint: Endpoint {
        switch self {
        case .getConversations:
            return GetConversationsEndpoint()
        }
    }
}

Con esto acabamos nuestro primer Componente, fácil ¿verdad? El endpoint tiene la responsabilidad de crear un endpoint con el path, httpMethod y los parámetros necesarios para lleva a cabo con éxito la petición HTTP.


Router

Ya hemos hecho nuestro primer componente. Vamos a ver el siguiente al que llamaremos Router.

El Router vamos hacer que tenga una dependencia llamada AppEnvironment. ¿Te acuerdas del spoiler que te mencionaba antes? Aquí es donde controlamos qué base URL usamos en nuestro endpoint. Es realmente útil para cambiar de entornos (beta, producción, dockers, etc).

En nuestro caso estamos trabajando directamente con producción por lo que haríamos lo siguiente:

class AppEnvironment {
    enum Base {
        case slack
    }
            
    func getUrl(api: Base) -> URL {
        switch api {
        case .slack:
            return URL(string: "https://slack.com/api")!
        }
    }
}

Si quisieramos crear beta, crearíamos otro case en el enum ¿ves que flexibilidad nos aporta añadir más componentes y separar la lógica en ellos? Como hemos dicho antes, AppEnvironment es una dependencia de nuestro Router. Por lo tanto se la tenemos que inyectar, en este caso se la inyectamos en el init(environtment:) ¿Pero qué hace exactamente nuestro Router? Se encarga de enrutar nuestros endpoints añadiendo la base URL que le indiquemos.

protocol RouterProtocol {
    func routeSlackEndpoint(_ endpoint: Endpoint) -> RoutedEndpoint
}

final class Router {
    private struct RouterEndpoint: Endpoint {
        let path: String
        let method: HTTPMethod
        var parameters: [String: AnyObject]?
    }
    
    private let environment: AppEnvironment
    
    init(environment: AppEnvironment = AppEnvironment()) {
        self.environment = environment
    }
    
    func routeSlackEndpoint(_ endpoint: Endpoint) -> Endpoint {
        routeEndpoint(endpoint, api: .slack)
    }
    
    private func routeEndpoint(_ endpoint: Endpoint, api: AppEnvironment.Base) -> Endpoint {
        var url = environment.getUrl(api: api)
        url.appendPathComponent(endpoint.path)
        return RouterEndpoint(path: url.absoluteString,
                              method: endpoint.method,
                              parameters: endpoint.parameters)
    }
}

La clase Router recibe un endpoint y devolverá un RoutedEndpoint con la base URL correcta. En este caso será: https://slack.com/api/conversations.list

Ya hemos visto dos componentes, uno que es el Endpoint con la información necesaria para hacer la request. El otro es el Router, que coge un endpoint y le añade la base URL. Ahora vamosa a ver el componente que se encarga de parsear los datos recibidos en nuestra petición HTTP.


Parser

Nuestro siguiente componente es el Parser. Este es el encargado de mapear los datos recibidos de nuestra petición HTTP a nuestros modelos de dominio. Estos modelos son importantes ya que son los que serán usados en nuestra app.

Hemos visto otros posts donde hacemos justamente esto, una petición y una transformación a nuestro modelo. En Swift podemos usar los genéricos para poder crear un parser que sea común para todos.

Vamos a crear una clase que llamaremos Parser y también vamos a crear una interfaz ParserProtocol.

protocol ParserProtocol {
    func parse<T: Decodable>(_ data: Data, type: T.Type, decoder: JSONDecoder) -> T?
}

final class Parser: ParserProtocol {
    func parse<T: Decodable>(_ data: Data, type: T.Type, decoder: JSONDecoder = .init()) -> T? {
        do {
            return try decoder.decode(T.self, from: data)
        } catch let error as DecodingError {
            printDecodable(error: error)
        } catch {
            print("Error \(error)")
        }
        return nil
    }
}

El trabajo del Parser es muy fácil, lo único que tiene que hacer es parsear los datos recibidos de nuestra petición a un modelo concreto de nuestro dominio.
En caso de que no se pueda parsear por algún error, hemos creado esta extension en el Parser:

extension Parser {
    func printDecodable(error: Error) {
        guard let error = error as? DecodingError else { return }
        let message: String
        switch error {
        case .keyNotFound(let key, let context):
            message = "[Decodable] Key \"\(key)\" not found \nContext: \(context.debugDescription)"
        case .dataCorrupted(let context):
            message = "[Decodable] Data corrupted \n(Context: \(context.debugDescription)) \nCodingKeys: \(context.codingPath)"
        case .typeMismatch(let type, let context):
            message = "[Decodable] Type mismatch \"\(type)\" \nContext: \(context.debugDescription)"
        case .valueNotFound(let type, let context):
            message = "[Decodable] Value not found, type \"\(type)\" \nContext: \(context.debugDescription)"
        @unknown default:
            message = "[Decodable] Unknown DecodingError catched"
            assertionFailure(message)
        }
        print(message)
    }
}

Con esta extensión podemos ver más fácilmente el motivo exacto del fallo. Pero ¿qué modelos creamos para pasar del JSON recibido a algo que entienda nuestra app? en este caso hemos creado dos, ConversationsDataModel y ConversationDataModel:

struct ConversationssDataModel: Decodable {
    let ok: Bool
    let conversations: [ConversationDataModel]
}

struct ConversationDataModel: Decodable {
    let id: String
    let name: String
    let creator: String
}

Bastante bien, ¿no? ya hemos creado otro componente que nos ayudará a "traducir" los datos de backend a nuestros modelos de dominio. Ahora vamos a ver como juntamos todas estas piezas. Y para ello vamos a ver nuestro último componente.


Requester

Nuestro último componente, al que vamos a llamar Requester, es el encargado de ir a red y realizar la petición HTTP.

Vamos a crear un protocolo que conformará nuestra clase Requester:

protocol RequesterProtocol {
    func execute(with endpoint: Endpoint, completionBlock: @escaping (Result<Data, Error>) -> ())
}

Nuestra clase Requester tiene dos dependencias, el URLSession y un token, este token es necesario para validar el recurso que queremos consumir, en este caso la API de Slack:

final class Requester: RequesterProtocol {
    private let urlSession: URLSession
    private let token: String
    
    init(urlSession: URLSession = URLSession.shared,
         token: String = "") {
        self.urlSession = urlSession
        self.token = token
    }
}

Dentro de nuestro clase, creamos dos métodos. El primero será el encargado de hacer la petición HTTP y el otro de construir la URLRequest con todos los parámetros necesarios:

func execute(with endpoint: Endpoint, completionBlock: @escaping (Result<Data, Error>) -> ()) {
    let urlRequest = buildURLRequest(endpoint: endpoint)!
    
    let task = urlSession.dataTask(with: urlRequest) {
        data, response, error in
        guard let error = error else {
            completionBlock(.success(data!))
            return
        }
        completionBlock(.failure(error))
    }

    task.resume()        
}

private func(endpoint: Endpoint) -> URLRequest? {
    var urlRequest = URLRequest(url: URL(string: endpoint.path)!)
    urlRequest.httpMethod = endpoint.method.rawValue
            
    if let parameters = endpoint.parameters,
        !parameters.isEmpty,
        let postData = (try? JSONSerialization.data(withJSONObject: endpoint.parameters as Any, options: [])) {
        urlRequest.httpBody = postData
    }
    
    urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
    urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
    
    return urlRequest
}

Ahora solo nos faltaría unir todos los componentes y ver como funcionan.

Para ello vamos a crear una clase API a la que inyectaremos un RequesterProtocol, ParserProtocol y RouterProtocol, el código queda´´ia de la siguiente manera:

final class API {
    let requester: RequesterProtocol
    let parser: ParserProtocol
    let router: RouterProtocol
    
    init(requester: RequesterProtocol = Requester(),
         parser: ParserProtocol = Parser(),
         router: RouterProtocol = Router()) {
        self.requester = requester
        self.parser = parser
        self.router = router
    }
}

Ahora solo nos faltaría crear otra clase a la que inyectaremos el token de Slack (necesario en cada petición) y nuestra API.

protocol SlackAPIProtocol {
    func getConversations()
}

final class SlackAPI {
    private let token: String
    private let api: API
        
    init(token: String) {
        self.token = token
        self.api = API(requester: Requester(token: token))
    }
    
    func getConversations(completionBlock: @escaping (ConversationsDataModel?) -> ()) {
    	// 1
        let endpoint = SlackEndpoints.getConversations.endpoint
        // 2
        let conversationsEndpoint = api.router.routeSlackEndpoint(endpoint)
        // 3
        api.requester.execute(with: conversationsEndpoint) { [weak self] result in
            guard let strongSelf = self else { return }
            switch result {
            case .success(let data):
				// 4
                let dataModel = strongSelf.api.parser.parse(data, type: ConversationsDataModel.self, decoder: JSONDecoder.init())
				// 5
                completionBlock(dataModel)
            case .failure(let error):
                print("Error \(error)")
            }
        }
    }
}

Como ves, dentro de la función getConversations:

  1. Obtenemos el endpoint de getConversations.
  2. Lo enrutamos para que tenga la base url correcta, en este caso la de producción. (https://slack.com/api)
  3. Hacemos la petición HTTP con nuestro endpoint.
  4. Una vez ha ido todo bien, parseamos la respuesta a nuestro modelo.

Si quisieras utilizar el APIClient en tu app, solo necesitas escribir:

var slackAPI = SlackAPI(token: "ADD-YOUR-TOKEN")

slackAPI.getConversations { conversations in
    print("Conversations \(String(describing: conversations))")
}
Hemos quitado el token, ya que al subir este código al repositorio, Slack por seguridad nos notifica y hace inservible el token

El resultado con un token sería:

Conversations Optional(Advance_PageSources.ConversationsDataModel
(	ok: true, 
	conversations: 
		[Advance_PageSources.ConversationDataModel(id: "C01EBJH6ATZ", name: 	 "random", creator: "U01ESGYB2JX"), 
     	Advance_PageSources.ConversationDataModel(id: "C01ESAVMG74", name: "aprender-a-programar-en-swift", creator: "U01ESGYB2JX"), 
     	Advance_PageSources.ConversationDataModel(id: "C01F4VDL95X", name: "general", creator: "U01ESGYB2JX")]
)
)

Todo este código lo puedes encontrar en nuestra cuenta de Github. Si te fijas, al crear distintos ficheros y usarlos en los playgrounds, tenemos que crearlos con el access level public para poder usarlos.


Hasta aquí el post de hoy, gracias por leernos! 🤓
Si tienes preguntas no dudes en contactar con nosotros a través de Twitter

Si quieres seguir aprendiendo sobre SwiftUI, Swift, Xcode, o cualquier tema relacionado con el ecosistema Apple


Network