Red: HTTP, JSON y URLSession
Al terminar: Entender una petición HTTP y su respuesta y hacer el primer GET con URLSession, async/await y Codable.
Hasta ahora todos los datos de Marcador vivían dentro de la app: los equipos, los partidos, los goles. Al terminar esta lección sabrás qué es pedir algo a un servidor por internet (qué es una petición, una respuesta, un código de estado, un JSON), habrás hecho tu primera petición real con URLSession y Marcador tendrá la pieza que habla con el servidor: un servicio y el tipo que recibe la lista de árbitros. La app se verá igual: todavía nadie usa esa pieza. Eso llega en la lección siguiente.
Importa porque casi toda app real habla con un servidor, y el código de red es donde más cosas fallan: no hay conexión, el servidor responde con un error, llega un JSON que no esperabas. Entender el vocabulario de esta lección te sirve para leer cualquier documentación de una API.
Temas: servidor, API y HTTP; URL, método, código de estado, cabeceras y cuerpo; JSON; DTO frente a modelo; URLSession.data(from:); y por qué en iOS no hay permiso de internet pero sí una regla de HTTPS. Nivel 2: usar.
Qué es pedir algo a un servidor
La app no lleva dentro todos los datos del mundo. Los pide a un servidor: un ordenador de otra persona, siempre encendido, que contesta preguntas. Un servidor hecho para que lo consulten programas y no personas es una API. La conversación entre tu app y el servidor sigue unas reglas que se llaman HTTP.
Cada parte del papel y del sobre tiene nombre:
- La dirección (la URL) dice a quién y qué le pides. En
https://jsonplaceholder.typicode.com/todos?_limit=3hay cuatro trozos:https://es el protocolo (la «s» indica que la conversación va cifrada),jsonplaceholder.typicode.comes el servidor,/todoses la ruta (qué cosa quieres) y lo que va detrás del?son los parámetros de la URL, paresclave=valorseparados por&(nada que ver con los parámetros de una función de Swift). Aquí: «dame las tareas, pero solo 3». - El método dice qué quieres hacer.
GETsignifica «dame».POSTsignifica «toma, guarda esto»: lo usarás dos lecciones más adelante. - El código de estado es el sello del sobre: un número.
200quiere decir «va bien». Los que empiezan por4(como404, «eso no existe») dicen que la petición no puede cumplirse: pedirla otra vez da lo mismo. Los que empiezan por5(500,503) dicen «el servidor ha fallado», y ahí a veces sí merece la pena reintentar. - Las cabeceras son notas pegadas al sobre. La que más importa aquí es
Content-Type, que dice en qué formato viene lo de dentro. - El cuerpo es lo de dentro. Aquí es JSON: texto con objetos
{ "clave": valor }y listas[ … ].
| Código | Quiere decir | ¿Reintentar? |
|---|---|---|
2xx (200, 201) | Todo bien | No hace falta |
4xx (404) | La petición no puede cumplirse | No: daría lo mismo |
5xx (500, 503) | Falló el servidor | A veces sí |
Esto es lo que contesta de verdad el servicio de pruebas que usarás en esta lección, JSONPlaceholder. Es gratuito, no pide registro ni clave, y sus datos son inventados. Dos peticiones (comprobadas el 7 de octubre de 2026; en la primera, el cuerpo está recortado):
GET https://jsonplaceholder.typicode.com/users/1
→ 200, content-type: application/json; charset=utf-8
{ "id": 1, "name": "Leanne Graham", "username": "Bret", "email": "Sincere@april.biz",
"address": { "street": "Kulas Light", "city": "Gwenborough", … },
"phone": "1-770-736-8031 x56442", "website": "hildegard.org", "company": { … } }
GET https://jsonplaceholder.typicode.com/users/99
→ 404 {}La primera trae el estado 200 y un usuario con muchos campos. La segunda pide un usuario que no existe y recibe el 404 con un cuerpo vacío ({}).
Es decir: este servidor no guarda nada de lo que le envíes. Sirve para practicar sin romper nada.
DTO frente a modelo
El JSON trae los nombres que decidió el servidor (name, address, city), en inglés y con muchos campos que a tu app no le hacen falta (phone, website, company…). Si pintaras ese tipo directamente en la pantalla, un cambio del servidor rompería media app.
Por eso se usan dos tipos:
- El DTO (Data Transfer Object, «objeto de transferencia de datos»): una copia exacta de la forma del JSON, solo con los campos que usas. Lo conoce la capa de red y nadie más.
- El modelo: lo que la app entiende, con sus propios nombres en español. Es lo que pintan las pantallas.
Entre los dos hay una traducción, escrita en un solo sitio. Ya hiciste algo parecido en Codable: guardar y cargar el torneo: los tipos guardados en el fichero eran aparte de los del juego.
Un ejemplo con una biblioteca, sin red, para ver las dos cosas juntas:
import Foundation
// El DTO: la forma del JSON tal como llega. `Decodable`: JSONDecoder sabe rellenarlo.
struct LibroDTO: Decodable {
let id: Int
let title: String
let pages: Int
}
// El modelo: lo que la app entiende, con nombres en español.
struct Libro {
let titulo: String
let paginas: Int
// La traducción de DTO a modelo, en UN solo sitio.
init(dto: LibroDTO) {
titulo = dto.title
paginas = dto.pages
}
}
// Un JSON escrito a mano, con un campo de más (`isbn`) que el DTO no declara.
let json = """
{ "id": 7, "title": "Dune", "pages": 612, "isbn": "978-0441013593" }
"""
do {
// `Data(json.utf8)`: el texto convertido en bytes, que es lo que pide `decode`.
let dto = try JSONDecoder().decode(LibroDTO.self, from: Data(json.utf8))
let libro = Libro(dto: dto)
print(libro.titulo, libro.paginas)
} catch {
print("No se pudo leer:", error)
}Dune 612Dos cosas: el campo isbn sobra y no da error (JSONDecoder ignora las claves que el tipo no declara), y la pantalla solo vería Libro, nunca LibroDTO. Si mañana el servidor renombra pages, solo cambia el DTO y la traducción.
Pedir una dirección con URLSession
URLSession es la clase de Apple para hablar con la red. Su función data(from:) hace un GET y es async throws: devuelve una tupla con dos cosas, los datos (Data) y la respuesta (URLResponse). Una tupla es el grupo de valores entre paréntesis de Funciones y switch, y let (datos, respuesta) = … la abre y da un nombre a cada valor.
La respuesta llega como URLResponse, y para leer el código hay que verla como HTTPURLResponse con as?, el mismo as? de Cancelar una tarea. Y aquí viene lo que más sorprende al empezar:
200...299 es un rango: todos los enteros del 200 al 299, y .contains(codigo) pregunta si el código está dentro.
Un ejemplo completo, con un álbum de fotos en vez de nada de Marcador. Pide un álbum, comprueba el código y lo decodifica; y lo llamamos tres veces: con uno que existe, con uno que no existe (para ver el 404 sin error) y con un servidor que no existe (para ver el fallo sin respuesta).
Fíjate en que el do tiene dos catch. Un catch is URLError { … } atrapa solo los errores de ese tipo, y el catch { … } de debajo atrapa todos los demás. Se prueban en orden y gana el primero que encaja, así que el específico va antes que el general:
import Foundation
struct AlbumDTO: Decodable {
let id: Int
let title: String
}
func pedirAlbum(numero: Int, servidor: String) async {
// `!` abre el opcional: `URL(string:)` devuelve nil si el texto no es una URL válida.
let url = URL(string: "\(servidor)/albums/\(numero)")!
do {
// `await`: la función se pausa hasta que llegue la respuesta, sin bloquear nada.
let (datos, respuesta) = try await URLSession.shared.data(from: url)
// La respuesta se ve como HTTPURLResponse para leer su código; 0 si no lo fuera.
let codigo = (respuesta as? HTTPURLResponse)?.statusCode ?? 0
print("código:", codigo)
// Un 404 NO lanza error: lo comprobamos nosotros.
guard (200...299).contains(codigo) else {
print("El servidor dijo que no")
return
}
let album = try JSONDecoder().decode(AlbumDTO.self, from: datos)
print(album.id, album.title)
} catch is URLError {
// Solo los URLError: no hubo respuesta (sin internet, servidor inexistente…).
print("Sin conexión")
} catch {
// Cualquier otro error: aquí, un JSON que no cuadra.
print("No se pudo leer:", error)
}
}
// En un Playground, como en un programa de Swift, `await` se puede escribir suelto.
await pedirAlbum(numero: 1, servidor: "https://jsonplaceholder.typicode.com")
await pedirAlbum(numero: 9999, servidor: "https://jsonplaceholder.typicode.com")
await pedirAlbum(numero: 1, servidor: "https://no-existe.invalid")código: 200
1 quidem molestiae enim
código: 404
El servidor dijo que no
Sin conexiónLas dos líneas del medio son el 404: llegó una respuesta, no saltó ningún catch y fue nuestro guard quien dijo que no. La última, Sin conexión, es el caso contrario: no hubo respuesta y salta el catch is URLError.
La dirección con parámetros: appending(queryItems:)
Escribir a mano ?_limit=3 funciona, pero si el valor viene de una variable hay que codificarlo: los espacios y los símbolos no pueden ir tal cual en una dirección y se escriben con un código (un espacio es %20, y «ó» es %C3%B3). URL lo hace por ti: url.appending(queryItems: [URLQueryItem(name: "title", value: "Ana López")]) añade ?title=Ana%20L%C3%B3pez. URLQueryItem es un par nombre-valor de la parte de después del ?. Lo usarás en el ejercicio.
Internet en iOS: sin permiso, pero con HTTPS
En Android, una app necesita declarar el permiso INTERNET en el manifiesto. En iOS no hay ningún permiso de internet: cualquier app puede conectarse. Lo que sí hay es ATS (App Transport Security, «seguridad del transporte de la app»): una regla del sistema que obliga a que las peticiones vayan por https:// y no por http://.
JSONPlaceholder sirve https://, así que no tendrás que tocar nada. Si una app pide una dirección http://, la petición falla, y en el simulador URLSession lanza un URLError con el código −1022:
The resource could not be loaded because the App Transport Security policy requires the use of a secure connection.(ATS solo vigila a las apps de iPhone. Si lanzas esa misma petición http:// desde un Playground o un programa del Mac, funciona; por eso el ejercicio no lo enseña y ATS lo verás dentro de la app.)
| Android | iOS | |
|---|---|---|
| Librería de red | Retrofit y OkHttp | URLSession |
| Permiso de internet | INTERNET en el manifiesto | Ninguno |
| Regla de HTTPS | http:// bloqueado por defecto desde Android 9 | ATS: http:// bloqueado por defecto |
Un 404 | Lanza HttpException | No lanza: se mira statusCode |
Reto · Ejercicio: las tres primeras tareas
En tu Playground (reutiliza el mismo y borra lo anterior), pide a https://jsonplaceholder.typicode.com/todos las tres primeras tareas, limitando el resultado con el parámetro _limit por medio de URLQueryItem. Cada tarea llega así (un objeto de una lista):
{ "userId": 1, "id": 1, "title": "delectus aut autem", "completed": false }- Escribe un DTO con los tres campos que vas a usar: el número, el título y si está hecha.
- Pide la dirección, comprueba que el código está entre 200 y 299 y, si no lo está, imprime «El servidor respondió» seguido del código y termina.
- Si todo va bien, decodifica la lista de tareas (
[TareaDTO].self) e imprime una línea por tarea con el formato «número. título · hecha» o «número. título · pendiente». - Con los dos
catchdel ejemplo anterior: si no hay conexión, imprime «Sin conexión»; si llega algo que no se puede leer, «No se pudo leer:» y el error.
Lo que debe salir con internet:
1. delectus aut autem · pendiente
2. quis ut nam facilis et officia qui · pendiente
3. fugiat veniam minus · pendientePara ver el mensaje sin conexión, cambia el servidor por una dirección que no existe, como https://no-existe.invalid.
Ver solución
import Foundation
struct TareaDTO: Decodable {
let id: Int
let title: String
let completed: Bool
}
func escribirTareas() async {
// `.appending(queryItems:)` añade `?_limit=3` a la dirección.
let url = URL(string: "https://jsonplaceholder.typicode.com/todos")!
.appending(queryItems: [URLQueryItem(name: "_limit", value: "3")])
do {
let (datos, respuesta) = try await URLSession.shared.data(from: url)
let codigo = (respuesta as? HTTPURLResponse)?.statusCode ?? 0
guard (200...299).contains(codigo) else {
print("El servidor respondió \(codigo)")
return
}
// `[TareaDTO].self`: el JSON es una LISTA de tareas, no una sola.
let tareas = try JSONDecoder().decode([TareaDTO].self, from: datos)
for tarea in tareas {
// `condicion ? a : b`: si la condición se cumple vale `a`, y si no, `b`.
print("\(tarea.id). \(tarea.title) · \(tarea.completed ? "hecha" : "pendiente")")
}
} catch is URLError {
// No hubo respuesta: sin internet, el servidor no existe…
print("Sin conexión")
} catch {
print("No se pudo leer:", error)
}
}
await escribirTareas()Con https://no-existe.invalid en lugar del servidor imprime «Sin conexión». El catch is URLError va antes del catch general: el primero que encaja gana.
Paso · El servicio de la API, sin pantalla
Objetivo: dos ficheros nuevos en Marcador, DTOsDelServidor.swift y ServicioDelTorneo.swift, con el DTO del árbitro y el servicio que hace el GET a /users. La app se ve exactamente igual y Cmd+B compila.
En el servicio de pruebas, los usuarios (GET /users, diez en total) hacen de árbitros de la liga. Su JSON tiene muchos campos; solo guardarás el número, el nombre, el correo y la ciudad (que está dentro de address).
La API de la liga la usarán los árbitros y la crónica, pero no es de ninguna de las dos. 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. Dentro de Core las carpetas se llaman por lo que hacen: Network es la de lo que habla con internet. Clic derecho sobre la carpeta Core (la de MarcadorApp, no la de MarcadorAppTests) → New Folder → Network. Después, clic derecho sobre Network → New File from Template… → Swift File → Next, escribe el nombre y pulsa Create, con solo el target MarcadorApp marcado. Con la Terminal: mkdir -p ~/Marcador-Swift/MarcadorApp/MarcadorApp/Core/Network && touch ~/Marcador-Swift/MarcadorApp/MarcadorApp/Core/Network/Nombre.swift (mkdir -p crea la carpeta si aún no existe).
Intenta escribirlos tú.
ArbitroDTO, un DTO que se puede decodificar, con el número, el nombre (name), el correo (email) y la dirección (address). La dirección es un tipo anidado,Direccion, que solo guarda la ciudad (city). Todo con los nombres exactos del JSON.ServicioDelTorneo, un protocolo con una sola función,arbitros(), que esasync throwsy devuelve una lista deArbitroDTO.ServicioJSONPlaceholder, unstructque cumple ese protocolo. Guarda la dirección base del servidor y laURLSessionque usa (por defecto, la compartida), y suarbitros()hace elGETa/usersconawait, comprueba que el código esté entre 200 y 299 y decodifica la lista. Si el código no es de éxito, lanzaURLError(.badServerResponse)(más adelante lo cambiarás por un error propio).
Para la dirección: base.appending(path: "users") cuelga users de la dirección base (es el hermano de appending(queryItems:): este añade un trozo de ruta). Cmd+B debe terminar en Build Succeeded.
Tres detalles del servicio que quizá no hayas visto:
- Valores por defecto en el
init.init(base: URL = …, sesion: URLSession = .shared)quiere decir «si quien me crea no da la dirección o la sesión, uso estas». AsíServicioJSONPlaceholder()basta, y las pruebas podrán dar otras..sharedes la sesión compartida que Apple ya trae; el punto delante es la forma corta deURLSession.shared, porque Swift sabe por el tipo desesionde qué tipo hablas. throw URLError(.badServerResponse). Hasta ahora solo atrapabas errores;throwlanza uno.URLErrores un tipo de Apple y.badServerResponsees uno de sus casos, «el servidor contestó algo que no vale». Lanzarlo aquí hace que quien llame vea un fallo si el código no es de éxito.- Una función
async throwsse llama contry await, como en el ejercicio.
El protocolo existe para lo mismo que el del repositorio de MVVM: el estado, el repositorio y el caso de uso: quien use el servicio dice qué quiere y no sabe cómo viaja la petición, así que en las pruebas se cambia por uno falso que no toca internet.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import Foundation
// DTO (Data Transfer Object): la forma EXACTA del JSON que manda el servidor, con sus nombres
// en inglés. Solo lo usa la capa de red; el resto de la app usa sus propios modelos.
//
// `Decodable`: `JSONDecoder` sabe convertir el JSON en este tipo. Las claves del JSON que no
// aparecen aquí (`username`, `phone`, `company`…) se IGNORAN sin dar error.
// Un usuario de `GET /users`, que en Marcador hace de árbitro de la liga.
struct ArbitroDTO: Decodable {
let id: Int
let name: String
let email: String
let address: Direccion
// El JSON trae la dirección como otro objeto dentro: `"address": { "city": "Gwenborough", … }`.
struct Direccion: Decodable {
let city: String
}
}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]
}
// 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] {
let url = base.appending(path: "users")
// `await`: la espera de la red no ocupa el hilo principal; la pantalla sigue respondiendo.
let (datos, respuesta) = try await sesion.data(from: url)
// 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 URLError(.badServerResponse) }
return try JSONDecoder().decode([ArbitroDTO].self, from: datos)
}
}Ejecuta con Cmd+R: la app se ve igual que antes, porque nadie llama todavía al servicio. Es normal que no haya nada nuevo en pantalla.
Punto de control
- Cmd+B termina en
Build Succeededcon los dos ficheros nuevos,DTOsDelServidor.swiftyServicioDelTorneo.swift. - Cmd+U sigue dando 41 pruebas en verde, 25 unitarias y 16 de interfaz: esta lección no añade ninguna. Si dejaste el ejercicio de la taquilla (XCTest y las pruebas del ViewModel) en
MarcadorAppTests/Ejercicios, salen 44: los ejercicios con pruebas suman a la cuenta. - El ejercicio del Playground imprime las tres tareas.
- La app se ve y se comporta igual que antes.
Comprueba lo que entendiste
7 preguntas · necesitas 5 para completar la lección
1.Una petición a un servidor devuelve el código
404. ¿Qué significa?2.Con
URLSession.shared.data(from:), el servidor contesta404. ¿Qué pasa en tu código?3.¿Para qué sirve un DTO separado del modelo de la app?
4.El JSON trae un campo,
isbn, que tuLibroDTOno declara. Al decodificarlo conJSONDecoder...5.¿Qué permiso de internet pide una app de iOS, y qué es ATS?
6.¿Qué comprueba
(200...299).contains(codigo)?7.Un
dotiene primerocatch is URLError { … }y debajocatch { … }. Salta unDecodingError. ¿Quécatchse ejecuta?