Saltar al contenido
K
Autenticación con SupabasePaso a paso28 min Avanzado

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.

shared
src/commonMain/kotlin/com/sebastiannarvaez/pokedex
data/network/dto/AuthDto.ktse crea (paso 1)
domain/Session.ktse crea (paso 2)
data/network/SupabaseAuthApi.ktse crea (paso 3)
src/commonTest/kotlin/com/sebastiannarvaez/pokedex
data/network/SupabaseAuthApiTest.ktse crea (paso 4)
Todo en `shared`. Vista Project.

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.kt

El 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:

ejemplo mínimo: el primer campo relleno, con ?: encadenado (con comentarios)
// 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
null

La 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.

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/dto/AuthDto.kt
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:

ejemplo mínimo: un cupón que vale una hora desde que llega (con comentarios)
// 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
true

El 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.

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/domain/Session.kt
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.kt

El 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:

ejemplo mínimo: un POST con cuerpo JSON en Ktor (con comentarios)
// 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: ok

Con 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:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/SupabaseAuthApi.kt: package, imports, interfaz y errores
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:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/SupabaseAuthApi.kt: la implementación
// 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.

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/SupabaseAuthApi.kt: el cliente
// 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.

SupabaseAuthApiTest.kt

(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.

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/data/network/SupabaseAuthApiTest.kt: los dos tests de los errores
    // `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.html

El 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án data/network/dto/AuthDto.kt, domain/Session.kt y data/network/SupabaseAuthApi.kt, y en shared/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.expiresIn conserva su = 3600 y AuthErrorDto tiene 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. 1.¿Por qué Session guarda expiresAtEpochSeconds y no expiresIn?

  2. 2.¿Por qué todos los campos de AuthErrorDto son opcionales?

  3. 3.¿Por qué Supabase usa un HttpClient distinto del de PokeAPI?

  4. 4.El cierre de sesión se traga los errores de red. ¿Por qué?

  5. 5.En client.post("token") { parameter("grant_type", "password") … }, ¿qué hace parameter?

Faltan 5 de 5