Skip to main content
  1. Posts/

AWS Amplify Override Amplify-generated resources. On example with resolvers.

·1066 words·6 mins
aws amplify aws appsync appsync resolver override amplify TypeScript aws cdk
Daniel Ancuta
Author
Daniel Ancuta
Backend team lead, with 16+ years of hands-on experience with modern technologies, as well as 4+ years of experience in leading of development teams. Let us get in touch!

AWS Amplify is a great tool when you’re building web and mobile apps, and you want to integrate them easily with the backend.

In a few simple steps, you can have a working GraphQL endpoint, storage on the backend, authentication, and authorization through Cognito.

But it also has some limitations, either small bugs or just design decisions:

Luckily, all listed above can be mitigated by developers using Amplify CLI.

Override Amplify-generated resources #

Today I want to focus on “Overriding Amplify-generated resources” when you use CDK for your cross-account or cross-region Amplify backend deployments.

As an example project, we will modify auto-generated AppSync resolvers. You can use this pattern to override other resources which you can’t simply override because of amplify export limitations.

But first things first.

Configure amplify project to use CDK #

For this exercise I’ve just created new amplify project, called amplifytest. So all examples below uses that name, you should replace it with name of your project.

Initialization of CDK #

  • cd my_project
  • npm i aws-cdk aws-cdk-lib
  • mkdir amplifytest && cd amplifytest (we need empty directory to initialize cdk)
  • ./node_modules/.bin/cdk init app --language=typescript
  • cd ..
  • cp -R ./amplifytest/bin ./bin
  • cp -R ./amplifytest/lib ./lib
  • cp ./amplifytest/cdk.json ./cdk.json

You need one more thing: install @aws-amplify/cdk-exported-backend. This library has a few dependencies, so most likely you will also need to install @types/node and lodash.

At this point, you should have three extra files in your Amplify project:

./cdk.json #

{
  "app": "npx ts-node --prefer-ts-exts bin/amplifytest.ts",
  ...
}

./bin/amplifytest.ts #

#!/usr/bin/env node


import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { AmplifytestStack } from '../lib/amplifytest-stack';

const app = new cdk.App();
new AmplifytestStack(app, 'AmplifytestStack', {
});

./lib/amplifytest-stack.ts #

That’s the one we will modify in order to set up @aws-amplify/cdk-exported-backend

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as path from 'path';
import { AmplifyExportedBackend }  from '@aws-amplify/cdk-exported-backend';

export class AmplifytestStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props);
        
        const basePath = path.resolve(process.cwd());
        const basePathExport = path.resolve(basePath, 'export-amplify-stack/amplify-export-amplifytest');

        new AmplifyExportedBackend(this, 'AmplifyExportedBackend', {
          path: basePathExport,
          amplifyEnvironment: 'dev',
        });
  }
}
Worth reading
  1. Use an exported Amplify backend in AWS Cloud Development Kit (CDK)
  2. @aws-amplify/cdk-exported-backend

Problem we want to solve: return custom validation error #

Let’s imagine you have your mutation, that has custom data source (lambda) connected:

type Mutation {
  customMutation(id: String): Boolean @function(name: "my-test-function")
}

And your function my-test-function does some request payload validation. The only way to return back to client validation errors is by raising an exception:

raise Exception("My error")

Which then is being transformed by AppSync to this response:

{
  "data": {
    "myMutation": null
  },
  "errors": [
    {
      "path": [
        "myMutation"
      ],
      "data": null,
      "errorType": "Lambda:Unhandled",
      "errorInfo": null,
      "locations": [
        {
          "line": 24,
          "column": 2,
          "sourceName": null
        }
      ],
      "message": "My error"
    }
  ]
}

But what if you want to be more flexible? You would want to modify other attributes like errorType, errorInfo, or you just want to return more than one validation error at a time?

To be able to achieve that, you would have to modify *LambdaDataSource.res.vtl, in our case, InvokeMyTestFunctionLambdaDataSource.res.vtl, resolver template.

Auto-generated resolvers are located in amplify/backend/api/amplifytest/build/resolvers, and you have to override them in amplify/backend/api/amplifytest/resolvers.

