This article is a little more advanced than usual, and we’ll cover a number topics in one go. A JavaScript proxy gives us the ability to intercept JavaScript as it attempts to access the properties and executes the functions of an object.

It’s a perfect way to introduce the concept of a ‘default method’ or ‘default property’ into an object.

Background

I wrote about another way of doing this (before proxies were generally available in Apps Script) in JavaScript snippet to create a default method for an object.

That was a little bit of a hack, so in this article I’ll show you a better way to do it using a proxy. At the end of this article, you’ll see there’s an Apps Script library that does most of the work for you, but I’ll go through how it works here.

What is a default method?

Let’s say you have an object with a couple of methods like this. It’s a very trivial example to get started with.

  const originalObject = {
    countArgs: (...args) => args.length,
    stringArgs: (...args) => JSON.stringify(args)
  };
starting object

It’ll give results like this

  console.log(originalObject.countArgs(1, 2, 3))  // 3
  console.log(originalObject.stringArgs(1, 2, 3)) // [1,2,3]
object with methods

It may be that we would like to give it a default behavior – i.e. what it would do if we didn’t specify any particular method. I’d like it to default to originalObject.countArgs(), but it’ll just fail because originalObject isn’t a function.

Sprinkling a little proxyDust on the originalObject sets it up.

originalObject(1, 2, 3) // fail

// after creating a proxy for the item with a default method of countargs
const dust = bmDuster.proxyDust({ originalObject, defaultMethod: originalObject.countArgs })
dust (1,2,3) // 3
default method

Accessing an invalid method or property

Another possible requirement might be to either fail (or execute something) if a non-existent method (or property) is referenced. I’d like an error thrown like the one below

  console.log(originalObject.nonsense) // undefined
  
  // with proxyDust
  console.log(dust.nonsense)
  
  /*
  Error	
    Invalid property: Attempt to access missing property nonsense
    UnknownPropertyError	@ errors.gs:3
    missingPropAction	@ proxying.gs:28
    get	@ proxying.gs:57
 */
missing property

Using a proxy

Using my bmDuster library you can set a default method, and check for invalid references.

const testProxy = () => {
  // start with this object
  const originalObject = {
    countArgs: (...args) => args.length,
    stringArgs: (...args) => JSON.stringify(args),
  };
  // set a default method
  const dust = bmDuster.proxyDust({ originalObject, defaultMethod: originalObject.countArgs })

  // now we can do this
  console.log(dust(1, 2, 3)) // 3

  // and this
  console.log(dust.countArgs(1, 2, 3)) // 3

  // and this
  console.log(dust.stringArgs(1, 2, 3)) // [1,2,3]

  // and this
  console.log(dust.nonsense) 
  /*
  Error	
    Invalid property: Attempt to access missing property nonsense
    UnknownPropertyError	@ errors.gs:3
    missingPropAction	@ proxying.gs:28
    get	@ proxying.gs:57
 */

}
proxyDust

How does that work ?

By creating a proxy for the originalObject, we can interfere with the normal JavaScript operation. Here’s the proxyDust function from the bmDuster library, if you want to see how it all works.

const _proxying = (() => {

  /**
   * check a type is as expected and optionally fail
   * @param {object} params
   * @param {*} params.originalObject the originalObject to check
   * @param {string} [params.type="string"] the type to check for
   * @param {boolean} [params.fail=false] whether to fail if its not the expected type
   * @return {boolean} whether it was the correct type
   */
  const _checkType = ({ item, type = "string", fail = false }) => {
    const result = typeof item === type;
    if (fail && !result)
      throw new UnexpectedTypeError(type, typeof item);
    return result;
  };


  /**
   * make a proxy with a default method
   * @param {object} params
   * @param {object} params.originalObject the originalObject with the properties to proxy
   * @param {function} params.defaultMethod the method to execute if no property is selected
   * @param {function} [params.missingPropAction] what to do if a missing property is called
   * @return {function} its the default function, but with all the properties of the originalObject 
   */
  const _proxyDust = ({ originalObject, defaultMethod, missingPropAction = (target, prop, originalObject) => {
    throw new UnknownPropertyError(prop)
  }, applyAction = (target, thisArg, ...args) => {
    return target.apply(thisArg, ...args)
  }, propAction = (target, prop, originalObject) => {
    return originalObject[prop]
  } }) => {

    // start with a default method - check it's a function
    _checkType({ item: defaultMethod, type: 'function', fail: true })

    // and the target is object
    _checkType({ item: originalObject, type: 'object', fail: true })

    // and the apply action is a function
    _checkType({ item: applyAction, type: 'function', fail: true })

    // and the propAction is a function
    _checkType({ item: propAction, type: 'function', fail: true })

    // make a proxy for the function to which we'll the originalObject
    const pust = new Proxy(defaultMethod, {
      get(target, prop) {
        // the property should exist in originalObject (not the default method)
        // so we just ignore the target typically
        if (prop in originalObject) {
          // just return the value for the existing prop
          return propAction(target, prop, originalObject)
        } else {
          // well that's a surprise
          return missingPropAction(target, prop, originalObject)
        }
      },
      apply(target, thisArg, ...args) {
        // this where we go when it's called in vanilla mode
        return applyAction(target, thisArg, ...args)
      }
    })

    return pust;
  }
  return {
    _proxyDust,
    _checkType
  }
})()



// hoist for export
var proxyDust = _proxying._proxyDust
var checkType = _proxying._checkType
proxyDust code

Hooking up the original object to the default method

The first thing you might notice is that the proxy is being created, not for the original object as you’d expect – but for the defaultMethod.

Yes – JavaScript is ok with a function pretending to have properties! 

Of course the defaultMethod doesn’t actually have any properties (and never will), but JavaScript doesn’t know that yet.

      get(target, prop) {
        // the property should exist in originalObject (not the default method)
        // so we just ignore the target typically
        if (prop in originalObject) {
          // just return the value for the existing prop
          return propAction(target, prop, originalObject)
        } else {
          // well that's a surprise
          return missingPropAction(prop)
        }
      },
intercept property access
  1. We intercept an attempt to access a property of the defaultMethod
  2. Instead of looking in the target object (i.e the defaultMethod which has no properties), we look in the originalObject
  3. We return the property from originalObject instead.

propAction

You’ll also notice that proxyDust takes a propAction function. This describes what to do when at attempt to access a property is made.

Usually the default will be fine. It simply returns the content of originalObject[prop], as described earlier, but it’s there in case you want to do some fancier thing.

propAction = (target, prop, originalObject) => {
    return originalObject[prop]
  }
default propAction

missingPropAction

There’s also a missingPropAction argument, which defines what to do if an attempt is made to access a non existent property.

The default is to throw a custom error (we’ll cover those too later in this article), but you may want to return undefined – which would mimic the unproxied behavior.

missingPropAction = (target, prop, originalObject) => {
    throw new UnknownPropertyError(prop)
  }


// or you could just return undefined to mimic an unproxied outcome
missingPropAction = (target, prop, originalObject) => {
	return undefined  //  which by default would be the same as return propAction(target, prop, originalObject)
  }
missingPropAction default

Hooking up the default method

In addition to property get, we can also intercept the running of a function at the ‘apply’ entry point in the proxy handler definition.

Since the defaultMethod is a function, that means whenever its invoked as a function (dust() rather than say, dust.countArgs()).

That gives us the place to simply execute the default function.

      apply(target, thisArg, ...args) {
        // this where we go when it's called in vanilla mode
        return applyAction(target, thisArg, ...args)
      }
apply entry point

Using a foreign default method

Note that in this example so far, the default function is one that exists in the originalObject. It can of course be something completely different.

const testDifferently = () => {
  const originalObject = {
    countArgs: (...args) => args.length,
    stringArgs: (...args) => JSON.stringify(args),
  };
  const dustDifferently = bmDuster.proxyDust({
    originalObject, defaultMethod: (...args) => console.log('you called the default function with these args', ...args)
  })
  dustDifferently('hello there') // you called the default function with these args hello there
}
a default function not in the original object

applyAction

proxyDust also takes an applyAction which is what to do when the defaultFunction is executed. The default is of course just to execute it, but again, you may want to do something magnificent here instead – as we will indeed do later in the article when we start fiddling with Apps Script services.

applyAction = (target, thisArg, ...args) => {
    return target.apply(thisArg, ...args)
  }
applyAction default

Summary of proxyDust

It’s centered around the idea of creating a default method for an object containing methods, and detecting bad property references more elegantly. Each handler action is configurable via optional arguments but generally the defaults should be adequate.

Custom Errors

I mentioned these earlier – the idea is to make error messages more specific and useful. bmDuster has 3 built in custom error classes which extend the default JavaScript Error class. Since classes aren’t visible from libraries, I’ve also added builders you can use to get instances of these classes should you wish to use them in your scripts.

class UnknownPropertyError extends Error {
  constructor(prop) {
    super(`Attempt to access missing property ${prop}`);
    this.name = "Invalid property";
    this.prop = prop
  }
}
class UnexpectedTypeError extends Error {
  constructor(expectedType, actualType) {
    super(`Expected ${expectedType} but got ${actualType}`);
    this.name = "Unexpected type";
    this.expectedType = expectedType
    this.actualType = actualType
  }
}
class UnexpectedValueError extends Error {
  constructor(expectedValue, actualValue) {
    super(`Expected ${JSON.stringify(expectedValue)} but got ${JSON.stringify(actualValue)}`);
    this.name = "Unexpected value";
    this.expectedValue = expectedValue
    this.actualValue = actualValue
  }
}
// hoist for exports
var newUnknownPropertyError = (prop) => new UnknownPropertyError(prop)
var newUnexpectedTypeError = (expectedType, actualType) => new  UnexpectedTypeError(expectedType, actualType)
var newUnexpectedValueError = (expectedValue, actualValue) => new  UnexpectedValueError(expectedValue, actualValue)
custom errors

Throwing a custom error

You just throw an instance of the error, passing the required arguments – for example

throw bmDuster.newUnexpectedValueError(expect, actual)
throwing a custom error

Logging a custom error

Just because it’s an ‘error’ it doesn’t mean you have to actually throw it. It can be useful for logging of errors too. This will log a formatted error, without actually throwing it

    console.log(bmDuster.newUnexpectedValueError(expect, actual))
logging custom error

More complex example

Let’s look at a handier example now. Say you wanted a shorthand way of comparing 1 or more values passed as arguments, or arrays of arguments to see if they are equal.

For example

compare({value: null}, null) // true
compare ({value:1}, 1,2,3) // false
compare ({value:'cheese'}, 'cheese','cheese') // true
simple compare

In this case the ‘defaultMethod’ is where each comparison of  arguments versus value is true. However there are other cases that need handled

  • compare.every ()  – the default – all arguments must match
  • compare.some () – at least 1 of the arguments must match
  • compare.none () – no matches
  • compare.partial () – at least 1 but not all of the arguments must match
  • compare.list () – instead of a single match an array of true or false

First we’ll need a proxy to a base object with these methods implemented, and a default method of .every(). In this case I’ve chosen to flatten all arrays that come through as arguments as well.

const duster = () => {

  // process the results once
  const results = ({ value, operation = bmDuster.equals }, ...args) => args.flat(Infinity).map((f) => operation(f, value));

  // the basic object
  const originalObject = {
    every: ({ operation, value }, ...args) => results({ operation, value }, ...args).every((f) => f),
    some: ({ operation, value }, ...args) => results({ operation, value }, ...args).some((f) => f),
    none: ({ operation, value }, ...args) => results({ operation, value }, ...args).every((f) => !f),
    partial: ({ operation, value }, ...args) => results({ operation, value }, ...args).some(f => f) &&
      !results({ operation, value }, ...args).every(f => f),
    list: ({ operation, value }, ...args) => results({ operation, value }, ...args),
  };


  return bmDuster.proxyDust({ originalObject, defaultMethod: originalObject.every })


}
compare

deep equals

You’ll notice that the default operation (how to compare 2 values) uses an equals function from bmDuster. This is a handy way of comparing equality that applies the === test, but it also handles Maps, Sets and so on as well. In particular it does a value equivalent test on objects.

object comparisons

in JavaScript, objects are the same if the reference the same address – not if their content is the same. bmDuster.equals checks for the values in an object matching as well as being at the same address.

const a = {name: "John", id:1 }
const b = a
const c = {name: "John", id:1 }

// vanilla JavaScript comparisons
a === b // true
a === c // false
b === c // false

// using bmDuster.equals
a === b === c // true
object comparison

I’ve exposed bmDuster.equals as it might be useful for you, even when not using proxies. For more on deep-eql see this package

 

Some tests

For these series of tests, I’m using this simple checker to see if the expected values match the actual values

const checker = (testNumber, expect, actual) => {
  if (!bmDuster.equals(expect, actual)) {
    console.log(`${testNumber}`, bmDuster.newUnexpectedValueError(expect, actual))
    return false
  }
  console.log(`${testNumber} passed: ${JSON.stringify(actual)}`)
  return true
}
results checker

There’s a node version of all this, with many more (non-apps script) tests at https://github.com/brucemcpherson/bm-duster

Here are the tests

const tester = () => {

  // comparisons I
  // various tests for null
  const compare = duster()
  let testNumber = 0
  checker(++testNumber, true, compare({ value: null }, null))
  checker(++testNumber, false, compare({ value: null }, 123))
  checker(++testNumber, false, compare({ value: null }, undefined))
  checker(++testNumber, true, compare({ value: null }, null, null))
  checker(++testNumber, false, compare({ value: null }, null, 99))
  checker(++testNumber, false, compare.every({ value: null }, null, 99))
  checker(++testNumber, true, compare.some({ value: null }, null, 99))
  checker(++testNumber, false, compare.partial({ value: 99 }, 99, 99))
  checker(++testNumber, [false, true], compare.list({ value: 99 }, 100, 99))
  checker(++testNumber, false, compare.none({ value: 99 }, 100, 99))
  checker(++testNumber, true, compare.none({ value: 99 }, 12, 'eggs', 56))
  checker(++testNumber, false, compare.some({ value: 99 }, 12, 'eggs', '99'))
  checker(++testNumber, true, compare.some({ value: 99 }, 12, 'eggs', 99))

  checker(++testNumber, false, compare({ value: { name: 'john' } }, 12, 'eggs', 99))
  checker(++testNumber, true, compare.partial({ value: { name: 'john', id: 123 } }, 12, 'eggs', { name: 'john', id: 123 }))
  checker(++testNumber, true, compare.every({ value: { name: 'john', id: 123 } }, { name: 'john', id: 123 }, { name: 'john', id: 123 }, { name: 'john', id: 123 }))
  checker(++testNumber, true, compare.partial({ value: { name: 'john', id: 123 } }, { name: 'fred', id: 123 }, { name: 'john', id: 123 }, { name: 'john', id: 123 }))

  checker(++testNumber, [true, false], compare.list({ value: new Set (['a','b']) }, new Set(['a','b']), new Set(['b','c'])))
  checker(++testNumber, [true, false], compare.list({ value: new Map ([['a','b'],['c','d']])}, new Map ([['a','b'],['c','d']]), new Map ([['a','b']])))

}
various tests

Fiddling with Apps Script objects

Let’s say you want to customize an Apps Script object.

I wrote some articles on this previously – for example Proxy implementation of Apps Script GraphQL Class and Resuscitating the Apps Script execution transcript: JavaScript Proxy and Reflect to the rescue

Now let’s use proxyDust to create a simple customized version of UrlFetchApp with these characteristics

  • Default method is fetch()
  • A base url is added to each url reference and Oauth tokens are inserted into the header automatically
  • Results are JSON parsed automatically
  • Add caching to reduce the number of API fetches made

The test API

I’m using the People API for a test here. We’ll use the REST API for this test though. If you enable the Apps Script People advanced service (although we’re not going to actually use it) it will enable the API in your cloud project for you. As usual we’ll  need to fiddle around a few scopes in your appsscript.json.

Here’s what your manifest file should contain.


  "oauthScopes": [
    "https://www.googleapis.com/auth/userinfo.email",
    "https://www.googleapis.com/auth/userinfo.profile",
    "https://www.googleapis.com/auth/script.external_request"
  ],
appsscript.json oauthscopes

Setting the default method

Let’s start by proxying UrlFetchApp and setting fetch as the default method

