콘텐츠로 이동

추가 모델

지난 예제에 이어서, 연관된 모델을 여러개 갖는 것은 흔한 일입니다.

특히 사용자 모델의 경우에 그러한데, 왜냐하면:

  • 입력 모델 은 비밀번호를 가져야 합니다.
  • 출력 모델 은 비밀번호를 가지면 안됩니다.
  • 데이터베이스 모델 은 해시처리된 비밀번호를 가질 것입니다.

위험

절대 사용자의 비밀번호를 평문으로 저장하지 마세요. 항상 이후에 검증 가능한 "안전한 해시(secure hash)"로 저장하세요.

만약 이게 무엇인지 모르겠다면, security chapters.에서 비밀번호 해시에 대해 배울 수 있습니다.

다중 모델

아래는 비밀번호 필드와 해당 필드가 사용되는 위치를 포함하여, 각 모델들이 어떤 형태를 가질 수 있는지 전반적인 예시입니다:

from fastapi import FastAPI
from pydantic import BaseModel, EmailStr

app = FastAPI()


class UserIn(BaseModel):
    username: str
    password: str
    email: EmailStr
    full_name: str | None = None


class UserOut(BaseModel):
    username: str
    email: EmailStr
    full_name: str | None = None


class UserInDB(BaseModel):
    username: str
    hashed_password: str
    email: EmailStr
    full_name: str | None = None


def fake_password_hasher(raw_password: str):
    return "supersecret" + raw_password


def fake_save_user(user_in: UserIn):
    hashed_password = fake_password_hasher(user_in.password)
    user_in_db = UserInDB(**user_in.dict(), hashed_password=hashed_password)
    print("User saved! ..not really")
    return user_in_db


@app.post("/user/", response_model=UserOut)
async def create_user(user_in: UserIn):
    user_saved = fake_save_user(user_in)
    return user_saved
🤓 Other versions and variants
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


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


def fake_password_hasher(raw_password: str):
    return "supersecret" + raw_password


def fake_save_user(user_in: UserIn):
    hashed_password = fake_password_hasher(user_in.password)
    user_in_db = UserInDB(**user_in.dict(), hashed_password=hashed_password)
    print("User saved! ..not really")
    return user_in_db


@app.post("/user/", response_model=UserOut)
async def create_user(user_in: UserIn):
    user_saved = fake_save_user(user_in)
    return user_saved

정보

Pydantic v1에서는 해당 메서드가 .dict()로 불렸으며, Pydantic v2에서는 .model_dump()로 이름이 변경되었습니다. .dict()는 여전히 지원되지만 더 이상 권장되지 않습니다.

여기에서 사용하는 예제는 Pydantic v1과의 호환성을 위해 .dict()를 사용하지만, Pydantic v2를 사용할 수 있다면 .model_dump()를 사용하는 것이 좋습니다.

**user_in.dict() 에 대하여

Pydantic의 .dict()

user_in은 Pydantic 모델 클래스인 UserIn입니다.

Pydantic 모델은 모델 데이터를 포함한 dict를 반환하는 .dict() 메서드를 제공합니다.

따라서, 다음과 같이 Pydantic 객체 user_in을 생성할 수 있습니다:

user_in = UserIn(username="john", password="secret", email="john.doe@example.com")

그 다음, 다음과 같이 호출합니다:

user_dict = user_in.dict()

이제 변수 user_dict에 데이터가 포함된 dict를 가지게 됩니다(이는 Pydantic 모델 객체가 아닌 dict입니다).

그리고 다음과 같이 호출하면:

print(user_dict)

Python의 dict가 다음과 같이 출력됩니다:

{
    'username': 'john',
    'password': 'secret',
    'email': 'john.doe@example.com',
    'full_name': None,
}

dict 언패킹(Unpacking)

user_dict와 같은 dict를 함수(또는 클래스)에 **user_dict로 전달하면, Python은 이를 "언팩(unpack)"합니다. 이 과정에서 user_dict의 키와 값을 각각 키-값 인자로 직접 전달합니다.

따라서, 위에서 생성한 user_dict를 사용하여 다음과 같이 작성하면:

UserInDB(**user_dict)

다음과 같은 결과를 생성합니다:

UserInDB(
    username="john",
    password="secret",
    email="john.doe@example.com",
    full_name=None,
)

혹은 더 정확히 말하자면, user_dict를 직접 사용하는 것은, 나중에 어떤 값이 추가되더라도 아래와 동일한 효과를 냅니다:

