🍙

Chaliceで簡単にサーバレスREST APIを作る

2022/06/21に公開

どんな人に向いてる?

  • REST APIを作りたいけどサーバとか運用したくない
  • APIゲートウェイとかLambdaを手動で管理するのだるい
  • AWSでなんとかしたい
  • 運用費用安く済ませたい

Chaliceって何? 何ができるもの?

Amazon AWSが開発しているオープンソースのWebフレームワーク。
簡単な記述でREST APIを定義し、ワンタッチでAWS API Gataeway + AWS Lambda上にデプロイすることができる

Chaliceを選ぶときの留意点

  • フルスタックなWebフレームワークではない
    いわゆるMVCを完備したフレームワークではなく、Vの部分に特化している
    MやCは適宜ORマッパやAWSサービスを使う必要がある。(SQLAlchemyとかboto3で連携)

  • Lambdaで導入できるライブラリに限定?
    Chaliceでは未確認だが、Lambdaの場合Importできるライブラリに制限があったはず。

  • 永続化について
    エフェメラルなコンテナで実行する都合上、メモリ上のデータの永続性が担保されない
    セッションの維持やデータの保管が必要な場合、適宜ElasticCacheやS3等を使って永続化すること

環境構築の前提

  • Python 3の使える環境(pyenv, python venv等でのバージョン管理推奨)

.aws設定ファイルの作成

  • クレデンシャルやコンフィグを設定する
MacBook-Air:codes $ cat ~/.aws/credentials
[default]
aws_access_key_id=XXXX
aws_secret_access_key=XXXXX

MacBook-Air:codes $ cat ~/.aws/config
[default]
region=us-west-2
output=json

Chalice プロジェクトの作成

chalice new-project helloworld

基本的な書き方例

  • リクエストに対してjsonレスポンスを返す例
from chalice import Chalice

app = Chalice(app_name='helloworld')
@app.route('/')
def index():
    return {'hello': 'world'}

デプロイ/アンデプロイ

AWSへのデプロイ

chalice deploy

ログ

$ chalice deploy
Creating deployment package.
Creating IAM role: helloworld-dev
Creating lambda function: helloworld-dev
Creating Rest API
Resources deployed:
  - Lambda ARN: arn:aws:lambda:us-west-2:99999944444:function:helloworld-dev
  - Rest API URL: https://jjjsssiii0.execute-api.us-west-2.amazonaws.com/api/

AWSコンソールで見ると、configで指定したリージョンにLambdaとAPIGWでアプリケーションが構築されているのが分かる

localへのdeploy

chalice local

ローカルにサーバが立ち上がり、ブラウザ等でアクセスできる

環境をわけてdeployする

以下の様に --stage 引数で任意のステージ名を指定して環境を分けることができる
(指定しないと自動的にdevが指定される)

MacBook-Air:helloworld $ chalice deploy --stage staging
Creating deployment package.
Reusing existing deployment package.
Creating IAM role: helloworld-staging
Creating lambda function: helloworld-staging
Creating Rest API
Resources deployed:
  - Lambda ARN: arn:aws:lambda:us-west-2:999999444444:function:helloworld-staging
  - Rest API URL: https://egmfhnogug.execute-api.us-west-2.amazonaws.com/api/

MacBook-Air:helloworld $ chalice deploy --stage production
Creating deployment package.
Reusing existing deployment package.
Creating IAM role: helloworld-production
Creating lambda function: helloworld-production
Creating Rest API
Resources deployed:
  - Lambda ARN: arn:aws:lambda:us-west-2:999999444444:function:helloworld-production
  - Rest API URL: https://ozkhx5xhwf.execute-api.us-west-2.amazonaws.com/api/

deployしたサーバへのURLを取得する

$ chalice url
https://y6zfasiii0.execute-api.us-west-2.amazonaws.com/api/

アプリケーションの削除

$chalice delete

ステージを複数定義している場合は

chalice delete --stage dev

という感じで指定できる

色々な書き方

URLパラメータの取得方法

