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á!