Saltar al contenido
K
Swift: el lenguajePaso a paso40 min de lectura · 10 min de máquina Básico

El JSON de Kotlin en Swift: recursos y errores de lectura

Al terminar: Leer en Swift el JSON que guardó Kotlin y manejar los errores de lectura.

Al terminar, Swift habrá leído el fichero que escribió tu versión de Kotlin. Generarás torneo.json con tu proyecto de Android Studio, lo copiarás a las pruebas del paquete Torneo y comprobarás que Swift reconstruye los tres equipos, los cuatro partidos y el capitán, y que calcula la tabla de ese torneo: Rayo FC 6 puntos, Toros 3 y Lobos 3. También sabrás qué dice Swift cuando un JSON no cuadra y cómo capturar cada caso.

El torneo del JSON de Kotlin no es el que guardaste y leíste en la lección anterior, el torneo de main.swift. Aquel tenía dos partidos y Lobos con 0 puntos; el de Kotlin tiene cuatro partidos y Lobos gana uno. Son datos distintos, y por eso la tabla cambia.

Importa porque es la prueba de que las dos mitades de Marcador hablan el mismo idioma: si el fichero lo entienden las dos versiones, cualquiera de ellas puede sustituir a la otra. Temas: recursos de un paquete, Bundle.module, los errores de lectura de JSONDecoder y los property wrappers como concepto.

Cuando el JSON no cuadra

Leer un JSON es fiarse de un texto que viene de fuera, y ese texto puede estar roto o no ser lo que esperabas. JSONDecoder lo dice lanzando un DecodingError, un enum con un caso por cada tipo de problema. Los tres que vas a ver:

CasoCuándo ocurreLo que trae
keyNotFoundfalta un campo que el tipo exigela clave que falta y el contexto
typeMismatchel campo existe, pero con otro tipo (un texto donde se esperaba un número)el tipo que esperaba y el contexto
dataCorruptedel texto no es JSON, o un valor no tiene sentidosolo el contexto

El contexto (context) dice dónde ocurrió el problema: el Path que ves al imprimir el error, como partidos[0].eventos[0].tipo. Con un catch con patrón, como el de Errores: throws, try y do/catch, puedes distinguir los casos y sacar sus datos: catch DecodingError.keyNotFound(let clave, _) captura solo ese caso y guarda la clave en clave; el _ ignora el contexto (si lo quieres, escribe let contexto en su lugar). Una clave es un CodingKey, y su stringValue es el nombre del campo, como texto. El contexto trae además codingPath: la lista de claves desde la raíz del JSON hasta el problema.

ejemplo mínimo: qué campo falla (con comentarios)
import Foundation
 
struct Receta: Codable { var nombre: String; var minutos: Int }
 
let textos = [
    #"{"nombre":"Arepa","minutos":15}"#,      // bien
    #"{"nombre":"Sopa"}"#,                    // falta «minutos»
    #"{"nombre":"Flan","minutos":"mucho"}"#,  // «minutos» no es un número
    #"{nombre"#,                              // ni siquiera es JSON
]
for texto in textos {
    do {
        let receta = try JSONDecoder().decode(Receta.self, from: Data(texto.utf8))
        print("Leída: \(receta.nombre), \(receta.minutos) min")
    } catch DecodingError.keyNotFound(let clave, _) {
        // `clave.stringValue` es el nombre del campo que falta, como texto.
        print("Falta el campo \(clave.stringValue)")
    } catch DecodingError.typeMismatch(let tipo, _) {
        // `tipo` es el tipo que esperaba el campo.
        print("Un campo no es del tipo \(tipo)")
    } catch {
        // Cualquier otro error: aquí, el texto roto.
        print("No es JSON")
    }
}

Lo que imprime:

Leída: Arepa, 15 min
Falta el campo minutos
Un campo no es del tipo Int
No es JSON

