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 aplicaciones SPA. Puesto que hay que ser capaz de usar cualquier API 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 idy 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.

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 y prefer: count=exact en el header. El resultado se recibe en el header content-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.

https://supabase.com/docs/guides/database/json

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:

  1. Habilitar o asegurarse de que está habilitada la autenticación por contraseña.

  2. Ir a la parte de SQL Editor y ejecutar el Quickstart 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.

  3. Ir al API Docs y mirar cómo se hace la autenticación por Bash.

  4. Crear funciones para autenticar y registrar usuarios con el método descrito en el paso anterior.

  5. Crear formularios para llamar a esas funciones.

  6. Guardar el token después de la autenticación en Localstorage.

  7. Modificar las funciones ya creadas para usar el tokenen cada petición.

  8. Modificar las RLS de las tablas para protegerlas de las peticiones que no tengan el token.

  9. Aplicar funciones para gestionar la duración de las sesiones, el logout…

  10. Gestionar los perfiles de usuario como una tabla más llamada profiles y un bucket avatars creados automáticamente con el User Management Starter.

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

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

Subida de una Imagen a Storage#

const headersFile = {
    "apiKey": SUPABASE_KEY,
    "Authorization": `Bearer ${token}`,
    "x-upsert": true
};
let response = await fetch(`${urlBase}${url}`, {
    method: 'POST',
    headers: headersFile,
    body: formImg
});
if (response.status >= 200 && response.status <= 300) {
    if (response.headers.get("content-type")) {
        let datos = await response.json();
        datos.urlAvatar = `${urlBase}${url}`;
        return datos;
    }
    return {};
}

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. Se debe agregar en la sección de Database > Replication 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

Ejemplo de Comunicación “Broadcast”#

Supabase permite la comunicación directa con otros usuarios o 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},
      })
   }); 
  })