Etiqueta: HTTP Client

  • Unidad 3 — Tema 1: Consumiendo una API real con Ktor en KMP

    Unidad 3 — Tema 1: Consumiendo una API real con Ktor en KMP

    En el artículo anterior de la serie (Unidad 2 — Tema 2), pusimos en práctica la interoperabilidad nativa implementando un lector de telemetría de hardware directamente sobre las APIs de Android SDK y CocoaTouch. Una vez consolidados los cimientos del lenguaje y la comunicación bidireccional, es hora de dar el salto al desarrollo de infraestructuras complejas y construir algo real: vamos a conectar News Explorer a una API de noticias real y ver los datos en pantalla.

    Al final de este artículo tendrás una función en commonMain que consulta la API de NewsAPI y devuelve un listado de artículos reales que podrás imprimir en consola desde Android. Pequeño, concreto, funcional.

    Lo que vamos a construir

    Una capa de red compartida con tres piezas:

    1. Los modelos de datos (DTOs): las clases que representan la respuesta JSON de la API.
    2. El cliente HTTP: configurado con Ktor en commonMain, con motores nativos para Android e iOS.
    3. El servicio de red: una clase que expone una función suspend que devuelve noticias reales.

    Nada de repositorios todavía, nada de base de datos, nada de ViewModels. Eso viene en los siguientes temas. Hoy, solo red.

    0. Consigue tu API Key

    Regístrate gratis en newsapi.org/register y copia tu API key. La necesitarás en unos minutos.

    1. Añade las dependencias

    Abre gradle/libs.versions.toml y añade las versiones y librerías de Ktor:

    [versions]
    ktor = "3.5.0"
    
    [libraries]
    ktor-client-core                = { module = "io.ktor:ktor-client-core",                    version.ref = "ktor" }
    ktor-client-okhttp              = { module = "io.ktor:ktor-client-okhttp",                   version.ref = "ktor" }
    ktor-client-darwin              = { module = "io.ktor:ktor-client-darwin",                   version.ref = "ktor" }
    ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation",     version.ref = "ktor" }
    ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json",     version.ref = "ktor" }
    
    [plugins]
    kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

    Ahora abre shared/build.gradle.kts y aplica el plugin de serialización y las dependencias según la plataforma:

    plugins {
        alias(libs.plugins.kotlinMultiplatform)
        alias(libs.plugins.kotlinxSerialization) // Necesario para @Serializable
    }
    
    kotlin {
        androidTarget()
        listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach {
            it.binaries.framework { baseName = "shared"; isStatic = true }
        }
    
        sourceSets {
            commonMain.dependencies {
                implementation(libs.ktor.client.core)
                implementation(libs.ktor.client.content.negotiation)
                implementation(libs.ktor.serialization.kotlinx.json)
            }
            androidMain.dependencies {
                implementation(libs.ktor.client.okhttp)   // Motor HTTP para Android
            }
            iosMain.dependencies {
                implementation(libs.ktor.client.darwin)   // Motor HTTP nativo de iOS (NSURLSession)
            }
        }
    }

    La razón de separar los motores por plataforma es simple: en Android usamos OkHttp (el estándar de la JVM), y en iOS usamos el motor Darwin que se integra directamente con NSURLSession de Apple, respetando sus políticas de batería y red. El código que escribimos en commonMain no sabe nada de esto; Ktor lo gestiona internamente.

    2. Define los modelos de datos

    Crea los siguientes archivos en shared/src/commonMain/kotlin/dev/asiles/newsexplorer/data/remote/model/.

    Primero, la respuesta raíz que devuelve la API:

    package dev.asiles.newsexplorer.data.remote.model
    
    import kotlinx.serialization.SerialName
    import kotlinx.serialization.Serializable
    
    @Serializable
    data class NewsResponseDto(
        @SerialName("status")       val status: String,
        @SerialName("totalResults") val totalResults: Int,
        @SerialName("articles")     val articles: List<ArticleDto>
    )

    Cada artículo individual:

    package dev.asiles.newsexplorer.data.remote.model
    
    import kotlinx.serialization.SerialName
    import kotlinx.serialization.Serializable
    
    @Serializable
    data class ArticleDto(
        @SerialName("source")      val source: SourceDto,
        @SerialName("author")      val author: String?,
        @SerialName("title")       val title: String,
        @SerialName("description") val description: String?,
        @SerialName("url")         val url: String,
        @SerialName("urlToImage")  val imageUrl: String?,
        @SerialName("publishedAt") val publishedAt: String,
        @SerialName("content")     val content: String?
    )
    
    @Serializable
    data class SourceDto(
        @SerialName("id")   val id: String?,
        @SerialName("name") val name: String
    )

    Nota el uso de String? en campos como author o description. La API de NewsAPI devuelve null en muchos de ellos, y si los declarásemos como String, la deserialización fallaría en tiempo de ejecución. Usar nullable types aquí es correctitud, no pesimismo.

    El plugin @Serializable genera en tiempo de compilación un serializador estático para cada clase. Esto significa que no hay reflexión en tiempo de ejecución, algo especialmente importante en iOS donde la reflexión de la JVM no existe.

    3. Crea el cliente HTTP y el servicio

    Crea el archivo NewsApiService.kt en shared/src/commonMain/kotlin/dev/asiles/newsexplorer/data/remote/:

    package dev.asiles.newsexplorer.data.remote
    
    import dev.asiles.newsexplorer.data.remote.model.NewsResponseDto
    import io.ktor.client.HttpClient
    import io.ktor.client.call.body
    import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
    import io.ktor.client.plugins.defaultRequest
    import io.ktor.client.request.get
    import io.ktor.client.request.header
    import io.ktor.client.request.parameter
    import io.ktor.serialization.kotlinx.json.json
    import kotlinx.serialization.json.Json
    
    private const val BASE_URL = "https://newsapi.org/v2/"
    
    fun createHttpClient(apiKey: String): HttpClient {
        return HttpClient {
            install(ContentNegotiation) {
                json(Json {
                    ignoreUnknownKeys = true  // Si la API añade campos nuevos, no rompe la app
                    isLenient = true
                })
            }
            defaultRequest {
                url(BASE_URL)
                header("X-Api-Key", apiKey)
            }
        }
    }
    
    class NewsApiService(private val client: HttpClient) {
    
        suspend fun getTopHeadlines(category: String = "technology"): NewsResponseDto {
            return client.get("top-headlines") {
                parameter("category", category)
                parameter("country", "us")
            }.body()
        }
    }

    La función createHttpClient recibe la API key como parámetro en lugar de tenerla hardcodeada. Esto facilita los tests (puedes pasarle una key de prueba o un mock) y deja la responsabilidad de gestionar secretos fuera del módulo compartido.

    4. Pruébalo: imprime noticias reales en Android

    Aquí hay un detalle importante de la arquitectura KMP: el módulo androidApp no debe importar clases de Ktor directamente. Ktor vive en shared, y androidApp solo ve la API pública del módulo compartido. Si intentas usar HttpClient o createHttpClient desde MainActivity, el compilador se quejará con un error de classpath.

    La solución es exponer una función de fachada desde shared que encapsule la llamada. Crea este archivo en shared/src/commonMain/kotlin/dev/asiles/newsexplorer/:

    package dev.asiles.newsexplorer
    
    import dev.asiles.newsexplorer.data.remote.NewsApiService
    import dev.asiles.newsexplorer.data.remote.createHttpClient
    
    // Punto de entrada público del SDK compartido.
    // androidApp solo necesita conocer esta clase, no las clases internas de Ktor.
    class NewsSdk(apiKey: String) {
        private val service = NewsApiService(createHttpClient(apiKey))
    
        suspend fun fetchHeadlines(): List<String> {
            return service.getTopHeadlines().articles.map { it.title }
        }
    }

    Ahora sí puedes llamarlo desde MainActivity sin ningún problema de classpath:

    import dev.asiles.newsexplorer.NewsSdk
    import kotlinx.coroutines.CoroutineScope
    import kotlinx.coroutines.Dispatchers
    import kotlinx.coroutines.launch
    
    class MainActivity : ComponentActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
    
            // Prueba temporal — lo eliminaremos cuando añadamos el ViewModel en el Tema 3
            CoroutineScope(Dispatchers.IO).launch {
                val sdk = NewsSdk(apiKey = "TU_API_KEY")
                sdk.fetchHeadlines().forEach { title ->
                    println("📰 $title")
                }
            }
        }
    }

    Ejecuta la app en un emulador o dispositivo Android y abre Logcat. Deberías ver algo así:

    📰 Google announces Kotlin Multiplatform support in Android Studio
    📰 JetBrains releases Kotlin 2.2 with new compiler features
    📰 Apple unveils Swift 6.1 interoperability improvements
    ...

    Si ves un error de red, asegúrate de tener el permiso de internet en tu AndroidManifest.xml:

    <uses-permission android:name="android.permission.INTERNET" />

    Por qué funciona así

    El módulo androidApp depende de shared, pero no tiene acceso directo a las dependencias transitivas de shared (como Ktor). Esto es deliberado: cada módulo solo expone lo que decide exponer. NewsSdk actúa como la frontera pública del módulo compartido hacia el mundo Android. Internamente puede usar Ktor, Room, o cualquier otra librería; desde fuera solo se ve una API limpia.

    Este patrón se repite en el Tema 3 cuando Koin gestiona todas las dependencias internamente y MainActivity solo necesita conocer el ViewModel.

    5. Enfoque senior: cuándo usar Ktor, cuándo evitarlo

    Ktor es la opción natural para proyectos KMP nuevos porque es la única librería HTTP con soporte oficial de JetBrains para todos los targets (Android, iOS, Desktop, WASM). Sin embargo, hay casos donde merece la pena evaluar alternativas:

    • Usa Ktor cuando tu proyecto es KMP desde el inicio y quieres una única capa de red compartida. También si necesitas WebSockets, SSE o streaming, donde Ktor brilla especialmente.
    • Evalúa Retrofit si tienes un módulo Android existente con Retrofit y no quieres migrar la capa de red. Retrofit no funciona en iOS, pero puedes mantenerlo en androidMain y definir una interfaz común en commonMain.
    • Evita Ktor si tu proyecto es 100% Android y no tienes planes de compartir código con iOS. En ese caso Retrofit + OkHttp tiene mejor ecosistema de herramientas de debugging (interceptors, Chuck, etc.).
    • Cuidado con la gestión de errores: en Ktor Client, la validación por código HTTP se controla de forma nativa con expectSuccess. Por defecto está en false, así que un 404 o 500 no lanza excepción automáticamente. Si activas expectSuccess = true, Ktor lanzará excepciones para respuestas no 2xx; y si necesitas reglas más finas, puedes complementarlo con HttpResponseValidator.

    Para profundizar

    Conclusión

    La capa de red que hemos construido sigue un principio clave: el código compartido no sabe en qué plataforma se ejecuta. commonMain define el contrato (cliente, DTOs, servicio), y cada plataforma aporta el motor HTTP adecuado. Esta separación no es solo arquitectónica; es lo que permite que el mismo código compile en la JVM, en LLVM (iOS) y potencialmente en WASM sin modificaciones.

    El punto débil de lo que tenemos hoy es la volatilidad: si el usuario no tiene conexión, la app no mostrará nada. En el próximo artículo, Unidad 3 — Tema 2: Offline-first con Room: caché local y Repository pattern, resolveremos esto con Room Multiplatform, añadiendo una capa de persistencia local que convierta nuestra app en offline-first: los datos siempre estarán disponibles, independientemente del estado de la red.