Firebase Cloud Functions

Firebase Cloud Functions Event Triggers

Learn how to use Cloud Functions to handle events triggered by Firebase services, such as Authentication, Firestore, & Storage triggers.

Marwa Diab
14 min read3 days ago

--

Cloud Functions for Firebase is a serverless framework that lets you automatically run backend code in response to events triggered by background events, HTTPS requests, the Admin SDK, or Cloud Scheduler jobs. Your JavaScript, TypeScript or Python code is stored on Google Cloud infrastructure and runs in a managed environment. There’s no need to manage and scale your own servers. ~ Firebase Documentation

When building mobile apps (iOS/Android) it’s important to consider backend development, this involves handling server events, optimizing operations, minimizing client-side server calls, and promptly responding to event triggers.

In this article, I will showcase some of the use cases I typically encounter while building apps that utilize Firebase services. In this example, you’ll learn how to handle Authentication, Firestore, and Storage events.

Use Cases

  1. When a new user is authenticated, create new document in users collection with auth data such as email, name, photoUrl, and providerData. (collection name can vary based on your app’s business logic)
  2. When a new document is created in Firestore, update the document with creationDate, and any other data required by the app upon document creation (e.g. isActive, isApproved, isAdmin, etc). Additionally, when a document is modified, update it with lastModifiedDate. These two cases are useful to ensure that you never need to worry about creation and modification dates when saving data from the client.
  3. When a user uploads an image (or any other file type) for a specific document, it will be uploaded in Firebase Storage in the path <collectionType>/<documentId/, The image’s URL will then be saved in the mediaUrl field within the document, allowing it to be downloaded and viewed in the app.

Prerequisites

  • Prior knowledge in Firebase services, such as Authentication, Firestore, and Storage.
  • Node.js (using nvm), and NPM installed (explanation provided in the attached video below).
  • Firebase Blaze Plan (note that Cloud Functions is not applicable in Spark Plan)

Project structure

Whenever I start a new mobile app/project, I create the following project structure:

MyAwesomeApp/
├─ AndroidApp/
├─ iOSApp/
├─ CloudFunctions/

The CloudFunctions folder is where the Firebase CLI will be installed.

Open Terminal, and navigate to CloudFunctions folder, you can easily obtain the full path by typing cd followed by a space, and then dragging the folder into the terminal. The folder path will be pasted.

Feel free to check out the following video tutorial by David East on the Firebase YouTube channel, I highly recommend watching it to:

  1. Understand the importance and the benefits of using Cloud Functions.
  2. Learn how to setup Firebase CLI, run Local Emulator Suite, and deploying Cloud Functions.
  3. Running functions asynchronously, and understand Promises.

Cloud Functions version

In this article, Cloud Function (2nd gen) will be used for Firestore and Storage triggers, while Cloud Function (1st gen) will be used for Auth triggers. The reason for this distinction is that Cloud Functions (2nd gen) doesn’t currently support Authentication triggers.

We recommend that you choose Cloud Functions (2nd gen) for new functions wherever possible. However, we plan to continue supporting Cloud Functions (1st gen). ~ Cloud Functions version comparison

Setup the CLI

As mentioned in the video

The CLI works as a project starter, setting up the boilerplate that you need to develop. It also works as a local emulator that spins up a local server that emulates a production environment and allows you to trigger different types of functions. It also serves as the Deployment Manager, allowing you to deploy all or even individual sets of functions.

Feel free to follow the steps in Set up your environment and the Firebase CLI and Initialize your project sections in the Cloud Functions Documentations, if you haven’t already followed the steps from the previous video.

Firebase Emulator

It’a a good practice to test Cloud Functions in Firebase Emulator before deploying changes to Firebase. To start the emulators, run firebase emulators:start in the command line.

Additionally, to import and export test data when running emulators (so that the data isn’t lost between sessions), add the following inside your package.json scripts.

"scripts": {
// other scripts...
"firebase:start:import": "npm run build && firebase emulators:start --import ../test-data --export-on-exit ../test-data",
}

This CLI command ensures that each time emulators are shut down, the data is persisted (exported) to the provided directory (in this case, test-data), and it imports data from the same directory each time the emulators start.

To test your functions, run the following command in the command line to build and start emulators with import and export options:

npm run firebase:start:import

Remember that you can use ^c (control+c) in the command line to exit the emulators.

Watch changes

