Lambda無しでGraphQLのAPIを作ろう!!AppSyncをAWSサービスのプロキシとして利用してみる

Lambdaなしでも結構頑張れちゃうんです
2020.04.08

先日のブログでAppSyncのHTTPデータソースとしてStep Functionsを利用する構成をご紹介しました。AppSyncのデータソースとしてネイティブに対応していないサービスであっても、HTTPデータソースを介してAppSyncと連携させることが可能です。

この例のようにAppSyncをAWSサービスのプロキシとして構成することで、CRUD操作のような単純なオペレーションに関してはわざわざLamdbaを使わなくてもAPIを実装することが可能です。AWSとしてもre:inventのセッション等で「LambdaはTransformのために使い、Transportに使わない」ことがベストプラクティスだと発信しており、Lambda無しで実現できることは極力Lambdaを使わないように設定で吸収していきたいところです。ただ、API Gatewayをサービスプロキシとして構成するパターンは色々と情報が見つかるのですが、AppSyncに関してはまだまだ情報が少ない印象です。

ということで、このエントリではサーバーレスなシステム開発で利用頻度の高いAWSサービス&オペレーションの

  • KinesisのPutRecord
  • S3のPutObject
  • SQSのSendMessage
  • SNSのPublish

に関してAppSyncをサービスプロキシとして構成する方法をご紹介します。

CFnテンプレート

まずCFnのテンプレートをご紹介します。このテンプレートを使えば、簡単なサンプルが作成可能です。

