tiangolo / fastapi

FastAPI framework, high performance, easy to learn, fast to code, ready for production
https://fastapi.tiangolo.com/
MIT License
73.21k stars 6.17k forks source link

Support providing serializing context to response models #11634

Open alexcouper opened 1 month ago

alexcouper commented 1 month ago

Context

https://github.com/pydantic/pydantic/pull/9495 introduces passing context to TypeAdapter models.

Problem

Some fastapi based projects use model definitions that have multiple uses, and serializing for an API response is only one. Up until now, fastapi/pydantic has supported exclusion of fields through the use of exclude keyword (example from sentry).

If however, a model needs to be serialized in different contexts - for example to be saved to dynamodb as well as to return via the API - it becomes limiting to have to opt in for inclusion/exclusion once and for all.

Solution

Pydantic Serialization Contexts provide a means to tell pydantic models the context of the serialization, and then they can act on that.

This PR makes it possible to reuse model definitions within a fastapi project for multiple purposes, and then simply state during route definition the context you want to be included when rendering.

Example

Simple example


class SerializedContext(StrEnum):
    DYNAMODB = "dynamodb"
    FASTAPI = "fastapi"

class Item(BaseModel):
    name: str = Field(alias="aliased_name")
    price: Optional[float] = None
    owner_ids: Optional[List[int]] = None

    @model_serializer(mode="wrap")
    def _serialize(self, handler, info: SerializationInfo | None = None):
        data = handler(self)
        if info.context and info.context.get("mode") == SerializedContext.FASTAPI:
            if "price" in data:
                data.pop("price")
        return data

@app.get(
    "/items/validdict-with-context",
    response_model=Dict[str, Item],
    response_model_context={"mode": SerializedContext.FASTAPI},
)
async def get_validdict_with_context():

    return {
        "k1": Item(aliased_name="foo"),
        "k2": Item(aliased_name="bar", price=1.0),
        "k3": Item(aliased_name="baz", price=2.0, owner_ids=[1, 2, 3]),
    }

And this model definition can be made more generalized like so:

class ContextSerializable(BaseModel):
    @model_serializer(mode="wrap")
    def _serialize(self, handler, info: SerializationInfo):
        d = handler(self)

        serialize_context = info.context.get("mode")
        if not serialize_context:
            return d
        for k, v in self.model_fields.items():
            contexts = (
                v.json_schema_extra.get("serialized_contexts", [])
                if v.json_schema_extra
                else []
            )
            if contexts and serialize_context not in contexts:
                d.pop(k)

        return d

class Item(ContextSerializable):
    name: str = Field(alias="aliased_name")
    price: Optional[float] = Field(serialized_contexts={SerializedContext.DYNAMODB})
    owner_ids: Optional[List[int]] = None

TODO:

pierrecdn commented 1 week ago

Hi @alexcouper :wave:,

I'm wondering if your proposal can be used to handle my use-case: I'd like a parameter passed to an endpoint to expand some datamodels. By default I want one property being serialized with exclude_unset=True, but in case expansion is required, I want to turn it to False. At first I though pydantic's serialization context would fix my issue, but I realize that without fastAPI integration of this to drive the context injection, it becomes yet another ugly hack.

class CustomObjectRead(SQLModel):
    identifier: int
    description: str | None = None
    config: Config

    @field_serializer('config')
    def expand_config(self, config: Config, info: SerializationInfo):
        context = info.context
        expand_config = context and context.get('expanded')
        return config.model_dump(exclude_unset=not expand_config)
@myrouter.get("")
async def customobject_get_all(
   expanded: bool = Query(default=False, description="Expand the configurations"),
) -> list[CustomObjectRead]:
     (...)
     # Find a way to pass expanded to the serialization context 
     return [my_object_collection]

The specifics here compared to your example is the fact the context should be hinted by the call itself. Maybe I just lost myself and there's another elegant way to do it?

alexcouper commented 1 week ago

@pierrecdn

If you want the behaviour to be determined by a query param flag then I'm not sure this will help you -at least you won't be able to make use of the response_model_context parameter as in the example.

One way round this would be to have 2 route endpoints dependent on expansion that use the same underlying function to get the objects. If you do that you can use the response_model_exclude_unset flag already present in a route to determine behaviour.

pierrecdn commented 1 week ago

One way round this would be to have 2 route endpoints dependent on expansion that use the same underlying function to get the objects. If you do that you can use the response_model_exclude_unset flag already present in a route to determine behaviour.

Yeah, but the "2 route endpoint" is not something I'd consider. Basically I want the behavior to be driven by query, such as /api/resource?expanded=true.

I guess it's worth logging a new issue at that point.