@app.route('/cities/{city}')
def state_of_city(city):
   return {'state': CITIES_TO_STATE[city]}

というように、routeの宣言でパラメータ位置を指定し、メソッド側の引数でそのパラメータ名を指定すると
その場所に入っていたパラメータを拾うことができる

エラーハンドリングの追加

例:400 Bad Requestを返す

from chalice import Chalice
from chalice import BadRequestError

app = Chalice(app_name='helloworld')

CITIES_TO_STATE = {
    'seattle': 'WA',
    'portland': 'OR',
}

..(同上)..

@app.route('/cities/{city}')
def state_of_city(city):
    try:
        return {'state': CITIES_TO_STATE[city]}
    except KeyError:
        raise BadRequestError("Unknown city '%s', valid choices are: %s" % (city, ', '.join(CITIES_TO_STATE.keys())))

これで存在しないcityをリクエストすると400 BadRequestErrorが返却される

POSTやPUTのAPI定義

PUTメソッドの例。methodsで宣言

@app.route('/resource/{value}', methods=['PUT'])
def put_test(value):
    return {"value": value}

あまり使わないかもしれないが、複数メソッドも定義できる

@app.route('/myview', methods=['POST', 'PUT'])
def myview():
    pass

メソッドで挙動を分けたい場合は

@app.route('/myview', methods=['POST'])
def myview_post():
    pass

@app.route('/myview', methods=['PUT'])
def myview_put():
    pass

リクエストBodyの取得

...
from chalice import NotFoundError

OBJECTS = {
}

@app.route('/objects/{key}', methods=['GET', 'PUT'])
def myobject(key):
    request = app.current_request
    if request.method == 'PUT':
        OBJECTS[key] = request.json_body
    elif request.method == 'GET':
        try:
            return {key: OBJECTS[key]}
        except KeyError:
            raise NotFoundError(key)

・app.current_requestがリクエストオブジェクト
・request.methodでメソッドを取得できる
・request.json_bodyでリクエストのjsonボディを取得できる

その他、app.currenct_requestから取れるもの
current_request.query_params
クエリパラメータ (辞書型) (URLの?以降のパラメータ)
current_request.headers
ヘッダー (辞書型) ※全て小文字にキャストされるので注意
current_request.uri_params
URIパラメータ (辞書型) (URLのパス部分のパラメータ)
current_request.method
リクエストのメソッド (文字列)
current_request.json_body
ボディ (json)
current_request.raw_body
ボディ (生のままのボディ (bytes型なのでキャスト要))
current_request.context
コンテキスト情報 (※説明は後述、中身要確認)
ソースIPやuserAgentはここで取得可能
current_request.stage_vars
APIGW のステージの設定に関する情報 (※説明は後述、中身要確認)
current_request自体をdict化することも可能

app.current_request.to_dict()

current_requestの中身の例:

{
    "context": {
        "apiId": "apiId",
        "httpMethod": "GET",
        "identity": {
            "accessKey": null,
            "accountId": null,
            "apiKey": null,
            "caller": null,
            "cognitoAuthenticationProvider": null,
            "cognitoAuthenticationType": null,
            "cognitoIdentityId": null,
            "cognitoIdentityPoolId": null,
            "sourceIp": "1.1.1.1",
            "userAgent": "HTTPie/0.9.3",
            "userArn": null
        },
        "requestId": "request-id",
        "resourceId": "resourceId",
        "resourcePath": "/introspect",
        "stage": "dev"
    },
    "headers": {
        "accept": "*/*",
        ...
        "x-testheader": "Foo"
    },
    "method": "GET",
    "query_params": {
        "query1": "value1",
        "query2": "value2"
    },
    "raw_body": null,
    "stage_vars": null,
    "uri_params": null
}

コンテンツタイプの選択(json以外のコンテンツタイプ受け付け)

import sys

from chalice import Chalice
from urllib.parse import urlparse, parse_qs

app = Chalice(app_name='helloworld')


@app.route('/', methods=['POST'],
           content_types=['application/x-www-form-urlencoded'])
