FastAPI를 배워보자 5일차 - Query, Path

1

fastapi

목록 보기
5/13

Query parameter와 Path parameter

Query parameters and string validations

fastapi는 각 parameter들에 대해서 추가적인 정보를 넣도록 할 수 있고, validation을 할 수 있도록 할 수 있다.

다음의 예제를 보도록하자.

from fastapi import FastAPI
from typing import Union

app = FastAPI()

@app.get("/items/")
async def read_items(q: Union[str, None] = None):
    results = {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}
    if q:
        results.update({"q": q})
    return results

위의 예제에서 q는 query parameter로 default값을 None을 가지기 때문에 필수값이 아니다. 그런데 만약 q의 최대 string길이가 50을 넘지 않아야 하는 조건이 있다면 어떻게 해야할까?? q를 받고 본문에서 처리하는 방법도 좋은 방법이지만, fastapi에서 제공해주는 방법이 있다. 필요한 것은 다음 두 가지이다.

  1. Annotated: typing, typing_extentions 모듈로 추가적인 정보를 입력할 때 사용한다.
  2. Query: fastapi에서 제공하는 모듈로 해당 파라미터가 query parameter이고, 각종 필요한 정보나 validation을 위한 조건들을 설정할 수 있다.
from fastapi import FastAPI, Query
from typing import Union, Annotated

app = FastAPI()

@app.get("/items/")
async def read_items(q: Annotated[Union[str, None], Query(max_length=50)] = None):
    results = {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}
    if q:
        results.update({"q": q})
    return results

q: Annotated[Union[str, None], Query(max_length=50)] = None다음과 같이 query parameter에 추가적인 정보와 validation 코드를 추가할 수 있다. 단, Annotated는 fastapi 0.95부터 지원하기 시작했다. 따라서 예전 버전을 사용한다면 다음과 같이 사용할 수 있다.

from fastapi import FastAPI, Query
from typing import Union

app = FastAPI()

@app.get("/items/")
async def read_items(q: Union[str, None] = Query(default=None,max_length=50)):
    results = {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}
    if q:
        results.update({"q": q})
    return results

다음과 같이 Annotated를 사용하지 않고 변수: type = Query()로 사용할 수 있다. default값은 Query안에 default field로 써주면 되는 것이다. 단, 다음의 경우는 허용하지 않는다.

q: Annotated[str, Query(default="rick")] = "morty"

Annotated안에 Query에 default값을 넣고 대입값에도 default값을 넣으면 어떤 것도 선택하지 못하므로 에러이다.

Query()에서 max_length를 50으로 검사하고 있기 때문에 q는 길이 50을 넘지 못한다. 가령 다음의 요청을 보내보도록 하자.

curl -H "Content-Type: application/json"  "localhost:8888/items/?q=11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"

{"detail":[{"type":"string_too_long","loc":["query","q"],"msg":"String should have at most 50 characters","input":"11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111","ctx":{"max_length":50},"url":"https://errors.pydantic.dev/2.4/v/string_too_long"}]}

이렇게 Query()와 같이 metadata를 추가하고 validation을 해주도록 하는 코드를 넣으면 다음의 장점이 있다.
1. 전달된 data가 조건에 맞게 validation check를 수행된다.
2. data가 invalid하다면 이에 합당한 error메시지가 전달된다.
3. fastapi에서 추가된 metadata를 기준으로 docs를 자동으로 생성해준다.

참고로 위의 코드는 python3.9버전을 기준으로하는데, 만약 3.10이면 Union을 다음과 같이 쓸 수 있다.

q: str | None = None # python3.10 | python3.9 => q: Union[str] = None

필자는 명시적으로 Union, Optional을 사용하는 것을 좋아해서, python3.9의 사용 방법을 선호한다.

Add more validations

max_length도 있었으니 당연히 min_length도 있다. 뿐만 아니라 정규표현식을 사용해서 원하는 형식으로 값이 오도록 검사할 수도 있다. 가령, fix-가 무조건 접두사로 와야하는 경우에 다음과 같이 Query를 사용할 수 있다.

from fastapi import FastAPI, Query
from typing import Optional, Annotated

app = FastAPI()

@app.get("/items/")
async def read_items(q: Annotated[Union[str, None], Query(max_length=50, min_length=4, pattern="^fix-.*$")] = None):
    results = {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}
    if q:
        results.update({"q": q})
    return results

regular expression은 사실 언어마다도 약간 다르고, 버전마다도 다를 수 있다. 또한, 매우 귀찮고 어려운 부분이기 때문에 가능하면 최소한으로 쓰도록 하자.

