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.