仅在作为 FastAPI 调用的一部分返回时排除 pydantic 模型字段

3

背景

我有一个非常复杂的 pydantic 模型,其中包含许多嵌套的 pydantic 模型。我想确保某些字段在 API 调用中永远不会返回,但我希望这些字段在内部逻辑中存在。

尝试过的方法

我首先尝试使用 pydantic 的 Field 函数来指定我不想返回的字段上的 exclude 标志。这个方法有效,但是当我的内部逻辑调用 .dict() 时,必须通过调用 .dict(exclude=None) 来覆盖这个方法。

相反,我在 Field 上指定了一个自定义标志 return_in_api,目标是只在 FastAPI 调用 .dict() 时应用排除。我尝试编写一个中间件,在其中调用 .dict() 并根据哪些嵌套字段包含 return_in_api=False 来传递自己的 exclude 属性。然而,FastAPI 的中间件为响应提供了一个流,我不想过早地解决它。

因此,我编写了一个装饰器,用适当的 exclude 值调用我的路由处理程序的返回值上的 .dict()

问题

一个挑战是每当添加新的端点时,添加它们的人必须记得包含这个装饰器,否则字段会泄漏。

理想情况下,我希望将此装饰器应用于每个路由,但通过中间件实现似乎会破坏响应流。


3
非常好的问题。下次我建议您包含一些超级简化的示例代码。即使是不起作用的代码,只要显示它的哪些部分不像它们应该那样工作,就很好。因为这是一个编程网站,当查看(简化的)代码来说明问题时,大多数人可以更快地理解问题,而不是仅仅是散文。例如,您可以提供一个带有return_in_api=False字段的简单模型,并提供一个返回该模型实例和所需输出的简单端点。您的详细说明应该在此之后给出。 - Daniil Fajnberg
1个回答

5

在所有路由中系统地排除字段

我发现最好使用一个具体但超级简单的示例来工作。假设您有以下模型:

from pydantic import BaseModel, Field


class Demo(BaseModel):
    foo: str
    bar: str = Field(return_in_api=False)

我们希望确保在响应中永远不会返回bar,无论是当response_model作为路由装饰器的参数明确提供时,还是仅将其设置为路由处理程序函数的返回注释时。(假设出于某种原因,我们不想使用内置的exclude参数来排除字段。)

我发现最可靠的方法是子类化fastapi.routing.APIRoute并钩入其__init__方法。通过复制父类代码的一小部分,我们可以确保始终获得正确的响应模型。一旦我们拥有了它,只需要在调用父构造函数之前设置路由的response_model_exclude参数即可。

这是我的建议:

from collections.abc import Callable
from typing import Any

from fastapi.responses import Response
from fastapi.dependencies.utils import get_typed_return_annotation, lenient_issubclass
from fastapi.routing import APIRoute, Default, DefaultPlaceholder


class CustomAPIRoute(APIRoute):
    def __init__(
        self,
        path: str,
        endpoint: Callable[..., Any],
        *,
        response_model: Any = Default(None),
        **kwargs: Any,
    ) -> None:
        # We need this part to ensure we get the response model,
        # even if it is just set as an annotation on the handler function.
        if isinstance(response_model, DefaultPlaceholder):
            return_annotation = get_typed_return_annotation(endpoint)
            if lenient_issubclass(return_annotation, Response):
                response_model = None
            else:
                response_model = return_annotation
        # Find the fields to exclude:
        if response_model is not None:
            kwargs["response_model_exclude"] = {
                name
                for name, field in response_model.__fields__.items()
                if field.field_info.extra.get("return_in_api") is False
            }
        super().__init__(path, endpoint, response_model=response_model, **kwargs)

我们现在可以在 Router 上设置自定义路由类 (documentation)。这样它就会被用于所有路由:

from fastapi import FastAPI
# ... import CustomAPIRoute
# ... import Demo

api = FastAPI()
api.router.route_class = CustomAPIRoute


@api.get("/demo1")
async def demo1() -> Demo:
    return Demo(foo="a", bar="b")


@api.get("/demo2", response_model=Demo)
async def demo2() -> dict[str, Any]:
    return {"foo": "x", "bar": "y"}

尝试使用uvicornGET获取端点/demo1/demo2的简单API示例,分别返回响应{"foo":"a"}{"foo":"x"}

确保模式一致性

值得一提的是(除非我们采取额外措施),bar字段仍将成为模式的一部分。这意味着,例如,两个端点的自动生成的OpenAPI文档将显示bar作为预期响应的顶级属性。

这不是你的问题的一部分,所以我假设你已经意识到这一点并采取措施确保一致性。如果没有,或者对于其他人阅读此内容,您可以在基本模型的Config上定义一个静态schema_extra方法,在返回模式之前删除那些永远不会“向外部”显示的字段:

from typing import Any
from pydantic import BaseModel, Field


class CustomBaseModel(BaseModel):
    class Config:
        @staticmethod
        def schema_extra(schema: dict[str, Any]) -> None:
            properties = schema.get("properties", {})
            to_delete = set()
            for name, prop in properties.items():
                if prop.get("return_in_api") is False:
                    to_delete.add(name)
            for name in to_delete:
                del properties[name]


class Demo(CustomBaseModel):
    foo: str
    bar: str = Field(return_in_api=False)

谢谢你这么详细的回答,Daniil。这非常有用,但不幸的是,这并没有完全解决我的问题。我想在模型不是响应模型时也排除那些字段。例如,如果Demo是另一个模型的子级。这段代码只考虑了Demo是唯一返回的情况。我意识到这个问题可能更多地涉及到pydantic而不是FastAPI,所以我还有另一个问题(你已经看到了):https://dev59.com/yddqpIgBRmDukGFE7WHv - rbhalla
@rbhalla 你说得对。我没有考虑到模型的嵌套。恐怕这种方法对于那个是无用的。那么我唯一合理的看法是使用内置的 exclude 功能,就像你已经做的那样,覆盖基本模型的 dict 方法,并有一个方便的方法来调用 dict(exclude=None) 来满足你的其他需求。我怀疑是否还有其他(有效)的解决方法。 - Daniil Fajnberg

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接