【AWS CLI】 静的ウェブサイトホスティングのフォーム実装 (SES + Lambda + API Gateway)
シリーズ
- 【AWS CLI】S3 + CloudFront + Route 53のハンズオン (静的ウェブサイトホスティング)
- 【AWS CLI】 静的ウェブサイトホスティングのフォーム実装 (SES + Lambda + API Gateway
はじめに
今回は静的ウェブサイトホスティング、Amazon SES + API Gateway + lambdaでフォームを実装していきます。(シリーズの続きですが、この記事だけでも完結できる内容になっています)
前置き
- AWS CLIのバージョンはv2です。
- AWS CLIで操作するリソースのリージョンは東京を指定しています。
cat ~/.aws/config
[default]
region = ap-northeast-1
output = json
...
✉️ SES
aws ses verify-email-identity --email-address hoge@sample.com
指定したメールアドレスに、メールアドレスの検証用のメールが送信されているので、メール文面のURLを押下してください。
SESのAWSコンソールで以下ようにメールアドレスが「検証済み」と表示されていればOKです!

これで指定したメールアドレス宛にメールを送信できるようになりました。
⚙️ Lambda
次にSESにメール配信処理を実行させるLambda関数を作成していきましょう。
実行ロールを作成
Lambda関数の実行ロールを作成し、必要なポリシーをアタッチします。
ロール名はLambdaToSendAutomaticReplyRoleとします。
aws iam create-role \
--role-name LambdaToSendAutomaticReplyRole \
--assume-role-policy-document '{"Version": "2012-10-17","Statement": [{ "Effect": "Allow", "Principal": {"Service": "lambda.amazonaws.com"}, "Action": "sts:AssumeRole"}]}'
ポリシーを2つアタッチします。
| ポリシー名 | 説明 | 
|---|---|
| AWSLambdaBasicExecutionRole | 主にCloudWatch Logsへの書き込み権限を提供[1] | 
| AmazonSESFullAccess | Amazon SES へのフルアクセス権を提供[2] (今回は検証用なのでフルアクセス) | 
aws iam attach-role-policy \
--role-name LambdaToSendAutomaticReplyRole \ 
--policy-arn "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
aws iam attach-role-policy \
--role-name LambdaToSendAutomaticReplyRole \
--policy-arn "arn:aws:iam::aws:policy/AmazonSESFullAccess"
IAMのAWSコンソールで以下のような表示になっていればOKです!

関数を作成
SESクライアントを作成し、eventパラメータで受けたフォームデータをSESクライアントのsend_emailメソッドで送信します。
(Node.jsで書かれている記事が多かったのでこの記事ではRubyパターンで作成しました。)
require 'aws-sdk-ses'
require 'json'
# アドレスを指定
ADMIN_ADDRESS = 'sample@gmail.com'
REPLAY_ADDRESS = 'sample@gmail.com'
# 本文を整形
def get_format_input_data(input_data)
  [
    '[タイトル]' + "\n" + input_data[:title].to_s + "\n",
    '[メールアドレス]' + "\n" + input_data[:email].to_s + "\n",
    '[本文]' + "\n" + input_data[:content].to_s + "\n"
  ].join("\n")
end
def handler(event:, context:)
  # API Gatewayから渡されたリクエストのbodyをパースし、フォーム入力データを抽出
  request_body = event['body'] ? JSON.parse(event['body']) : {}
  input_data = {
    title: request_body['title'],
    email: request_body['email'],
    content: request_body['content']
  }
  # SESで送信用のデータを定義
  email_data = {
    source: REPLAY_ADDRESS,
    destination: {
      to_addresses: [ADMIN_ADDRESS]
    },
    message: {
      subject: {
        data: 'フォームからのお問い合わせ'
      },
      body: {
        text: {
          data: get_format_input_data(input_data)
        }
      }
    }
  }
  # 送信処理
  ses = Aws::SES::Client.new(region: 'ap-northeast-1')
  ses.send_email(email_data)
  return {
    statusCode: 200,
    headers: {},
    body: JSON.generate(input_data)
  }
end
圧縮します。
zip -r lambda_main.zip lambda_main.rb
先程作成したLambda用のロールのARNを確認します。
aws iam get-role --role-name LambdaToSendAutomaticReplyRole --query "Role.Arn"
"arn:aws:iam::***********:role/LambdaToSendAutomaticReplyRole"
Lambda関数をデプロイします。
aws lambda create-function \
--function-name send-automatic-replay-function \
--runtime ruby3.2 \
--role "arn:aws:iam::***********:role/LambdaToSendAutomaticReplyRole" \
--handler lambda_main.handler \
--zip-file fileb://lambda_main.zip
SESのAWSコンソールで以下ようにと表示されていればOKです!

🎯 API Gateway
次にフォームの送信先のAPIエンドポイントをAPI Gatewayで作成していきます。
API Gatewayで受け取ったフォームのPOSTリクエストを、パラメータとして先程作成したLambda関数に渡していきましょう。
aws apigateway create-rest-api --name "SendAutomaticReplayAPI"
AWSコンソールで以下のように表示されていればOKです!

上記の画面に表示されているAPI GatewayのIDは以降もAWS CLIで活用するため、プロセスの環境変数に入れておきましょう。
APIGATEWAY_ID=rjurgrc9ka
また、ルートパスのidもこのタイミングでプロセスの環境変数に入れておきます。
aws apigateway get-resources --rest-api-id $APIGATEWAY_ID
{
    "items": [
        {
            "id": "ytexcamp3k",
            "path": "/"
        }
    ]
}
APIGATEWAY_ROOT_PATH_ID=ytexcamp3k
リソースを作成
ルートパスの下にformリソース(form)を作成します。
aws apigateway create-resource \
--rest-api-id $APIGATEWAY_ID \
--parent-id $APIGATEWAY_ROOT_PATH_ID \
--path-part form
{
    "id": "xwfl85",
    "parentId": "ytexcamp3k",
    "pathPart": "form",
    "path": "/form"
}
AWSコンソールでもformリソースが作成されていればOKです!

APIGATEWAY_FORM_PATH_ID=xwfl85
では次のセクションからメソッドを作成していきましょう。
メソッドを作成
先にこのセクションで作成するメソッドの全体像です。API Gatewayで受けたリクエストをLambdaへ転送する場合は、ユーザーから受けるメソッドリクエストの他に、API GatewayからLambdaに発行される結合リクエストの作成が必要です。

メソッドリクエストを作成
今回のメソッドリクエストは、API GatewayのfromリソースにPOSTメソッドで受けるため、POSTメソッドを作成します。
aws apigateway put-method \
--rest-api-id $APIGATEWAY_ID \
--resource-id $APIGATEWAY_FORM_PATH_ID \
--http-method POST \
--authorization-type "NONE"
{
    "httpMethod": "POST",
    "authorizationType": "NONE",
    "apiKeyRequired": false
}
結合リクエストを作成
「API Gateway → Lambda」へのリクエストです。
結合リクエストを作成するコマンドに必要な--uriオプションは以下の値のため、先に組んでおきます。
arn:aws:apigateway:ap-northeast-1:lambda:path/2015-03-31/functions/{LambdaのARN}/invocations
Lambda関数のARNを確認します。
aws lambda get-function --function-name send-automatic-replay-function --query "Configuration.FunctionArn" --output text
arn:aws:lambda:ap-northeast-1:***********:function:send-automatic-replay-function
--uriオプション用の値を作成し、プロセスの環境変数にセットしておきます。
APIGATEWAY_LAMBDA_INTEGRATION_URI=arn:aws:apigateway:ap-northeast-1:lambda:path/2015-03-31/functions/arn:aws:lambda:ap-northeast-1:***********:function:send-automatic-replay-function/invocations
それではPOSTリクエストに対し、Lambda関数への結合リクエストを作成していきましょう。
aws apigateway put-integration \
--rest-api-id $APIGATEWAY_ID \
--resource-id $APIGATEWAY_FORM_PATH_ID \
--http-method POST \
--type AWS_PROXY \
--integration-http-method POST \
--uri $APIGATEWAY_LAMBDA_INTEGRATION_URI
{
    "type": "AWS_PROXY",
    "httpMethod": "POST",
    "uri": "arn:aws:apigateway:ap-northeast-1:lambda:path/2015-03-31/functions/arn:aws:lambda:ap-northeast-1:***********:function:send-automatic-replay-function/invocations",
    "passthroughBehavior": "WHEN_NO_MATCH",
    "timeoutInMillis": 29000,
    "cacheNamespace": "xwfl85",
    "cacheKeyParameters": []
}
AWSコンソールで以下の表示になっていればOKです!

実行権限を付与
Lambda側の権限を更新し、API GatewayからLambda関数を実行できるようにします。
--source-arnにはAPI GatewayのformリソースのPOSTメソッドのARNを指定指定します。
💻 API GatewayのformリソースのPOSTメソッドのARNの表記箇所

aws lambda add-permission \
--function-name send-automatic-replay-function \
--statement-id apigateway-access \
--action lambda:InvokeFunction \
--principal apigateway.amazonaws.com \
--source-arn "arn:aws:execute-api:ap-northeast-1:***********:rjurgrc9ka/*/POST/form"
LambdaのAWSコンソールで「設定 > アクセス権」を確認し、以下の表示なっていればOKです!

Lambdaとの疎通テスト
AWSコンソールのAPI Gatewayの画面で「テスト」タブを選択し、リクエスト本文に以下を入力してテストボタンを押下してください。
{
    "title": "タイトル",
    "email": "hoge@sample.com",
    "content": "本文"
}
テスト画面下に表示されるログでステータスが200と表示され、メールが届いていればOKです!
デプロイ
aws apigateway create-deployment \
--rest-api-id $APIGATEWAY_ID \
--stage-name prod
エンドポイントは以下です。
https://$APIGATEWAY_ID.execute-api.ap-northeast-1.amazonaws.com/prod/form
エンドポイントのテスト
API Gatewayがデプロイされ、エンドポイント経由でアクセスできるようになったのでレスポンスが返るか確認してみましょう。
ローカルからHTTPリクエストを飛ばす処理を作成します。
require 'net/http'
require 'uri'
require 'json'
api_gateway_id = 'rjurgrc9ka'
region = 'ap-northeast-1'
request_body = {
  title: 'タイトル',
  email: 'hoge@sample.com',
  content: '本文'
}.to_json
# URIオブジェクト作成
API_URL = "https://#{api_gateway_id}.execute-api.#{region}.amazonaws.com/prod/form"
uri = URI(API_URL)
# HTTPリクエスト作成
http = Net::HTTP::new(uri.host, uri.port).tap{ |h| h.use_ssl = true }
# リクエストの中身を作成
request = Net::HTTP::Post.new(
  uri.request_uri,
  {
    'Content-Type' => 'application/json'
  }
)
request.body = request_body
# HTTP通信
response = http.request(request)
# レスポンスを表示
puts "Response Code: #{response.code}"
puts "Response Body: #{response.body}"
スクリプトを実行します。
ruby request_test.rb
Response Code: 200
Response Body: {"title":"タイトル","email":"hoge@sample.com","content":"本文"}
無事200が返れば成功です👍!
♻️ CORSの設定
ここまででAPI GatewayでバックエンドのフォームのAPIを作成してきました。
ただ、実際にブラウザからAPIにアクセスすると、ブラウザに表示されたリソース(HTMLファイルなど)のオリジンと、APIのオリジンが異なります。
ブラウザはSame Origin Security Policyというセキュリティ機能があり、クロスオリジン(別のオリジン)へリクエストする場合はCORS(Cross-Origin Resource Sharing)という仕組みを活用して実現しようとします。
そのため、クロスオリジンへリクエストを飛ばし、正常にレスポンスを受け取るためにはCORSの設定をする必要あります。
CORSエラーを確認
まずはシンプルなHTMLファイルを作成し、CORSエラーが出ることを確認します。
HTMLはこちら
- 送信ボタンを押下
- Ajax通信でAPI GatewayのformリソースへPOSTリクエスト
- API Gatewayからレスポンスを受け取り、console.logで結果を確認
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Contact</title>
    <!-- jQuery CDN -->
    <script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
    <script>
      $(document).ready(function () {
        $("#submit").on("click", function (e) {
          e.preventDefault(); // フォームのデフォルト送信をキャンセル
          const data = {
            title: $("input[name='title']").val(),
            email: $("input[name='email']").val(),
            content: $("textarea[name='content']").val()
          };
          $.ajax({
            url: "https://rjurgrc9ka.execute-api.ap-northeast-1.amazonaws.com/prod/form",
            type: "post",
            dataType: "json",
            contentType: "application/json",
            scriptCharset: "utf-8",
            data: JSON.stringify(data)
          })
            .done(function (response) {
              console.log("送信に成功しました", response);
            })
            .fail(function (error) {
              console.error("送信に失敗しました", error);
            });
        });
      });
    </script>
  </head>
  <body>
    <h1>Contact</h1>
    <form method="post" id="form">
      <input name="title" placeholder="タイトル" /><br>
      <input name="email" placeholder="メールアドレス" /><br>
      <textarea name="content" placeholder="本文"></textarea><br>
      <button id="submit">送信</button>
    </form>
  </body>
</html>
今回はローカルのUsersディレクトリに作成したため、ブラウザ上では以下のパスで開きます。
file:///Users/{$USER}/form.html

フォーム送信すると以下の順番でリクエストが発行された後、CORSエラーが出ます。

CORSエラーになるまでの流れ
1. プリフライトリクエスト (画像の①)
【リクエスト】
まずはリクエスト先のクロスオリジンに対して、Originヘッダーを追加したOPTIONリクエストを飛ばします。(ローカルファイルの場合はnullですが本来はhttps://sample.comなどのオリジンの値が入ります)

【レスポンス】
API側では、レスポンスする際に自身のOPTIONメソッドのレスポンスヘッダーの中身を確認し、返すステータスコードを変えます。
- 
Access-Control-Allow-Originの指定- ない場合
- 403を返す
 
- ある場合
- 
Access-Control-Allow-Originの値に、プリフライトリクエストのOriginヘッダーの値が含まれている場合は200を返す(含まれてない場合は403を返す)
 
- 
 
- ない場合
【結果】
現時点ではそもそもAPI GatewayのformリソースにOPTIONメソッドを設定していないため、Access-Control-Allow-Originの検証はされず、403を返しています。
2. 本リクエスト (画像の②)
①のプリフライトリクエストが403を返したため、フォームデータを送信する本リクエストは送信されず、CORSエラーになりました。
(ちなみに①のプリフライトリクエストからレスポンスの結果がわかるまでは、②の本リクエストはpendding状態になります)
では次のセクションからクロスオリジンからのリクエストを許可する設定に変更していきましょう。
CORSの許可設定
①のプリフライトリクエストに対するレスポンスのCORSを設定
API GatewayのformリソースのOPTIONメソッドを作成します。
まずはformリソース画面のCORSを有効にするボタンを押下します。

Access-Control-Allow-Originに*が入っていることを確認し、保存を押下してください。

最後に、APIをデプロイボタンからデプロイして設定を反映してください。(以外と忘れがちなのでお気をつけください)

②の本リクエストに対するレスポンスのCORSを設定
次に本リクエストに対するレスポンスのCORSを設定します。すでにプリフライトリクエストでCORS設定をしたため、本リクエストがAPIに飛ぶ状態です。
API GatewayのLambda結合の設定で、本リクエストに対するレスポンスはlambdaの処理結果をそのまま返す仕組みのため、Lambda関数のレスポンスヘッダーにもAccess-Control-Allow-Originを追加します。
def handler(event:, context:)
  ...(略)
  return {
    statusCode: 200,
    headers: {
+      'Access-Control-Allow-Origin' => '*'
    },
    body: JSON.generate(input_data)
  }
end
圧縮した後、lambda関数を更新します。
zip -r lambda_main.zip lambda_main.rb
aws lambda update-function-code \
  --function-name send-automatic-replay-function \
  --zip-file fileb://lambda_main.zip
送信テスト
再度フォームを送信すると2つのリクエストが通ります。

念のためLambda関数で指定したADMIN_ADDRESSにもメールが届いているか確認し、届いていればOKです👍
参考記事
シリーズ
- 【AWS CLI】S3 + CloudFront + Route 53のハンズオン (静的ウェブサイトホスティング)
- 【AWS CLI】 静的ウェブサイトホスティングのフォーム実装 (SES + Lambda + API Gateway



Discussion