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"])