Ebenezer Don Full-stack software engineer with a passion for building meaningful products that ease the lives of users.

Building a REST API with Firebase cloud functions, TypeScript, and Firestore

13 min read 3658

Firebase and TypeScript Logos Overlapping

Firebase is one of the leading solutions for building serverless apps. It offers developers a way to easily develop scalable, high-quality apps without worrying about the cost of setting up and maintaining servers over the long term from the onset. Firebase also offers easy integration with Google services like Google Analytics and the Firebase document database, Firestore.

In this article, we’ll see how to build a REST API with Firebase cloud functions, TypeScript, and Firestore. You’ll also need a bit of knowledge about Express.js to build our demo app. However, you don’t need to be a pro in any of the aforementioned technologies to follow along. Our demo app will be a journal API that’ll be able to accept new entries, and retrieve, update, and delete existing entries.

Setting up Firebase

To get started with our app, you’ll need to have Node.js installed on your computer. Click here to see the installation guide for Node.js. If you already have it installed, running the following command on your terminal should return your current Node.js version:

node --version

To follow this tutorial without any warnings, you’ll need to install the same version of Node.js we’ll use in the Firebase cloud. Currently, Firebase SDK supports Node.js 12 and 10. Let’s use version 12 for our app. The Node.js installation comes with npm, which we’ll use to install the Firebase CLI. Let’s run the following command on our terminal to do that:

npm install -g firebase-tools

With Node.js and the Firebase CLI installed, you can go ahead and navigate to the Firebase console on your browser. You’ll be required to sign in to your Google account. After a successful sign-in, you should see a Create a project button that looks like this:

Firebase Create a Project Button

Depending on when you follow this walkthrough, the user interface may look a bit different, but you should still be able to find your way around. When you click on the Create a project button, you’ll find the process of creating a Firebase project straightforward.

Next, you’ll be given the option of choosing a project name and ID. I’ll use journal-rest-api for mine. Since the ID is a unique identifier for your project, you’ll need to pick a different project ID. With that done, you can successfully create your Firebase project.

After a successful creation, you should see a project overview page that looks like this:

Overview Page

We made a custom demo for .
No really. Click here to check it out.

Upgrading our billing plan

By default, your new account should be on the Spark Free Billing plan. However, to be able to use Firebase functions, you’ll need to upgrade your billing plan to the Blaze Pay as you go. We don’t have to worry about money for this demo, but Firebase nevertheless requires that you upgrade your billing plan to be able to use the cloud functions.

To upgrade your billing plan, open the Develop dropdown in the left-hand nav and click on Functions. On the Functions page, you’ll be prompted to upgrade your billing. The message should look like this:

Upgrade Project Page

Creating a Firestore database

With our billing plan upgraded, let’s create a Firestore database for our app. Navigate to the Cloud Firestore option on the left pane, then click on the Create database button. You should see a modal that looks like this:

Start in Production Mode Option

We’ll use the Start in production mode option for this demo. With our database created, we can now write and deploy our first cloud function.

Writing our first cloud function

To write our fist cloud function, we’ll go back to our terminal and run the following command to sign in to our firebase account:

firebase login

Running the command should take you to an authentication page on your browser. After a successful authentication, create a project directory on your computer and, inside it, run the following command to initialize Firebase functions:

firebase init functions

You can go with the default options for your setup. Select the Use an existing project option, then chose the project you just created on Firebase. When asked What language would you like to use to write Cloud Functions? Select TypeScript. Finally, accept the option to install the npm dependencies. If everything goes well, you should see a success message that looks like this:

Success Message

The project structure for our new app should look like this:

├── .firebaserc
├── .gitignore
├── .firebase.json
└── functions
    ├── package.json
    ├── tsconfig.json
    ├── .gitignore
    └── src
        ├── index.ts

Notice that the package.json file is inside the functions directory. This means that when running our npm commands, we would need to navigate to the functions directory on our terminal. When you open the ./functions/src/index.ts file, you should see this:

