iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

Lambda PowerToolsで便利にAPIを作ることができる(Swagger UIでAPIドキュメントも見れるよ)

こんにちは、インフラエンジニアのhogeです。本記事はiimonアドベントカレンダー9日目の記事となります。

はじめに

今回はLambdaの開発時におすすめのライブラリ、AWS Lambda Powertoolsについて書きたいと思います。

Lambda Powertoolsの機能は多岐に渡りますが、この記事ではAPIを便利に作るということにフォーカスを当てて解説したいと思いますので、イベントハンドラの機能のみの解説になります(他のユーティリティについては触れません)。この記事を見て本ライブラリを使ってLambdaでAPIを作るきっかけになれば幸いです。

Lambda Powertoolsとは?

Lambda Powertoolsは、サーバーレスのベストプラクティスを実装するためのユーティリティライブラリです。

Powertools for AWS Lambda (Python) is a developer toolkit to implement Serverless best practices and increase developer velocity.

公式の説明は上記の通りで、toolkitと記載されているように、フレームワークのようなものではなく、Lambdaを開発する上で便利なツールがまとまったものになります。

現在対応している言語はPythonJava・TypeScript・.NETです。 本記事でのサンプルコードは全てPythonになります。

導入方法

導入方法として、以下の2通りの方法があります。

  • コンテナイメージに同梱する
  • Lambdaレイヤー

コンテナイメージに同梱する場合は、pip installでaws-lambda-powertools をインストールします。

pip install aws-lambda-powertools

Lambdaレイヤーを使う場合は、「AWSレイヤー」にPowertoolsが既に用意されているので、それを指定するだけで導入することができます。

Lambda Powertoolsのイベントハンドラの機能

この例ではLambda Function UrlsでAPIを作成します。 よくあるTodoのCRUD処理を行うAPIを例に機能を解説します。

コード

from aws_lambda_powertools.event_handler import LambdaFunctionUrlResolver
from aws_lambda_powertools import Logger
from typing import List,Dict
from pydantic import BaseModel, Field
from aws_lambda_powertools.utilities.typing import LambdaContext
import requests
from requests import Response

logger = Logger()

app = LambdaFunctionUrlResolver(enable_validation=True)
app.enable_swagger(path="/_swagger")

class Todo(BaseModel):
    userId: int
    id_: int = Field(alias="id")
    title: str
    completed: bool

@app.get("/todos")
def get_todos() -> Dict[str,List[Todo]]:
    todo_id: str = app.current_event.get_query_string_value(name="id", default_value="")
    endpoint = "https://jsonplaceholder.typicode.com/todos"
    if todo_id:
        endpoint = f"{endpoint}/{todo_id}"
    todos: Response = requests.get(endpoint)
    todos.raise_for_status()
    if isinstance (todos.json(),dict):
        return {"todos": [todos.json()]}
    return {"todos": todos.json()[:10]}

@app.post("/todos")
def create_todo(todo: Todo) -> Todo:
    todo_data: dict = todo.dict(by_alias=True)
    response: Response = requests.post(
        "https://jsonplaceholder.typicode.com/todos", json=todo_data
    )
    response.raise_for_status()
    return Todo(**response.json())

@app.put("/todos/<todo_id>")
def update_todo(todo_id: str,todo:Todo) -> Todo:
    todo_data: dict = todo.dict(by_alias=True)
    response: Response = requests.put(
        f"https://jsonplaceholder.typicode.com/todos/{todo_id}", json=todo_data
    )
    response.raise_for_status()
    return Todo(**response.json())

@logger.inject_lambda_context
def lambda_handler(event: dict, context: LambdaContext) -> dict:
    logger.info("hoge")
    return app.resolve(event, context)

FlaskやFastAPIのようにデコレータでパスルーティングをする

素のLambda関数でパスルーティングが適切に行えるまともなAPIを作るのはするのはなかなか面倒な作業ですが、event handlerの機能を使えば以下のようにflaskやfastapiライクに簡単に実装することができます。

@app.get("/todos")
def get_todos() -> Dict[str,List[Todo]]:
    todo_id: str = app.current_event.get_query_string_value(name="id", default_value="")
    endpoint = "https://jsonplaceholder.typicode.com/todos"
    if todo_id:
        endpoint = f"{endpoint}/{todo_id}"
    todos: Response = requests.get(endpoint)
    todos.raise_for_status()
    if isinstance (todos.json(),dict):
        return {"todos": [todos.json()]}
    return {"todos": todos.json()[:10]}

@app.post("/todos")
def create_todo(todo: Todo) -> Todo:
    todo_data: dict = todo.dict(by_alias=True)
    response: Response = requests.post(
        "https://jsonplaceholder.typicode.com/todos", json=todo_data
    )
    response.raise_for_status()
    return Todo(**response.json())

@app.put("/todos/<todo_id>")
def update_todo(todo_id: str,todo:Todo) -> Todo:
    todo_data: dict = todo.dict(by_alias=True)
    response: Response = requests.put(
        f"https://jsonplaceholder.typicode.com/todos/{todo_id}", json=todo_data
    )
    response.raise_for_status()
    return Todo(**response.json())

