Cloud Native
Control Tower: Then vs Now
Control Tower today is not the same Control Tower that you may have been introduced to in the past.
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 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.
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.
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.
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!
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());
}
}
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.
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.
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),
})
}
})
});
}
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.
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.
Control Tower today is not the same Control Tower that you may have been introduced to in the past.