FastAPIを用いた複雑な条件によるフィルタリングの実装
こんにちは、ものレボの橋本です。
今回は以前の記事でも軽く触れましたが、ものレボのバックエンドの実装で使っているFastAPIについて、実務上あった課題への対応の概略について、書きたいと思います。
いきなり本題に入りますが、以下のようなデータがあった場合に、options
の中身をフィルタリングしてAPIから返したいようなケースでした。
{
"items": [
{
"id": 1,
"name": "name1",
"options": {}
},
{
"id": 2,
"name": "name2",
"options": {
"int": 1,
"float": 1.1,
"str": "foobazbar",
"date": "2020-01-01"
}
}
]
}
options
の中のデータ構造が決まっていれば、よくあるRESTfulなAPIなら/items?options_int=1
のようなクエリパラメータでフィルタリングを実現することができますが、今回のケースでは、options
は、クライアント側が任意にPOSTしたデータを保持するためのフィールドとなっていて、サーバサイドではどのようなデータ構造となっているかわからない状態で、常にクライアントサイドのみで使われるものでした。
そこで以下のような実装を用いて、フィルタリングを実現しました。
import json
from enum import Enum, auto
from fastapi import FastAPI, Query, Body
from pydantic import BaseModel, Field, Json
from typing import Any, Optional
app = FastAPI()
class StrEnum(str, Enum):
@staticmethod
def _generate_next_value_(name: str, start: int, count: int, last_values: list[Any]) -> str:
return name.lower()
class Condition(StrEnum):
EQ = auto() # equal ==
NEQ = auto() # not equal !=
GT = auto() # greater than <
GE = auto() # greater equal <=
LT = auto() # less than >
LE = auto() # less equal >=
class Filter(BaseModel):
key: str = Field(title="key")
val: Any = Field(title="value")
cond: Condition = Field(title="condition")
class Filters(BaseModel):
options: list[Filter] = Field([], title="optionsのフィルタ")
class Item(BaseModel):
id: int = Field(title="ID")
name: str = Field(title="名前")
options: dict[str, Any] = Field(title="options")
def options_filter(f: Filter, item: Item) -> bool:
if f.key not in item.options:
return False
val = item.options[f.key]
if type(f.val) != type(val):
return False
if f.cond == Condition.EQ:
return val == f.val
if f.cond == Condition.NEQ:
return val != f.val
if f.cond == Condition.GT:
return val > f.val
if f.cond == Condition.GE:
return val >= f.val
if f.cond == Condition.LT:
return val < f.val
if f.cond == Condition.LE:
return val <= f.val
return False # pragma: no cover
items = [
Item(id=1, name="名前1", options={}),
Item(id=2, name="名前2", options={"int": 1, "float": 1.1, "date": "2021-01-01", "str": "foobazbar"}),
]
@app.post("/items/search", response_model=list[Item])
def search_items(
name: str = Query("", title="name", description="nameの部分一致"),
filters: Filters = Body(Filters(), title="filter", description="options filter")
) -> Any:
results = items
if name:
results = list(filter(lambda x: name in x.name, results))
for o in filters.options:
results = list(filter(lambda x: options_filter(o, x), results))
return results
少し長くなってしまいましたが、ほぼ定数や型の定義部分で重要なのは、filters: Filters = Body(Filters(), title="filter", description="options filter")
の部分です。
これが何を行っているかというと、本来取得系であれば、/items
をエンドポイントにして、GETでリクエストするのが一般的ですが、あえてPOSTで受けてやることで、複雑な条件部分をjsonで受け取ることができるようにしています。
FastAPIとしては、GETリクエストでもリクエストボディを受け取ることができるので、POSTではなく、GETで受けてもよいのですが、公式のドキュメントにも下記のようにあり、稀なユースケースとなるため、/items/search
と検索用のエンドポイントを用意してやることで対処しています。
データを送るには、
https://fastapi.tiangolo.com/ja/tutorial/body/POST
(もっともよく使われる)、PUT
、DELETE
またはPATCH
を使うべきです。
GET リクエストでボディを送信することは、仕様では未定義の動作ですが、FastAPI でサポートされており、非常に複雑な(極端な)ユースケースにのみ対応しています。
また、その他の部分の実装は、key, value, conditonを受け取って条件通りにフィルタリングするだけのもので特筆すべきことはないため、ここでは解決を省略します。
上記の実装により、以下のようにリクエストボディにフィルタリング用のjsonを指定することで、期待した動作をさせることができました。
bash-5.1$ http POST 'http://127.0.0.1:8000/items/search'
HTTP/1.1 200 OK
content-length: 136
content-type: application/json
date: Thu, 15 Apr 2021 05:11:58 GMT
server: uvicorn
[
{
"id": 1,
"name": "名前1",
"options": {}
},
{
"id": 2,
"name": "名前2",
"options": {
"date": "2021-01-01",
"float": 1.1,
"int": 1,
"str": "foobazbar"
}
}
]
bash-5.1$ cat filters.json
{
"options": [
{
"key": "float",
"val": 1.0,
"cond": "ge"
}
]
}
bash-5.1$ http POST 'http://127.0.0.1:8000/items/search' < filters.json
HTTP/1.1 200 OK
content-length: 97
content-type: application/json
date: Thu, 15 Apr 2021 05:12:03 GMT
server: uvicorn
[
{
"id": 2,
"name": "名前2",
"options": {
"date": "2021-01-01",
"float": 1.1,
"int": 1,
"str": "foobazbar"
}
}
]
しかし、取得系の処理にPOSTを使うことや、クエリパラメータとリクエストボディを併用する気持ち悪さもあるため、filtersの部分をjsonに変更してみます。
@app.get("/items", response_model=list[Item])
def read_items(
name: str = Query("", title="name", description="nameの部分一致"),
filters: Optional[Json] = Query(None, title="filter", description="options filter")
) -> Any:
try:
validated_filters = Filters(**filters)
except ValidationError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=e.errors(),
)
results = items
if name:
results = list(filter(lambda x: name in x.name, results))
for o in validated_filters.options:
results = list(filter(lambda x: options_filter(o, x), results))
return results
先程のコードに上記のような実装を追加することで、filters
でNone
もしくは、jsonをデコードしたdict
が受け取れるので、それを更にFilters
にしています。
バリデーションエラーとなった場合には、通常のFastAPIの実装と同様のレスポンスになるように、ステータスコード=422でエラーを返してやっています。
(ここはもう少しいいやり方がある気がしますが、執筆中に思いつかなかったためより良い方法があれば教えてほしいです。)
bash-5.1$ http GET 'http://127.0.0.1:8000/items?filters=%7B%22options%22%3A%20%5B%7B%22key%22%3A%20%22float%22%2C%20%22val%22%3A%201.0%2C%20%22cond%22%3A%20%22ge%22%7D%5D%7D'
HTTP/1.1 200 OK
content-length: 97
content-type: application/json
date: Thu, 15 Apr 2021 05:17:55 GMT
server: uvicorn
[
{
"id": 2,
"name": "名前2",
"options": {
"date": "2021-01-01",
"float": 1.1,
"int": 1,
"str": "foobazbar"
}
}
]
これで期待したような実装ができましたが、filters
のクエリパラメータの可読性が著しく低く、options
に複雑なデータが入りフィルタリングが複雑になった場合URLの長さも問題になりそうなため、risonを導入したいと思います。
@app.get("/items", response_model=list[Item])
def read_items(
name: str = Query("", title="name", description="nameの部分一致"),
filters: Optional[str] = Query(None, title="filter", description="options filter")
) -> Any:
if filters:
try:
decoded_filters = prison.loads(filters)
validated_filters = Filters(**decoded_filters)
except prison.decoder.ParserException as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except ValidationError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=e.errors(),
)
results = items
if name:
results = list(filter(lambda x: name in x.name, results))
for o in validated_filters.options:
results = list(filter(lambda x: options_filter(o, x), results))
return results
filters
をpydantic.Json
から、str
で受け取るように変更し、それをprison
でデコードする処理を追加します。
理想を言うのであれば、pydantic.Json
のように、Rison
型を用意してやってクエリパラメータで受け取った時点で、バリデーションとデコードがされているとベストです。
bash-5.1$ http GET 'http://127.0.0.1:8000/items?filters=(options:!((cond:ge,key:float,val:1.0)))'
HTTP/1.1 200 OK
content-length: 97
content-type: application/json
date: Thu, 15 Apr 2021 05:34:17 GMT
server: uvicorn
[
{
"id": 2,
"name": "名前2",
"options": {
"date": "2021-01-01",
"float": 1.1,
"int": 1,
"str": "foobazbar"
}
}
]
URLもスッキリと短くなりました。
少し可読性は下がりますが、key, val, condとしているものをk, v, cなど1文字にしてより短いURLにするのも有りだと思います。
最終的なコードはこのリポジトリにありますので、眺めてみたり、より良い方法があればフォークしてPRをいただけるととても嬉しいです。