Ran into something this week, and I wanted to distill it to an understandable post and put it here for those that might find a need, and run across it while searching. There are a lot of posts about the use of the Javascript Proxy object. In short, it's goal is to allow a user to wrap an Object - or function, with a similar object, and intercept (aka trap) the calls to that Object, and modify the behavior of the result.
The examples are all very nice... how to override getting a property value... how to add default values for undefined properties... how to add validation to setting of properties... and all these are good things... but they are simplistic. What if you have an Object that's an interface to a service? Like Stripe... or HelloSign... or Plaid... and you want to be able to augment or modify function calls? What if they are returning Promises? Now we're getting tricky.
The problem is that what's needed is a little more general example of a Proxy, and so we come to this post. 🙂 Let's start with an Object that's actually an API into a remote service. For this, I'll use Platter, but it could have as easily been Stripe, HelloSign, Plaid, or any of the SaaS providers that have a Node Client.
We create an access Object simply:
const baseDb = new Postgres({
key: process.env.PLATTER_API_KEY,
})
but Postgres will have lower-case column names, and we really want to camel case where: first_name in the table, becomes firstName in the objects returned.
So for that, we need to Proxy this access Object, and change the query function to run camelCaseKeys from the camelcase-keys Node library. So let's start by recognizing that the function call is really accessed with the get trap on the Proxy, so we can say:
const db = new Proxy(baseDb, {
get: (target, prop) => {
if (prop === 'query') {
return (async (sql, args) => {
const rows = await target.query(sql, args)
return rows.map(camelCaseKeys)
})
}
}
})
The signature of the query() function on the access Object is that it returns a Promise, so we need to have the return value of the get trap for the prop equal to query, return a function similar in signature - inputs and output, and that's just what:
return (async (sql, args) => {
const rows = await target.query(sql, args)
return rows.map(camelCaseKeys)
})
does. It takes the two arguments: a SQL string that will become a prepared statement, and a list of replacement values for the prepared statement.
This isn't too bad, and it works great. But what about all the other functions that we want to leave as-is? How do we let them pass through unaltered? Well... from the docs, you might be led to believe that something like this will work:
return Reflect.get(...arguments)
But that really doesn't work for functions - async or not. So how to handle it?
The solution I came to involved making a few predicate functions:
function isFunction(arg) {
return arg !== null &&
typeof arg === 'function'
}
function isAsyncFunction(arg) {
return arg !== null &&
isFunction(arg) &&
Object.prototype.toString.call(arg) === '[object AsyncFunction]'
}
which simply test if the argument is a function, and an async function. So let's use this to expand the code above and add a else to the if, above:
const db = new Proxy(baseDb, {
get: (target, prop) => {
if (prop === 'query') {
return (async (sql, args) => {
const rows = await target.query(sql, args)
return rows.map(camelCaseKeys)
})
} else {
value = target[prop]
if (isAsyncFunction(value)) {
return (async (...args) => {
return await value.apply(target, args)
})
} else if (isFunction(value)) {
return (...args) => {
return value.apply(target, args)
}
} else {
return value
}
}
}
})
In this addition, we get the value of the access Object at that property. This could be an Object, an Array, a String, a function... anything. But now we have it, and now we can use the predicate functions to see how to treat it.
If it's an async function, create a new async function - taking any number of arguments - thereby matching any input signature, and apply the function to that target with those arguments. If it's a simple synchronous function, do the similar thing, but make it a direct call.
If it's not a function at all, then it's a simple data accessor - and return that value to the caller.
With this, you can augment the behavior of the SaaS client Object, and add in things like the mapping of keys... or logging... or whatever you need - and pass the rest through without any concerns.