Logo

dev-resources.site

for different kinds of informations.

Using Firestore in Apps Script

Published at
1/10/2024
Categories
firestore
appsscript
googleworkspace
Author
jpoehnelt
Categories
3 categories in total
firestore
open
appsscript
open
googleworkspace
open
Author
9 person written this
jpoehnelt
open
Using Firestore in Apps Script

When using Apps Script, sometimes the CacheService and PropertiesService do not match the requirements of the project – perhaps there a need for a longer ttl or storing many more values. In these cases, Firestore can be used!

Setup

  1. To use Firestore in Apps Script, you will need to enable the Firestore API in the Google Cloud Console.
  2. You will also need to add the following scopes to your Apps Script project:
{
  "oauthScopes": [
    "https://www.googleapis.com/auth/datastore",
    "https://www.googleapis.com/auth/script.external_request"
  ]
}
Enter fullscreen mode Exit fullscreen mode
  1. Finally, you will need to set the Cloud project id in the Apps Script settings.
  2. Create a collection named kv in Firestore so the examples below will work.

This post is going to be using the Firestore REST API with OAuth access tokens via ScriptApp.getOAuthToken(). Alternatively, you could use a service account.

UrlFetchApp and the Firestore REST API

The UrlFetchApp can be used to make requests to the Firestore REST API. I wrap the UrlFetchApp in two function layers to make it easier to use with the OAuth token and handle errors. The first is a simple wrapper to add the OAuth token to the request header.

/**
 * Wraps the `UrlFetchApp.fetch()` method to always add the 
 * Oauth access token in the header 'Authorization: Bearer TOKEN'.
 * 
 * @params {string} url
 * @params {Object=} params
 * @returns {UrlFetchApp.HTTPResponse}
 */
function fetchWithOauthAccessToken__(url, params = {}) {
  const token = ScriptApp.getOAuthToken();
  const headers = {
    Authorization: `Bearer ${token}`,
    "Content-type": 'application/json',
  };
  params.headers = params.headers ?? {};
  params.headers = { ...headers, ...params.headers };
  return UrlFetchApp.fetch(url, params);
}
Enter fullscreen mode Exit fullscreen mode

I didn’t evaluate the performance impacts of repeated ScriptApp.getOAuthToken() calls.

The second function layer is a wrapper to handle errors and parsing that I included as part of the Firestore class I created (more later).

