Saltar al contenido
K
Arquitectura en iOSPaso a paso38 min de lectura · 8 min de máquina Intermedio

POST y errores de red

Al terminar: Enviar datos con un POST y convertir los fallos de red en errores que la pantalla entienda.

Hasta ahora Marcador solo pedía datos al servidor. En esta lección aprende a enviarlos: al terminar tendrá un POST que manda una crónica, un error propio de la app, ErrorDeRed, con dos fallos bien distintos y un servicio que los traduce. La pantalla llega en la lección siguiente; hoy el proyecto compila, la lista de árbitros funciona igual (ahora pasa por la función nueva que mira el código) y el POST lo probarás primero en tu Playground.

Importa porque casi toda app, además de leer, escribe: crea un pedido, envía un mensaje, sube un comentario. Y porque aquí aprendes lo más delicado de la red: hay dos fallos muy distintos (no hubo respuesta, o hubo respuesta con un código de error) y cada uno se trata de otra manera.

Temas: POST con URLRequest, Encodable para el cuerpo, el código 201, la cabecera Content-Type, un error propio (ErrorDeRed) con LocalizedError, qué pasa con un catch que no encaja, y una función genérica para no repetir la comprobación. Nivel 2: usar.

Enviar datos: el POST

Un GET dice «dame». Un POST dice «toma, guarda esto»: la petición lleva, además de la dirección, un cuerpo con los datos que quieres enviar (la parte del papel de la ventanilla donde escribes tu pedido). Para construirla ya no basta una URL: hace falta un URLRequest, una petición con todos sus detalles.

Un POST tiene cuatro piezas:

  • URLRequest(url:): la petición a esa dirección. Se guarda en una var y no en un let porque vas a ir completándola, línea a línea (método, cabecera, cuerpo), y un let no se puede modificar.
  • httpMethod = "POST": el método. Por defecto es GET.
  • setValue(…, forHTTPHeaderField: "Content-Type"): la cabecera que dice en qué formato va el cuerpo. Para JSON, application/json.
  • httpBody: el cuerpo, en bytes (Data). Se obtiene con JSONEncoder().encode(…) a partir de un tipo Encodable, que es el camino contrario de Decodable: de un valor de Swift a JSON.

Se envía con data(for:) (con una URLRequest) en vez de data(from:) (con una URL).

Un servidor responde a un POST con 201, «creado»: la cosa se ha creado y aquí tienes el resultado, normalmente con el número (id) que le ha dado. Es un éxito, como el 200, y entra en el rango 200...299.

Un ejemplo con una lista de tareas pendientes, en el servicio de pruebas (que no guarda nada y responde 201 con un id inventado). Va en tu Playground, con el await suelto como en Red: HTTP, JSON y URLSession:

ejemplo mínimo: un POST con URLRequest (con comentarios)
import Foundation
 
// Lo que se ENVÍA: una tarea nueva, sin id (lo pone el servidor).
// `Encodable`: JSONEncoder sabe convertirlo en JSON.
struct TareaNueva: Encodable {
    let userId: Int
    let title: String
    let completed: Bool
}
 
do {
    let direccion = URL(string: "https://jsonplaceholder.typicode.com/todos")!
 
    // Una petición con más datos que un simple GET: método, cabecera y cuerpo.
    var peticion = URLRequest(url: direccion)
    peticion.httpMethod = "POST"
    peticion.setValue("application/json; charset=UTF-8", forHTTPHeaderField: "Content-Type")
    peticion.httpBody = try JSONEncoder().encode(TareaNueva(userId: 1, title: "Comprar pan", completed: false))
 
    // `data(for:)`: envía la petición completa.
    let (datos, respuesta) = try await URLSession.shared.data(for: peticion)
    let codigo = (respuesta as? HTTPURLResponse)?.statusCode ?? 0
    print("POST código:", codigo)
    // El servidor devuelve lo que enviaste más el `id` que ha inventado.
    // `String(decoding: datos, as: UTF8.self)` convierte los bytes recibidos en texto legible.
    print(String(decoding: datos, as: UTF8.self))
} catch {
    print("Falló:", error)
}
POST código: 201
{
  "title": "Comprar pan",
  "completed": false,
  "userId": 1,
  "id": 201
}

