Amazon API GatewayとAWS Lambda Python版

[Amazon API Gateway](https://aws.amazon.com/jp/api-gateway/)と[AWS Lambda](https://aws.amazon.com/jp/lambda/)を組み合わせて、サーバーレスアーキテクチャを実現する件について書きます。Pythonで。

# API Gateway
## API Gatewayとは
API GatewayはRestful WebAPIを提供するために、ラッパーになってくれるサービス。
自身も[CloudFront](https://aws.amazon.com/jp/cloudfront/)と結合されているというか、そのラッパーとして実装されているようです。CloudFrontといえばCDNというイメージですが、[AWS WAF](https://aws.amazon.com/jp/waf/)もCloudFrontと結合されているあたり、CDNに限らずLayer7のネットワークサービスという位置づけになったのかもしれません。CDNだけだと競合も多く、ある意味コモディティ化していきそうですしね。

## API Gatewayの構成要素
* API  
    * 作成するAPI(の器)。
* Resource
    * APIのパス。いわゆるルーティングの定義です。
    * example.com/hoge/fugaのhogeとかfugaの部分。
    * (当たり前だが)example.com/hoge/fugaのfugaとexample.com/fugaのfugaは別物として定義します。
    * "var"ではなく"{var}"のように定義すると、パス変数になります。
* Method
    * HTTP Method(GET/POST/PUT, etc.)を定義します。
    * 一つのResourceに対して複数定義できます。
* Integration
    * API Gatewayから見たBackendの定義。CloudFrontのOriginに対応しているのかと。
    * Methodと1対1
    * HTTPでアクセス可能なエンドポイントのほか、本題のLambdaも起動可能です。
    * Methodから渡されるRequest Bodyを変換することが可能。
        * content-type毎に定義します。
        * フォーマット変換はまさかのVTL([Velocity Template Language](https://velocity.apache.org/engine/releases/velocity-1.5/user-guide.html#velocity_template_language_vtl:_an_introduction))で書きます。
* Integration Response
    * IntegrationからのResponse(Status Code、Response Body)と作成するAPIのResponseを対応づけます。
    * 一つにMethodに対して複数指定可能(クライアントに返したいステータスコードの数だけ)。
    * Status Codeのマッピングと、Response Bodyのフォーマット変換が可能。
* Method Response
    * Integration ResponseのStatus CodeとResponse Model([Json Schema](http://json-schema.org/))を紐づける
    * 初期状態で二つのModelが定義されています。
        * Empty(パススルー用)
        * Error(エラーメッセージ用)

# サンプル
## API Gateway
せっかくなのでPythonからAPIを作成。冒頭のregion, function, roleを適宜変更してください。

```createapi.py
# -*- coding: utf-8 -*-

import boto3
client = boto3.client('apigateway')
region = 'ap-northeast-1'
function = 'arn:aws:lambda:ap-northeast-1:AWS_ACCOUNT_ID:function:YOUR_LAMBDA_FUNCTION'
role = 'arn:aws:iam::AWS_ACCOUNT_ID:role/YOUR_IAM_ROLE_FOR_INVOCATION'


def create_api():
    rest_api = client.create_rest_api(
        name='sample01',
        description='sample api',
    )
    return rest_api['id']


def create_resource(rest_api_id):
    for resource in client.get_resources(
        restApiId=rest_api_id
    )['items']:
        if resource['path'] == '/':
            path_root_id = resource['id']
    new_resource = client.create_resource(
        restApiId=rest_api_id,
        parentId=path_root_id,
        pathPart='{hoge}',
    )
    return new_resource['id']


def setup_method(rest_api_id, resource_id):
    client.put_method(
        restApiId=rest_api_id,
        resourceId=resource_id,
        httpMethod='GET',
        authorizationType='NONE',
    )
    uri = 'arn:aws:apigateway:' + region + ':lambda:path/2015-03-31/functions/' + function + '/invocations'
    client.put_integration(
        restApiId=rest_api_id,
        resourceId=resource_id,
        httpMethod='GET',
        type='AWS',
        integrationHttpMethod='POST',
        uri=uri,
        credentials=role,
        requestTemplates={
            'application/json': get_request_template()
        },
    )
    client.put_integration_response(
        restApiId=rest_api_id,
        resourceId=resource_id,
        httpMethod='GET',
        statusCode='200',
        responseTemplates={
            'application/json': '',
        },
    )
    client.put_method_response(
        restApiId=rest_api_id,
        resourceId=resource_id,
        httpMethod='GET',
        statusCode='200',
        responseModels={
            'application/json': 'Empty',
        },
    )
    client.put_integration_response(
        restApiId=rest_api_id,
        resourceId=resource_id,
        httpMethod='GET',
        statusCode='400',
        selectionPattern='^\[400:.*',
        responseTemplates={
            'application/json': get_response_template(),
        },
    )
    client.put_method_response(
        restApiId=rest_api_id,
        resourceId=resource_id,
        httpMethod='GET',
        statusCode='400',
        responseModels={
            'application/json': 'Error',
        },
    )


def get_request_template():
    """
    ref. http://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html
    """
    return """
{
  "pathParams": {
#foreach ($key in $input.params().path.keySet())
    "$key": "$util.escapeJavaScript($input.params().path.get($key))"#if ($foreach.hasNext),#end
#end
  }
}
"""


def get_response_template():
    """
    ref. http://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html
    """
    return """
#set($data = $input.path('$'))
{
  "message" : "${data.errorMessage}"
}
"""


def deploy(rest_api_id):
    client.create_deployment(
        restApiId=rest_api_id,
        stageName='snapshot',
        stageDescription='snapshot stage',
    )
    client.update_stage(
        restApiId=rest_api_id,
        stageName='snapshot',
        patchOperations=[
            {
                'op': 'replace',
                'path': '/*/*/logging/loglevel',
                'value': 'INFO',
            },
        ],
    )

if __name__ == '__main__':
    rest_api_id = create_api()
    resource_id = create_resource(rest_api_id)
    setup_method(rest_api_id, resource_id)
    deploy(rest_api_id)
    api_url = 'https://' + rest_api_id + '.execute-api.' + region + '.amazonaws.com/snapshot/'
    print 'OK : {0}'.format(api_url + 'hoge')
    print 'NG : {0}'.format(api_url + 'fuga')
```

## Lambda
Lambdaのデプロイ方法については本題から外れるので、コードだけ。

```lambdemo.py
# -*- coding: utf-8 -*-


def lambda_handler(event, context):
    hoge = event['pathParams']['hoge']
    if hoge == 'hoge':
        return {'message': 'hogehoge'}
    else:
        raise NotHogeError(hoge)


class NotHogeError(Exception):
    def __init__(self, hoge):
        self.hoge = hoge

    def __str__(self):
        return '[400:BadRequest] {0} is not hoge'.format(self.hoge)
```
## 動作確認
上のcreateapi.pyを実行すると、(バグってなければ)こんな感じで二つURLが表示されます。

```Bash:createapi.pyの実行結果
OK : https://xxxxx.execute-api.somewhere.amazonaws.com/snapshot/hoge
NG : https://xxxxx.execute-api.somewhere.amazonaws.com/snapshot/fuga
```

OKの方にアクセスすると

```Bash:OK
$ curl https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/snapshot/hoge
{"message": "hogehoge"}
```

NGの方だと、怒られます。

```Bash:NG
$ curl https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/snapshot/fuga  -w '%{http_code}\n'
{
  "message" : "[400:BadRequest] fuga is not hoge"
}
400
```

# 説明
## Integration
### LambdaのURI
バックエンドとなるLambdaのURIを指定する必要があります。
複雑ですが仕様が決まっているようで、リージョンとLambda Function ARNから決まります。
最後の「invocations」を抜かしていると「500 internal server error」を返してくれます。地味なはまりポイント。

``` Python:URIの作り方
uri = 'arn:aws:apigateway:' + region + ':lambda:path/2015-03-31/functions/' + function + '/invocations'
```
### リクエストフォーマット変換
Velocityテンプレートを使ってLambda用のリクエスト(json)を作ります。
使用可能な変数は[リファレンス](http://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html)を参照してください。
サンプルではハードコードしてますが、実運用ではテンプレートファイルとして外出しするのが良いかと思います。

```request_template.vm
{
  "pathParams": {
#foreach ($key in $input.params().path.keySet())
    "$key": "$util.escapeJavaScript($input.params().path.get($key))"#if ($foreach.hasNext),#end
#end
  }
}
```

## Lambdaの作り方
### リクエスト
上記でフォーマット変換したデータはPythonの場合、eventにdictとして入ってます。

```Python:リクエストの取得
hoge = event['pathParams']['hoge']
```

### レスポンス
LambdaからはAPI Gatewayにレスポンスを返す際は、returnかraiseを使います。

```Python:正常終了
return {'message': 'hogehoge'}
```

```Python:異常終了
raise NotHogeError(hoge)
```

次に説明しますがreturnで返してしまうと、クライアントにはステータスコード200で返ります。
(正確にはデフォルトのステータスコードですが。。。)

## Integration Response
Lambdaからのレスポンスをクライアントレスポンスに変換します。

* 正規表現(デフォルトにする場合はなし)
* ステータスコード
* content-type
* テンプレート(VTL)
の組で指定します。

テンプレートを指定しない(''にする)場合、lambdaからのレスポンスがそのまま帰ります。
この場合はMethod Responseでresponse modelをEmptyにしておきます。

ステータスコードを200で返すだけなら簡単なのですが、クライアントエラー(400番台)やサーバーエラー(500番台)、あるいはリダイレクトしたい(300番台)等で返すことも必要になります。
そのためには、どのようなレスポンスの時にどのステータスコードにするのか、正規表現で対応づけることになります。

### 正規表現はどこを見ているのか
ここでサンプルのLambdaを書き換えてみます。

```Python:returnしてみると
else:
        #raise NotHogeError(hoge)
        return '[400:BadRequest] {0} is not hoge'.format(hoge)
```

```Bash:200で返っちゃう
$ curl https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/snapshot/fuga -w '%{http_code}\n'
"[400:BadRequest] fuga is not hoge"200
```

ステータスコードが200になってしまいました。。。
実はLambda handlerの返り値を見ているわけではないんですね。

```Python:これも
return {'message' : '[400:BadRequest] {0} is not hoge'.format(hoge)}
```
```Python:これもだめ
return {'errorMessage' : '[400:BadRequest] {0} is not hoge'.format(hoge)}
```

色々やってたどり着いたのがこれです。

```exception.py
class NotHogeError(Exception):
    def __init__(self, hoge):
        self.hoge = hoge

    def __str__(self):
        """ここ！"""
        return '[400:BadRequest] {0} is not hoge'.format(self.hoge)
```

ということで、ステータスコードを変えたいときは例外を投げようということらしいです。

### レスポンスフォーマット変換
正常系の場合はLambdaのレスポンスをパススルーで返せばよいのですが、jsonではなくXMLやHTMLで返す場合は変換する必要があります。また、異常系の場合、パススルーだとStackTraceも返ってしまい情けないことになるので、エラーメッセージだけ返すようにします。

元々ErrorというResponse Modelが定義されているので、それに合わせてテンプレートを作ります。

```response_template.vm
#set($data = $input.path('$'))
{
  "message" : "${data.errorMessage}"
}
```

# まとめ
API Gatewayってそんなに簡単じゃなくねって思いましたが、そもそもちゃんとAPI設計するならこれくらい考えろってことな気もしてきました。