class Firestore {
  // ... omitted
  fetch(url, options) {
    options = {
      ...options,
      muteHttpExceptions: true
    }
    const response = fetchWithOauthAccessToken__(url, options);
    if (response.getResponseCode() < 300) {
      return JSON.parse(response.getContentText());
    } else {
      throw new Error(response.getContentText());
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Firestore class for Apps Script

To abstract some of the common methods, I created a Firestore class. This class is not meant to be a complete wrapper of the Firestore REST API, but rather a starting point.

Below is the .patch() method as an example which transforms the payload to JSON and passes it to the .fetch() wrapper method.

class Firestore {
  // ... omitted
  /**
   * @params {string} documentPath
   * @params {Object=} params Include parameters such as `updateMask`, `mask`, etc
   * @params {Object=} payload
   */
  patch(documentPath, params = {}, payload) {
    return this.fetch(
      this.url(documentPath, params), 
      { method: Methods.PATCH, payload: JSON.stringify(payload) }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

I also included a url method to generate the Firestore REST API url and include any parameters. This method is used by the other methods to generate the url.

class Firestore {
  /**
   * @params {string} projectId
   * @params {string} [databaseId="(default)"]
   */
  constructor(projectId, databaseId = "(default)") {
    this.basePath = `https://firestore.googleapis.com/v1/projects/${projectId}/databases/${databaseId}/documents`
  }
  // ... omitted
  /**
   * @params {string} documentPath
   * @params {Object=} params Include parameters such as `updateMask`, `mask`, etc
   */
  url(documentPath, params = {}) {
    return encodeURI([
      `${this.basePath}${documentPath}`, 
      Object.entries(params).map(([k, v]) => `${k}=${v}`).join("&")
      ].join("?"));
  }
}
Enter fullscreen mode Exit fullscreen mode

This could be extended as necessary for queries, collections, etc.

Firestore typed documents

When using the Firestore REST API, documents are represented with a JSON object containing their types. Below is an example of a document with a nested object and array.

{
  "fields": {
    "name": {
      "stringValue": "John Doe"
    },
    "age": {
      "integerValue": "30"
    },
    "address": {
      "mapValue": {
        "fields": {
          "street": {
            "stringValue": "123 Main St"
          },
          "city": {
            "stringValue": "New York"
          },
          "state": {
            "stringValue": "NY"
          },
          "zip": {
            "stringValue": "10001"
          }
        }
      }
    },
    "hobbies": {
      "arrayValue": {
        "values": [
          {
            "stringValue": "hiking"
          },
          {
            "stringValue": "biking"
          }
        ]
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

I didn’t bother with wrapping and unwrapping this, but a helper function could do this for you. See this GitHub library, grahamearley/FirestoreGoogleAppsScript/Document.ts for an example implementation.

Usage of the Apps Script Firestore class

Below is an example of using the Firestore class to patch, get, and delete a document in a collection I had already created named kv.

function main() {
  const db = new FirestoreService(PROJECT_ID, DATABASE_ID);
  const doc = {
    fields: {
      foo: {
        stringValue: "test"
      }
    }
  };
  console.log(db.patch("/kv/test", {}, doc,));
  console.log(db.get("/kv/test"));
  console.log(db.delete("/kv/test"));
}
Enter fullscreen mode Exit fullscreen mode

This outputs the following:

10:30:56 AM   Notice  Execution started
10:30:57 AM   Info    { name: 'projects/OMITTED/databases/(default)/documents/kv/test',
  fields: { foo: { stringValue: 'test' } },
  createTime: '2024-01-08T21:52:09.794036Z',
  updateTime: '2024-01-10T18:30:57.728011Z' }
10:30:58 AM   Info    { name: 'projects/OMITTED/databases/(default)/documents/kv/test',
  fields: { foo: { stringValue: 'test' } },
  createTime: '2024-01-08T21:52:09.794036Z',
  updateTime: '2024-01-10T18:30:57.728011Z' }
10:30:58 AM   Info    {}
10:30:58 AM   Notice  Execution completed

Enter fullscreen mode Exit fullscreen mode

Future experiments with Firestore in Apps Script

  • Use Firestore rules for segmenting user data
  • Use Firestore as a larger cache than the CacheService
  • Use a service account instead of OAuth access tokens

You may want to consider using the library FirestoreGoogleAppsScript instead of the code in this post. It is a more complete wrapper of the Firestore REST API, however there is a balance to using an incomplete external library vs writing a small amount of code yourself as demonstrated here.

Complete code

const PROJECT_ID = "OMITTED"; // Update this
const DATABASE_ID = "(default)"; // Maybe update this
/**
 * @readonly
 * @enum {string}
 */
var Methods = {
  GET: "GET",
  PATCH: "PATCH",
  POST: "POST",
  DELETE: "DELETE",
};
/**
 * Wrapper for the [Firestore REST API] using `URLFetchApp`.
 * 
 * This functionality requires the following scopes:
 *  "https://www.googleapis.com/auth/datastore",
 *  "https://www.googleapis.com/auth/script.external_request"
 */
class FirestoreService {
  /**
   * @params {string} projectId
   * @params {string} [databaseId="(default)"]
   */
  constructor(projectId, databaseId = "(default)") {
    this.basePath = `https://firestore.googleapis.com/v1/projects/${projectId}/databases/${databaseId}/documents`
  }
  /**
   * @params {string} documentPath
   * @params {Object=} params Include parameters such as `updateMask`, `mask`, etc
   */
  get(documentPath, params = {}) {
    return this.fetch(
      this.url(documentPath, params),
      { method: Methods.GET }
    );
  }
  /**
   * @params {string} documentPath
   * @params {Object=} params Include parameters such as `updateMask`, `mask`, etc
   * @params {Object=} payload
   */
  patch(documentPath, params = {}, payload) {
    return this.fetch(
      this.url(documentPath, params),
      { method: Methods.PATCH, payload: JSON.stringify(payload) }
    );
  }
  /**
   * @params {string} documentPath
   * @params {Object=} params Include parameters such as `updateMask`, `mask`, etc
   * @params {Object=} payload
   */
  create(documentPath, params = {}, payload) {
    return this.fetch(
      this.url(documentPath, params),
      { method: Methods.POST, payload: JSON.stringify(payload) }
    );
  }
  /**
   * @params {string} documentPath
   * @params {Object=} params Include parameters such as `updateMask`, `mask`, etc
   */
  delete(documentPath, params = {}) {
    return this.fetch(
      this.url(documentPath, params),
      { method: Methods.DELETE}
    );
  }
  /**
    * @params {string} documentPath
    * @params {Object=} params Include parameters such as `updateMask`, `mask`, etc
    */
  url(documentPath, params = {}) {
    return encodeURI([
      `${this.basePath}${documentPath}`, 
      Object.entries(params).map(([k, v]) => `${k}=${v}`).join("&")
      ].join("?"));
  }
  /**
   * @params {string} documentPath
   * @params {Methods} method
   * @params {Object} options
   * @params {Object=} params Include parameters such as `updateMask`, `mask`, etc
   */
  fetch(url, options) {
    options = {
      ...options,
      muteHttpExceptions: true
    }
    const response = fetchWithOauthAccessToken__(url, options);
    if (response.getResponseCode() < 300) {
      return JSON.parse(response.getContentText());
    } else {
      throw new Error(response.getContentText());
    }
  }
}
/**
 * Wraps the `UrlFetchApp.fetch()` method to always add the 
 * Oauth access token in the header 'Authorization: Bearer TOKEN'.
 * 
 * @params {string} url
 * @params {Object=} params
 * @returns {UrlFetchApp.HTTPResponse}
 */
function fetchWithOauthAccessToken__(url, params = {}) {
  const token = ScriptApp.getOAuthToken();
  const headers = {
    Authorization: `Bearer ${token}`,
    "Content-type": 'application/json',
  };
  params.headers = params.headers ?? {};
  params.headers = { ...headers, ...params.headers };
  return UrlFetchApp.fetch(url, params);
}
function main() {
  const db = new FirestoreService(PROJECT_ID, DATABASE_ID);
  const doc = {
    fields: {
      foo: {
        stringValue: "test"
      }
    }
  };
  console.log(db.patch("/kv/test", {}, doc,));
  console.log(db.get("/kv/test"));
  console.log(db.delete("/kv/test"));
}
Enter fullscreen mode Exit fullscreen mode
firestore Article's
30 articles in total
Favicon
Dev Video Review: Firestore Data Structure, Limitations, and IMHO
Favicon
Do you need a No Code tool for Firebase?
Favicon
Firebase: The Ultimate Backend for Your CMS
Favicon
NgSysV2-10.1: Firestore CRUD templates
Favicon
NgSysV2-3.3: A Serious Svelte InfoSys: Firebase D/b rules and Login
Favicon
NgSysV2-3.4: A Serious Svelte InfoSys: Rules-friendly version
Favicon
NgSysV2-3.5: A Serious Svelte InfoSys: Client-Server Version
Favicon
Dive into the world of serverless - GCP Edition
Favicon
Visualizing Firebase Data: Unlocking the Power of Real-Time Insights
Favicon
Implementing Batch Write Operations in Firestore with Express
Favicon
Enforcing Firebase App Check for Firestore with Initialization Configuration
Favicon
Retrieving User Roles from Firestore in a Next.js Application
Favicon
How to Keep Your Custom Claims in Sync with Roles Stored in Firestore
Favicon
Scheduling Events in Firebase Firestore with Server Timestamps
Favicon
Using Google Cloud Firestore with Django's ORM
Favicon
Firebase Realtime Database vs Cloud Firestore
Favicon
Real-Time Data Handling with Firestore: Tracking Pending Orders
Favicon
How to programmatically backup your Firestore database with simple steps
Favicon
Understanding Real-Time Data with Firebase Firestore in JavaScript
Favicon
Enabling Offline Capabilities in Firebase with IndexedDB Persistence
Favicon
Querying Firestore for Capital Cities with JavaScript
Favicon
Explorando o Firebase: Uma Plataforma Poderosa para Desenvolvimento de Aplicativos
Favicon
Using Firestore in Apps Script
Favicon
What is Flutter, how do I get started ??!!!
Favicon
How to Stream Data From Firebase to BigQuery Easily
Favicon
Retrieve a list of data under specific user collection in flitter
Favicon
Tentando não ficar pobre antes de ficar rico criando uma Startup de serviços de inteligência artificial
Favicon
Trying to Maintain a Workable Budget Creating a Chatbot Using GPT and Vector Database
Favicon
Uptime Monitoring with Firebase
Favicon
Firestore data modeling

Featured ones: