THE FINNTERNET

Amplify the CDK

TL;DR

I'm a huge fan of AWS and think their Amplify initiative is cool but too much magic and too many terminal prompts for me. Honestly, I think the CDK is about as good as you can get for defining your backend. The best part of Amplify is the ability to autogenerate CRUD tables and resolvers with authentication rules from a schema. This is an amazing developer experience and is one of the things that makes tools like Prisma popular so I am glad to see AWS now has this ability. The problem for me is that I want to define my backend with the CDK but also be able to use this functionality. Luckily, Kenneth Winner made cdk-appsync-transformer to give us the best of both worlds! In this post we will take this example project from the Amplify King and replace the Amplify configuration with, what I call, the Amplified CDK implementation.

Initialize Example Project

Let's clone and configure the example project.

# Clone the example project
% git clone [https://github.com/full-stack-serverless/react-chat-app-aws.git](https://github.com/full-stack-serverless/react-chat-app-aws.git)amplify-the-cdk && cd $_
# Set remote to our repository
% git remote set-url origin :thefinnomenon/amplify-the-cdk.git
# Move schema.graphql because we will be reusing it
% mv amplify/backend/api/AmplifyChat/schema.graphql src/
# Remove Amplify stuff
% rm -rf amplify/ src/graphql/ amplify.json .graphqlconfig.yml

Initialize CDK

Now that have the frontend for this project, we will use the CDK to define the backend.

# Install the CDK CLI
% npm install -g aws-cdk
# Create CDK directory
% mkdir backend && cd $_
# Initialize CDK App
% cdk init app --language typescript

Rename Stack

We should give our stack a unique and more descriptive name.

// backend/bin/backend.ts
...
new BackendStack(app, 'chat-app-BackendStack', {});

Create Backend

Next, we start defining our backend stack.

Authentication

First, let's define the authentication for the project.

Install Dependencies

% npm i @aws-cdk/aws-cognito@1.120.0 --save-exact

My biggest CDK tip is to always pin the CDK dependencies to the same version using @#.#.# and --save-exact otherwise you almost certainly will end up with some type errors.

Configure UserPool and UserPoolClient

The chat app uses Cognito email and password authentication with email code verification.

// backend/lib/backend-stack.ts
import * as cdk from '@aws-cdk/core';
import * as cognito from '@aws-cdk/aws-cognito';

export class BackendStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    /* COGNITO */
    const userPool = new cognito.UserPool(this, 'chat-app-UserPool', {
      selfSignUpEnabled: true,
      accountRecovery: cognito.AccountRecovery.PHONE_AND_EMAIL,
      userVerification: {
        emailStyle: cognito.VerificationEmailStyle.CODE
      },
      autoVerify: {
        email: true
      },
      standardAttributes: {
        email: {
          required: true,
          mutable: true
        }
      }
    });

    const userPoolClient = new cognito.UserPoolClient(this, 'chat-app-UserPoolClient', {
      userPool,
      authFlows: {
        // We really should use SRP instead but in the spirit of not modifying the frontend, we will use this
        userPassword: true,
      },
    });

    new cdk.CfnOutput(this, 'UserPoolId', {
      value: userPool.userPoolId
    });

    new cdk.CfnOutput(this, 'UserPoolClientId', {
      value: userPoolClient.userPoolClientId
    });
  }
}

Install Dependencies

% npm i cdk-appsync-transformer @aws-cdk/aws-appsync@1.120.0 --save-exact

Fix cdk-appsync-transformer Version

There is currently a pending PR to bump the CDK version used by the transformer but we can bump it ourselves for now (or to match a more recent version).

% cd backend/node_modules/cdk-appsync-transformer/
// Change all "@aws-cdk/*" dependency versions to "~1.120.0"
% npm install

Configure API

We will define an AppSync API using the schema.graphql.

// backend/lib/backend-stack.ts
...
import * as appsync from '@aws-cdk/aws-appsync';
import { AppSyncTransformer } from 'cdk-appsync-transformer';
...
export class BackendStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
		...
		/* APPSYNC */
    const api = new AppSyncTransformer(this, `chat-app-AppSyncApi}`, {
      schemaPath: '../src/schema.graphql',
      authorizationConfig: {
        defaultAuthorization: {
          authorizationType: appsync.AuthorizationType.USER_POOL,
          userPoolConfig: {
            userPool: userPool,
            defaultAction: appsync.UserPoolDefaultAction.ALLOW,
          }
        },
      },
      xrayEnabled: true,
    });

    new cdk.CfnOutput(this, "AppSyncApiGraphQLUrlOutput", {
      value: api.appsyncAPI.graphqlUrl
    });
		...
	}
}

Deploy

Let's deploy our backend

% cdk deploy

Generate Helpers

Now that the backend is deployed, we have two more steps left; generating our configuration file from the CDK outputs and generating the GraphQL operations and types from our schema.

Exports File

We will use a script written by Kenneth Winner (thanks again!) to gather the outputs from our CDK stack and format them into the expected Amplify aws-exports.js file.

# In the backend directory
% npm i -D aws-sdk
// backend/utilities/generateExports.js
/* source: https://github.com/kcwinner/advocacy/blob/master/cdk-amplify-appsync-helpers/generateExports.js */
const fs = require('fs');
const path = require('path');
const AWS = require('aws-sdk');

const REGION = process.env.AWS_REGION || 'us-east-1';
// This have to match the stack name you chose in lib/backend-stack.ts
const STACK_NAME = `chat-app-BackendStack`;

