강의

멘토링

커뮤니티

인프런 커뮤니티 질문&답변

kyo lee님의 프로필 이미지
kyo lee

작성한 질문수

[매일 완독 챌린지] 저자와 함께하는 <FastAPI로 기획에서 출시까지>

7.5절 : 계정 설정 페이지 구현 (p253~p263)

patch 요청시 payload가 넘어가지 않습니다.

해결된 질문

작성

·

40

·

수정됨

0

아래 두 요청 코드에서 patch 요청 시 payload가 엔드포인트 함수에서 None으로 잡혀 model_dump()에서 오류가 발생합니다. 원인을 잘 모르겠습니다.

[오류]

image.png



[요청 코드]

@pytest.mark.parametrize("payload", [
    {"display_name": "푸딩캠프"},
    {"email": "hannal@example.com"},
    {"display_name": "푸딩캠프", "email": "hannal@example.com"},
])
async def test_사용자가_변경하는_항목만_변경되고_나머지는_기존_값을_유지한다(
    client_with_auth: TestClient, # 인증을 받은 클라이언트
    payload: dict, # 클라이언트 요청 페이로드
    host_user: User, # 클라이언트 사용자
):
    # 현재 사용자 정보를 보관한다.
    before_data = host_user.model_dump()

    response = client_with_auth.patch("/account/@me", json=payload)
    # (...)
async def test_비밀번호_변경_시_해싱_처리한_비밀번호가_저장되어야_한다(
    client_with_auth: TestClient,
    host_user: User,
    db_session: AsyncSession,
):
    before_data = host_user.hashed_password
    payload = {
        "password": "new_password",
        "password_again": "new_password",
    }

    response = client_with_auth.patch("/account/@me", json=payload)
    # (...)


[엔드포인트]

@router.patch("/@me", response_model=UserDetailOut)
async def update_user(
    user: CurrentUserDep,
    session: DbSessionDep,
    payload: UpdateUserPayload = Body(...),
) -> User:
    updated_data = payload.model_dump(exclude_none=True, exclude={"password", "password_again"})

    stmt = update(User).where(User.id == user.id).values(**updated_data)
    await session.execute(stmt)
    await session.commit()
    await session.refresh(user)
    return user

 

[스키마]

class UpdateUserPayload(SQLModel):
    display_name: str | None = Field(default=None, min_length=4, max_length=40)
    email: EmailStr | None = Field(default=None, max_length=128)
    password: str | None = Field(default=None, min_length=8, max_length=128)
    password_again: str | None = Field(default=None, min_length=8, max_length=128)

    @model_validator(mode="after")
    def check_all_fields_are_none(self) -> Self:
        if not self.model_dump(exclude_none=True):
            raise ValueError("최소 하나의 필드는 반드시 제공되어야 합니다.")
        return self
    
    @model_validator(mode="after")
    def verify_password(self) -> Self:
        if self.password is not None or self.password_again is not None:
            # 둘 중 하나라도 들어오면 둘 다 있어야 함
            if not self.password or not self.password_again:
                raise ValueError("비밀번호 변경 시 password와 password_again을 모두 제공해야 합니다.")

            if self.password != self.password_again:
                raise ValueError("비밀번호가 일치하지 않습니다.")

    @computed_field
    @property
    def hashed_password(self) -> str | None:
        if self.password:
            return hash_password(self.password)
        return None


[픽스처]

@pytest.fixture(autouse=True) 
async def db_session():
    dsn = "sqlite+aiosqlite:///:memory:"
    engine = create_async_engine(dsn)
    async with engine.begin() as conn:
        await conn.run_sync(SQLModel.metadata.drop_all)
        await conn.run_sync(SQLModel.metadata.create_all) 

        session_factory = create_session(engine) 
        async with session_factory() as session: 
            yield session

        await conn.run_sync(SQLModel.metadata.drop_all)
        
    await engine.dispose()


@pytest.fixture()
def fastapi_app(db_session: AsyncSession):
    app = FastAPI()       
    include_routers(app)  

    async def override_use_session():
        yield db_session

    app.dependency_overrides[use_session] = override_use_session
    return app


@pytest.fixture()
async def host_user(db_session: AsyncSession):
    user = account_models.User(
        username="puddingcamp",
        hashed_password=hash_password("testtest"),
        email="puddingcamp@example.com",
        display_name="푸딩캠프",
        is_host=True,
    )
    db_session.add(user)
    await db_session.flush()
    await db_session.commit()
    return user
    

@pytest.fixture()
def client_with_auth(fastapi_app: FastAPI, host_user: account_models.User):
    payload = LoginPayload.model_validate({
        "username": host_user.username,
        "password": "testtest",
    })

    with TestClient(fastapi_app) as client:

        response = client.post("/account/login", json=payload.model_dump())
        assert response.status_code == status.HTTP_200_OK

        auth_token = response.cookies.get("auth_token")
        assert auth_token is not None

        client.cookies.set("auth_token", auth_token)
        yield client 


답변 3

1

한날님의 프로필 이미지
한날
지식공유자

확인했습니다. 힌트만 먼저 드릴게요. UpdateUserPayload 스키마에서 verify_password 이 메서드에서 문제를 찾아보세요. 🙂

1

한날님의 프로필 이미지
한날
지식공유자