import * as functions from 'firebase-functions';
// // Start writing Firebase Functions
// // https://firebase.google.com/docs/functions/typescript
//
// export const helloWorld = functions.https.onRequest((request, response) => {
//   functions.logger.info("Hello logs!", {structuredData: true});
//   response.send("Hello from Firebase!");
// });

Uncommenting the helloworld function should give us this:

import * as functions from 'firebase-functions';

export const helloWorld = functions.https.onRequest((request, response) => {
  functions.logger.info("Hello logs!", {structuredData: true});
  response.send("Hello from Firebase!");
});

Deploying our first function

To deploy the default helloworld function, navigate to the functions directory on your terminal and run the following command:

npm run deploy

If the deployment is successful, you should receive a response that looks like this:

Deploy Complete Message

With that done, when we navigate to the Functions section of our Firebase console, we should see our deployed helloWorld function and its URL. Navigating to the displayed URL on our browser should return the response Hello from Firebase!

If our goal is to make simple API calls and database queries, what we’ve done so far should be enough. However, since we want to build a more robust REST API with middleware and multiple routes, we’ll need a Node.js framework like Express. To install Express.js, let’s run the following code on our terminal — remember, we’re still in the functions directory:

npm i -s express

With Express installed, we can initialize it in our app by replacing the code in the ./index.ts file with the following:

import * as functions from 'firebase-functions'
import * as express from 'express'

const app = express()
app.get('/', (req, res) => res.status(200).send('Hey there!'))
exports.app = functions.https.onRequest(app)

Next, let’s redeploy our app by running npm run deploy on our terminal. When prompted with a warning about the deletion of our initial helloWorld function, select yes.

The following functions are found in your project but do not exist in your local
 source code:
   helloWorld(us-central1)
...
Would you like to proceed with deletion? Selecting no will continue the rest of the deployments. (y/N)

With that done, when you revisit the functions page on your Firebase console, you should see a new function named app and its request URL beside it. Navigating to that URL should return the response “Hey there!” as instructed on line 5 of our ./index.ts file:

app.get('/', (req, res) => res.status(200).send('Hey there!'))

Creating a service account for our app

In order to access our Firestore database and admin tools from our app, we’ll need to create a service account. To do this, click on the gear icon next to Project Overview in the left pane:

Gear Icon Next to the Project Overview Tab

From the Settings page, navigate to Service accounts, select Node.js, and click on the Generate new private key button.

Generate New Private Key Button

This should generate a JSON configuration file for your new service account. Here’s what the JSON file should look like:

{
  "type": "service_account",
  "project_id": "journal-rest-api",
  "private_key_id": "private_key_id",
  "private_key": "private_key",
  "client_email": "client_email",
  "client_id": "client_id",
  "auth_uri": "auth_url",
  "token_uri": "token_url",
  "auth_provider_x509_cert_url": "auth_provider_x509_cert_url",
  "client_x509_cert_url": "client_x509_cert_url"
}

Accessing our service account config

An easy way to use our new service account information would be to move the JSON file to our project directory and then import it where it’s needed. However, it’s recommended to keep this information private, and if our codebase is pushed to a remote repository, storing it in the project directory will expose it to public view.

A better way to use the information from our service account is by storing the needed key-value pairs as our Firebase environment variables. For our application, we’ll need the private_key, project_id, and client_email. Let’s store these as environment variables. To do so, we’ll go back to our terminal and run the following command:

firebase functions:config:set private.key="YOUR API KEY" project.id="YOUR CLIENT ID" client.email="YOUR CLIENT EMAIL"

Before running the above command, remember to substitute the string values with the one in your JSON file. It’s important to note that only lowercase characters are accepted in keys, and each of the keys should be namespaced using periods — for example, private.key. If the update is successful, you should receive a response that looks like this:

+  Functions config updated.

Please deploy your functions for the change to take effect by running firebase deploy --only functions

Let’s re-deploy our functions for the changes we’ve made to take effect:

npm run deploy

Setting up our Firebase config

Now that we have our service account information as Firebase environment variables, let’s set up our config file. We’ll create a new folder named config in the ./functions/src directory, and inside it, a new file named firebase.ts. Here’s what the structure should look like:

