How to call AWS Step Functions from AWS Amplify
Tags: amplify, aws, webdev, serverless
Published: 2022-11-06 09:47:23

## Purpose / Motivation
I read this great article.

> [AWS Amplify: execute a Step Functions state machine from Appsync](https://awstip.com/aws-amplify-initialize-step-functions-execution-from-appsync-ad32b9dc35be)

This article shows how to call AWS Step Functions from AWS Amplify, including creating some VTL files.

But now we can use AWS CDK inside AWS Amplify with the Amplify Custom feature.
And I want to use AWS CDK.
So let's translate it to the AWS CDK style.

## Why call AWS Step Functions from AWS Amplify
In many cases, AWS Amplify has needed to implement logic on the Front-end side.
If we can call AWS Step Functions from AWS Amplify, we can put some logic to Back-end, which is constructed in the Serverless style.
This has positive effects in many aspects, such as scalability, security, etc.

## How
![Figure: Architecture for How to call AWS Step Functions from AWS Amplify](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ruqqr6pkehach873p6tl.png)
*Figure: Architecture for How to call AWS Step Functions from AWS Amplify*

**Note:**
We can use AWS CDK inside AWS Amplify by Amplify Custom feature.
But we need to use AWS CDK v1.

### Create a project

```sh
% npm create vite@latest sample-app -- --template react-ts 
% cd sample-app
% amplify init
% npm i @aws-amplify/ui-react aws-amplify
```

### Add API (GraphQL)

```sh
% amplify add api
❯ GraphQL 
❯ Continue
❯ Single object with fields (e.g., “Todo” with ID, name, description)
? Do you want to edit the schema now? (Y/n) ‣ no
```

Check your API name, and replace below [YOUR_API_NAME] with your own.

[PROJECT_TOP]/amplify/backend/api/[YOUR_API_NAME]/schema.graphql
```graphql
# This "input" configures a global authorization rule to enable public access to
# all models in this schema. Learn more about authorization rules here: https://docs.amplify.aws/cli/graphql/authorization-rules
input AMPLIFY { globalAuthRule: AuthRule = { allow: public } } # FOR TESTING ONLY!

type Todo @model {
  id: ID!
  name: String!
  description: String
}

type Mutation {
  sendSns(subject: String, message: String): String
}
```

Add Mutation for call AWS Step Functions.

### Add Custom (AWS CDK)

```sh
% amplify add custom
❯ AWS CDK
? Provide a name for your custom resource ‣ [YOUR_CUSTOM_RESOURCE_NAME]
? Do you want to edit the CDK stack now? (Y/n) ‣ no

% cd amplify/backend/custom/[YOUR_CUSTOM_RESOURCE_NAME]
% npm i @aws-cdk/aws-appsync @aws-cdk/aws-stepfunctions @aws-cdk/aws-stepfunctions-tasks
% cd ../../../..
```

Check your custom resource name, and replace below [YOUR_CUSTOM_RESOURCE_NAME] with your own.

Also, set up SNS in advance and check the ARN. Replace [SNS_ARN] with it.

And replace [REGION] with the region of your project.

[PROJECT_TOP]/amplify/backend/custom/[YOUR_CUSTOM_RESOURCE_NAME]/cdk-stack.ts
```typescript
import * as cdk from '@aws-cdk/core';
import * as AmplifyHelpers from '@aws-amplify/cli-extensibility-helper';
import { AmplifyDependentResourcesAttributes } from '../../types/amplify-dependent-resources-ref';
import * as iam from '@aws-cdk/aws-iam';
import * as appsync from '@aws-cdk/aws-appsync';
import * as sns from '@aws-cdk/aws-sns';
import * as stepfunctions from '@aws-cdk/aws-stepfunctions';
import * as tasks from '@aws-cdk/aws-stepfunctions-tasks';

export class cdkStack extends cdk.Stack {
  constructor(
    scope: cdk.Construct,
    id: string,
    props?: cdk.StackProps,
    amplifyResourceProps?: AmplifyHelpers.AmplifyResourceProps
  ) {
    super(scope, id, props);
    /* Do not remove - Amplify CLI automatically injects the current deployment environment in this input parameter */
    new cdk.CfnParameter(this, 'env', {
      type: 'String',
      description: 'Current Amplify CLI env name'
    });
    /* AWS CDK code goes here - learn more: https://docs.aws.amazon.com/cdk/latest/guide/home.html */

    // # Step Functions
    // ## Define Tasks
    // ### Choice
    const choiceTask = new stepfunctions.Choice(this, 'choiceTask');
    // Wait
    const waitTask = new stepfunctions.Wait(this, 'waitTask', {
      time: stepfunctions.WaitTime.duration(cdk.Duration.seconds(5))
    });
    // ### SNS
    const snsTopic = sns.Topic.fromTopicArn(
      this,
      'topic',
      '[SNS_ARN]'
    );
    const snsTask = new tasks.SnsPublish(this, 'publish', {
      topic: snsTopic,
      integrationPattern: stepfunctions.IntegrationPattern.REQUEST_RESPONSE,
      subject: stepfunctions.TaskInput.fromJsonPathAt('$.input.subject').value,
      message: stepfunctions.TaskInput.fromJsonPathAt('$.input.message')
    });

    // ### Role for SNS called from Step Functions
    const statesRole = new iam.Role(this, 'StatesServiceRole', {
      assumedBy: new iam.ServicePrincipal('states.[REGION].amazonaws.com')
    });

    // ## Set Step Functions
    const sf = new stepfunctions.StateMachine(this, 'StateMachine', {
      // stateMachineType: stepfunctions.StateMachineType.EXPRESS,
      definition: choiceTask
        .when(
          stepfunctions.Condition.stringEquals('$.input.subject', 'wait'),
          waitTask.next(snsTask)
        )
        .otherwise(snsTask),
      role: statesRole
    });

    // # AppSync
    // ## Access other Amplify Resources
    const retVal: AmplifyDependentResourcesAttributes =
      AmplifyHelpers.addResourceDependency(
        this,
        amplifyResourceProps.category,
        amplifyResourceProps.resourceName,
        [
          {
            category: 'api',
            resourceName: '[YOUR_API_NAME]'
          }
        ]
      );

    // ## Request VTL
    const requestVTL = `
      $util.qr($ctx.stash.put("executionId", $util.autoId()))
      
      #set( $Input = {} )
      $util.qr($Input.put("subject", $ctx.args.subject))
      $util.qr($Input.put("message", $ctx.args.message))
      
      #set( $Headers = {
        "content-type": "application/x-amz-json-1.0",
        "x-amz-target":"AWSStepFunctions.StartExecution"
      } )
      #set( $Body = {
        "stateMachineArn": "${sf.stateMachineArn}"
      } )
      
      #set( $BaseInput = {} )
      $util.qr($BaseInput.put("input", $Input))
      $util.qr($Body.put("input", $util.toJson($BaseInput)))
      
      #set( $PutObject = {
        "version": "2018-05-29",
        "method": "POST",
        "resourcePath": "/"
      } )
      #set ( $Params = {} )
      $util.qr($Params.put("headers",$Headers))
      $util.qr($Params.put("body",$Body))
      
      $util.qr($PutObject.put("params",$Params))
      $util.toJson($PutObject)
    `;
    // ## Response VTL
    const responseVTL = `
      $util.toJson($ctx.result)
    `;

    // ## Role for Step Functions
    const stepFunctionsRole = new iam.Role(this, 'stepFunctionsRole', {
      assumedBy: new iam.ServicePrincipal('appsync.amazonaws.com')
    });
    stepFunctionsRole.addToPolicy(
      new iam.PolicyStatement({
        actions: ['states:StartExecution'],
        resources: [sf.stateMachineArn]
      })
    );

    // ## AppSync DataSource
    const dataSourceId = 'sendSnsHttpDataSource';
    const dataSource = new appsync.CfnDataSource(this, dataSourceId, {
      apiId: cdk.Fn.ref(retVal.api.[YOUR_API_NAME].GraphQLAPIIdOutput),
      name: dataSourceId,
      serviceRoleArn: stepFunctionsRole.roleArn,
      type: 'HTTP',
      httpConfig: {
        endpoint: 'https://states.[REGION].amazonaws.com',
        authorizationConfig: {
          authorizationType: 'AWS_IAM',
          awsIamConfig: {
            signingRegion: '[REGION]',
            signingServiceName: 'states'
          }
        }
      }
    });

    // ## AppSync Resolver
    const resolver = new appsync.CfnResolver(this, 'custom-resolver', {
      apiId: cdk.Fn.ref(retVal.api.[YOUR_API_NAME].GraphQLAPIIdOutput),
      fieldName: 'sendSns',
      typeName: 'Mutation',
      requestMappingTemplate: requestVTL,
      responseMappingTemplate: responseVTL,
      dataSourceName: dataSource.name
    });
  }
}
```

