SuperFetch plugin – Firebase client for Apps Script
Frb is a SuperFetch plugin to easily access a Firebase Real time database. SuperFetch is a proxy for UrlFetchApp with additional features – see SuperFetch – a proxy enhancement to Apps Script UrlFetch for how it works and what it does.
This is another in my series on SuperFetch plugins.
I use Firebase a fair bit, mainly from Node, but also occassionally from Apps Script. I like the idea of making everything relative to an object path – as you have with the Node Firebase client .ref() and a very simple API structure with just the things I need to use a lot.
Most SuperFetch plugin APIS have the concept of .ref() and support caching out of the box, so this Plugin will help to access Firebase REST API from Apps Script.
Firebase is pretty fast, so there’s not a huge speed benefit from caching, but if you’re on a pay as go plan, SuperFertch caching can reduce your Firebase costs.
As with all SuperFetch plugins, it is dependency free, minimal and implements only the methods that are in common use.
If you need additional methods exposed, or if you want to contribute one of your own (creating a plugin is quick and easy), ping me.
Script and Firebase Preparation
You’ll need the bmSuperFetch library and if you want to run some tests, the bmUnitTest library – details at the end of the article.
I use a regular cloud project for all of these plugins rather than the Apps Script managed one (you can change it in Project settings), and you’ll need at least these scopes enabled in your manifest file (appsscript.json)
You’ll also need to create your Firebase Realtime database in the firebase console if you don’t already have one. By default, the database name will be ‘yourprojectid-default-rtdb’.
I’ll give some pointers on Firebase security rules later – you can just leave it open access for now till we get going.
Instantiation
First you need a SuperFetch instance. If you don’t need caching, then just omit the cacheService property.
const { Plugins, SuperFetch } = bmSuperFetch
// use this basic setup for all tests const superFetch = new SuperFetch({ fetcherApp: UrlFetchApp, tokenService: ScriptApp.getOAuthToken, cacheService: CacheService.getUserCache() })
There are various other standard SuperFetch Plugin parameters that I’ll deal with later, but the ones shown are of special interest to this article.
dbName property (required)
The dbName will be ‘yourprojectid-default-rtdb’.
noCache property (optional)
In this instance, I’m turning caching off for now.
base property (optional)
You can ignore the base property if you want to be able to write to the top level of the database, but for testing, I recommend always adding ‘test’ as a base. This means that all object paths accessed by this instance will be relative to test/. This prevents overwriting any real data later if I do some testing.
Calling structure
Like most SuperFetch Plugins, Frb has the concept of ref and path to point to a particular place in the database hierarchy
Path
This defines the path relative to the base you want to operate on – so to access the object at ‘test/users/profiles’ where the frb base is ‘test’ the path object would be
Ref can be used to create a new Frb instance with a different base. So we could also access the object at ‘test/users/profiles’ using various combinations of path and ref – for example
// accessing test/users/profiles with a base of 'test' // all these are requivalent const frb = new Frb({ superFetch, dbName: 'yourprojectid-default-rtdb', base: 'test' })
Database access methods in Frb (get, set and patch) are methods of the path object. Every method returns a standard SuperFetch response (see later)
get
Gets the JSON object at a given path
// returns a SuperFetch standard response const result = frb.path({path: 'users/profile'}).get()
// or equivalent const path = frb.path({path: 'users/profile'}) const result = path.get()
get an object at a given path
set
Sets the JSON object at a given path. This will overwrite everything from the path downwards – so children in the database, but not in the new data will be removed
// returns a SuperFetch standard response const result = frb.path({path: 'users/profile'}).set({data})
// or equivalent const path = frb.path({path: 'users/profile'}) const result = path.set({data})
set an object at a given path
patch
Sets the JSON object at a given path. This will only overwrite children if the are in the new data – those missing from the new data will not be overwritten – this operation is known as update in the firebase clients of some other platforms, but patch in the REST API.
// returns a SuperFetch standard response const result = frb.path({path: 'users/profile'}).patch({data})
// or equivalent const path = frb.path({path: 'users/profile'}) const result = path.patch({data})
set an object at a given path
path domain
The calling plan for the complete path domain looks like this
/** * path domain * @param {object} params * @param {string} params.path the folder/file path * @return {object} containing the functions available in the path domain */ path({ path = '' } = {}) { const self = this return { /** * get object * @param {...*} params any additional api params * @return {PackResponse} standard response with data from firebase in .data */ get: (...params) => self.get({ path }, ...params),
/** * set object * @param {object} p * @param {object} p.data the data to write * @param {...*} params any additional api params * @return {PackResponse} standard response with data from firebase in .data */ set: ({ data }, ...params) => self.set({ path, data, method: "PUT" }, ...params),
/** * patch object * @param {object} p * @param {object} p.data the data to patch * @param {...*} params any additional api params * @return {PackResponse} standard response with data from firebase in .data */ patch: ({ data }, ...params) => self.set({ path, data, method: "PATCH" }, ...params)
} }
path domain
SuperFetch reponse
The result of each of these operations is a SuperFetch response that looks like this.
/** * This is the response from the apply proxied function * @typedef PackResponse * @property {boolean} cached whether item was retrieved from cache * @property {object||null} data parsed data * @property {number} age age in ms of the cached data * @property {Blob|| null} blob the recreated blob if there was one * @property {boolean} parsed whether the data was parsed * @property {HttpResponse} response fetch response * @property {function} throw a function to throw on error * @property {Error || string || null} the error if there was one * @property {number} responseCode the http response code */
result
You’ll mainly be interested in these properties
// the parsed data - the contents of the requested path const data = result.data
// whether there was an error if (result.error) { // handle error console.log(result.error) }
// whether it came from cache const wasCached = result.cached
// how many ms since it was written to cache const cacheAge = result.age
// if you want it to throw an error if there was one add .throw() after result // if no error you'll get the result required // for example const data = result.throw().data
I’ve mentioned the usual Frb constructor parameters earlier, but here’s a full list of those expected by the constructor.
/** * @typedef FrbApiOptions * @property {_SuperFetch} superFetch a superfetch instance * @property {boolean} noCache whether to cache * @property {string} dbName the firebase database name * @property {string} base the base path on the bucket (all paths will be relative to this) * @property {boolean} showUrl whether to showUrls when fetching * @property {object[]} extraParams always add these params */
Frb class constructor
Ref parameters
The ref method creates a new FRB instance, but with a new base. The FrbApiOptions argument is the same object as for the Frb constructor, but the base property is ignored.
const ref = frb.ref (newBase, frbApiOptions)
ref parameters
Firebase Security Rules
This is a little outside the scope of this article, but here’s just a quick tip, as you’ll want to lock down your database. Normally you’ll want to be setting specifc access rules for various paths in your database, but one of the useful things I’ve found is to create the concept of admin access – ie. giving special access to particular users.
Admins Path
The first thing is to create an admins path. My database looks like this.
The admins path should contain the email address of those you need to have admin access. Note that the email address needs to be escaped. This is because Firebase can’t have property names with ‘.’ in them.
Admin Rules
In the rules section of the database add this to give full access to all those in the admins path.
I’ll use Simple but powerful Apps Script Unit Test library to demonstrate calls and responses. It should be straightforward to see how this works and the responsese to expect from calls. These tests demonstrate each of the topics mentioned in this article and could serve as a useful crib sheet for the plugin
const testFrb = ({ force = false, unit } = {}) => {
// use this basic setup for all tests const superFetch = new SuperFetch({ fetcherApp: UrlFetchApp, tokenService: ScriptApp.getOAuthToken, cacheService: CacheService.getUserCache() })
// test data const assets = { top: { value: 100, inside: { value: 200 } } }
unit.section(() => { // get an instance to test sb starting at the testing base endpoint const frb = new Frb({ superFetch, dbName: SETTINGS.frb.dbName, base: SETTINGS.frb.base, noCache: true }) unit.is(assets, frb.path().set({ data: assets }).throw().data, { description: 'set assets for tests' }) unit.is(assets, frb.path().get().throw().data, { description: 'fixture properly set up' }) unit.is(assets, frb.path().patch({ data: assets }).throw().data, { description: 'patching should leave and return it the same' }) unit.is(assets, frb.path().get().throw().data, { description: 'fixture still unchanged' })
unit.section(() => { // get an instance to test sb starting at the testing base endpoint const frb = new Frb({ superFetch, dbName: SETTINGS.frb.dbName, base: SETTINGS.frb.base, noCache: true })
unit.is(assets, frb.path().set({ data: assets }).throw().data, { description: 'set assets for tests' }) unit.is(assets, frb.path().get().throw().data, { description: 'fixture properly set up' }) // this ref should do nothing const blankRef = frb.ref()
unit.is(assets, blankRef.path().patch({ data: assets }).throw().data, { description: 'patching should leave and return it the same' }) unit.is(frb.path().get().throw().data, blankRef.path().get().throw().data, { description: 'a blank ref returns same result' }) // lets make a ref down a bit const topRef = frb.ref('top') unit.is(frb.path({path:'top/inside'}).get().throw().data, topRef.path({path:'inside'}).get().throw().data, { description: 'ref works' }) unit.is({ value: 500 }, topRef.path({ path: 'inside' }).patch({ data: { value: 500 } }).throw().data, { description: 'value from path top/inside is updated' })
unit.is(500, topRef.path({ path: 'inside/value' }).get().throw().data, { description: 'value from path top/inside/value is as expected' })
// path can get stored as a shortcut const frbPath = topRef.path({ path: 'value' }) unit.is(frbPath.get().throw().data, frb.path({ path: 'top/value' }).get().throw().data, { description: 'shortcut ref path returns same value' })
unit.is(assets.top.value, frbPath.get().throw().data, { description: 'value from path top/value is as expected' })
unit.not(assets, frb.path().get().throw().data, { description: 'value is not the same as the original' })
}, { skip: skipTest.ref, description: 'Frb basic tests - with refs - no caching' })
unit.section(() => { // get an instance to test sb starting at the testing base endpoint const frb = new Frb({ superFetch, dbName: SETTINGS.frb.dbName, base: SETTINGS.frb.base, noCache: true })
// we can turn caching on with a ref as well as via the initial instantion const frbCached = frb.ref('', {noCache: false})
unit.is(true, frbCached.path().get().throw().cached, { description: 'came from cache' })
// this ref should do nothing const blankRef = frbCached.ref() unit.is(true, blankRef.path().get().throw().cached, { description: 'ref also came from cache' })
unit.is(assets, blankRef.path().set({ data: assets }).throw().data, { description: 'cached with a ref returns proper result' })
// lets make a ref down a bit const topRef = frbCached.ref('top') unit.is(frb.path({path:'top/inside'}).get().throw().data, topRef.path({path:'inside'}).get().throw().data, { description: 'ref works' })
// path can get stored as a shortcut const frbPath = topRef.path({ path: 'value' }) unit.is(frbPath.get().throw().data, frbCached.path({ path: 'top/value' }).get().throw().data, { description: 'shortcut ref path returns same value' })
unit.is(assets.top.value, frbPath.get().throw().data, { description: 'value from path top/value is as expected when cached' })
unit.is(true, frbPath.get().throw().cached, { description: 'value is cached as expected' })
// set all back to normal unit.is(assets, frb.path().set({ data: assets }).throw().data)
// caching should be gone now for top level unit.is(false, frbCached.path().get().throw().cached, { description: 'top level removed after noncache read' })
bruce mcpherson is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License. Based on a work at http://www.mcpher.com. Permissions beyond the scope of this license may be available at code use guidelines