サーバレスwebアプリをLambdaでホストする

# はじめに
今更ながらGPTを使っている。GPTを使うとLambda(Node.js)をつかって、簡単にフロントエンド部分が書ける。フロントよくわからないので非常に良い。今回はLambda、APIGateway、DynamoDBを使ってCloudFormationでテンプレート化してみる。


## 今回やること
- CloudFormationでテンプレート化する（SAMは使わない）
- 画像はS3からロードさせる
- WebページDynamoDBの表示、DBへの入力欄を持つ

作ったもの

https://github.com/bd8z/serverless-frontend.git

### テンプレート化の対象
- テンプレート化の対象。閉域用途のためVPCエンドポイント経由で。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/54100/d536c415-718d-fad2-bb1c-154574a4df58.png)


- Lambdaに乗せるindex.mjs
（画像付き＋DynamoDB機能付き･･･基礎はGTP、画像はdale3で作った）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/54100/f03dda0d-33e0-584e-e77a-ea0266aa6031.png)

### Clodformationテンプレート
vpcエンドポイント経由でAPI Gatewayへアクセスさせる部分はコメントアウトした。
```yaml:template.yaml
AWSTemplateFormatVersion: "2010-09-09"

Parameters:
  UUID:
    Description: UUID of stack items
    Type: String
  APIPath:
    Type: String
    Default: TrialPath
  PartitionKey:
    Type: String
    Default: pipelineID
  # vpcEndpoint:
  #   Description: vpce
  #   Type: String
  #   Default: vpce-xxxxxxxx

Resources:
  MyTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub GitReuslt-${UUID}
      AttributeDefinitions:
        - AttributeName: !Ref PartitionKey
          AttributeType: S
      KeySchema:
        - AttributeName: !Ref PartitionKey
          KeyType: HASH
      BillingMode: PROVISIONED
      ProvisionedThroughput:
        ReadCapacityUnits: 1
        WriteCapacityUnits: 1
  RestAPI:
    Type: AWS::ApiGateway::RestApi
    Properties:
      EndpointConfiguration:
        Types:
          - REGIONAL
          # - PRIVAETE
      Name: !Sub "frontend-${UUID}-API"
      # Policy: {
      #   "Version": "2012-10-17",
      #   "Statement": [
      #       {
      #           "Effect": "Allow",
      #           "Principal": "*",
      #           "Action": "execute-api:Invoke",
      #           "Resource": [
      #               "execute-api:/*"
      #           ]
      #       },
      #       {
      #           "Effect": "Deny",
      #           "Principal": "*",
      #           "Action": "execute-api:Invoke",
      #           "Resource": [
      #               "execute-api:/*"
      #           ],
      #           "Condition" : {
      #               "StringNotEquals": {
      #                   "aws:SourceVpce": !Sub "${vpcEndpoint}"
      #               }
      #           }
      #       }
      #   ]
      # }

  APIResource:
    Type: "AWS::ApiGateway::Resource"
    Properties:
      RestApiId: !Ref RestAPI
      ParentId: !GetAtt RestAPI.RootResourceId  
      PathPart: !Ref APIPath
  APIPermission:
    Type: "AWS::Lambda::Permission"
    Properties:
      FunctionName:  !Ref MyLambdaFunction
      Action: "lambda:InvokeFunction"
      Principal: apigateway.amazonaws.com
  APIMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref RestAPI
      ResourceId: !Ref APIResource
      HttpMethod: ANY
      AuthorizationType: NONE
      Integration:
        Type: AWS_PROXY
        IntegrationHttpMethod: "POST"
        Uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${MyLambdaFunction.Arn}/invocations"
    DependsOn: APIPermission
  APIDeployment:
    Type: AWS::ApiGateway::Deployment
    Properties:
      RestApiId: !Ref RestAPI
    DependsOn: APIMethod
  APIStage:
    Type: AWS::ApiGateway::Stage
    Properties:
      RestApiId: !Ref RestAPI
      StageName: prod
      Description: prod stage
      DeploymentId: !Ref APIDeployment
  LambdaServiceRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Principal:
            Service: lambda.amazonaws.com
          Action: sts:AssumeRole
      Policies:
        - PolicyName: InlinePolicy
          PolicyDocument: 
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: iam:PassRole
                Resource: '*'
                
      ManagedPolicyArns:
      - arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess
      - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      - arn:aws:iam::aws:policy/AmazonS3FullAccess
      RoleName: !Sub "LambdaServiceRole-${UUID}"

  MyLambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub "App-Lambda-${UUID}"
      Handler: index.handler
      Role: !GetAtt LambdaServiceRole.Arn
      Runtime: nodejs18.x
      Timeout: 300
      Environment:
        Variables:
          APIPATH: !Sub prod/${APIPath}
          APIURL: !Sub https://${RestAPI}.execute-api.${AWS::Region}.amazonaws.com/
          BUCKETNAME01: images-for-serverless-page
          DYNAMODB_TALBENAME: !Ref MyTable
          IMAGE_KEY01: background.png
          PARTITION_KEY: !Ref PartitionKey

      Code:
        ZipFile: |
          const { DynamoDBClient, PutItemCommand, ScanCommand } = require("@aws-sdk/client-dynamodb")
          const { GetObjectCommand, S3Client } = require ("@aws-sdk/client-s3");
          const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");

          const client = new DynamoDBClient({ region: "ap-northeast-1" });
          const s3Client = new S3Client({ region: "ap-northeast-1" });
          const TABLE_NAME = process.env.DYNAMODB_TALBENAME
          const URL = process.env.APIURL
          const apiPath = process.env.APIPATH
          const bucketName = process.env.BUCKETNAME01
          const imageKey = process.env.IMAGE_KEY01
          const partitionKey = process.env.PARTITION_KEY

          exports.handler = async (event) => {
              switch (event.httpMethod) {
                  case "GET":
                      return getItems();
                  case "POST":
                      return addItem(event);
                  default:
                      return {
                          statusCode: 400,
                          headers: {
                              "Content-Type": "text/html",
                          },
                          body: "<p>Invalid method</p>",
                      };
              }
          };

          const getItems = async () => {
              const command = new ScanCommand({ TableName: TABLE_NAME });
              const data = await client.send(command);

              // 署名付きURLを取得
              const s3command = new GetObjectCommand({
                  Bucket: bucketName,
                  Key: imageKey
              });
              const imageUrl = await getSignedUrl(s3Client, s3command, { expiresIn: 3600 });

              let body = `<form action="${URL}${apiPath}" method="post" enctype="application/x-www-form-urlencoded">
                  <input type="text" name="data" placeholder="Enter data" required />
                  <button type="submit">Add</button>
                  </form>
                  <img src="${imageUrl}" alt="S3 Image" /><br><br>
                  <table border="1"><tr><th>ID</th><th>Data</th></tr>`;

              
              data.Items.forEach(item => {
                  body += `<tr><td>${item[partitionKey]["S"]}</td><td>${item.data.S}</td></tr>`;
              });
              body += "</table>";

              return {
                  statusCode: 200,
                  headers: {
                      "Content-Type": "text/html",
                  },
                  body: body,
              };
          };

          const addItem = async (event) => {
              var Item_ = {}
              Item_[partitionKey] = { S: Date.now().toString() }
              Item_["data"] = { S: unescape(event.body.split('=')[1]) }
              const command = new PutItemCommand({
                  TableName: TABLE_NAME,
                  Item: Item_,
              });

              await client.send(command);

              return {
                  statusCode: 303,
                  headers: {
                      "Location": "/" + apiPath,
                      "Content-Type": "text/html",
                  },
                  body: "",
              };
          };

Outputs:
  APIENdppointURL: # 渡す値を出力する
    Value: !Sub https://${RestAPI}.execute-api.${AWS::Region}.amazonaws.com/prod/${APIPath}
```

## 最後に
- はじめPowershellでテンプレートからcreate stackしていたら、ダブルクオーテーションをうまく読み取れなかったため、bashからスタック作成するようにした。
- API Gatewayで定義すべきリソースが結構多かった。SAM使った方が簡単そう…
- テンプレートにLambdaコードをベタ書きするのもいい加減やめたい。