Setup

dependencies {
    implementation("com.squareup.retrofit2:retrofit:2.11.0")
    implementation("com.squareup.retrofit2:converter-gson:2.11.0")
    // O con kotlinx.serialization (más moderno):
    implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")

    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
}

// Manifest — permiso de internet
<uses-permission android:name="android.permission.INTERNET" />

Definir la API con suspend functions

Con soporte moderno de Retrofit para coroutines, las funciones de la interfaz son simplemente suspend:

interface ProductoApi {

    @GET("productos")
    suspend fun getProductos(
        @Query("categoria") categoria: String? = null,
        @Query("page") page: Int = 1,
        @Query("limit") limit: Int = 20
    ): List<ProductoDto>

    @GET("productos/{id}")
    suspend fun getProducto(@Path("id") id: Int): ProductoDto

    @POST("productos")
    suspend fun crearProducto(@Body dto: CrearProductoDto): ProductoDto

    @PUT("productos/{id}")
    suspend fun actualizarProducto(
        @Path("id") id: Int,
        @Body dto: ActualizarProductoDto
    ): ProductoDto

    @DELETE("productos/{id}")
    suspend fun eliminarProducto(@Path("id") id: Int): Response<Unit>

    // Response<T> cuando necesitás acceder al código HTTP y headers
    @GET("productos")
    suspend fun getProductosConMeta(): Response<List<ProductoDto>>
}

DTOs y serialización

// Con Gson:
data class ProductoDto(
    @SerializedName("id") val id: Int,
    @SerializedName("product_name") val nombre: String,  // nombre distinto al dominio
    @SerializedName("unit_price") val precio: Double,
    @SerializedName("stock_count") val stock: Int,
    @SerializedName("category_id") val categoriaId: Int
)

// Con kotlinx.serialization (preferido en proyectos modernos):
@Serializable
data class ProductoDto(
    val id: Int,
    @SerialName("product_name") val nombre: String,
    @SerialName("unit_price") val precio: Double,
    @SerialName("stock_count") val stock: Int,
    @SerialName("category_id") val categoriaId: Int
)

Patrón sealed Result para errores de red

Retrofit lanza excepciones en caso de error. La forma más limpia de manejarlas es con un wrapper Result o un sealed class propio:

// Wrapper genérico — una sola función maneja todos los casos
suspend fun <T> safeApiCall(call: suspend () -> T): Result<T> {
    return try {
        Result.success(call())
    } catch (e: HttpException) {
        val errorMsg = when (e.code()) {
            401 -> "No autorizado"
            403 -> "Acceso denegado"
            404 -> "No encontrado"
            500 -> "Error del servidor"
            else -> "Error HTTP ${e.code()}"
        }
        Result.failure(Exception(errorMsg))
    } catch (e: IOException) {
        Result.failure(Exception("Sin conexión a internet"))
    } catch (e: Exception) {
        Result.failure(e)
    }
}

// Uso en el Repository:
class ProductoRepositoryImpl @Inject constructor(
    private val api: ProductoApi
) : ProductoRepository {

    override suspend fun getProducto(id: Int): Result<Producto> {
        return safeApiCall { api.getProducto(id).toDomain() }
    }
}

// En el ViewModel:
fun cargar(id: Int) {
    viewModelScope.launch {
        _uiState.update { it.copy(isLoading = true) }
        repo.getProducto(id)
            .onSuccess { producto ->
                _uiState.update { it.copy(isLoading = false, producto = producto) }
            }
            .onFailure { error ->
                _uiState.update { it.copy(isLoading = false, error = error.message) }
            }
    }
}

Interceptores OkHttp

Los interceptores son middleware para todas las requests HTTP. Usados para logging, autenticación y manejo de errores global:

// Interceptor de logging (solo en debug)
val loggingInterceptor = HttpLoggingInterceptor().apply {
    level = if (BuildConfig.DEBUG)
        HttpLoggingInterceptor.Level.BODY
    else
        HttpLoggingInterceptor.Level.NONE
}

// Interceptor de headers comunes
val headersInterceptor = Interceptor { chain ->
    val request = chain.request().newBuilder()
        .addHeader("Accept", "application/json")
        .addHeader("App-Version", BuildConfig.VERSION_NAME)
        .build()
    chain.proceed(request)
}

val okHttp = OkHttpClient.Builder()
    .addInterceptor(loggingInterceptor)
    .addInterceptor(headersInterceptor)
    .connectTimeout(30, TimeUnit.SECONDS)
    .readTimeout(30, TimeUnit.SECONDS)
    .build()

Autenticación con Bearer token

class AuthInterceptor @Inject constructor(
    private val tokenProvider: TokenProvider  // donde guardás el JWT
) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val token = tokenProvider.getToken()

        val request = if (token != null) {
            chain.request().newBuilder()
                .addHeader("Authorization", "Bearer $token")
                .build()
        } else {
            chain.request()
        }

        val response = chain.proceed(request)

        // Si el servidor devuelve 401, el token expiró
        if (response.code == 401) {
            tokenProvider.clearToken()
            // Podés emitir un evento para redirigir al login
        }

        return response
    }
}

Estrategia offline-first

El patrón más robusto: Room es la fuente de verdad, la red sincroniza en background:

// En el Repository: exponer siempre el Flow local, sincronizar por separado
class ProductoRepositoryImpl @Inject constructor(
    private val dao: ProductoDao,
    private val api: ProductoApi
) : ProductoRepository {

    // La UI siempre observa Room — nunca bloquea esperando la red
    override fun getProductos(): Flow<List<Producto>> =
        dao.getAll().map { it.map { e -> e.toDomain() } }

    // Sincronización separada: llamada explícita desde el ViewModel
    override suspend fun sincronizar(): Result<Unit> = safeApiCall {
        val remotos = api.getProductos()
        dao.reemplazarTodos(remotos.map { it.toEntity() })
    }
}