def index():
    parsed = parse_qs(app.current_request.raw_body.decode())
    return {
        'states': parsed.get('states', [])
    }

上記の様に、content_typesで指定できる(リスト型なので複数指定可)
・指定外の形式を送られると415 Unsuported media
・app.current_request.json_bodyはapplication/jsonタイプのリクエストでのみ生成される

curl -i -X POST -H"Content-Type: application/json" https://XXXXXX.execute-api.us-west-2.amazonaws.com/api/ -d'{"chalice":"helloapi"}'

レスポンスBodyのカスタマイズ

・Chaliceでは普通にオブジェクトをreturnすると、シリアライズしてJSONに変換にしてレスポンスを作る
・JSON以外でレスポンスしたい場合は以下の様にする
・プレーンテキストを返却する例

@app.route('/')
def index():
    return Response(body='hello world!',
                    status_code=200,
                    headers={'Content-Type': 'text/plain'})

JSONをgzip圧縮して返却する

import json
import gzip
from chalice import Chalice, Response

app = Chalice(app_name='compress-response')
app.api.binary_types.append('application/json')

@app.route('/')
def index():
    blob = json.dumps({'hello': 'world'}).encode('utf-8')
    payload = gzip.compress(blob)
    custom_headers = {
        'Content-Type': 'application/json',
        'Content-Encoding': 'gzip'
    }
    return Response(body=payload,
                    status_code=200,
                    headers=custom_headers)

ポイントは以下
・gzipをインポートする
・app.api.binary_types.appendでapplication/jsonを追加することを宣言する
・jsonレスポンスをencodeしてblobを作り、gzip.compressで圧縮してpayloadを作る
・Responseオブジェクトにbody引数にpayloadを渡す

このように、Responseオブジェクトを使うことで、柔軟なレスポンス作成ができる

ファイルの分割

app.pyと並列にchalicelibフォルダを作り、配下にpyファイルを置くことで、ロジックの切り出しができる。
※それ以外の命名だとLambdaに反映されないっぽい。

環境変数のコード管理

環境変数はコンソールで設定してもよいが、以下のように.chalice/config.json に記載しておくこともできる

{
  "version": "2.0",
  "app_name": "xxx",
  "environment_variables": {
    "env_var_common":"is_common"
  },
  "stages": {
    "dev": {
      "api_gateway_stage": "api",
      "environment_variables": {
        "env_var_stage":"is_dev"
      }
    },
    "local": {
      "environment_variables": {
        "env_var_stage":"is_local"
      }
    }
  }
}

・app_nameはchaliceのプロジェクト名
・environment_variables: 全てのステージに共通に適用される環境変数
・stages以下: それぞれdev, localなど名前に合致するステージで利用される環境変数

CORSサポート

@app.route('/supports-cors', methods=['PUT'], cors=True)
def supports_cors():
    return {}

・上記のようにcors=Trueを指定することで、CORSに対応させられる
さらに細かく設定したい場合は

from chalice import CORSConfig
cors_config = CORSConfig(
    allow_origin='https://foo.example.com',
    allow_headers=['X-Special-Header'],
    max_age=600,
    expose_headers=['X-Special-Header'],
    allow_credentials=True
)
@app.route('/custom-cors', methods=['GET'], cors=cors_config)
def supports_custom_cors():
    return {'cors': True}

複数のORIGINを許可したい場合の例:

from chalice import Chalice, Response

app = Chalice(app_name='multipleorigincors')

_ALLOWED_ORIGINS = set([
    'http://allowed1.example.com',
    'http://allowed2.example.com',
])


@app.route('/cors_multiple_origins', methods=['GET', 'OPTIONS'])
def supports_cors_multiple_origins():
    method = app.current_request.method
    if method == 'OPTIONS':
        headers = {
            'Access-Control-Allow-Method': 'GET,OPTIONS',
            'Access-Control-Allow-Origin': ','.join(_ALLOWED_ORIGINS),
            'Access-Control-Allow-Headers': 'X-Some-Header',
        }
        origin = app.current_request.headers.get('origin', '')
        if origin in _ALLOWED_ORIGINS:
            headers.update({'Access-Control-Allow-Origin': origin})
        return Response(
            body=None,
            headers=headers,
        )
    elif method == 'GET':
        return 'Foo'

