FastAPI를 배워보자 8일차 - Response Model

0

fastapi

목록 보기
8/13

Response Model - Return Type

https://fastapi.tiangolo.com/tutorial/response-model/#response_model-or-return-type

handler의 parameter들 type을 정하듯이 return value에 대한 type을 지정하여 fastapi, pydantic의 validation과 document기능을 제공받을 수 있다.

사용 방법은 다음과 같다.

from typing import Union

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class Item(BaseModel):
    name: str
    description: Union[str, None] = None
    price: float
    tax: Union[float, None] = None
    tags: list[str] = []


@app.post("/items/")
async def create_item(item: Item) -> Item:
    return item


@app.get("/items/")
async def read_items() -> list[Item]:
    return [
        Item(name="Portal Gun", price=42.0),
        Item(name="Plumbus", price=32.0),
    ]

create_itemread_items를 보면 ->로 반환 타입을 나타내고 있다. 이 return type이 제공하는 기능은 다음과 같다.
1. 반환값에 대한 유효성 검사: 정의한 return type과 비교하여 반환되는 data가 유효하지 않으면, data를 client에게 보내지 않고 internal server error를 발생시킨다.
2. JSON schema: return type을 지정하였으므로 자동으로 fastapi에서 docs에 반영한다.
3. security: return type에 정의된 field들만 반환하며, return type이 다르면 client에게 전달하지 않고 internal server error를 발생시키는 것은 client에게 주요한 정보가 원치않게 전달되는 것을 막을 수 있다.

response_model parameter

fastapi의 path operation(@app.get @app.post, @app.put, @app.delete etc)에 response_model이라는 파라미터로 해당 handler의 반환값이 어떤 타입을 가져야 하는 지 알려줄 수 있다. 또한, fastapi는 지정한 response_model에 맞춰 return value의 유효성 검사를 하고, 필터링을 하며, 문서화를 한다.

그런데, 이렇게되면 위에서 ->로 사용한 return type annotation과 별반 다를바 없어보인다.

이 둘의 가장 큰 차이는 바로 IDE가 읽어들이는 가, 아닌 가에 따라 다르다. return type annotation은 python 공식으로 지원하는 기능이기 때문에 python ide가 읽어들여서 해당 타입과 다르면 ide자체에서 에러를 발생시킨다.

반면에 response_modelpath annotation의 파라미터일 뿐이라 ide에서 읽어들여도 해당 타입에 맞는 반환값을 쓰지 않아도 문제를 발생시키지않는다.

그런데, 왜 return type annotation과 다른 type을 반환하는 일이 생기는 것일까?? 생각해보면 반환 타입과 다른 반환 값을 반환한다는 게 말이 안되지만, 다음의 경우가있다.

return type annotation은 pydantic의 BaseModel을 상속한 class model이지만 반환하고 싶은 값은 해당 class model을 지키는 dict인 경우이다.

from typing import Union

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
    name: str
    description: Union[str, None] = None
    price: float
    tax: Union[float, None] = None
    tags: list[str] = []

@app.get("/items/")
async def read_items() -> list[Item]:
    return [
        {"name": "Portal Gun", "price": 42.0},
        {"name": "Plumbus", "price": 32.0},
    ]

list[Item]을 return type annotation으로 쓰고 있지만, 반환값의 타입은 list[dict]이다. 물론 코드를 구동하면 어떠한 문제도 없다. 다만 ide와 mypy와 같이 type기반으로 정적분석을 하는 경우에는 error처리로 된다. 동일한 타입이 아니기 때문이다.

이때 response_model을 사용하면 된다. response_model은 mypy나 ide의 대상이 아니기 때문이다.

@app.get("/items/", response_model=list[Item])
async def read_items() -> any:
    return [
        {"name": "Portal Gun", "price": 42.0},
        {"name": "Plumbus", "price": 32.0},
    ]

이처럼 read_items의 반환값으로 any를 쓰고 response_modellist[Item]을 써주면 fastapi의 pydantic validation, filtering, document 기능을 모두 제공받을 수 있다. 또한, ide나 mypy에서는 return type annotationany이기 때문에 list[dict]를 반환해도 문제가 없다고 판단하는 것이다.

이러한 경우말고는 이 둘을 크게 나누는 일은 없다. 혼용해서 사용해도 되고, 이 둘 중 하나만 사용해도 된다.

단, 이 둘을 같이 사용할 때 fastapi에서는 response_model을 기준으로 fastapi에서 validation, filtering, document가 진행된다. 따라서, return typeresponse_model을 같이 사용하면 어차피 fastapi에서 response_model을 적용하기 때문에 response_model에 focus를 맞추고 return type은 mypy나 ide에서 focus하도록 사용하면 된다.

Add an output model

return type annotationresponse model을 통해서 특정 field를 필터링 할 수 있다.

먼저, pydantic으로 email을 validate할 수 있는 모듈을 다운 받도록 하자.

pip3 install pydantic[email]

다음의 코드를 보도록 하자.

from typing import Union

from fastapi import FastAPI
from pydantic import BaseModel, EmailStr

app = FastAPI()

class UserIn(BaseModel):
    username: str
    password: str
    email: EmailStr
    full_name: Union[str, None] = None

class UserOut(BaseModel):
    username: str
    email: EmailStr
    full_name: Union[str, None] = None

@app.post("/user/", response_model=UserOut)
async def create_user(user: UserIn) -> any:
    return user

create_user handler에서 UserIn 타입을 가지는 user을 받는다. user정보에는 password가 있기 때문에 그대로 반환해서는 안된다. client의 password가 브라우저에 노출될 수 있기 때문이다. 따라서, UserOut이라는 class model을 만들어서 password를 빼버리는 것이다.

fastapi에서는 재밌게도 UserIn타입으로 들어온 user이지만 UserOut타입 반환을 허용해주어, password만 필터링해버리고 나머지는 그대로 반환해준다.

curl -X POST -H "Content-Type: application/json"  "localhost:8888/user/" -d '{"username": "hello", "password": "bye", "email": "example@example.net", "full_name": "hello bye"}'

{"username":"hello","email":"example@example.net","full_name":"hello bye"}

password만 제외하고 반환되는 것을 볼 수 있다.

그러나, 위의 방법은 response_model을 사용하여 ide나 mypy같은 tool의 타입 검사를 피하는 방식이다. return type annotation을 사용하는 좋은 방법이 있는데, model을 세분화하여 나눈다음 상속을 받도록 하는 것이다. 다음의 예시를 보도록 하자.

from typing import Union

from fastapi import FastAPI
from pydantic import BaseModel, EmailStr

app = FastAPI()

class BaseUser(BaseModel):
    username: str
    email: EmailStr
    full_name: Union[str, None] = None

class UserIn(BaseUser):
    password: str

@app.post("/user/")
async def create_user(user: UserIn) -> BaseUser:
    return user

UserInBaseUser를 상속받아 username, email, full_name field을 모두 가지고 있다. 따라서, UserIn으로 create_user handler의 입력 파라미터를 받고 반환 타입으로 BaseUser로 쓰면 user에서 password만 제외한 나머지 데이터를 가져오는 방식이다.

사실 별로 어려운 방법은 아니며 BaseUser로 변환한 뒤에 반환했다고 생각하면 쉽다. password를 담을 field가 없기 때문에 password는 사라지고 BaseUser타입인 값이 반환되는 것이다. 즉, base_user = BaseUser(**user.model_dump()) 다음과 같이 user에 있던 모든 field들을 BaseUser field에 맞추어 할당시켜주는데 passwordBaseUser에 없으므로 생략한다. 참고로 .model_dump함수는 pydantic class model을 dict로 변환해주는 함수이다.

Other Return Type annotations

fastapi에서는 Response라는 객체를 제공하여 응답을 전송하기 편하게 해준다. Response객체를 직접 반환할 수도 있고, 이를 상속하는 또 다른 response객체들이 있는데, RedirectResponseJSONResponse가 있다.

from fastapi import FastAPI, Response
from fastapi.responses import JSONResponse, RedirectResponse

app = FastAPI()

@app.get("/portal")
async def get_portal(teleport: bool = False) -> Response:
    if teleport:
        return RedirectResponse(url="https://www.youtube.com/watch?v=dQw4w9WgXcQ")
    return JSONResponse(content={"message": "Here's your interdimensional portal."})

Response 객체는 fastapi에 있지만 이를 상속한 subclass들은 fastapi.responses에 있다. get_portal handler의 반환 타입은 Response이지만 JSONResponse, RedirectResponse를 반환할 수 있다. 이들이 모두 Response의 subclass이기 때문이다.

teleport query parameter가 true이면 아주 좋은 노래?로 텔레포트되고 그냥 요청하면 다음의 응답이 나온다.

curl -X GET -H "Content-Type: application/json"  "localhost:8888/portal"

{"message":"Here's your interdimensional portal."}

content부분에 json형식을 msg를 넣으면 되는 것이다.

재밌는 것은 Response 타입이지만 return value로 dict를 써주어도 된다.

@app.get("/portal")
async def get_portal(teleport: bool = False) -> Response:
    if teleport:
        return RedirectResponse(url="https://www.youtube.com/watch?v=dQw4w9WgXcQ")
    return {"message": "Here's your interdimensional portal."}

Response을 return type annotation으로 제공할 때, pydantic에서 제공해주는 model들을 사용하는 것은 큰 문제가 없지만, 몇 가지 미묘한 문제들이있다. 가령 Union[Response, dict]로 return type을 만들면, fastapi에서 Response이 pydantic model이라 이것만을 return타입으로 잡는 문제가 있다. 가령 다음의 코드를 보자.

from typing import Union

from fastapi import FastAPI, Response
from fastapi.responses import RedirectResponse

app = FastAPI()

@app.get("/portal")
async def get_portal(teleport: bool = False) -> Union[Response, dict]:
    if teleport:
        return RedirectResponse(url="https://www.youtube.com/watch?v=dQw4w9WgXcQ")
    return {"message": "Here's your interdimensional portal."}

get_portal handler의 return type annotation이 Union[Response, dict]이다. 이렇게 만들면 fastapi는 Response를 보고 response_model type을 Response만으로 설정하는 바람에 Union에서 에러가 발생한다.

astapi.exceptions.FastAPIError: Invalid args for response field! Hint: check that typing.Union[starlette.responses.Response, dict] is a valid Pydantic field type. If you are using a return type annotation that is not a valid Pydantic field (e.g. Union[Response, dict, None]) you can disable generating the response model from the type annotation with the path operation decorator parameter response_model=None. Read more: https://fastapi.tiangolo.com/tutorial/response-model/

fastapi에서 Response type이 return type에 있으면, 이를 기반으로 response_modelResponse로 만들기 때문에 발생하는 문제같다. 해결방법은 다음과 같지만 사용을 추천하진 않는다.

@app.get("/portal", response_model=None)
async def get_portal(teleport: bool = False) -> Union[Response, dict]:
    if teleport:
        return RedirectResponse(url="https://www.youtube.com/watch?v=dQw4w9WgXcQ")
    return {"message": "Here's your interdimensional portal."}

response_model=None으로 두는 것이다. 왜냐하면 fastapi가 Responseresponse_model로 두었기 때문에 발생하는 문제이기 때문이다. 다만, 별로 추천은 안하고 Response를 쓴다면 Response만 return value로 쓰도록 하자.

response_model_exclude_unset parameter

path operator decorator에 response_model_exclude_unset=True로 설정하면 response_modeldefault값이 응답 값에 들어가는 것을 막을 수 있다. 가령 다음의 코드를 보자.

from typing import Union
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
    name: str
    description: Union[str, None] = None
    price: float
    tax: float = 10.5
    tags: list[str] = []


