From d8d097deaba5c1f36ffacdcade3c0cfe96e72cd7 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Sat, 16 Nov 2024 12:30:31 +0100 Subject: [PATCH 1/5] Allow to have multiple Query parameter models --- fastapi/dependencies/utils.py | 55 ++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index e2866b488..26222f27b 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -212,11 +212,15 @@ def get_flat_dependant( def _get_flat_fields_from_params(fields: List[ModelField]) -> List[ModelField]: if not fields: return fields - first_field = fields[0] - if len(fields) == 1 and lenient_issubclass(first_field.type_, BaseModel): - fields_to_extract = get_cached_model_fields(first_field.type_) - return fields_to_extract - return fields + + fields_to_extract = [] + for f in fields: + if lenient_issubclass(f.type_, BaseModel): + fields_to_extract.extend(get_cached_model_fields(f.type_)) + else: + fields_to_extract.append(f) + return fields_to_extract + def get_flat_params(dependant: Dependant) -> List[ModelField]: @@ -747,15 +751,15 @@ def request_params_to_args( if not fields: return values, errors - first_field = fields[0] fields_to_extract = fields - single_not_embedded_field = False - if len(fields) == 1 and lenient_issubclass(first_field.type_, BaseModel): - fields_to_extract = get_cached_model_fields(first_field.type_) - single_not_embedded_field = True - params_to_process: Dict[str, Any] = {} + model_fields = [field for field in fields if lenient_issubclass(field.type_, BaseModel)] + if model_fields: + fields_to_extract = [ + cached_field for field in fields for cached_field in get_cached_model_fields(field.type_) + ] + processed_keys = set() for field in fields_to_extract: @@ -780,27 +784,24 @@ def request_params_to_args( if key not in processed_keys: params_to_process[key] = value - if single_not_embedded_field: - field_info = first_field.field_info - assert isinstance( - field_info, params.Param - ), "Params must be subclasses of Param" - loc: Tuple[str, ...] = (field_info.in_.value,) - v_, errors_ = _validate_value_with_model_field( - field=first_field, value=params_to_process, values=values, loc=loc - ) - return {first_field.name: v_}, errors_ - for field in fields: - value = _get_multidict_value(field, received_params) field_info = field.field_info assert isinstance( field_info, params.Param ), "Params must be subclasses of Param" - loc = (field_info.in_.value, field.alias) - v_, errors_ = _validate_value_with_model_field( - field=field, value=value, values=values, loc=loc - ) + + if lenient_issubclass(field.type_, BaseModel): + loc: Tuple[str, ...] = (field_info.in_.value,) + v_, errors_ = _validate_value_with_model_field( + field=field, value=params_to_process, values=values, loc=loc + ) + else: + value = _get_multidict_value(field, received_params) + loc = (field_info.in_.value, field.alias) + v_, errors_ = _validate_value_with_model_field( + field=field, value=value, values=values, loc=loc + ) + if errors_: errors.extend(errors_) else: From 64f3cea0694e89f3c0d0b618f6aed66aa028cd04 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 16 Nov 2024 11:41:42 +0000 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20for?= =?UTF-8?q?mat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/dependencies/utils.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 26222f27b..d2cb48ad4 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -222,7 +222,6 @@ def _get_flat_fields_from_params(fields: List[ModelField]) -> List[ModelField]: return fields_to_extract - def get_flat_params(dependant: Dependant) -> List[ModelField]: flat_dependant = get_flat_dependant(dependant, skip_repeats=True) path_params = _get_flat_fields_from_params(flat_dependant.path_params) @@ -754,10 +753,14 @@ def request_params_to_args( fields_to_extract = fields params_to_process: Dict[str, Any] = {} - model_fields = [field for field in fields if lenient_issubclass(field.type_, BaseModel)] + model_fields = [ + field for field in fields if lenient_issubclass(field.type_, BaseModel) + ] if model_fields: fields_to_extract = [ - cached_field for field in fields for cached_field in get_cached_model_fields(field.type_) + cached_field + for field in fields + for cached_field in get_cached_model_fields(field.type_) ] processed_keys = set() From 02a39f67b2d06a3a94b63fa1fa1268cf25eb2da7 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Sun, 17 Nov 2024 11:28:07 +0100 Subject: [PATCH 3/5] Add tests --- fastapi/dependencies/utils.py | 2 +- tests/test_multiple_params_models.py | 137 +++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 tests/test_multiple_params_models.py diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index d2cb48ad4..38bc3eb4e 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -759,7 +759,7 @@ def request_params_to_args( if model_fields: fields_to_extract = [ cached_field - for field in fields + for field in model_fields for cached_field in get_cached_model_fields(field.type_) ] diff --git a/tests/test_multiple_params_models.py b/tests/test_multiple_params_models.py new file mode 100644 index 000000000..f8d7c8b8d --- /dev/null +++ b/tests/test_multiple_params_models.py @@ -0,0 +1,137 @@ +from typing import Any, Callable + +import pytest +from fastapi import APIRouter, Cookie, FastAPI, Header, Query, status +from fastapi.testclient import TestClient +from pydantic import BaseModel +from typing_extensions import Annotated + +app = FastAPI() +client = TestClient(app) + + +class NameModel(BaseModel): + name: str + + +class AgeModel(BaseModel): + age: int + + +def add_routes( + in_: Callable[..., Any], + prefix: str, +) -> None: + router = APIRouter(prefix=prefix) + + @router.get("/models") + async def route_models( + name_model: Annotated[NameModel, in_()], + age_model: Annotated[AgeModel, in_()], + ): + return { + "name": name_model.name, + "age": age_model.age, + } + + @router.get("/mixed") + async def route_mixed( + name_model: Annotated[NameModel, in_()], + age: Annotated[int, in_()], + ): + return { + "name": name_model.name, + "age": age, + } + + app.include_router(router) + + +add_routes(Query, "/query") +add_routes(Header, "/header") +add_routes(Cookie, "/cookie") + + +@pytest.mark.parametrize( + ("in_", "prefix", "call_arg"), + [ + (Query, "/query", "params"), + (Header, "/header", "headers"), + (Cookie, "/cookie", "cookies"), + ], + ids=[ + "query", + "header", + "cookie", + ], +) +@pytest.mark.parametrize( + "type_", + [ + "models", + "mixed", + ], + ids=[ + "models", + "mixed", + ], +) +def test_multiple_params(in_, prefix, call_arg, type_): + params = {"name": "John", "age": "42"} + kwargs = {} + + if call_arg == "cookies": + client.cookies = params + else: + kwargs[call_arg] = params + + response = client.get(f"{prefix}/{type_}", **kwargs) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"name": "John", "age": 42} + + +@pytest.mark.parametrize( + ("prefix", "in_"), + [ + ("/query", "query"), + ("/header", "header"), + ("/cookie", "cookie"), + ], + ids=[ + "query", + "header", + "cookie", + ], +) +@pytest.mark.parametrize( + "type_", + [ + "models", + "mixed", + ], + ids=[ + "models", + "mixed", + ], +) +def test_openapi_schema(prefix, in_, type_): + response = client.get("/openapi.json") + + assert response.status_code == status.HTTP_200_OK + + schema = response.json() + assert schema["paths"][f"{prefix}/{type_}"]["get"]["parameters"] == [ + { + "required": True, + "in": in_, + "name": "name", + "schema": {"title": "Name", "type": "string"}, + }, + { + "required": True, + "in": in_, + "name": "age", + "schema": {"title": "Age", "type": "integer"}, + }, + ] From f6473177e5f64cf442f6fa411027ad0b20382139 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Tue, 5 Aug 2025 11:12:45 +0200 Subject: [PATCH 4/5] Simplify fields to extract calculation --- fastapi/dependencies/utils.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index baf226b7a..58cb9f36c 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -750,20 +750,16 @@ def request_params_to_args( if not fields: return values, errors - fields_to_extract = fields default_convert_underscores = True params_to_process: Dict[str, Any] = {} - model_fields = [ - field for field in fields if lenient_issubclass(field.type_, BaseModel) + fields_to_extract = [ + cached_field + for field in fields + if lenient_issubclass(field.type_, BaseModel) + for cached_field in get_cached_model_fields(field.type_) ] - if model_fields: - fields_to_extract = [ - cached_field - for field in model_fields - for cached_field in get_cached_model_fields(field.type_) - ] processed_keys = set() From 66b486bf341b6f8a8ff7127a1b81b42187988abb Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Tue, 5 Aug 2025 11:30:24 +0200 Subject: [PATCH 5/5] Fix header tests --- fastapi/dependencies/utils.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 58cb9f36c..1f2b7ee30 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -755,7 +755,7 @@ def request_params_to_args( params_to_process: Dict[str, Any] = {} fields_to_extract = [ - cached_field + (field, cached_field) for field in fields if lenient_issubclass(field.type_, BaseModel) for cached_field in get_cached_model_fields(field.type_) @@ -763,13 +763,15 @@ def request_params_to_args( processed_keys = set() - for field in fields_to_extract: + for parent_field, field in fields_to_extract: alias = None if isinstance(received_params, Headers): # Handle fields extracted from a Pydantic Model for a header, each field # doesn't have a FieldInfo of type Header with the default convert_underscores=True convert_underscores = getattr( - field.field_info, "convert_underscores", default_convert_underscores + parent_field.field_info, + "convert_underscores", + default_convert_underscores, ) if convert_underscores: alias = (