Serverless GraphQL with AWS AppSync and Lambda

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.

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.


View on GitHub Source code is published using the MIT license.