└── functions
    └── src
        └── config
           ├── firebase.ts

Let’s paste the following code inside the firebase.ts file:

import * as admin from 'firebase-admin'
import * as functions from 'firebase-functions'

admin.initializeApp({
  credential: admin.credential.cert({
    privateKey: functions.config().private.key.replace(/\\n/g, '\n'),
    projectId: functions.config().project.id,
    clientEmail: functions.config().client.email
  }),
  databaseURL: 'https://[your_project_id].firebaseio.com'
})

const db = admin.firestore()
export { admin, db }

We started by importing the Firebase admin for initializing our app and Firebase functions for accessing the Firebase environment variables. We went on to call the admin.initializeApp() function and supplied an object argument to it, which contains our service account credentials. We’ve also used functions.config().name_of_variable to access our environment variables.

Notice that we added a .replace() method to the private key. This replaces \\n in the private key string to \n. Without this, we would get an invalid PEM format error message when we try to use the variable. We also added a databaseUrl key after the credential.

You should replace [your_project_id] with the one you provided when creating your Firebase project. Next, we created a variable for our Firestore database named db and gave it the value admin.firestore(). Finally, we exported admin and db from the firebase.ts file.

Working with Firestore

Firestore is a NoSQL database for mobile, web, and server development. It offers a seamless integration with Firebase cloud functions, and its flexible and scalable nature makes it a good fit for building web APIs. Like all NoSQL databases, Firestore is non-relational and is made up of documents and collections. These documents contain a set of key-value pairs and are stored in collections.

In Firestore, documents and collections can be created from the Firebase console or through cloud functions. To create documents or collections from our cloud functions, we’ll use the db we exported from the Firebase config file:

db.collection('entries').doc().create({ document_object_here })

The line of code above will create a new document in the collection named entries, and if such a collection does not exist, a new one will be created with the provided name. We can also create an empty document and then provide key-value pairs for it later on:

const entry = db.collection('entries').doc()

const entryObject = {
  id: entry.id,
  title: 'entry title here',
  text: 'entry text here'
}

entry.set(entryObject)

Firebase documents have a .set() method that can be used to update them. We’ll see how to use a few other methods as we progress.

Adding entries to our Firestore database

With our database config set up, we can go ahead and add functionality for creating journal entries. Inside the ./functions/src directory, let’s create a new file named entryController.ts. We’ll use this file to house all the controllers for our entry. Inside the entryController.ts file, we’ll start by importing our Response type from express and db from ./config/firebase:

import { Response } from 'express'
import { db } from './config/firebase'

Next, we’ll define a type for our entries and then use it to create our Request type:

...
type EntryType = {
  title: string,
  text: string,
  coverImageUrl: string
}

type Request = {
  body: EntryType,
  params: { entryId: string }
}

For the Request type, we included entryId as a property of params. We’ll use this property when we want to edit an entry. Next, let’s create our addEntry function:

...
const addEntry = async (req: Request, res: Response) => {
  const { title, text } = req.body
  try {
    const entry = db.collection('entries').doc()
    const entryObject = {
      id: entry.id,
      title,
      text,
    }

    entry.set(entryObject)

    res.status(200).send({
      status: 'success',
      message: 'entry added successfully',
      data: entryObject
    })
  } catch(error) {
      res.status(500).json(error.message)
  }
}

In our addEntry function, we started by destructuring title and text from the request body. Next, we created a new Firebase document using the db.collection().doc() method and then updated the document with our entryObject. Our new document comes with an id. We’ll need to store this id as part of its key-value pairs so that we can easily access it.

res.status(200).send() returns a response to our user if the API request is successful. We also wrapped our database actions in a try…catch statement so that if there’s an error with our database requests, the catch block will respond with a 500 error and message from the error object.

The 500 HTTP status code does not cover user input errors or other types of errors, so we’ll need to cover those cases before making our database requests or use a middleware for that purpose.

To use our addEntry function, let’s export it from the entryController.ts file:

