JavaScript
Ajax
CORS
lambda
APIGateway

AWS API Gateway クロスドメイン通信

AWS S3に静的ウェブサイトのホスティングの記事で、S3上静的Webサイトをホストするやり方を紹介しましたが、静的ページに動的コンテンツを加えることもできます。
JavascriptのSDKAPI Gateway + Lambdaを活用した2TierアーキテクトはS3の定番ユースケースです。

API Gateway + Lambdaを利用すると、JavascriptによるAjax通信がよく使われます。その場合API Gatewayの設定もクロスドメインを許可する必要があります。
ここで、API Gatewayのクロスドメイン設定をして、GetとPostによるAjax通信を検証してみました。

概要アーキテクチャ図

静的なHtml等をS3にホストし、動的コンテンツをAPI Gateway+Lambdaから取得するアーキテクチャを以下で実現します。
もちろん、HtmlをホストしているS3のドメインと、動的コンテンツを取得するAPI Gatewayのドメインが異なるので、クロスドメイン問題が発生します。
image_arch.png

クロスドメインとは

簡単に言うと、異なるドメイン間で Ajax の実行を許可しないという制約です。制約といってもブラウザの仕様(デフォルト設定)であり、サーバ側のレスポンスで許可すれば、通信可能になります。
CORS(Cross-Origin Resource Sharing)によるクロスドメイン通信の傾向と対策の説明がわかりやすいです。

Lambda作成

通信を試すだけなので、GETで指定のTimeZoneを送信し、サーバ時刻を返す簡単なLambda関数SampleFunctionを作成しておきます。POSTの場合、入力した日時とサーバ時刻の時間差を取得するような処理となります。

SampleFunction
from datetime import datetime, timedelta
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):

    logger.info(event)
    dt = datetime.now()
    result = ""

    try:
        httpmethod = event["context"]["http-method"]
        if "GET" == httpmethod:
            timezone = event["params"]["querystring"]["timezone"]
            dt = dt + timedelta(hours=int(timezone))
            result = dt.strftime("%Y/%m/%d %H:%M:%S")
        elif "POST" == httpmethod:
            postservertime = event["body-json"]["postservertime"]
            postedtime = datetime.strptime(postservertime, "%Y/%m/%d %H:%M:%S")

            timezone = event["body-json"]["timezone"]
            dt = dt + timedelta(hours=int(timezone))

            delta = dt - postedtime
            result = str(round(delta.total_seconds() / 3600, 2)) + " hours difference."
        else:
            logger.error("Unexpected http method : " + httpmethod)

        return {"statusCode" : 200,
            "body" : result,
            "headers" : {"Content-Type" : "application/json"}}

    except Exception as e:
        logger.error("type : %s", type(e))
        logger.error(e)

        return {"statusCode" : 200,
            "body" : e,
            "headers" : {"Content-Type" : "application/json"}}

API Gateway作成

早速作ってみます。

Create API

[Create API]でSampleAPIを新規作成します。

Create Resource

API作成したら次API内にリソースを定義します。
image11.png
sampleというResourceを作成し、リソース名がURLのパスの一部となります。
Enable API Gateway CORSチェックすれば、クロスドメイン設定も可能ですが、違いを見るために、ここで設定せずに、Create Resourceを押します。
image12.png

Create Method

最後にリソースにメソッドを定義します。(ActionsのCreate Method)
image21.png
メソッドはリソース+HTTPメソッドで構成され、スタンダードな7つのHTTPメソッドとANYをサポートします。
ここでまずGETとPOSTを定義します。

GET

image22.png
Lambda Functionに作成したSampleFunctionを指定、Saveします。
Add Permission to Lambda Functionポップアップが表示され、API GatewayにLambdaをinvokeする権限を与えます。

You are about to give API Gateway permission to invoke your Lambda function:
arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:SampleFunction

image23.png

POST

GETと同じ手順でPOSTも作っておきます。

デプロイ

リソースとHTTPメソッドを定義したら、Actionsの「Deploy API」でデプロイします。
image31.png

ステージ

APIはステージにデプロイされます。ステージはそれぞれの環境を表し、dev、test、beta、prod等で定義します。
image32.png
Stage nameをdevとします。
image33.png

デプロイしたら、URLをたたくことでAPIを呼び出し可能です。
https://nsoi8vgl6k.execute-api.ap-northeast-1.amazonaws.com/dev/sample

Javascript

ただJavascriptからajaxで呼び出すとクロスドメインの制約に引っかかりますので、まず試してみます。
jqueryのajaxを利用して非同期でAPIを呼び出すサンプルhtmlを作成します。

