AWSが公開している「チュートリアル: WebSocket API、Lambda、DynamoDB を使用したサーバーレスチャットアプリケーションの構築」をAWS SAMで試す

## はじめに

[チュートリアル: WebSocket API、Lambda、DynamoDB を使用したサーバーレスチャットアプリケーションの構築](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/websocket-api-chat-app.html)

上記を使って、WebSocketを使ったシステムをAPI Gateway、SAM、DynamoDB、Lambdaで構築する基礎を学びたいと思います。

上記資料では、[CFnテンプレート](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/samples/ws-chat-app-starter.zip)が用意されているものの、API Gatewayはコンソールで作成されているので、以下の別サンプルコードも参照し、自分が理解しやすいテンプレートに作り替えます。

https://github.com/aws-samples/simple-websockets-chat-app/blob/master/template.yaml


## CFnテンプレートをSAMテンプレートに書き換える - DynamoDB
まずは一番シンプルなDynamoDBテーブル(ConnectionsTable8000B8A1)のみ移植します。

```template.yaml```を作成します。

```yaml: template.yaml
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Sample SAM Template for WebSocket
Resources:
  ConnectionsTable8000B8A1:
    Type: AWS::DynamoDB::Table
    Properties:
      KeySchema:
        - AttributeName: connectionId
          KeyType: HASH
      AttributeDefinitions:
        - AttributeName: connectionId
          AttributeType: S
      BillingMode: PAY_PER_REQUEST
    UpdateReplacePolicy: Delete
    DeletionPolicy: Delete
```

変更部分は以下を削除し
```
ProvisionedThroughput:
  ReadCapacityUnits: 5
  WriteCapacityUnits: 5
```
以下を追加しています。
```
BillingMode: PAY_PER_REQUEST
```
※ CloudWatchなど余計なリソースが作成されて、料金発生すると嫌なので


```
sam validate --lint
```
でチェックし問題なければdeployしてみます。

初回なので
```
sam deploy --guided
```
入力内容は
```
	Stack Name [sam-app]: sam-websocket        
	AWS Region [us-east-1]: 
	#Shows you resources changes to be deployed and require a 'Y' to initiate deploy
	Confirm changes before deploy [y/N]: 
	#SAM needs permission to be able to create roles to connect to the resources in your template
	Allow SAM CLI IAM role creation [Y/n]: 
	#Preserves the state of previously provisioned resources when an operation fails
	Disable rollback [y/N]: 
	Save arguments to configuration file [Y/n]: 
	SAM configuration file [samconfig.toml]: 
	SAM configuration environment [default]: 
```


## CFnテンプレートをSAMテンプレートに書き換える - Lambda(ConnectHandler)
次にLambda関数(ConnectHandler2FFD52D8)と関連するポリシー、ロールを追加します。


主な変更は
Typeを
```
Type: AWS::Lambda::Function
```
から
```
Type: AWS::Serverless::Function
```
にしています。
これはCFnとSAMの違いによるものです。

Runtimeは
```
Runtime: nodejs20.x
```
にしています。

またCodeは読みにくいのでテンプレートからファイルに独立させます。

```
Code:
  ZipFile: |-
  [以下略]
```
を削除し
```
CodeUri: connect/
```
とします。
```Code```はCFn、```CodeUri```はSAMの記述方法という違いもあります。

削除したコードは
connectディレクトリを作成し
index.jsに記載します。

connectディレクトリ内では
```
npm install aws-sdk
```
もしておきます。
aws-sdkは自力でinstallしなくても良い気がしたのですが、後ほどテスト時にNGだっとのinstallしています。


```js: index.js
const AWS = require('aws-sdk');
const ddb = new AWS.DynamoDB.DocumentClient();
exports.handler = async function (event, context) {
  try {
    await ddb
      .put({
        TableName: process.env.table,
        Item: {
          connectionId: event.requestContext.connectionId,
        },
      })
      .promise();
  } catch (err) {
    return {
      statusCode: 500,
    };
  }
  return {
    statusCode: 200,
  };
};

```
また
```
DependsOn:
  - ConnectHandlerServiceRoleDefaultPolicy7DE94863
  - ConnectHandlerServiceRole7E4A9B1F
```
は
```
sam validate --lint
```
すると警告が出ます。

```
W3005 Obsolete DependsOn on resource (ConnectHandlerServiceRole7E4A9B1F), dependency already enforced by a "Fn:GetAtt" at Resources/ConnectHandler2FFD52D8/Properties/Role/Fn::GetAtt
```

``` Fn::GetAtt ``` によって依存関係が示されているため不要とのことなので削除します。

これで警告が消えます。
PolicyやRoleは特に変更が不要だったのでそのまま移植しました。


一旦ここまででデプロイします。
```
sam deploy
```

## CFnテンプレートをSAMテンプレートに書き換える - Lambda(残り部分)
上記と同様の手順で
- DisconnectHandlerCB7ED6F7
- SendMessageHandlerDCEABF13
- DefaultHandler604DF7AC
関連のリソースを移植します。

```
sam validate --lint
```
で問題がなければ
```
sam deploy
```
します。

## ドキュメント上ではコンソールで作成される API Gateway + 関連リソースをSAMテンプレート化

冒頭にも書きましたが、今回参照したチュートリアルは API Gatewayをコンソールで作成しているので

以下の別のサンプルコードを元にテンプレートを作成しました。
https://github.com/aws-samples/simple-websockets-chat-app/blob/master/template.yaml

これでテンプレート作成は完了なので全体を掲載しておきます

```yaml: template.yaml
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Sample SAM Template for WebSocket
Resources:
  ConnectionsTable8000B8A1:
    Type: AWS::DynamoDB::Table
    Properties:
      KeySchema:
        - AttributeName: connectionId
          KeyType: HASH
      AttributeDefinitions:
        - AttributeName: connectionId
          AttributeType: S
      BillingMode: PAY_PER_REQUEST
    UpdateReplacePolicy: Delete
    DeletionPolicy: Delete
  ConnectHandlerServiceRole7E4A9B1F:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Action: sts:AssumeRole
            Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
        Version: "2012-10-17"
      ManagedPolicyArns:
        - Fn::Join:
            - ""
            - - "arn:"
              - Ref: AWS::Partition
              - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
  ConnectHandlerServiceRoleDefaultPolicy7DE94863:
    Type: AWS::IAM::Policy
    Properties:
      PolicyDocument:
        Statement:
          - Action:
              - dynamodb:BatchWriteItem
              - dynamodb:PutItem
              - dynamodb:UpdateItem
              - dynamodb:DeleteItem
              - dynamodb:DescribeTable
            Effect: Allow
            Resource:
              - Fn::GetAtt:
                  - ConnectionsTable8000B8A1
                  - Arn
              - Ref: AWS::NoValue
        Version: "2012-10-17"
      PolicyName: ConnectHandlerServiceRoleDefaultPolicy7DE94863
      Roles:
        - Ref: ConnectHandlerServiceRole7E4A9B1F
  ConnectHandler2FFD52D8:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: connect/
      Role:
        Fn::GetAtt:
          - ConnectHandlerServiceRole7E4A9B1F
          - Arn
      Environment:
        Variables:
          table:
            Ref: ConnectionsTable8000B8A1
      Handler: index.handler
      Runtime: nodejs20.x
  DisconnectHandlerServiceRoleE54F14F9:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Action: sts:AssumeRole
            Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
        Version: "2012-10-17"
      ManagedPolicyArns:
        - Fn::Join:
            - ""
            - - "arn:"
              - Ref: AWS::Partition
              - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
  DisconnectHandlerServiceRoleDefaultPolicy1800B9E5:
    Type: AWS::IAM::Policy
    Properties:
      PolicyDocument:
        Statement:
          - Action:
              - dynamodb:BatchWriteItem
              - dynamodb:PutItem
              - dynamodb:UpdateItem
              - dynamodb:DeleteItem
              - dynamodb:DescribeTable
            Effect: Allow
            Resource:
              - Fn::GetAtt:
                  - ConnectionsTable8000B8A1
                  - Arn
              - Ref: AWS::NoValue
        Version: "2012-10-17"
      PolicyName: DisconnectHandlerServiceRoleDefaultPolicy1800B9E5
      Roles:
        - Ref: DisconnectHandlerServiceRoleE54F14F9
  DisconnectHandlerCB7ED6F7:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: disconnect/
      Role:
        Fn::GetAtt:
          - DisconnectHandlerServiceRoleE54F14F9
          - Arn
      Environment:
        Variables:
          table:
            Ref: ConnectionsTable8000B8A1
      Handler: index.handler
      Runtime: nodejs20.x
  SendMessageHandlerServiceRole5F523417:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Action: sts:AssumeRole
            Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
        Version: "2012-10-17"
      ManagedPolicyArns:
        - Fn::Join:
            - ""
            - - "arn:"
              - Ref: AWS::Partition
              - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
  SendMessageHandlerServiceRoleDefaultPolicyF9D10585:
    Type: AWS::IAM::Policy
    Properties:
      PolicyDocument:
        Statement:
          - Action:
              - dynamodb:BatchGetItem
              - dynamodb:GetRecords
              - dynamodb:GetShardIterator
              - dynamodb:Query
              - dynamodb:GetItem
              - dynamodb:Scan
              - dynamodb:ConditionCheckItem
              - dynamodb:DescribeTable
            Effect: Allow
            Resource:
              - Fn::GetAtt:
                  - ConnectionsTable8000B8A1
                  - Arn
              - Ref: AWS::NoValue
        Version: "2012-10-17"
      PolicyName: SendMessageHandlerServiceRoleDefaultPolicyF9D10585
      Roles:
        - Ref: SendMessageHandlerServiceRole5F523417
  SendMessageHandlerDCEABF13:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: send-message/
      Role:
        Fn::GetAtt:
          - SendMessageHandlerServiceRole5F523417
          - Arn
      Environment:
        Variables:
          table:
            Ref: ConnectionsTable8000B8A1
      Handler: index.handler
      Runtime: nodejs20.x
  DefaultHandlerServiceRoleDF00569C:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Action: sts:AssumeRole
            Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
        Version: "2012-10-17"
      ManagedPolicyArns:
        - Fn::Join:
            - ""
            - - "arn:"
              - Ref: AWS::Partition
              - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
  DefaultHandlerServiceRoleDefaultPolicy2F57C32F:
    Type: AWS::IAM::Policy
    Properties:
      PolicyDocument:
        Statement:
          - Action: execute-api:ManageConnections
            Effect: Allow
            Resource:
              Fn::Join:
                - ""
                - - "arn:aws:execute-api:"
                  - Ref: AWS::Region
                  - ":"
                  - Ref: AWS::AccountId
                  - ":"
                  - "*/*/POST/@connections/*"
          - Action: execute-api:ManageConnections
            Effect: Allow
            Resource:
              Fn::Join:
                - ""
                - - "arn:aws:execute-api:"
                  - Ref: AWS::Region
                  - ":"
                  - Ref: AWS::AccountId
                  - ":"
                  - "*/*/GET/@connections/*"
        Version: "2012-10-17"
      PolicyName: DefaultHandlerServiceRoleDefaultPolicy2F57C32F
      Roles:
        - Ref: DefaultHandlerServiceRoleDF00569C
  DefaultHandler604DF7AC:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: default/
      Role:
        Fn::GetAtt:
          - DefaultHandlerServiceRoleDF00569C
          - Arn
      Handler: index.handler
      Runtime: nodejs20.x
  manageConnections7F91357B:
    Type: AWS::IAM::Policy
    Properties:
      PolicyDocument:
        Statement:
          - Action: execute-api:ManageConnections
            Effect: Allow
            Resource:
              Fn::Join:
                - ""
                - - "arn:aws:execute-api:"
                  - Ref: AWS::Region
                  - ":"
                  - Ref: AWS::AccountId
                  - ":"
                  - "*/*/POST/@connections/*"
        Version: "2012-10-17"
      PolicyName: manageConnections7F91357B
      Roles:
        - Ref: SendMessageHandlerServiceRole5F523417
  WebsocketChatAppTutorial:
    Type: AWS::ApiGatewayV2::Api
    Properties:
      Name: websocket-chat-app-tutorial
      ProtocolType: WEBSOCKET
      RouteSelectionExpression: "$request.body.action"
  ConnectRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref WebsocketChatAppTutorial
      RouteKey: $connect
      AuthorizationType: NONE
      OperationName: ConnectRoute
      Target: !Join
        - "/"
        - - "integrations"
          - !Ref ConnectInteg
  ConnectInteg:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref WebsocketChatAppTutorial
      Description: Connect Integration
      IntegrationType: AWS_PROXY
      IntegrationUri:
        Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${ConnectHandler2FFD52D8.Arn}/invocations
  ConnectPermission:
    Type: AWS::Lambda::Permission
    DependsOn:
      - WebsocketChatAppTutorial
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref ConnectHandler2FFD52D8
      Principal: apigateway.amazonaws.com
  DisconnectRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref WebsocketChatAppTutorial
      RouteKey: $disconnect
      AuthorizationType: NONE
      OperationName: DisconnectRoute
      Target: !Join
        - "/"
        - - "integrations"
          - !Ref DisconnectInteg
  DisconnectInteg:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref WebsocketChatAppTutorial
      Description: Disconnect Integration
      IntegrationType: AWS_PROXY
      IntegrationUri:
        Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${DisconnectHandlerCB7ED6F7.Arn}/invocations
  DisconnectPermission:
    Type: AWS::Lambda::Permission
    DependsOn:
      - WebsocketChatAppTutorial
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref DisconnectHandlerCB7ED6F7
      Principal: apigateway.amazonaws.com
  SendRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref WebsocketChatAppTutorial
      RouteKey: sendmessage
      AuthorizationType: NONE
      OperationName: SendRoute
      Target: !Join
        - "/"
        - - "integrations"
          - !Ref SendInteg
  SendInteg:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref WebsocketChatAppTutorial
      Description: Send Integration
      IntegrationType: AWS_PROXY
      IntegrationUri:
        Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${SendMessageHandlerDCEABF13.Arn}/invocations
  SendMessagePermission:
    Type: AWS::Lambda::Permission
    DependsOn:
      - WebsocketChatAppTutorial
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref SendMessageHandlerDCEABF13
      Principal: apigateway.amazonaws.com
  DefaultRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref WebsocketChatAppTutorial
      RouteKey: $default
      AuthorizationType: NONE
      OperationName: DefaultRoute
      Target: !Join
        - "/"
        - - "integrations"
          - !Ref DefaultInteg
  DefaultInteg:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref WebsocketChatAppTutorial
      Description: Default Integration
      IntegrationType: AWS_PROXY
      IntegrationUri:
        Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${DefaultHandler604DF7AC.Arn}/invocations
  DefaultPermission:
    Type: AWS::Lambda::Permission
    DependsOn:
      - WebsocketChatAppTutorial
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref DefaultHandler604DF7AC
      Principal: apigateway.amazonaws.com
  Deployment:
    Type: AWS::ApiGatewayV2::Deployment
    DependsOn:
      - ConnectRoute
      - SendRoute
      - DisconnectRoute
      - DefaultRoute
    Properties:
      ApiId: !Ref WebsocketChatAppTutorial
  Stage:
    Type: AWS::ApiGatewayV2::Stage
    Properties:
      StageName: production
      Description: Prod Stage
      DeploymentId: !Ref Deployment
      ApiId: !Ref WebsocketChatAppTutorial
Outputs:
  WebSocketURI:
    Description: "The WSS Protocol URI to connect to"
    Value:
      !Join [
        "",
        [
          "wss://",
          !Ref WebsocketChatAppTutorial,
          ".execute-api.",
          !Ref "AWS::Region",
          ".amazonaws.com/",
          !Ref "Stage",
        ],
      ]
```

API Gateway部分は、結構試行錯誤があり、必ずしもチュートリアルの再現にはなっていません。
一応、```sam delete``` でスタックを削除してから再度作成したもので動作確認していますが不備があればご指摘いただければ幸いです。

## テスト

[wscat を使用した WebSocket API への接続とメッセージの送信](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-how-to-call-websocket-api-wscat.html)

上記も参照にしつつ、wscatを使いチュートリアルに記載されているテストを一通り実行します。

## 最後に
冒頭にも書きましたが、上記は本番での動作を想定してない学習用のコードです。
API Gateway + WebSocketの理解が浅いため間違いや改善点が多いかと思いますが、よろしければコメント欄にてご指摘いただけると大変助かります。

~~実際にやり切れるかはまだ未知数ですが、以後このプロトタイプを足がかりに、Discord Gateway API を Serverless実装で使用できないか試す予定です。~~
2024.06.17修正
→ API GatewayはWebSocketのクライアント側でサーバーとの接続を維持するような振る舞いはしないようで、DiscordのGateway APIを試すのは見送りました。

その過程で、今回のコードも可能な限り間違いや改善可能な点は修正できればと思っています。

参考までに、 Gateway API なしのシステム構成は以下にありますので、ご興味ありましたらご一読ください。
[DiscordアプリをAWS SAMで作った話(概要・システム構成編)](https://qiita.com/robo20240430/items/5af426c2fe9e426ee9ba)