fix-가 최소한 4글자를 가지고 있기 때문에 min_length를 4로 설정하였다.

Required query parameter로 만들기

필수적으로 client가 전달해줘야 하는 query parameter를 만들기 위해서는 default value를 써주지 않으면 된다고 했다.

from fastapi import FastAPI, Query
from typing import Optional, Annotated

app = FastAPI()

@app.get("/items/")
async def read_items(q: Annotated[str, Query(min_length=3)]):
    results = {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}
    if q:
        results.update({"q": q})
    return results

다음의 경우 q는 query parameter이지만 default value가 없다. 설령 qOptional[str]None을 받을 수 있다하더라도 default value가 없다면 필수 query parameter(required query parameter)가 된다. 따라서, parameter를 client가 필수로 전달해주어야 할 때는 default value를 써주어야 한다는 것이다.

실제로 요청해보면 다음의 응답이 나온다.

curl -H "Content-Type: application/json"  "localhost:8888/items/"

{
  "detail": [
    {
      "type": "missing",
      "loc": [
        "query",
        "q"
      ],
      "msg": "Field required",
      "input": null,
      "url": "https://errors.pydantic.dev/2.4/v/missing"
    }
  ]
}

명시적으로 query parameter를 required(필수)로 만들 수 있는 방법이 있다. default value에 ...을 써주면 된다는 것이다. 이렇게하면 어떤 값이 오는 지 모르기 때문에 client가 반드시 전달해주어야 한다는 것을 알 수 있다.

from fastapi import FastAPI, Query
from typing import Optional, Annotated

app = FastAPI()

@app.get("/items/")
async def read_items(q: Annotated[str, Query(min_length=3)] = ...):
    results = {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}
    if q:
        results.update({"q": q})
    return results

단, 이 경우에 client가 요청 시에 query parameter가 생략된 경우 exception을 발생시키는 경우가 있어서, 엄청 좋은 방법은 아니다.

query parameter list/ multiple values

이전에 말했듯이 query parameter는 기본 타입인 str, int, float 등이 오면 자동으로 할당된다고 했다. 그럼 list와 같이 multiple value들을 받아낼 때는 어떻게할까??

FastAPI에서는 query parameter을 명시적으로 알려주는 Query객체를 사용하면 된다. Query를 query parameter에 함께 써주면 fastapi에서 명시적으로 해당 변수가 query parameter로 쓰이고 있는 것으로 알 수 있으므로 list와 같은 값을 할당할 수 있다.

from fastapi import FastAPI, Query
from typing import Optional, Annotated

app = FastAPI()

@app.get("/items/")
async def read_items(q: Annotated[Optional[list[str]], Query()] = None):
    query_items = {"q": q}
    return query_items

다음과 같이 q가 query parameter라는 것을 알려주기 위해서 Query를 사용해준다. 단, q가 multiple value를 받을 수 있도록 list로 타입을 정해주는 것이 중요하다. 현재는 Optional[list[str]]str로 된 list를 받되, None도 받을 수 있다는 것이다.

curl -H "Content-Type: application/json"  "localhost:8888/items/?q=foo&q=bar"
{"q":["foo","bar"]}

위와 같이 하나의 query parameter에 여러 value를 받도록 하면 list에 차곡차곡 쌓인다.

metadata추가하기

Query에 metadata를 추가적으로 담을 수 있는데, metadata는 OpenAPI docs에 추가적인 정보를 생성해주는데 큰 도움이 된다.

from fastapi import FastAPI, Query
from typing import Optional, Annotated

app = FastAPI()

@app.get("/items/")
async def read_items(q: Annotated[Optional[str], 
                                  Query(title="Query string",
                                        description="Query string for the items to search in the database",
                                        min_length=3)] = None):
    results = {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}
    if q:
        results.update({"q": q})
    return results

다음의 code에서 Query부분을 보면 title, description 등이 적혀있는데, 이는 metadata로 실제 구동에 영향을 주진 않는다. 다만, docs로 보게되면 자세한 사항들을 설명하는 부분으로 위의 titledescription이 적용된 것을 볼 수 있다.

필자는 8888port로 서버를 열었기 때문에 다음이 링크로 접속할 수 있다.

localhost:8888/docs

docstitle, description이 해당 query parameter에 반영된 것을 볼 수 있다.

Alias parameter와 deprecated parameters

만약 query parameter 이름을 다른 것도 같이 혼용하고 싶을 때는 어떻게 할까?? q라는 query parameter를 item-query로도 client에게 받고 싶다면, alias를 사용하면 된다.