UserInDB(
    username = user_dict["username"],
    password = user_dict["password"],
    email = user_dict["email"],
    full_name = user_dict["full_name"],
)

다른 모델 데이터로 새 Pydantic 모델 생성

위의 예제에서 user_in.dict()로부터 user_dict를 생성한 것처럼, 아래 코드는:

user_dict = user_in.dict()
UserInDB(**user_dict)

다음과 동일합니다:

UserInDB(**user_in.dict())

...왜냐하면 user_in.dict()dict이며, 이를 **로 Python이 "언팩(unpack)"하도록 하여 UserInDB에 전달하기 때문입니다.

따라서, 다른 Pydantic 모델의 데이터를 사용하여 새로운 Pydantic 모델을 생성할 수 있습니다.

dict 언패킹(Unpacking)과 추가 키워드

그리고 다음과 같이 추가 키워드 인자 hashed_password=hashed_password를 추가하면:

UserInDB(**user_in.dict(), hashed_password=hashed_password)

다음과 같은 결과를 생성합니다:

UserInDB(
    username = user_dict["username"],
    password = user_dict["password"],
    email = user_dict["email"],
    full_name = user_dict["full_name"],
    hashed_password = hashed_password,
)

경고

추가적으로 제공된 함수 fake_password_hasherfake_save_user는 데이터 흐름을 시연하기 위한 예제일 뿐이며, 실제 보안을 제공하지 않습니다.

중복 줄이기

코드 중복을 줄이는 것은 FastAPI의 핵심 아이디어 중 하나입니다.

코드 중복은 버그, 보안 문제, 코드 비동기화 문제(한 곳은 업데이트되었지만 다른 곳은 업데이트되지 않는 문제) 등의 가능성을 증가시킵니다.

그리고 이 모델들은 많은 데이터를 공유하면서 속성 이름과 타입을 중복하고 있습니다.

더 나은 방법이 있습니다.

UserBase 모델을 선언하여 다른 모델들의 기본(base)으로 사용할 수 있습니다. 그런 다음 이 모델을 상속받아 속성과 타입 선언(유형 선언, 검증 등)을 상속하는 서브클래스를 만들 수 있습니다.

모든 데이터 변환, 검증, 문서화 등은 정상적으로 작동할 것입니다.

이렇게 하면 각 모델 간의 차이점만 선언할 수 있습니다(평문 password가 있는 경우, hashed_password만 있는 경우, 혹은 비밀번호가 없는 경우):

from fastapi import FastAPI
from pydantic import BaseModel, EmailStr

app = FastAPI()


class UserBase(BaseModel):
    username: str
    email: EmailStr
    full_name: str | None = None


class UserIn(UserBase):
    password: str


class UserOut(UserBase):
    pass


class UserInDB(UserBase):
    hashed_password: str


def fake_password_hasher(raw_password: str):
    return "supersecret" + raw_password


def fake_save_user(user_in: UserIn):
    hashed_password = fake_password_hasher(user_in.password)
    user_in_db = UserInDB(**user_in.dict(), hashed_password=hashed_password)
    print("User saved! ..not really")
    return user_in_db


@app.post("/user/", response_model=UserOut)
async def create_user(user_in: UserIn):
    user_saved = fake_save_user(user_in)
    return user_saved
🤓 Other versions and variants
from typing import Union

from fastapi import FastAPI
from pydantic import BaseModel, EmailStr

app = FastAPI()


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


class UserIn(UserBase):
    password: str


class UserOut(UserBase):
    pass


class UserInDB(UserBase):
    hashed_password: str


def fake_password_hasher(raw_password: str):
    return "supersecret" + raw_password


def fake_save_user(user_in: UserIn):
    hashed_password = fake_password_hasher(user_in.password)
    user_in_db = UserInDB(**user_in.dict(), hashed_password=hashed_password)
    print("User saved! ..not really")
    return user_in_db


@app.post("/user/", response_model=UserOut)
async def create_user(user_in: UserIn):
    user_saved = fake_save_user(user_in)
    return user_saved

Union 또는 anyOf

두 가지 이상의 타입을 포함하는 Union으로 응답을 선언할 수 있습니다. 이는 응답이 그 중 하나의 타입일 수 있음을 의미합니다.

OpenAPI에서는 이를 anyOf로 정의합니다.

