こんにちは、広野です。
React アプリから実行できる Amazon DynamoDB へのデータインポート画面を作成したときの話です。
インプットするデータ量は少ないのですが、データを加工したりデータから AI サービスを使用してさらにデータを作成したりしなければならなかったので、裏で AWS Step Functions ステートマシンを使用します。AWS AppSync から Mutation でインポートデータをステートマシンに渡し、開始させる仕組みを作成しました。そこで AWS AppSync の HTTP リゾルバという設定を使いましたが、少し工夫が必要な面があったので紹介します。
つくったもの
主に、以下の図の 緑色の線 の部分を説明します。
AWS AppSync への命令は Mutation にしましたが、Query でも動くと思います。ただ、データを取得するのが目的ではなかったので Mutation にしました。
HTTP リゾルバから実行するアクションは states:StartExecution を使用します。AWS AppSync はステートマシンを開始させたら一旦役目は終了です。以降、結果は非同期に通知される仕様とします。※本記事では言及しません。
AWS AppSync には主にデータベースと接続するための専用リゾルバが用意されていますが、それ以外の AWS サービスを呼び出すには HTTP リゾルバを使用します。呼び出すのに必要な Sigv4 と呼ばれる署名プロセスも自動で実施してくれます。
以降、必要な設定を紹介します。
AWS AppSync スキーマ
例として、startImportJob という Mutation を作成し、data という string 型のデータをやり取りする簡単な設定にしています。
type ImportData { data: String } type Mutation { startImportJob(data: String!): ImportData } schema { query: Query mutation: Mutation subscription: Subscription }
AWS AppSync データソース
AWS Step Functions ステートマシンをデータソースと見立てて、設定します。タイプは HTTP にします。このデータソースに関連付ける IAM ロールを別途作成する必要があります。今回は、AWS AppSync が states:StartExecution を実行できる権限さえあれば良いです。
AWS AppSync リゾルバ
今回は JavaScript リゾルバを使用しています。
import { util } from '@aws-appsync/utils'; export function request(ctx) { return { method: 'POST', params: { headers: { 'Content-Type': 'application/x-amz-json-1.0', 'x-amz-target': 'AWSStepFunctions.StartExecution' }, body: { 'stateMachineArn': 'arn:aws:states:ap-northeast-1:231376435333:stateMachine:xxxxxxxxxxx', 'input': JSON.stringify({ "data": ctx.args.data, //アプリから渡された引数 "username": ctx.identity.claims.email //emailアドレスをIDトークンから取得している }) } }, resourcePath: '/' }; } export function response(ctx) { if (ctx.error) { util.error(ctx.error.message, ctx.error.type); } return { "data": JSON.stringify({ "statusCode": ctx.result.statusCode, //ステートマシンの実行結果 "result": JSON.parse(ctx.result.body) //結果メッセージ }) } }
ステートマシンに渡すパラメータを説明します。
headers の部分はこのままにします。
body.stateMachineArn には呼び出したいステートマシンの ARN を入れます。
body.input に入れたデータが、そのままステートマシンへの input になります。ただし、格納するデータは JSON.stringify で文字列化した JSON データに変換する必要があります。(VTL の場合、$util.toJson を使用します)
レスポンスについて説明します。
あらかじめ定義していた data (string 型) 変数を使用してアプリに結果を戻します。そのため、戻りのデータを JSON.stringify で文字列化しています。AWS Step Functions から戻ってきた結果は、ctx.result に入ります。エラーがあると、ctx.result.statusCode が 200 以外になります。エラーの内容は ctx.result.body に入っているのですが、これが JSON オブジェクトではなく文字列化された JSON で格納されているので、ctx.result.body を JSON.parse をかけて JSON オブジェクトとして読み取れる状態に変換しています。そうして statusCode とマージさせた JSON を stringify させて返す、という処理にしています。当初ここで結果を読み取ることができず、苦労しました。
この呼び出し方は、説明は雑でしたが以下の AWS ブログに書いてありました。
React
React アプリ側の GraphQL コードも書いておきます。
import { generateClient } from 'aws-amplify/api'; import gql from 'graphql-tag'; //create AppSync client const client = generateClient(); //インポートジョブ開始関数 const startImportJob = async (data) => { const mutateImportJob = gql` mutation startImportJob($data: String!) { startImportJob(data: $data) { data } } `; const res = await client.graphql({ query: mutateImportJob, variables: { data: data } }); return JSON.parse(res.data.startImportJob.data); //文字列化されたJSONが返されるのでparseしている }; export { startImportJob };
AWS CloudFormation テンプレート
抜粋ですが、関連する部分のテンプレートを貼っておきます。詳細な設定はこちらをご覧ください。AWS Step Functions は省略しています。検証した AWS AppSync が Amazon Cognito ユーザープールで認証するタイプになっているので、その記述が紛れ込んでいます。
AWSTemplateFormatVersion: 2010-09-09 Description: The CloudFormation template that creates an AppSync API to start a Step Functions state machine and relevant IAM roles. # ------------------------------------------------------------# # Input Parameters # ------------------------------------------------------------# Parameters: SubName: Type: String Description: System sub name that is used for all deployed resources. (e.g. prod or dev) Default: dev MaxLength: 10 MinLength: 1 Resources: # ------------------------------------------------------------# # AppSync # ------------------------------------------------------------# AppSyncApi: Type: AWS::AppSync::GraphQLApi Description: AppSync API for example Properties: Name: !Sub example-${SubName} AuthenticationType: AMAZON_COGNITO_USER_POOLS AdditionalAuthenticationProviders: - AuthenticationType: AWS_IAM UserPoolConfig: UserPoolId: xxxxx //CognitoのユーザープールID を入れる AwsRegion: !Sub ${AWS::Region} DefaultAction: "ALLOW" IntrospectionConfig: DISABLED LogConfig: CloudWatchLogsRoleArn: !GetAtt AppSyncCloudWatchLogsPushRole.Arn ExcludeVerboseContent: true FieldLogLevel: ALL Visibility: GLOBAL XrayEnabled: true Tags: - Key: Cost Value: !Sub example-${SubName} DependsOn: - AppSyncCloudWatchLogsPushRole AppSyncSchema: Type: AWS::AppSync::GraphQLSchema Properties: ApiId: !GetAtt AppSyncApi.ApiId Definition: | schema { query: Query mutation: Mutation subscription: Subscription } type ImportData { data: String } type Mutation { startImportJob(data: String!): ImportData } DependsOn: - AppSyncApi AppSyncDataSourceStateMachines: Type: AWS::AppSync::DataSource Properties: ApiId: !GetAtt AppSyncApi.ApiId Name: !Sub example${SubName}StateMachines Description: AppSync DataSource to call State Machines. Type: HTTP ServiceRoleArn: !GetAtt AppSyncStepFunctionsRole.Arn HttpConfig: Endpoint: !Sub https://states.${AWS::Region}.amazonaws.com/ AuthorizationConfig: AuthorizationType: AWS_IAM AwsIamConfig: SigningRegion: !Ref AWS::Region SigningServiceName: states DependsOn: - AppSyncApi - AppSyncStepFunctionsRole AppSyncResolverStartImportJob: Type: AWS::AppSync::Resolver Properties: ApiId: !GetAtt AppSyncApi.ApiId TypeName: Mutation FieldName: startImportJob DataSourceName: !GetAtt AppSyncDataSourceStateMachines.Name Kind: UNIT Runtime: Name: APPSYNC_JS RuntimeVersion: 1.0.0 Code: | import { util } from '@aws-appsync/utils'; export function request(ctx) { return { method: 'POST', params: { headers: { 'Content-Type': 'application/x-amz-json-1.0', 'x-amz-target': 'AWSStepFunctions.StartExecution' }, body: { 'stateMachineArn': 'arn:aws:states:ap-northeast-1:231376435333:stateMachine:example-xxxxx', 'input': JSON.stringify({ "data": ctx.args.data, "username": ctx.identity.claims.email }) } }, resourcePath: '/' }; } export function response(ctx) { if (ctx.error) { util.error(ctx.error.message, ctx.error.type); } return { "data": JSON.stringify({ "statusCode": ctx.result.statusCode, "result": JSON.parse(ctx.result.body) }) } } DependsOn: - AppSyncDataSourceStateMachines # ------------------------------------------------------------# # AppSync CloudWatch Invocation Role (IAM) # ------------------------------------------------------------# AppSyncCloudWatchLogsPushRole: Type: AWS::IAM::Role Properties: RoleName: !Sub example-AppSyncCloudWatchLogsPushRole-${SubName} Description: This role allows AppSync to push logs to CloudWatch Logs. AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: - appsync.amazonaws.com Action: - sts:AssumeRole Path: / ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSAppSyncPushToCloudWatchLogs # ------------------------------------------------------------# # AppSync Step Functions Invocation Role (IAM) # ------------------------------------------------------------# AppSyncStepFunctionsRole: Type: AWS::IAM::Role Properties: RoleName: !Sub example-AppSyncStepFunctionsRole-${SubName} Description: This role allows AppSync to invoke Step Functions. AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: - appsync.amazonaws.com Action: - sts:AssumeRole Path: / Policies: - PolicyName: !Sub example-AppSyncStepFunctionsPolicy-${SubName} PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - "states:StartExecution" Resource: - "arn:aws:states:ap-northeast-1:231376435333:stateMachine:example-xxxxx"
まとめ
いかがでしたでしょうか?
時間のかかる処理を AWS AppSync の裏側で AWS Step Functions を非同期実行させるのに活用できると思います。もちろん、AWS AppSync のタイムアウト範囲内で終わることが確実な処理であれば同期処理にしてしまってもよいでしょう。
本記事が皆様のお役に立てれば幸いです。