Цель / Мотивация

Я прочитал эту замечательную статью.

AWS Amplify: выполнение конечного автомата Step Functions из Appsync

В этой статье показано, как вызывать AWS Step Functions из AWS Amplify, включая создание некоторых файлов VTL.

Но теперь мы можем использовать AWS CDK внутри AWS Amplify с функцией Amplify Custom.
И я хочу использовать AWS CDK.
Итак, давайте переведем это в стиль AWS CDK.


Зачем вызывать AWS Step Functions из AWS Amplify

Во многих случаях AWS Amplify требовалось реализовать логику на стороне внешнего интерфейса.
Если мы сможем вызвать AWS Step Functions из AWS Amplify, мы сможем поместить некоторую логику в Back-end, построенную в бессерверном стиле.
Это имеет положительный эффект во многих аспектах, таких как масштабируемость, безопасность и т. д.


Как

Рисунок: Архитектура для вызова AWS Step Functions из AWS Amplify
Рисунок: Архитектура для вызова AWS Step Functions из AWS Amplify

Примечание:
Мы можем использовать AWS CDK внутри функции AWS Amplify by Amplify Custom.
Но нам нужно использовать AWS CDK v1.


Создать проект

% 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]/усилить/бэкенд/API/[YOUR_API_NAME]/схема.graphql

# This "input" configures a global authorization rule to enable public access to
# all models in this schema. Learn more about authorization rules here: 
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.


Добавить пользовательский (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 ../../../..
Войти в полноэкранный режим

Выйти из полноэкранного режима

Проверьте имя пользовательского ресурса и замените ниже [YOUR_CUSTOM_RESOURCE_NAME] со своим.

Кроме того, заранее настройте SNS и проверьте ARN. Заменять [SNS_ARN] с этим.

И заменить [REGION] с регионом вашего проекта.

[PROJECT_TOP]/усилить/бэкэнд/пользовательский/[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:  */

    // # 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 и Front-end.

[PROJECT_TOP]/vite.config.ts

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

// 
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
Войти в полноэкранный режим

Выйти из полноэкранного режима

Проверяем работу с фильмом