GraphQL is a query language for HTTP APIs, providing a statically-typed interface to conveniently represent today's complex data hierarchies. One way to use GraphQL is to import a library implementing a GraphQL server to which one registers custom resolvers implementing the database interface. An alternative way is to use a GraphQL cloud service to implement the GraphQL server and register serverless cloud functions as resolvers. Among the many benefits of cloud services, one of the biggest practical advantages is the resolvers' independence and composability. For example, we can write one resolver to a relational database and another to a search database.
We consider such a kind of setup using Amazon Web Services (AWS) in the following. In particular, we use AWS AppSync as the GraphQL cloud service and AWS Lambda to run a relational database resolver, which we implement using Go with Ent as the entity framework. Compared to Nodejs, the most popular runtime for AWS Lambda, Go offers faster start times, higher performance, and, from my point of view, an improved developer experience. As an additional complement, Ent presents an innovative approach towards type-safe access to relational databases, which, in my opinion, is unmatched in the Go ecosystem. In conclusion, running Ent with AWS Lambda as AWS AppSync resolvers is an extremely powerful setup to face today's demanding API requirements.
In the next sections, we set up GraphQL in AWS AppSync and the AWS Lambda function running Ent. Subsequently, we propose a Go implementation integrating Ent and the AWS Lambda event handler, followed by performing a quick test of the Ent function. Finally, we register it as a data source to our AWS AppSync API and configure the resolvers, which define the mapping from GraphQL requests to AWS Lambda events. Be aware that this tutorial requires an AWS account and the URL to a publicly-accessible Postgres database, which may incur costs.
Setting up AWS AppSync schema
To set up the GraphQL schema in AWS AppSync, sign in to your AWS account and select the AppSync service through the navbar. The landing page of the AppSync service should render you a "Create API" button, which you may click to arrive at the "Getting Started" page:
Getting started from scratch with AWS AppSync
In the top panel reading "Customize your API or import from Amazon DynamoDB" select the option "Build from scratch" and click the "Start" button belonging to the panel. You should now see a form where you may insert the API name. For the present tutorial, we type "Todo", see the screenshot below, and click the "Create" button.
Creating a new API resource in AWS AppSync
After creating the AppSync API, you should see a landing page showing a panel to define the schema, a panel to query the API, and a panel on integrating AppSync into your app as captured in the screenshot below.
Landing page of the AWS AppSync API
Click the "Edit Schema" button in the first panel and replace the previous schema with the following GraphQL schema:
input AddTodoInput {
title: String!
}
type AddTodoOutput {
todo: Todo!
}
type Mutation {
addTodo(input: AddTodoInput!): AddTodoOutput!
removeTodo(input: RemoveTodoInput!): RemoveTodoOutput!
}
type Query {
todos: [Todo!]!
todo(id: ID!): Todo
}
input RemoveTodoInput {
todoId: ID!
}
type RemoveTodoOutput {
todo: Todo!
}
type Todo {
id: ID!
title: String!
}
schema {
query: Query
mutation: Mutation
}
After replacing the schema, a short validation runs and you should be able to click the "Save Schema" button on the top right corner and find yourself with the following view:
Final GraphQL schema of AWS AppSync API
If we sent GraphQL requests to our AppSync API, the API would return errors as no resolvers have been attached to the schema. We will configure the resolvers after deploying the Ent function via AWS Lambda.
Explaining the present GraphQL schema in detail is beyond the scope of this tutorial. In short, the GraphQL schema implements a list todos operation via Query.todos
, a single read todo operation via Query.todo
, a create todo operation via Mutation.createTodo
, and a delete operation via Mutation.deleteTodo
. The GraphQL API is similar to a simple REST API design of an /todos
resource, where we would use GET /todos
, GET /todos/:id
, POST /todos
, and DELETE /todos/:id
. For details on the GraphQL schema design, e.g., the arguments and returns from the Query
and Mutation
objects, I follow the practices from the GitHub GraphQL API.
Setting up AWS Lambda
With the AppSync API in place, our next stop is the AWS Lambda function to run Ent. For this, we navigate to the AWS Lambda service through the navbar, which leads us to the landing page of the AWS Lambda service listing our functions:
AWS Lambda landing page showing functions.
We click the "Create function" button on the top right and select "Author from scratch" in the upper panel. Furthermore, we name the function "ent", set the runtime to "Go 1.x", and click the "Create function" button at the bottom. We should then find ourselves viewing the landing page of our "ent" function:
AWS Lambda function overview of the Ent function.
Before reviewing the Go code and uploading the compiled binary, we need to adjust some default settings of the "ent" function. First, we change the default handler name from hello
to main
, which equals the filename of the compiled Go binary:
AWS Lambda runtime settings of Ent function.
Second, we add an environment the variable DATABASE_URL
encoding the database network parameters and credentials:
AWS Lambda environment variables settings of Ent function.
To open a connection to the database, pass in a DSN, e.g., postgres://username:password@hostname/dbname
. By default, AWS Lambda encrypts the environment variables, making them a fast and safe mechanism to supply database connection parameters. Alternatively, one can use the AWS Secretsmanager service and dynamically request credentials during the Lambda function's cold start, allowing, among others, rotating credentials. A third option is to use AWS IAM to handle the database authorization.
If you created your Postgres database in AWS RDS, the default username and database name is postgres
. The password can be reset by modifying the AWS RDS instance.
Setting up Ent and deploying AWS Lambda
We now review, compile and deploy the database Go binary to the "ent" function. You can find the complete source code in bodokaiser/entgo-aws-appsync.
First, we create an empty directory to which we change:
mkdir entgo-aws-appsync
cd entgo-aws-appsync
Second, we initiate a new Go module to contain our project:
go mod init entgo-aws-appsync
Third, we create the Todo
schema while pulling in the ent dependencies:
go run -mod=mod entgo.io/ent/cmd/ent new Todo
and add the title
field:
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/field"
)
// Todo holds the schema definition for the Todo entity.
type Todo struct {
ent.Schema
}
// Fields of the Todo.
func (Todo) Fields() []ent.Field {
return []ent.Field{
field.String("title"),
}
}
// Edges of the Todo.
func (Todo) Edges() []ent.Edge {
return nil
}
Finally, we perform the Ent code generation:
go generate ./ent
Using Ent, we write a set of resolver functions, which implement the create, read, and delete operations on the todos:
package resolver
import (
"context"
"fmt"
"strconv"
"entgo-aws-appsync/ent"
"entgo-aws-appsync/ent/todo"
)
// TodosInput is the input to the Todos query.
type TodosInput struct{}
// Todos queries all todos.
func Todos(ctx context.Context, client *ent.Client, input TodosInput) ([]*ent.Todo, error) {
return client.Todo.
Query().
All(ctx)
}
// TodoByIDInput is the input to the TodoByID query.
type TodoByIDInput struct {
ID string `json:"id"`
}
// TodoByID queries a single todo by its id.
func TodoByID(ctx context.Context, client *ent.Client, input TodoByIDInput) (*ent.Todo, error) {
tid, err := strconv.Atoi(input.ID)
if err != nil {
return nil, fmt.Errorf("failed parsing todo id: %w", err)
}
return client.Todo.
Query().
Where(todo.ID(tid)).
Only(ctx)
}
// AddTodoInput is the input to the AddTodo mutation.
type AddTodoInput struct {
Title string `json:"title"`
}
// AddTodoOutput is the output to the AddTodo mutation.
type AddTodoOutput struct {
Todo *ent.Todo `json:"todo"`
}
// AddTodo adds a todo and returns it.
func AddTodo(ctx context.Context, client *ent.Client, input AddTodoInput) (*AddTodoOutput, error) {
t, err := client.Todo.
Create().
SetTitle(input.Title).
Save(ctx)
if err != nil {
return nil, fmt.Errorf("failed creating todo: %w", err)
}
return &AddTodoOutput{Todo: t}, nil
}
// RemoveTodoInput is the input to the RemoveTodo mutation.
type RemoveTodoInput struct {
TodoID string `json:"todoId"`
}
// RemoveTodoOutput is the output to the RemoveTodo mutation.
type RemoveTodoOutput struct {
Todo *ent.Todo `json:"todo"`
}
// RemoveTodo removes a todo and returns it.
func RemoveTodo(ctx context.Context, client *ent.Client, input RemoveTodoInput) (*RemoveTodoOutput, error) {
t, err := TodoByID(ctx, client, TodoByIDInput{ID: input.TodoID})
if err != nil {
return nil, fmt.Errorf("failed querying todo with id %q: %w", input.TodoID, err)
}
err = client.Todo.
DeleteOne(t).
Exec(ctx)
if err != nil {
return nil, fmt.Errorf("failed deleting todo with id %q: %w", input.TodoID, err)
}
return &RemoveTodoOutput{Todo: t}, nil
}
Using input structs for the resolver functions allows for mapping the GraphQL request arguments. Using output structs allows for returning multiple objects for more complex operations.
To map the Lambda event to a resolver function, we implement a Handler, which performs the mapping according to an action
field in the event:
package handler
import (
"context"
"encoding/json"
"fmt"
"log"
"entgo-aws-appsync/ent"
"entgo-aws-appsync/internal/resolver"
)
// Action specifies the event type.
type Action string
// List of supported event actions.
const (
ActionMigrate Action = "migrate"
ActionTodos = "todos"
ActionTodoByID = "todoById"
ActionAddTodo = "addTodo"
ActionRemoveTodo = "removeTodo"
)
// Event is the argument of the event handler.
type Event struct {
Action Action `json:"action"`
Input json.RawMessage `json:"input"`
}
// Handler handles supported events.
type Handler struct {
client *ent.Client
}
// Returns a new event handler.
func New(c *ent.Client) *Handler {
return &Handler{
client: c,
}
}
// Handle implements the event handling by action.
func (h *Handler) Handle(ctx context.Context, e Event) (interface{}, error) {
log.Printf("action %s with payload %s\n", e.Action, e.Input)
switch e.Action {
case ActionMigrate:
return nil, h.client.Schema.Create(ctx)
case ActionTodos:
var input resolver.TodosInput
return resolver.Todos(ctx, h.client, input)
case ActionTodoByID:
var input resolver.TodoByIDInput
if err := json.Unmarshal(e.Input, &input); err != nil {
return nil, fmt.Errorf("failed parsing %s params: %w", ActionTodoByID, err)
}
return resolver.TodoByID(ctx, h.client, input)
case ActionAddTodo:
var input resolver.AddTodoInput
if err := json.Unmarshal(e.Input, &input); err != nil {
return nil, fmt.Errorf("failed parsing %s params: %w", ActionAddTodo, err)
}
return resolver.AddTodo(ctx, h.client, input)
case ActionRemoveTodo:
var input resolver.RemoveTodoInput
if err := json.Unmarshal(e.Input, &input); err != nil {
return nil, fmt.Errorf("failed parsing %s params: %w", ActionRemoveTodo, err)
}
return resolver.RemoveTodo(ctx, h.client, input)
}
return nil, fmt.Errorf("invalid action %q", e.Action)
}
In addition to the resolver actions, we also added a migration action, which is a convenient way to expose database migrations.
Finally, we need to register an instance of the Handler
type to the AWS Lambda library.
package main
import (
"database/sql"
"log"
"os"
"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
"github.com/aws/aws-lambda-go/lambda"
_ "github.com/jackc/pgx/v4/stdlib"
"entgo-aws-appsync/ent"
"entgo-aws-appsync/internal/handler"
)
func main() {
// open the database connection using the pgx driver
db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatalf("failed opening database connection: %v", err)
}
// initiate the ent database client for the Postgres database
client := ent.NewClient(ent.Driver(entsql.OpenDB(dialect.Postgres, db)))
defer client.Close()
// register our event handler to listen on Lambda events
lambda.Start(handler.New(client).Handle)
}
The function body of main
is executed whenever an AWS Lambda performs a cold start. After the cold start, a Lambda function is considered "warm," with only the event handler code being executed, making Lambda executions very efficient.
To compile and deploy the Go code, we run:
GOOS=linux go build -o main ./lambda
zip function.zip main
aws lambda update-function-code --function-name ent --zip-file fileb://function.zip
The first command creates a compiled binary named main
. The second command compresses the binary to a ZIP archive, required by AWS Lambda. The third command replaces the function code of the AWS Lambda named ent
with the new ZIP archive. If you work with multiple AWS accounts you want to use the --profile <your aws profile>
switch.
After you successfully deployed the AWS Lambda, open the "Test" tab of the "ent" function in the web console and invoke it with a "migrate" action:
Invoking Lambda with a "migrate" action
On success, you should get a green feedback box and test the result of a "todos" action:
Invoking Lambda with a "todos" action
In case the test executions fail, you most probably have an issue with your database connection.
Configuring AWS AppSync resolvers
With the "ent" function successfully deployed, we are left to register the ent Lambda as a data source to our AppSync API and configure the schema resolvers to map the AppSync requests to Lambda events. First, open our AWS AppSync API in the web console and move to "Data Sources", which you find in the navigation pane on the left.
List of data sources registered to the AWS AppSync API
Click the "Create data source" button in the top right to start registering the "ent" function as data source:
Registering the ent Lambda as data source to the AWS AppSync API
Now, open the GraphQL schema of the AppSync API and search for the Query
type in the sidebar to the right. Click the "Attach" button next to the Query.Todos
type:
Attaching a resolver for the todos Query in the AWS AppSync API
In the resolver view for Query.todos
, select the Lambda function as data source, enable the request mapping template option,
Configuring the resolver mapping for the todos Query in the AWS AppSync API
and copy the following template:
{
"version" : "2017-02-28",
"operation": "Invoke",
"payload": {
"action": "todos"
}
}
Repeat the same procedure for the remaining Query
and Mutation
types:
{
"version" : "2017-02-28",
"operation": "Invoke",
"payload": {
"action": "todo",
"input": $util.toJson($context.args.input)
}
}
{
"version" : "2017-02-28",
"operation": "Invoke",
"payload": {
"action": "addTodo",
"input": $util.toJson($context.args.input)
}
}
{
"version" : "2017-02-28",
"operation": "Invoke",
"payload": {
"action": "removeTodo",
"input": $util.toJson($context.args.input)
}
}
The request mapping templates let us construct the event objects with which we invoke the Lambda functions. Through the $context
object, we have access to the GraphQL request and the authentication session. In addition, it is possible to arrange multiple resolvers sequentially and reference the respective outputs via the $context
object. In principle, it is also possible to define response mapping templates. However, in most cases it is sufficient enough to return the response object "as is".
Testing AppSync using the Query explorer
The easiest way to test the API is to use the Query Explorer in AWS AppSync. Alternatively, one can register an API key in the settings of their AppSync API and use any standard GraphQL client.
Let us first create a todo with the title foo
:
mutation MyMutation {
addTodo(input: {title: "foo"}) {
todo {
id
title
}
}
}
"addTodo" Mutation using the AppSync Query Explorer
Requesting a list of the todos should return a single todo with title foo
:
query MyQuery {
todos {
title
id
}
}
"addTodo" Mutation using the AppSync Query Explorer
Requesting the foo
todo by id should work too:
query MyQuery {
todo(id: "1") {
title
id
}
}
"addTodo" Mutation using the AppSync Query Explorer
Wrapping Up
We successfully deployed a serverless GraphQL API for managing simple todos using AWS AppSync, AWS Lambda, and Ent. In particular, we provided step-by-step instructions on configuring AWS AppSync and AWS Lambda through the web console. In addition, we discussed a proposal for how to structure our Go code.
We did not cover testing and setting up a database infrastructure in AWS. These aspects become more challenging in the serverless than the traditional paradigm. For example, when many Lambda functions are cold started in parallel, we quickly exhaust the database's connection pool and need some database proxy. In addition, we need to rethink testing as we only have access to local and end-to-end tests because we cannot run cloud services easily in isolation.
Nevertheless, the proposed GraphQL server scales well into the complex demands of real-world applications benefiting from the serverless infrastructure and Ent's pleasurable developer experience.
Have questions? Need help with getting started? Feel free to join our Discord server or Slack channel](https://entgo.io/docs/slack/).
- Subscribe to our Newsletter
- Follow us on Twitter
- Join us on #ent on the Gophers Slack
- Join us on the Ent Discord Server