← Volver al blog

Construyendo una Pokédex en iOS — Parte 2: Capa de red

Parte 2 de la serie PokeTracker: construimos el cliente HTTP, definimos los endpoints con un enum y creamos errores de red tipados.

En el post anterior dejamos el proyecto con su estructura de carpetas lista. Ahora construimos la capa de red: el código que habla con la PokéAPI.

Hay proyectos donde el networking está esparcido por los ViewModels: un URLSession.shared.data(from: url) aquí, otro allá. Funciona al principio. Cuando quieres agregar reintentos, timeouts, logging, o simplemente probar sin hacer llamadas reales, se complica.

Centralizar la red en un solo cliente resuelve eso. Todo el código que habla con el exterior pasa por un lugar.


HTTPMethod

Crea Core/Network/NetworkError.swift. Es un enum simple que evita usar strings mágicos para los métodos HTTP:

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

Por ahora solo usamos GET, pero tener el enum hace explícito qué métodos conoce la app.


Endpoint

Crea Core/Network/Endpoint.swift. Este enum representa cada URL que la app puede llamar:

import Foundation

enum Endpoint {
    case pokemonList(limit: Int, offset: Int)
    case pokemonDetail(idOrName: String)
    case pokemonSpecies(idOrName: String)
    case type(name: String)
    case generation(id: Int)
    case evolutionChain(id: Int)

    private var baseURL: String {
        "https://pokeapi.co/api/v2"
    }

    var path: String {
        switch self {
        case .pokemonList:
            return "/pokemon"
        case .pokemonDetail(let idOrName):
            return "/pokemon/\(idOrName)"
        case .pokemonSpecies(let idOrName):
            return "/pokemon-species/\(idOrName)"
        case .type(let name):
            return "/type/\(name)"
        case .generation(let id):
            return "/generation/\(id)"
        case .evolutionChain(let id):
            return "/evolution-chain/\(id)"
        }
    }

    var queryItems: [URLQueryItem]? {
        switch self {
        case .pokemonList(let limit, let offset):
            return [
                URLQueryItem(name: "limit", value: String(limit)),
                URLQueryItem(name: "offset", value: String(offset))
            ]
        default:
            return nil
        }
    }

    var url: URL? {
        var components = URLComponents(string: baseURL + path)
        components?.queryItems = queryItems
        return components?.url
    }

    var method: HTTPMethod {
        .get
    }
}

La ventaja del enum es que cada caso tiene parámetros tipados. No se puede llamar pokemonDetail sin pasar un idOrName. No se puede olvidar el limit en pokemonList. Si la app llama a un endpoint que no está aquí, el compilador lo rechaza.


NetworkError

Crea Core/Network/NetworkError.swift. Los errores tipados son uno de los lugares donde Swift es más expresivo que otros lenguajes:

import Foundation

enum NetworkError: Error, LocalizedError {
    case invalidURL
    case invalidResponse
    case invalidData
    case decodingError(Error)
    case serverError(statusCode: Int)
    case noConnection
    case timeout
    case unknown(Error)

    var errorDescription: String? {
        switch self {
        case .invalidURL:
            return "The URL is invalid."
        case .invalidResponse:
            return "Invalid response from server."
        case .invalidData:
            return "The data received is invalid."
        case .decodingError(let error):
            return "Failed to decode response: \(error.localizedDescription)"
        case .serverError(let statusCode):
            return "Server error with status code: \(statusCode)"
        case .noConnection:
            return "No internet connection."
        case .timeout:
            return "Request timed out."
        case .unknown(let error):
            return "Unknown error: \(error.localizedDescription)"
        }
    }
}

Conformar con LocalizedError y proporcionar errorDescription hace que cualquier vista pueda mostrar error.localizedDescription directamente.


APIClient

Crea Core/Network/APIClient.swift. Este es el cliente HTTP:

import Foundation

protocol APIClientProtocol: Sendable {
    func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T
    func fetchData(from url: URL) async throws -> Data
}

final class APIClient: APIClientProtocol, Sendable {

    private let session: URLSession
    private let decoder: JSONDecoder

    init(session: URLSession = .shared) {
        self.session = session

        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        self.decoder = decoder
    }

    func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        guard let url = endpoint.url else {
            throw NetworkError.invalidURL
        }

        var request = URLRequest(url: url)
        request.httpMethod = endpoint.method.rawValue
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.timeoutInterval = 30

        do {
            let (data, response) = try await session.data(for: request)

            guard let httpResponse = response as? HTTPURLResponse else {
                throw NetworkError.invalidResponse
            }

            guard (200...299).contains(httpResponse.statusCode) else {
                throw NetworkError.serverError(statusCode: httpResponse.statusCode)
            }

            return try decoder.decode(T.self, from: data)

        } catch let error as NetworkError {
            throw error
        } catch let error as URLError {
            switch error.code {
            case .notConnectedToInternet, .networkConnectionLost:
                throw NetworkError.noConnection
            case .timedOut:
                throw NetworkError.timeout
            default:
                throw NetworkError.unknown(error)
            }
        } catch {
            throw NetworkError.unknown(error)
        }
    }

    func fetchData(from url: URL) async throws -> Data {
        do {
            let (data, response) = try await session.data(from: url)

            guard let httpResponse = response as? HTTPURLResponse else {
                throw NetworkError.invalidResponse
            }

            guard (200...299).contains(httpResponse.statusCode) else {
                throw NetworkError.serverError(statusCode: httpResponse.statusCode)
            }

            return data

        } catch let error as NetworkError {
            throw error
        } catch let error as URLError {
            switch error.code {
            case .notConnectedToInternet, .networkConnectionLost:
                throw NetworkError.noConnection
            case .timedOut:
                throw NetworkError.timeout
            default:
                throw NetworkError.unknown(error)
            }
        } catch {
            throw NetworkError.unknown(error)
        }
    }
}

Hay tres decisiones que vale la pena notar:

El protocolo APIClientProtocol. APIClient conforma un protocolo, no solo define la clase. Esto es lo que permite crear un MockAPIClient en los tests sin hacer llamadas reales a la red. En el Post 9 lo veremos en acción.

.convertFromSnakeCase. La PokéAPI devuelve los campos en snake_case (base_experience, sprite_url). Con esta estrategia el decoder mapea automáticamente a camelCase en Swift. Sin esto tendrías que definir CodingKeys en cada DTO.

El manejo de errores en capas. El bloque catch tiene tres ramas: primero relanza los NetworkError que ya tienes (para no perder el tipo), luego convierte los URLError más comunes a errores propios con contexto útil, y finalmente envuelve cualquier error desconocido. El resultado es que quien llama a request() siempre recibe un NetworkError, no un error genérico.

fetchData hace lo mismo pero devuelve Data crudo en lugar de decodificar a un tipo. Lo usamos para las imágenes.


Qué viene en el siguiente post

La capa de red está lista pero todavía no tiene DTOs que deserializar. En el Post 3 construimos los objetos de transferencia de datos y explicamos por qué existen.