Skip to content

✨ Support providing serializing context to response models#11634

Open
alexcouper wants to merge 17 commits intofastapi:masterfrom
alexcouper:serializing-context
Open

✨ Support providing serializing context to response models#11634
alexcouper wants to merge 17 commits intofastapi:masterfrom
alexcouper:serializing-context

Conversation

@alexcouper
Copy link

@alexcouper alexcouper commented May 24, 2024

Context

pydantic/pydantic#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
Copy link

pierrecdn commented Jun 27, 2024

Hi @alexcouper 👋,

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
Copy link
Author

@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
Copy link

pierrecdn commented Jun 27, 2024

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.

@alexcouper alexcouper force-pushed the serializing-context branch from 66d13a4 to 25716e3 Compare August 9, 2024 13:30
@alexcouper alexcouper marked this pull request as ready for review August 9, 2024 13:57
@svlandeg svlandeg changed the title Support providing serializing context to response models ✨ Support providing serializing context to response models Aug 16, 2024
@svlandeg svlandeg added the feature New feature or request label Aug 16, 2024
@svlandeg
Copy link
Member

Hi @alexcouper, thanks for the PR! We'll get this reviewed by the team and get back to you 🙏

@svlandeg svlandeg changed the title ✨ Support providing serializing context to response models ✨ Support providing serializing context to response models Aug 26, 2024
@alexcouper alexcouper force-pushed the serializing-context branch 2 times, most recently from 404a85f to 90eb570 Compare September 12, 2024 10:47
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There were linting issues when changing this to use dict approach from #11670 so leaving as is.

@alexcouper
Copy link
Author

alexcouper commented Oct 17, 2024

@svlandeg should I be regularly rebasing this PR, as conflicts come in, or is it OK to wait until a first review is done?

@agamble-oai
Copy link

Any update on this? This would be a useful feature and am keen to help land.

Copy link
Member

@YuriiMotov YuriiMotov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexcouper, thanks for working on this!

First we need to decide whether we want to implement this feature this way or we also want to support passing values from request.

Let's wait for Sebastián to look at this and take a decision

UPD:
Found the problem in this implementation.
Imagine we want to remove some field if we serialize model not in FastAPI context:

class MultiUseItem(BaseModel):
    name: str
    owner_ids: Optional[list[int]] = None

    @model_serializer(mode="wrap")
    def _serialize(self, handler, info: SerializationInfo):
        data = handler(self)
        if info.context and info.context.get("mode") == "FASTAPI":
            pass
        else:  # context is not passed or mode is not FASTAPI
            if "owner_ids" in data:
                data.pop("owner_ids")
        return data

If we try to use this model as response_model and return the instance of it from the endpoint, it will be serialized by jsonable_encoder without context passed and this way we loose owner_ids.

Full code example in details

Details
from typing import Optional

from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, SerializationInfo, model_serializer


class MultiUseItem(BaseModel):
    name: str
    owner_ids: Optional[list[int]] = None

    @model_serializer(mode="wrap")
    def _serialize(self, handler, info: SerializationInfo):
        data = handler(self)
        if info.context and info.context.get("mode") == "FASTAPI":
            pass
        else:  # context is not passed or mode is not FASTAPI
            if "owner_ids" in data:
                data.pop("owner_ids")
        return data


app = FastAPI()


@app.get(
    "/items/exclude-if-not-fastapi",
    response_model=dict[str, MultiUseItem],
    response_model_context={"mode": "FASTAPI"},
)
async def get_validdict_with_context():
    return {"k": MultiUseItem(name="baz", owner_ids=[1, 2, 3])}


client = TestClient(app)


def test_exclude_if_not_fastapi_context():
    response = client.get("/items/exclude-if-not-fastapi")
    response.raise_for_status()

    expected_response = {"k": {"name": "baz", "owner_ids": [1, 2, 3]}}

    assert response.json() == expected_response

So, we also need to pass serialization context to jsonable_encoder.

This PR #13475 modifies jsonable_encoder to accept the context

- "3.9"
- "3.8"
pydantic-version: ["pydantic-v1", "pydantic-v2"]
pydantic-version: ["pydantic-v1", "pydantic-v2.7", "pydantic-v2.8+"]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we don't need this.
There are other places where the code may fail with older versions of Pydantic and if we try to cover all such cases we will end up having huge amount of runs

Comment on lines +154 to +178
#
# context argument was introduced in pydantic 2.8
if PYDANTIC_VERSION >= "2.8":
return self._type_adapter.dump_python(
value,
mode=mode,
include=include,
exclude=exclude,
by_alias=by_alias,
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
context=context,
)
else:
return self._type_adapter.dump_python(
value,
mode=mode,
include=include,
exclude=exclude,
by_alias=by_alias,
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#
# context argument was introduced in pydantic 2.8
if PYDANTIC_VERSION >= "2.8":
return self._type_adapter.dump_python(
value,
mode=mode,
include=include,
exclude=exclude,
by_alias=by_alias,
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
context=context,
)
else:
return self._type_adapter.dump_python(
value,
mode=mode,
include=include,
exclude=exclude,
by_alias=by_alias,
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
)
kwargs: dict[str, Any] = (
{"context": context} if PYDANTIC_VERSION >= "2.8" else {}
)
return self._type_adapter.dump_python(
value,
mode=mode,
include=include,
exclude=exclude,
by_alias=by_alias,
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
**kwargs,
)

This doesn't give any linting issues


This will be passed in as serialization context to the response model.

Note: This feature is a noop on pydantic < 2.8
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Note: This feature is a noop on pydantic < 2.8
Note: This feature requires Pydantic 2.8 or higher

@github-actions
Copy link
Contributor

This pull request has a merge conflict that needs to be resolved.

@github-actions github-actions bot added the conflicts Automatically generated when a PR has a merge conflict label Sep 29, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conflicts Automatically generated when a PR has a merge conflict feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants