Let me be honest with you: GraphQL is the shit! Once you use GraphQL, you will never want to use anything else again. The same is true for a working and maintainable serverless FaaS infrastructure. Combine both technologies to run a genuinely serverless GraphQL API using AWS AppSync and Lambda resolvers.
Update: Read how you can add a custom domain for your GraphQL API with AppSync, CloudFront, Route53, and CloudFormation.
Motivation
After attending GraphQL Europe in Berlin, I wondered why serverless and FaaS infrastructure had not been
a major topic in a single talk. When I talked to people during the conference, and at the evening events, I noticed the pattern that people just need a server and want to start building frontend stuff.
Serverless and FaaS approaches can be complex in the beginning, and without proper tooling, you will see more drawbacks than benefits. Luckily things got a lot better in the last months and years. With AWS AppSync, Amazon introduced a robust solution to host GraphQL services, without maintaining any infrastructure.
Desired Goal
To assure a minimum of code for the GraphQL infrastructure, this guide uses AWS AppSync, Lambda, CloudFormation, and the Serverless Application Model. All commands are handled using the AWS CLI, and besides the mentioned tools you will only need to set up a custom GraphQL schema and can potentially learn some basic Go
syntax.
All sources are on GitHub as appsync-resolvers-example. Feel free to just head over to the sources and skip the guide. 😘
Use Case
Let’s assume, you have a list of people information and want to build an API for retrieving the full list of People
and get the profile for a single Person
filtered by an identifier.
Schema
A Person
always has the same standard properties, let’s go for id,
name,
and age.
Based on these rough information, you already know everything to create a GraphQL schema that might look like this:
type Person {
id: Int
name: String
age: Int
}
type Query {
people: [Person]
person(id: Int): Person
}
schema {
query: Query
}
Resolver
If you have already read some resources about GraphQL, chances are high you stumbled upon a thing called resolver.
Regardless, if you are working with Queries,
Mutations,
or Fields,
the resolver
is the logical handler for your requested query.
Long story short, we need a resolver for people
and one person,
the later one must support a parameter to look up the person by its identifier. In Go,
two simple functions that cover these cases can look like this:
// Return the whole list of people
func handlePeople() (interface{}, error) {
return people, nil
}
// Receive an ID and return the matching person
func handlePerson(args struct {
ID int `json:"id"`
}) (interface{}, error) {
return people.ByID(args.ID)
}
For the sake of a quick guide, we skip the setup of a database, and just use a static list of example data:
// Person stores the information for a person
type Person struct {
ID int `json:"id"`
Name string `json:"name"`
Age int `json:"age"`
}
// People is just a list of Person objects
type People []*Person
var (
people = People{
&Person{1, "Frank Ocean", 47},
&Person{2, "Paul Gascoigne", 51},
&Person{3, "Uwe Seeler", 81},
}
)
To filter the list of people, you need an additional helper to search for a Person
by a specific identifier:
// ByID looks for a Person in People with the given ID
func (p People) ByID(id int) (*Person, error) {
for _, person := range p {
if person.ID == id {
return person, nil
}
}
return nil, fmt.Errorf("Cannot find person with ID: %d", id)
}
Did you notice what a beauty of code we just created? Pure functions, no overhead, nothing related to a transport layer, and this code would still look the same if we planned for a REST
API instead of GraphQL. Did I already mentioned tests? With code like this, writing tests is dead simple.
Orchestration
You now have created a GraphQL schema, example data, and two resolvers doing the data lookup. Now what? All you saw until now is part of your business logic, this is all you should worry about, and you should not write unit tests for more than these few lines of code.
All that’s left for the project is orchestration: Schema validation, Resolver mapping, HTTP transport, and even authentication.
Resolver Mapping
Amazon has a few nifty Go
packages for Lambda, and together with appsync-resolvers you can get rid of most basic boilerplate code and have a simple management of your resolvers:
var (
r = resolvers.New()
)
func init() {
r.Add("query.people", handlePeople)
r.Add("query.person", handlePerson)
}
func main() {
lambda.Start(r.Handle)
}
First, a new list of resolvers
is created, and then both functions are bound to the matching query from the GraphQL schema. As the final step, the resolvers listen for Lambda invocations.
CloudFormation
To simplify serverless architecture, Amazon created the Serverless Application Model. SAM will support you in packaging your service and creating artifacts for deployment. Together with a few new CloudFormation types, the setup is less complex than it would have been just a few years ago.
For any GraphQL API you plan to deploy, the necessary steps will always be the same: Configure an AWS Lambda function, a proper IAM role, set up AppSync, connect AppSync with Lambda, and import the GraphQL schema.
When we ignore everything that you do not need to worry about, the only customization parts left are custom resolvers matching the GraphQL queries.
AppSyncResolverPeople:
Type: AWS::AppSync::Resolver
Properties:
ApiId: !GetAtt [AppSyncAPI, ApiId]
TypeName: Query
FieldName: people
DataSourceName: !GetAtt [AppSyncDataSource, Name]
RequestMappingTemplate: '{ "version" : "2017-02-28", "operation": "Invoke", "payload": { "resolve": "query.people", "context": $utils.toJson($context) } }'
ResponseMappingTemplate: $util.toJson($context.result)
AppSyncResolverPerson:
Type: AWS::AppSync::Resolver
Properties:
ApiId: !GetAtt [AppSyncAPI, ApiId]
TypeName: Query
FieldName: person
DataSourceName: !GetAtt [AppSyncDataSource, Name]
RequestMappingTemplate: '{ "version" : "2017-02-28", "operation": "Invoke", "payload": { "resolve": "query.person", "context": $utils.toJson($context) } }'
ResponseMappingTemplate: $util.toJson($context.result)
If you watch at both resolvers carefully, you will notice there are only a few small differences: With the values for TypeName
and FieldName
you specify the mapped query, and inside the RequestMappingTemplate
you configure the payload to call the correct function in your Go
implementation.
Authentication
Currently, AppSync requires your API to be protected in some way. Amazon supports a broad variety of authentication methods; one is using a static API key:
AppSyncAPIKey:
Type: AWS::AppSync::ApiKey
Properties:
ApiId: !GetAtt [AppSyncAPI, ApiId]
Expires: !Ref ParamKeyExpiration
Deployments
Of course, we do want a simple deployment for the GraphQL infrastructure. You should be able to deploy the service in different regions, environments, and stages without any significant overhead.
Your best friend for these kinds of tasks is a simple Makefile
in your project. Based on a generic approach, a deploy process should always consist of these basic steps:
# Create S3 Bucket to store deploy artifacts
$ > make configure
# Build go binary for deployment
$ > make build
# Create and upload deployable artifact
$ > make package
# Deploy CloudFormation Stack
$ > make deploy
Usage
After deploying everything, you should have all needed information about your GraphQL service in the CloudFormation stack output. This is a general best practice as well, in this case, all information you need is the URL of your GraphQL API and an API key to send requests.
# Show CloudFormation stack output
$ > make outputs
[
{
"OutputKey": "APIKey",
"OutputValue": "da2-jlewwo38ojcrfasc3dpaxqgxcc",
"Description": "API Key"
},
{
"OutputKey": "GraphQL",
"OutputValue": "https://3mhugdjvrzeclk5ssrc7qzjpxn.appsync-api.eu-west-1.amazonaws.com/graphql",
"Description": "GraphQL URL"
}
]
With these information, you can query for the names of all people in your service using a simple curl
request:
$ > curl \
-XPOST https://3mhugdjvrzeclk5ssrc7qzjpxn.appsync-api.eu-west-1.amazonaws.com/graphql \
-H "Content-Type:application/graphql" \
-H "x-api-key:da2-jlewwo38ojcrfasc3dpaxqgxcc" \
-d '{ "query": "query { people { name } }" }' | jq
{
"data": {
"people": [
{
"name": "Frank Ocean"
},
{
"name": "Paul Gascoigne"
},
{
"name": "Uwe Seeler"
}
]
}
}
To lookup a specific person by an identifier, use the person(id: int)
query and define the desired response properties.
$ > curl \
-XPOST https://3mhugdjvrzeclk5ssrc7qzjpxn.appsync-api.eu-west-1.amazonaws.com/graphql \
-H "Content-Type:application/graphql" \
-H "x-api-key:da2-jlewwo38ojcrfasc3dpaxqgxcc" \
-d '{ "query": "query { person(id: 2) { name } }" }' | jq
{
"data": {
"person": {
"name": "Paul Gascoigne"
}
}
}
Simple, right? Sure, I skipped a few parts, like the commands in the Makefile
and CloudFormation boilerplate, but you can find everything on GitHub in the appsync-resolvers-example project. I tried to focus only on the parts you edit, change, maintain, test, or need to invoke. Everything else should not be of your concern at all.
What do you think? Ever tried to run serverless GraphQL API? Share you thoughts on Twitter and make sure to check out the appsync-resolvers-example sources on GitHub.