Por qué Ktor y no Retrofit en KMP
Retrofit es una librería de Android que usa reflection en runtime y depende del ecosistema Java. No tiene soporte para iOS ni para otros targets KMP. Ktor es el cliente HTTP oficial de JetBrains para Kotlin, diseñado desde el principio para ser multiplataforma: el código de red vive en commonMain y Ktor usa el engine correcto en cada plataforma automáticamente.
# Retrofit en KMP → NO funciona (no tiene soporte commonMain)
# Ktor en KMP → SÍ funciona
# Ktor en Android usa OkHttp bajo el capó (mismo que Retrofit)
# Ktor en iOS usa NSURLSession (el cliente nativo de Apple)
# El código de negocio no sabe cuál está usando — mismo API en ambos
Configurar el HttpClient
// commonMain — la configuración del cliente es idéntica para todas las plataformas
// El engine se provee por DI según la plataforma (ver siguiente sección)
fun crearHttpClient(engine: HttpClientEngine): HttpClient {
return HttpClient(engine) {
// Serialización automática con kotlinx.serialization
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true // ignorar campos nuevos de la API
isLenient = true
coerceInputValues = true // null → valor default en lugar de error
})
}
// Timeout
install(HttpTimeout) {
requestTimeoutMillis = 30_000
connectTimeoutMillis = 10_000
socketTimeoutMillis = 30_000
}
// Logging (útil en debug, deshabilitar en release)
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.HEADERS // NONE, INFO, HEADERS, BODY, ALL
}
// Reintentos automáticos
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = 3)
exponentialDelay()
}
// Headers por default en todos los requests
defaultRequest {
header("Accept", "application/json")
header("Content-Type", "application/json")
}
}
}
Engine por plataforma con expect/actual
// commonMain — declarar la factory del engine
expect fun httpClientEngine(): HttpClientEngine
// androidMain — OkHttp
actual fun httpClientEngine(): HttpClientEngine = OkHttp.create {
// Configuración específica de OkHttp
config {
followRedirects(true)
// certificatePinner, proxy, etc.
}
}
// iosMain — Darwin (NSURLSession)
actual fun httpClientEngine(): HttpClientEngine = Darwin.create {
// Configuración específica de iOS
configureSession {
// timeoutIntervalForRequest = 30.0
}
}
// Uso al crear el cliente (en el módulo de DI):
val httpClient = crearHttpClient(httpClientEngine())
GET, POST y manejo de errores
// data/remote/ProductoApi.kt — commonMain
class ProductoApi(private val client: HttpClient) {
companion object {
const val BASE_URL = "https://api.miapp.com"
}
// GET con parámetros
suspend fun getProductos(): List<ProductoDto> {
return client.get("$BASE_URL/productos").body()
}
suspend fun getProducto(id: Int): ProductoDto? {
return client.get("$BASE_URL/productos/$id").body()
}
suspend fun buscar(query: String): List<ProductoDto> {
return client.get("$BASE_URL/productos/buscar") {
parameter("q", query)
parameter("limit", 20)
}.body()
}
// POST con body JSON
suspend fun agregarAlCarrito(productoId: Int, cantidad: Int): CarritoDto {
return client.post("$BASE_URL/carrito") {
setBody(AgregarAlCarritoRequest(productoId, cantidad))
}.body()
}
// Manejo de errores HTTP tipado
suspend fun getProductoSeguro(id: Int): Resultado<ProductoDto> {
return try {
val response = client.get("$BASE_URL/productos/$id")
when (response.status.value) {
in 200..299 -> Resultado.Exito(response.body())
404 -> Resultado.Error("Producto no encontrado")
401 -> Resultado.Error("No autorizado")
in 500..599 -> Resultado.Error("Error del servidor")
else -> Resultado.Error("Error HTTP: ${response.status}")
}
} catch (e: IOException) {
Resultado.Error("Sin conexión: ${e.message}", e)
} catch (e: SerializationException) {
Resultado.Error("Error al parsear respuesta", e)
}
}
}
@Serializable
data class AgregarAlCarritoRequest(
@SerialName("product_id") val productoId: Int,
val cantidad: Int
)
Serialización con kotlinx.serialization
// A diferencia de Gson/Moshi, kotlinx.serialization:
// → Es multiplataforma (funciona en commonMain)
// → Usa procesamiento en compilación, no reflection
// → Las clases NO necesitan tener un constructor sin parámetros
// → Falla en compilación si la clase no es serializable correctamente
@Serializable
data class ProductoDto(
val id: Int,
@SerialName("product_name") val nombre: String, // mapear nombre de campo
val precio: Double,
val descripcion: String? = null, // null si falta en el JSON
@SerialName("image_url") val imageUrl: String,
@Contextual val fechaCreacion: Instant? = null // tipos custom con serializer
)
// Listas y objetos anidados funcionan automáticamente:
@Serializable
data class RespuestaListaProductos(
val productos: List<ProductoDto>,
val total: Int,
val pagina: Int
)
// Serializar/deserializar manualmente cuando necesitás:
val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
val texto = json.encodeToString(producto)
val productoDeserializado = json.decodeFromString<ProductoDto>(texto)
// Sealed classes — para respuestas polimórficas de la API:
@Serializable
sealed class EventoDto {
@Serializable @SerialName("click") data class Click(val elementId: String) : EventoDto()
@Serializable @SerialName("compra") data class Compra(val productoId: Int) : EventoDto()
}
Interceptors y autenticación
// Token de autenticación en todos los requests
fun crearHttpClientAutenticado(
engine: HttpClientEngine,
tokenProvider: TokenProvider // interfaz del shared module
): HttpClient {
return HttpClient(engine) {
install(ContentNegotiation) { json() }
// Interceptor de autenticación — agrega el token a cada request
install(Auth) {
bearer {
loadTokens {
val token = tokenProvider.getAccessToken()
BearerTokens(token, tokenProvider.getRefreshToken())
}
// Token refresh automático cuando el server retorna 401
refreshTokens {
val nuevoToken = tokenProvider.refreshToken(oldTokens?.refreshToken ?: "")
BearerTokens(nuevoToken.accessToken, nuevoToken.refreshToken)
}
}
}
}
}
// Interfaz en commonMain — implementación en cada plataforma según el storage
interface TokenProvider {
suspend fun getAccessToken(): String
suspend fun getRefreshToken(): String
suspend fun refreshToken(refreshToken: String): TokenPair
suspend fun guardarTokens(tokens: TokenPair)
suspend fun limpiarTokens()
}
data class TokenPair(val accessToken: String, val refreshToken: String)