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
- To use Firestore in Apps Script, you will need to enable the Firestore API in the Google Cloud Console.
- 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" ] }
- Finally, you will need to set the Cloud project id in the Apps Script settings.
- 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); }
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()); } } }
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) } ); } }
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("?")); } }
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" } ] } } } }
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")); }
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
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")); }
Top comments (0)