AWS Amplify から AWS Step Functions を呼ぶ方法

こちらは、 “ゆるWeb勉強会@札幌 Advent Calendar 2022” の 14日目の記事です。

および、 “AWS Advent Calendar 2022” の 14日目の記事です。

こちらは、以前に私の書いた下記記事の和訳となります。

How to call AWS Step Functions from AWS AmplifyPurpose / Motivation I read this great article. AWS Amplify: execute a Step Functions...
How to call AWS Step Functions from AWS Amplify dev.to
How to call AWS Step Functions from AWS Amplify

目的

こちらの記事を読んで、 AWS AppSync から AWS Step Functions を呼べることがわかりました。

Attention Required! | CloudflareThis website is using a security service to protect itself from online attacks. The action you just performed triggered the security solution. There are several actions that could trigger this block including submitting a certain word or phrase, a SQL command or malformed data.
Attention Required! | Cloudflare awstip.com
Attention Required! | Cloudflare

この記事では、 VTL ファイルを利用して AppSync のリゾルバを AWS Amplify 内で定義して AWS Step Functions を呼んでいます。

しかし、現在は Amplify Custom 機能を使うことによって、 AWS CDK を AWS Amplify 内で使うことができます。

そして、私は AWS CDK で書きたいです。

ということで、 AWS CDK を使う方法に書き換えてみます。

なぜ AWS Step Functions を AWS Amplify から呼ぶのか

多くの場合、AWS Amplify はフロントエンド側にロジックを実装する必要がありました。

AWS Amplify から AWS Step Functions を呼び出すことができれば、サーバーレスで構築されたバックエンドにロジックを配置できるようになります。

これは、スケーラビリティ、セキュリティなど、多くの面で良い効果が期待できます。

どのようにやるか

Figure: Architecture for How to call AWS Step Functions from AWS Amplify
図: AWS Amplify から AWS Step Functions を呼ぶためのアーキテクチャ

Amplify Custom 機能により、AWS Amplify 内で AWS CDK を使用できます。

ただし、AWS CDK v1 を使用する必要があります。

[訳注 2022/12/14] Amplify Custom で利用する AWS CDK に v2 が Preview 版として取り込まれました。

Use AWS CDK v2 with the AWS Amplify CLI extensibility features (Preview) | Amazon Web ServicesWith v11.0.0-beta of the Amplify CLI, you can now use AWS CDK v2 to extend or modify your Amplify backend stack. As a recap, at re:Invent 2021, Amplify CLI (v7+) announced a number of extensibility features that gave developers the flexibility to modify their Amplify backend using infrastructure-as-code providers such as AWS CDK or AWS […]
Use AWS CDK v2 with the AWS Amplify CLI extensibility features (Preview) | Amazon Web Services aws.amazon.com
Use AWS CDK v2 with the AWS Amplify CLI extensibility features (Preview) | Amazon Web Services

プロジェクト作成

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

API (GraphQL) の追加

% 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

自分の作成した API名を確認し、 [YOUR_API_NAME] を変更して該当するファイルを編集してください。

[PROJECT_TOP]/amplify/backend/api/[YOUR_API_NAME]/schema.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
}

AWS Step Functions を呼ぶための Mutation を追加します。

Custom (AWS CDK) の追加

% 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 ../../../..

custom リソース名を確認し、 [YOUR_CUSTOM_RESOURCE_NAME] を変更して作業を行なってください。

また、事前に SNS のセットアップを行ない ARN を確認し、 [SNS_ARN] を置き換えてください。

そして [REGION] を利用するプロジェクトのリージョンへ置き換えてください。

[PROJECT_TOP]/amplify/backend/custom/[YOUR_CUSTOM_RESOURCE_NAME]/cdk-stack.ts

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
    });
  }
}

Amplify プロジェクトのプッシュ

% amplify push

Amplify プロジェクトをプッシュし、少し待ちます。

他のコードの設定

Viteやフロントエンドのコードを設定していきます。

[PROJECT_TOP]/vite.config.ts

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

<!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

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

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;

操作の確認(動画)

実際の動作を確認しましょう。

% npm run dev

GIF動画はこちらです。

Check the operation with the movie

tacck
  • tacck
  • 北の大地の普通のソフトウェアエンジニア。
    インフラ・バックエンド・フロントエンドと、色々やります。

    初心者・若手向けのメンターも希望あればお受けします。

    勉強会運営中
    * ゆるWeb勉強会@札幌
    * スマートスピーカーで遊ぼう会@札幌

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください