Browse Source

feat(deps): merge Annotated shape markers with Depends(model)

pull/15477/head
Suren Khorenyan 1 month ago
parent
commit
a79cabcb62
  1. 229
      fastapi/dependencies/utils.py

229
fastapi/dependencies/utils.py

@ -55,7 +55,7 @@ from fastapi.concurrency import (
contextmanager_in_threadpool, contextmanager_in_threadpool,
) )
from fastapi.dependencies.models import Dependant from fastapi.dependencies.models import Dependant
from fastapi.exceptions import DependencyScopeError from fastapi.exceptions import DependencyScopeError, FastAPIError
from fastapi.logger import logger from fastapi.logger import logger
from fastapi.security.oauth2 import SecurityScopes from fastapi.security.oauth2 import SecurityScopes
from fastapi.types import DependencyCacheKey from fastapi.types import DependencyCacheKey
@ -390,6 +390,204 @@ class ParamDetails:
field: ModelField | None field: ModelField | None
@dataclass(slots=True)
class _AnnotatedBodyDependsMerge:
"""Result of ``Annotated[..., marker, Depends(Model)]`` (order-independent)."""
field_info: FieldInfo
use_annotation: Any
class _AnnotatedBodyDependsMerger:
"""
Merge ``Body`` / ``Form`` / ``File`` / ``Query`` with ``Depends(model_cls)`` inside ``Annotated``.
FastAPI normally keeps only the last marker in ``Annotated[...]``, so
``Annotated[T, Body(), Depends(Foo)]`` used to ignore ``Body`` and treat ``Foo`` as a
dependency (e.g. query fields). This path combines them into one field (body, form,
multipart, or query): OpenAPI and validation use ``Foo``; the handler parameter's
declared type stays ``T`` (``declared_type``, i.e. the first ``Annotated`` argument).
Contract:
- Exactly one of ``Body()``, ``Form()``, ``File()``, or ``Query()`` among
FastAPI-specific metadata (not ``Body`` and ``Query`` together).
- Exactly one ``Depends``, whose dependency resolves to a single ``BaseModel`` subclass
(not a callable, not ``Security``).
- No other ``Param`` kinds in the same ``Annotated`` (e.g. ``Path``, ``Header``, )
besides the single shape marker and ``Depends``.
- Not used on path parameters.
- Order of markers in ``Annotated`` does not matter.
"""
__slots__ = (
"param_name",
"declared_type",
"fastapi_specific_annotations",
"value",
"is_path_param",
)
def __init__(
self,
*,
param_name: str,
declared_type: Any,
fastapi_specific_annotations: list[Any],
value: Any,
is_path_param: bool,
) -> None:
self.param_name = param_name
self.declared_type = declared_type
self.fastapi_specific_annotations = fastapi_specific_annotations
self.value = value
self.is_path_param = is_path_param
def try_build(self) -> _AnnotatedBodyDependsMerge | None:
body_like_markers, query_markers, depends_markers = self._collect_markers()
if body_like_markers and query_markers:
raise FastAPIError(
f"Cannot combine `Query` with `Body`, `Form`, or `File` in `Annotated` for "
f"{self.param_name!r}"
)
shape_markers = body_like_markers or query_markers
if not (shape_markers and depends_markers):
return None
self._validate_path_parameter()
self._validate_single_body_like_marker(body_like_markers)
self._validate_single_query_marker(query_markers)
self._validate_single_depends_marker(depends_markers)
shape_marker = shape_markers[0]
self._validate_no_conflicting_param_markers(shape_marker=shape_marker)
dep_marker = self._copy_depends_marker_with_declared_type_fallback(
depends_markers=depends_markers,
)
self._validate_depends_not_security(dep_marker)
model_cls = self._require_pydantic_model_class(dep_marker)
field_info, merged_annotation = self._build_field_info_and_annotation(
model_cls=model_cls,
shape_marker=shape_marker,
)
self._apply_signature_default_to_field_info(field_info)
return _AnnotatedBodyDependsMerge(
field_info=field_info,
use_annotation=merged_annotation,
)
def _collect_markers(self) -> tuple[list[Any], list[Any], list[Any]]:
body_like = [
arg
for arg in self.fastapi_specific_annotations
if isinstance(arg, params.Body)
]
queries = [
arg
for arg in self.fastapi_specific_annotations
if isinstance(arg, params.Query)
]
depends = [
arg
for arg in self.fastapi_specific_annotations
if isinstance(arg, params.Depends)
]
return body_like, queries, depends
def _validate_path_parameter(self) -> None:
if self.is_path_param:
raise FastAPIError(
f"Cannot combine `Body`/`Form`/`File`/`Query` with `Depends` in `Annotated` "
f"for path parameter {self.param_name!r}"
)
def _validate_single_body_like_marker(self, body_like_markers: list[Any]) -> None:
if len(body_like_markers) > 1:
raise FastAPIError(
f"Cannot specify multiple `Body`, `Form`, or `File` markers for "
f"{self.param_name!r} when using `Depends` in the same `Annotated`"
)
def _validate_single_query_marker(self, query_markers: list[Any]) -> None:
if len(query_markers) > 1:
raise FastAPIError(
f"Cannot specify multiple `Query` markers for {self.param_name!r} when "
f"using `Depends` in the same `Annotated`"
)
def _validate_single_depends_marker(self, depends_markers: list[Any]) -> None:
if len(depends_markers) > 1:
raise FastAPIError(
f"Cannot specify multiple `Depends` together with `Body`, `Form`, "
f"`File`, or `Query` in `Annotated` for {self.param_name!r}"
)
def _conflicting_param_markers(self, *, shape_marker: Any) -> list[Any]:
return [
arg
for arg in self.fastapi_specific_annotations
if isinstance(arg, params.Param) and arg is not shape_marker
]
def _validate_no_conflicting_param_markers(self, *, shape_marker: Any) -> None:
if self._conflicting_param_markers(shape_marker=shape_marker):
raise FastAPIError(
f"Cannot combine `Body`/`Form`/`File`/`Query` with other parameter types "
f"(e.g. `Path`, `Header`) in `Annotated` for {self.param_name!r}"
)
def _copy_depends_marker_with_declared_type_fallback(
self, depends_markers: list[Any]
) -> params.Depends:
dep_marker = cast(params.Depends, copy(depends_markers[0]))
if dep_marker.dependency is None:
dep_marker = dataclasses.replace(dep_marker, dependency=self.declared_type)
return dep_marker
def _validate_depends_not_security(self, dep_marker: params.Depends) -> None:
if isinstance(dep_marker, params.Security):
raise FastAPIError(
f"Cannot combine `Body`/`Form`/`File`/`Query` with `Security` in `Annotated` "
f"for {self.param_name!r}"
)
def _require_pydantic_model_class(
self, dep_marker: params.Depends
) -> type[BaseModel]:
dependency = dep_marker.dependency
assert dependency is not None
if not isinstance(dependency, type) or not lenient_issubclass(
dependency, BaseModel
):
raise FastAPIError(
f"When using `Body`, `Form`, `File`, or `Query` together with `Depends` in "
f"`Annotated` for {self.param_name!r}, `Depends` must reference a Pydantic "
f"model class (e.g. `Depends(MyModel)`), not a callable dependency."
)
return dependency
def _build_field_info_and_annotation(
self,
model_cls: type[BaseModel],
shape_marker: Any,
) -> tuple[FieldInfo, Any]:
merged_annotation = Annotated[model_cls, shape_marker] # type: ignore[valid-type]
field_info = copy_field_info(
field_info=shape_marker,
annotation=merged_annotation,
)
assert field_info.default == Undefined or field_info.default == RequiredParam, (
f"`{field_info.__class__.__name__}` default value cannot be set in"
f" `Annotated` for {self.param_name!r}. Set the default value with `=` instead."
)
return field_info, merged_annotation
def _apply_signature_default_to_field_info(self, field_info: FieldInfo) -> None:
if self.value is not inspect.Signature.empty:
assert not self.is_path_param, "Path parameters cannot have default values"
field_info.default = self.value
else:
field_info.default = RequiredParam
def analyze_param( def analyze_param(
*, *,
param_name: str, param_name: str,
@ -428,14 +626,24 @@ def analyze_param(
), ),
) )
] ]
if fastapi_specific_annotations: merged_body_depends = False
fastapi_annotation: FieldInfo | params.Depends | None = ( fastapi_annotation: FieldInfo | params.Depends | None = None
fastapi_specific_annotations[-1] body_depends_merge = _AnnotatedBodyDependsMerger(
) param_name=param_name,
else: declared_type=type_annotation,
fastapi_annotation = None fastapi_specific_annotations=fastapi_specific_annotations,
# Set default for Annotated FieldInfo value=value,
if isinstance(fastapi_annotation, FieldInfo): is_path_param=is_path_param,
).try_build()
if body_depends_merge is not None:
field_info = body_depends_merge.field_info
use_annotation = body_depends_merge.use_annotation
depends = None
merged_body_depends = True
elif fastapi_specific_annotations:
fastapi_annotation = fastapi_specific_annotations[-1]
# Set default for Annotated FieldInfo / Depends (single winner unless merged above)
if not merged_body_depends and isinstance(fastapi_annotation, FieldInfo):
# Copy `field_info` because we mutate `field_info.default` below. # Copy `field_info` because we mutate `field_info.default` below.
field_info = copy_field_info( field_info = copy_field_info(
field_info=fastapi_annotation, field_info=fastapi_annotation,
@ -452,8 +660,7 @@ def analyze_param(
field_info.default = value field_info.default = value
else: else:
field_info.default = RequiredParam field_info.default = RequiredParam
# Get Annotated Depends elif not merged_body_depends and isinstance(fastapi_annotation, params.Depends):
elif isinstance(fastapi_annotation, params.Depends):
depends = fastapi_annotation depends = fastapi_annotation
# Get Depends from default value # Get Depends from default value
if isinstance(value, params.Depends): if isinstance(value, params.Depends):

Loading…
Cancel
Save