const dustfetchA = () => {

  // start with this object
  const originalObject = UrlFetchApp

  // set a default method
  const dustFetch = bmDuster.proxyDust({ originalObject, defaultMethod: originalObject.fetch })

  // now we can do this
  const endPoint = 'https://people.googleapis.com/v1/'
  const url = 'people/me?personFields=names,emailAddresses'

  console.log(dustFetch(endPoint + url, {
    headers: {
      authorization: 'Bearer ' + ScriptApp.getOAuthToken()
    }
  }).getContentText())

}
enabling the default function

Modifying the fetch options

Enhance that by adding the fixed endpoint and the oauth token to all default requests

const dustfetchB = () => {

  // start with this object
  const originalObject = UrlFetchApp

  // the base endpoint
  const endPoint = 'https://people.googleapis.com/v1/'

  // the applyAction adds the auth header and the base url
  const applyAction = (target, thisArg, ...args) => {
    // the arguments to fetch
    let [url, options = {} ] = args
    url = endPoint + url
    let { headers = {} } = options
    headers.authorization  = 'Bearer ' + ScriptApp.getOAuthToken()
    options = {
      ...options,
      headers
    }
    return target.apply(thisArg, [url, options])
  }

  // set a default method
  const dustFetch = bmDuster.proxyDust({ 
    originalObject, 
    defaultMethod: originalObject.fetch,
    applyAction
  })

  // now every time we need to call the api we can just do this
  console.log(dustFetch('people/me?personFields=names,emailAddresses').getContentText())

}
modify options

Parsing and organizing the reponse

Here we’ll further enhance by examining the response, parse it and create an object with it ready parsed. Note that it also creates a .throw() method which you can add if you want an error thrown if one is detected.

const dustfetchC = () => {

  // start with this object
  const originalObject = UrlFetchApp

  // the base endpoint
  const endPoint = 'https://people.googleapis.com/v1/'

  // the applyAction adds the auth header and the base url
  const applyAction = (target, thisArg, ...args) => {
    // the arguments to fetch
    let [url, options = {} ] = args
    url = endPoint + url
    let { headers = {} } = options
    headers.authorization  = 'Bearer ' + ScriptApp.getOAuthToken()
    options = {
      ...options,
      headers,
      muteHttpExceptions: true
    }
    // we'll execute the thing and deal with the response
    const response = target.apply(thisArg, [url, options])

    // we'll make a data packet of the response
    const pack = {
      response,
      data: null,
      error: null,
      parsed: false
    }
    // see if it was successful
    if (Math.floor(1 + response.getResponseCode() /200 ) === 2) {
      // parse if successful
      try {
        pack.data = JSON.parse (response.getContentText())
        pack.parsed = true
      } catch (error) {
        pack.error = error
      }
    } else {
      console.log(response.getResponseCode(), Math.floor(response.getResponseCode() /200 ) )
      pack.error = response.getContentText()
    }
    // add a throw method shortcut
    pack.throw = pack.error 
      ? () => {
          throw new Error(pack.error) 
        } 
      : () => pack
    return pack
  }

  // set a default method
  const dustFetch = bmDuster.proxyDust({ 
    originalObject, 
    defaultMethod: originalObject.fetch,
    applyAction
  })

  // now every time we need to call the api we can just do this
  console.log(dustFetch('people/me?personFields=names,emailAddresses').data)

  // or if you want to throw an error if one is detected 
  console.log(dustFetch('people/me?personFields=names,emailAddresses').throw().data)

}
organizing the reponse

Adding caching

Finally, we’ll add automatic caching so it will go to cache first rather than the API