...
export { addEntry }

Next, we’ll import it in our ./functions/src/index.ts file:

...
import { addEntry } from './entryController'

Then, we’ll add the following code below the line where we initialized express:

app.post('/entries', addEntry)

Our index.ts file should now look like this:

import * as functions from 'firebase-functions'
import * as express from 'express'
import { addEntry } from './entryController'

const app = express()

app.get('/', (req, res) => res.status(200).send('Hey there!'))
app.post('/entries', addEntry)

exports.app = functions.https.onRequest(app)

To redeploy our app, we’ll go back to the Functions directory on our terminal and run the following command:

npm run deploy

If our deployment is successful, we should get a response that looks like this:

+  functions: Finished running predeploy script.
i  ...
+  functions[app(us-central1)]: Successful update operation.

+  Deploy complete!

To test our API, let’s navigate to the Functions section of our Firebase console. We should see our deployed function name app and its URL. To add a new entry to our Firestore database, we’ll append the route /entries to our URL. It should look like this: https://us-central1-project-id-here.cloudfunctions.net/app/entries.

Then, we’ll send a POST request using an API client like Postman or Insomnia to our URL with the properties title and text in our request body:

{
  "title": "My first entry",
  "text": "Hey there! I'm awesome!"
}

We should get a similar response to this:

{
    "status": "success",
    "message": "entry added successfully",
    "data": {
        "id": "g8fBsSVQWlkby9GMf0LC",
        "title": "My first entry",
        "text": "Hey there! I'm awesome!"
    }
}

Getting entries from our Firestore database

Now that we’ve seen how to add entries to our Firestore database using cloud functions, let’s see how we can get data from it. For this, we’ll create a new function named getAllEntries in the entryController.ts file:

...
const getAllEntries = async (req: Request, res: Response) => {
  try {
    const allEntries = await db.collection('entries').get()
    return res.status(200).json(allEntries.docs)
  } catch(error) { return res.status(500).json(error.message) }
}

export { addEntry, getAllEntries }

In our getAllEntries function, we’ve used the db.collection('entries').get() method to retrieve all the data in our entries collection and then accessed the documents in our response statement using the docs property. If we try using this function to retrieve entries through an API client, we’ll get a response that includes sensitive information like our private key. Let’s avoid this by updating our function to return only the entry data:

const getAllEntries = async (req: Request, res: Response) => {
  try {
    const allEntries: EntryType[] = []
    const querySnapshot = await db.collection('entries').get()
    querySnapshot.forEach((doc: any) => allEntries.push(doc.data()))
    return res.status(200).json(allEntries)
  } catch(error) { return res.status(500).json(error.message) }
}

In our getAllEntries function, we started by creating an array named allEntries with type EntryType[]. Next, we assigned the data from our db.collection().get() method to a variable named querySnapshot. In Firestore, a QuerySnapshot contains the result of a query. The forEach method loops through our QuerySnapshot and pushes the document data to our allEntries array.

To use our getAllEntries function, we’ll import it in the ./functions/src/index.ts file and then add it to a GET request method:

...
import { addEntry, getAllEntries } from './entryController'

const app = express()

app.get('/', (req, res) => res.status(200).send('Hey there!'))
app.post('/entries', addEntry)
app.get('/entries', getAllEntries)

Let’s re-deploy our app and make a GET request to ./entries. We should receive a response that looks like this:

[
    {
        "text": "Hey there! I'm awesome!",
        "id": "nS4so0TiUI0YLQH3xZLT",
        "title": "My first entry"
    }
]

Updating and deleting entries

Now that we’ve seen how to make POST and GET requests from Firebase cloud functions to our Firestore database, let’s see how we can update and delete entries. To add the functionality for updating entries, we’ll create a new function named updateEntry in the entryController.ts file:

...
const updateEntry = async (req: Request, res: Response) => {
  const { body: { text, title }, params: { entryId } } = req

  try {
    const entry = db.collection('entries').doc(entryId)
    const currentData = (await entry.get()).data() || {}
    const entryObject = {
      title: title || currentData.title,
      text: text || currentData.text,
    }

    await entry.set(entryObject).catch(error => {
      return res.status(400).json({
        status: 'error',
        message: error.message
      })
    })

    return res.status(200).json({
      status: 'success',
      message: 'entry updated successfully',
      data: entryObject
    })
  }
  catch(error) { return res.status(500).json(error.message) }
}

In our updateEntry function, we started by fetching the requested document by its ID provided in the request params. We used the db.collection().doc() method for this. Next, we got the current document data object with the .data() method so that when we update an entry with the .set() method, we can replace any field not provided by the user with the current entry data.

Let’s also create a function for deleting entries:

...
const deleteEntry = async (req: Request, res: Response) => {
  const { entryId } = req.params

  try {
    const entry = db.collection('entries').doc(entryId)

    await entry.delete().catch(error => {
      return res.status(400).json({
        status: 'error',
        message: error.message
      })
    })

    return res.status(200).json({
      status: 'success',
      message: 'entry deleted successfully',
    })
  }
  catch(error) { return res.status(500).json(error.message) }
}

Our deleteEntry function also gets the document using the entryId from our request params and the db.collection().doc() method. Next, we used the delete method to delete our entry. We also added the catch method to watch for errors and return a response if our database operation is unsuccessful.

To use our new functions, we’ll export them from the entryController.ts file:

...
export { addEntry, getAllEntries, updateEntry, deleteEntry }

We can now import them in the ./functions/src/index.ts file:

...
import { addEntry, getAllEntries, updateEntry, deleteEntry } from './entryController'

With the functions imported, let’s add the patch and delete routes:

...
app.patch('/entries/:entryId', updateEntry)
app.delete('/entries/:entryId', deleteEntry)

Our index.ts file should now look like this:

import * as functions from 'firebase-functions'
import * as express from 'express'
import { addEntry, getAllEntries, updateEntry, deleteEntry } from './entryController'

const app = express()

app.get('/', (req, res) => res.status(200).send('Hey there!'))
app.post('/entries', addEntry)
app.get('/entries', getAllEntries)
app.patch('/entries/:entryId', updateEntry)
app.delete('/entries/:entryId', deleteEntry)

exports.app = functions.https.onRequest(app)

To test the new functions, we’ll re-deploy our app using the npm run deploy command and then send a PATCH or DELETE request to /entries/:entryId using an API client like Postman. Remember that we included an entryId in the body of our entries when creating them. We can get those IDs by sending a GET request to ./entries.

Conclusion

In this article, we’ve seen how to set up Firebase and use cloud functions to build a REST API with TypeScript and Firestore. We did not cover all the Firestore methods in this demo, but you can find them in the official docs, and with your knowledge from this article, they’ll be easier to understand.

In a production-ready app, you’ll need to set up middleware to handle a lot of cases; for example, we could have a middleware that checks whether an entry exists before trying to update it. We could also have an authentication and authorization middleware (here’s a good way to handle authentication with Firebase React apps) just before the controller functions. With our app structure, adding middleware will be straightforward.

Here’s a link to the GitHub repo for our demo app. Feel free to reach out to me on LinkedIn if you need any help!

Writing a lot of TypeScript? Watch the recording of our recent TypeScript meetup to learn about writing more readable code.

TypeScript brings type safety to JavaScript. There can be a tension between type safety and readable code. Watch the recording for a deep dive on some new features of TypeScript 4.4.

Ebenezer Don Full-stack software engineer with a passion for building meaningful products that ease the lives of users.

2 Replies to “Building a REST API with Firebase cloud functions, TypeScript,…”

  1. Hello Ebenezer, i like your article, very clear and concise.

    I have a little more interest in the section “Getting entries from our Firestore database”

    In fact, i m pretty a newbe with Firestore, and i have an app developped with Flutter which store its data in Firestore.

    So now i would like to retrieve this data and send it to another app (precisely Salesforce) , but don t know really how to proceed… i guess , i m suppose to write the class also directly in firestore or ?

    Thank you in advance

Leave a Reply