sample.html
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html lang="en">
<head>
  <title>Sample API Async Invoke</title>
  <meta charset="utf-8">
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js" type="text/javascript"></script>
  <script type="text/javascript">
      function startget() {

        timezone = document.getElementById("timezone").value;
        geturl = "https://nsoi8vgl6k.execute-api.ap-northeast-1.amazonaws.com/dev/sample?timezone=" + timezone;
        $.ajax({
            type : "GET",
            url : geturl,
            success: function(resp, status) {
                         document.getElementById("getservertime").value = resp["body"];
            },
            error: function(XMLHttpRequest, textStatus, errorThrown) {
                       document.getElementById("getservertime").value = 
                           "XMLHttpRequest: " + XMLHttpRequest.status + 
                           "\r\ntextStatus: " + textStatus + 
                           "\r\nerrorThrown: " + errorThrown.message;
            }
        });
      }

      function startpost() {

        postservertime = document.getElementById("postservertime").value;
        timezone = document.getElementById("timezone").value;

        posturl = "https://nsoi8vgl6k.execute-api.ap-northeast-1.amazonaws.com/dev/sample";

        var jsondata = {"postservertime" : postservertime, "timezone" : timezone};
        $.ajax({
            dataType : "json",
            data : JSON.stringify(jsondata),
            type : "POST",
            url : posturl,
            success: function(resp, status) {
                         document.getElementById("postservertime").value = resp["body"];
            },
            error: function(XMLHttpRequest, textStatus, errorThrown) {
                       document.getElementById("postservertime").value = 
                           "XMLHttpRequest: " + XMLHttpRequest.status + 
                           "\r\ntextStatus: " + textStatus + 
                           "\r\nerrorThrown: " + errorThrown.message;
            }
        });
      }
  </script>
</head>
<body>
    <div>
        <div>
            <p>Time Zone<br>
            <select id="timezone">
                <option value="-5">America/New_York</option>
                <option value="9">Asia/Tokyo</option>
                <option value="10">Australia/Sydney</option>
                <option value="0">Europe/London</option>
            </select></p>
            <textarea id="getservertime" cols="30" rows="5"></textarea>
        </div>
        <div>
            <button type="button" id="btn-get" onclick="startget()">Get</button>
        <div>
    </div>
    <br>
    <div>
        <div>
            <textarea id="postservertime" cols="30" rows="5"></textarea>
        </div>
        <div>
            <button type="button" id="btn-post" onclick="startpost()">Post</button>
        </div>
    </div>
</body>
</html>

AWS S3に静的ウェブサイトのホスティングの手順通り、S3にホストします。
http://sample-async-invoke.s3-website-ap-northeast-1.amazonaws.com/
このままGetやPost実行するとエラーとなります。
image41.png

CORS設定

AWSのAPI Gatewayでのクロスドメイン許可設定は非常に簡単で、Actionsの「Enable CORS」でできます。
image51.png
image52.png
イメージとして、OPTIONSメソッドを追加してくれて、Response headerをつけてくれるような感じですね。

Response header 設定値 説明
Access-Control-Allow-Headers 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token' 次に来るCORSリクエストに含めてよいヘッダ
Access-Control-Allow-Methods 'POST,GET,OPTIONS' 次にCORSリクエスト可能なHTTPメソッド
Access-Control-Allow-Origin '*' 次にCORSリクエスト可能なオリジン(リクエスト元のドメイン)

Request Detail情報の有効化

CORS設定完了後、Ajax通信が可能になりますが、Lambdaのevent引数からまだRequest Detail情報を受け取れません。なぜなら、Use Lambda Proxy integrationにチェックが外れている状態です。(チェックを入れいると今度Ajax通信が失敗するので、Lambda Proxy integrationは使えないということです。)
image_req_detail.png
そこで自前にBody Mapping Templatesを作成する必要があります。(自作でもよいですが、実際用意されているデフォルトテンプレートを適用してもよいです)

GETメソッドBody Mapping Templates

GETメソッドのIntegration Request画面で、Body Mapping TemplatesのContent-Typeにapplication/jsonを登録し、Mapping Templateを追加します。

image_get_body1.png
TemplateはMethod Request passthroughを利用(説明にあるように、このテンプレートはpath、querystring、header等すべての情報を変換してくれてLambdaにパスします。
image_get_body2.png

上記を設定すれば、リクエストヘッダとマッピングテンプレートのContent-Typeが一致の場合、mapping templateに従いデータ変換してくれます。

POSTメソッドBody Mapping Templates

POSTメソッドも同じ考え方です。Content-Typeはapplication/x-www-form-urlencodedになります。
image_post_body1.png

GETメソッドのQueryString

sample.html
  <script type="text/javascript">
      function startget() {

        timezone = document.getElementById("timezone").value;
        geturl = "https://nsoi8vgl6k.execute-api.ap-northeast-1.amazonaws.com/dev/sample?timezone=" + timezone;
        $.ajax({
            type : "GET",
            url : geturl,
            success: function(resp, status) {
                         document.getElementById("getservertime").value = resp["body"];
            },
            error: function(XMLHttpRequest, textStatus, errorThrown) {
                       document.getElementById("getservertime").value = 
                           "XMLHttpRequest: " + XMLHttpRequest.status + 
                           "\r\ntextStatus: " + textStatus + 
                           "\r\nerrorThrown: " + errorThrown.message;
            }
        });
      }

最後に、GETの場合、timezoneをQueryStringとしてくっつけているので、timezoneのvalueをLambdaに渡すには、Method Request画面でURL Query String Parametersに登録しておく必要があります。
image.png

デプロイ

すべての設定が完了後、デプロイを忘れずに実施します。

検証

Getボタンを押すと、選択しているNew YorkのTimeZoneのサーバ時刻がテキストボックスに表示されます。
Response Headersにaccess-control-allow-origin: *が追加されています。
image_ver1.png
次に、TimeZoneをTokyoに変更し、PostのテキストボックスにNew Yorkのサーバ時刻を入れて、Postボタンを押します。時間差が問題なく表示されました。
image_ver2.png

参考