To automatically recompile code with new changes, you can use the npm run build:watch command. It monitors the source files for modifications and triggers a rebuild of the project whenever any changes are detected. This feature is particularly useful during development, as it eliminates the need to manually rebuild the project each time you make edits, allowing you to see the results almost immediately.

Authentication Triggers

You can trigger functions in response to the creation and deletion of Firebase user accounts. For example, you could send a welcome email to a user who has just created an account in your app. ~ Firebase Authentication triggers.

Import the required modules and initialize Admin SDK:

import * as functions from "firebase-functions";
import * as admin from "firebase-admin";

admin.initializeApp();

onCreate

Add the following code for onAuthUserCreate trigger function that handles the creation of a Firebase Auth user:

  1. Create userData containing data to be updated in the user’s document. Add an isAuthenticated flag to indicate that the user is authenticated. feel free to update the document with any additional data your app requires upon authentication.
  2. Create a user DocumentReference using user.uid as the documentId, use set(data:options:) to write data to the document, this method creates the document if it doesn’t exist yet or merges data into an existing document.
export const onAuthUserCreate = functions.auth.user().onCreate(async user => {
// 1.
const userData: any = {
"email": user.email,
"isAuthenticated": true,
};

if (user.displayName !== null) {
userData["name"] = user.displayName
}
if (user.photoURL !== null) {
userData["photoUrl"] = user.photoURL
}

// 2.
const userDocRef = admin.firestore().doc(`users/${user.uid}`);
return await userDocRef.set(userData, {merge: true});
// TODO: Send a welcome email
});

onDelete

The Delete User Data extension (delete-user-data) lets you delete a user's data when the user is deleted from your Firebase project. You can configure this extension to delete user data from any or all of the following: Cloud Firestore, Realtime Database, or Cloud Storage. Each trigger of the extension to delete data is keyed to the user's UserId. ~ Using the Delete User Data extension

Before Firebase introduced the Delete User Data extension, I manually deleted all user data in Firestore within the onDelete trigger function. Now that the extension handles data deletion, the remaining task is to send the user a farewell email.

export const onAuthUserDelete = functions.auth.user().onDelete(async user => {
// TODO: Send a farewell email
});

Firestore Triggers

With Cloud Functions, you can handle events in Cloud Firestore with no need to update client code. You can make Cloud Firestore changes via the document snapshot interface. ~ Cloud Firestore triggers.

Warning: Any time you write to the same document that triggered a function, you are at risk of creating an infinite loop. Use caution and ensure that you safely exit the function when no change is needed.

This is a crucial warning to take into account, with further explanation provided in the onDocumentUpdated section below.

Import the following event handlers (onDocumentCreated, & onDocumentUpdated), Change class, and QueryDocumentSnapShot type:

import { 
onDocumentCreated,
onDocumentUpdated,
Change,
QueryDocumentSnapshot
} from "firebase-functions/v2/firestore";

onDocumentCreated

Add the following onUserDocumentCreate trigger function that handles a document creation in users collection in Firestore, within this function, update the newly created document with the following data:

  • updateTime The timestamp when the document was last updated (at the time the snapshot was generated).
  • isActive is a Boolean indicating whether this document is active. Feel free to use any default values that your app requires upon document creation. For example, you can check for a mediaUrl field, and if it’s null, set it with a placeholder image saved in Cloud Storage.
export const onUserDocumentCreate = onDocumentCreated("users/{userId}", (event) => {
return event.data?.ref.update({
creationDate: event.data.updateTime,
"isActive": true,
});
})

onDocumentUpdated

Add the following onUserDocumentUpdate trigger function that handles a document update in the users collection in Firestore, call the updateLastModifiedOnDocumentUpdate function and pass change.data, which holds the before-and-after changes made to the document.

export const onUserDocumentUpdate = onDocumentUpdated("users/{userId}", (change) => {
return updateLastModifiedOnDocumentUpdate(change.data)
})

Add the following updateLastModifiedOnDocumentUpdate(change:) function that handles updating the document’s modification date:

function updateLastModifiedOnDocumentUpdate(change: Change<QueryDocumentSnapshot> | undefined): any {
if (!change) { return null; }
// 1.
if (change.before.data().lastModifedDate) {
// 2.
if (change.before.data().lastModifedDate.isEqual(change.after.data().lastModifedDate)) {
return change.after.ref.update({
lastModifedDate: change.after.updateTime,
});
}
} else {
// 3.
return change.after.ref.update({
lastModifedDate: change.after.updateTime,
});
}
};

