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)