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_item
과 read_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
parameterfastapi의 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_model
은 path 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_model
로 list[Item]
을 써주면 fastapi의 pydantic validation, filtering, document 기능을 모두 제공받을 수 있다. 또한, ide나 mypy에서는 return type annotation
이 any
이기 때문에 list[dict]
를 반환해도 문제가 없다고 판단하는 것이다.
이러한 경우말고는 이 둘을 크게 나누는 일은 없다. 혼용해서 사용해도 되고, 이 둘 중 하나만 사용해도 된다.
단, 이 둘을 같이 사용할 때 fastapi에서는 response_model
을 기준으로 fastapi에서 validation, filtering, document가 진행된다. 따라서, return type
과 response_model
을 같이 사용하면 어차피 fastapi에서 response_model
을 적용하기 때문에 response_model
에 focus를 맞추고 return type
은 mypy나 ide에서 focus하도록 사용하면 된다.
return type annotation
과 response 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
UserIn
은 BaseUser
를 상속받아 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에 맞추어 할당시켜주는데 password
는 BaseUser
에 없으므로 생략한다. 참고로 .model_dump
함수는 pydantic class model을 dict로 변환해주는 함수이다.
fastapi에서는 Response
라는 객체를 제공하여 응답을 전송하기 편하게 해준다. Response
객체를 직접 반환할 수도 있고, 이를 상속하는 또 다른 response객체들이 있는데, RedirectResponse
와 JSONResponse
가 있다.
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_model
을 Response
로 만들기 때문에 발생하는 문제같다. 해결방법은 다음과 같지만 사용을 추천하진 않는다.
@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가 Response
를 response_model
로 두었기 때문에 발생하는 문제이기 때문이다. 다만, 별로 추천은 안하고 Response
를 쓴다면 Response
만 return value로 쓰도록 하자.
response_model_exclude_unset
parameterpath operator decorator에 response_model_exclude_unset=True
로 설정하면 response_model
의 default
값이 응답 값에 들어가는 것을 막을 수 있다. 가령 다음의 코드를 보자.
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_item
의 response_model
을 Item
으로 설정한 것을 볼 수 있다. Item
은 default value로 tax
가 10이 설정되어 있고, tags
는 []
, description
은 None
으로 설정되어있다. 따라서 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_defaults
를 True
로 하면된다. 또한, None
인 field에 대해서만 배제시키고 싶다면 response_model_exclude_none
를 True
로 사용하면 된다.
response_model_include
와 response_model_exclude
특정 field를 지정해서 어떤 것들은 포함하고, 어떤 것들은 포함하지 않도록 할 수 있다. 타입은 set
또는 str
를 사용하여 response model의 field를 지정할 수 있다. 참고로 set
으로 안쓰고 list
나 tuple
로 써도 문제없이 변환해준다.
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]
다음의 예제를 보면 items
의 bar
는 tax
를 가지고 있다. 그러나 read_item_public_data
에서는 response_model_exclude
가 tax
이므로 tax
를 제외해서 응답을 해준다.
curl localhost:8888/items/bar/public
응답이 온 것을 확인해보도록 하자.
{"name":"Bar","description":"The Bar fighters","price":62.0,"tags":[]}
tax
만 쏙 빠진 것을 볼 수 있다.
단, 이렇게 response model의 특정 field를 제외시키거나 포함시키는 것보다는 그냥 response model 자체를 세분화해서 응답을 보내주는 것이 유지보수 측면에서 좋다.