const cloudformation = new AWS.CloudFormation({ region: REGION });

// These have to match the output names in your stack definition
const appsyncURLOutputKey = 'AppSyncApiGraphQLUrlOutput';
const userPoolIDOutputKey = 'UserPoolIdOutput';
const userPoolClientOutputKey = 'UserPoolClientIdOutput';

// Modify the Auth to fit your use case
// Configuration reference: https://docs.amplify.aws/lib/client-configuration/configuring-amplify-categories/q/platform/js/#top-level-configuration
const awsmobile = {
  aws_project_region: REGION,
  aws_appsync_graphqlEndpoint: '',
  aws_appsync_region: REGION,
  aws_appsync_authenticationType: 'AMAZON_COGNITO_USER_POOLS',
  Auth: {
    region: REGION,
    userPoolId: '',
    userPoolWebClientId: '',
    authenticationFlowType: 'USER_PASSWORD_AUTH',
  },
  aws_user_pools_id: '',
  aws_user_pools_web_client_id: '',
  aws_cognito_region: REGION,
};

main();

async function main() {
  const exportFileName = 'aws-exports.js';
  console.log('Generating aws-exports.js');

  const describeStackParams = {
    StackName: STACK_NAME,
  };

  const stackResponse = await cloudformation.describeStacks(describeStackParams).promise();
  const stack = stackResponse.Stacks[0];

  const appsyncURL = stack.Outputs.find(output => {
    return output.OutputKey === appsyncURLOutputKey;
  });

  const userPoolId = stack.Outputs.find(output => {
    return output.OutputKey === userPoolIDOutputKey;
  });

  const userPoolClientId = stack.Outputs.find(output => {
    return output.OutputKey === userPoolClientOutputKey;
  });

  awsmobile.aws_appsync_graphqlEndpoint = appsyncURL.OutputValue;
  awsmobile.Auth.userPoolId = userPoolId.OutputValue;
  awsmobile.Auth.userPoolWebClientId = userPoolClientId.OutputValue;
  awsmobile.aws_user_pools_id = userPoolId.OutputValue;
  awsmobile.aws_user_pools_web_client_id = userPoolClientId.OutputValue;

  const curDir = process.cwd();

  const awsExportsPath = path.join(curDir, '../src', exportFileName);

  const data = `const awsmobile = ${JSON.stringify(awsmobile, null, 4)}
    
    export default awsmobile;`.replace(/^ {4}export default awsmobile/gm, 'export default awsmobile');

  fs.writeFileSync(awsExportsPath, data);
}

Add Script

Let's add a script to generate our exports and another to combine the deploy and generate step.

// backend/package.json
{
	"scripts": {
		...
		"deploy": "cdk deploy && npm run generate:exports",
		"generate:exports": "node utilities/generateExports.js"
	}
}

Ignore Exports File

Add src/aws-exports.js to .gitignore.

GraphQL Operations & Types

To generate our GraphQL operation & types, we will use Amplify's codegen, but first, we have to create .graphqlconfig.yml.

// .graphqlconfig.yml
# schemaPath isn't working for me so run codegen from backend/appsync/.
# All the paths are relative to backend/appsync/.
projects:
  ChatApp:
    schemaPath: schema.graphql
    includes:
      - ../../src/graphql/**/*.ts
    excludes: []
    extensions:
      amplify:
        codeGenTarget: typescript
        generatedFileName: ../../src/graphql/appsync.ts
        docsFilePath: ../../src/graphql/
        frontend: javascript
        framework: react-native
        maxDepth: 2
extensions:
  amplify:
    version: 3

Add Scripts

Let's add some scripts to automate the generation of these helpers.

// package.json
{
  ...
  "scripts": {
    ...
    "backend:deploy": "cd backend && cdk deploy && cd .. && yarn generate:graphql",
    "generate:graphql": "cd backend/appsync && amplify codegen",
    "postinstall": "npm --prefix ./backend install"
  }
}

Deploy & Generate

Now that we have our new deploy script that deploys the backend and generates our helpers, let's call it.

% yarn backend:deploy

Update Frontend

Okay, now all that's left to do is make sure the frontend is working.

Update Dependencies

I had dependency errors when I tried running npm install. The react version wasn't matching the one for the amplify-ui so I ended up just updating them.

// package.json
{
  ...
  "dependencies": {
    "@aws-amplify/ui-react": "^1.2.15",
    "@testing-library/jest-dom": "^4.2.4",
    "@testing-library/react": "^9.3.2",
    "@testing-library/user-event": "^7.1.2",
    "aws-amplify": "^3.0.22",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "react-router-dom": "^5.2.0",
    "react-scripts": "3.4.1"
  },
  ...
}

Run App

Now let's start our app and make sure everything is working.

% npm start

Final Thoughts

I really enjoy this way of defining my backend. Since this example was already implemented with Amplify and the CDK, and now, with the Amplified CDK, you can directly compare the three approaches. Compared to the Amplify approach, the Amplified CDK approach provides more flexibility and explicit control of the configuration. The advantage of using Amplify over this method is that it handles permissions for you but I don't find this too difficult in the CDK. If you compare this approach to the CDK implementation you will see that it greatly reduces the amount of CDK code necessary since you no longer have to manually define the tables and resolvers. Give it a shot and let me know what you think!

Chris' bitmoji smiling

Hi, how's it going? I’m Chris Finn, a full-stack developer from Boston, MA. I specialize in UI and UX design, cross-platorm app development, and eating calzone. I am also a sucker for automation and security (in a past life I was a cybersecurity researcher).