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() })
}
}