AWSTemplateFormatVersion: '2010-09-09'
Description: AppSync Service Proxy Sample
Resources:
  ProxyApi:
    Type: AWS::AppSync::GraphQLApi
    Properties:
      AuthenticationType: API_KEY
      Name: AppSync Service Proxy API
  ProxyApiKey:
    Type: AWS::AppSync::ApiKey
    Properties:
      ApiId: !GetAtt ProxyApi.ApiId      
  MessagesSchema:
    Type: AWS::AppSync::GraphQLSchema
    Properties:
      ApiId: !GetAtt ProxyApi.ApiId
      DefinitionS3Location: schema.graphql
  AppSyncServiceRole:
    Type: AWS::IAM::Role
    Properties:
      Path: /
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Action:
              - sts:AssumeRole
            Principal:
              Service:
                - appsync.amazonaws.com
      Policies:
        - PolicyName: proxy-policy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - s3:PutObject
                  - sqs:SendMessage
                  - kinesis:PutRecord
                  - sns:Publish
                Resource: "*"      
  S3Bucket:
    Type: AWS::S3::Bucket
  S3PutObjectDs:
    Type: AWS::AppSync::DataSource
    Properties:
      ApiId: !GetAtt ProxyApi.ApiId
      Name: S3PutObjDataSource
      Type: HTTP
      ServiceRoleArn: !GetAtt AppSyncServiceRole.Arn
      HttpConfig:
        Endpoint: !Sub https://${S3Bucket}.s3.${AWS::Region}.amazonaws.com/
        AuthorizationConfig:
          AuthorizationType: AWS_IAM
          AwsIamConfig:
            SigningRegion: !Ref AWS::Region
            SigningServiceName: s3
  S3PutObjectMutationResolver:
    Type: AWS::AppSync::Resolver
    Properties:
      ApiId: !GetAtt ProxyApi.ApiId
      TypeName: Mutation
      FieldName: putObject
      DataSourceName: !GetAtt S3PutObjectDs.Name
      RequestMappingTemplate: |
        {
          "version": "2018-05-29",
          "method": "PUT",
          "resourcePath": "/test.json",
          "params": {
            "headers": {
              "Content-Type": "application/json"
            },
            "body": $utils.toJson($ctx.arguments.data)
          }
        }
      ResponseMappingTemplate: |
        #if($ctx.result.statusCode == 200)
            $utils.toJson($ctx.result.headers)
        #else
            $utils.appendError($ctx.result.body, "$ctx.result.statusCode")
        #end          
  SQSQueue:
    Type: AWS::SQS::Queue            
  SQSSendMsgDs:
    Type: AWS::AppSync::DataSource
    Properties:
      ApiId: !GetAtt ProxyApi.ApiId
      Name: SQSSendMessageDataSource
      Type: HTTP
      ServiceRoleArn: !GetAtt AppSyncServiceRole.Arn
      HttpConfig:
        Endpoint: !Sub https://${AWS::Region}.queue.amazonaws.com/
        AuthorizationConfig:
          AuthorizationType: AWS_IAM
          AwsIamConfig:
            SigningRegion: !Ref AWS::Region
            SigningServiceName: sqs
  SQSSendMsgMutationResolver:
    Type: AWS::AppSync::Resolver
    Properties:
      ApiId: !GetAtt ProxyApi.ApiId
      TypeName: Mutation
      FieldName: sendMessage
      DataSourceName: !GetAtt SQSSendMsgDs.Name
      RequestMappingTemplate: !Sub |
        #set($action = "Action=SendMessage")
        #set($version = "Version=2012-11-05")
        #set($queueurl = "QueueUrl=${SQSQueue}")
        #set($msgbody = "MessageBody=$ctx.arguments.data")

        {
          "version": "2018-05-29",
          "method": "POST",
          "resourcePath": "/",
          "params": {
            "headers": {
              "Content-Type": "application/x-www-form-urlencoded"
            },	
            "body": "$action&$version&$queueurl&$msgbody"
          }
        }
      ResponseMappingTemplate: |
        #if($ctx.result.statusCode == 200)
            $utils.xml.toJsonString($ctx.result.body)
        #else
            $utils.appendError($utils.xml.toJsonString($ctx.result.body), "$ctx.result.statusCode")
        #end
  Kinesis:
    Type: AWS::Kinesis::Stream
    Properties: 
      ShardCount: 1
  KinesisPutRecDs:
    Type: AWS::AppSync::DataSource
    Properties:
      ApiId: !GetAtt ProxyApi.ApiId
      Name: KinesisPutRecordDataSource
      Type: HTTP
      ServiceRoleArn: !GetAtt AppSyncServiceRole.Arn
      HttpConfig:
        Endpoint: !Sub https://kinesis.${AWS::Region}.amazonaws.com/
        AuthorizationConfig:
          AuthorizationType: AWS_IAM
          AwsIamConfig:
            SigningRegion: !Ref AWS::Region
            SigningServiceName: kinesis
  KinesisPutRecMutationResolver:
    Type: AWS::AppSync::Resolver
    Properties:
      ApiId: !GetAtt ProxyApi.ApiId
      TypeName: Mutation
      FieldName: putRecord
      DataSourceName: !GetAtt KinesisPutRecDs.Name
      RequestMappingTemplate: !Sub |
        {
          "version": "2018-05-29",
          "method": "POST",
          "resourcePath": "/",
          "params": {
            "headers": {
              "X-Amz-Target": "Kinesis_20131202.PutRecord",
              "Content-Type": "application/x-amz-json-1.1"
            },	
            "body": {
              "StreamName": "${Kinesis}",
              "Data": "$util.base64Encode($ctx.arguments.data)",
              "PartitionKey": "$util.autoId()"
            }
          }
        }
      ResponseMappingTemplate: |
        #if($ctx.result.statusCode == 200)
            $util.toJson($ctx.result.body)
        #else
            $utils.appendError($util.toJson($ctx.result.body), "$ctx.result.statusCode")
        #end
  SNSTopic:
    Type: AWS::SNS::Topic
  SNSPublishDs:
    Type: AWS::AppSync::DataSource
    Properties:
      ApiId: !GetAtt ProxyApi.ApiId
      Name: SNSPublishDataSource
      Type: HTTP
      ServiceRoleArn: !GetAtt AppSyncServiceRole.Arn
      HttpConfig:
        Endpoint: !Sub https://sns.${AWS::Region}.amazonaws.com/
        AuthorizationConfig:
          AuthorizationType: AWS_IAM
          AwsIamConfig:
            SigningRegion: !Ref AWS::Region
            SigningServiceName: sns
  SNSPublishMutationResolver:
    Type: AWS::AppSync::Resolver
    Properties:
      ApiId: !GetAtt ProxyApi.ApiId
      TypeName: Mutation
      FieldName: publish
      DataSourceName: !GetAtt SNSPublishDs.Name
      RequestMappingTemplate: !Sub |
        #set($action = "Action=Publish")
        #set($version = "Version=2010-03-31")
        #set($topicarn = "TopicArn=${SNSTopic}")
        #set($msg = "Message=$ctx.arguments.data")

        {
          "version": "2018-05-29",
          "method": "POST",
          "resourcePath": "/",
          "params": {
            "headers": {
              "Content-Type": "application/x-www-form-urlencoded"
            },	
            "body": "$action&$version&$topicarn&$msg"
          }
        }
      ResponseMappingTemplate: |
        #if($ctx.result.statusCode == 200)
            $utils.xml.toJsonString($ctx.result.body)
        #else
            $utils.appendError($utils.xml.toJsonString($ctx.result.body), "$ctx.result.statusCode")
        #end            

GraphQLのスキーマです

schema.graphql

input PutObj {
	attr1: String
	attr2: String
}

type Query {
	search(text: String): String
}

type Mutation {
	putObject(data: PutObj): String
	sendMessage(data: String): String
	putRecord(data: String): String
	publish(data: String): String
}

schema {
	query: Query
	mutation: Mutation
}

S3のPutObjectについてはattr1,attr2というキーに持つオブジェクトを、その他のオペレーションは全てString型のデータを引数に受け取って、String型のデータを返却する仕様にしています。実案件で利用する際は適宜typeを定義するようにして下さい。

※searchというクエリはダミーで置いてるだけなので無視して下さい。

このテンプレートをpackage & deployすれば環境構築完了です。

$aws cloudformation package --template-file template.yml --s3-bucket <適当なS3バケット> --output-template-file output.yml
$ aws cloudformation deploy --template-file output.yml  --stack-name <適当なスタック名> --capabil
ities CAPABILITY_AUTO_EXPAND CAPABILITY_IAM

ここからは各サービス&オペレーションに指定するテンプレートの詳細を解説していきます。

KinesisのPutRecord

まずKinesisのPutRecordです。HTTPデータソースは以下のように指定しています。

KinesisPutRecDs:
    Type: AWS::AppSync::DataSource
    Properties:
      ApiId: !GetAtt ProxyApi.ApiId
      Name: KinesisPutRecordDataSource
      Type: HTTP
      ServiceRoleArn: !GetAtt AppSyncServiceRole.Arn
      HttpConfig:
        Endpoint: !Sub https://kinesis.${AWS::Region}.amazonaws.com/
        AuthorizationConfig:
          AuthorizationType: AWS_IAM
          AwsIamConfig:
            SigningRegion: !Ref AWS::Region
            SigningServiceName: kinesis

こちらはあまり特記事項はありません。AuthorizationConfigを指定し、AppSyncからエンドポイントにリクエストを発行する際にServiceRoleArnで指定したIAMロールを使ってSIGv4の署名を付与するように設定しています。ServiceRoleArnで指定するIAMロールはテンプレート内で作成しており、今回使用する4つのサービス&オペレーションに必要な権限を付与しています。

リクエストマッピングテンプレートは以下の通りです

RequestMappingTemplate: !Sub |
        {
          "version": "2018-05-29",
          "method": "POST",
          "resourcePath": "/",
          "params": {
            "headers": {
              "X-Amz-Target": "Kinesis_20131202.PutRecord",
              "Content-Type": "application/x-amz-json-1.1"
            },	
            "body": {
              "StreamName": "${Kinesis}",
              "Data": "$util.base64Encode($ctx.arguments.data)",
              "PartitionKey": "$util.autoId()"
            }
          }
        }

AppSyncからエンドポイントにリクエストを発行する際のHTTPヘッダーとしてX-Amz-TargetKinesis_20131202.PutRecordを指定することで、実行するオペレーション=PutRecordを指定します。

ホディにはPutRecordに必要なパラメータである

  • StreamName
  • Data
  • PartitionKey

を指定しています。StreamNameは!Sub ${Kinesis}と指定することで、テンプレート内で作成したKinesisのストリーム名を参照しています。Dataは$util.base64Encode($ctx.arguments.data)を指定し、ミューテーションの引数に渡されたdataをbase64でエンコードした文字列を設定、PartitionKeyに関しては、$util.autoIdでUUIDを採番して利用しています。本番利用する際は、何かしらのコンテキスト情報から設定するのが良いでしょう。

やってみる

実際にAppSyncのコンソールからミューテーションを実行してみましょう

mutation putRecord{
	putRecord(data: "hoge")
}

OKです

S3のPutObject

続いてS3のPutObjectです。HTTPデータソースは以下のように指定しています。

S3PutObjectDs:
    Type: AWS::AppSync::DataSource
    Properties:
      ApiId: !GetAtt ProxyApi.ApiId
      Name: S3PutObjDataSource
      Type: HTTP
      ServiceRoleArn: !GetAtt AppSyncServiceRole.Arn
      HttpConfig:
        Endpoint: !Sub https://${S3Bucket}.s3.${AWS::Region}.amazonaws.com/
        AuthorizationConfig:
          AuthorizationType: AWS_IAM
          AwsIamConfig:
            SigningRegion: !Ref AWS::Region
            SigningServiceName: s3

S3のPutObjectはAPIはエンドポイントにバケット名を含めたURLを指定する必要があるためEndpoint: !Sub https://${S3Bucket}.s3.${AWS::Region}.amazonaws.com/としてテンプレート内で作成したバケット名を参照させています。