이를 위해 표준 Python 타입 힌트인 typing.Union을 사용할 수 있습니다:

참고

Union을 정의할때는 더 구체적인 타입을 먼저 포함하고, 덜 구체적인 타입을 그 뒤에 나열해야합니다. 아래 예제에서는 Union[PlaneItem, CarItem] 를 보면, 더 구체적인 PlaneItemCarItem보다 앞에 위치합니다.

from typing import Union

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class BaseItem(BaseModel):
    description: str
    type: str


class CarItem(BaseItem):
    type: str = "car"


class PlaneItem(BaseItem):
    type: str = "plane"
    size: int


items = {
    "item1": {"description": "All my friends drive a low rider", "type": "car"},
    "item2": {
        "description": "Music is my aeroplane, it's my aeroplane",
        "type": "plane",
        "size": 5,
    },
}


@app.get("/items/{item_id}", response_model=Union[PlaneItem, CarItem])
async def read_item(item_id: str):
    return items[item_id]
🤓 Other versions and variants
from typing import Union

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class BaseItem(BaseModel):
    description: str
    type: str


class CarItem(BaseItem):
    type: str = "car"


class PlaneItem(BaseItem):
    type: str = "plane"
    size: int


items = {
    "item1": {"description": "All my friends drive a low rider", "type": "car"},
    "item2": {
        "description": "Music is my aeroplane, it's my aeroplane",
        "type": "plane",
        "size": 5,
    },
}


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

Python 3.10에서 Union

위의 예제에서는 response_model 인자 값으로 Union[PlaneItem, CarItem]을 전달합니다.

이 경우, 이를 타입 어노테이션(type annotation) 이 아닌 인자 값(argument value) 으로 전달하고 있기 때문에 Python 3.10에서도 Union을 사용해야 합니다.

만약 타입 어노테이션에 사용한다면, 다음과 같이 수직 막대(|)를 사용할 수 있습니다:

some_variable: PlaneItem | CarItem

하지만 이를 response_model=PlaneItem | CarItem과 같이 할당하면 에러가 발생합니다. 이는 Python이 이를 타입 어노테이션으로 해석하지 않고, PlaneItemCarItem 사이의 잘못된 연산(invalid operation)을 시도하기 때문입니다

모델 리스트

마찬가지로, 객체 리스트 형태의 응답을 선언할 수도 있습니다.

이를 위해 표준 Python의 typing.List를 사용하세요(또는 Python 3.9 이상에서는 단순히 list를 사용할 수 있습니다):

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class Item(BaseModel):
    name: str
    description: str


items = [
    {"name": "Foo", "description": "There comes my hero"},
    {"name": "Red", "description": "It's my aeroplane"},
]


@app.get("/items/", response_model=list[Item])
async def read_items():
    return items
🤓 Other versions and variants
from typing import List

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class Item(BaseModel):
    name: str
    description: str


items = [
    {"name": "Foo", "description": "There comes my hero"},
    {"name": "Red", "description": "It's my aeroplane"},
]


@app.get("/items/", response_model=List[Item])
async def read_items():
    return items

임의의 dict 응답

Pydantic 모델을 사용하지 않고, 키와 값의 타입만 선언하여 평범한 임의의 dict로 응답을 선언할 수도 있습니다.

이는 Pydantic 모델에 필요한 유효한 필드/속성 이름을 사전에 알 수 없는 경우에 유용합니다.

이 경우, typing.Dict를 사용할 수 있습니다(또는 Python 3.9 이상에서는 단순히 dict를 사용할 수 있습니다):

from fastapi import FastAPI

app = FastAPI()


@app.get("/keyword-weights/", response_model=dict[str, float])
async def read_keyword_weights():
    return {"foo": 2.3, "bar": 3.4}
🤓 Other versions and variants
from typing import Dict

from fastapi import FastAPI

app = FastAPI()


@app.get("/keyword-weights/", response_model=Dict[str, float])
async def read_keyword_weights():
    return {"foo": 2.3, "bar": 3.4}

요약

여러 Pydantic 모델을 사용하고, 각 경우에 맞게 자유롭게 상속하세요.

엔터티가 서로 다른 "상태"를 가져야 하는 경우, 엔터티당 단일 데이터 모델을 사용할 필요는 없습니다. 예를 들어, 사용자 "엔터티"가 password, password_hash, 또는 비밀번호가 없는 상태를 포함할 수 있는 경우처럼 말입니다.