InvokeMyTestFunctionLambdaDataSource.res.vtl resolver, from:

## [Start] Handle error or return result. **
#if( $ctx.error )
  $util.error($ctx.error.message, $ctx.error.type)
#end
$util.toJson($ctx.result)
## [End] Handle error or return result. **

would have to be overridden by:

## [Start] Handle error or return result. **
#if( $ctx.result && $ctx.result.errorMessage )
    $util.error($ctx.result.errorMessage, $ctx.result.errorType, $ctx.result.data, $ctx.result.errorInfo)
#elseif( $ctx.error )
    $util.error($ctx.error.message, $ctx.error.type)
#else
    $utils.toJson($ctx.result)
#end
## [End] Handle error or return result. **

After that is done your lambda can return something like that:

return {
    "data": [],
    "errorType": "MyCustomErrorType",
    "errorMessage": 'Error message',
    "errorInfo": {"key": "value"},
}

As you can see, it’s a rather easy process. That can be done manually.

But doing it manually has a few disadvantages:

  • Someone would have to remember to do it every time a new custom data source is introduced.
  • Change of resolver template requires changes in multiple files.
  • When your list of custom data sources is short, you can get away with manual updates, but what if it grows to 10, 20, 30, and more?

It’s not an ideal situation. A more robust way would be to do it automatically.

Create template file #

We will create a “template” file, which will then be used for all of our data source resolvers.

## amplify/backend/api/amplifytest/base_resolvers/_BaseLambdaDataSource.res.vtl
## [Start] Handle error or return result. **
#if( $ctx.result && $ctx.result.errorMessage )
    $util.error($ctx.result.errorMessage, $ctx.result.errorType, $ctx.result.data, $ctx.result.errorInfo)
#elseif( $ctx.error )
    $util.error($ctx.error.message, $ctx.error.type)
#else
    $utils.toJson($ctx.result)
#end
## [End] Handle error or return result. **

Modify ./lib/amplifytest-stack.ts #

Here, we will add code that will be executed every time you deploy your stack with CDK.

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

const fs = require('fs');
import {AmplifyExportedBackend} from '@aws-amplify/cdk-exported-backend';

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

        const basePath = path.resolve(process.cwd());
        const basePathExport = path.resolve(basePath, 'export-amplify-stack/amplify-export-amplifytest');
        const functionDirectiveStack = require(
            path.resolve(basePathExport,
                "api", "amplifytest", "amplify-appsync-files", "stacks", "FunctionDirectiveStack.json")
        );
        const resolverTemplate = fs.readFileSync(
            path.resolve(basePath, "amplify", "backend", "api", "amplifytest", "base_resolvers", "_BaseLambdaDataSource.res.vtl")
        );

        Object
            .entries(functionDirectiveStack.Resources)
            .filter(([_, value]: [string, any]) => value.Type === "AWS::AppSync::FunctionConfiguration")
            .forEach(([_, value]: [string, any], index) => {
                let responseMapping = value.Properties["ResponseMappingTemplateS3Location"]["Fn::Join"][1][4];

                fs.writeFileSync(
                    path.resolve(
                        basePathExport, "api", "amplifytest", "amplify-appsync-files", "resolvers",
                        path.parse(responseMapping).base
                    ),
                    resolverTemplate
                );
            });

        new AmplifyExportedBackend(this, 'AmplifyExportedBackend', {
            path: basePathExport,
            amplifyEnvironment: 'dev',
        });
    }
}

In those few lines of code, we loop through all custom data source functions and create a modified version of the resolver. This is more flexible than the default one.

The whole process happens during the cdk deploy execution.

Test a solution #

To test it we need to deploy our stack

  1. ./node_modules/.bin/amplify export
  2. npx cdk deploy AmplifytestStack/AmplifyExportedBackend-amplify-backend-stack

Final Thoughts #

Even though I have started with listing some limitations of Amplify, I hope you haven’t gotten scared.

AWS Amplify is a good tool, but like every tool, it has its own problems. Still, I would recommend using it for your production applications!

The End #

That’s it! If your company needs some help with AWS, get in touch.