Con el código de la lección anterior, estos tres JSON incorrectos (un evento con "tipo": "penalti", un equipo sin nombre y el texto {no es json) dan, al imprimir el error con print(error), algo como esto. CodableTests, que copiarás más abajo, cubre el primero, el tercero y un equipo inexistente:

DecodingError.dataCorrupted: Data was corrupted. Path: partidos[0].eventos[0].tipo. Debug description: Tipo de evento desconocido: penalti
DecodingError.keyNotFound: Key 'nombre' not found in keyed decoding container. Path: equipos[0]. Debug description: No value associated with key CodingKeys(stringValue: "nombre", intValue: nil) ("nombre").
DecodingError.dataCorrupted: Data was corrupted. Debug description: The given data was not valid JSON.. Underlying error: Error Domain=NSCocoaErrorDomain Code=3840 "Unexpected character 'n' around line 1, column 3." UserInfo={…}

El primero sale del default de tu switch; el segundo, de un campo que falta; el tercero, de un texto que ni siquiera es JSON. El Path dice dónde está el problema. Un equipo que no existe no es un DecodingError sino un ErrorDeCarga, que lanza tu propio código.

Cuando el fallo está dentro de una lista, codingPath dice en qué elemento. Cada elemento de la lista aparece como Index 0, Index 1… (empieza en 0):

ejemplo mínimo: en qué elemento falla (con comentarios)
import Foundation
 
struct Producto: Codable { var nombre: String; var precio: Int }
struct Pedido: Codable { var cliente: String; var productos: [Producto] }
 
// El segundo producto no trae «precio».
let texto = #"{"cliente":"Ana","productos":[{"nombre":"Pan","precio":3},{"nombre":"Leche"}]}"#
do {
    _ = try JSONDecoder().decode(Pedido.self, from: Data(texto.utf8))
} catch DecodingError.keyNotFound(let clave, let contexto) {
    // `codingPath` es la lista de claves; a cada una se le pide su texto y se unen con « > ».
    let camino = contexto.codingPath.map(\.stringValue).joined(separator: " > ")
    print("Falta \(clave.stringValue) en: \(camino)")
} catch {
    print("Otro problema")
}

Imprime Falta precio en: productos > Index 1: el campo precio falta en el elemento 1 de productos, o sea, el segundo producto. En keyNotFound el camino llega hasta el elemento y la clave que falta viene aparte; en typeMismatch el camino ya incluye el campo que no cuadra.

Property wrappers (para más adelante)

Un property wrapper (envoltorio de propiedad) es un tipo que se pone delante de una propiedad con @ para que controle cómo se guarda y se lee. El código de Torneo no usa ninguno, pero lo verás más adelante en SwiftUI, donde varias piezas del conjunto de herramientas de Apple para crear pantallas se escriben así; por eso conviene saber qué son.

ejemplo mínimo: property wrapper (con comentarios)
// `@propertyWrapper` marca un tipo como envoltorio.
@propertyWrapper
struct Limitado {
    private var valor = 0
    // `wrappedValue` es lo que ve quien lee y escribe la propiedad envuelta.
    // `get` se ejecuta al leerla y devuelve el valor guardado; `set` se ejecuta
    // al escribirla, y `newValue` es lo que el código intenta guardar.
    var wrappedValue: Int {
        get { valor }
        // Aquí se recorta: `max(…, 0)` evita negativos y `min(…, 99)` pasa de 99.
        set { valor = min(max(newValue, 0), 99) }
    }
}
 
struct Camiseta {
    @Limitado var dorsal: Int      // cada lectura y escritura pasa por `Limitado`
}
 
var camiseta = Camiseta()
camiseta.dorsal = 150
print(camiseta.dorsal)   // 99

Imprime 99: el 150 que se intentó guardar se recortó. wrappedValue es el único requisito. El código de recortar vive una vez, en Limitado, y se reutiliza en cualquier propiedad; es la misma idea que by lazy en Kotlin (Delegación: by lazy): otro objeto lleva el get y el set.

Reto · Ejercicio: en qué libro falla

En tu Playground, escribe un tipo Libro, con un titulo de texto y unas paginas enteras, y un tipo Estante, con un nombre y una lista de libros; los dos se pueden leer de JSON. Lee estos tres textos:

  • {"nombre":"Sala","libros":[{"titulo":"Atlas","paginas":120},{"titulo":"Mapas"}]}
  • {"nombre":"Sala","libros":[{"titulo":"Atlas","paginas":"muchas"}]}
  • {"nombre":"Sala","libros":[{"titulo":"Atlas","paginas":120},{"titulo":"Mapas","paginas":80}]}

Por cada uno imprime el estante leído (nombre y cuántos libros); o, si falta un campo, su nombre y el camino hasta él; o, si un campo tiene otro tipo, el tipo que esperaba y su camino. Las claves del camino se unen con « > ». Lo que debe salir:

Falta paginas en: libros > Index 1
Esperaba Int en: libros > Index 0 > paginas
Leído: Sala, 2 libros

Para pensar: ¿por qué en el primer caso el camino acaba en Index 1 y en el segundo en paginas?

Ver solución
solución: en qué libro falla
import Foundation
 
struct Libro: Codable { var titulo: String; var paginas: Int }
struct Estante: Codable { var nombre: String; var libros: [Libro] }
 
let textos = [
    #"{"nombre":"Sala","libros":[{"titulo":"Atlas","paginas":120},{"titulo":"Mapas"}]}"#,
    #"{"nombre":"Sala","libros":[{"titulo":"Atlas","paginas":"muchas"}]}"#,
    #"{"nombre":"Sala","libros":[{"titulo":"Atlas","paginas":120},{"titulo":"Mapas","paginas":80}]}"#,
]
for texto in textos {
    do {
        let estante = try JSONDecoder().decode(Estante.self, from: Data(texto.utf8))
        print("Leído: \(estante.nombre), \(estante.libros.count) libros")
    } catch DecodingError.keyNotFound(let clave, let contexto) {
        let camino = contexto.codingPath.map(\.stringValue).joined(separator: " > ")
        print("Falta \(clave.stringValue) en: \(camino)")
    } catch DecodingError.typeMismatch(let tipo, let contexto) {
        let camino = contexto.codingPath.map(\.stringValue).joined(separator: " > ")
        print("Esperaba \(tipo) en: \(camino)")
    } catch {
        print("No es JSON")
    }
}

La respuesta: con keyNotFound el campo no existe, así que el camino llega solo hasta el libro que lo debía tener (Index 1) y la clave que falta viene aparte. Con typeMismatch el campo existe, y el camino ya lo incluye (paginas).

Paso 1 · Lee el torneo.json de Kotlin

Objetivo. Que una prueba lea el torneo.json que escribe tu proyecto Kotlin y compruebe que Swift entiende el mismo fichero. Es lo que da sentido al formato: dos programas, un contrato.

Torneo
Package.swiftse modifica
Tests
TorneoTests
Fixtures
torneo.jsonse crea (copia del de Kotlin)
CodableTests.swiftse crea y se rellena con «Copiar fichero»
Lo que tocas en este paso: una carpeta con el JSON, un ajuste en el manifiesto y un test.

1 · Genera el JSON con Kotlin

En Android Studio, con el proyecto Marcador abierto, abre la Terminal (la pestaña Terminal de la barra inferior, la misma de Tu primer proyecto en Kotlin). Se abre en la carpeta del proyecto. Ejecuta:

./gradlew :torneo:run --args=kotlin

Esa orden arranca la función main de tu :torneo, y --args=kotlin es la forma que tiene Gradle de pasarle la palabra kotlin (llega al parámetro args de main). Qué fichero hace qué, tal como los dejaste en El partido, minuto a minuto:

  • Main.kt tiene main. Si args contiene kotlin, llama a demoKotlin() y termina; si no, ejecuta las demos de corrutinas.
  • DemoKotlin.kt es tu antiguo Main.kt de las primeras lecciones, ahora con la función demoKotlin(). Es la que construye el torneo con tres equipos y cuatro partidos y lo guarda en torneo/torneo.json, dentro de la carpeta del módulo.

Comprueba que hay tres equipos: grep -c '"plantilla"' torneo/torneo.json cuenta las líneas del fichero que contienen la palabra "plantilla" (hay una por equipo) e imprime 3. Y una advertencia: las demos de corrutinas (./gradlew :torneo:run sin argumento) también escriben un torneo/torneo.json, pero con cuatro equipos y dos partidos. Si lo ejecutas sin el argumento, vuelve a lanzar la orden de arriba antes de copiar el fichero. Después de copiarlo en el paso siguiente, tu copia de Swift ya no depende de lo que escriba Gradle.

2 · Cópialo al paquete de Swift

Desde esa misma Terminal, crea la carpeta Fixtures (los datos fijos de una prueba) en las pruebas de tu paquete y copia el fichero. ~ es tu carpeta personal:

mkdir -p ~/Marcador-Swift/Torneo/Tests/TorneoTests/Fixtures
cp torneo/torneo.json ~/Marcador-Swift/Torneo/Tests/TorneoTests/Fixtures/torneo.json

En Xcode aparece Fixtures dentro de Tests → TorneoTests (si no se ve enseguida, cierra y vuelve a abrir el paquete). Las pruebas dependen de cómo es el fichero:

extracto del torneo.json de Kotlin
{
    "nombre": "Copa Barrio",
    "equipos": [
        {
            "nombre": "Rayo FC",
            "plantilla": [ { "nombre": "Ana", "dorsal": 9 }, { "nombre": "Luis" } ],
            "capitan": "Ana"
        }
    ],
    "partidos": [
        { "local": "Rayo FC", "visitante": "Toros", "eventos": [ { "tipo": "gol", "minuto": 12, "jugador": "Ana", "equipo": "Rayo FC" } ] }
    ]
}

(El extracto está compactado; el fichero real tiene un campo por línea.) Dos detalles: Luis no tiene dorsal, y el evento lleva "tipo": "gol". Los dos son el contrato que Swift tiene que respetar.

Si no consigues generarlo, puedes usar este, que es el mismo. Crea el fichero vacío con touch ~/Marcador-Swift/Torneo/Tests/TorneoTests/Fixtures/torneo.json (después del mkdir de arriba), ábrelo con doble clic en el Project navigator, pulsa el botón, selecciona todo con Cmd+A y pega con Cmd+V.

torneo.json

3 · Instala el recurso en Package.swift

Una prueba que quiere leer un fichero necesita saber dónde está cuando se ejecuta, y el fichero no viaja solo con el paquete: hay que declararlo. Un recurso (resource) es un fichero que no es código pero que se copia junto con un objetivo. Es la maleta de un viaje: sin anotarla en la lista, no sube al avión.

Son tres piezas, de la más sencilla a la que usarás en el test:

  1. resources: [...] es un argumento más de .testTarget, una lista de los recursos del objetivo. Cada uno se declara con .copy("Fixtures") (copia la carpeta tal cual, con su nombre) o con .process(...) (deja que SwiftPM la transforme para la plataforma, algo útil con imágenes). Con un JSON, .copy es lo más claro.
  2. Bundle es, en Foundation, «una carpeta con recursos de un programa». Para un paquete con recursos, SwiftPM genera solo un Bundle.module: la carpeta donde dejó los recursos del objetivo. Si el objetivo no declara recursos, ese Bundle.module no existe.
  3. Cómo lo usará el test: pide la URL de un recurso por su nombre y la lee. No lo escribes tú todavía (viene en el fichero de más abajo); léelo solo para ver la forma:
cómo encuentra el test el JSON (solo para leer)
// `Bundle.module.url(...)` devuelve la dirección del fichero dentro de los
// recursos, o `nil` si no está; por eso el resultado es opcional.
let url = Bundle.module.url(forResource: "torneo", withExtension: "json", subdirectory: "Fixtures")
// Con la dirección se leen sus bytes, igual que en la lección anterior. El `!` afirma
// que no es `nil` (el test real comprueba antes que no sea `nil`).
let datos = try Data(contentsOf: url!)

Antes de tocar nada, mira qué dice SwiftPM. En la Terminal, desde ~/Marcador-Swift/Torneo, ejecuta xcrun swift build --build-tests. Termina con Build complete!, pero avisa:

warning: 'torneo': found 1 file(s) which are unhandled; explicitly declare them as resources or exclude from the target

Es la pista: SwiftPM encontró en Tests/TorneoTests un fichero que no sabe qué hacer con él (el torneo.json). Se arregla declarándolo.

Ya conoces la receta de tocar el manifiesto de Closures; aquí cambia otra parte: el objetivo de pruebas. Abre Package.swift (en la raíz de Torneo, en el Project navigator). Busca el bloque que empieza en .testTarget( y termina en su ),, cuatro líneas, y sustitúyelo entero, dejando el resto del fichero igual. Intenta escribirlo tú: el mismo objetivo de pruebas, con un argumento más, sus recursos, que copian tal cual la carpeta Fixtures. Lo que debes ver al volver a compilar las pruebas: Build complete! sin el aviso de fichero sin declarar.

Package.swift: antes (el bloque que sustituyes)
        .testTarget(
            name: "TorneoTests",
            dependencies: ["Torneo"]
        ),
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Torneo/Package.swift
        // `resources`: ficheros que viajan con las pruebas. `.copy("Fixtures")` copia la
        // carpeta tal cual y se lee con `Bundle.module`, que SwiftPM genera solo cuando
        // hay recursos. Ahí vive el `torneo.json` que escribió la versión Kotlin.
        .testTarget(
            name: "TorneoTests",
            dependencies: ["Torneo"],
            resources: [.copy("Fixtures")]
        ),

Los argumentos de .testTarget van siempre en este orden: name, dependencies, resources. Al guardar, Xcode vuelve a leer el manifiesto solo. Comprueba, desde ~/Marcador-Swift/Torneo: xcrun swift build --build-tests termina con Build complete! y el aviso de arriba ya no aparece.

4 · Los tests

CodableTests.swift comprueba que Swift lee el JSON de Kotlin y la ida y vuelta. Usa Swift Testing, que ya conoces de La regla de los puntos y tu primer test, así que se copia, con el mismo orden de siempre: crear el fichero, pulsar el botón, seleccionar todo, pegar.

  1. En el Project navigator, clic derecho sobre Tests → TorneoTests → New File from Template… → Swift File → Next, escribe CodableTests y pulsa Create. (Con la Terminal: touch Tests/TorneoTests/CodableTests.swift.)
  2. Pulsa el botón de abajo.
  3. Haz clic dentro de CodableTests.swift, selecciona todo con Cmd+A y pega con Cmd+V.
CodableTests.swift

Después, desde ~/Marcador-Swift/Torneo:

xcrun swift test

Punto de control

  • xcrun swift build (desde ~/Marcador-Swift/Torneo) termina sin errores.
  • xcrun swift run torneo-cli termina con Cargado: Copa Barrio, 2 partidos, líder Rayo FC, y no deja ningún torneo-swift.json en la carpeta.
  • xcrun swift test termina con Test run with 89 tests in 11 suites passed: 11 más que en la lección anterior (78), los de CodableTests.
  • Provócalo: xcrun swift test --filter calculaLosMismosPuntosQueKotlin ejecuta solo esa prueba. Pasa: desde el JSON de Kotlin, Swift calcula Rayo FC 6, Toros 3 y Lobos 3. Ahora abre Fixtures/torneo.json, cambia el primer "tipo": "gol" por "tipo": "penalti" y repite el comando: falla con Tipo de evento desconocido: penalti, el default de tu switch. Devuelve "gol" y comprueba que pasa otra vez.
  • En el Project navigator ves Fixtures → torneo.json dentro de Tests → TorneoTests, y Package.swift declara resources: [.copy("Fixtures")].
  • Sabes decir qué DecodingError sale cuando falta un campo y cuál cuando un campo tiene otro tipo, y qué te dice codingPath.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.Tus pruebas usan Bundle.module y el compilador dice type 'Bundle' has no member 'module'. ¿Qué falta?

  2. 2.Con @Limitado var dorsal: Int, ¿qué significa @Limitado?

  3. 3.Decodificas {"titulo":"Mapas"} como un Libro que exige titulo y paginas (un Int). ¿Qué error lanza JSONDecoder?

  4. 4.En catch DecodingError.keyNotFound(let clave, _), ¿cómo sacas el nombre del campo que falta, como texto?

  5. 5.En el torneo.json de Kotlin, Luis no tiene dorsal. ¿Qué pasa al leerlo en Swift con var dorsal: Int? en JugadorGuardado?

  6. 6.Decodificas un Estante con una lista libros y el segundo libro no trae paginas. En catch DecodingError.keyNotFound(let clave, let contexto), ¿qué es contexto.codingPath?

Faltan 6 de 6