ksergio.com

I love coding

← Volver

Serverless con Firebase

25/1/2024

Voy a crear una app serverless que permite mostrar unas fotos publicas y otras privadas a distintos usuarios .

Ejemplo de la web desplegada aquí y el repositorio con el código.

- Firestore
- Hosting
- Storage
- Authentication
- Cloud functions

Los nombres son bastante descriptivos por si mismo, pero a continuación detallo como los he implementado en Vue.

Firestore

Estoy usando Vue 3 con composition API y Typescript (de una forma muy laxa).

Primero he creado un servicio para listar las fotos que voy a enseñar en el frontend.

//src/services/photo.service.ts

class  PhotosService {
    public  static  async  getPublicPhotos():  Promise<Photo[]> {
        // Construyo la consulta con los filtros que quiera
        const  q  =  query(collection(db, 'public_photos'))
        // Respuesta del servidor
        const  querySnapshot  =  await  getDocs(q)
        
        // Firestore devuelve "objetos" con los datos 
        // por un lado y el id separado.
        // Quiero juntarlos en el mismo objeto.
        
        const  publicPhotos:  Photo[] = []

        querySnapshot.forEach((doc) => {
            publicPhotos.push({
            id:  doc.id,
            ...doc.data()
            })
        })

        return  publicPhotos
    }
}

export { PhotosService }

Ahora simplemente podría llamar este servicio donde quiera y me devuelve los datos. Puedo usarlo directamente en componentes o crear un composable para reutilizarlo facilmente.

En este caso simplemente llamo el servicio cuando se monta el componente:

import { onMounted, ref } from  'vue';
import { PhotosService } from  '@/services/photos.service';

import { Photo } from  '@/types';

let  photos  =  ref<Photo[]>([])
    onMounted(async ()=>{
    photos.value  =  await  PhotosService.getPublicPhotos()
})

Esto me permite cambiar la implementación de como buscar las fotos cuando quiera sin tener que cambiar todo el código. Por ejemplo. Si quiero cambiar de backend, solo tendría que cambiar el servicio. El componente en el que lo uso, no sufriría ningún cambio.

Ahora si quiero seguir abstrayendo lo puedo hacer creando un composable tal que así

// src/composables/usePhotos.ts

import { ref, onMounted } from 'vue'
import { PhotosService } from '@/services/photo.service'
import { Photo } from '@/types'