items = {
    "foo": {"name": "Foo", "price": 50.2},
    "bar": {"name": "Bar", "description": "The bartenders", "price": 62, "tax": 20.2},
    "baz": {"name": "Baz", "description": None, "price": 50.2, "tax": 10.5, "tags": []},
}

@app.get("/items/{item_id}", response_model=Item)
async def read_item(item_id: str):
    return items[item_id]

read_itemresponse_modelItem으로 설정한 것을 볼 수 있다. Item은 default value로 tax가 10이 설정되어 있고, tags[], descriptionNone으로 설정되어있다. 따라서 foo에 요청을 보내면 default value가 설정된 response 값을 받을 수 있다.

curl localhost:8888/items/foo
{"name":"Baz","description":null,"price":50.2,"tax":10.5,"tags":[]}

default값들이 설정되어 응답으로 온 것을 볼 수 있다.

만약 값을 넣어주지 않는 field에 대해서 filtering되어 응답을 보내고 싶다면 response_model_exclude_unset=True로 설정하면 된다.

@app.get("/items/{item_id}", response_model=Item, response_model_exclude_unset=True)
async def read_item(item_id: str):
    return items[item_id]

위과 같이 설정하고 다시 curl localhost:8888/items/foo에 요청을 보내면

{"name":"Foo","price":50.2}

다음과 같이 설정된 값만 오고, 설정되지 않은 field에 대해서는 생략된다.

한가지 오해하면 안되는 것이 response_model_exclude_unset이므로 개발자가 설정하지 않은 field에 대해서만 생략하는 것이지, default값에 대해서 생략하는 것이 아니다. 가령 bar로 요청을 보내보면 알 수 있는데,

curl localhost:8888/items/bar

다음의 응답이 온다.

{"name":"Foo","description":null,"price":200.0,"tax":10.5,"tags":[]}

이렇게 default value가 설정되서 온 것은 사실 items를 보면 bar는 사용자가 default value와 동일한 값을 직접 설정해서 그렇다. 만약 default value와 동일한 값이 있어도 응답 모델에 안나오도록 하고싶다면 response_model_exclude_defaultsTrue로 하면된다. 또한, None인 field에 대해서만 배제시키고 싶다면 response_model_exclude_noneTrue로 사용하면 된다.

response_model_includeresponse_model_exclude

특정 field를 지정해서 어떤 것들은 포함하고, 어떤 것들은 포함하지 않도록 할 수 있다. 타입은 set 또는 str를 사용하여 response model의 field를 지정할 수 있다. 참고로 set으로 안쓰고 listtuple로 써도 문제없이 변환해준다.

from typing import Union

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
    name: str
    description: Union[str, None] = None
    price: float
    tax: float = 10.5
    tags: list[str] = []

items = {
    "foo": {"name": "Foo", "price": 50.2},
    "bar": {"name": "Bar", "description": "The Bar fighters", "price": 62, "tax": 20.2},
    "baz": {
        "name": "Baz",
        "description": "There goes my baz",
        "price": 50.2,
        "tax": 10.5,
    },
}

@app.get(
    "/items/{item_id}/name",
    response_model=Item,
    response_model_include={"name", "description"},
)
async def read_item_name(item_id: str):
    return items[item_id]

@app.get("/items/{item_id}/public", response_model=Item, response_model_exclude={"tax"})
async def read_item_public_data(item_id: str):
    return items[item_id]

다음의 예제를 보면 itemsbartax를 가지고 있다. 그러나 read_item_public_data에서는 response_model_excludetax이므로 tax를 제외해서 응답을 해준다.

curl localhost:8888/items/bar/public

응답이 온 것을 확인해보도록 하자.

{"name":"Bar","description":"The Bar fighters","price":62.0,"tags":[]}

tax만 쏙 빠진 것을 볼 수 있다.

단, 이렇게 response model의 특정 field를 제외시키거나 포함시키는 것보다는 그냥 response model 자체를 세분화해서 응답을 보내주는 것이 유지보수 측면에서 좋다.

0개의 댓글

관련 채용 정보