リクエストマッピングテンプレートは以下の通りです

RequestMappingTemplate: |
        {
          "version": "2018-05-29",
          "method": "PUT",
          "resourcePath": "/test.json",
          "params": {
            "headers": {
              "Content-Type": "application/json"
            },
            "body": $utils.toJson($ctx.arguments.data)
          }
        }

resourcePathに/test.jsonという固定値を設定しています。S3のAPIはPUT時に指定されたパスがオブジェクトキーになるので、この設定であればtest.jsonというオブジェクトキーでJSONファイルがアップロードされることになります。実際に使用する際ははミューテーションに渡された入力値や$context.identityからAPI実行ユーザーの情報(ユーザーID等)を取得してresourcePathに反映すると良いでしょう。今回はPutObjectでJSONファイルをアップロードできるようにしたかったので、HTTPヘッダのContent-Typeは固定でapplication/jsonとし、HTTPボディは$utils.toJson($ctx.arguments.data)でミューテーションの引数に渡されたdataをJSONに変換してPUTするようにしています。

レスポンスマッピングテンプレートです。

ResponseMappingTemplate: |
        #if($ctx.result.statusCode == 200)
            $utils.toJson($ctx.result.headers)
        #else
            $utils.appendError($ctx.result.body, "$ctx.result.statusCode")
        #end  

正常終了のときにレスポンスボディが空で返ってくるので、代わりにレスポンスヘッダをJSONにして返却するようにしています。

やってみる

AppSyncのコンソールからミューテーションを実行してみます

mutation putObject{
	putObject(data: {
  	attr1: "hoge"
    attr2: "fuga"
  })
}

OKです

SQSのSendMessage

続いてSQSのSendMessageです。HTTPデータソースは以下のように指定しています。

SQSSendMsgDs:
    Type: AWS::AppSync::DataSource
    Properties:
      ApiId: !GetAtt ProxyApi.ApiId
      Name: SQSSendMessageDataSource
      Type: HTTP
      ServiceRoleArn: !GetAtt AppSyncServiceRole.Arn
      HttpConfig:
        Endpoint: !Sub https://${AWS::Region}.queue.amazonaws.com/
        AuthorizationConfig:
          AuthorizationType: AWS_IAM
          AwsIamConfig:
            SigningRegion: !Ref AWS::Region
            SigningServiceName: sqs

こちらは特に特記事項はありません。リクエストマッピングテンプレートは以下の通りです

RequestMappingTemplate: !Sub |
        #set($action = "Action=SendMessage")
        #set($version = "Version=2012-11-05")
        #set($queueurl = "QueueUrl=${SQSQueue}")
        #set($msgbody = "MessageBody=$ctx.arguments.data")

        {
          "version": "2018-05-29",
          "method": "POST",
          "resourcePath": "/",
          "params": {
            "headers": {
              "Content-Type": "application/x-www-form-urlencoded"
            },	
            "body": "$action&$version&$queueurl&$msgbody"
          }
        }

SQSのAPIでPOSTリクエストを使用する場合はクエリパラメーターをボディに設定する必要があります。

POST リクエストの作成

リクエストマッピングテンプレート内で

#set($action = "Action=SendMessage")
#set($version = "Version=2012-11-05")
#set($queueurl = "QueueUrl=${SQSQueue}")
#set($msgbody = "MessageBody=$ctx.arguments.data")
...略
"body": "$action&$version&$queueurl&$msgbody"

と指定することで、ミューテーションの引数dataに渡された文字列から最終的にリクエストボディに設定する

Action=SendMessage&Version=2012-11-05&QueueUrl=<キューのURL>&MessageBody=<メッセージ>

という文字列を生成しています。さらに、レスポンスのマッピングテンプレートを以下のように設定しています。

ResponseMappingTemplate: |
        #if($ctx.result.statusCode == 200)
            $utils.xml.toJsonString($ctx.result.body)
        #else
            $utils.appendError($utils.xml.toJsonString($ctx.result.body), "$ctx.result.statusCode")
        #end

SQSのAPIから返却されるレスポンスがXML形式になっているため、$utils.xml.toJsonString($ctx.result.body)を使ってJSONに変換しています。

やってみる

AppSyncのコンソールからミューテーションを実行してみます

mutation sendMessage{
	sendMessage(data: "hoge")
}

OKです

SNSのPublish

最後にSNSのPublishです。HTTPデータソースは以下のように指定しています。

