📮

【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パターンで作成しました。)

lambda_main.rb
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リクエストを飛ばす処理を作成します。

request_test.rb
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が返れば成功です👍!

https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/set-up-lambda-proxy-integration-using-cli.html

https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/permissions.html#api-gateway-control-access-iam-permissions-model-for-calling-api

♻️ CORSの設定

ここまででAPI GatewayでバックエンドのフォームのAPIを作成してきました。

ただ、実際にブラウザからAPIにアクセスすると、ブラウザに表示されたリソース(HTMLファイルなど)のオリジンと、APIのオリジンが異なります。

ブラウザはSame Origin Security Policyというセキュリティ機能があり、クロスオリジン(別のオリジン)へリクエストする場合はCORS(Cross-Origin Resource Sharing)という仕組みを活用して実現しようとします。

そのため、クロスオリジンへリクエストを飛ばし、正常にレスポンスを受け取るためにはCORSの設定をする必要あります。

https://zenn.dev/tm35/articles/3eeb44f5e3ec8a
https://zenn.dev/tm35/articles/ad05d8605588bd
https://zenn.dev/qnighy/articles/6ff23c47018380

CORSエラーを確認

まずはシンプルなHTMLファイルを作成し、CORSエラーが出ることを確認します。

HTMLはこちら
  1. 送信ボタンを押下
  2. Ajax通信でAPI GatewayのformリソースへPOSTリクエスト
  3. API Gatewayからレスポンスを受け取り、console.logで結果を確認
form.html
<!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ディレクトリに作成したため、ブラウザ上では以下のパスで開きます。

URL
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を追加します。

lambda_main.rb

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です👍

参考記事

https://dev.classmethod.jp/articles/serverless-mailform-s3-website-hosting/

シリーズ

脚注
  1. AWSLambdaBasicExecutionRole ↩︎

  2. AmazonSESFullAccess ↩︎

Discussion