Here’s what happens in this code snippet:

  1. Check if lastModifiedDate field exists.
  2. If the lastModifiedDate exists, check if an update occurred and it was not triggered by lastModifiedDate field (before and after changes have the same value), then update lastModifiedDate with the snapshot timestamp.
  3. If the lastModifiedDate field does not exist, then insert new value.

The following condition is crucial to prevent infinite loop, it updates the document only if the update was triggered by any other field.

if (change.before.data().lastModifedDate.isEqual(change.after.data().lastModifedDate)) {}

Test it out

Run the emulators to test authentication and firestore triggers, navigate to the Authentication emulator, and click on Add User to create a new authenticated user, enter the necessary data and click Save. Next navigate to the Firestore emulator, where you’ll find a new user document created with the UID as the Document ID:

Create new authenticated user, and resulted user document in Firestore.

Storage Triggers

You can trigger a function in response to the uploading, updating, or deleting of files and folders in Cloud Storage. ~ Cloud Storage Triggers.

Based on previously mentioned use case for Firebase Storage, there are three types of URLs:

  1. Token URLs, These are persistent and have security features.
  2. Signed URLs, These are temporary and also have security features.
  3. Public URLs, These are persistent but lack security.

Import the following event handlers, and functions:

import { 
onObjectFinalized, onObjectDeleted
} from "firebase-functions/v2/storage";

import { getStorage, getDownloadURL } from "firebase-admin/storage"

onObjectFinalized

Add the following onFileFinalized trigger function that handles object creation in Cloud Storage:

export const onFileFinalized = onObjectFinalized(async (event) => {
// 1.
if (!event.data) { return null; }
// 2.
if (event.data.contentType == "application/octet-stream") { return null; }

const filePath = event.data.name;
const bucketName = event.data.bucket;
// 3.
if (event.data.name.includes("<collectionName>/")) {
const docCollection = event.data.name.split("/")[0];
const docId = event.data.name.split("/")[1];
const fileRef = getStorage().bucket(bucketName).file(filePath);

// TODO: check if running on emulator

//4.
const mediaUrl = await getDownloadURL(fileRef);
return await admin.firestore().doc(`${docCollection}/${docId}`).set({ "mediaUrl": mediaUrl }, { merge: true });
}
return null;
})

Here’s what happens in this code snippet:

  1. If event.data is null, then return null.
  2. If contentType is of application/octet-stream (indicating that the file is a stream of binary data for which the actual type is unknown or unspecified), then return null.
  3. Check if the file is uploaded to <collectionName>/ directory, If it is, retrieve the document collection and the documentId, and then generate a file storage reference.
  4. Retrieve the download URL for the generated file reference using the getDownloadURL(file:) function, and then set this URL in the document.

If you attempt to run the code on the Emulator, the following error will be thrown:

Error: Permission denied. No READ permission.

Emulator Limitations

The reason for this error is that the Firebase emulators are designed for local testing of Firebase applications, focusing on security rules and basic interactions. Some features, such as getDownloadURL(), may not be supported.

A workaround solution: unlike getDownloadURL(), publicUrl() is supported in Firebase Emulator, however, it does not contain a token, making it a good option for the local testing environment, but not recommended for production.

Replace // TODO: check if running on emulator comment with the following code, which checks if the user environment running in the Emulator, and then generate publicURL() instead:

if (process.env.FUNCTIONS_EMULATOR) {
console.log("Emulator detected. Switching to publicUrl.")
const publicUrl = fileRef.publicUrl()
return await admin.firestore().doc(`${docCollection}/${docId}`).set({ "mediaUrl": publicUrl }, { merge: true });
}

onObjectDeleted

Add the following onFileDeleted trigger function that handles object deletion in Cloud Storage:

export const onFileDeleted = onObjectDeleted(async (event) => {
if (event.data.name.includes("<collectionName>/")) {
const docCollection = event.data.name.split("/")[0];
const docId = event.data.name.split("/")[1];
return await admin.firestore().doc(`${docCollection}/${docId}`).set({"mediaUrl": null}, {merge: true})
}
return null;
})

Here’s what happens in this code snippet:

  1. Check if the file is deleted from <collectionName>/ directory, then retrieve the document collection and the documentId.
  2. Update mediaUrl with null in the document.

Using Signed URL

Using signedUrl() requires that Firebase Admin SDK to be initialized with a service account key that includes the client_email and the private_key fields, if Firebase Admin SDK is not properly initialized with the necessary credentials, the following error will be thrown:

Error: Cannot sign data without client_email.

Service account key can be downloaded form Firebase and saved securely in your project.

