I’ve written a few articles on here about JavaScript proxy and reflect. I use both extensively not only in Node projects but also in Apps Script. In this article I’ll demonstrate how to extend built-in Apps Script objects. We’ll fiddle with UrlFetchApp to build in caching and graphql capabilities right into the fetch function and CacheSevices to add parsing.
Usually, if you want to extend an object or its methods, you’d build a wrapper with its own properties around that object. Creating a proxy for an object allows you to intercept or ‘trap’ accesses to its properties, or to modify the behavior of its methods. We’ll use this trapping to add rich functionality to Apps Script without having to build wrapper functions.
First off we’ll just create a proxy for UrlFetchApp that does nothing extra. Calling proxy.fetch will have exactly the same effect as calling UrlFetchApp.fetch. The ‘handler’ – the second argument to the Proxy constructor is empty so no trapping is done. The proxy instance created is essentially just a clone of UrlFetchApp.
Getting started
const proxy1 = () => {
// use httpbin for initial testing const endpoint = "https://httpbin.org"
// make a simple proxy of UrlFetchApp // in this case the handler (2nd argument) does nothing const proxy = new Proxy(UrlFetchApp, {})
// check it still works result = JSON.parse(proxy.fetch(endpoint "/get").getContentText()) console.log(result) }
This proxy does nothing
Trapping property gets
Here’s how to intercept an access to a property. We’ll be working on the fetch method, so first of we need to notice when the fetch method is accessed. At this point, we’re still not modifying any behavior – simply logging the fact that the fetch property has been accessed. The 3 arguments passed to the get function in our proxy are
target – the object being proxied. In this case, UrlFetchApp
prop – the property being accessed. In this case, ‘fetch’
receiver – this is the proxy we’re making. You seldom need to worry about this argument
We use Reflect to complete the get request, as we are not modifying any of the behaviors at this point. All that will happen is we’ll see a message confirming that an access to the fetch property has been observed.
const proxy2 = () => {
// use httpbin for initial testing const endpoint = "https://httpbin.org"
// lets intercept calls to the fetch argument const proxy = new Proxy(UrlFetchApp, { // every time fetch is accessed this will be called first get(target, prop, receiver) { if (prop === 'fetch') { console.log('fetch called') } // reflect.get will return allthe same args as was passed to the proxy return Reflect.get(target, prop, receiver) } })
// check it still works result = JSON.parse(proxy.fetch(endpoint "/get").getContentText()) console.log(result) }
Logging property access
Validating references properties
One useful by product of using a proxy (I often use proxies just for this purpose) is to notice if you attempt to access a property that doesn’t exist. It’s all too easy in JavaScript to mispell a property name and get an unexpected and unnoticed ‘undefined’ result back. Here we are going to throw an error if an attempt is made to access a property that doesn’t exist on the object.
const proxy3 = () => {
// use httpbin for initial testing const endpoint = "https://httpbin.org"
// now let's validate that any args passed to the proxy exist const proxy = new Proxy(UrlFetchApp, { // every time fetch is accessed this will be called first get(target, prop, receiver) { console.log('trying property', prop) if (!Reflect.has(target, prop)) { throw `attempt to access unknown fetchapp property '${prop}'` } // reflect.get will return allthe same args as was passed to the proxy return Reflect.get(target, prop, receiver) } }) // check it still works let result = JSON.parse(proxy.fetch(endpoint "/get").getContentText()) console.log(result)
// try an invalid key result = JSON.parse(proxy.nonsense(endpoint "/get").getContentText()) console.log(result) }
check for valid property names
Encapsulating values in the proxy
This is the first small change to the UrlFetchApp.fetch behavior we’ll make. Instead of callers to ‘fetch’ having to specify the base endpoint, we’ll build that into the proxy. This will allow, for example, different version of your api to be used with no change required anywhere other than in the proxy.
In this case, when the ‘fetch’ property is accessed, we return a modified version of the regular method. In fact, it’s returning a proxy for UrlFetchApp.fetch which traps ‘apply’. Apply is called when a method is executed. Here’s what happens.
proxy.fetch (url, options) is called
the proxy detects the attempt to find the fetch function
instead of returning the regular fetch function it returns a modified version of it
this modifed version has an ‘apply’ trap for when it is executed to call UrlFetchApp.fetch (endpoint + url, options) – in other words to extend the originally passed url
This small victory forms the basis to some of the much more complex things we’ll introduce in the next part of this article.
const proxy4 = () => {
// use httpbin for initial testing const endpoint = "https://httpbin.org"
// now intercept a function call to fetch and add the endpoint automatically const proxy = new Proxy(UrlFetchApp, {
// every time fetch is accessed this will be called first get(target, prop, receiver) {
// check its a good call if (!Reflect.has(target, prop)) { throw `attempt to access unknown fetchapp property '${prop}'` } // if we get a fetch call, we'd like to send it back with the endpoint encapsulated // so that when it's applied, it will execute my version of the function if (prop === 'fetch') { return new Proxy(target[prop], { // this apply(func, thisArg, args) { // pick off the url and fiddle with the arguments const url = args[0] || '' return func.apply(thisArg, [endpoint url].concat(args.slice(1))) } }) }
// reflect.get will return allthe same args as was passed to the proxy return Reflect.get(target, prop, receiver) } })
// check it still works this time we don't need the endpoint let result = JSON.parse(proxy.fetch("/get").getContentText()) console.log(result)
// make sure that additional arguments still work result = JSON.parse(proxy.fetch("/get", { method: "post", payload: { data: "post options" }, contentType: "application/json", header: { Authorization: "Bearer " ScriptApp.getOAuthToken() } }).getContentText())
console.log(result)
}
Modifying the base end point
Proxy for caching
We can build caching right into the fetch method, but before that we are going to create a proxy for the Apps Script cacheservice. Some of the features are
key prefixes automatically added to separate different data
tracking data added so we can tell how old the cache entry is
encapsulating data in an object so that multiple types of data can be saved to cache
instead of returning just the cache data, it will return an object with a value property containing the value retrieved from cache, and a cacheData property which will hold metadata about the cache entry such as when it was written
To achieve this we need to trap calls to the CacheService get, put and remove methods and to return versions of these with ‘apply’ traps to provide the behavior described above.
// a cache proxy return new Proxy(CacheService.getUserCache(), { // every time fetch is accessed this will be called first get(target, prop, receiver) {
// check its a good call if (!Reflect.has(target, prop)) { throw `attempt to access unknown cacher property '${prop}'` } // if we get a fetch call, we'd like to send it back with the endpoint encapsulated // so that when it's applied, it will execute my version of the function
// reflect.get will return allthe same args as was passed to the proxy return Reflect.get(target, prop, receiver) } }) }
A proxy for CacheService
Adding caching to fetch
Now we’re ready to add caching to the fetch proxy. In this case fetch will return an object with these properties
value – the value returned from either cache or by fetching it
cacheData – if it came from cache, this property will contains metadata about the age of the cache as well as other info
reponse – if it came from fetch, this will be a UrlFetchResponse
The example below shows multiple calls to the proxy with a delay to allow cached values to expire. Note that only ‘gets’ are cached, and only the url is used as the key. In a later example, we’ll enhance that to cache other kinds of requests.
const proxy6 = () => {
// use requestbin for post testing const endpoint = "https://httpbin.org"
// every time fetch is accessed this will be called first get(target, prop, receiver) {
// check its a good call if (!Reflect.has(target, prop)) { throw `attempt to access unknown fetchapp property '${prop}'` }
// a get will populate cache when its not found in cache if (prop === 'fetch') { return new Proxy(target[prop], { // this apply(func, thisArg, args) { const options = args[1] || {} const shouldCache = (options.method || "get").toLowerCase() === 'get' const url = endpoint (args[0] || '') const cacheEntry = shouldCache && cacher.get(url) if (cacheEntry) return cacheEntry // since we're oly dealing with json in this example. we'll parse the result while we're at it const response = func.apply(thisArg, [url].concat(args.slice(1))) const text = response.getContentText() const value = JSON.parse(text) if (shouldCache) cacher.put(url, value) return { value, response } } }) }
// reflect.get will return allthe same args as was passed to the proxy return Reflect.get(target, prop, receiver) } })
// we're doing caching and parsing in the proxy now let result = proxy.fetch("/get") // this is the api result console.log(result) console.log(result.value) // this is the cache data if it came from cache console.log(result.cacheData)
// if we do it again we should get it from cache console.log ('from cache', proxy.fetch("/get").cacheData)
// what happens if we wait a bit - it should not come from cache Utilities.sleep (5000) console.log ('not from cache', proxy.fetch("/get").cacheData)
}
add caching to the fetch method
Putting it all together
You’ll probably want multiple proxies for different things – for example caching and non caching versions, different endpoints etc, so we can easily generalize all this into a function that returns a fully baked proxy with everything in it we’ve looked at so far
// every time fetch is accessed this will be called first get(target, prop, receiver) {
// check its a good call if (!Reflect.has(target, prop)) { throw `attempt to access unknown fetchapp property '${prop}'` }
// a get will populate cache when its not found in cache if (prop === 'fetch') { return new Proxy(target[prop], { // this apply(func, thisArg, args) { const options = args[1] || {} const shouldCache = cacher && (options.method || "get").toLowerCase() === 'get' const url = endpoint (args[0] || '') const cacheEntry = shouldCache && cacher.get(url) if (cacheEntry) return cacheEntry // since we're oly dealing with json in this example. we'll parse the result while we're at it const response = func.apply(thisArg, [url].concat(args.slice(1))) const text = response.getContentText() const value = JSON.parse(text) if (shouldCache) cacher.put(url, value) return { value, response } } }) }
// reflect.get will return allthe same args as was passed to the proxy return Reflect.get(target, prop, receiver) } })
}
Generalized function to get a proxy
Caching examples
Here’s a couple of examples showing multiple proxy instances with different characteristics
const proxy8 = () => {
// this one with caching and everything. should be able to support posting as well const proxy = getProxy ({endpoint: "https://httpbin.org"})
// we're doing caching and parsing in the proxy now let result = proxy.fetch("/post", { method: "post" })
// this is the api result console.log(result) console.log(result.value) // this is the cache data if it came from cache console.log('nocaching on a post', result.cacheData)
// we can turn off caching too const noCache = getProxy ({noCache: true, endpoint: "https://httpbin.org"}) noCache.fetch ('/get')
// normally this would be cached - but we've turned it off console.log('shoud be undefined', noCache.fetch("/get").cacheData)
}
multiple instnaces
Implementing a graphql proxy
Nowadays many APIs offer graphQL endpoints rather than or as well as REST endpoints. For this example I’m going to use the filmID api endpoint. You need an api key for this, so you won’t be able to run it, but it will demonstrate how the previous examples can easily be put to other uses.
A gql proxy
This differs a little from the REST proxy in the previous examples
all requests to graphql are posts rather than gets. A request that updates is called a mutation, whereas a get operation is called a query
the payload is a graphql query or mutation, plus some options variables
the api I’m using here needs an api key in the header
because the query is built into the payload rather than the url, we need to digest the payload and the url to build a cache key, and because the apikey used might limit data access, we should add that too. In this example, I’m using an md5 digest to create a cache key from all that
the url for a graphql query is actually the endpoint, so instead of calling proxy.fetch (url, options), we will call proxy (graphqldefinition).
graphqldefinition is an object that looks like this {query, mutation, variables}. It’ll be the job of the apply trap in the fetch proxy to convert that into a fetch payload and options.
// make a digest from arbitrary set of objects const digest = (...args) => { return Utilities.base64Encode( Utilities.computeDigest( Utilities.DigestAlgorithm.MD5, Utilities.newBlob(JSON.stringify({ a: Array.from(args) })).getBytes() )) }
return new Proxy(UrlFetchApp, {
// every time fetch is accessed this will be called first get(target, prop, receiver) {
// check its a good call if (!Reflect.has(target, prop)) { throw `attempt to access unknown fetchapp property '${prop}'` }
// a get will populate cache when its not found in cache if (prop === 'fetch') { return new Proxy(target[prop], { // this apply(func, thisArg, args) {
// calls to gql are always post // we'll cache queries but not mutations - im not implementing mutations for this demo // the key needs to be a digest of the query content, the apikey and the url /// args are {query, variables} if (!args[0]) throw `arg should be {query?, mutation?, variables}` const load = args[0] const shouldCache = cacher && load.query const keys = digest({ endpoint, load }) const cacheEntry = shouldCache && cacher.get(digest) if (cacheEntry) return cacheEntry
const payload = JSON.stringify(load) const contentType = "application/json" const headers = getHeaders() const options = { headers, contentType, muteHttpExceptions: true, payload, method: "post" } // since we're oly dealing with json in this example. we'll parse the result while we're at it const response = func.apply(thisArg, [endpoint, options]) const text = response.getContentText() const value = JSON.parse(text) if (shouldCache) cacher.put(digest, value) return { value, response } } }) }
// reflect.get will return allthe same args as was passed to the proxy return Reflect.get(target, prop, receiver) } })
}
The gql proxy
Using the gql query
Now we have a very simple to use proxy that can handle graphql, and has built in caching.
const queryTest = () => {
const queries = { Film: `query Film ($id: ID!) { Film (id: $id) { id filmName synopsis } } ` }
"Film": { "id": "1018", "filmName": "Submarine", "synopsis": "A man wakes up in a Russian submarine, dressed in a monkey suit. He han drunk too much last night and does not remember anything.." }
result
Summary
Proxies are a great way of implementing abstraction and extension and a much cleaner than implementing wrappers.
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