diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 40dffba64b..85c583032b 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -243,11 +243,42 @@ def get_typed_signature(call: Callable[..., Any]) -> inspect.Signature: def get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any: + # Resolve bare string / ForwardRef annotations (e.g. produced by + # `from __future__ import annotations` when the annotation itself is a + # plain type name). if isinstance(annotation, str): annotation = ForwardRef(annotation) annotation = evaluate_forwardref(annotation, globalns, globalns) if annotation is type(None): return None + + # When `from __future__ import annotations` is active, the *base* type + # inside `Annotated[SomeType, ...]` may still be a string or ForwardRef + # even after the outer annotation has been evaluated. Handle that by + # resolving the base type and reconstructing the Annotated alias. + if get_origin(annotation) is Annotated: + args = get_args(annotation) + # args[0] is the base type; args[1:] are the metadata (Depends, etc.) + base_type = args[0] + metadata = args[1:] + + # Resolve the base type if it is still a string or ForwardRef + if isinstance(base_type, str): + base_type = ForwardRef(base_type) + if isinstance(base_type, ForwardRef): + base_type = evaluate_forwardref(base_type, globalns, globalns) + + # Recursively resolve in case of nested Annotated / ForwardRef + base_type = get_typed_annotation(base_type, globalns) + + # Keep NoneType handling consistent with the bare-string path above + if base_type is type(None): + base_type = None + + # Reconstruct Annotated correctly via __class_getitem__ so that + # Annotated[T, meta1, meta2] is produced, not Annotated[(T, meta1, meta2)]. + annotation = Annotated.__class_getitem__((base_type, *metadata)) + return annotation diff --git a/tests/test_annotated_forwardref.py b/tests/test_annotated_forwardref.py new file mode 100644 index 0000000000..b7e4e2e5aa --- /dev/null +++ b/tests/test_annotated_forwardref.py @@ -0,0 +1,56 @@ +# IMPORTANT: This future import MUST be at the top of the module. +# It is what causes annotations to be stored as strings (ForwardRef), +# which is exactly the bug scenario described in issue #13056. +from __future__ import annotations + +from dataclasses import dataclass +from typing import Annotated + +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +# ── App setup ───────────────────────────────────────────────────────────────── + + +app = FastAPI() + + +@dataclass +class Potato: + color: str + size: int + + +def get_potato() -> Potato: + return Potato(color="red", size=10) + + +# The annotation "Potato" is a string here because of `from __future__ import +# annotations`. Before the fix, FastAPI would fail to resolve it when it is +# wrapped inside Annotated[...]. +@app.get("/") +def read_root(potato: Annotated[Potato, Depends(get_potato)]) -> dict: + return {"color": potato.color, "size": potato.size} + + +client = TestClient(app) + + +# ── Tests ────────────────────────────────────────────────────────────────────── + + +def test_annotated_forwardref_response() -> None: + """Endpoint using Annotated[ForwardRef, Depends(...)] must return 200.""" + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"color": "red", "size": 10} + + +def test_annotated_forwardref_openapi_schema() -> None: + """OpenAPI schema must be generated without errors and must not leak ForwardRef.""" + response = client.get("/openapi.json") + assert response.status_code == 200 + schema_text = response.text + assert "ForwardRef" not in schema_text, ( + "Unresolved ForwardRef leaked into the OpenAPI schema" + ) diff --git a/tests/test_dependencies_utils.py b/tests/test_dependencies_utils.py index 9257d1c9ee..f5bfa0405b 100644 --- a/tests/test_dependencies_utils.py +++ b/tests/test_dependencies_utils.py @@ -1,8 +1,73 @@ +# NOTE: Do NOT add `from __future__ import annotations` here. +# That future import would turn the string subscripts in Annotated[...] bodies +# into deferred references and break isinstance() checks in this file. + +from typing import Annotated, ForwardRef, get_args, get_origin + +from fastapi import params from fastapi.dependencies.utils import get_typed_annotation +# --------------------------------------------------------------------------- +# Existing coverage test +# --------------------------------------------------------------------------- + -def test_get_typed_annotation(): - # For coverage - annotation = "None" - typed_annotation = get_typed_annotation(annotation, globals()) +def test_get_typed_annotation_none_string() -> None: + # A bare "None" string should resolve to Python's NoneType (returned as None) + typed_annotation = get_typed_annotation("None", globals()) assert typed_annotation is None + + +# --------------------------------------------------------------------------- +# Issue #13056 – ForwardRef inside Annotated +# --------------------------------------------------------------------------- + + +class _Potato: + """Stand-in model used by the tests below.""" + + +def _get_potato() -> "_Potato": + return _Potato() + + +def test_annotated_with_string_base_type() -> None: + """Annotated["_Potato", Depends(...)] must resolve the base type.""" + dep = params.Depends(_get_potato) + annotation = Annotated["_Potato", dep] + resolved = get_typed_annotation(annotation, globals()) + + assert get_origin(resolved) is Annotated + args = get_args(resolved) + assert args[0] is _Potato, f"Expected _Potato, got {args[0]}" + assert isinstance(args[1], params.Depends) + + +def test_annotated_with_forwardref_base_type() -> None: + """Annotated[ForwardRef("_Potato"), Depends(...)] must also resolve.""" + dep = params.Depends(_get_potato) + annotation = Annotated[ForwardRef("_Potato"), dep] + resolved = get_typed_annotation(annotation, globals()) + + assert get_origin(resolved) is Annotated + args = get_args(resolved) + assert args[0] is _Potato, f"Expected _Potato, got {args[0]}" + assert isinstance(args[1], params.Depends) + + +def test_annotated_with_already_resolved_type() -> None: + """Annotated[_Potato, Depends(...)] (already resolved) must pass through unchanged.""" + dep = params.Depends(_get_potato) + annotation = Annotated[_Potato, dep] + resolved = get_typed_annotation(annotation, globals()) + + assert get_origin(resolved) is Annotated + args = get_args(resolved) + assert args[0] is _Potato + assert isinstance(args[1], params.Depends) + + +def test_bare_string_annotation() -> None: + """A bare string annotation (non-Annotated) must still resolve correctly.""" + resolved = get_typed_annotation("_Potato", globals()) + assert resolved is _Potato