### Push your Amplify project

```sh
% amplify push
```

Push your Amplify project and wait a minute.

### Set up other codes
Set up Vite and Front-end codes.

[PROJECT_TOP]/vite.config.ts
```typescript
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  server: {
    port: 8080,
  },
  resolve: {
    alias: [
      { find: "./runtimeConfig", replacement: "./runtimeConfig.browser" },
      { find: "@", replacement: "/src" },
    ],
  },
});
```

[PROJECT_TOP]/index.html
```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React + TS</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
    <script>
      window.global = window;
      window.process = {
        env: { DEBUG: undefined },
      };
      var exports = {};
    </script>
  </body>
</html>
```

[PROJECT_TOP]/src/main.tsx
```tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import '@aws-amplify/ui-react/styles.css';

import { Amplify } from 'aws-amplify';
import awsExports from './aws-exports';
Amplify.configure(awsExports);

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
```

[PROJECT_TOP]/src/App.tsx
```tsx
import React, { useState } from 'react';

import { Flex, Button, TextField } from '@aws-amplify/ui-react';

import { API } from 'aws-amplify';
import { sendSns } from './graphql/mutations';

function App(): JSX.Element {
  const [subject, setSubject] = useState('');
  const [message, setMessage] = useState('');

  const callSendSns = async (): Promise<void> => {
    if (!subject || subject.length === 0) {
      return;
    }
    if (!message || message.length === 0) {
      return;
    }

    const result = await API.graphql({
      query: sendSns,
      variables: {
        subject,
        message
      },
      authMode: 'API_KEY'
    });
    console.log('callSendSns', result);
    setSubject('');
    setMessage('');
  };

  const handleSetSubject = (event: React.FormEvent<HTMLInputElement>): void => {
    setSubject((event.target as any).value);
  };
  const handleSetMessage = (event: React.FormEvent<HTMLInputElement>): void => {
    setMessage((event.target as any).value);
  };

  return (
    <Flex direction="column">
      <TextField
        placeholder="Subject"
        label="Subject"
        isRequired={true}
        value={subject}
        errorMessage="There is an error"
        onInput={handleSetSubject}
      />
      <TextField
        placeholder="Message"
        label="Message"
        isRequired={true}
        value={message}
        errorMessage="There is an error"
        onInput={handleSetMessage}
      />
      <Button
        type="submit"
        variation="primary"
        onClick={() => {
          callSendSns();
        }}
      >
        Send SNS
      </Button>
    </Flex>
  );
}

export default App;
```

### Check the operation with the movie
Let's check the operation with the movie.

```sh
% npm run dev
```

![Check the operation with the movie](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/dlt23s6ysu3xlwgyl9zm.gif)