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.
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
- When a new user is authenticated, create new document in
users
collection with auth data such asemail
,name
,photoUrl
, andproviderData
. (collection name can vary based on your app’s business logic) - 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 withlastModifiedDate
. These two cases are useful to ensure that you never need to worry about creation and modification dates when saving data from the client. - 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 themediaUrl
field within the document, allowing it to be downloaded and viewed in the app.
I’ve written a comprehensive series of articles on implementing Firebase Authentication. Feel free to check them out if you haven’t already!
In SwiftUI apps:
And in Jetpack Compose apps:
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:
- Understand the importance and the benefits of using Cloud Functions.
- Learn how to setup Firebase CLI, run Local Emulator Suite, and deploying Cloud Functions.
- 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:
- Create
userData
containing data to be updated in theuser
’s document. Add anisAuthenticated
flag to indicate that the user is authenticated. feel free to update the document with any additional data your app requires upon authentication. - Create a user
DocumentReference
usinguser.uid
as thedocumentId
, useset(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'sUserId
. ~ 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 amediaUrl
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:
- Check if
lastModifiedDate
field exists. - If the
lastModifiedDate
exists, check if an update occurred and it was not triggered bylastModifiedDate
field (before and after changes have the same value), then updatelastModifiedDate
with the snapshot timestamp. - 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
:
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:
- Token URLs, These are persistent and have security features.
- Signed URLs, These are temporary and also have security features.
- 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:
- If
event.data
is null, then return null. - If
contentType
is ofapplication/octet-stream
(indicating that the file is a stream of binary data for which the actual type is unknown or unspecified), then return null. - Check if the file is uploaded to
<collectionName>/
directory, If it is, retrieve the document collection and thedocumentId
, and then generate a file storage reference. - 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:
- Check if the file is deleted from
<collectionName>/
directory, then retrieve the document collection and thedocumentId
. - 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:
Click on Generate new private key, you will be presented with the following warning, click on Generate key to download the JSON file:
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 yourgitignore
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:
- Create path to Firebase service account key file.
- Check if the Firebase Admin SDK has not been initialized.
- 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:
Since we’re running on a local environment, the Public URL will be printed out in the console like follows:
Change useSignedUrl
back to true
to test getSignedUrl()
, and test again. The Signed URL will be printed out in the console like follows:
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:
Go ahead and perform the same test you did previously in the emulators:
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 👩🏻💻♡
Resources
“ Everyone has something to learn. Everyone has something to teach.” ~ Paul Hudson