指定されたパスと HTTP メソッドを処理できるようにリゾルバーを初期化する必要があります。

from aws_lambda_powertools.event_handler import LambdaFunctionUrlResolver

app = LambdaFunctionUrlResolver(enable_validation=True)

def lambda_handler(event: dict, context: LambdaContext) -> dict:
    return app.resolve(event, context)

以下のように動作確認をすると正しくtodoが取得できます

curl -i https://ohpezd7em4wzbystokhsji2tbm0nodtw.lambda-url.ap-northeast-1.on.aws/todos
HTTP/1.1 200 OK
Date: Sat, 09 Dec 2023 15:18:21 GMT
Content-Type: application/json
Content-Length: 830
Connection: keep-alive
x-amzn-RequestId: bab20a70-9135-4945-8174-9df5efd5c04e
X-Amzn-Trace-Id: root=1-6574853d-6d568d0f4c9d4e4045c2ad2a;sampled=0;lineage=f047d065:0

{"todos":[{"userId":1,"id":1,"title":"delectus aut autem","completed":false},{"userId":1,"id":2,"title":"quis ut nam facilis et officia qui","completed":false},{"userId":1,"id":3,"title":"fugiat veniam minus","completed":false},{"userId":1,"id":4,"title":"et porro tempora","completed":true},{"userId":1,"id":5,"title":"laboriosam mollitia et enim quasi adipisci quia provident illum","completed":false},{"userId":1,"id":6,"title":"qui ullam ratione quibusdam voluptatem quia omnis","completed":false},{"userId":1,"id":7,"title":"illo expedita consequatur quia in","completed":false},{"userId":1,"id":8,"title":"quo adipisci enim quam ut ab","completed":true},{"userId":1,"id":9,"title":"molestiae perspiciatis ipsa","completed":false},{"userId":1,"id":10,"title":"illo est ratione doloremque quia maiores aut","completed":true}]}%

また、無効なパスにリクエストした場合、きちんと404で返してくれます

curl -i https://ohpezd7em4wzbystokhsji2tbm0nodtw.lambda-url.ap-northeast-1.on.aws/invalid_path
HTTP/1.1 404 Not Found
Date: Sat, 09 Dec 2023 15:17:31 GMT
Content-Type: application/json
Content-Length: 40
Connection: keep-alive
x-amzn-RequestId: da288619-db8f-4764-8b7f-43fb4a77dfc9
X-Amzn-Trace-Id: root=1-6574850b-10fb4c347eb89fe23cc6d23f;sampled=0;lineage=f047d065:0

{"statusCode":404,"message":"Not found"}%

レスポンスが成功の場合、デフォルトでは200ですが、レスポンスと一緒にステータスコードも返すことでカスタマイズすることができます。 また、レスポンスは、辞書型のオブジェクトをリターンすると自動でJSONシリアライズされるので、json.dumps()などは不要です。

return Todo(**response.json()),201

以下はPOSTの例

curl -i  --location -X POST 'https://ohpezd7em4wzbystokhsji2tbm0nodtw.lambda-url.ap-northeast-1.on.aws/todos'  --header 'Content-Type: application/json' \
--data-raw '{"userId":1,"id":2,"title":"et porro tempora","completed":"True"}'
HTTP/1.1 201 Created
Date: Sat, 09 Dec 2023 15:21:26 GMT
Content-Type: application/json
Content-Length: 65
Connection: keep-alive
x-amzn-RequestId: 1edc720f-019c-4a5d-aee2-133f1c0409fb
X-Amzn-Trace-Id: root=1-657485f5-0cc1733e2e296456399f75f9;sampled=0;lineage=f047d065:0

上記のことを行いたいだけなら、慣れ親しんだツールとしてAWSGIを使用し、Flaskを組み込む方法も良い選択だと思いますが、AWSGIはAPI Gateway経由のeventのみしか処理できません。これに対して、AWS Lambda Powertoolsは、API Gateway、Function URLs、ALB(Application Load Balancer)経由のイベントを処理することができます。例えばAPI Gatewayのイベントを処理したい場合、リゾルバを以下のように書き換えるだけで対応することができます。

# app = LambdaFunctionUrlResolver(enable_validation=True)
app = APIGatewayRestResolver(enable_validation=True)

動的ルーティング

デコレータに@app.put("/todos/<todo_id>")のように登録することで動的ルーティングにも対応することができます。

@app.put("/todos/<todo_id>")
def update_todo(todo_id: str,todo:Todo) -> Todo:
    todo_data: dict = todo.dict(by_alias=True)
    response: Response = requests.put(
        f"https://jsonplaceholder.typicode.com/todos/{todo_id}", json=todo_data
    )
    response.raise_for_status()
    return Todo(**response.json())

バリデーション

ゾルバーに引数enable_validation=Trueを渡すことで、リクエスト、レスポンスにバリデーションをすることができます