SNSPublishDs:
    Type: AWS::AppSync::DataSource
    Properties:
      ApiId: !GetAtt ProxyApi.ApiId
      Name: SNSPublishDataSource
      Type: HTTP
      ServiceRoleArn: !GetAtt AppSyncServiceRole.Arn
      HttpConfig:
        Endpoint: !Sub https://sns.${AWS::Region}.amazonaws.com/
        AuthorizationConfig:
          AuthorizationType: AWS_IAM
          AwsIamConfig:
            SigningRegion: !Ref AWS::Region
            SigningServiceName: sns

こちらは特に特記事項はありません。

リクエストマッピングテンプレートは以下の通りです

#set($action = "Action=Publish")
        #set($version = "Version=2010-03-31")
        #set($topicarn = "TopicArn=${SNSTopic}")
        #set($msg = "Message=$ctx.arguments.data")

        {
          "version": "2018-05-29",
          "method": "POST",
          "resourcePath": "/",
          "params": {
            "headers": {
              "Content-Type": "application/x-www-form-urlencoded"
            },	
            "body": "$action&$version&$topicarn&$msg"
          }
        }

多少パラメータ名など違いますが、考え方はSQSのSendMessageと同じですね。SNSもSQSと同様レスポンスがXML形式になるため、レスポンスのマッピングテンプレートにSQSと同一の内容を設定しています。

やってみる

AppSyncのコンソールからミューテーションを実行してみます

mutation publish{
	publish(data: "hoge")
}

OKです

マッピングテンプレートを書くためのコツ

ここまで各サービスのマッピングテンプレートを見てきましたが、それぞれ微妙にAPIの仕様が異なることがお分かり頂けたかと思います。例えばKinesisの場合はリクエストヘッダのX-Amz-Targetを使ってアクションをSQSやSNSはリクエストボディで指定する といった具合です。では、別のサービスもしくは別のオペレーションに対してAppSyncをサービスプロキシとして構成したい場合はどのようにマッピングテンプレートを書いていけば良いのでしょうか?各サービスのAPI仕様書を漁っても良いのですが、個人的にオススメなのはAWS CLIの-debugオプションです。AWS CLI実行時に--debugオプションを指定することで、実際に発行されているリクエストの中身を確認することができます。AWS CLIからリクエストの構造の当たりを付けてPostmanから動作確認、動作が確認できたらテンプレートに落とし込んでいくという流れがオススメです。

例としてSQSのSendMessageについて考えてみましょう。まずはAWS CLIを--debug付きで実行します。

$aws sqs send-message --message-body hoge --queue-url <SQSのエンドポイント>  --debug

デバッグ出力が流れてくるので、Making request for...と表示されている部分を探してHTTPボディとヘッダの中身を確認します。 ※見やすいように少し加工しています

- MainThread - botocore.endpoint - DEBUG - Making request for OperationModel(name=SendMessage) with params: 
{
  'url_path': '/',
  'query_string': '',
  'method': 'POST',
  'headers': {
    'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
    'User-Agent': 'aws-cli/1.18.36 Python/3.6.5 Darwin/18.7.0 botocore/1.15.36'
   },
   'body': {
     'Action': 'SendMessage',
     'Version': '2012-11-05',
     'QueueUrl': 'https://sqs.ap-northeast-1.amazonaws.com/123456789012/AppSyncServiceProxy-SQSQueue-1169XBJXHNBYX',
     'MessageBody': 'hoge'
     },
  'url': 'https://ap-northeast-1.queue.amazonaws.com/',
  'context': {'client_region': 'ap-northeast-1',
  'client_config': <botocore.config.Config object at 0x10ea617f0>,
  'has_streaming_input': False,
  'auth_type': None}
}

リクエストの構造が確認できたらPostmanからリクエストを試してみます。Authorizationの設定から適宜必要事項を入力します。

続いてAWS CLIのデバッグ出力から確認した内容をもとにリクエストヘッダとリクエストボディを埋めていきます。一通り埋め終わったら、実際にAPIを実行してみてエンドポイントから正常応答が返却されるかを確認しましょう。

正常応答が返ってくればHTTPヘッダーとボディが正しく構成できているということです。ここまで確認できたらPostmanのCodeリンクをクリックします。

左側のペインでHTTPを選択することで生のHTTPリクエストが確認できるので、リクエストマッピングテンプレートの解決結果 = 生のHTTPリクエスト となるようにテンプレートを編集していきましょう。

まとめ

AppSyncをAWSサービスのプロキシとして構成する方法についてご紹介しました。簡単な権限チェックや固定値設定、加工程度であればLambdaを挟まなくてもリクエストマッピングテンプレートだけで対応することができるので、うまく活用して開発を高速化していきたいですね!