const usePhotos = () => {
  const photos = ref<Photo[]>([])

  const fetchPhotos = async () => {
    photos.value = await PhotosService.getPublicPhotos();
  }

  onMounted(fetchPhotos);

  return {
    photos,
    fetchPhotos,
  }

Ahora podría llamar en el componente simplemente

const {photos, fetchPhotos} = usePhotos()

Usando photos como un ref normal y corriente, y fetchPhotos() cuando desee refrescarlas.

Storage

Sin entrar mucho en detalles, voy a crear otro metodo dentro del serivicio de fotos, pero esta vez, quiero descargarme las fotos almacendas en el Storage de Firebase.

Se tienen que descargar en el momento, porque estas fotoso son privadas. No tienen un link de descarga accessible. Se debe descargar a travez del SDK de firebase.

//src/services/photo.service.ts

class  PhotosService {
    ...otros métodos

    public  static  async  getSharedPhotosBlobs(userId:  string):  Promise<Blob[]> {
        // Solo los documentos que tengan el userId como authorizado
        const  authorizedFoldersQuery  =  query(
            collection(db, 'shared'),
            where('authorizedUsersId', 'array-contains', userId)
        )
        
        // La respuesta de Firestore
        const  authorizedFoldersSnapshot  =  await  getDocs(authorizedFoldersQuery);

        // Voy a crear un array de Blobs para enviar al frontend
        const  blobs:  Blob[] = [];
        
        // Recorrer las carpetas autorizadas
        for (const  folderDoc  of  authorizedFoldersSnapshot.docs) {
            const  folderId  =  folderDoc.id;
            // Obtener la lista de fotos en la carpeta del storage
            const  storage  =  getStorage();
            const  storageRef  =  ref(storage, `shared/${folderId}/`);
            const  photosList  =  await  listAll(storageRef);
            // Descargar cada foto (excluyendo archivos con extensión .gf)
                for (const  photoRef  of  photosList.items) {
                    if (!photoRef.name.endsWith('.gf')) {
                    const  blob  =  await  getBlob(photoRef);
                    blobs.push(blob);
                    }
                }
        }

El archivo .gf es un archivo interno que tengo para poder crear carpetas y visualizarlas. El storage de Firebase no tiene carpetas, se guia por el nombre de archivo delimitado con el caracter /. Por eso tengo que crear algo con , en este caso, la ruta /shared/{nombre_de_la_carpeta}/.gf. Así el storage entiende que existe una carpeta {nombre_de_la_carpeta} . El archivo .gf siempre lo voy a excluir porque no sirve para nada más.

Aqui las funciones importantes son

- getStorage
- ref
- listAll
- getBlob

getStorage() crea una referencia al bucket.

ref() Es una referencia (podemos considerarlo como un puntero que apunta a la localización donde queremos mirar en el bucket. La ruta, pero no el archivo).

listAll() Lista todos los archivos de donde se este apuntando con el ref.

getBlob() descarga el contenido apuntado por ref como un archivo blob.

Cloud functions

Cuando inicias el proyecto de firebase con firebase init y seleccionamos la opcion de Cloud functions. Se nos crea en el proyecto un directorio functions como un proyecto de node.

Aqui dentro en index.js se tienen que exportar las funciones que se quieren utilizar.

Podemos crear archivos separados importarlos en index.js y luego exportarlos todos allí.

Basicamente podemos crear funciones que se accionen por un trigger que ocurra en un servicio de Firebase.

Existen dos versiones de funciones la v1 y v2.

const  functionsv1  =  require('firebase-functions')
const  functionsv2  =  require('firebase-functions/v2')



// Podemos inicializar variables
// que reutilizemos en distintas funciones.

// Este SDK de admin funciona con privilegios mayores.
const  admin  =  require('firebase-admin')
admin.initializeApp()

const  db  =  admin.firestore()


// Aqui exporto la función
exports.saveUserToFirestore  =  functionsv1.region('europe-west3').auth.user().onCreate(async  user  => {
    await  db.collection('users').doc(user.uid).set({
    email:  user.email,
    fechaRegistro:  FieldValue.serverTimestamp(),
    admin:  false,
    })
})

Esta función se lanza cuando un usuario se crea en el serivicio de Firebase de Authentication.

Lo que hace es crear un firestore un documento con los datos del nuevo usuario que se ha creado.

Aquí hay una utilidad de las cloud functions, que nos da acceso a los claims de los usuarios, que de otra forma no tendríamos. Estos claims solo se pueden setear desde el SDK de admin (que no se puede usar en nuestro frontend).

Aquí dejo un ejemplo de como escucho los cambios en la coleccion que tiene los usuarios. Cuando detecta el cambio, comprueba el campo admin y dependiendo si es true o false, genera un custom claim en el usuario.

Ahora cuando sacamos el objeto user en nuestro frontend, podemos pedirle el token de ese suario con getToken() y dentro de este token estarán las custom claims, en este caso dentro del token existirá un campo admin .

exports.updateAdminClaim  =  functionsv1.region('europe-west3').firestore
.document('users/{userId}')
.onUpdate(async (change, context) => {
    const  userId  =  context.params.userId;
    const  newValue  =  change.after.data().admin;
    const  previousValue  =  change.before.data().admin;

    // Verifica si el campo admin ha cambiado
    if (newValue  !==  previousValue) {
        try {
            const  user  =  await  admin.auth().getUser(userId);
            // Agregar o quitar el custom claim según el valor del campo admin
            if (newValue) {
                await  admin.auth().setCustomUserClaims(userId, { admin:  true });
            } else {
                await  admin.auth().setCustomUserClaims(userId, { admin:  false });
            }
            logger.log(`Custom claim 'admin' actualizado para el usuario ${userId}.`);
            return  null;
        } catch (error) {
            logger.error('Error al actualizar el custom claim:', error);
            return  null;
        }
    }
    return  null;
});

También podemos invocar funciones directamente por HTTP como si fuera un servidor de node

exports.createZip  =  functionsv1.region('europe-west3').https.onRequest(async (req, res) => {
    // La funcion iría aquí
}

Cabe a destacar que se puede escoger la region donde invocar la función para minimizar la latencia.

Autenticacion

Para manejarla en el frontend estoy usando un store hecho con pinia.

// Esta funcion escucha cambios en la autenticacion de firebase
import { onAuthStateChanged } from  'firebase/auth'

export  const  useAuthStore  =  defineStore('auth', () => {
    const  userId  =  ref()
    const  user  =  ref()
    const  admin  =  ref(false)

    onAuthStateChanged(auth, async (changedUser) => {
        if (changedUser) {
            user.value  =  changedUser.email
            // Custom claims de admin
            const  token  =  await  changedUser.getIdTokenResult()
            admin.value  =  token.claims.admin  as  boolean
        } else {
            user.value  =  ''
            admin.value  =  false
            userId.value  =  null
            }
        })


    return { user, admin, userId }
})

Hosting

Hacer build del proyecto de frontend y tenerlo en la carpeta que se haya configurado con firebase init. Solo queda hacer

firebase deploy

y ya está!