S3を使った情報の永続化

OBJECTS = {
}

@app.route('/objects/{key}', methods=['GET', 'PUT'])
def myobject(key):
    request = app.current_request
    if request.method == 'PUT':
        OBJECTS[key] = request.json_body
    elif request.method == 'GET':
        try:
            return {key: OBJECTS[key]}
        except KeyError:
            raise NotFoundError(key)

上記のコードで、指定したKeyに対してオブジェクトをPUTメソッドで保存し、同じkeyでGETできるようになるが、
所詮Lambdaで動いているため永続性は保証されない(Lambdaを実行しているコンテナが消滅すると揮発してしまう)

以下ではS3を使って永続化を行う。

$ pip install boto3
$ pip freeze | grep boto3 >> requirements.txt
import json
import boto3
from botocore.exceptions import ClientError

from chalice import NotFoundError


S3 = boto3.client('s3', region_name='us-west-2')
BUCKET = 'your-bucket-name'


@app.route('/objects/{key}', methods=['GET', 'PUT'])
def s3objects(key):
    request = app.current_request
    if request.method == 'PUT':
        S3.put_object(Bucket=BUCKET, Key=key,
                      Body=json.dumps(request.json_body))
    elif request.method == 'GET':
        try:
            response = S3.get_object(Bucket=BUCKET, Key=key)
            return json.loads(response['Body'].read())
        except ClientError as e:
            raise NotFoundError(key)

とすることで、S3のバケット上にオブジェクトが保存され、永続化することができる
この際、必要なIAMポリシーは自動的に設定される(※すごい)

生成されるポリシーは以下で確認できる

$ chalice gen-policy
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": [
        "s3:ListAllMyBuckets"
      ],
      "Resource": [
        "*"
      ],
      "Effect": "Allow",
      "Sid": "9155de6ad1d74e4c8b1448255770e60c"
    }
  ]
}
  • 手動でポリシーを設定する
    もし自動設定ポリシーを使用せず、手動で設定したい場合は、
"manage_iam_role":false
"iam_role_arn":"arn:aws:iam::<account-id>:role/<role-name>"
  • 認証のカスタマイズ
    以下の様な方法で認証を行うことができる
  • API Key
  • AWS IAM
  • Cognito User Pools
  • Custom Auth Handler
    例: API Keyによる認証
@app.route('/authenticated', methods=['GET'], api_key_required=True)
def authenticated():
    return {"secure": True}

ここでいうAPI KeyはAWSアクセスキーではなく、
Amazon API GatewayのAPIキーのことを指す
https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/welcome.html

作り方↓

  1. APIGWのAPIを選択後、左側ペインから"API Keys"を選択

  2. Createを実施

  3. Usage Plansを作成
    ここで、秒間リクエスト許容数等の制限を定義する

throttling: rate: 一秒あたりにリクエストできる回数
throttling: burst: Tokenブランケットに蓄積できるToken数
quota: このUsage planが適用されたユーザが日、週、月ごとにAPI発行できる上限

4.UsageをAPIステージ(helloworldとか)と紐づける
5. 作成した鍵をUsager Planに紐づける

つまり、鍵 -> Usage Plan -> API Stageというモデルになっている(それぞれ1:n)

ここまで実施した上でリクエストすると、

$ curl -H"X-Api-Key:rAjMYhVQkCXXXXXXXXXXXXXXXX" https://y6zfasiii0.execute-api.us-west-2.amazonaws.com/api/authenticated
{"secure":true}

という形でリクエストを認証できる

主に参考したドキュメント

https://aws.github.io/chalice/quickstart.html

ーーー以下はAWS APIGWと連携させる使い方。event sourceで

Discussion