Amazon API Gateway is, along with AWS Lambda, arguably, the most frequently used service in AWS serverless architectures. The service's feature spectrum, starting from rate-limiting and ending on request/response validation, is vast, making it an ideal choice for many API-driven applications.
Amongst its features, as a "Lambda-less" enthusiast, one is particularly interesting to me – the ability to create mapping templates that transform the payload or the response without the need for AWS Lambda.
This article will touch on something I've spent many hours debugging – the behavior of the built-in mapping template variables concerning Amazon REST API Gateway VTL and talk about my assumptions that turned out to be wrong.
The code in this article is sourced from this GitHub repository.
The architecture
I often find that often the understanding lies in the concrete. This blog post is no exception. The following architecture will serve as a reference in our discussions.
The user makes a request to an API frontend with Amazon API Gateway backed by a mock integration. The AWS Lambda authorizer handles authorization. The role of the mapping template is to respond with some of the Amazon API Gateway mapping template variables, mainly the ones related to authorization.
The mapping template is crucial since its structure dictates the response of the API. Let us look at it next.
The code
Luckily, to showcase the behavior I'm teasing you about, we do not need to write a lot of code. A two-liner of a mapping template and a very bare-bones AWS Lambda authorizer will do.
The following is the mapping template code.
{
"authorizerContextUsernameProperty": "$context.authorizer.username",
"wholeAuthorizerVariable": "$util.escapeJavaScript($context.authorizer)",
}
The mapping template declares two keys, the authorizerContextUsernameProperty
which is read directly from the $context.authorizer.username
and the wholeAuthorizerVariable
. I'm escaping the value of $context.authorizer
to avoid parsing issues.
And here is the AWS Lambda authorizer code.
export const handler: APIGatewayTokenAuthorizerHandler = async event => {
// Assuming the user is authenticated and the token is valid
return {
principalId: "1",
context: {
username: "test-username"
},
policyDocument: {
Statement: [
{
Action: "execute-api:Invoke",
Effect: "Allow",
Resource: event.methodArn
}
],
Version: "2012-10-17"
}
};
};
The authorizer is not doing much. It is only there to populate the context
property so that we have something to work with within the mapping template.
The surprising behavior
If I were to invoke the API, what would be the output? As a reminder, the following is our response mapping template.
{
"authorizerContextUsernameProperty": "$context.authorizer.username",
"wholeAuthorizerVariable": "$util.escapeJavaScript($context.authorizer)",
}
When I first started looking into API Gateway mapping templates I thought that the result would look similar to the following object.
{
"authorizerContextUsernameProperty": "test-username",
"wholeAuthorizerVariable": "{\"username\": \"test-username\", ... OTHER_PROPERTIES }"
}
To my surprise, that was not the case. Here is the response I have received.
{
"authorizerContextUsernameProperty": "test-username",
"wholeAuthorizerVariable": ""
}
Even though we clearly see that $context.authorizer.username
is defined, the $context.authorizer
somehow is "empty". After staring at the screen trying to come up with an answer, I've decided to check the whole $context
object.
{
"authorizerContextUsernameProperty": "$context.authorizer.username",
- "wholeAuthorizerVariable": "$util.escapeJavaScript($context.authorizer)",
+ "wholeAuthorizerVariable": "$util.escapeJavaScript($context)",
}
And the result was surprising to me as well! (I redacted some of the values).
{
"authorizerContextUsernameProperty": "test-username",
"wholeAuthorizerVariable": "{resourceId=w7badv0rv4, authorizer=, resourcePath=/, httpMethod=GET, extendedRequestId=OY6_EHr1DoEFSzg=, requestTime=03/Mar/2022:04:06:30 +0000, path=/prod/, accountId=XXX, protocol=HTTP/1.1, requestOverride=, stage=prod, domainPrefix=9sd680n5z5, requestTimeEpoch=1646280390676, requestId=f9393904-3014-43f6-8e24-f594db2d7604, identity=, domainName=9sd680n5z5.execute-api.eu-west-1.amazonaws.com, responseOverride=, apiId=9sd680n5z5}"
}
As you can see, the authorizer
, identity
, responseOverride
properties are empty. Since I did not override any request and response parameters, the emptiness of responseOverride
property is understandable. But what about the authorizer
and identity
? I can see that $context.authorizer.username
contains a value, otherwise the authorizerContextUsernameProperty
property would be empty.
What about AWS AppSync?
Like Amazon API Gateway, the AWS AppSync service enables developers to write VTL mapping templates to transform data. Will AWS AppSync VTL parsing logic behave differently from the one we observed in Amazon API Gateway? Let us find out.
The architecture is very similar to the one we have looked at previously. The main difference is that instead of making a GET request, we will be making a POST request since AWS AppSync exposes a GraphQL API.
The mapping template and the response
The following is the AppSync resolver request mapping template we will be working with. Check out resolver mapping template reference for Node data source documentation for more information.
This article is not about AWS AppSync itself. If you are not familiar with the service, this GitHub repository contains a lot of AWS AppSync specific resources that you can consume.
#set($payload = {
"authorizerContextUsernameProperty": $context.identity.resolverContext.username,
"wholeAuthorizerVariable": $util.escapeJavaScript($context.identity.resolverContext)
})
{
"version": "2018-05-29",
"payload": $util.toJson($payload),
}
If the mapping templates would work the same way as those declared in the context of Amazon API Gateway, the wholeAuthorizerVariable
should return an empty string whenever I perform the GraphQL Query that uses this mapping template.
That is not the case. The following is the response I've got.
{
"data": {
"getData": {
"authorizerContextUsernameProperty": "test-username",
"wholeAuthorizerVariable": "{username=test-username}"
}
}
}
The wholeAuthorizerVariable
contains the key with a variable set by AWS Lambda authorizer – much different from how our sample Amazon API Gateway mapping template behaved.
If I were to amend the mapping template the following way:
#set($payload = {
"authorizerContextUsernameProperty": $context.identity.resolverContext.username,
- "wholeAuthorizerVariable": $util.escapeJavaScript($context.identity.resolverContext)
+ "wholeAuthorizerVariable": $util.escapeJavaScript($context.identity)
})
{
"version": "2018-05-29",
"payload": $util.toJson($payload),
}
The GraphQL Query result is even more interesting as it leaks some service internals.
{
"data": {
"getData": {
"authorizerContextUsernameProperty": "test-username",
"wholeAuthorizerVariable": "com.amazonaws.deepdish.common.identity.LambdaAuthIdentity@610a9a15"
}
}
}
I have no idea what com.amazonaws.deepdish.common.identity.LambdaAuthIdentity@610a9a15
is.
Bottom line
It appears that Amazon API Gateway evaluates the mapping template variables in a lazy fashion. Only the "deepest" properties of a given built-in variable are evaluated at all.
The AWS AppSync acts a bit differently. As we saw earlier with the $util.escapeJavaScript($context.identity.resolverContext)
returning the stringified object.
After playing a bit more with the code, I have created the following rule of thumb I will be following from now on: Whenever working with built-in mapping template variables, always work with the "deepest" properties of a given variable.
Closing words
I wrote this article because I've spent a lot of time debugging a "weird" behavior of Amazon API Gateway mapping templates. While the knowledge of what I wrote about is not necessary for you to develop incredible applications, I hope it could save you the time I've wasted.
For more AWS serverless content, consider following me on Twitter – @wm_matuszewski.
Thank you for your precious time.