app = LambdaFunctionUrlResolver(enable_validation=True)

pydantincのbasemodelを継承したクラスを作成します

class Todo(BaseModel):
    userId: int
    id_: int = Field(alias="id")
    title: str
    completed: bool

basemodelを継承したクラスを型に設定します

@app.post("/todos")
def create_todo(todo: Todo) -> Todo:
    todo_data: dict = todo.dict(by_alias=True)
    response: Response = requests.post(
        "https://jsonplaceholder.typicode.com/todos", json=todo_data
    )
    response.raise_for_status()
    return Todo(**response.json()),201

型に違反したリクエストを送った場合、422 Unprocessable Entityエラーが返されます

$ curl -i  --location -X POST 'https://ohpezd7em4wzbystokhsji2tbm0nodtw.lambda-url.ap-northeast-1.on.aws/todos'  --header 'Content-Type: application/json' \
--data-raw '{"userId":"a","id":2,"title":1,"completed":"True"}' 
HTTP/1.1 422 Unprocessable Entity
Date: Sat, 09 Dec 2023 15:43:30 GMT
Content-Type: application/json
Content-Length: 122
Connection: keep-alive
x-amzn-RequestId: e1715329-2654-47d7-a3ea-1d3d242ebeb4
X-Amzn-Trace-Id: root=1-65748b22-24ec1e956eb255be5ee2d731;sampled=0;lineage=f047d065:0

{"statusCode":422,"detail":[{"loc":["body","userId"],"type":"int_parsing"},{"loc":["body","title"],"type":"string_type"}]}%

Swagger UIでAPIドキュメントを作成する

swagger UIを有効にすることができます(デフォルトのパスは/swagger)

app.enable_swagger(path="/_swagger")

/_swaggerエンドポイントにアクセスすると、Swagger UIでAPIドキュメントを見ることができます

Lambda PowertoolsでAPIを作って感じたメリット

ざっくり言うと、以下のメリットがあると感じました。

  1. バリデーションを自前で実装しなくて良くなる
  2. 型の恩恵を受けることができる
  3. 型からAPIドキュメントを自動的に生成してくれる
  4. Lambdaに入ってくるイベント形式を知らなくても実装できる。

以下はLambda function urlsのイベントの構造です。これを見て実装すること自体は難しくないですが、Lambda Powertoolsを使うことで、この複雑なイベント構造を理解する必要が減り、よりビジネスロジックの実装に集中できるようになります。また、API GatewayとLambda function urlsでイベント形式が異なりますが、リゾルバが差異を吸収してくれて同じように処理を書くことができるのもメリットかと思います。

{
  "version": "2.0",
  "routeKey": "$default",
  "rawPath": "/my/path",
  "rawQueryString": "parameter1=value1&parameter1=value2&parameter2=value",
  "cookies": [
    "cookie1",
    "cookie2"
  ],
  "headers": {
    "header1": "value1",
    "header2": "value1,value2"
  },
  "queryStringParameters": {
    "parameter1": "value1,value2",
    "parameter2": "value"
  },
  "requestContext": {
    "accountId": "123456789012",
    "apiId": "<urlid>",
    "authentication": null,
    "authorizer": {
        "iam": {
                "accessKey": "AKIA...",
                "accountId": "111122223333",
                "callerId": "AIDA...",
                "cognitoIdentity": null,
                "principalOrgId": null,
                "userArn": "arn:aws:iam::111122223333:user/example-user",
                "userId": "AIDA..."
        }
    },
    "domainName": "<url-id>.lambda-url.us-west-2.on.aws",
    "domainPrefix": "<url-id>",
    "http": {
      "method": "POST",
      "path": "/my/path",
      "protocol": "HTTP/1.1",
      "sourceIp": "123.123.123.123",
      "userAgent": "agent"
    },
    "requestId": "id",
    "routeKey": "$default",
    "stage": "$default",
    "time": "12/Mar/2020:19:03:58 +0000",
    "timeEpoch": 1583348638390
  },
  "body": "Hello from client!",
  "pathParameters": null,
  "isBase64Encoded": false,
  "stageVariables": null
}

まとめ

Lambda Powertoolsを使うことで素のLambdaで実装する場合と比べて、とても快適にAPIを作ることができました。 大量のエンドポイントを持つ巨大なAPIを作る場合は、Lambdaは適していないかもしれませんが、ルーティングが必要だったり、複数のメソッドをサポートする必要がある場合、Lambda Powertoolsを使ってこれらの処理を簡潔かつ安全に実装できるのは非常に有用だと思いました。 弊社ではAPIサーバから部分的に機能を切り出してLambdaでAPIを作ることがあるので、今後は積極的にLambda Powertoolsを使って効率的にAPIを実装したいと思いました。

最後に

この記事を読んで興味を持って下さった方がいらっしゃればカジュアルにお話させていただきたく、是非ご応募をお願いします。

Wantedly / Green

次のアドベントカレンダーの記事はなかむーさんです!どんな記事を書くのかとても楽しみです!