DEV Community

alex-vladut
alex-vladut

Posted on

Deploy AWS Amplify GraphQL Transformers with AWS CDK

AWS Amplify provides a set of tools and libraries to enable rapid web and native application development. By making use of their tools you can get an application up and running very quickly and with minimal knowledge of the underlying AWS infrastructure. AWS Amplify CLI is one of the tools offered by AWS Amplify in their toolchain. The CLI provides an interactive experience to adding backend capabilities to your application such as GraphQL or RESTful API, authentication, storage, etc. Those configurations are compiled by AWS Amplify CLI into CloudFormation stacks that could be easily deployed to AWS. As an example, the auth capability provided by AWS Amplify is translated to a CloudFormation stack defining a Cognito User Pool with the properties selected interactively during the setup process. This is an exempt from AWS Amplify's docs showing how you will be guided step-by-step in configuring the authentication:

$ amplify add auth  ## "amplify update auth" if already configured
$ Do you want to use the default authentication and security configuration? 
❯ Default configuration 
  Default configuration with Social Provider (Federation) 
  Manual configuration 
  I want to learn more.
Enter fullscreen mode Exit fullscreen mode

As mentioned above, the AWS Amplify CLI worked great for getting started with a fully working application and even having it deployed very easily, but there are a few limitations I came across along the way that made me look for alternatives. For example, I encountered a few situations when upgrading to a newer version of the AWS Amplify CLI broke my deployment and I had to recreate all the resources from scratch, which is not acceptable for an application running in production. On top of that, performing more advanced configurations requires you to dive into the JSON CloudFormation templates which could be tedious. AWS Amplify is expecting to own the whole structure of your project and as a result, I had a hard time configuring the Lambda functions' code at a different location to use the same linting rules and bundling tools as for the rest of the project, as well as to share some code with the other applications. As AWS CDK was released, I found it to provide a much better experience and flexibility. All the infrastructure resources could be defined in the same programming language as the rest of your code, and you get nice features such as code auto-completion suggestions. The main part of AWS Amplify that I still find useful is the Appsync GraphQL transformers. It is a significant productivity boost as it automatically generates many of the queries and mutations needed by the application. As a result, I was looking around for options to integrate the AWS Amplify GraphQL transformers into a project deployed with AWS CDK, and it turns out the Amplify code is modular enough and it is not as hard as I feared it would be to accomplish this goal.

Note: A GitHub repository is available to demonstrate the full implementation. Check it out here. The starting point for this article is a project already having AWS CDK configured, which is effectively the result of my previous article https://dev.to/alexvladut/how-to-add-aws-cdk-to-an-existing-project-2d30. This article assumes you're already familiar with how AWS Amplify GraphQL transformers work, but you can find more details in this section of the documentation https://docs.amplify.aws/cli/graphql/overview/.

AWS Amplify code is open-sourced and could be found on GitHub at https://github.com/aws-amplify/amplify-cli. We could make use of the NPM packages deployed independently to recreate the functionality offered by the AWS Amplify CLI regarding the GraphQL transformers. The entry point for generating the Appsync resolvers could be found here and the class we are interested in is GraphQLTransform which takes as a parameter all the individual transformers and iterates over them to generate the GraphQL resolvers and the associated CloudFormation stacks for deploying those resolvers.

This article is split into two parts. First, we'll look into implementing a script for transforming the GraphQL schema with the end goal being to generate the Appsync resolvers and all the associated CloudFormation stacks to deploy the GraphQL API into the cloud. Next, we'll see how we can import the GraphQL stacks from AWS CDK and perform the deployment to AWS.

Applying the GraphQL transformers

Let's get started by creating a simple GraphQL schema that will be used as the base for generating the resolvers and associated CloudFormation stacks. Inside the infra directory create a new file called schema.graphql and add the following content to it:

type User @model @auth(rules: [{ allow: owner, ownerField: "id" }]) {
  id: ID! @primaryKey
  name: String!
  email: AWSEmail!
  phone: AWSPhone
  createdAt: AWSDateTime!
  updatedAt: AWSDateTime!
}
Enter fullscreen mode Exit fullscreen mode

Add the following NPM packages to your project's package.json:

+  "dependencies": {
+    "@aws-amplify/graphql-auth-transformer": "^0.7.7",
+    "@aws-amplify/graphql-default-value-transformer": "^0.5.17",
+    "@aws-amplify/graphql-function-transformer": "^0.7.11",
+    "@aws-amplify/graphql-http-transformer": "^0.8.11",
+    "@aws-amplify/graphql-index-transformer": "^0.11.2",
+    "@aws-amplify/graphql-model-transformer": "^0.13.2",
+    "@aws-amplify/graphql-predictions-transformer": "^0.6.11",
+    "@aws-amplify/graphql-relational-transformer": "^0.7.7",
+    "@aws-amplify/graphql-searchable-transformer": "^0.13.3",
+    "@aws-amplify/graphql-transformer-core": "^0.16.2",
+    "amplify-provider-awscloudformation": "^5.9.8",
Enter fullscreen mode Exit fullscreen mode

These are all the GraphQL transformers that Amplify makes available at this point, while amplify-provider-awscloudformation is used to write the resulting CloudFormation stacks to disk.

Now create a new JavaScript file in your project where we'll implement the logic for loading the GraphQL schema and apply the transformers to it. Our process consists of the following high level steps:

async function graphqlTransformerExecutor(options) {
  const schema = await getSchemaDocs(path.normalize(options.schemaPath));

  const transformer = await createTransformer(options);
  const deployment = transformer.transform(schema);

  await writeDeploymentToDisk(
    deployment,
    options.outputPath,
    'appsync.cloudformation.json',
    {}
  );
};
Enter fullscreen mode Exit fullscreen mode

In the project, I created you can find the full implementation in this file https://github.com/alex-vladut/aws-amplify-cdk/blob/main/tools/executors/graphql/impl.js. The logic consists of the following steps:

  1. read the GraphQL schema from a given location
  2. create an instance of the GraphqlTranform and pass all the transformers available
  3. run the schema through the transformers to generate the Appsync resolvers and CloudFormation stacks associated (holding this metadata in memory at this point)
  4. write the Cloudformation stacks and Appsync resolvers to disk

The logic for loading the schema files was inspired by the logic found here. I will not fully describe it here as it only consists of reading some files from a particular location, so there is nothing that interesting to it.

Next an instance of GraphqlTranform will be created like this:

async function createTransformer(options) {
  const modelTransformer = new ModelTransformer()
  const indexTransformer = new IndexTransformer()
  const hasOneTransformer = new HasOneTransformer()
  const authTransformer = new AuthTransformer({
    adminRoles: options.adminRoles ?? [],
    identityPoolId: options.identityPoolId,
  })

  const transformerList = [
    modelTransformer,
    new FunctionTransformer(),
    new HttpTransformer(),
    new PredictionsTransformer(options?.storageConfig),
    new PrimaryKeyTransformer(),
    indexTransformer,
    new BelongsToTransformer(),
    new HasManyTransformer(),
    hasOneTransformer,
    new ManyToManyTransformer(
      modelTransformer,
      indexTransformer,
      hasOneTransformer,
      authTransformer
    ),
    new DefaultValueTransformer(),
    authTransformer,
  ]

  if (options?.addSearchableTransformer) {
    transformerList.push(new SearchableModelTransformer())
  }

  const transformer = new GraphQLTransform({
    transformers: transformerList,
    authConfig,
    stacks: {},
    resolverConfig: {
      project: {
        ConflictHandler: ConflictHandlerType.AUTOMERGE,
        ConflictDetection: 'VERSION',
      },
    },
    sandboxModeEnabled: false,
    featureFlags,
  })
  return transformer
}
Enter fullscreen mode Exit fullscreen mode

The original implementation in Amplify CLI code could be found here. All I did here was to copy the code from that file and strip out all the unnecessary logic, such as importing custom transformers. Now that we have full control, we could import any custom transformer directly here similarly to all the other transformers.

If you run this script it will automatically parse the schema.graphql file and generate the Appsync resolvers under the dist/infra/build directory. Alongside the CloudFormation stacks for deploying those resources in AWS are generated as well. The directory structure will look as follows:

- resolvers
- stacks
appsync.cloudformation.json
parameters.json
schema.graphql
Enter fullscreen mode Exit fullscreen mode

This is the standard structure of the artifacts generated by Amplify regarding Appsync resources. resolvers include all the VTL Appsync resolvers, stacks will include one nested stack per model (i.e. here you will find a single CloudFormation stack called User.json), appsync.cloudformation.json is the entry stack holding all the other nested stacks, parameters.json is something used in general by Amplify to pass some parameters to the stack (won't need it) and schema.graphql is the GraphQL schema derived from our higher-level schema.

Once these resources are generated we're ready to continue with the second part, importing the CloudFormation stack from AWS CDK and deploying the resources to AWS.

Importing CloudFormation stacks from AWS CDK

We will start by defining a stack for creating Cognito User Pool as it will be used as an authentication mechanism:

import { Construct } from 'constructs';
import * as cdk from 'aws-cdk-lib';
import * as cognito from 'aws-cdk-lib/aws-cognito';

export class AuthStack extends cdk.NestedStack {
  public readonly userPool: cognito.UserPool;
  public readonly userPoolClient: cognito.UserPoolClient;

  constructor(scope: Construct, id: string, props?: cdk.NestedStackProps) {
    super(scope, id, props);

    this.userPool = new cognito.UserPool(this, 'CognitoUserPool', {
      signInAliases: { email: true, phone: true },
      selfSignUpEnabled: true,
      signInCaseSensitive: false,
      standardAttributes: {
        email: { required: true, mutable: true },
        familyName: { required: true, mutable: true },
        givenName: { required: true, mutable: true },
      },
    });

    this.userPoolClient = this.userPool.addClient('CognitoUserPoolClient');
  }
}
Enter fullscreen mode Exit fullscreen mode

Another stack will be implemented to define an S3 bucket where the VTL resolvers will be deployed and from there will be referenced by Appsync:

import { Construct } from 'constructs';
import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';

import { normalize, join } from 'path';
import { readFileSync } from 'fs';
import { createHash } from 'crypto';

export class GraphQlApiDeploymentStack extends cdk.NestedStack {
  public readonly bucket: s3.Bucket;
  public readonly appsyncFilesKey: string;

  constructor(scope: Construct, id: string, props?: cdk.NestedStackProps) {
    super(scope, id, props);

    this.bucket = new s3.Bucket(this, 'AppsyncDeploymentBucket');

    const path = normalize(join(__dirname, 'build', 'schema.graphql'));
    this.appsyncFilesKey = `appsync-files/${generateHashForFile(path)}`;

    new s3deploy.BucketDeployment(this, 'AppsyncFilesDeployment', {
      sources: [s3deploy.Source.asset(normalize(join(__dirname, 'build')))],
      destinationBucket: this.bucket,
      destinationKeyPrefix: this.appsyncFilesKey,
      memoryLimit: 2048,
    });
  }
}

function generateHashForFile(path: string) {
  const file = readFileSync(path);
  const hash = createHash('sha256');
  hash.update(file);
  return hash.digest('hex');
}
Enter fullscreen mode Exit fullscreen mode

I used the logic defined in this helper library created by AWS to help with importing the CloudFormation resources generated by AWS Amplify https://github.com/aws-amplify/amplify-cli-export-construct. It cannot be used per se due to it requiring all the other resources being available, not only the Appsync related ones, but still, it provides good inspiration.

Next let's define the nested stack responsible for importing the Appsync CloudFormation stack:

import { Construct } from 'constructs';
import * as cdk from 'aws-cdk-lib';
import * as cfninc from 'aws-cdk-lib/cloudformation-include';

import { readdirSync } from 'fs';
import { normalize, join } from 'path';

interface NestedStacksReferences {
  [key: string]: cfninc.IncludedNestedStack;
}

export class GraphQlApiStack extends cdk.NestedStack {
  public readonly graphqlApiId: string;
  public readonly graphqlApiEndpoint: string;
  public readonly nestedStacks: NestedStacksReferences;

  constructor(scope: Construct, id: string, props?: cdk.NestedStackProps) {
    super(scope, id, props);

    new cdk.CfnParameter(this, 'cognitoUserPoolId', {
      type: 'String',
      description: 'Auth Cognito User Pool ID',
    });
    new cdk.CfnParameter(this, 'authenticatedRoleName', {
      type: 'String',
      description:
        'Reference to the name of the Auth Role created for the project',
    });
    new cdk.CfnParameter(this, 'unauthenticatedRoleName', {
      type: 'String',
      description:
        'Reference to the name of the Unauthenticated Role created for the project',
    });
    new cdk.CfnParameter(this, 's3DeploymentBucket', {
      type: 'String',
      description: 'S3 bucket containing all the Appsync deployment artefacts',
    });
    new cdk.CfnParameter(this, 's3DeploymentRootKey', {
      type: 'String',
      description: 'S3 deployment rook key',
    });

    const apiNestedStacks = readdirSync(
      normalize(join(__dirname, 'build', 'stacks'))
    ).reduce(
      (aggregate, file) => ({
        ...aggregate,
        [file.replace('.json', '')]: {
          templateFile: normalize(join(__dirname, 'build', 'stacks', file)),
          preserveLogicalIds: true,
          parameters: {},
        },
      }),
      {}
    );
    const graphQlApiStack = new cfninc.CfnInclude(this, 'GraphqlApi', {
      templateFile: normalize(
        join(__dirname, 'build', 'appsync.cloudformation.json')
      ),
      preserveLogicalIds: true,
      loadNestedStacks: apiNestedStacks,
      parameters: {
        AppSyncApiName: 'aws-amplify-cdk',
        AuthCognitoUserPoolId: props.parameters.cognitoUserPoolId,
        authRoleName: props.parameters.authenticatedRoleName,
        unauthRoleName: props.parameters.unauthenticatedRoleName,
        S3DeploymentBucket: props.parameters.s3DeploymentBucket,
        S3DeploymentRootKey: props.parameters.s3DeploymentRootKey,
        DynamoDBEnablePointInTimeRecovery: 'true',
        DynamoDBEnableServerSideEncryption: 'true',
        env: 'dev',
      },
    });
    this.graphqlApiId = graphQlApiStack.getOutput('GraphQLAPIIdOutput').value;
    this.graphqlApiEndpoint = graphQlApiStack.getOutput(
      'GraphQLAPIEndpointOutput'
    ).value;

    // includes all the relevant nested stacks so that could be referenced from the parent stack
    this.nestedStacks = Object.keys(apiNestedStacks).reduce(
      (stacks, name) => ({
        ...stacks,
        [name]: graphQlApiStack.getNestedStack(name),
      }),
      {}
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we'll import all the nested stacks from our main stack and link them together:

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';

import { AuthStack } from './auth.stack';
import { GraphQlApiDeploymentStack } from './graphql-api-deployment.stack';
import { GraphQlApiStack } from './graphql-api.stack';

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

    const authStack = new AuthStack(this, 'AuthStack');
    const graphqlApiDeploymentStack = new GraphQlApiDeploymentStack(
      this,
      'GraphQlApiDeploymentStack'
    );
    const graphqlApiStack = new GraphQlApiStack(this, 'GraphQlApiStack', {
      parameters: {
        cognitoUserPoolId: authStack.userPool.userPoolId,
        authenticatedRoleName: authStack.authenticatedRole.roleName,
        unauthenticatedRoleName: authStack.unauthenticatedRole.roleName,
        s3DeploymentBucket: graphqlApiDeploymentStack.bucket.bucketName,
        s3DeploymentRootKey: graphqlApiDeploymentStack.appsyncFilesKey,
      },
    });

    new cdk.CfnOutput(this, 'CognitoIdentityPoolId', {
      value: authStack.identityPool.ref,
    });
    new cdk.CfnOutput(this, 'UserPoolId', {
      value: authStack.userPool.userPoolId,
    });
    new cdk.CfnOutput(this, 'UserPoolWebClientId', {
      value: authStack.userPoolClient.userPoolClientId,
    });
    new cdk.CfnOutput(this, 'AppsyncResolversBucketName', {
      value: graphqlApiDeploymentStack.bucket.bucketName,
    });
    new cdk.CfnOutput(this, 'AppsyncResolversKey', {
      value: graphqlApiDeploymentStack.appsyncFilesKey,
    });

    new cdk.CfnOutput(this, 'oauth', { value: '{}' });
    new cdk.CfnOutput(this, 'GraphqlApiId', {
      value: graphqlApiStack.graphqlApiId,
    });
    new cdk.CfnOutput(this, 'GraphqlApiEndpoint', {
      value: graphqlApiStack.graphqlApiEndpoint,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Future improvements

A better development experience would be provided if the AWS CDK stack created by AWS Amplify could be directly imported from your project. There are currently a couple of limitations that would need to be addressed to accomplish this goal:

  • AWS Amplify assigns a new scope to the AWS CDK stack created for the Appsync resources. See this proposal for more details #9254,
  • AWS Amplify uses AWS CDK v1, so even if the issue above was addressed, this still won't work if you already migrated to v2.

This approach won't be optimal either, due to AWS Amplify CLI generating both the Appsync resolvers and the associated resources for deploying those resolvers (e.g. the CloudFormation stacks for defining the DynamoDB tables, Appsync API). A further step would require architectural changes of the AWS Amplify GraphQL transformers code to decouple the generation of the resolvers from the creation of the deployment resources.

Conclusion

In this article, we have looked into how the AWS Amplify GraphQL transformers capability could be used with AWS CDK. This approach provides a much better experience as you can define the infrastructure in Typescript instead of working directly with the CloudFormation templates. In addition to that, you will get more flexibility in defining your infrastructure resources and overall it offers a smoother integration with other development tools of your choice.

Discussion (2)

Collapse
isaactrevino profile image
Isaac • Edited on

This is great! I'm currently transitioning from Amplify to CDK. Have you considered making this a construct? github.com/kcwinner/cdk-appsync-tr... is another resource you should check out.

Collapse
alexvladut profile image
alex-vladut Author

Thanks for reaching out and sharing this repo, it looks very useful indeed. I'll check it out in more depth, as I can see it uses AWS Amplify Transformers v1 and AWS CDK v1, but there seems to be some work to migrate it to CDK v2, so will keep an eye on it.
As for making it a construct, I don't have any experience in this area but sounds like a good idea, I'll dig into that at some point. I was just hoping that AWS Amplify will expose the GraphQL transformers as a distinct component too, rather than expecting to just use everything :)