from fastapi import FastAPI, Query
from typing import Optional, Annotated

app = FastAPI()

@app.get("/items/")
async def read_items(q: Annotated[Optional[str], Query(alias="item-query")] = None):
    results = {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}
    if q:
        results.update({"q": q})
    return results

위의 코드에서 q query parameter에 aliasitem-query로 설정한 것을 볼 수 있다.alias를 사용하면 client가 q로만 query parameter를 요청하지 않고 item-query으로도 요청을 할 수 있다.

curl -H "Content-Type: application/json"  "localhost:8888/items/?item-query=hello"
{"items":[{"item_id":"Foo"},{"item_id":"Bar"}],"q":"hello"}

?item-query=hello으로 요청했는데도 q로 받아낸 것을 볼 수 있다.

또한, 특정 query parameter가 deprecated될 때가 있는데, 이 역시도 Query객체에 표시해줄 수 있다. deprecated를 표시하면 client가 사용 못하는 것은 아니고 docs에 해당 query parameter가 deprecated되었다고 나온다.

from fastapi import FastAPI, Query
from typing import Union, Annotated

app = FastAPI()

@app.get("/items/")
async def read_items(
    q: Annotated[
        Union[str, None],
        Query(
            alias="item-query",
            title="Query string",
            description="Query string for the items to search in the database that have a good match",
            min_length=3,
            max_length=50,
            deprecated=True,
        ),
    ] = None,
):
    results = {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}
    if q:
        results.update({"q": q})
    return results

QuerydeprecatedTrue로 사용하면 된다. client는 문제없이 해당 query parameter를 사용할 수 있지만 docs에는 deprecated되었다고 나온다.

Path parameters and Numeric Validations

https://fastapi.tiangolo.com/tutorial/path-params-numeric-validations/

Query를 통해서 query parameter의 metadata를 추가하고, validation을 추가한 것을 볼 수 있었다. path parameter역시도 Path를 통해서 metadata를 추가하고 validation을 추가할 수 있다.

from typing import Optional, Annotated
from fastapi import FastAPI, Query, Path

app = FastAPI()

@app.get("/items/{item_id}")
async def read_items(
        item_id: Annotated[int, Path(title="The ID of the item to get")],
        q: Annotated[Optional[str], Query(alias="item-query")] = None):
    
    results = {"item_id": item_id}
    if q:
        results.update({"q": q})
    return results

from fastapi import PathPath를 가져올 수 있고, path parameter에 Annotated와 함께 Path를 적어주면 된다.

앞서 말했듯이 Annotatedfastapi 0.95에서 부터 지원하기 이전 버전의 경우는 다음과 같이 사용할 수 있다.

@app.get("/items/{item_id}")
async def read_items(
        item_id: int = Path(title="The ID of the item to get"),
        q: Optional[str] = Query(default=None, alias="item-query")):
    
    results = {"item_id": item_id}
    if q:
        results.update({"q": q})
    return results

동작하는 방식은 똑같다. 다만 Annotated를 사용하는 것이 더 default value를 설정하는 방법이 더 명시적이라서 좋다.

Annotated를 왜 쓰는 것이 더 좋을까?

Annotated를 사용하지 않으면 python에서 발생하는 미묘한 에러가 있는데, 다음의 예제를 보도록 하자.

@app.get("/items/{item_id}")
async def read_items(
        item_id: int = Path(title="The ID of the item to get"), q: str):
    
    results = {"item_id": item_id}
    if q:
        results.update({"q": q})
    return results

위 코드를 pythond ide에서 보면 q부분에서 error가 발생한다고 표시될 것이다. 왜냐하면 python의 경우 함수에 default 값을 쓸 때는 맨 뒤에 있는 파라미터부터 default값을 써야하기 때문이다. 가령 다음의 코드를 보자.

# 가능
def temp1(a, b = 2):
    pass

# 에러
def temp2(a = 2, b):
    pass

temp1은 맨 뒤 파라미터부터 default값을 써서 문제가 없지만, temp2는 앞부터 default값을 써서 뒤에 default값이 없는 파라미터가 있다. 이런 경우 발생할 수 있는 문제점은 temp2(1)이라고 요청했을 때, temp2(1)의 1은 a에 할당될 것인지 b에 할당될 것인지이다. 당연히 a에 할당되야하는 것 같지만, 그렇다면 a에 default값을 써주는 이유가 없다. 즉, temp2()일 때만 a의 default값이 의미가 있는데, 이 경우는 b도 default값을 써주어여야 하는 상황이라는 것이다. 또한, 특정 개발자들은 temp2a가 default값이 있어서 temp2(1)라고 하면 a는 건너뛰고 b에 할당되어야 하는 것 아니냐는 의견도 있었다.

반면, temp1b가 default값을 가지고 있기 때문에 temp1(1)의 경우에 반드시 a가 1이고 b는 default값 2이다. 이는 코드를 분명히 만들고 더 정확하게 문맥을 파악할 수 있게 한다.

이 문제가 위의 fastapi code에서도 발생하는 것이다. item_id는 default값이 있는 것처럼 보이며, q는 명백히 없다. 사실 엄밀히 말하면 item_id도 default값이 있는 것이 아니다. 다만, ide입장에서 볼 때 =대입문 오른쪽에 있으니 default값으로 Path를 생각하는 것이다. 따라서, python ide입장에서는 item_id는 default값이 있고, q는 없다고 생각하는 것이다. 사실 실제로 fastapi를 실행하면 문제없이 구동된다.

이를 해결하는 가장 쉬운 방법은 parameter 순서만 바꿔주면 된다.

@app.get("/items/{item_id}")
async def read_items(
         q: str,
        item_id: int = Path(title="The ID of the item to get")):
    
    results = {"item_id": item_id}
    if q:
        results.update({"q": q})
    return results

python ide에서 error로 체크해주지 않는 것을 볼 수 있다.

가장 좋은 방법은 = 대입문 자체를 쓸 일이 없다면 쓰지 않는 것이다. 따라서 Annotated를 쓰면 이러한 문제가 발생하지 않는 것이다.

from typing import Annotated
from fastapi import FastAPI, Path

app = FastAPI()

@app.get("/items/{item_id}")
async def read_items(
    q: str, item_id: Annotated[int, Path(title="The ID of the item to get")]
):
    results = {"item_id": item_id}
    if q:
        results.update({"q": q})
    return results

python ide에서 error로 잡지 않는 것을 볼 수 있다. 왜냐하면 =문이 없기 때문이다. 이렇게 Annotaed를 사용하는 방법이 좋은 방법이지만, fastapi버전이 0.95보다 낮다면 어쩔 수 없이 위의 trick을 사용하는 수 밖에 없다.

Number validations

Path에서도 Query와 마찬가지로 validation 기능을 사용할 수 있는데, 이전에 문자열 길이에 대한 validation을 봤으니 이제는 숫자에 대한 validation을 사용하도록 하자. 이 기능은 QueryPath든 둘 다 가능하다.

ge는 greater than, equal로 지정한 숫자 n보다는 커야하고 n가 같아도 된다는 의미이다.

from typing import Annotated
from fastapi import FastAPI, Path

app = FastAPI()

@app.get("/items/{item_id}")
async def read_items(
    item_id: Annotated[int, Path(title="The ID of the item to get", ge=1)], q: str
):
    results = {"item_id": item_id}
    if q:
        results.update({"q": q})
    return results

Path를 잘보면 ge=1이라는 표시가 있다. 이는 1보다는 크고, 같아도된다는 의미이다. 따라서 다음과 같이 요청을 전달하면 된다.

curl -H "Content-Type: application/json"  "localhost:8888/items/2?q=hello"
{"item_id":2,"q":"hello"}

curl -H "Content-Type: application/json"  "localhost:8888/items/1?q=hello"
{"item_id":1,"q":"hello"}

curl -H "Content-Type: application/json"  "localhost:8888/items/0?q=hello" | jq
{
  "detail": [
    {
      "type": "greater_than_equal",
      "loc": [
        "path",
        "item_id"
      ],
      "msg": "Input should be greater than or equal to 1",
      "input": "0",
      "ctx": {
        "ge": 1
      },
      "url": "https://errors.pydantic.dev/2.4/v/greater_than_equal"
    }
  ]
}

item_id path parameter를 0으로 주니 에러가 발생하는 것을 볼 수 있다.

이외에도 ge, le, lt, gt가 있는데 정리하면 다음과 같다.
1. gt: n보다 커야한다. ( path parameter > n)
2. ge: n보다 크거나 같아야한다. ( path parameter >= n)
3. lt: n보다 작아야 한다. ( path parameter < n)
4. le: n보다 작거나 같아야 한다. (path parameter <= n)

Query, Path 등 추후에 볼 다른 class들은 모두 Param class의 subclass이기 때문에 위의 속성들을 모두 공통적으로 갖고 있다.

0개의 댓글

관련 채용 정보