API Gatewayのリクエストバリデーション機能でLambdaのリソース消費を抑制する

# 課題

AWS SAM (Serverless Application Model)は、サーバーレスアプリケーションを簡単に作成、デプロイするためのフレームワークです。
私たちのプロジェクトでは、API Gateway, Lambda, DynamoDBを組み合わせたサーバーレスアーキテクチャを採用しており、AWS SAMを用いてデプロイを管理しています。

しかし、その際に直面した課題があります。
それは、リクエストのバリデーションをLambda関数内で行っていたため、不適切なリクエストがLambdaまで到達してしまい、リソースの無駄遣いにつながっていました。

# 考えた方法

この課題に対して考えた解決策は、API Gatewayでリクエストのバリデーションを行うことです。
AWS SAMのテンプレートを修正して、API Gatewayにリクエストの定義を追加し、リクエストが定義に合致しているかどうかをチェックする設定を加えることで、リソースの無駄遣いを防ぐことが可能になります。

# 今日の目標

今日の目標は、AWS SAMのテンプレートを修正し、API Gatewayでリクエストのバリデーションを行えるようにすることです。

# 手順・実装内容

以下に、実装内容とそれに対応するSAMテンプレートの修正箇所を示します。

## 初期のSAMテンプレート

今回修正を加えるSAMテンプレートは下記です。
API Gateway, Lambda, DynamoDBを組み合わせたシンプルな構成のテンプレートです。

```json
{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Transform": "AWS::Serverless-2016-10-31",
  "Description": "SampleProject Sample SAM Template for SampleProject",
  "Globals": {
    "Function": {
      "Timeout": 3
    }
  },
  "Resources": {
    "SampleTable": {
      "Type": "AWS::DynamoDB::Table",
      "Properties": {
        "AttributeDefinitions": [
          {
            "AttributeName": "sample_id",
            "AttributeType": "S"
          }
        ],
        "KeySchema": [
          {
            "AttributeName": "sample_id",
            "KeyType": "HASH"
          }
        ],
        "ProvisionedThroughput": {
          "ReadCapacityUnits": 5,
          "WriteCapacityUnits": 5
        }
      }
    },
    "postSample": {
      "Type": "AWS::Serverless::Function",
      "Properties": {
        "CodeUri": "./postSample",
        "Handler": "handler.handler",
        "Runtime": "python3.9",
        "Environment": {
          "Variables": {
            "SAMPLE_TABLE_NAME": { "Ref": "SampleTable" },
            "SAMPLE_TABLE_ARN": { "Fn::GetAtt": ["SampleTable", "Arn"] }
          }
        },
        "Policies": [
          { "DynamoDBWritePolicy": { "TableName": { "Ref": "SampleTable" } } },
          { "DynamoDBCrudPolicy": { "TableName": { "Ref": "SampleTable" } } }
        ],
        "MemorySize": 3008,
        "Timeout": 3,
        "Events": {
          "SampleApi": {
            "Type": "Api",
            "Properties": {
              "Path": "/sample",
              "Method": "post",
              "RestApiId": { "Ref": "SamplesApi" }
            }
          }
        }
      }
    },
    "SamplesApi": {
      "Type": "AWS::Serverless::Api",
      "Properties": {
        "StageName": "Prod",
        "DefinitionBody": {
          "swagger": "2.0",
          "info": {
            "title": { "Ref": "AWS::StackName" }
          },
          "paths": {
            "/sample": {
              "post": {
                "x-amazon-apigateway-integration": {
                  "uri": {
                    "Fn::Sub": "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${postSample.Arn}/invocations"
                  },
                  "responses": {},
                  "httpMethod": "POST",
                  "type": "aws_proxy"
                },
                "responses": {}
              }
            }
          }
        }
      }
    }
  },
  "Outputs": {
    "SampleApi": {
      "Description": "API Gateway endpoint URL for Prod stage for Post function",
      "Value": {
        "Fn::Sub": "https://${SamplesApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/Prod/"
      }
    },
    "postSample": {
      "Description": "Post Lambda Function ARN",
      "Value": {
        "Fn::GetAtt": ["postSample", "Arn"]
      }
    },
    "SampleTable": {
      "Description": "DynamoDB table name",
      "Value": {
        "Fn::GetAtt": ["SampleTable", "Arn"]
      }
    }
  }
}
```

## モデルの追加

まずは、リクエストの形式を定義するモデルを追加しました。
このモデルはリクエストが必要とする`UserID`と`UserName`の2つのプロパティを定義し、どちらも必須であることを示しています。

```json
{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Transform": "AWS::Serverless-2016-10-31",
  "Description": "SampleProject Sample SAM Template for SampleProject",
  "Globals": {
    "Function": {
      "Timeout": 3
    }
  },
  "Resources": {
    "SampleTable": {
      "Type": "AWS::DynamoDB::Table",
      "Properties": {
        "AttributeDefinitions": [
          {
            "AttributeName": "sample_id",
            "AttributeType": "S"
          }
        ],
        "KeySchema": [
          {
            "AttributeName": "sample_id",
            "KeyType": "HASH"
          }
        ],
        "ProvisionedThroughput": {
          "ReadCapacityUnits": 5,
          "WriteCapacityUnits": 5
        }
      }
    },
    "postSample": {
      "Type": "AWS::Serverless::Function",
      "Properties": {
        "CodeUri": "./postSample",
        "Handler": "handler.handler",
        "Runtime": "python3.9",
        "Environment": {
          "Variables": {
            "SAMPLE_TABLE_NAME": { "Ref": "SampleTable" },
            "SAMPLE_TABLE_ARN": { "Fn::GetAtt": ["SampleTable", "Arn"] }
          }
        },
        "Policies": [
          { "DynamoDBWritePolicy": { "TableName": { "Ref": "SampleTable" } } },
          { "DynamoDBCrudPolicy": { "TableName": { "Ref": "SampleTable" } } }
        ],
        "MemorySize": 3008,
        "Timeout": 3,
        "Events": {
          "SampleApi": {
            "Type": "Api",
            "Properties": {
              "Path": "/sample",
              "Method": "post",
              "RestApiId": { "Ref": "SamplesApi" }
            }
          }
        }
      }
    },
    "SamplesApi": {
      "Type": "AWS::Serverless::Api",
      "Properties": {
        "StageName": "Prod",
        "DefinitionBody": {
          "swagger": "2.0",
          "info": {
            "title": { "Ref": "AWS::StackName" }
          },
          "paths": {
            "/sample": {
              "post": {
                "x-amazon-apigateway-integration": {
                  "uri": {
                    "Fn::Sub": "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${postSample.Arn}/invocations"
                  },
                  "responses": {},
                  "httpMethod": "POST",
                  "type": "aws_proxy"
                },
                "parameters": [
                  {
                    "name": "body",
                    "in": "body",
                    "required": true,
                    "schema": {
                      "$ref": "#/definitions/SampleInput"
                    }
                  }
                ],
                "responses": {}
              }
            }
          },
          "definitions": {
            "SampleInput": {
              "type": "object",
              "properties": {
                "UserID": {
                  "type": "string"
                },
                "UserName": {
                  "type": "string"
                }
              },
              "required": ["UserID", "UserName"]
            }
          }
        }
      }
    }
  },
  "Outputs": {
    "SampleApi": {
      "Description": "API Gateway endpoint URL for Prod stage for Post function",
      "Value": {
        "Fn::Sub": "https://${SamplesApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/Prod/"
      }
    },
    "postSample": {
      "Description": "Post Lambda Function ARN",
      "Value": {
        "Fn::GetAtt": ["postSample", "Arn"]
      }
    },
    "SampleTable": {
      "Description": "DynamoDB table name",
      "Value": {
        "Fn::GetAtt": ["SampleTable", "Arn"]
      }
    }
  }
}
```

## リクエストバリデーションの設定

次に、API Gatewayへのリクエストが上記で定義したモデルに合致しているかをチェックするように設定をしました。
これにより、Lambdaが呼び出される前に不適切なリクエストをはじくことが可能になります。

