Frame Work/FastAPI

[FastAPI] Pydantic GenericModel을 이용한 Response Data 문서화

j4ko 2023. 8. 15. 15:30
728x90
반응형

목차

     

    개요

    최근 들어 FastAPI를 다뤄볼 일이 생겼다. 

     

    그동안 Django나 Flask를 다루면서 겪었던 문제인 API 문서 생성 방법은 추가적인 라이브러리를 사용하거나 직접 문서를 작성하는 방식이었다. 이는 결국 라이브러리를 익히고 어떻게 사용해야 할지를 또 고민해야 한다는 점에서 러닝 커브가 발생했다.

     

    그러나 FastAPI에는 API 문서를 자동 생성해 주는 기능이 포함되었기에 편리함을 몸소 체감 중이다. 조금 더 알아보다가 type hint를 잘 사용한다면 조금 입맛대로 표현해볼 수 있지 않을까 싶은 생각이 들었다. FastAPI에서 API 문서를 생성할 때는 pydantic의 BaseModel과 많이 연계된 듯 보이는데 그렇다면 pydantic에 Generic을 곁들어 이용해 보자라는 취지를 가졌다.

     

    그러하여 Generic을 활용하여 pydantic의 BaseModel을 조정해보고 이 부분이 API 문서에 어떤 식으로 반영되는지 탐구해 봤다.

     

     

    BaseModel과 ResponseModel

    FastAPI에서는 pydantic의 BaseModel 혹은 python의 dataclass로 Return Type을 명시해 주면 이에 관련하여 API 문서를 자동으로 생성해 주고 이에 따라 API 문서의 Example Value에 정보가 그대로 표현된다. 아래는 기본적으로 dataclass와 BaseModel을 이용하여 객체를 정의한 간단한 예제의 코드이다.

    import dataclasses
    
    from fastapi.routing import APIRouter
    from pydantic import BaseModel
    
    member_router = APIRouter(tags=['Member'])
    
    
    class Member(BaseModel):
        name: str
        age: int
    
    
    @dataclasses.dataclass
    class Member2:
        name: str
        age: int
    
    
    @member_router.get(path="/member")
    async def member_retreieve() -> Member:
        return Member(name="jako", age=999)

    이 코드의 결과 FastAPI에서는 다음과 같은 문서가 생성되며 127.0.0.1:8000/docs에 접속하면 확인할 수 있다.

     

    Generic을 활용한 API 데이터 모델 정의

    class를 return type에 명시하면 이를 자동으로 문서화를 해준다니 정말 편리하다.!

     

    그렇다면 조금 더 동적으로 활용해 보기 위한 아이디어로 Generic을 활용해 보기로 했다. "Generic을 이용해서까지 Return Type을 명시할 일이 뭐가 있을까?"에 대해서는 다음과 같이 구상해봤다.

    특정 Api에서 반환되어야 하는 모델의 데이터의 형식을 미리 정의(ResponseBaseModel)해두고 Generic을 이용해 특정 Entity와 연관시켜 주어 사용하는 방법이다. 이렇게 하면 반환되어야 하는 데이터에 대해 Entity 클래스만 명시해 주면 되기 때문에 Api의 ResponseModel에 대한 Typing에 대해 편리하게 이용할 수 있으려나 싶었다.

     

    다음은 이를 코드로 구현한 것이다.

    T = TypeVar("T")
    
    
    class ResponseBaseModel(GenericModel, Generic[T]):
        status: int = 200
        code: int = 20000
        data: T
    
    
    @dataclasses.dataclass
    class MemberEntity:
        age: int
        name: str
    
    
    @member_router.get(path="/member")
    async def member_retreieve() -> ResponseBaseModel[MemberEntity]:
        return JSONResponse(content={})

    그런데 위 코드는 IDE 상에서 return type hint와 실제 return 되는 객체가 틀리다는 경고를 주기 때문에 다음과 같이 사용하면 경고가 노출되지 않는다.

    @member_router.get(path="/member")
    async def member_retreieve() -> ResponseBaseModel[MemberEntity]:
        return ResponseBaseModel[MemberEntity](data=MemberEntity(age=999, name="jako"))

    이 코드의 결과는 FastAPI의 문서를 통해 확인하면 다음과 같이 노출된다.

    의도한 대로 API 문서상에 데이터가 명시되었다. 미리 정의해 둔 응답 데이터를 Generic을 통해 특정 Entity를 명시하면 동적으로 변경되었다.

     

     

    Nested Type은?

    사실 위 예제는 간단한 데이터 타입의 표현이다. 현실에서는 Entity안의 Entity 그리고 Entity안의 또 다른 무엇이 들어있을 수 있다. Entity안의 Entity가 들어있는 경우의 ResponseModel은 어떻게 표현할 수 있을까? 예를 들어 MemberEntity안에 Member에 대해 설명하는 MemberProfile 객체를 포함할 수 있다고 가정해 보면 코드의 형태는 다음과 같을 것이다.

    @dataclasses.dataclass
    class MemberEntity:
        age: int
        name: str
        profile: Optional[MemberProfile]
    
    
    @dataclasses.dataclass
    class MemberProfile:
        description: str

    나머지 코드는 변경된 바 없으니 FastAPI docs를 확인해 보자.

    RootEntity(MemberEntity)의 특정 속성이 다른 타입(객체)을 포함한 경우 이를 그대로 표현해 준다.

     

     

    제한점

    사실 여기까지 정리된 내용은 FastAPI에서 제시하는 사용 방법에서 다소 벗어난 방법이긴 하다. FastAPI 문서의 가이드에 따른 pydantic을 통해 BaseModel을 컨트롤하는 것이 아니라 pydantic의 BaseModel과 python의 dataclass를 조합해서 사용하다 보니 발생하는 부분이 있었다. 필자가 파악한 부분은 다음과 같다.

     

    Entity안의 특정 필드 제외하기

    먼저 특정 필드를 제외하는 경우이다. 예를 들어 다음과 같은 MemberEntity가 있다고 가정해 보자

    @dataclasses.dataclass
    class MemberEntity:
        age: int
        name: str
        password: str
        profile: Optional[MemberProfile] = dataclasses.field(default=None)

    password 필드의 경우 굳이 Response Data에 포함시킬 필요가 없다. 그러니 Response Data에서 MemberEntity안에 들어있는 password 데이터를 제외하는 방법이 필요하다. pydantic의 BaseModel에서는 다음과 같이 사용해야 한다.

    class ResponseBaseModel(GenericModel, Generic[T]):
        status: int = 200
        code: int = 20000
        data: T
    
        class Config:
            @staticmethod
            def schema_extra(schema, model):
                """
                :param schema:
                    {'title': 'MemberEntity', 'type': 'object', 'properties': {'age': {'title': 'Age', 'type': 'integer'}, 'name': {'title': 'Name', 'type': 'string'}, 'password': {'title': 'Password', 'type': 'string'}, 'profile': {'$ref': '#/components/schemas/MemberProfile'}}, 'required': ['age', 'name', 'password']} <class 'src.controller.member.MemberEntity'>
                """
    
                schema_properties = schema.get("properties", {})
    
                hidden_fields = []
                for k, v in schema_properties.items():
                    if "password" == k:
                        hidden_fields.append(k)
    
                for field in hidden_fields:
                    if field in schema_properties:
                        del schema["properties"][field]

    이 방법이 제한점이라고 생각하는 이유는 제외해야 되는 필드를 특정 Entity에 맞게 조정해야 줘야 하지만 class 하나로 한 번에 관리하다 보니 어떤 Entity의 특정 필드를 제거하는 것인지 보이지 않는다는 점이다. 같은 단어임에도 불구하고 Entity에 따라 정의가 다른 경우가 종종 있기 때문에 무조건적으로 단어를 기준으로 일괄적으로 삭제하는 방법은 위험하고 특정 Entity에 따라 특정 필드를 제거하는 코드를 넣다 보면 유지보수가 잘 되지 않을 것이 본다.  생각하는 이상적인 형태는 다음과 같이 컨트롤하는 것이다.

    class MemberEntity:
    	...
    	__hidden_fields__ = {} 
    

    특정 Entity에서 비공개 필드를 관리하는 방식이다. 이는 django의 admin에서 특정 필드를 노출시킬 때 사용하는 방법인데 위와 같은 방법이 적용 가능한지는 아직 찾지 못했다.

     

    pydantic 2.x 버전에서의 GenericModel 이전 및 경고 메시지

    지금까지는 pydantic에서 GenericModel라는 객체가 제공되어 사용했지만 이는 사실 pydantic 1.x 버전에서만 제공된다. 2.x에서는 BaseModel에 통합되었다고 하며 v2에서 GenericModel을 사용할 경우 다음과 같은 경고 문구가 나온다.

    UserWarning: `pydantic.generics:GenericModel` has been moved to `pydantic.BaseModel`.
    

     

    맺음말

    FastAPI를 다루게 되면서 API Document에서 제공해 주는 방법이 아닌 다른 형태의 활용 방법을 생각해 보는데 막상 적용하기 쉽지 않은 듯하다. 또한 Document에서 제공하지 않은 방법이다 보니 이렇게 하는 게 맞을까는 늘 당면하는 문제의 하나이다.

     

    그럼에도 불구하고 입맛대로 무언가를 만들어서 사용할 수 있다는 것이 코딩하는 재미이지 않을까 싶다. 입맛대로 그리고 자유자재로 사용할 수 있기 위한 Python 자체에 대한 이해도를 높이는 게 중요할 듯싶다.

     

    Reference

    - https://stackoverflow.com/questions/76030107/excluding-pydantic-model-fields-only-when-returned-as-part-of-a -fastapi-call

     

    Excluding pydantic model fields only when returned as part of a FastAPI call

    Context I have a very complex pydantic model with a lot of nested pydantic models. I would like to ensure certain fields are never returned as part of API calls, but I would like those fields prese...

    stackoverflow.com

    728x90
    반응형