Obtain a Firebase service account key

In Firebase Console, select your project, navigate to Project settings, and select Service accounts tab, with Firebase Admin SDK selected, it will look like follows:

Firebase Admin SDK

Click on Generate new private key, you will be presented with the following warning, click on Generate key to download the JSON file:

Generate new private key

Create a new directory named secure under the functions directory, place the downloaded JSON file into the newly created secure directory, rename the JSON file to serviceAccountKey.json. Your project structure should look like this:

MyAwesomeApp/
├─ AndroidApp/
├─ iOSApp/
├─ CloudFunctions/
│ ├─ functions/
│ │ ├─ secure/
│ │ │ ├─ serviceAccountKey.json/

Remember to add secure/ to your gitignore before committing your code.

Initialize Firebase Admin SDK with the service account key

Update the code to initialize the Firebase Admin SDK with serviceAccountKey.json file. Replace admin.initializeApp() with the following:

// 1.
let serviceAccountPath = '../secure/serviceAccountKey.json';

// 2.
if (admin.apps.length === 0) {
admin.initializeApp({
// 3.
credential: admin.credential.cert(require(serviceAccountPath)),
storageBucket: 'your-storage-bucket-url'
});
}

Here’s what happens in this code snippet:

  1. Create path to Firebase service account key file.
  2. Check if the Firebase Admin SDK has not been initialized.
  3. Initialize the Firebase Admin with the service account key, and storageBucket.

Make sure to replace your-storage-bucket-url with your actual Firebase Storage bucket URL.

Add the following variable to toggle between using getDownloadURL, and getSignedUrl, (created for this article purposes only):

const useSignedUrl = true;

Go back to onFileFinalized trigger function and replace the code starting form if (process.env.FUNCTIONS_EMULATOR), with the following:

if (!useSignedUrl) {
if (process.env.FUNCTIONS_EMULATOR) {
console.log("Emulator detected. Switching to publicUrl.")
const publicUrl = fileRef.publicUrl()
console.log("Public URL: ", publicUrl)
return await admin.firestore().doc(`${docCollection}/${docId}`).set({ "mediaUrl": publicUrl }, { merge: true });
}

const mediaUrl = await getDownloadURL(fileRef)
console.log("Download URL: ", mediaUrl)
return await admin.firestore().doc(`${docCollection}/${docId}`).set({ "mediaUrl": mediaUrl }, { merge: true });
}

try {
const options: any = {
version: 'v4', // Use version 4 signing process
action: 'read', // Specify the action (read, write, delete, etc.)
expires: Date.now() + 15 * 60 * 1000, // 15 minutes from now
};
const [mediaUrl] = await fileRef.getSignedUrl(options)
console.log("Signed URL: ", mediaUrl)
return await admin.firestore().doc(`${docCollection}/${docId}`).set({ "mediaUrl": mediaUrl }, { merge: true });
} catch (error) {
console.error("Error generating signed URL: ", error);
}

Keep in mind that the maximum allowed expiration date is seven days ahead (604800 seconds).

Unlike getDownloadURL(file:), getSignedUrl(options:) is supported in Firebase Emulator, so you can test it in the Emulator.

Test it out

Change useSignedUrl to false to test getDownloadURL(), then run the emulators to test storage triggers. Considering the example of books/<DocumentID> as the path for both Firestore document path and file storage folder path. Navigate to the Storage emulator, and click on Upload File to add a new file, next navigate back to Firestore emulator, where you’ll a mediaUrl field created with the generated URL:

Adding file in Storage emulator

Since we’re running on a local environment, the Public URL will be printed out in the console like follows:

Public URL printed in console.

Change useSignedUrl back to true to test getSignedUrl(), and test again. The Signed URL will be printed out in the console like follows:

Signed URL printed in console.

In order to test getDownloadURL(), cloud functions must be deployed to Firebase. To do this, run the following command in the command line to deploy (you can find deploy script in package.json):

npm run deploy

After functions are successfully deployed to Firebase, go to the Firebase Console and navigate to the Functions section, where you’ll find all deployed functions like follows:

Deployed Function in Firestore

Go ahead and perform the same test you did previously in the emulators:

Adding file in Firebase Storage
Download URL printed out in Logs Explorer

Note: Remember to handle any errors or edge cases that may arise during the update process.

As Linus Torvalds once said: “Most good programmers do programming not because they expect to get paid or get adulation by the public, but because it is fun to program.”

Enjoy Coding 👩🏻‍💻♡

--

--