```json
{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Transform": "AWS::Serverless-2016-10-31",
  "Description": "SampleProject Sample SAM Template for SampleProject",
  "Globals": {
    "Function": {
      "Timeout": 3
    }
  },
  "Resources": {
    "SampleTable": {
      "Type": "AWS::DynamoDB::Table",
      "Properties": {
        "AttributeDefinitions": [
          {
            "AttributeName": "sample_id",
            "AttributeType": "S"
          }
        ],
        "KeySchema": [
          {
            "AttributeName": "sample_id",
            "KeyType": "HASH"
          }
        ],
        "ProvisionedThroughput": {
          "ReadCapacityUnits": 5,
          "WriteCapacityUnits": 5
        }
      }
    },
    "postSample": {
      "Type": "AWS::Serverless::Function",
      "Properties": {
        "CodeUri": "./postSample",
        "Handler": "handler.handler",
        "Runtime": "python3.9",
        "Environment": {
          "Variables": {
            "SAMPLE_TABLE_NAME": { "Ref": "SampleTable" },
            "SAMPLE_TABLE_ARN": { "Fn::GetAtt": ["SampleTable", "Arn"] }
          }
        },
        "Policies": [
          { "DynamoDBWritePolicy": { "TableName": { "Ref": "SampleTable" } } },
          { "DynamoDBCrudPolicy": { "TableName": { "Ref": "SampleTable" } } }
        ],
        "MemorySize": 3008,
        "Timeout": 3,
        "Events": {
          "SampleApi": {
            "Type": "Api",
            "Properties": {
              "Path": "/sample",
              "Method": "post",
              "RestApiId": { "Ref": "SamplesApi" }
            }
          }
        }
      }
    },
    "SamplesApi": {
      "Type": "AWS::Serverless::Api",
      "Properties": {
        "StageName": "Prod",
        "DefinitionBody": {
          "swagger": "2.0",
          "info": {
            "title": { "Ref": "AWS::StackName" }
          },
          "paths": {
            "/sample": {
              "post": {
                "x-amazon-apigateway-integration": {
                  "uri": {
                    "Fn::Sub": "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${postSample.Arn}/invocations"
                  },
                  "responses": {},
                  "httpMethod": "POST",
                  "type": "aws_proxy"
                },
                "parameters": [
                  {
                    "name": "body",
                    "in": "body",
                    "required": true,
                    "schema": {
                      "$ref": "#/definitions/SampleInput"
                    }
                  }
                ],
                "responses": {},
                "x-amazon-apigateway-request-validator": "body-validator"
              }
            }
          },
          "definitions": {
            "SampleInput": {
              "type": "object",
              "properties": {
                "user_id": {
                  "type": "string",
                  "required": true
                },
                "user_name": {
                  "type": "string",
                  "required": true
                }
              },
              "required": ["user_id", "user_name"]
            }
          },
          "x-amazon-apigateway-request-validators": {
            "body-validator": {
              "validateRequestBody": true,
              "validateRequestParameters": false
            }
          }
        }
      }
    }
  },
  "Outputs": {
    "SampleApi": {
      "Description": "API Gateway endpoint URL for Prod stage for Post function",
      "Value": {
        "Fn::Sub": "https://${SamplesApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/Prod/"
      }
    },
    "postSample": {
      "Description": "Post Lambda Function ARN",
      "Value": {
        "Fn::GetAtt": ["postSample", "Arn"]
      }
    },
    "SampleTable": {
      "Description": "DynamoDB table name",
      "Value": {
        "Fn::GetAtt": ["SampleTable", "Arn"]
      }
    }
  }
}
```

# 動作検証
以下に、Postmanを使用してリクエストの内容を確認した結果を示します。

## 正しいリクエストのテスト結果
正しくユーザーIDとユーザー名を含むリクエストを送った結果、正常にレスポンスを受け取ることができました。
![スクリーンショット 2023-07-19 21.33.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3476743/f8c39025-3940-648a-613f-6358ed2b449c.png)

## 不適切なリクエスト（ユーザーIDがタイプミス）のテスト結果
ユーザーIDがタイプミスの状態でリクエストを送ったところ、API Gatewayがエラーメッセージを返し、リクエストが拒否されました。これにより、Lambda関数が実行される前に誤ったリクエストがフィルタリングされました。
![スクリーンショット 2023-07-19 21.34.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3476743/5f944807-fcab-da25-54e8-3d0b95f336d5.png)


以上の手順とテスト結果により、リクエストの内容が事前にチェックされ、不適切なリクエストがLambda関数に到達するのを防ぐことが可能となりました。
これにより、Lambda関数の実行時間とリソース使用量が節約されました。