El orden de las claves del JSON puede variar de una vez a otra. Lo importante: el código es 201 y el id es 201, el siguiente al último de las 200 tareas del servicio. Y si lo pides otra vez, vuelve a salir 201: el servidor no guarda nada.

Los dos fallos de la red

Cuando pides algo a un servidor, puede salir mal de dos maneras muy distintas:

FalloQué pasóCómo se ve en URLSession
Sin respuestaNo hay internet, el servidor no existe, la conexión se cortódata(for:) lanza un URLError
Con respuesta de errorEl servidor contestó, pero con un 404, un 500…No lanza nada: hay que mirar statusCode

Es lo que viste en Red: HTTP, JSON y URLSession. La pantalla no quiere saber nada de URLError ni de códigos HTTP: quiere un mensaje. Por eso se traducen los dos a un error propio de la app, un enum con un caso por cada fallo:

ejemplo mínimo: un error propio con texto legible (con comentarios)
import Foundation
 
enum ErrorDeComentarios: Error, Equatable, LocalizedError {
    case sinConexion                    // no hubo respuesta
    case servidor(codigo: Int)          // hubo respuesta, pero con un código de error
 
    // `LocalizedError`: el texto que verá una persona, en `error.localizedDescription`.
    var errorDescription: String? {
        switch self {
        case .sinConexion: "No hay conexión"
        case let .servidor(codigo): "El servidor respondió con el código \(codigo)"
        }
    }
}
 
let error: Error = ErrorDeComentarios.servidor(codigo: 404)
print(error.localizedDescription)
El servidor respondió con el código 404

Un catch que no encaja deja salir el error

Para traducir el fallo sin respuesta hace falta un catch is URLError. Lo importante es lo que no hace: un catch con un tipo solo atrapa errores de ese tipo, y cualquier otro error sale de la función tal cual, como si el do no existiera. Es justo lo que queremos: el error propio que lanzaremos nosotros dentro del do no debe ser «traducido» otra vez. Un ejemplo sin red, en el que el 0 y el 1 lanzan un error distinto:

ejemplo mínimo: el catch que no encaja deja salir el error (con comentarios)
import Foundation
 
enum ErrorDePrueba: Error {
    case sinStock
}
 
func pedir(_ numero: Int) throws {
    do {
        if numero == 0 { throw URLError(.notConnectedToInternet) }
        if numero == 1 { throw ErrorDePrueba.sinStock }
    } catch is URLError {
        // Solo atrapa los URLError. Un `ErrorDePrueba` no encaja y sale de la función.
        print("dentro: era un URLError")
    }
}
 
for numero in 0...2 {
    do {
        try pedir(numero)
        print(numero, "sin error")
    } catch {
        print(numero, "salió fuera:", error)
    }
}
dentro: era un URLError
0 sin error
1 salió fuera: sinStock
2 sin error

El 0 fue atrapado dentro (por eso pedir terminó «sin error»), el 1 no encajó y salió, y el 2 no falló.

Y para no repetir la comprobación en cada petición, se escribe una vez en una función genérica, enviar<T: Decodable>: «T es cualquier tipo que se pueda decodificar», y la función devuelve ese tipo. No le dices a T qué es: Swift lo deduce del tipo que la función tiene que devolver. Si arbitros() declara que devuelve [ArbitroDTO] y su cuerpo es try await enviar(…), Swift entiende que aquí T es [ArbitroDTO]; si publicarCronica devuelve CronicaPublicadaDTO, ahí T es ese. enviar hace siempre lo mismo: enviar, mirar el código, decodificar y traducir los fallos.