const dustfetchD = () => {

  // start with this object
  const originalObject = UrlFetchApp

  // the base endpoint
  const endPoint = 'https://people.googleapis.com/v1/'


  // create a caching routine
  const cacher = {
    // the cache to use
    cachePoint: CacheService.getUserCache(),
    expiry: 60 * 60 * 1000,
    // create a key from arbitrary args
    digester() {
      // conver args to an array and digest them
      const t = Array.prototype.slice.call(arguments).map(function (d) {
        return (Object(d) === d) ? JSON.stringify(d) : d.toString();
      }).join("-")
      const s = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_1, t, Utilities.Charset.UTF_8)
      return Utilities.base64EncodeWebSafe(s)
    },

    cacheable(options) {
      const method = (options.method || "get").toLowerCase()
      return method === "get"
    },

    getter(url, options = {}) {
      if (!this.cacheable(options)) return null

      // we'll just use the url to base the key on - could be fancier with some options too
      const data = this.cachePoint.get(this.digester(url))
      return data ? JSON.parse(data) : null
    },

    setter(url, options = {}, data) {
      if (!this.cacheable(options)) return null
      // we'll just use the url to base the key on - could be fancier with some options too
      return this.cachePoint.put(this.digester(url), JSON.stringify(data), this.expiry)
    }
  }

  // the applyAction adds the auth header and the base url
  const applyAction = (target, thisArg, ...args) => {
    // the arguments to fetch
    let [url, options = {}] = args
    url = endPoint + url
    let { headers = {} } = options
    headers.authorization = 'Bearer ' + ScriptApp.getOAuthToken()
    options = {
      ...options,
      headers,
      muteHttpExceptions: true
    }

    // lets see if we can get it from cache
    const cached = cacher.getter(url, options)

    // we'll make a data packet of the response
    const pack = {
      response: null,
      data: null,
      error: null,
      parsed: false,
      cached: Boolean(cached)
    }
    if (pack.cached) {
      pack.data = cached
    } else {
      // we'll execute the thing and deal with the response
      pack.response = target.apply(thisArg, [url, options])

      // see if it was successful
      if (Math.floor(1 + pack.response.getResponseCode() / 200) === 2) {
        // parse if successful
        try {
          pack.data = JSON.parse(pack.response.getContentText())
          pack.parsed = true
          // write it to cache for next time
          cacher.setter(url, options, pack.data)
        } catch (error) {
          pack.error = error
        }
      } else {
        pack.error = pack.response.getContentText()
      }
    }
    // add a throw method shortcut
    pack.throw = pack.error
      ? () => {
        throw new Error(pack.error)
      }
      : () => pack
    return pack
  }

  // set a default method
  const dustFetch = bmDuster.proxyDust({
    originalObject,
    defaultMethod: originalObject.fetch,
    applyAction
  })

  // now every time we need to call the api we can just do this
  console.log(dustFetch('people/me?personFields=names,emailAddresses').data)

  // or if you want to throw an error if one is detected 
  console.log(dustFetch('people/me?personFields=names,emailAddresses').throw().data)

  // we can see if came from cache
  const { data, cached } = dustFetch('people/me?personFields=names,emailAddresses').throw()
  console.log(data, cached)

}
added caching

 

That’s it.

We’ve created a new version of UrlFetchApp with all the same characteristics as before – but now we have a default method which automatically does all the stuff we’d normally have to do when calling an API.

 

Summary

I’ve covered quite a bit in this article so thanks for sticking with it the whole way through. Proxies are a very powerful way of avoiding writing wrappers for existing objects, such as the the Apps Script services – or anything else you can think of.

Links

As usual you can find all my libraries in my github repository at https://github.com/brucemcpherson

or in the IDE at

bmDuster 1iBu-3kNBl2TKGeiQLKKDHte22dI0J9Z55SwYktpfbXCbxB0yrbG9ngC-

testBmDuster 1ilZxyMMU3rZn4168Sg7HEAP0_IAUdjoLktZETXf82SlQsWL2swu9FVCE

There’s a node version of all this, with many more tests at https://github.com/brucemcpherson/bm-duster

Related

SuperFetch

SuperFetch – a proxy enhancement to Apps Script UrlFetch

I've written a few articles about JavaScript proxying on here, and I'm a big fan. I also use a lot ...
Read More
js proxy

JavaScript proxy, fiddling with Apps Script objects and custom errors

This article is a little more advanced than usual, and we'll cover a number topics in one go. A JavaScript ...
Read More
apps script v8

Proxy implementation of Apps Script GraphQL Class

In Resuscitating the Apps Script execution transcript - JavaScript Proxy and Reflect to the rescue I showed how we could use ...
Read More
apps script v8

Resuscitating the Apps Script execution transcript – JavaScript Proxy and Reflect to the rescue

If you've been using Apps Script for a while, since back when it was running on the Rhino JavaScript emulator; ...
Read More