보여주신 코드만으로는 저도 파악이 안 되는데, 전체 코드를 볼 수 있을까요?

kyo lee님의 프로필 이미지
kyo lee
질문자

테스트 코드 말씀이실까요? 아래 우선 올려보겠습니다!

@pytest.mark.parametrize("payload", [
    {"display_name": "푸딩캠프"},
    {"email": "hannal@example.com"},
    {"display_name": "푸딩캠프", "email": "hannal@example.com"},
])
async def test_사용자가_변경하는_항목만_변경되고_나머지는_기존_값을_유지한다(
    client_with_auth: TestClient, # 인증을 받은 클라이언트
    payload: dict, # 클라이언트 요청 페이로드
    host_user: User, # 클라이언트 사용자
):
    # 현재 사용자 정보를 보관한다.
    before_data = host_user.model_dump()

    response = client_with_auth.patch("/account/@me", json=payload)

    assert response.status_code == status.HTTP_200_OK
    data = response.json()

    # 변경된 항목은 변경된 값으로 변경되어야 한다.
    for key, value in payload.items():
        assert data[key] == value

    # 변경되지 않은 항목은 기존 값을 유지한다.
    for key in UPDTABLE_FIELDS - frozenset(payload.keys()):
        assert data[key] == before_data[key]
async def test_비밀번호_변경_시_해싱_처리한_비밀번호가_저장되어야_한다(
    client_with_auth: TestClient,
    host_user: User,
    db_session: AsyncSession,
):
    before_data = host_user.hashed_password
    payload = {
        "password": "new_password",
        "password_again": "new_password",
    }

    response = client_with_auth.patch("/account/@me", json=payload)
    assert response.status_code == status.HTTP_200_OK

    await db_session.refresh(host_user)
    assert host_user.hashed_password != before_data
한날님의 프로필 이미지
한날
지식공유자

프로젝트 코드 전체요. github 에 repository로 관리하신다면, 그곳을 알려주셔도 좋고, local에서만 작업하셨다면 압축하여 전달해주시면 되어요. 파일을 내려받을 url 알려주세요. ( kay hannal.net 으로 알려주세요. 🙂

kyo lee님의 프로필 이미지
kyo lee
질문자

kyo lee님의 프로필 이미지
kyo lee
질문자

흑흑 원인을 찾았습니다... ㅠㅠ 스키마 model_validator에 return를 빼먹었네요. "after" 옵션이라 모델 객체 생성 후 검증 과정에서 return이 없어 payload가 None으로 찍힌 것 같습니다.

0

안녕하세요, 인프런 AI 인턴입니다. kyo lee님.

질문해주신 패치 요청 시 페이로드가 None으로 처리되어 발생하는 문제에 대해 설명드리겠습니다.

원인 및 해결 방법

  1. 데이터 유효성 검사: 문제가 발생하는 부분은 update_user 엔드포인트에서 payloadNone이 되는 경우입니다. 이는 Body(...)를 통해 요청 본문을 읽어오도록 되어 있는데, 실제 클라이언트 측에서 json=payload 형태로 데이터를 전송하고 있으므로 이 경우 payload의 스키마가 잘못되었거나 FastAPI에서 본문을 제대로 읽어오지 못하는 경우일 수 있습니다.

  2. 모델 매칭: UpdateUserPayload 스키마가 정확히 요청의 데이터 구조와 일치하는지 확인해야 합니다. 예를 들어, display_name, email, password, password_again 등의 필드가 올바르게 정의되어 있는지 확인하세요.

  3. Request Body Parsing: 만약 Body(...)가 제대로 작동하지 않는 것 같다면, payload를 직접 JSON으로 전송하고 올바르게 파싱되는지 확인해야 합니다. 혹시 JSON 형식이 잘못되어 수신되지 않는 것은 아닌지 확인해보세요.

아래는 체크리스트를 제시합니다:

  • UpdateUserPayload 정의: 이 데이터 모델이 요청의 데이터 구조와 정확히 일치하는지 다시 확인합니다.
  • FastAPI Configuration: FastAPI 애플리케이션에서 JSON 요청 본문을 읽어오도록 설정이 되어 있는지 확인합니다.
  • 테스트 데이터 검증: 테스트에서 사용 중인 데이터 payload의 구조가 모델의 필드와 일치하는지 확인합니다.

추가적인 링크

유사한 문제가 발생할 수 있는 사례에 대한 링크를 살펴보세요:
- FastAPI Request Body Management – Inflearn 기존 질문

이는 일반적인 FastAPI 엔드포인트에서의 Request Body 파싱 문제일 수 있습니다. 따라서, 위의 해결책들을 단계적으로 확인하여 문제가 되는 부분을 수정해 보시기 바랍니다.

저는 질문자님의 이해를 돕고자 지식공유자님의 기존 답변을 참고하여 유사한 답변 링크를 추천해드리고 있습니다.
현재 베타 기능으로 답변이 만족스럽지 않을 수 있는 점 양해 부탁드립니다. 🙏
추가적으로 궁금한 점이 있으시면, 이어서 질문해 주세요. 곧 지식공유자께서 답변해 주실 것입니다.

kyo lee님의 프로필 이미지
kyo lee

작성한 질문수

질문하기