Part Three
Author's Note: Since this article series was first drafted, AWS announced support for JavaScript resolvers. While I am excited to begin using JS resolvers, the tooling - most notably the serverless-appsync-plugin - has yet to fully support it. Look for new posts in the near future where I will show you how to set up and use JS resolvers. Until then, I think the following content still has value and I hope it helps you.
VTL for A POST
An HTTP POST (or PUT) differs from a GET (or DELETE) in that a body is passed in. In this tutorial, we are going to pass a JSON body to our Product service endpoint to create a new Product
.
Everything we want to send in the body is contained in the GraphQL input CreateProductInput
and will show up in the resolver at $context.arguments.input
. Given that, we are going to add two new properties to our HTTP request object: a content type header and a body.
The header is a constant of "application/json" and we can situate it next to our existing custom header:
"headers": {
"x-custom-store-id": "${storeId}",
"Content-Type": "application/json"
}
The body is considered a "param" and is a sibling to headers
. The only thing we need to do is invoke the utility function $util.toJson()
to get us a "stringified" JSON representation of the input object:
"headers": { ... ,
"body": $util.toJson($context.arguments.input)
Your create-product response template will be very similar to the GET, but be sure to set the expected status code to match your HTTP service endpoint. In our example, we are now expecting a 201, so we change our #if
conditional statement accordingly:
...
#if($ctx.result.statusCode == 201)
$ctx.result.body
#else
...
Quick Tip: HTTP Status Codes
There are many (many) HTTP status codes out there. But, when dealing with "success" status codes - as we are in this tutorial - we can limit ourselves to a handful. The common success codes are 200 (OK), 201 (Created), and 204 (No Content). These are often associated with particular HTTP verbs: POST returns 201, GET/PUT returns 200, and DELETE returns 204, but these are not hard-and-fast rules. Stay consistent with the particular service you are integrating with.
IAM Authorization
In Part One, I mentioned that our micro-service endpoints are IAM protected. This means that API Gateway will only allow interactions from callers who have an IAM policy that allows them to execute-api:Invoke
against the ARN of the particular API Gateway you are trying to reach. This is part of the "Zero Trust" security model and I have written about it previously in case the idea is new to you.
Fortunately, all we need to do is provide an authorizationConfig
and an iamRoleStatements
section to our previously-created Data Source from Part One. This is not well documented in the appsync-serverless-plugin, but you can see pieces of what it becomes in AWS's HttpConfig
documentation.
Authorization Config
The authorizationConfig
requires two basic properties:
authorizationType
- a string that tells CloudFormation what kind of auth it is to use. As of this writing, there is only one options here, and that is to use "AWS_IAM"awsIamConfig
- an object specifying your signing region and service name. Again, there is not a lot of documentation on which values to provide, but I will show you how to get this working.
Given that, let's add an authoriationConfig
to our existing ProductsService
HTTP Data Source:
- type: HTTP
name: ProductsService
config:
endpoint: 'your-api-gtwy-base-url'
authorizationConfig:
authorizationType: AWS_IAM
awsIamConfig:
signingRegion: 'your-aws-region'
signingServiceName: execute-api
Role Statements
In order for the signature on AppSync's HTTP call to mean anything, the Data Source has to have permissions granted to it. The serverless-appsync-plugin makes this easy. If you are familiar with the serverless-iam-roles-per-function plugin, you will recognize the pattern used.
We are going to have the Serverless framework create a role for our Data Source that will have permissions for only what we allow it to have. This is the principle of “Least Privilege” applied to our ProductsService
Data Source.
To our existing Data Source config, we need to add a section called iamRoleStatements
. Here, we can define a policy (or multiple policies) that grants our entity very specific permissions. In this case, we want to allow our Data Source to be able to execute-api:Invoke
against our deployed Products Service, which we specify by its API Gateway ARN.
Here's what that looks like:
- type: HTTP
name: ProductsService
config:
endpoint: 'your-api-gtwy-base-url'
authorizationConfig:
authorizationType: AWS_IAM
awsIamConfig:
signingRegion: 'your-aws-region'
signingServiceName: execute-api
iamRoleStatements:
- Effect: Allow
Action:
- execute-api:Invoke
Resource:
- !Sub arn:aws:execute-api:${self:provider.region}:${AWS::AccountId}:${your-HttpApiId}/*/GET/products/*
- !Sub arn:aws:execute-api:${self:provider.region}:${AWS::AccountId}:${your-HttpApiId}/*/POST/products
There are a couple of things to note here. First, the Action is execute-api
. Notice how that corresponds to the signingServiceName
from the authorizationConfig
. This tells AppSync to construct its v4 Signature for use against API Gateway. Second (this is for those who are familiar with the serverless-iam-roles-per-function plugin I mentioned earlier) note that the Action is an array. This must be an array, even if you only have one item. This differs from how you would attach a role-per-function on a Lambda where you can specify a single Action as a string.
Also, notice that we provided two Resources in the role specification: one for GET by id and one for POST. If you wanted your Data Source to also PUT or DELETE, you would have to add those resources to the list. For more on how to construct an ARN for an API Gateway, see API Gateway ARNs in their developer guide.
Now, when AppSync routes a getProduct
query, it will create an HTTP request, sign it with the role associated with our ProductsService
data source, and send it to our underlying micro-service. We've moved our domain logic out into its own self-contained, separately deployable service and used AppSync as our user authenticator and request router.
Bonus Section: Response Transforms
I want to add a quick section describing how VTL can function as a simple transform layer. The example is made up but the subject was born from a mismatch of property names between the engineer who wrote the AppSync schema and the engineer who wrote the domain micro-service.
In this case, let's imagine that Fred defined the Big Shopper GraphQL schema for Product, and he named the property to represent the Product's price cost
. Sally, building the domain micro-service, used the property name price
and now the two data models are not in alignment. To fix this, we can transform the values into the correct shape in our VTL response template.
Transforming in VTL
Our original response, where we returned the same result from our domain service, looked like this:
...
#if($context.result.statusCode == 200)
$context.result.body
#else
...
All we need to do is pull the values out of the domain service response and map them to a new return object. Since we receive $ctx.result.body
as a string, so our first step is to parse this into an object.
AWS AppSync provides several convenience functions for common operations in the $util
variable, including one called parseJson
that takes stringified JSON and returns an object - just what we need. Our first step is to transform the result body from our domain service into an object we can work with, like so:
#set($bodyObj = $util.parseJson($ctx.result.body))
From here, we can create the new Product result object with the property names we need:
{
"name": "$bodyObj.name",
"cost": "$bodyObj.price", ## <- rename
"productId": "$bodyObj.productId",
"storeId": "$bodyObj.storeId"
}
VTL will automatically return the object we just wrote, including the expected property cost
instead of price
. You can, of course, perform much more complicated transformations than a simple rename, but this example should point you in the right direction.
Wrap-Up
Let's review what we set out to do in the first installment of this series:
Know how to use VTL resolvers so we can evaluate whether we should use them.
Check! We are using VTL requests and responses and know the basic pros & cons.
Be able to limit concurrent Lambda executions
Before VTL, if I wanted to call my Product service from AppSync, I would write a Lambda that used axios
and v4Sig signing to create an HTTP request. Now, we can create a similar request using an AppSync data source and VTL. This eliminates the Lambda resolver and leaves capacity in our concurrent execution pool.
Cut down on response times; no cold-starts
With no Lambda, we eliminate the possibility of a Lambda cold-start. The VTL execution is fast and consistent.
Help segment my application into independently deployable pieces
Check! The HTTP Resolver helped me easily split out all of my Product domain logic into its own service without losing the communication and security I need between it and AppSync.
If you want to see the examples in this article in action, be sure to visit the accompanying GitHub repository.
Look for a follow-up, stand-alone article on AppSync's JavaScript resolver support. Much of what we covered in this tutorial still applies, however. I hope you find value in it. Have fun!
Further Reading
Source Code
Resolver Mapping Template Programming Guide
My article on "Zero Trust Serverless on AWS"
Yan Cui's AppSync Masterclass course
Serverless AppSync Plugin
Serverless IAM Roles Per Function Plugin