Cloud Native

AppSync With The AWS Cloud Development Kit

The raw power of the GraphQL SDL from the Amplify CLI and the unparalleled flexibility of the CDK brought together.
Ken Winner User Image
Ken Winner | Apr 16 2020

Note: All code and information can be found on GitHub. This is a guest post from former Trek10 team member, and current CTO at GetVoxi, Ken Winner. Many thanks!

I've found a way to blend some of the best parts of various tooling that the AWS ecosystem offers into something that I think some of you may enjoy learning a bit more about. We'll start with the technology and then move to what we did with it.

We love AppSync

We chose AppSync to build on because of a few critical points. GraphQL is an excellent interface to build clients on top of, AppSync is the "AWS Native" way to implement GraphQL services against various datastores like DynamoDB. Additionally, you get some neat capabilities like subscriptions. We find that building with AppSync as our API layer brings expedience and predictable results to projects.

The Challenge With AppSync

If you are familiar with AppSync, you know how frustrating it can be to build out a full API, writing each piece of the schema for your models, your connections, filters, queries, and mutations. Not only must you write the schema, but you also have to write each resolver using velocity template language (VTL). A simple application can quickly become a few hundred lines of SDL, VTL, and Cloudformation.

GraphQL Transform Saves The Day

The Amplify CLI introduced some fantastic packages to help transform your AppSync schema into types, queries, mutations, subscriptions, tables, and resolvers using something called the GraphQL Schema Definition Language (SDL). Using supported directives the CLI transformation plugin will transform your SDL into deployable templates, streamlining the process of creating AppSync APIs. It sets up and wires together almost all of the necessary request and response VTL templates and datastores, making otherwise fairly laborious work take minutes.

An example directive for the @model directive looks like this:

type Product
  @model {
    id: ID!
    name: String!
    description: String!
    price: String!
    active: Boolean!
    added: AWSDateTime!
}

After transformation, we get the following schema, as well as resolvers and CloudFormation for a DynamoDB table.

type Product {
  id: ID!
  name: String!
  description: String!
  price: String!
  active: Boolean!
  added: AWSDateTime!
}

type ModelProductConnection {
  items: [Product]
  nextToken: String
}

input CreateProductInput {
  id: ID
  name: String!
  description: String!
  price: String!
  active: Boolean!
  added: AWSDateTime!
}

input UpdateProductInput {
  id: ID!
  name: String
  description: String
  price: String
  active: Boolean
  added: AWSDateTime
}

input DeleteProductInput {
  id: ID
}

input ModelProductFilterInput {
  id: ModelIDFilterInput
  name: ModelStringFilterInput
  description: ModelStringFilterInput
  price: ModelStringFilterInput
  active: ModelBooleanFilterInput
  added: ModelStringFilterInput
  and: [ModelProductFilterInput]
  or: [ModelProductFilterInput]
  not: ModelProductFilterInput
}

type Query {
  getProduct(id: ID!): Product
  listProducts(filter: ModelProductFilterInput, limit: Int, nextToken: String): ModelProductConnection
}

type Mutation {
  createProduct(input: CreateProductInput!): Product
  updateProduct(input: UpdateProductInput!): Product
  deleteProduct(input: DeleteProductInput!): Product
}

type Subscription {
  onCreateProduct: Product @aws_subscribe(mutations: ["createProduct"])
  onUpdateProduct: Product @aws_subscribe(mutations: ["updateProduct"])
  onDeleteProduct: Product @aws_subscribe(mutations: ["deleteProduct"])
}

Using the GraphQL Transform plugin, we turned 9 lines of SDL with a declaration into 62 lines. Extrapolate this to multiple types, and we begin to see how automated transformations not only save us time but also give us a concise way of declaring some of the boilerplate around AppSync APIs.

Challenges of using AWS Amplify CLI

As outstanding as many of the features of the Amplify CLI are, I've found I personally prefer to define my resources using the AWS Cloud Development Kit (CDK) since it's easier to integrate with other existing systems and processes. Unfortunately for me, the transformation plugin only exists in the Amplify CLI. I decided that to emulate this functionality, I would take the same transformation packages used in the Amplify CLI and integrate them into my CDK project!

Before going any further, I do want to point out that by design Amplify has extensibility and “escape hatches” built-in so that folks can use pieces as they need if they don’t want all the components. In fact, the bit we rely on for the rest of this post is a documented practice!

Recreating The Schema Transformer

To emulate the Amplify CLI transformer, we have to have a schema transformer and import the existing transformers. Luckily the Amplify docs show us an implementation here. Since we want to have all the same directives available to us, we must implement the same packages and structure outlined above. This gives us our directive resolution, resolver creation, and template generation!

We end up with something like this:

