Supabase: (Backend como Servicio)#
Supabase es un Backend como Servicio (BaaS) que proporciona un conjunto de herramientas para desarrollar aplicaciones web sin la necesidad de implementar un backend completo. Este servicio incluye características como SDK, analíticas, autenticación, bases de datos, almacenamiento, dashboard, WebSockets, entre otros.
Supabase utiliza PostgreSQL como su motor de base de datos, permitiendo a los desarrolladores aprovechar todas las capacidades de esta robusta base de datos relacional.
Ofrece varios tipos de APIs
y un SDK
que facilita la interacción con el backend desde el cliente. También soporta el almacenamiento de archivos estáticos y permite la ejecución de funciones definidas por el usuario a través de RPC (Remote Procedure Call).
Supabase será útil en este curso porque nos permite tener un Backend sin programarlo. Lo configuraremos mínimamente para usar su
API REST
y crear aplicacionesSPA
. Puesto que hay que ser capaz de usar cualquierAPI Rest
, no usaremos el SDK de Supabase inicialmente.
Gestión de Tablas y API#
Cuando se crea una tabla en Supabase, se genera automáticamente una API para interactuar con ella. Puedes ver la API generada en el SDK o en Bash. Aquí utilizaremos ejemplos en Bash para evitar la instalación del SDK.
curl 'https://bqhnvwfovmcxfxr.supabase.co/rest/v1/graphs?select=id' \
-H "apikey: tu_apikey" \
-H "Authorization: Bearer tu_token"
Este comando curl
realiza una petición GET a la tabla graphs
para obtener el campo id
.
En JavaScript, se puede hacer una solicitud similar usando fetch
:
(()=>{
fetch('https://bqhnvwfovmcxfxr.supabase.co/rest/v1/graphs', {
method: 'GET',
headers: {
"apiKey": "tu_apikey",
"Authorization": "Bearer tu_token"
}
})
})();
Al igual que el ‘verbo’ GET
se pueden usar todos los demás de HTTP
para crear, actualizar, o borrar registros en la base de datos. Cómo usarlos está documentado en la API Docs
autogenerada por Supabase para cada tabla. Algunas operaciones más complejas como filtros avanzados no las documentan automáticamente, pero se pueden consultar en la documentación genérica.
Select#
Si no queremos todas las columnas o queremos los datos con otro formato, podemos pedir que realice un SELECT
, al fin y al cabo es una base de datos relacional. La sintaxis se puede ver en estos ejemplos:
https://bqhnvwfovmcxfxr.supabase.co/rest/v1/profiles?select=username,telefono,avatar_url,empresa:empresas(*),id
En este ejemplo obtenemos algunos datos típicos de un profile
y todos los datos de la empresa, la cual está relacionada con una clave que Supabase conoce. Esto producirá una subconsulta y no solo retornará el id
, que es lo que hay en la columna empresa
, si no también otros datos.
https://bqhnvwfovmcxfxr.supabase.co/rest/v1/empresas?select=nombre,dirección,telefono,c_postal,localidad,cif,email,web,presidente,coordinadores:coordinacion_empresa(profiles(id,username)),socios:empresas_socios(socios(id,nombre)),id
En este ejemplo hay un SELECT
complejo en el que se piden datos que están en la tabla, pero luego hay una subconsulta en la que se piden los coordinadores de esa empresa, que están en una tabla intermedia coordinacion_empresa
. Como Supabase conoce las claves ajenas, retorna los profiles
que están en esa relación muchos a muchos. Además, de estos extrae el id
y el username
. Lo mismo para con los socios
, la tabla intermedia empresas_socios
y la tabla socios
, de la que queremos el id y el nombre.
https://bqhnvwfovmcxfxr.supabase.co/rest/v1/profiles?select=*&id=eq.8662025a-25be-4777-ae54-66cb6111129e
En este ejemplo ponemos una condición simple de búsqueda, lo que será el WHERE
en la consulta. En ella pedimos que el id
cumpla la condición de ser igual (eq
) que un uuid
determinado separado por un punto.
https://bqhnvwfovmcxfxr.supabase.co/rest/v1/teams?select=*,players(*,goals(*))&team_id=eq.50124
En el ejemplo anterior obtenemos el equipo con una condición y aprovechamos que players
tiene una clave ajena a teams
, al igual que goals
con players
para conseguir todos los datos de los players
y goals
de ese equipo. Esta consulta funciona siempre que las relaciones están bien establecidas en las tablas.
Se pueden poner condiciones más complejas. En ese caso es mejor consultar la documentación, pero aquí tenemos algún ejemplo:
Pedir la cantidad (count): Método
HEAD
yprefer: count=exact
en el header. El resultado se recibe en el headercontent-range
.Consultas con “like”:
products?select=*&identifier=like.%AX%
Consultas con OR lógico:
select=*&or=(id.eq.464, id.eq.466)
En cualquier caso, si queremos saber cómo se traduce una petición en la API REST, podemos usar esta herramienta: https://supabase.com/docs/guides/api/sql-to-rest
POST#
Sirve para crear nuevos registros en una tabla. En la URL se pone la tabla, el verbo POST
y en el BODY
se ponen los datos en formato JSON
.
let response = await fetch(`https://bqhnvwfovmcxfxr.supabase.co/rest/v1/movies`,{
method: `POST`,
headers: {
"apiKey": SUPABASE_KEY,
"Content-Type": "application/json",
"Authorization" :"Bearer "+token,
"Prefer" : "return=representation"
};
body: JSON.stringify(body);
});
PATCH#
Funciona igual que el POST
,pero hay que indicar en la URL el id
o otro campo de filtro para modificar los que cumplan esa condición:
let response = await fetch(`https://bqhnvwfovmcxfxr.supabase.co/rest/v1/movies?id=eq.${id}`,{
method: `PATCH`,
headers: {
"apiKey": SUPABASE_KEY,
"Content-Type": "application/json",
"Authorization" :"Bearer "+token,
"Prefer" : "return=representation"
};
body: JSON.stringify(body);
});
Además, tenemos DELETE
con la sintaxis de PATCH
para eliminar.
Almacenamiento de JSON#
En Supabase, el primer nivel de los objetos se guarda como columnas en la tabla. Si los objetos tienen subniveles o se realizan consultas a otras tablas, se guardan en formato JSON como texto.
Autenticación#
Las tablas públicas son accesibles con la apiKey pública. Sin embargo, al estar en el cliente, cualquiera podría ver esta apiKey en el código. Para proteger tablas privadas, se deben crear usuarios y aplicar métodos de autenticación y autorización.
En resumen, para implementar la autenticación en Supabase hay que seguir unos pasos:
Habilitar o asegurarse de que está habilitada la autenticación por contraseña.
Ir a la parte de
SQL Editor
y ejecutar elQuickstart
User Management Starter
. Esto no es preciso para la autenticación, pero prepara la base de datos para mantener los perfiles de los usuarios y sus avatares. Si lo hacemos desde el principio, todos los usuarios funcionarán correctamente.Ir al
API Docs
y mirar cómo se hace la autenticación porBash
.Crear funciones para autenticar y registrar usuarios con el método descrito en el paso anterior.
Crear formularios para llamar a esas funciones.
Guardar el
token
después de la autenticación enLocalstorage
.Modificar las funciones ya creadas para usar el
token
en cada petición.Modificar las
RLS
de las tablas para protegerlas de las peticiones que no tengan el token.Aplicar funciones para gestionar la duración de las sesiones, el logout…
Gestionar los perfiles de usuario como una tabla más llamada
profiles
y un bucketavatars
creados automáticamente con elUser Management Starter
.
Los endpoints para registrar, hacer login o refrescar la sesión los encontraremos en el API Docs
en la sección de User Management
. Como veremos a continuación, no son como los de las tablas:
https://<id proyecto>.supabase.co/auth/v1/signup
https://<id proyecto>.supabase.co/auth/v1/token?grant_type=password
https://<id proyecto>.supabase.co/auth/v1/token?grant_type=refresh_token
Activación de RLS (Row Level Security)#
Primero, es necesario activar RLS en las tablas y crear políticas para controlar el acceso. Por ejemplo, podemos permitir que solo los usuarios autenticados puedan leer y escribir en una tabla.
Las
RLS
son muy interesantes y, por si solas casi permiten configurar el comportamiento de un backend completo. No obstante, por cuestión de tiempo y enfoque, para el curso las haremos simples y permisivas para todos los usuarios con la única condición de que estén autenticados.
Autenticación por Email y Contraseña#
https://www.youtube.com/watch?v=pi33WDrgfpI
Podemos invitar usuarios a través de la interfaz o crear un formulario de inscripción. En cualquier caso, es necesario que el usuario confirme su email.
En el caso de la invitación, la redirección por defecto es a localhost:3000
, pero esto se puede modificar. Una vez confirmado, el usuario recibirá un token que se puede probar en jwt.io. Este token se usará en la cabecera Authorization: Bearer
.
El token lo podemos guardar en localstorage
para recuperar cada vez que se haga una petición.
Gestión del Tiempo de Sesión#
Después de iniciar sesión, guardamos el token en LocalStorage
para realizar peticiones. Cada inicio de sesión retorna un tiempo de expiración (expires_in
y expires_at
). Podemos calcular la fecha de expiración y evitar confiar en el token después de esta fecha.
El refresh token
(token de actualización) se utiliza para obtener un nuevo access token cuando el anterior ha caducado. A diferencia del access token, el refresh token tiene una vida útil más larga (por ejemplo, varios días o semanas).
Flujo de trabajo:
El usuario se autentica con sus credenciales.
El servidor emite dos tokens:
Un access token (para acceder a recursos protegidos).
Un refresh token (para obtener un nuevo access token cuando el anterior expire).
Cuando el access token caduca, el cliente utiliza el refresh token para solicitar al servidor un nuevo access token.
El servidor verifica el refresh token (que usualmente se almacena de manera segura en el lado del cliente, por ejemplo, en cookies o almacenamiento local) y, si es válido, emite un nuevo access token. Este proceso no requiere que el usuario se vuelva a autenticar.
Además, se puede configurar un tiempo de inactividad en el cual, si el usuario no hace nuevas peticiones, el token queda revocado. Siempre puede utilizar el refresh token
para volver a obtener un token válido.
Un Refesh token es de un solo uso, al utilizarlo hay que volver a obtener uno nuevo.
Gestión de Perfiles de Usuario#
Es común que los usuarios necesiten gestionar información personal como avatar, dirección, teléfono, etc. Para ello, Supabase proporciona un script SQL (Quickstart
User Management Starter
) que crea una tabla de perfiles de usuario y asocia la información correspondiente. Este script crea unos triggers
en la base de datos para crear una fila en profiles
cada vez que un usuario se registra.
Cuando un usuario inicia sesión, obtenemos sus datos de perfil y los almacenamos en LocalStorage
:
async function getProfile(){
let access_token = localStorage.getItem('access_token');
let uid = localStorage.getItem('uid');
let responseGet = await getData(`profiles?id=eq.${uid}&select=*`,access_token); // Función que simplifica el fetch
let avatar_url = responseGet[0].avatar_url;
responseGet[0].avatar_blob = false;
if (avatar_url){
let imageBlob = await getFileRequest(avatar_url,access_token); // Función que simplifica el fetch a un bucket.
console.log(imageBlob);
if(imageBlob instanceof Blob){
responseGet[0].avatar_blob = URL.createObjectURL( imageBlob );
}
}
return responseGet;
}
Esta función, además, pide el fichero del avatar.
Gestión de Avatares#
Los avatares se guardan en “Storage”. Es necesario crear un bucket con permisos de escritura y actualización para los usuarios autenticados. Para subir imágenes, se realiza un POST con un FormData
que contiene la imagen.
async function updateProfile(profile) {
let access_token = localStorage.getItem('access_token');
let uid = localStorage.getItem('uid');
let formImg = new FormData();
formImg.append("avatar", profile.avatar, 'avatarProfile.png');
let avatarResponse = await fileRequest(`/storage/v1/object/avatars/avatar${uid}.png`, formImg, access_token);
profile.avatar_url = avatarResponse.urlAvatar;
delete profile.avatar;
let responseUpdate = await updateData(`profiles?id=eq.${uid}&select=*`, access_token, profile);
}
En el ejemplo anterior se supone que hay una función que sube la imagen. Lo que nos interesa es que la subida genera una url que será la que se guarde en la columna de avatar, en vez de la imagen.
Storage#
Subida de una Imagen a Storage#
Aquí hay un ejemplo de cómo podemos subir una imagen a storage. Veamos que añadimos "x-upsert": true
para permitir reescribir. Luego añadimos la imagen a un formData
, ya que supabase la necesita como de un formulario. Finalmente se sube a la url correpondiente con el bucket y el nombre.
const headersFile = {
"apiKey": SUPABASE_KEY,
"Authorization": `Bearer ${token}`,
"x-upsert": true
};
const formData = new FormData();
formData.append('file', imageBlob, fileName);
let responseSupa = await fetch(`https://${urlProject}.supabase.co/storage/v1/object/${bucket}/${fileName}.png`, {
method: 'POST',
headers: headersFile,
body: formData
});
Descarga de una Imagen#
Si no se necesita autorización, se puede utilizar un bucket público y la URL proporcionada. Si se requiere autorización, se debe enviar la API key y el token.
const headersFile = {
"apiKey": SUPABASE_KEY,
"Authorization": `Bearer ${token}`
};
let response = await fetch(`${url}`, {
method: 'GET',
headers: headersFile
});
if (response.status >= 200 && response.status <= 300) {
if (response.headers.get("content-type")) {
let datos = await response.blob();
return datos;
}
return {};
}
let avatar_url = responseGet[0].avatar_url;
responseGet[0].avatar_blob = URL.createObjectURL(await getFileRequest(avatar_url, access_token));
WebSocket#
Supabase ofrece una función llamada Realtime para manejar WebSockets. Es una buena manera de practicar con un servidor capaz de manejarlos. Permite varias formas como Broadcast
para enviar mensajes entre clientes, Presence
para sincronizar estados entre clientes o Postgres Changes
para escuchar cambios en tablas de la base de datos. Si quisiéramos hacer un juego, por ejemplo, podemos usar las dos primeras opciones, y para tener websocket
pero con persistencia usaríamos la tercera opción.
Para reaccionar a los cambios en la base de datos o publicar datos en tiempo real se debe agregar en la sección de Database > Publications
las tablas que queremos monitorear. Es muy recomendable utilizar las librerías proporcionadas por Supabase para esto.
https://supabase.com/docs/guides/realtime/postgres-changes
El SDK
simplifica mucho la conexión al websocket. La idea es crear un Channel
que escuche los cambios en una tabla de la base de datos. Esos cambios pueden filtrarse por tipo de evento o por filtros como el un select
. Este canal tiene la función subscribe()
, que no es igual que la de los Observables
. Esta función activa el Websocket
y ejecuta la función que indiquemos en la función on()
.
Ejemplo de Comunicación “Broadcast”#
En este ejemplo se puede observar cómo Supabase permite la notificación de cambios en las tablas mediante WebSockets.
const supabaseRealTimeClient = supabase.createClient(supabaseUrl, SUPABASE_KEY);
const currentUser = Math.random()+"";
const channel = supabaseRealTimeClient.channel('chat-db')
function renderMessage(message){
const messageContainer = document.getElementById('message-container');
const messageElement = document.createElement('div');
messageElement.classList.add('message');
messageElement.classList.add(message.user === currentUser ? 'outgoing' : 'incoming');
const messageContent = document.createElement('span');
messageContent.classList.add('message-content');
messageContent.textContent = message.message;
messageElement.appendChild(messageContent);
messageContainer.appendChild(messageElement);
// Hacer scroll hacia abajo para mostrar el último mensaje
messageContainer.scrollTop = messageContainer.scrollHeight;
}
channel
.on(
'postgres_changes', {
event: '*',
schema: 'public',
table: 'chat',
}, (payload) => {
console.log(payload);
renderMessage(payload.new)
}).subscribe((status) => console.log(status))
async function downloadHistoric(){
const { data, error } = await supabaseRealTimeClient.from("chat").select();
// console.log(data);
data.forEach(renderMessage);
}
document.addEventListener("DOMContentLoaded",()=>{
downloadHistoric();
document.querySelector("#send-button").addEventListener("click", async ()=>{
let message = document.querySelector('#message-input').value;
const { data, error } = await supabaseRealTimeClient.from("chat").insert([
{
user: currentUser,
message: message
},
]);
});
})
Ejemplo de Chat con Mensajes Guardados#
Supabase facilita la implementación de un sistema de chat donde los mensajes se guardan en la base de datos. Puedes modificar el chat para mantener la sesión del usuario en cookies.
const supabaseRealTimeClient = supabase.createClient(supabaseUrl, SUPABASE_KEY);
const currentUser = Math.random()+"";
const channel = supabaseRealTimeClient.channel('chat', {
config: {
broadcast: {
self: true,
},
},
})
channel.subscribe()
.on('broadcast', { event: 'supa' }, (payload) => {
console.log(payload);
const messageContainer = document.getElementById('message-container');
const messageElement = document.createElement('div');
messageElement.classList.add('message');
messageElement.classList.add(payload.payload.user === currentUser ? 'outgoing' : 'incoming');
const messageContent = document.createElement('span');
messageContent.classList.add('message-content');
messageContent.textContent = payload.payload.message;
messageElement.appendChild(messageContent);
messageContainer.appendChild(messageElement);
// Hacer scroll hacia abajo para mostrar el último mensaje
messageContainer.scrollTop = messageContainer.scrollHeight;
})
document.addEventListener("DOMContentLoaded",()=>{
document.querySelector("#send-button").addEventListener("click",()=>{
let message = document.querySelector('#message-input').value;
channel.send({
type: 'broadcast',
event: 'supa',
payload: {message,user:currentUser},
})
});
})
El objeto payload
contiene información relevante sobre el evento de cambio, y típicamente incluye las siguientes propiedades:
new
: Los datos completos de la fila después de la operación.old
: Los datos completos de la fila antes de la operación (solo disponible para operaciones comoUPDATE
oDELETE
).schema
: El esquema en el que ocurrió el evento (generalmentepublic
).table
: El nombre de la tabla en la que ocurrió el evento.eventType
: El tipo de evento (INSERT
,UPDATE
, oDELETE
).commit_timestamp
: Marca de tiempo de cuando ocurrió el evento.errors
: Cualquier error relacionado con el evento (si los hay).