AndroidiOS
Un 404Retrofit lanza HttpExceptionURLSession no lanza nada
Quién traduce a ErrorDeRedEl repositorio, con una funciónEl servicio, porque tiene que mirar el código a mano de todas formas
Mensaje sin conexión«No hay conexión»«No hay conexión» (el mismo)

Un detalle para cuando pruebes con la app: un URLError de ATS (una dirección http://, de Red: HTTP, JSON y URLSession) también acaba en «No hay conexión», porque el servicio traduce todos los URLError igual. La consola del Xcode escribe el código real si dudas.

Reto · Ejercicio: crear un comentario

En tu Playground (reutiliza el mismo y borra lo anterior), escribe una función crearComentario(ruta:) que publique un comentario en https://jsonplaceholder.typicode.com/ seguido de la ruta que reciba, y devuelva el número que le da el servidor. Tiene que:

  1. Enviar un POST con cuatro datos: el número del artículo comentado (postId, 1), el título (name, «Muy bueno»), el correo (email, «ana@example.com») y el texto (body, «Me gustó el libro»), con la cabecera de JSON. Lo que contesta el servidor trae el id del comentario.
  2. Si el código no es de éxito (fuera de 200 a 299), lanzar un error propio, servidor, que lleve el código.
  3. Si no hubo respuesta (un URLError), lanzar otro caso del mismo error propio, sinConexion. Piensa dónde pones cada cosa: el error del punto 2 lo lanzas tú dentro del do, y no debe acabar convertido en sinConexion.

Llámala con la ruta comments y después con comentarios (que está mal escrita a propósito, y el servidor responde 404). Atrapa cada error propio e imprime tú el mensaje: «Fallo: el servidor respondió» seguido del código, o «Fallo: sin conexión». Lo que debe salir:

Comentario creado: 501
Fallo: el servidor respondió 404
Ver solución
solución: crear un comentario
import Foundation
 
enum ErrorDeComentarios: Error, Equatable {
    case sinConexion
    case servidor(codigo: Int)
}
 
struct ComentarioNuevo: Encodable {
    let postId: Int
    let name: String
    let email: String
    let body: String
}
 
struct ComentarioCreado: Decodable {
    let id: Int
}
 
func crearComentario(ruta: String) async throws -> Int {
    var peticion = URLRequest(url: URL(string: "https://jsonplaceholder.typicode.com/\(ruta)")!)
    peticion.httpMethod = "POST"
    peticion.setValue("application/json; charset=UTF-8", forHTTPHeaderField: "Content-Type")
    peticion.httpBody = try JSONEncoder().encode(
        ComentarioNuevo(postId: 1, name: "Muy bueno", email: "ana@example.com", body: "Me gustó el libro")
    )
    do {
        let (datos, respuesta) = try await URLSession.shared.data(for: peticion)
        let codigo = (respuesta as? HTTPURLResponse)?.statusCode ?? 0
        guard (200...299).contains(codigo) else { throw ErrorDeComentarios.servidor(codigo: codigo) }
        return try JSONDecoder().decode(ComentarioCreado.self, from: datos).id
    } catch is URLError {
        // No hubo respuesta: se traduce a un caso propio.
        throw ErrorDeComentarios.sinConexion
    }
}
 
for ruta in ["comments", "comentarios"] {
    do {
        let id = try await crearComentario(ruta: ruta)
        print("Comentario creado: \(id)")
    } catch ErrorDeComentarios.servidor(let codigo) {
        // `let codigo` saca el número que lleva el caso `.servidor`.
        print("Fallo: el servidor respondió \(codigo)")
    } catch ErrorDeComentarios.sinConexion {
        print("Fallo: sin conexión")
    }
}

El throw ErrorDeComentarios.servidor(…) está dentro del do, pero el catch is URLError solo atrapa los URLError: el error propio sale de la función sin tocarse. En el servicio de pruebas, /comments devuelve el id 501 (el siguiente a sus 500 comentarios).

Paso 1 · El error de la app: ErrorDeRed.swift

Objetivo: el error propio de Marcador, con los dos fallos y sus mensajes.

MarcadorApp (proyecto)
MarcadorApp
Core
Domain
ErrorDeRed.swiftse crea
Network
DTOsDelServidor.swiftse modifica
ServicioDelTorneo.swiftse modifica
Cronica
Data
Cronicas.swiftse crea
Los ficheros de esta lección en el Project navigator.

ErrorDeRed es una regla o error que entienden varias funcionalidades, no código que habla con internet: por eso va en Core/Domain y no en Core/Network. Va en Core lo que no pertenece a ninguna funcionalidad, aunque lo usen varias; si es de una, se queda en ella aunque otras lo lean. Clic derecho sobre la carpeta Core de MarcadorApp (no la de MarcadorAppTests) → New Folder → Domain; después, clic derecho sobre Domain → New File from Template… → Swift File, solo con el target MarcadorApp marcado, y de nombre ErrorDeRed. Con la Terminal: mkdir -p ~/Marcador-Swift/MarcadorApp/MarcadorApp/Core/Domain && touch ~/Marcador-Swift/MarcadorApp/MarcadorApp/Core/Domain/ErrorDeRed.swift.

Intenta escribirlo tú: ErrorDeRed, un enum que es un error, se puede comparar y da textos legibles, con dos casos: sinConexion (texto «No hay conexión») y servidor con el código (texto «El servidor respondió con el código» seguido del número).

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Core/Domain/ErrorDeRed.swift
import Foundation
 
// EL ERROR DE DOMINIO de la red: lo que puede salir mal al hablar con el servidor, dicho con
// palabras de la app y no de `URLSession` (gemelo de `ErrorDeRed` en Android).
//
// Hay DOS fallos muy distintos:
//   - no hubo respuesta (sin internet, el servidor no existe…): `URLSession` lanza un `URLError`;
//   - hubo respuesta, pero con un código de error (404, 500…): `URLSession` NO lanza nada,
//     y es el servicio quien mira el código y lanza `.servidor`.
//
// `LocalizedError`: da el texto que verá el usuario (`error.localizedDescription`).
enum ErrorDeRed: Error, Equatable, LocalizedError {
    case sinConexion
    case servidor(codigo: Int)
 
    var errorDescription: String? {
        switch self {
        case .sinConexion: "No hay conexión"
        case let .servidor(codigo): "El servidor respondió con el código \(codigo)"
        }
    }
}

Paso 2 · Los datos que viajan: DTOsDelServidor.swift

Objetivo: el DTO de lo que se envía y el de lo que contesta el servidor.

Al final de DTOsDelServidor.swift (debajo de ArbitroDTO) añade dos tipos. Uno que se puede codificar, CronicaNuevaDTO, con userId (el servicio lo exige; vale cualquier usuario del 1 al 10), title y body. Otro que se puede decodificar, CronicaPublicadaDTO, con el id que da el servidor y el title.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Core/Network/DTOsDelServidor.swift
// … ArbitroDTO no cambia …
 
// Lo que se ENVÍA en el POST de la crónica: sin id, porque lo pone el servidor.
// `Encodable`: `JSONEncoder` sabe convertirlo en JSON.
struct CronicaNuevaDTO: Encodable {
    let userId: Int
    let title: String
    let body: String
}
 
// Lo que el servidor DEVUELVE al crearla: lo que se envió, con el id que le ha dado.
struct CronicaPublicadaDTO: Decodable {
    let id: Int
    let title: String
}

Paso 3 · El servicio gana el POST: ServicioDelTorneo.swift

Objetivo: que el servicio publique crónicas, y que todas las peticiones pasen por una sola función que mira el código y traduce los fallos.

Sustituye el contenido de ServicioDelTorneo.swift. Cambia tres cosas: el protocolo gana publicarCronica(_:); arbitros() deja de comprobar el código él mismo y llama a enviar; y aparece enviar<T: Decodable>, que envía la petición, mira el código y decodifica. Los URLError los convierte en .sinConexion, y un código fuera de 200...299 en .servidor(codigo:). Fíjate en que ni arbitros() ni publicarCronica dicen qué es T: lo deduce Swift del tipo que cada una devuelve.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Core/Network/ServicioDelTorneo.swift
import Foundation
 
// EL SERVICIO: lo que la app puede pedir al servidor (gemelo de la interfaz `LigaApi` de Retrofit).
//
// Es un PROTOCOLO, como el repositorio: quien lo usa dice QUÉ quiere («dame los árbitros») y no
// sabe cómo viaja la petición. En las pruebas se cambia por un falso que no toca la red.
protocol ServicioDelTorneo {
    func arbitros() async throws -> [ArbitroDTO]
    func publicarCronica(_ cronica: CronicaNuevaDTO) async throws -> CronicaPublicadaDTO
}
 
// La implementación de verdad: `URLSession` contra JSONPlaceholder, un servidor de pruebas gratuito.
//
// En iOS no hay que pedir ningún permiso para usar internet. Lo que sí hay es ATS (App Transport
// Security): las peticiones tienen que ir por `https://`. Esta dirección lo es, así que no se toca nada.
struct ServicioJSONPlaceholder: ServicioDelTorneo {
    let base: URL
    let sesion: URLSession
 
    init(base: URL = URL(string: "https://jsonplaceholder.typicode.com")!, sesion: URLSession = .shared) {
        self.base = base
        self.sesion = sesion
    }
 
    // GET https://jsonplaceholder.typicode.com/users
    func arbitros() async throws -> [ArbitroDTO] {
        try await enviar(URLRequest(url: base.appending(path: "users")))
    }
 
    // POST https://jsonplaceholder.typicode.com/posts, con la crónica en JSON en el cuerpo.
    // Responde 201 («creado») con lo mismo que se envió más un `id`. Es un servidor de pruebas:
    // no guarda nada, y el id es siempre 101.
    func publicarCronica(_ cronica: CronicaNuevaDTO) async throws -> CronicaPublicadaDTO {
        var peticion = URLRequest(url: base.appending(path: "posts"))
        peticion.httpMethod = "POST"
        // Sin esta cabecera el servidor responde 201 igual, pero no entiende el cuerpo como JSON.
        peticion.setValue("application/json; charset=UTF-8", forHTTPHeaderField: "Content-Type")
        peticion.httpBody = try JSONEncoder().encode(cronica)
        return try await enviar(peticion)
    }
 
    // Lo que tienen en común todas las peticiones: enviar, comprobar el código y decodificar.
    // `T` es el tipo que se espera en la respuesta (`[ArbitroDTO]`, `CronicaPublicadaDTO`…).
    // Aquí se traducen los fallos de `URLSession` al error de dominio, `ErrorDeRed`.
    private func enviar<T: Decodable>(_ peticion: URLRequest) async throws -> T {
        do {
            let (datos, respuesta) = try await sesion.data(for: peticion)
            // Un 404 o un 500 NO lanzan ningún error en `URLSession`: hay que mirar el código a mano.
            let codigo = (respuesta as? HTTPURLResponse)?.statusCode ?? 0
            guard (200...299).contains(codigo) else { throw ErrorDeRed.servidor(codigo: codigo) }
            return try JSONDecoder().decode(T.self, from: datos)
        } catch is URLError {
            // No hubo respuesta: sin internet, el servidor no existe, se cortó…
            throw ErrorDeRed.sinConexion
        }
    }
}

Paso 4 · La crónica y su repositorio: Cronicas.swift

Objetivo: el tipo de la crónica y el repositorio que la publica.

La crónica es una funcionalidad nueva y este fichero guarda sus datos: clic derecho sobre la carpeta MarcadorApp (la de dentro) → New Folder → Cronica; clic derecho sobre Cronica → New Folder → Data; y en Data, New File from Template… → Swift File → Cronicas (con la Terminal: mkdir -p ~/Marcador-Swift/MarcadorApp/MarcadorApp/Cronica/Data && touch ~/Marcador-Swift/MarcadorApp/MarcadorApp/Cronica/Data/Cronicas.swift). El modelo y el protocolo del repositorio comparten fichero con la clase que lo cumple, así que los tres van en Data.

Intenta escribirlo tú: Cronica, con título y texto, que se puede comparar. CronicasRepositorio, un protocolo solo para clases, con una función publicar(titulo:texto:), asíncrona y que puede fallar, que devuelve el número que da el servidor. Y RepositorioDeCronicasEnRed, que recibe un servicio por el init, arma el CronicaNuevaDTO (con userId 1) y devuelve el id de la respuesta.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Cronica/Data/Cronicas.swift
// LA CRÓNICA DE UN PARTIDO: un título («Rayo FC 2-1 Toros») y un texto con una línea por gol.
struct Cronica: Equatable {
    let titulo: String
    let texto: String
}
 
// EL REPOSITORIO de las crónicas: publicar una y recibir el número que le dio el servidor.
protocol CronicasRepositorio: AnyObject {
    func publicar(titulo: String, texto: String) async throws -> Int
}
 
// La implementación con el servicio: arma el DTO, hace el POST y se queda con el id.
// Los fallos ya llegan traducidos a `ErrorDeRed` desde el servicio.
final class RepositorioDeCronicasEnRed: CronicasRepositorio {
    private let servicio: any ServicioDelTorneo
 
    init(servicio: any ServicioDelTorneo) {
        self.servicio = servicio
    }
 
    func publicar(titulo: String, texto: String) async throws -> Int {
        // `userId` es obligatorio en JSONPlaceholder: cualquier usuario del 1 al 10 vale.
        let nueva = CronicaNuevaDTO(userId: 1, title: titulo, body: texto)
        return try await servicio.publicarCronica(nueva).id
    }
}

Aún nadie usa este repositorio: la pantalla que lo conecta llega en La pantalla de la crónica y sus pruebas. Pulsa Cmd+B; después, Cmd+R y abre «Árbitros»: la lista sigue saliendo, ahora a través de enviar.

Punto de control

  • Cmd+B termina en Build Succeeded con ErrorDeRed.swift y Cronicas.swift nuevos, y DTOsDelServidor.swift y ServicioDelTorneo.swift modificados.
  • Cmd+U da 43 pruebas en verde, 27 unitarias y 16 de interfaz: esta mitad no añade ninguna (las tres de la crónica llegan en la lección siguiente). Si dejaste el ejercicio de la taquilla en MarcadorAppTests/Ejercicios, salen 46: los ejercicios con pruebas suman a la cuenta.
  • En el simulador, «Árbitros» sigue enseñando las 10 filas: el GET pasa ahora por enviar.
  • El ejercicio del Playground imprime «Comentario creado: 501» y «Fallo: el servidor respondió 404».

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.Para enviar un POST con JSON, ¿qué necesita tener la URLRequest?

  2. 2.El servidor responde 201 a tu POST. ¿Qué significa?

  3. 3.Envías un POST sin la cabecera Content-Type a JSONPlaceholder. ¿Qué ocurre?

  4. 4.¿Cuáles son los dos fallos distintos de una petición de red y cómo se ven en URLSession?

  5. 5.¿Para qué sirve LocalizedError en ErrorDeRed?

  6. 6.¿Qué ventaja tiene escribir enviar<T: Decodable> una sola vez?

Faltan 6 de 6