The Enigma of Javascript Proxies
This is quite a technically involved article. It is not for the faint of heart.
When proxies were introduced to Javascript via the ECMAScript 6 specification, I was giddy with excitement. “What great doors this will open”, I thought. “Metaprogramming in the best language in the world is finally here, and I can’t wait to use it responsibly!”
Narrator: He did not, as it turns out, use this responsibly.
Poring over specifications that I convinced myself I was able to understand, I realized that t̶h̶e̶r̶e̶ ̶w̶a̶s̶ ̶n̶o̶ ̶m̶e̶a̶n̶i̶n̶g̶ ̶t̶o̶ ̶l̶i̶f̶e proxies could be quite powerful if used correctly. Sure, you could do the boring things like “log property accesses, validate, format, or sanitize inputs, and so on”, but… what if you didn’t want to do those things? What if you wanted more?
As it turns out, attaining omnipotence within Javascript is entirely possible. It simply involves using a wonderfully magnificently elegant feature and gleefully defiling it such a manner its creators would be ashamed of.
T̶h̶e̶s̶e̶ ̶a̶r̶e̶ ̶t̶h̶e̶i̶r̶ ̶s̶t̶o̶r̶i̶e̶s̶. This is my story.
An Exotic Prelude
The ECMAScript 2023 specification has the following definition:
A Proxy object is an exotic object whose essential internal methods are partially implemented using ECMAScript code.
An exotic object, quite helpfully, is simply anything that is not an ordinary object. Read that definition at your own risk.
Most important here are the “essential internal methods”; in this case, a special internal method called [[Get]]
, which we can “override” by providing our own get
method on what is known as a “Proxy Handler”. You can create a proxy object by simply writing new Proxy(target, handler)
. The target
is then known as the proxy’s target object. The result of this mess is a facade for your target object, with the proxy handler lying in-between the facade and the target.
A Delicious Layering
We now have three delectable toppings on our tempting proverbial sandwich:
- Proxy Object: The exotic object, and what other code actually “sees”
- Proxy Target Object: An object “wrapped” by the proxy object.
- Proxy Handler: An object containing certain methods “overriding” those on the proxy object, to administrate property accesses and other fun things
With a diagram, let’s picture some sandwich object that has been wrapped with a proxy:
All the external code is able to see is the sandwich proxy object, and it has no knowledge of any of the internals. In requesting the type
property on the sandwich object, the proxy object calls the handler with the get
method, passing through a reference to the target object.
Notably, the handler is free to do whatever it wishes with this request. It can log something to the console, make an HTTP request, buy some bitcoin (before promptly losing it all) — it’s just a function, it has free will, limited only by the imagination of its creator.
Controversially, the notion of free will has been debated for centuries. One might think a discussion about moral culpability, determinism, libertarianism, and related topics have no place in a post about programming. One might be right.
The Bespoke Testing Framework
At Hack the North, we use an Apollo GraphQL backend to make writing server code simple. The concepts of GraphQL itself are not too important, but I’ll define required concepts as we go on to bake ourselves a yummy testing framework.
A Missing Dessert
A problem that we regularly run into on the backend side of code is both a lack of tests and a lack of motivation to write those tests. My thoughts on this are three-fold:
- Overhead: GraphQL requires a request body even for the simplest of requests. This request body is typically untyped, even if using Typescript, as the definition is contained within a string. There is a library that explains this problem nicely and provides a solution much better than mine.
- Abstraction: It’s difficult to remember that, despite all the overhead needed to construct a full GQL request, it all simply amounts to a function call on the server side.
- Type-safety: Returned objects from the untyped requests are also untyped. This messes with code autocompletion — a feature I cannot live without.
A fourth reason, of course, is the fact that we’ll get to it next sprint. I promise.
To give some concrete examples, a simple test might look as follows:
This test isn’t too particularly complicated, but as the tests become increasingly complex, or as we want to test user flows that require multiple GQL queries, the actual “meat” of the test will get buried beneath all the overhead. It’s also quite ugly.
What We’re Making
One night I found myself under the weather and with a fever running quite high. Said fever had knocked me out for the day, but it gave me something better: a fever dream. That dream set the stage for today’s treat.
Instead of soaring through the skies or frolicking through some daisy fields, I had a dream about this problem. In particular, it was of me writing a test and — God forbid — actually enjoying it. I realized that a great solution to this conundrum would be to simply call the GQL resolver as if it was a function, abstracting all the HTTP overhead away from the developer.
Whether or not this was a good idea is beyond the scope of the article. At this point in time I was too fixated on what was possible rather than what was practical. This was personal now.
On a friendly note to the happy code-monkeys following along, this is what a resolver method might look like:
What if we could simply call this as UserResolver.updateUserEmail(...)
in our tests? What a joy that would be. Alas, t’was doomed to be merely a pipe dream.
Narrator: Or was it?
The Ingredients
Any good recipe calls for ingredients in imperial measurements that are impossible to convert cleanly to metric:
- 3 ounces of codegen
- 1.5 pounds of proxy
- Half a tablespoon of determination
- A sprinkle of insanity
With ingredients in hand we can move on to the next step.
Preparation
The separation between our testing environment and running backend server means we can’t actually directly call the resolver methods straight from our testing code.
So instead, we create some genius code generation to create “stubbed” versions of the server-side resolvers that provide all the type information that IntelliSense (or similar) could possibly need. These stubs would also strip out all the decorators and any other unnecessary data.
Key items of note:
- The type of query is no longer declared via decorator but simply returned inline. This is because I couldn’t find a way to get the presence of the
@Mutation
decorator at runtime. - The method has an explicit
Promise<User>
return type and completely disregards it by returning a string. We tell a lie to make code autocompletion work. - The method is marked
static
—Apollo internally instantiates new instances of each resolver, but for our testing code we would like not to have tonew
the resolver before calling methods upon it.
Run this codegen against every single resolver at compile time and we will have access to all the type information we could possibly need.
I’m sure there’s an even more convoluted way to obtain the type information from the original resolvers directly without codegen. My fever dream did not provide a solution.
Time for some fun.
Cooking
Before continuing, make sure to preheat the oven to 400°F to save time!
Finally, we can wrap back to the proxy objects.
“Why do we need them?”, you, dear reader, might ask.
“So we can create a GQL request, of course!”
Recall that a proxy is able to intercept calls to the proxy target and do… anything it wants. Conveniently, a subset of “anything” includes constructing and sending a GQL request.
In order to protect our users from the horrors of untyped GQL requests, we must first lie to them a little. Instead of exporting the stub itself, we export a proxied stub, invisible to our developers. This forces clients to use the proxied stub rather than the stub itself, allowing us to correct their oft-misguided ways.
There is one small caveat: GQL requires that the client specify the fields that are desired on the response type. We cannot (easily) dynamically determine these fields, so in the actual implementation we kindly ask our test writers to define the fields they want. For this article, I will pretend that the server just knows what fields to return.
Proxy Handler
The proxy handler for the stub will intercept all function calls to the stub and construct a real GQL request to be delivered to the server.
The Apollo framework supports a feature called
executeOperation
that allows us to skip actually constructing the HTTP request ourselves. Since we’re not trying to test the HTTP layer, this will do just fine.
Conceptually, this proxy handler will process all the required information to construct a valid GQL request that is then dispatched to the Apollo server.
To do this, we not only need to intercept the call to updateUserEmail
, but to every single method on the stub. Conveniently, the get
trap allows us to do just that.
Let’s take a look at this code as if UserResolver.updateUserEmail(1, “hello@email.com")
were called:
target
is theUserResolver
proxy target, which is also the typeT
prop
is"updateUserEmail"
- we return an async function to accept the
(1, "hello@email.com")
invocation mapArgumentsToObject
constructs a map of the parameters of the method calls to their arguments. For example, the methodupdateUserEmail
is defined asupdateUserEmail(id, email)
, and since methods can be converted to strings, we can extract the parameter names,[“id", “email"]
. We map the arguments the caller has provided to these parameter names, so a call toupdateUserEmail(1, "hello@email.com")
would create a neat object{ id: 1, email: "hello@email.com" }
.generateQueryFor
calls the stub method to determine the type of query. In this case, the stub returns"Mutation"
. Here we hand-wave some magic and say that it can also enumerate all the fields we want on the response.delegate
merges the query with theexecutionContext
in order to map the arguments to their respective parameters for the actual GQL request. It then dispatches the request to the Apollo server viaexecuteOperation
.
The resulting query might appear as follows:
mutation {
updateUserEmail(id: 1, email: "hello@email.com") {
...
}
}
The fact that this is able to work makes three critical observations/assumptions:
- the name of the method is also the name of the GQL query/mutation
- the names of the method arguments are also the names that GQL will use, such as
id
andemail
- the return type is given by the stub, which is what code autocompletion thinks you are calling. how naïve.
If either of these assumptions do not hold, the code will cry.
I cried regardless.
One additional caveat: the return type specified on the stub contains all fields, not just ones that we requested. This can most certainly be improved in future iterations and is left as an exercise to the reader.
Results
All this said and done, we can finally write a wonderful piece of testing code:
const response = await UserResolver.updateUserEmail(1, "hello@email.com);
expect(response.email).toBe("hello@email.com");
No GQL overhead. Proper types. The code reads exactly like a sentence, allowing developers to focus on writing tests that matter instead of getting stuck in the weedy implementation details.
Don’t forget to turn off the oven when you’re done. Wouldn’t want any fires.
Epilogue
There are a few more components of this testing framework that do not make use of proxies, but are nonetheless still quite interesting. It includes incredibly useful one-line helper functions to abstract the construction of pipelines and claims (a little more on that in this article), addresses the often-leaky lifetimes of test objects (ie. not being destroyed), and tightly integrates with some other aspects of our backend code — all the while, of course, remaining properly typed.
Proxies support a number of other traps other than just get
, you can write apply
to trap function calls, and even construct
to trap new
calls. Use knowledge wisely.
Instead of requiring a full proxy object, Javascript also provides getters and setters that perform a limited subset of the same functions a proxy would otherwise provide. This can be used more for simple runtime computations or input validation without the mess of a proxy.
Javascript also provides the Reflect class. I’m not quite sure how to use it, but it seems similar. Go forth and explore!
Was this framework a good idea? Perhaps. Was it fun? Absolutely. Enjoy your dessert.