Del JSON al Pokémon: DTO y repositorio
Al terminar: Convertir el JSON de PokeAPI en un DTO y entregar al resto de la app el modelo de dominio desde un repositorio.
La lección anterior dejó un cliente de red listo, pero sin nada que pedirle. Al terminar esta, la app tendrá una capa de datos entera, escrita una sola vez en commonMain: unas clases que describen el JSON de PokeAPI, una interfaz que dice qué se le pide y un repositorio que devuelve ya traducido a Pokemon, como el de Marcador pero escrito una sola vez para las dos plataformas. Todavía no se ve en pantalla (eso llega con La lista en Android y La lista en iOS), pero queda probada con tests.
Hay una sorpresa del mundo real por el camino: PokeAPI no da en el listado los datos que hacen falta para pintar una tarjeta, y completarlos cuesta veintiuna peticiones por página.
Todas las rutas de esta lección se dan en la vista Project del panel de la izquierda (las carpetas tal como están en el disco). Si el selector de arriba del panel dice Android, cámbialo a Project.
DTO frente a dominio, otra vez con PokeAPI
Aquí es lo mismo, con dos matices propios de este proyecto:
- El DTO es
internal: fuera desharednadie tiene por qué saber que PokeAPI existe, ni la app de Android ni la de iOS. La traducción aPokemon(el modelo de dominio de la Pokédex) vive en un solo sitio, y si PokeAPI renombra un campo, se toca ese sitio y no las pantallas. - Aquí el repositorio tiene una sola fuente, PokeAPI. Las dos fuentes (red y disco) que viste en Dos fuentes: el almacén y el repositorio y su gemela de iOS no hacen falta en la Pokédex: la lista se pide siempre a PokeAPI, y los favoritos de Persistencia con Room 3 son solo locales.
Paso 1 · El JSON del listado
El archivo. Hacen falta dos ficheros, uno por respuesta de PokeAPI, en un paquete nuevo dto. Empieza por el del listado: PokemonPageDto.kt.
Créalo. Clic derecho sobre la carpeta shared (la de la raíz del proyecto) → New → File y escribe la ruta que sigue a partir de shared, como en El módulo compartido y la prueba de humo. En todos los ficheros nuevos de esta lección, primero va la línea package y después los import.
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/dto/PokemonPageDto.ktpackage com.sebastiannarvaez.pokedex.data.network.dto
import kotlinx.serialization.Serializable
// La respuesta del listado, tal cual llega. Es un DTO («objeto de transferencia
// de datos»): describe el JSON, no lo que la app necesita.
// `@Serializable` pide al plugin que genere el código para leerlo.
@Serializable
internal data class PokemonPageDto(
// Cuántos Pokémon hay en total (1351).
val count: Int,
// La dirección de la página siguiente; `null` en la última. Con valor por
// defecto: si el campo falta, no falla.
val next: String? = null,
// Los Pokémon de esta página: solo nombre y dirección.
val results: List<PokemonRefDto> = emptyList(),
)
// Una fila del listado: el nombre y la dirección del detalle.
@Serializable
internal data class PokemonRefDto(
val name: String,
// Termina en `/pokemon/25/`: ahí está el identificador.
val url: String,
) {
// No viene en el JSON: se calcula cada vez que se lee (es una propiedad
// con `get()`). Quita la barra final, se queda con lo que hay detrás de la
// última barra y lo pasa a número (o `null` si no lo es).
val id: Int?
get() = url.trimEnd('/').substringAfterLast('/').toIntOrNull()
}Pruébalo con un test mínimo. Crea src/commonTest/kotlin/com/sebastiannarvaez/pokedex/data/network/dto/PokemonPageDtoTest.kt (clic derecho sobre shared → New → File, igual que antes):
package com.sebastiannarvaez.pokedex.data.network.dto
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
class PokemonPageDtoTest {
// Un lector de JSON que ignora los campos que la clase no declara: el mismo
// ajuste que lleva el cliente.
private val json = Json { ignoreUnknownKeys = true }
@Test
fun leeElListadoYIgnoraLoQueSobra() {
// Entre tres comillas (""") cabe un texto de varias líneas con sus propias
// comillas, tal cual. El campo "sobra" no existe en PokemonPageDto.
// `trimIndent()` quita la sangría común de todas las líneas.
val texto = """
{ "count": 1351, "next": null, "sobra": true,
"results": [ { "name": "bulbasaur", "url": "https://pokeapi.co/api/v2/pokemon/1/" } ] }
""".trimIndent()
// Convierte el texto en un PokemonPageDto.
val pagina = json.decodeFromString<PokemonPageDto>(texto)
assertEquals(1351, pagina.count)
// `single()` devuelve el único elemento de la lista (y falla si hay más o ninguno).
assertEquals("bulbasaur", pagina.results.single().name)
}
@Test
fun elIdentificadorSaleDeLaUrl() {
// La dirección termina en «/25/»: el 25 es el identificador.
val pikachu = PokemonRefDto(name = "pikachu", url = "https://pokeapi.co/api/v2/pokemon/25/")
assertEquals(25, pikachu.id)
}
}Ejecútalo en la JVM, en Android y en el simulador de iOS: ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test. Debe acabar en BUILD SUCCESSFUL. Es el mismo test en tres plataformas; lo que cambia es quién lo ejecuta.
Paso 2 · El JSON del detalle
Y el segundo DTO, el del detalle. Solo declara cinco campos: de los 272 KB que llegan, se describen esos y el resto lo tira ignoreUnknownKeys. Crea el fichero igual que el anterior:
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/dto/PokemonDetailDto.ktpackage com.sebastiannarvaez.pokedex.data.network.dto
import kotlinx.serialization.Serializable
// El detalle de un Pokémon. La respuesta real pesa 272 KB; aquí solo se
// declaran los campos que la app usa, y `ignoreUnknownKeys` tira el resto.
@Serializable
internal data class PokemonDetailDto(
val id: Int,
val name: String,
// En decímetros, no en metros: PokeAPI mide raro. Con valor por defecto,
// porque si falta no es motivo para romper la tarjeta.
val height: Int = 0,
// En hectogramos.
val weight: Int = 0,
// Los tipos, cada uno con su posición (`slot`).
val types: List<TypeSlotDto> = emptyList(),
)
// Un tipo con su posición: el primero (`slot` 1) es el principal.
@Serializable
internal data class TypeSlotDto(
val slot: Int = 0,
val type: NamedRefDto,
)
// Una referencia con nombre: del recurso solo nos interesa el nombre ("grass").
@Serializable
internal data class NamedRefDto(val name: String)Paso 3 · La frontera, en una interfaz
El archivo. PokeApi.kt dice qué necesita la app de PokeAPI (dos preguntas) y trae su implementación con Ktor. Es la única pieza que sabe que hay un cliente HTTP debajo. Ruta:
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/PokeApi.ktpackage com.sebastiannarvaez.pokedex.data.network
import com.sebastiannarvaez.pokedex.data.network.dto.PokemonDetailDto
import com.sebastiannarvaez.pokedex.data.network.dto.PokemonPageDto
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
// Lo que la app necesita de PokeAPI, en una interfaz. Así las capas de arriba
// dependen de la interfaz, y los tests pueden darles una falsa sin red.
internal interface PokeApi {
// Una página del listado: `limit` Pokémon a partir del `offset`.
suspend fun page(limit: Int, offset: Int): PokemonPageDto
// El detalle de un Pokémon por su identificador.
suspend fun detail(id: Int): PokemonDetailDto
}
// La implementación real, con Ktor. Recibe el cliente ya configurado.
internal class KtorPokeApi(private val client: HttpClient) : PokeApi {
override suspend fun page(limit: Int, offset: Int): PokemonPageDto =
// GET a «pokemon» (se une a la dirección base) …
client.get("pokemon") {
// … con `?limit=…&offset=…` añadido a la dirección.
parameter("limit", limit)
parameter("offset", offset)
// `.body()` lee la respuesta y la convierte en un PokemonPageDto.
}.body()
override suspend fun detail(id: Int): PokemonDetailDto =
// GET a «pokemon/25», por ejemplo.
client.get("pokemon/$id").body()
}client.get(…) { … } y .body() son las mismas funciones de Ktor del ejemplo de la lección anterior: la primera hace la petición y la segunda convierte la respuesta, usando lo que instalaste en ContentNegotiation. Si body o parameter salen en rojo, añade los imports con Option+Enter.
Paso 4 · El repositorio, y el N+1 que se asume
El archivo. PokemonRepository.kt es la pieza que traduce: pide a PokeApi y devuelve Pokemon. Ruta:
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/PokemonRepository.ktpackage com.sebastiannarvaez.pokedex.data
import com.sebastiannarvaez.pokedex.core.AppDispatchers
import com.sebastiannarvaez.pokedex.data.network.PokeApi
import com.sebastiannarvaez.pokedex.domain.Pokemon
import com.sebastiannarvaez.pokedex.domain.PokemonType
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
// Traduce lo que da PokeAPI (DTO) a lo que la app necesita (`Pokemon`).
// Recibe la interfaz `PokeApi`, no Ktor: no sabe de dónde salen los datos.
internal class PokemonRepository(
private val api: PokeApi,
// Quién decide en qué hilo corre cada trabajo (lo creaste con las corrutinas).
private val dispatchers: AppDispatchers,
) {
// Una página de Pokémon ya completos. El listado no trae tipos ni imagen,
// así que hay que pedir también el detalle de cada uno: son 1 + 20 peticiones.
suspend fun page(limit: Int, offset: Int): List<Pokemon> = withContext(dispatchers.io) {
// La petición del listado.
val pagina = api.page(limit = limit, offset = offset)
// `coroutineScope` abre un alcance que no termina hasta que acaben todas
// las corrutinas lanzadas dentro; si una falla, cancela las demás.
coroutineScope {
pagina.results
// El identificador sale de la dirección; si no sale, esa fila se descarta.
.mapNotNull { it.id }
// Una corrutina por Pokémon, para pedir los veinte detalles a la vez.
.map { id -> async { pokemon(id) } }
// Espera a las veinte y junta los resultados, en el mismo orden.
.awaitAll()
}
}
// Un Pokémon completo: pide el detalle y lo traduce al modelo de dominio.
suspend fun pokemon(id: Int): Pokemon = withContext(dispatchers.io) {
val detalle = api.detail(id)
Pokemon(
id = detalle.id,
name = detalle.name,
types = detalle.types
// Primero el tipo principal (`slot` 1).
.sortedBy { it.slot }
// `deApi` devuelve `null` si el tipo no lo conocemos; `mapNotNull`
// lo descarta en vez de romper la tarjeta.
.mapNotNull { PokemonType.deApi(it.type.name) },
)
}
}Una página de veinte cuesta veintiuna peticiones: una de listado y veinte de detalle. Es el llamado N+1: una petición para la lista y N más, una por elemento. No es un descuido, es la forma de la API, y las veinte van a la vez para que la espera no se multiplique por veinte.
Pruébalo, sin red. El repositorio recibe una interfaz, así que un test le da una PokeApi falsa que cuenta sus llamadas. Es un fake, como la API falsa de la crónica en La pantalla de la crónica y sus pruebas: una pieza de mentira que cumple el mismo contrato (la diferencia entre fake y mock está en Tests de un ViewModel: fakes, setMain y Turbine). Este fichero trae uno, que verás enseguida.
Este test es largo y no enseña nada nuevo de Kotlin, así que no lo escribes: lo copias con el botón Copiar fichero, que aparece debajo. El botón no toca tu proyecto: pone el contenido del fichero en el portapapeles. El orden es siempre el mismo: crea el fichero vacío (clic derecho sobre shared → New → File → src/commonTest/kotlin/com/sebastiannarvaez/pokedex/data/PokemonRepositoryTest.kt), pulsa el botón, selecciona todo el contenido del fichero (Cmd+A) y pega (Cmd+V).
Antes de ejecutarlo, ábrelo y mira el doble, que es lo único nuevo. Es un trozo de lo que acabas de pegar (solo para leer):
// Una PokeApi de mentira: da los nombres que le pases y apunta cuántas veces
// le piden un detalle (`private set`: solo ella cambia ese contador).
private class FakePokeApi(
private val nombres: List<String>,
private val tipos: Map<Int, List<String>> = emptyMap(),
) : PokeApi {
var llamadasAlDetalle = 0
private set
override suspend fun detail(id: Int): PokemonDetailDto {
llamadasAlDetalle++
// … devuelve un DTO con ese id, su nombre y sus tipos.
}
}
// Un AppDispatchers de mentira: los tres trabajos van al mismo dispatcher de prueba.
private class TestDispatchers(private val d: CoroutineDispatcher) : AppDispatchers { /* io, default, main = d */ }
// En cada test se usa así: el repositorio real con la API y los dispatchers de mentira.
val repo = PokemonRepository(api, TestDispatchers(StandardTestDispatcher(testScheduler)))StandardTestDispatcher(testScheduler) y runTest son los de Tests de corrutinas: tiempo virtual: el reloj de pruebas manda sobre todo lo que lance el repositorio. En Testing del núcleo compartido se explica cuándo conviene que un doble esté en un fichero compartido.
Son cuatro tests: la página llega traducida a Pokemon; la ilustración sale del identificador de la URL (con el doble, el id de cada Pokémon se calcula a partir de su posición); pedir cuatro Pokémon cuesta exactamente cuatro peticiones de detalle (el N+1 queda escrito para que nadie lo descubra mirando el tráfico); y un tipo desconocido ("cosmico") no rompe la tarjeta. Ejecuta ./gradlew :shared:jvmTest: sigue en BUILD SUCCESSFUL.
Paso 5 · Al grafo, y el test que se puso rojo
Abre shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt. Dentro de module { … }, debajo de single { Pokedex(get()) } y encima de viewModelOf, las cuatro piezas nuevas. Después añade los imports con Option+Enter sobre cada nombre en rojo:
// La configuración, como interfaz: quien la pida recibe la de verdad.
single<AppConfig> { DefaultAppConfig() }
// El cliente HTTP, creado una vez; `get()` le da el AppConfig de arriba.
single { createHttpClient(get()) }
// La API de PokeAPI, pedida por su interfaz.
single<PokeApi> { KtorPokeApi(get()) }
// El repositorio: recibe la API y los dispatchers, ya registrados.
single { PokemonRepository(get(), get()) }Ejecuta ./gradlew :shared:jvmTest. El test que verifica el grafo entero, que hasta ahora estaba en verde, falla:
MissingKoinDefinitionException: Missing definition for
'[field:'engine' - type:'io.ktor.client.engine.HttpClientEngine']'
in definition '[Singleton: 'io.ktor.client.HttpClient']'.// antes
pokedexModule.verify()
// después. `extraTypes` declara lo que el grafo no construye pero existe:
// «esto existe, pero no sale de aquí». `HttpClientEngine::class` es «el tipo
// HttpClientEngine» como valor (la clase misma, no un objeto de esa clase).
// Hace falta el import io.ktor.client.engine.HttpClientEngine (Option+Enter).
pokedexModule.verify(extraTypes = listOf(HttpClientEngine::class))Vuelve a ejecutar ./gradlew :shared:jvmTest: en verde otra vez.
Punto de control
./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Testtermina enBUILD SUCCESSFUL, con seis tests más en cada plataforma que antes de la lección (dos del DTO y cuatro del repositorio) y el del grafo en verde. En total, con tus ejercicios: 16 en la JVM, 15 en Android y 15 en iOS; sin ellos: 12, 11 y 11. Los «ejercicios» son los de las lecciones anteriores y suman tests (los de los que hayas hecho), y la JVM tiene uno más que Android porqueModulesTestsolo corre en la JVM../gradlew :androidApp:assembleDebugtermina enBUILD SUCCESSFUL.- Existen
PokemonPageDto.kt,PokemonDetailDto.kt,PokeApi.ktyPokemonRepository.kt, y el grafo de Koin registraAppConfig, el cliente,PokeApiy el repositorio. - Ningún DTO ha salido del paquete
dto: lo que llega a las capas de arriba esPokemon.
La lección siguiente, Testing del núcleo compartido, prueba el cliente de red entero con un servidor de mentira.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Por qué
PokemonRepositorydevuelvePokemony noPokemonDetailDto, si los dos tienen casi los mismos datos?2.¿Por qué pintar una página de veinte Pokémon cuesta veintiuna peticiones a PokeAPI?
3.Quitas el
= 0deheightenPokemonDetailDtoy lees un JSON que no trae ese campo. ¿Qué ocurre?4.Una clase
@Serializablecompila bien, pero al leer JSON falla con «Serializer for class … is not found». ¿Qué has olvidado?5.El grafo declara
single { createHttpClient(get()) }, que no recibe ningún motor, y aun asíverify()pide unHttpClientEngine. ¿Por qué?