Photo by Kai Oberhäuser on Unsplash

Custom resources for your amplify backend

tl;dr

Amplify is a powerful tool to speed up your development, but there is a limit on the resources it does support. With increasing requirements, you might outgrow Amplify and have to add custom resources to the backend it created for you. In this article, I will show you how to do that and overcome some of Amplify’s limitations.

Why should you want that?

Amplify [1] is a powerful tool to develop a mobile or web-based application fast. You can find plenty tutorials on how to implement a working full stack application with an API, Authentication, database and hosting with just a few CLI commands [2, 3].

This can get you up to speed very fast, but the framework has its limits: While AWS offers over 150 different services and counting, the Amplify CLI is only able to handle a few of them.

However, since Amplify uses CloudFormation [4] under the hood, you are able to provide custom resources and use them with your existing environment. Currently the official documentation does not cover this, so let me show you how to do it.

The exemplary use case

This article is the first of a short series about a recent project where we implemented a parking app for Germany. The app replaces your common ticket machine found in parking lots. It enables the customer to choose the desired parking duration and pay upfront (with the possibility to extend this duration). Alternatively, the customer pays for the actual time parked after they are done.

How can you do it?

Let’s take a closer look to our Amplify project structure. This is the backend folder:

amplify
  /backend
    /api
      ...
    /auth
      ...
    /storage
      ...
    /function
      /functionA
        functionA-cloudformation-template.json
        parameters.json
        ...
      /functionB
        ... 

On the top level are the category folders (storage, function, etc.) and in those folders are the explicit resources. A resource consists of two files: the CloudFormation template file and its parameters file.

The „backend-config.json“ in the backend folder reflects this folder structure:

{
    "api": { ... },
    "auth": { ... },
    "storage": { ... },
    "function": { 
      "functionA": { ... },
      "functionB": { ... }
    }
}

Coincidence? I don’t think so. Let’s give it a try and add a custom resource.

Customize your amplify stack

We will add a Step Function to handle the parking process. First, we need to create the folder structure of this new resource. We create a category folder called “steps” with a subfolder “processParking”.

amplify
  /backend
    /api
      ...
    /auth
      ...
    /storage
      ...
    /function
      ...
    /steps
      /processParking
        processParking-cloudformation-template.yml
        parameters.json

Then we add the Step Function to the “backend-config.json” file so it correctly reflects the folder structure again:

{
    "api": { ... },
    "auth": { ... },
    "storage": { ... },
    "function": { 
      "functionA": { ... },
      "functionB": { ... }
    },
    "steps": {
        "processParking": {
            "providerPlugin": "awscloudformation"
        }
    }
}

Important note: Ensure to run the command „amplify env checkout <env>“ before pushing / publishing, otherwise your new custom resources will not be published.

How to write the custom template – a few tips and tricks

We are now able to provide any resource that is deployable via CloudFormation.

But wait, there is more! One great feature of Amplify is the ability to switch between environments. It enables you to test some stuff in a test-environment without affecting the production-environment. It’s even possible to isolate them on account level.

If you have a look at the resources Amplify creates, you will notice that every one of them is marked by an added „-<environment>“ postfix to the resource. As we want separate resources for our different environments, we stick to this convention. Luckily, Amplify adds a parameter, called „env“ also to resources, including custom resources. So we just need to add the „env“-parameter to our „Parameters“ section and use it for our resource naming:

AWSTemplateFormatVersion: 2010-09-09
Parameters:
  env:
    Type: String
  ...
Resources:
  ProcessParking:
  Type: AWS::StepFunctions::StateMachine
  Properties:
    StateMachineName: !Sub
      - ProcessParking-${env}
      - env: !Ref env
...

Now we are getting a separate State-Machine for every environment we create. However, it is completely isolated from the rest of the environment. We could just utilize the already created Lambda-functions and add each ones ARN to the template. This would work at first, but as soon as the environment gets changed, the State Machine accesses the wrong function. Another dirty solution would be to substitute the postfix <env> of the Arn. And the <region> and the <account_id>. Works, but there is a more elegant way to do it.

As we already saw, Amplify uses CloudFormation under the hood to create all the resources. Furthermore, it uses a concept called „Nested Stacks“ [5]. Every resource you are creating with Amplify is a stack, which is part of a bigger parent stack. And to be able to link every resource with the other ones, Amplify adds by default some output to every nested stack. Let’s use this.

For example, each Lambda outputs its ARN:

{
    
  "AWSTemplateFormatVersion": "2010-09-09",
  "Parameters": {
    "env": {
      "Type": "String"
    }
  "Resources":
    ...
  "Outputs": {
    "Arn": {
      "Value": {
        "Fn::GetAtt": [
          "LambdaFunction",
          "Arn"
        ]
      }
    ...
  }
}

Let’s see how to connect nested stacks: Amplify creates a file called „nested-cloudformation-stack.yml“, which is in „.gitignore“ and should therefore not be touched. But this file shows us, how every resource is named internally and how parameters are passed from one stack to the other. We can also see, that every stack has an „env“-parameter, as already mentioned.

...
"functionfunctionA": {
  "Type": "AWS::CloudFormation::Stack",
  "Properties": {
    "TemplateURL": "...",
    "Parameters": {
      "authparking71b155d8UserPoolId": {
        "Fn::GetAtt": [
          "authparking71b155d8",
          "Outputs.UserPoolId"
        ]
      },
      "env": "dev"
   }
}
...

You can also see that the „stopParking“ method is getting the UserPoolId of the authentication stack via the CloudFormation-function „Fn::GetAtt“ [6].

Here, the paramaters are filled automatically, because Amplify handles Lambda-functions and authentication automatically. But how can we add custom parameters, when the file is in „.gitignore“? Remember the file „parameter.json“? The content of this file is put into the parameter section of the stack file – as it is.

An in-depth analysis of the file has shown that every stack follows the same naming convention: <category><givenName>. All we have to do now is to state the parameters in the JSON file and the CloudFormation template and get the output of all the stacks / resources we need.

processParking-cloudformation-template.yml
----

AWSTemplateFormatVersion: 2010-09-09
Parameters:
  env:
    Type: String
  functionAArn:
    Type: String
  functionBArn:
    Type: String
...
parameters.json
---
{
  "functionAArn": {
    "Fn::GetAtt": ["functionfunctionA", "Outputs.Arn"]
  },
  "functionBArn": {
    "Fn::GetAtt": ["functionfunctionB", "Outputs.Arn"]
  }
}    

Now we can use the ARNs of the functions inside our State Machine and do not have to cope with weird substitutions to get the correct name et voilà: we connected a custom resource to our existing backend which is now handled by Amplify. Every update is recognized and can be pushed to different environments.

Conclusion and Outlook

You can use Amplify to quickly create your basic cloud environment. Should you reach a point where you need to add a non-supported resource you now know how to do that manually.

In my next article, I will show you how to combine Step Functions and Amplify to a long-lasting and low-cost serverless backend.

References

[1] https://aws.amazon.com/amplify/

[2] https://docs.amplify.aws/start

[3] https://ionicframework.com/blog/adding-aws-amplify-to-an-ionic-4-app/

[4] https://aws.amazon.com/cloudformation/

[5] https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-nested-stacks.html

[6] https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html