import { GraphQLTransform } from 'graphql-transformer-core';
import { DynamoDBModelTransformer } from 'graphql-dynamodb-transformer';
import { ModelConnectionTransformer } from 'graphql-connection-transformer';
import { KeyTransformer } from 'graphql-key-transformer';
import { FunctionTransformer } from 'graphql-function-transformer';
import { VersionedModelTransformer } from 'graphql-versioned-transformer';
import { ModelAuthTransformer, ModelAuthTransformerConfig } from 'graphql-auth-transformer'
const { AppSyncTransformer } = require('graphql-appsync-transformer')
import { normalize } from 'path';
import * as fs from "fs";

const outputPath = './appsync'

export class SchemaTransformer {
    transform() {
        // These config values do not even matter... So set it up for both
        const authTransformerConfig: ModelAuthTransformerConfig = {
            authConfig: {
                defaultAuthentication: {
                    authenticationType: 'API_KEY',
                    apiKeyConfig: {
                        description: 'Testing',
                        apiKeyExpirationDays: 100
                    }
                },
                additionalAuthenticationProviders: [
                    {
                        authenticationType: 'AMAZON_COGNITO_USER_POOLS',
                        userPoolConfig: {
                            userPoolId: '12345xyz'
                        }
                    }
                ]
            }
        }

        // Note: This is not exact as we are omitting the @searchable transformer.
        const transformer = new GraphQLTransform({
            transformers: [
                new AppSyncTransformer(outputPath),
                new DynamoDBModelTransformer(),
                new VersionedModelTransformer(),
                new FunctionTransformer(),
                new KeyTransformer(),
                new ModelAuthTransformer(authTransformerConfig),
                new ModelConnectionTransformer(),
            ]
        })

        const schema_path = './schema.graphql'
        const schema = fs.readFileSync(schema_path)

        return transformer.transform(schema.toString());
    }
}

Writing Our Own Transformer

After implementing the schema transformer the same, I realized it doesn't fit our CDK implementation perfectly. For example, instead of the JSON CloudFormation output of our DynamoDB tables, we want iterable resources that can be created via the CDK. In comes our own transformer!

In this custom transformer, we do two things - look for the @nullable directive and grab the transformer context after completion.

@nullable Directive

When creating a custom key using the @key directive on a model, the associated resolver does not allow for using $util.autoId() to generate a unique identifier and creation time. There are a couple of existing options, but we wanted to provide a "consistent" behavior to our developers that was easy to implement, so I created the "nullable" directive to enable using a custom @key directive that would autoId the id field if it wasn't passed in.

# We use my nullable tag so that the create can have an autoid on the ID field
type Order
    @model
    @key(fields: ["id", "productID"]) {
        id: ID! @nullable
        productID: ID!
        total: String!
        ordered: AWSDateTime!
}

I've implemented this new directive using graphql-auto-transformer as a guide. This outputs a modified resolver for the field with our custom directive.

Post Transformation

After schema transformation is complete, our custom transformer grabs the context, searches for AWS::DynamoDB::Table resources, and builds a table object for us to create a table from later. Later, we can loop over this output and create our tables and resolvers like so:

createTablesAndResolvers(api: GraphQLApi, tableData: any, resolvers: any) {
    Object.keys(tableData).forEach((tableKey: any) => {
      let table = this.createTable(tableData[tableKey]);

      const dataSource = api.addDynamoDbDataSource(tableKey, `Data source for ${tableKey}`, table);

      Object.keys(resolvers).forEach((resolverKey: any) => {
        let resolverTableName = this.getTableNameFromFieldName(resolverKey)
        if (tableKey === resolverTableName) {
          let resolver = resolvers[resolverKey]

          dataSource.createResolver({
            typeName: resolver.typeName,
            fieldName: resolver.fieldName,
            requestMappingTemplate: MappingTemplate.fromFile(resolver.requestMappingTemplate),
            responseMappingTemplate: MappingTemplate.fromFile(resolver.responseMappingTemplate),
          })
        }
      })
    });
  }

Using The Schema Transformer

To run our transformer before the CDK's template generation, we must import our transformer, run the transformer, and pass the data to our stack!

#!/usr/bin/env node
import * as cdk from '@aws-cdk/core';
import { AppStack } from '../lib/app-stack';
import { SchemaTransformer } from '../lib/schema-transformer';

const transformer = new SchemaTransformer();
const outputs = transformer.transform();
const resolvers = transformer.getResolvers();

const STAGE = process.env.STAGE || 'demo'

const app = new cdk.App({ 
    context: { STAGE: STAGE }
})

new AppStack(app, 'AppStack', outputs, resolvers);

All code can be found on Github. It is important to note if you are going to take this approach, be sure to pin your transformer versions and validate things in the future. Since we are borrowing from the existing Amplify CLI, there is no established contract that Amplify may not change things as they move forward.

Where Do We Go From Here?

We believe this would work much better as a CDK plugin or an npm package. Unfortunately, the CDK plugin system currently only supports credential providers at the moment. I played around with writing it in as a plugin (it sort of works), but you would have to write the cfdoc to a file and read it from your app to bring in the resources.

References

Author
Ken Winner User Image
Ken Winner

Ken has a background in cloud services ranging from AWS to Azure as well as desktop applications. He is currently the CTO at GetVoxi.