From 2f9439742f930289a0dc69ad07d7a8fefa5e3741 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sun, 8 Feb 2026 02:09:24 -0800 Subject: [PATCH] Fix issue #13056: Handle ForwardRef in Annotated types When using Annotated types with ForwardRef string annotations (e.g., Annotated['ModelName', Query()]), FastAPI would fail with a Pydantic error about the type not being fully defined. The fix resolves ForwardRef in the extracted type annotation during parameter analysis, ensuring that the actual resolved type is passed to Pydantic rather than the unresolved ForwardRef. Changes: - Modified analyze_param() to resolve ForwardRef when extracting from Annotated - Added globalns parameter to pass the function's global namespace for resolution - Updated get_dependant() to extract and pass globalns to analyze_param() - Added comprehensive tests for ForwardRef with Annotated types The fix handles various scenarios: - Query parameters with ForwardRef - Path parameters with ForwardRef - Header parameters with ForwardRef - Request body with ForwardRef - Optional types with ForwardRef - Multiple annotations with ForwardRef --- fastapi/dependencies/utils.py | 15 +++ tests/test_annotated_forwardref.py | 205 +++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 tests/test_annotated_forwardref.py diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 80f9c76e90..14e9389a28 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -278,6 +278,8 @@ def get_dependant( path_param_names = get_path_param_names(path) endpoint_signature = get_typed_signature(call) signature_params = endpoint_signature.parameters + unwrapped = inspect.unwrap(call) + globalns = getattr(unwrapped, "__globals__", {}) for param_name, param in signature_params.items(): is_path_param = param_name in path_param_names param_details = analyze_param( @@ -285,6 +287,7 @@ def get_dependant( annotation=param.annotation, value=param.default, is_path_param=is_path_param, + globalns=globalns, ) if param_details.depends is not None: assert param_details.depends.dependency @@ -367,6 +370,7 @@ def analyze_param( annotation: Any, value: Any, is_path_param: bool, + globalns: Optional[dict[str, Any]] = None, ) -> ParamDetails: field_info = None depends = None @@ -382,6 +386,17 @@ def analyze_param( if get_origin(use_annotation) is Annotated: annotated_args = get_args(annotation) type_annotation = annotated_args[0] + # Resolve ForwardRef if present in the extracted type annotation + if isinstance(type_annotation, ForwardRef) and globalns is not None: + try: + resolved_type = evaluate_forwardref(type_annotation, globalns, globalns) + type_annotation = resolved_type + # Reconstruct Annotated with resolved type for use_annotation + use_annotation = Annotated[resolved_type, *annotated_args[1:]] + except (NameError, AttributeError): + # If resolution fails, keep the ForwardRef as-is + # Pydantic will attempt to resolve it with its own logic + pass fastapi_annotations = [ arg for arg in annotated_args[1:] diff --git a/tests/test_annotated_forwardref.py b/tests/test_annotated_forwardref.py new file mode 100644 index 0000000000..96f4f5283d --- /dev/null +++ b/tests/test_annotated_forwardref.py @@ -0,0 +1,205 @@ +""" +Tests for Annotated types with ForwardRef support. +Tests for issue #13056: Annotated types with ForwardRef cause errors. +""" + +from typing import Annotated, Optional + +import pytest +from fastapi import FastAPI, Query, Header, Path, Body +from fastapi.testclient import TestClient +from pydantic import BaseModel + + +class UserModel(BaseModel): + """User model for testing ForwardRef.""" + + name: str = "test_user" + age: int = 25 + + +class Item(BaseModel): + """Item model for testing ForwardRef.""" + + title: str = "test_item" + description: Optional[str] = None + + +def test_annotated_forwardref_query_param(): + """Test Annotated with ForwardRef in query parameter.""" + app = FastAPI() + + @app.get("/users") + async def get_user(user: Annotated["UserModel", Query()]): + return {"user": user} + + client = TestClient(app) + response = client.get("/users?name=John&age=30") + assert response.status_code == 200 + assert response.json() == {"user": {"name": "John", "age": 30}} + + +def test_annotated_forwardref_query_param_default(): + """Test Annotated with ForwardRef in query parameter with default.""" + app = FastAPI() + + @app.get("/users-default") + async def get_user_default(user: Annotated["UserModel", Query()] = UserModel()): + return {"user": user} + + client = TestClient(app) + response = client.get("/users-default") + assert response.status_code == 200 + assert response.json() == {"user": {"name": "test_user", "age": 25}} + + +def test_annotated_forwardref_optional(): + """Test Annotated with ForwardRef in Optional parameter.""" + app = FastAPI() + + @app.get("/items") + async def get_item(item: Annotated[Optional["Item"], Query()] = None): + return {"item": item} + + client = TestClient(app) + + # Test with no parameter + response = client.get("/items") + assert response.status_code == 200 + assert response.json() == {"item": None} + + +def test_annotated_forwardref_path_param(): + """Test Annotated with ForwardRef in path parameter.""" + app = FastAPI() + + @app.get("/users/{user_id}") + async def get_user_by_id(user_id: Annotated["int", Path()]): + return {"user_id": user_id} + + client = TestClient(app) + response = client.get("/users/123") + assert response.status_code == 200 + assert response.json() == {"user_id": 123} + + +def test_annotated_forwardref_header(): + """Test Annotated with ForwardRef in header parameter.""" + app = FastAPI() + + @app.get("/headers") + async def get_headers(x_token: Annotated["str", Header()]): + return {"x_token": x_token} + + client = TestClient(app) + response = client.get("/headers", headers={"x-token": "test-token"}) + assert response.status_code == 200 + assert response.json() == {"x_token": "test-token"} + + +def test_annotated_forwardref_body(): + """Test Annotated with ForwardRef in request body.""" + app = FastAPI() + + @app.post("/items") + async def create_item(item: Annotated["Item", Body()]): + return {"item": item} + + client = TestClient(app) + response = client.post("/items", json={"title": "Test Item", "description": "A test item"}) + assert response.status_code == 200 + assert response.json() == {"item": {"title": "Test Item", "description": "A test item"}} + + +def test_annotated_forwardref_multiple_annotations(): + """Test Annotated with ForwardRef and multiple metadata items.""" + app = FastAPI() + + @app.get("/multi") + async def get_multi( + param: Annotated["UserModel", "some metadata", Query(), "more metadata"] + ): + return {"param": param} + + client = TestClient(app) + response = client.get("/multi?name=John&age=30") + assert response.status_code == 200 + assert response.json() == {"param": {"name": "John", "age": 30}} + + +def test_annotated_forwardref_openapi_schema(): + """Test that OpenAPI schema is generated correctly with ForwardRef.""" + app = FastAPI() + + @app.get("/users") + async def get_user(user: Annotated["UserModel", Query()]): + return {"user": user} + + client = TestClient(app) + response = client.get("/openapi.json") + assert response.status_code == 200 + openapi_schema = response.json() + + # Check that the endpoint is in the schema + assert "/users" in openapi_schema["paths"] + + # Check that the parameter schema is generated + endpoint_schema = openapi_schema["paths"]["/users"]["get"] + assert "parameters" in endpoint_schema + + # Check that the UserModel schema is in the components + assert "components" in openapi_schema + assert "schemas" in openapi_schema["components"] + + +def test_annotated_forwardref_get_type_hints(): + """Test that get_type_hints works correctly with Annotated ForwardRef.""" + from typing import get_type_hints + + def test_func(param: Annotated["UserModel", Query()]): + pass + + # This should work without errors (it will resolve UserModel since it's defined at module level) + hints = get_type_hints(test_func) + assert "param" in hints + # get_type_hints resolves the ForwardRef to the actual type + assert hints["param"] == UserModel + + +def test_annotated_forwardref_with_dependencies(): + """Test Annotated with ForwardRef in dependency chain.""" + app = FastAPI() + + def get_current_user( + user: Annotated["UserModel", Query()] + ) -> "UserModel": + return user + + @app.get("/protected") + async def get_protected(current_user: Annotated[UserModel, Query()]): + return {"user": current_user} + + client = TestClient(app) + response = client.get("/protected?name=Alice&age=28") + assert response.status_code == 200 + assert response.json() == {"user": {"name": "Alice", "age": 28}} + + +def test_annotated_forwardref_complex_types(): + """Test Annotated with ForwardRef for complex type annotations.""" + app = FastAPI() + + @app.get("/search") + async def search( + items: Annotated[list["Item"], Query()] = None + ): + return {"items": items or []} + + client = TestClient(app) + # This tests that complex types with ForwardRef don't break the system + response = client.get("/search") + assert response.status_code in [200, 422] # May be 422 if the type check is too strict + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])