Autenticación en el núcleo: la API
Al terminar: Registrar, entrar y salir contra la API REST de Supabase, con la respuesta traducida a tipos de Kotlin.
Las peticiones ya funcionan desde curl. Ahora las hace la app. Al terminar esta lección tendrás en el núcleo la parte que habla con Supabase: registrar, entrar, refrescar y salir, con la respuesta del servidor traducida a tipos de Kotlin y con sus tests. La lección siguiente, La sesión en el núcleo, guarda lo que esta recibe y lo convierte en el estado de sesión que miran las dos pantallas, la de Android y la de iOS.
No hay pantallas todavía. Todo lo de aquí se comprueba con tests, y eso es parte de la lección: un flujo de autenticación se puede tener terminado y probado antes de pintar un solo campo de texto.
No se instala nada. Ktor, su plugin de JSON, kotlinx.serialization y el MockEngine de pruebas ya están en shared desde HTTP y Ktor: el cliente de PokeAPI y Testing del núcleo compartido. Son cuatro ficheros nuevos y ninguno se modifica, todos en shared, y los comandos se escriben en la Terminal de Android Studio, en la carpeta de tu proyecto.
Cada fichero nuevo se crea igual: clic derecho sobre la carpeta shared → New → File y la ruta desde la carpeta del módulo (el método de El módulo compartido y la prueba de humo). En todos, primero va la línea package y después los import.
Paso 1 · Los DTO, y uno de ellos con truco
El archivo. AuthDto.kt describe la forma de lo que devuelve Supabase, tal cual llega por la red: la sesión, el usuario y el error.
Créalo con la ruta:
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/dto/AuthDto.ktEl concepto. Mira los campos de error que viste con curl en Preparar Supabase: el login fallido y el registro rechazado devolvían msg (con code y error_code), y la petición sin apikey devolvía message y hint. Además, otros endpoints de Supabase (los de OAuth, por ejemplo) usan error y error_description. Un DTO de error que exija un campo concreto convierte el error del usuario en un fallo de parseo. Por eso AuthErrorDto tiene todos los campos opcionales, y la propiedad mensaje coge el primero que venga relleno.
«El primero que venga relleno» se escribe con ?:, que significa «si lo de la izquierda es null, lo de la derecha», y se puede encadenar. Un ejemplo ajeno al proyecto, con el formulario de reclamaciones de una tienda, donde la queja puede llegar en tres sitios:
// Un formulario de reclamaciones: la queja puede llegar en tres sitios.
data class Reclamacion(val queja: String? = null, val nota: String? = null, val margen: String? = null) {
// `?:` («si es null, lo de la derecha»): el primero que venga relleno.
val texto: String? get() = queja ?: nota ?: margen
}
fun main() {
println(Reclamacion(queja = "Llegó frío").texto)
println(Reclamacion(margen = "Faltaba el postre").texto)
println(Reclamacion().texto)
}Esto es lo que sale:
Llegó frío
Faltaba el postre
nullLa primera reclamación trae la queja en su sitio; la segunda, en el margen; la tercera, en ninguno, y entonces no hay mensaje (null) en vez de un error. Es lo que hace mensaje en el fichero de abajo.
Una cosa más, que verás en el código de abajo: en la sesión, expires_in lleva = 3600, un valor por defecto (el que se usa si el JSON no trae ese campo). Lleva ese valor porque, si un día el servidor no lo manda, un campo obligatorio rompería la lectura entera. Con una respuesta sin ese campo y sin el = 3600, la app no entra y el error dice Field 'expires_in' is required … but it was missing. Los campos que no controlas se declaran opcionales con un valor razonable: no es dejadez, es aceptar que el contrato de un servidor cambia sin avisarte y que tu app tiene que seguir funcionando cuando pase. El valor de 3600 es además una elección conservadora: si el servidor no dice cuánto dura, la app asume una hora y la comprobación de caducidad se encarga del resto.
package com.sebastiannarvaez.pokedex.data.network.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// La sesión tal y como la devuelve Supabase. `@SerialName` liga el nombre del
// JSON (con guion bajo) con el de la propiedad (en camelCase).
@Serializable
internal data class SessionDto(
@SerialName("access_token") val accessToken: String,
@SerialName("refresh_token") val refreshToken: String,
// Con valor por defecto: si el servidor no lo manda, se asume una hora.
@SerialName("expires_in") val expiresIn: Long = 3600,
@SerialName("token_type") val tokenType: String = "bearer",
val user: UserDto? = null,
)
@Serializable
internal data class UserDto(
val id: String,
val email: String? = null,
)
// El error de Supabase, con TODOS los campos opcionales: según el endpoint
// llega uno u otro, y un campo ausente no debe romper el parseo.
@Serializable
internal data class AuthErrorDto(
val error: String? = null,
@SerialName("error_description") val errorDescription: String? = null,
val msg: String? = null,
val message: String? = null,
@SerialName("error_code") val errorCode: String? = null,
) {
// El primero que venga relleno, que es lo único que se puede enseñar. `?:`
// devuelve lo de la derecha si lo de la izquierda es `null`.
val mensaje: String?
get() = errorDescription ?: msg ?: message ?: error
}Paso 2 · La sesión guarda el instante, no la duración
El archivo. Session.kt es lo que la app entiende por «sesión», distinto de lo que el servidor manda. Créalo con la ruta src/commonMain/kotlin/com/sebastiannarvaez/pokedex/domain/Session.kt.
El concepto. El servidor manda expires_in: 3600, que es cierto solo en el instante en que llega. Guardarlo tal cual hace que, media hora después, la app siga creyendo que le quedan 3600 segundos. Por eso Session guarda cuándo caduca, no cuánto dura.
El instante se mide en segundos desde el 1 de enero de 1970, la fecha que los ordenadores usan como punto cero (la «época Unix»; ya la viste con toEpochDays() en El Pokémon del día en el núcleo). Un ejemplo ajeno al proyecto, un cupón de descuento que vale una hora desde que llega:
// Un cupón de descuento que vale una hora desde que llega.
data class Cupon(val caducaEn: Long) {
// Con un margen de 60 s: un cupón al que le quedan 50 ya no sirve para pagar.
fun caducado(ahora: Long, margen: Long = 60): Boolean = ahora + margen >= caducaEn
}
fun main() {
val recibidoA = 1_000L // el reloj cuando llega el cupón, en segundos
val duracion = 3_600L // lo que dice el cupón: «vale una hora»
val cupon = Cupon(caducaEn = recibidoA + duracion)
println(cupon.caducaEn)
println(cupon.caducado(ahora = 2_000))
println(cupon.caducado(ahora = 4_550))
}Esto es lo que sale:
4600
false
trueEl cupón llegó cuando el reloj marcaba 1000 y valía 3600 segundos, así que caduca en 4600: se suma una sola vez, al recibirlo. A las 2000 todavía sirve; a las 4550 le quedan 50 segundos y, con el margen de 60, ya se da por caducado. Son los mismos números del test entrar convierte expires_in en instante de caducidad de la lección siguiente (1000 + 3600 = 4600): lo reconocerás.
package com.sebastiannarvaez.pokedex.domain
// Modelo de dominio: lo que la app entiende. No lleva anotaciones de
// serialización porque no viaja por la red.
data class Session(
val accessToken: String,
val refreshToken: String,
// El instante de caducidad, en segundos desde 1970 («época Unix»).
val expiresAtEpochSeconds: Long,
val userId: String,
val email: String?,
) {
// Si conviene refrescar ya. Con un margen: un token que caduca dentro de
// treinta segundos ya no sirve para una petición que tarda dos.
fun caducaAntesDe(ahoraEpochSeconds: Long, margenSegundos: Long = 60): Boolean =
ahoraEpochSeconds + margenSegundos >= expiresAtEpochSeconds
}Aquí está la diferencia entre un DTO y un modelo de dominio, y es más que un cambio de nombres: el DTO es lo que dice el servidor; el dominio es lo que la app necesita saber para decidir.
Paso 3 · La API, y un cliente aparte
El archivo. SupabaseAuthApi.kt es quien habla con Supabase: cuatro operaciones y un cliente HTTP propio. Se escribe en tres trozos dentro del mismo fichero. Créalo con la ruta:
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/SupabaseAuthApi.ktEl concepto: un POST en Ktor. Hasta ahora la app solo pedía (GET). Para registrarse hay que enviar un correo y una contraseña: es un POST, como el que hiciste en Marcador con Retrofit y con URLSession (POST y errores de red). En Ktor se escribe client.post(dirección) { … } y dentro del bloque se arma la petición. Un ejemplo ajeno al proyecto, con el pedido a una pizzería. Hay un servidor de mentira (un MockEngine) que enseña lo que le llega:
// Lo que se envía: un pedido a una pizzería, como JSON.
@Serializable
data class Pedido(val pizza: String, val unidades: Int)
// Un servidor de mentira que enseña lo que le llega y contesta «ok».
val motor = MockEngine { peticion ->
println("método: ${peticion.method.value}")
println("dirección: ${peticion.url}")
println("cabecera tienda: ${peticion.headers["tienda"]}")
// `as TextContent`: «trata este cuerpo como un texto», para poder leer su `text`.
println("cuerpo: ${(peticion.body as TextContent).text}")
respond("ok")
}
val cliente = HttpClient(motor) { install(ContentNegotiation) { json() } }
// post: un POST (enviar datos). Dentro del bloque se arma la petición.
val respuesta = cliente.post("https://pizzeria.test/pedidos") {
parameter("urgente", "si") // ?urgente=si al final de la dirección
header("tienda", "centro") // una cabecera: un dato que viaja aparte del cuerpo
contentType(ContentType.Application.Json) // avisa de que el cuerpo es JSON
setBody(Pedido("margarita", 2)) // el objeto, que Ktor convierte en JSON
}
println("respuesta: ${respuesta.bodyAsText()}")Este fragmento se ejecutó dentro de un test (por eso no lleva main ni los import); no hace falta que lo repitas. Esto es lo que salió:
método: POST
dirección: https://pizzeria.test/pedidos?urgente=si
cabecera tienda: centro
cuerpo: {"pizza":"margarita","unidades":2}
respuesta: okCon esto se entienden solos el ?grant_type=password y la cabecera apikey del curl de la lección anterior: parameter pone lo que va detrás del ?, header pone una cabecera y setBody el cuerpo.
Primero, lo que la app le pide y cómo falla. Los imports de todo el fichero van arriba; las clases con private solo se ven dentro de él:
package com.sebastiannarvaez.pokedex.data.network
import com.sebastiannarvaez.pokedex.core.AppConfig
import com.sebastiannarvaez.pokedex.core.Log
import com.sebastiannarvaez.pokedex.data.network.dto.AuthErrorDto
import com.sebastiannarvaez.pokedex.data.network.dto.SessionDto
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.call.body
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
// Lo que la app le pide a Supabase: la lista de operaciones, sin cómo se hacen.
// Una interfaz permite que el repositorio use un doble en los tests.
internal interface SupabaseAuthApi {
suspend fun registrar(email: String, password: String): SessionDto
suspend fun entrar(email: String, password: String): SessionDto
suspend fun refrescar(refreshToken: String): SessionDto
suspend fun salir(accessToken: String)
}
// Los cuerpos JSON que se envían. `private`: solo los usa este fichero.
@Serializable
private data class CredencialesDto(val email: String, val password: String)
@Serializable
private data class RefrescoDto(@SerialName("refresh_token") val refreshToken: String)
// Un error de autenticación, con el mensaje que devolvió el servidor. Se lanza
// en vez de devolver un resultado: el mapeo a `UiError` (el error ya traducido
// para enseñarlo, de «Errores que se pueden contar») ya trata cualquier `Throwable`, y un tipo de resultado nuevo
// duplicaría el camino de los errores.
// `val codigo` guarda el código como propiedad de la clase; `mensaje` no lleva
// `val` porque solo se pasa a `Exception`, no se guarda aparte.
internal class AuthException(val codigo: Int, mensaje: String?) :
Exception(mensaje ?: "error de autenticación ($codigo)")Segundo, la implementación, donde se ve que entrar y refrescar son el mismo endpoint. Va debajo, en el mismo fichero:
// La implementación con Ktor. Las rutas son relativas: la base
// (`…/auth/v1/`) la pone el cliente del último trozo.
internal class KtorSupabaseAuthApi(private val client: HttpClient) : SupabaseAuthApi {
override suspend fun registrar(email: String, password: String): SessionDto =
client.post("signup") {
contentType(ContentType.Application.Json)
setBody(CredencialesDto(email, password))
}.sesionOError()
// Entrar y refrescar: la misma ruta `token`, distinto `grant_type`.
override suspend fun entrar(email: String, password: String): SessionDto =
client.post("token") {
parameter("grant_type", "password")
contentType(ContentType.Application.Json)
setBody(CredencialesDto(email, password))
}.sesionOError()
override suspend fun refrescar(refreshToken: String): SessionDto =
client.post("token") {
parameter("grant_type", "refresh_token")
contentType(ContentType.Application.Json)
setBody(RefrescoDto(refreshToken))
}.sesionOError()
override suspend fun salir(accessToken: String) {
// Si falla, da igual: el token local se borra de todos modos. Un cierre
// de sesión que falla por falta de red no debe dejar al usuario dentro.
runCatching {
client.post("logout") { header("Authorization", "Bearer $accessToken") }
}.onFailure { Log.w("no se pudo cerrar sesion en el servidor", it) }
}
// Si la respuesta es un éxito (2xx), la convierte en `SessionDto`. Si no,
// lee el cuerpo como error (`getOrNull` da `null` si no se entiende) y lanza.
private suspend fun HttpResponse.sesionOError(): SessionDto {
// `body()` sin más lee la respuesta como el tipo que promete la función
// (`SessionDto`); `body<AuthErrorDto>()` nombra el tipo a mano.
if (status.isSuccess()) return body()
val error = runCatching { body<AuthErrorDto>() }.getOrNull()
throw AuthException(status.value, error?.mensaje)
}
}Fíjate en salir: es la única que se traga el error. Es peor un cierre de sesión que falla por falta de red y deja al usuario dentro que uno en el que el servidor no llega a enterarse.
Por qué un HttpClient nuevo
Tercero, el cliente. Va al final del fichero, igual que createHttpClient en el de PokeAPI.
// Un cliente APARTE para Supabase. No se reutiliza el de PokeAPI: con uno solo
// habría que repetir la dirección en cada petición y, peor, la cabecera
// `apikey` y el token de sesión viajarían también a PokeAPI, que no tiene por
// qué verlos. El parámetro `engine` existe para los tests: en producción es
// `null` y Ktor elige el motor de la plataforma; un test le pasa un MockEngine.
internal fun createSupabaseClient(
config: AppConfig,
engine: HttpClientEngine? = null,
): HttpClient {
val configuracion: HttpClientConfig<*>.() -> Unit = {
// `ignoreUnknownKeys`: si el JSON trae un campo que la clase no declara,
// se ignora en vez de fallar.
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
install(HttpTimeout) {
requestTimeoutMillis = 15_000
connectTimeoutMillis = 10_000
}
defaultRequest {
// Todas las rutas relativas cuelgan de aquí.
url(config.supabaseUrl.trimEnd('/') + "/auth/v1/")
// `apikey` en todas las peticiones: sin ella, Supabase responde 401
// aunque el token de sesión sea correcto.
header("apikey", config.supabaseKey)
}
}
// `if` también da un valor: aquí, el cliente que se devuelve.
return if (engine == null) HttpClient(configuracion) else HttpClient(engine, configuracion)
}Paso 4 · Los tests de la API
Primero, la configuración de mentira. Estos tests usan ConfigDePrueba, el doble único que creaste en Configuración y secretos por entorno (shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/dobles/ConfigDePrueba.kt): hereda de la clase base y lleva una URL y una clave de Supabase cualquiera. No hay que crear nada.
Después, el fichero. SupabaseAuthApiTest.kt son siete tests con un MockEngine y una respuesta recortada de las que viste con curl: comprueban que la petición lleva la apikey y el grant_type, que refrescar usa el otro grant_type, que salir no lanza aunque el servidor falle y los errores de abajo. Repiten el patrón del cliente de PokeAPI y no aportan nada nuevo, así que se copian. Clic derecho sobre shared → New → File → src/commonTest/kotlin/com/sebastiannarvaez/pokedex/data/network/SupabaseAuthApiTest.kt (la carpeta ya existe), pulsa Copiar fichero, selecciona todo (Cmd+A) y pega (Cmd+V); el botón lo presentó Del JSON al Pokémon: DTO y repositorio.
(json(cuerpo, código), que usan los tests, es una pequeña función del propio fichero que arma el servidor de mentira con esa respuesta.) Lo que merece leerse son los dos que justifican el DTO tolerante del paso 1: Supabase no usa siempre el mismo campo para el mensaje (el servidor de autenticación usa msg, como viste con curl; otros endpoints, error_description; la puerta de entrada, message), y el test comprueba que llegue sea cual sea.
// `error` + `error_description`: la forma que usan otros endpoints de Supabase.
@Test
fun `un error con error_description llega al mensaje`() = runTest {
val cuerpo = """{"error":"invalid_grant","error_description":"Invalid login credentials"}"""
val api = KtorSupabaseAuthApi(
createSupabaseClient(ConfigDePrueba(), json(cuerpo, HttpStatusCode.BadRequest)),
)
val e = assertFailsWith<AuthException> { api.entrar("ash@pueblo-paleta.test", "malo") }
assertEquals(400, e.codigo)
assertEquals("Invalid login credentials", e.message)
}
// `msg`: la forma de `/signup` y de `/token`, la que viste con `curl`.
@Test
fun `un error con msg tambien`() = runTest {
val cuerpo = """{"code":422,"msg":"User already registered"}"""
val api = KtorSupabaseAuthApi(
createSupabaseClient(ConfigDePrueba(), json(cuerpo, HttpStatusCode.UnprocessableEntity)),
)
val e = assertFailsWith<AuthException> { api.registrar("ash@pueblo-paleta.test", "pikachu") }
assertEquals("User already registered", e.message)
}Los nombres de test entre acentos graves (`) admiten espacios y tildes, pero no comas: sin comas en ningún nombre, el mismo test compila en la JVM y en iOS.
Lanza las tres tareas de tests, sin filtro. iosSimulatorArm64Test solo corre en un Mac:
./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test
open shared/build/reports/tests/jvmTest/index.html
open shared/build/reports/tests/testAndroidHostTest/index.html
open shared/build/reports/tests/iosSimulatorArm64Test/index.htmlEl recuadro tests de cada informe dice cuántos se han ejecutado. Salen 87 en la JVM, 51 en Android host y 54 en iOS: los 80 / 44 / 47 que traías, más los siete de la API en las tres plataformas.
Provócalo tú. Ahora que tienes los tests, vuelve a AuthDto.kt, quita = 3600 del campo expiresIn y lanza otra vez las tres tareas: pasan todos, porque ninguno manda una respuesta sin expires_in. Es la prueba de que un test solo vigila lo que se le ha ocurrido vigilar. Vuelve a poner = 3600.
Punto de control
- En
shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/estándata/network/dto/AuthDto.kt,domain/Session.ktydata/network/SupabaseAuthApi.kt, y enshared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/data/network/,SupabaseAuthApiTest.kt. - Las tres tareas sin filtro terminan en
BUILD SUCCESSFUL, y los recuadros tests de los informes dan 87 en la JVM, 51 en Android host y 54 en iOS. Ningún nombre de test lleva comas. SessionDto.expiresInconserva su= 3600yAuthErrorDtotiene todos los campos opcionales.- La app compila y arranca igual que antes: todavía no hay pantallas de sesión, y la API aún no está en el grafo de Koin (nadie la usa).
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Por qué
SessionguardaexpiresAtEpochSecondsy noexpiresIn?2.¿Por qué todos los campos de
AuthErrorDtoson opcionales?3.¿Por qué Supabase usa un
HttpClientdistinto del de PokeAPI?4.El cierre de sesión se traga los errores de red. ¿Por qué?
5.En
client.post("token") { parameter("grant_type", "password") … }, ¿qué haceparameter?