HTTP y Ktor: el cliente de PokeAPI
Al terminar: Entender qué es HTTP y pedir la primera página de PokeAPI con Ktor desde código compartido.
Hasta ahora la app sabía decir en qué plataforma corre. Al terminar esta lección tendrá un cliente de red escrito una sola vez en commonMain, listo para pedir cosas a PokeAPI desde Android y desde iOS. Todavía no pide nada: eso llega en Del JSON al Pokémon: DTO y repositorio, la lección siguiente, que le da forma a lo que llega. Los Pokémon reales no los verás en pantalla hasta La lista en Android y La lista en iOS; antes se prueba todo con dobles (piezas de mentira, como la API falsa de Marcador), sin tocar internet.
Pedir cosas a un servidor ya lo hiciste en Marcador, en Android y en iOS. Aquí no se repite: se recuerda lo justo, se mira lo que contesta PokeAPI, y se instala Ktor, la librería de red que sirve a las dos plataformas a la vez, para montar el cliente en un solo sitio. Cerrarás con una línea del manifiesto de Android.
Lo que ya sabes de HTTP, y lo que es de PokeAPI
Lo propio de PokeAPI es la dirección y cómo pagina. En https://pokeapi.co/api/v2/pokemon?limit=2&offset=0, /api/v2/pokemon es la ruta y limit=2&offset=0 significa «dame 2 y no te saltes ninguno»; con offset=4 te saltarías los cuatro primeros y recibirías desde el quinto. Solo atiende GET.
Esto es lo que contestó a dos peticiones reales (comprobadas el 7 de octubre de 2026, con el cuerpo en una sola línea):
GET https://pokeapi.co/api/v2/pokemon?limit=2&offset=0
→ 200, content-type: application/json; charset=utf-8
{"count":1351,"next":"https://pokeapi.co/api/v2/pokemon?offset=2&limit=2","previous":null,"results":[{"name":"bulbasaur","url":"https://pokeapi.co/api/v2/pokemon/1/"},{"name":"ivysaur","url":"https://pokeapi.co/api/v2/pokemon/2/"}]}
GET https://pokeapi.co/api/v2/pokemon/99999
→ 404 {"status":404,"message":"Not Found"}La primera trae el estado 200 y un JSON con el total (count), la dirección de la página siguiente (next) y los dos primeros Pokémon. La segunda pide un Pokémon que no existe y recibe el 404 con un mensaje. Y recuerda el timeout, el tiempo máximo que esperas antes de rendirte: sin él, una petición sin contestar dejaría la pantalla colgada.
Paso 1 · Mira lo que devuelve PokeAPI antes de escribir nada
Pídela tú desde la Terminal de Android Studio (la pestaña Terminal, abajo del todo; curl es un programa que hace una petición y enseña la respuesta):
curl -s "https://pokeapi.co/api/v2/pokemon?limit=3&offset=0"Esto es lo que contesta, resumido: curl -s imprime solo el cuerpo, así que la primera línea (qué petición era) la he puesto yo, y los ... son recortes.
GET /api/v2/pokemon?limit=3&offset=0
{ "count": 1351, "next": "...", "previous": null,
"results": [ { "name": "bulbasaur", "url": ".../pokemon/1/" }, ... ] }El listado no trae ni tipos ni imagen. Solo el nombre y una URL. El identificador hay que sacarlo de esa URL, que termina en /pokemon/1/ para Bulbasaur (en /pokemon/25/ para Pikachu).
El detalle sí los trae (curl -s https://pokeapi.co/api/v2/pokemon/1), y ahí está la otra medida: la respuesta de un solo Pokémon pesa 272 KB para los cinco campos que la app usa (id, name, height, weight y types). Eso decide una opción de configuración que verás en el paso 3.
Ktor: el cliente que hace la petición
En Android el cliente fue Retrofit (con OkHttp debajo) y en iOS URLSession; los dos solo valen para su sistema. Para escribirlo una vez en commonMain hace falta uno multiplataforma: Ktor, el de JetBrains, que funciona igual en todas las plataformas. Se reparte en piezas pequeñas: core (el cliente y sus ajustes), content-negotiation (convierte el cuerpo de la respuesta en objetos), serialization-kotlinx-json (le enseña a hacerlo con JSON) y logging (apunta cada petición en el registro).
Hay una pieza más, y es la que hace de Ktor algo propio de KMP: el cliente no sabe hablar con la red. Eso lo hace un motor, y cada sistema tiene el suyo: en Android, OkHttp (el mismo que ya llevaba Retrofit por debajo); en iOS, Darwin, que se apoya en la misma URLSession que usaste en Red: HTTP, JSON y URLSession.
Frente a lo que ya conoces:
| Retrofit (Android) | URLSession (iOS) | Ktor (las dos) | |
|---|---|---|---|
| Dónde se escribe | Solo en la app de Android | Solo en la app de iOS | Una vez, en commonMain |
| Cómo se pide | Una interfaz con anotaciones (@GET) | URLSession.shared.data(from:) | cliente.get(…) y body() |
| Quién hace la petición | OkHttp | El sistema | OkHttp en Android, Darwin en iOS |
Así se ve una petición completa, en pequeño y con datos que no son de la app. No lo escribas ni lo ejecutes: es solo para leer (la salida de debajo es la que dio al ejecutarlo en un test de prueba). Pide el Pokémon 25 y lo imprime.
@Serializable
data class PersonajeDto(val name: String, val height: Int) // solo los campos que quiero leer
suspend fun pedirPikachu() { // suspend: espera a la red sin bloquear
val cliente = HttpClient(OkHttp) { // el cliente, con el motor de esta plataforma
install(ContentNegotiation) { // plugin: convierte el cuerpo JSON en objetos
json(Json { ignoreUnknownKeys = true }) // y tira los campos que no declaro
}
}
val respuesta: HttpResponse = cliente.get("https://pokeapi.co/api/v2/pokemon/25") // la petición GET
println("Estado: ${respuesta.status.value}") // el código de estado
println("Tipo: ${respuesta.headers["Content-Type"]}") // una cabecera
val pikachu: PersonajeDto = respuesta.body() // el cuerpo, ya convertido
println("Leído: $pikachu")
cliente.close() // un cliente se cierra cuando ya no se usa
}Al ejecutarlo, imprime:
Estado: 200
Tipo: application/json; charset=utf-8
Leído: PersonajeDto(name=pikachu, height=4)Fíjate en que install(…) { … } añade una capacidad al cliente (un plugin), y que el cuerpo llega ya convertido en un PersonajeDto: de eso se encarga ContentNegotiation, y sabe a qué clase convertirlo por el tipo que escribes en val pikachu: PersonajeDto. En esta lección se usan tres cosas que se llaman «plugin» y no son lo mismo: el de Ktor (install(…), una capacidad del cliente), el de Gradle (la sección [plugins], que enseña a Gradle a compilar) y el de compilador de serialización (el que genera el código que lee JSON). Te avisaré de cuál es cada vez. En la app no escribirás OkHttp: lo elige Ktor según la plataforma, como verás en el paso 2.
Paso 2 · Instala Ktor y kotlinx.serialization en shared
Objetivo: que shared pueda hacer peticiones HTTP y leer JSON desde commonMain, y que cada plataforma ponga debajo su propio motor de red.
Todas las rutas de esta lección se dan en la vista Project del panel de la izquierda (la que muestra las carpetas tal como están en el disco). Si el selector de arriba del panel dice Android, cámbialo a Project.
1 · Qué instalas y por qué ahora. Son tres cosas:
- Ktor (cliente). Las cuatro piezas de arriba:
core,content-negotiation,serialization-kotlinx-jsonylogging. - Un motor por plataforma. OkHttp va en
androidMainy Darwin eniosMain, no encommonMain: cada uno solo existe para su sistema. - kotlinx.serialization (JSON) y su plugin. Es la librería que ya usaste en JSON e instalar kotlinx.serialization, con el mismo plugin de compilador (el tercer sentido de «plugin»). Aquí va en
commonMain: publica versión para todas las plataformas.
Se instalan ahora porque el paso siguiente ya escribe el cliente.
2 · Las versiones, en [versions]. Abre gradle/libs.versions.toml y añade al final de la sección [versions], antes de la cabecera [libraries]:
# Un solo número para todas las piezas de Ktor: se publican a la vez.
ktor = "3.5.2"
# El número de la librería JSON. El plugin no lleva uno propio: usa el de Kotlin.
serialization = "1.11.0"3 · Las librerías, en [libraries]. Al final de esa sección, antes de [plugins], siete líneas. Los nombres llevan guiones; en el código serán puntos (ktor-client-core → libs.ktor.client.core):
# El cliente y sus ajustes (timeout, dirección base…): común a todas las plataformas.
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
# Los dos motores: OkHttp para Android, Darwin para iOS.
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
# Convierte el cuerpo de la petición y de la respuesta en objetos, y al revés.
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
# Apunta las peticiones en el registro.
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
# Le enseña a ContentNegotiation a hablar JSON con kotlinx.serialization.
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
# La librería que lee y escribe JSON (`Json`, `decodeFromString`).
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }4 · El plugin, en [plugins]. Este es el plugin de Gradle: la sección donde Gradle apunta los complementos que le enseñan a compilar. Al final de esa sección (en este proyecto los plugins llevan nombre en camelCase —primera palabra en minúscula y cada palabra siguiente con mayúscula inicial, como kotlinxSerialization—, igual que los que escribió el asistente de nuevo proyecto al crearlo):
# Un plugin de compilador debe tener la versión del compilador: version.ref = "kotlin".
kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }5 · El plugin, en la raíz y en el módulo. Ya conoces la pareja apply false y aplicación de JSON e instalar kotlinx.serialization y de Un Flow hasta Swift. En el build.gradle.kts de la raíz (el suelto, en la carpeta del proyecto, no el de shared), una línea más en plugins (disponible, sin activar):
alias(libs.plugins.kotlinxSerialization) apply falseY en shared/build.gradle.kts, ahora sí activado, dentro del bloque plugins de arriba:
// Genera el código que convierte cada clase @Serializable en JSON y viceversa.
alias(libs.plugins.kotlinxSerialization)6 · Las dependencias, cada una en su source set. En el mismo shared/build.gradle.kts, dentro de sourceSets { … }. Primero commonMain.dependencies, debajo de la línea de koin.core.viewmodel:
// implementation: solo las usa el código de shared; ningún tipo de Ktor
// aparece en lo que `shared` ofrece a las apps.
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.serialization.json)Después, un motor en cada plataforma: en el bloque androidMain.dependencies (el de koin.android) y en el de iosMain.dependencies (el de nativecoroutines):
// El motor de Android. Cada plataforma usa el suyo: el cliente de Ktor
// es común, quien hace la petición de verdad no lo es.
implementation(libs.ktor.client.okhttp) // El motor de iOS, que se apoya en lo que ya trae el sistema.
implementation(libs.ktor.client.darwin)7 · Sync Now, y comprueba. Pulsa Sync Now; debe terminar sin rojo (la primera vez descarga las librerías y necesita internet). Para ver que cada plataforma recibe su motor, pide a Gradle la lista de dependencias de tres «configuraciones» (la lista que arma para un destino concreto: iOS al compilar, Android y la JVM al ejecutar). En la Terminal de Android Studio, desde la carpeta de tu proyecto (cd ~/Pokedex-KMP), y con grep para quedarte solo con las líneas de Ktor (grep filtra: deja solo las líneas que contienen el texto que le das, y la barra | le pasa la salida del comando de su izquierda):
./gradlew :shared:dependencies --configuration iosSimulatorArm64CompileKlibraries | grep ktor-client
./gradlew :shared:dependencies --configuration androidRuntimeClasspath | grep ktor-client
./gradlew :shared:dependencies --configuration jvmRuntimeClasspath | grep ktor-clientLa primera lista ktor-client-darwin (y ktor-client-core con su variante iossimulatorarm64: Gradle eligió la versión de iOS sin que se lo pidieras). La segunda, ktor-client-okhttp. La tercera lista ktor-client-core, pero ningún motor: la JVM, la plataforma de los tests rápidos, no tiene el suyo. Por eso el cliente del paso siguiente deja entrar un motor desde fuera, y Testing del núcleo compartido lo aprovecha.
Errores típicos de esta instalación, con lo que verás:
- Una línea del catálogo sin escribir o mal escrita.
Unresolved reference 'ktor'.(o'serialization') enbuild.gradle.kts, con el fichero y la línea. Compara el alias con el catálogo, leyendo los guiones como puntos. Si faltan las líneas del catálogo, salen siete errores de golpe, uno por dependencia. - Un motor en
commonMain. Si ponesktor.client.okhttparriba, el error habla de plataformas y no de Ktor:KMP Dependencies Resolution Failure … Couldn't resolve dependency 'io.ktor:ktor-client-okhttp:3.5.2' in 'commonMain' for all target platforms … Unresolved platforms: [iosArm64, iosSimulatorArm64]. OkHttp solo existe para Java y Android. Sale al sincronizar, repetido una vez por cada source set que hereda decommonMain(appleMain,iosMainynativeMain, carpetas intermedias que agrupan destinos parecidos, además del propiocommonMain), y la compilación de la JVM puede seguir terminando bien: es un aviso del Sync, no de compilar. Muévelo aandroidMain. - Plugin sin aplicar en
shared. No falla al sincronizar ni al compilar: falla al ejecutar, cuando algo intenta leer JSON. Lo verás en la lección siguiente.
Para el resto de fallos de Sync, la caja «Si Sync sale en rojo» de Los ficheros de Gradle de tu proyecto los ordena por lo que ves.
Paso 3 · El cliente, configurado una sola vez
El archivo. PokeApiClient.kt crea el HttpClient de la app, con todos sus ajustes en un solo sitio. Va en commonMain, en un paquete nuevo data.network. Antes, un fichero más pequeño que le pasa la dirección de PokeAPI: AppConfig.kt.
Créalos. En la vista Project, clic derecho sobre la carpeta shared → New → File y escribe la ruta desde esa carpeta, como en El módulo compartido y la prueba de humo (un fichero con barras en el nombre crea las carpetas que falten). En todos los ficheros nuevos de esta lección, primero va la línea package y después los import. Primero:
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/core/AppConfig.ktpackage com.sebastiannarvaez.pokedex.core
// Lo que cambia de un entorno a otro, pedido como dependencia en vez de escrito
// donde se usa. Hoy solo hay una dirección; mañana habrá claves.
interface AppConfig {
// La dirección base de PokeAPI: todas las rutas cuelgan de ella.
val pokeApiBaseUrl: String
}
// La configuración de verdad. `internal`: solo `shared` la construye (la
// construirá el grafo de Koin, en la lección siguiente).
internal class DefaultAppConfig : AppConfig {
// La barra final importa: Ktor une esta dirección con "pokemon" o "pokemon/25".
override val pokeApiBaseUrl: String = "https://pokeapi.co/api/v2/"
}La dirección no es una constante suelta: es una interfaz con una implementación. Hoy podría ser un const val; se monta así porque las claves de Supabase (el servicio de cuentas y datos que la app usará más adelante) de Configuración y secretos por entorno no van a poder serlo, y para entonces el sitio donde ponerlas ya existirá.
Y ahora el cliente, con la ruta:
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/PokeApiClient.ktLa función guarda sus ajustes en una variable y se la da después a HttpClient(…); el porqué está en el comentario de return, al final. Dos piezas del cliente que ya existen en tu proyecto: Log es el registro de mensajes que creaste en La primera dependencia, y el cliente recibe el motor como parámetro opcional (engine) para que los tests puedan sustituirlo sin tocar nada más. object : Logger { … } crea, sin nombre, un objeto que implementa esa interfaz: lo viste en object, companion y vararg.
package com.sebastiannarvaez.pokedex.data.network
import com.sebastiannarvaez.pokedex.core.AppConfig
import com.sebastiannarvaez.pokedex.core.Log
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.http.URLProtocol
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
// Crea el cliente HTTP de la app. `internal`: nadie fuera de `shared` lo necesita.
// Se llama una vez y el grafo de Koin guarda el resultado.
internal fun createHttpClient(
// De aquí sale la dirección base.
config: AppConfig,
// El motor, opcional. En producción se deja a `null` y Ktor usa el de la
// plataforma (OkHttp en Android, Darwin en iOS); en los tests se le pasa uno
// de mentira. Así lo que se prueba es esta misma configuración.
engine: HttpClientEngine? = null,
): HttpClient {
// La configuración se guarda en una variable para dársela a las dos formas
// de crear un `HttpClient` (la última línea). Cada `install` añade una
// capacidad al cliente.
val configuracion: HttpClientConfig<*>.() -> Unit = {
// Convierte el JSON de la respuesta en objetos Kotlin, y al revés.
install(ContentNegotiation) {
json(
Json {
// PokeAPI manda decenas de campos que no usamos. Sin esta línea,
// cualquier campo que no declaremos hace fallar la lectura.
ignoreUnknownKeys = true
},
)
}
// Si el servidor no contesta a tiempo, falla en vez de dejar la pantalla colgada.
install(HttpTimeout) {
// Tiempo máximo de la petición entera: 15 segundos.
requestTimeoutMillis = 15_000
// Tiempo máximo para llegar a conectar: 10 segundos.
connectTimeoutMillis = 10_000
}
// Apunta cada petición en el registro de la app.
install(Logging) {
// `object : Logger` crea, sin nombre, un objeto que implementa la interfaz.
logger = object : Logger {
// Ktor nos da el texto; nuestro `Log` (el de Kermit) decide dónde sale.
override fun log(message: String) = Log.d(message)
}
// INFO: línea de petición y de respuesta, sin cuerpos.
level = LogLevel.INFO
}
// Valores que llevan todas las peticiones.
defaultRequest {
// La dirección base, que sale de la configuración.
url(config.pokeApiBaseUrl)
// Siempre por HTTPS: `this.url` es la dirección de la petición (el
// `this` evita confundirla con la función `url(…)` de arriba).
this.url.protocol = URLProtocol.HTTPS
}
}
// Dos funciones distintas con el mismo nombre: `HttpClient(configuracion)`
// deja que Ktor busque el motor de la plataforma; `HttpClient(engine, configuracion)`
// usa el que se le da. Por eso los ajustes viven en una variable: así se
// escriben una vez y valen para las dos.
return if (engine == null) HttpClient(configuracion) else HttpClient(engine, configuracion)
}Tres cosas del fichero que merecen una explicación:
- Dos funciones con el mismo nombre. Kotlin permite escribir varias funciones que se llaman igual si reciben cosas distintas (se llama sobrecarga). Ktor trae dos
HttpClient(…): una que solo recibe los ajustes y otra que además recibe el motor. La última línea elige una u otra según haya o no motor, y la variableconfiguracionevita escribir los ajustes dos veces. - Por qué
defaultRequesty noinstall.defaultRequest { … }es una ayuda de Ktor para los valores que lleva toda petición; no es un plugin con nombre propio y se escribe sininstall. - Por qué forzar HTTPS. Con
httpsla conversación va cifrada; así, aunque alguien escribierahttpen la dirección base, la petición saldría cifrada igualmente.
Ese «false por defecto» es el motivo de que la línea esté puesta, y sin ella falla ya, hoy: el detalle pesa 272 KB y la app lee cinco campos, así que en cuanto llega base_experience (o cualquier otro campo que no declaramos) la lectura lanza una excepción. Hasta el listado trae campos que la app no usará, como previous (la página anterior). Y el día que PokeAPI añada otro, la app dejaría de funcionar sin que nadie haya tocado el cliente.
Compila para comprobar que todo encaja (en la Terminal de Android Studio): ./gradlew :shared:compileKotlinJvm. Debe acabar en BUILD SUCCESSFUL. Si ContentNegotiation o json salen subrayados, falta una dependencia del paso 2.
Paso 4 · El permiso de internet de Android
El archivo. AndroidManifest.xml es la ficha de la app para Android; ya lo tocaste para declarar la Application en Arrancar Koin desde Android y desde iOS.
Abre androidApp/src/main/AndroidManifest.xml. Dentro de <manifest>, antes de la etiqueta <application, añade:
<!-- Sin esto, la red dependería de que una librería lo declare por ti. -->
<uses-permission android:name="android.permission.INTERNET" />Cuando algo falla en una plataforma y no en la otra, mira primero lo que no se comparte: este permiso solo existe en Android, y sin él la red fallaría ahí mientras en iOS funcionaría.
Comprueba que la app de Android sigue compilando: ./gradlew :androidApp:assembleDebug. Debe acabar en BUILD SUCCESSFUL.
Punto de control
./gradlew :shared:compileKotlinJvmy./gradlew :androidApp:assembleDebugterminan enBUILD SUCCESSFUL.- Los tests siguen como al terminar la lección anterior:
./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Testpasa, con tus ejercicios: 10 en la JVM, 9 en Android y 9 en iOS; sin ellos: 6, 5 y 5. 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. Esta lección no añade ninguno: todavía no hay nada que probar sin red. - El catálogo tiene
ktor = "3.5.2"yserialization = "1.11.0", siete librerías nuevas y el pluginkotlinxSerialization; la raíz lo declara conapply falseysharedlo aplica. sharedpone OkHttp solo enandroidMainy Darwin solo eniosMain; ningún motor encommonMain.- Existen
AppConfig.kt(encore) yPokeApiClient.kt(endata/network), yAndroidManifest.xmltieneandroid.permission.INTERNET.
La lección siguiente da forma a lo que llega por este cliente: Del JSON al Pokémon: DTO y repositorio.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.En la dirección
https://pokeapi.co/api/v2/pokemon?limit=2&offset=4, ¿qué sonlimit=2yoffset=4?2.Pides un Pokémon que no existe y PokeAPI responde con el código
404. ¿Qué significa?3.Quitas
ignoreUnknownKeys = truedel cliente. ¿Cuándo se rompe la app?4.¿Por qué
ktor-client-okhttpva enandroidMainy no encommonMain?5.Algo funciona en el simulador de iOS y falla en Android. ¿Dónde miras primero?