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 01/10] 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 02/10] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=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 03/10] 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 04/10] 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 05/10] 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 = ( From b61128b071d48427ba3608280e4564b28c1464f6 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Thu, 5 Feb 2026 21:11:36 +0100 Subject: [PATCH 06/10] Update fastapi/dependencies/utils.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- fastapi/dependencies/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 0e3342ffc..e735b17cd 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -196,7 +196,7 @@ def _get_flat_fields_from_params(fields: List[ModelField]) -> List[ModelField]: fields_to_extract = [] for f in fields: - if _is_model_class(first_field.type_): + if _is_model_class(f.type_): fields_to_extract.extend(get_cached_model_fields(f.type_)) else: fields_to_extract.append(f) From 6013e08889f689b373afc7b2b7096c2e32a93591 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Thu, 5 Feb 2026 21:12:18 +0100 Subject: [PATCH 07/10] Update fastapi/dependencies/utils.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- fastapi/dependencies/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index e735b17cd..12d6ae990 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -194,7 +194,7 @@ def _get_flat_fields_from_params(fields: List[ModelField]) -> List[ModelField]: if not fields: return fields - fields_to_extract = [] + fields_to_extract = [] for f in fields: if _is_model_class(f.type_): fields_to_extract.extend(get_cached_model_fields(f.type_)) From e8d30d7b66f633e3cf70e484f5d28045b2a2758b Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Sun, 8 Feb 2026 14:35:05 +0100 Subject: [PATCH 08/10] merge with main --- fastapi/dependencies/utils.py | 73 ++++++++++++++--------------------- 1 file changed, 30 insertions(+), 43 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 80f9c76e9..50c6a5747 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -181,13 +181,13 @@ 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.field_info.annotation, BaseModel - ): - fields_to_extract = get_cached_model_fields(first_field.field_info.annotation) - return fields_to_extract - return fields + fields_to_extract = [] + for f in fields: + if lenient_issubclass(f.field_info.annotation, BaseModel): + fields_to_extract.extend(get_cached_model_fields(f.field_info.annotation)) + else: + fields_to_extract.append(f) + return fields_to_extract def get_flat_params(dependant: Dependant) -> list[ModelField]: @@ -762,32 +762,25 @@ def request_params_to_args( if not fields: return values, errors - first_field = fields[0] - fields_to_extract = fields - single_not_embedded_field = False default_convert_underscores = True - if len(fields) == 1 and lenient_issubclass( - first_field.field_info.annotation, BaseModel - ): - fields_to_extract = get_cached_model_fields(first_field.field_info.annotation) - single_not_embedded_field = True - # If headers are in a Pydantic model, the way to disable convert_underscores - # would be with Header(convert_underscores=False) at the Pydantic model level - default_convert_underscores = getattr( - first_field.field_info, "convert_underscores", True - ) - params_to_process: dict[str, Any] = {} + fields_to_extract = [ + (field, cached_field) + for field in fields + if lenient_issubclass(field.field_info.annotation, BaseModel) + for cached_field in get_cached_model_fields(field.field_info.annotation) + ] + 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 = get_validation_alias(field) @@ -809,27 +802,21 @@ def request_params_to_args( else: params_to_process[key] = received_params.get(key) - 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, get_validation_alias(field)) - v_, errors_ = _validate_value_with_model_field( - field=field, value=value, values=values, loc=loc - ) + in_ = getattr(field.field_info, "in_", params.ParamTypes.query) + + if lenient_issubclass(field.field_info.annotation, BaseModel): + loc: tuple[str, ...] = (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 = (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 846cef1d9dbcfa0d0f5fe94b04f3d99046bc0c16 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 13:50:08 +0000 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/dependencies/utils.py | 4 +++- tests/test_multiple_params_models.py | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 50c6a5747..2eff34a15 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -780,7 +780,9 @@ def request_params_to_args( # 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( - parent_field.field_info, "convert_underscores", default_convert_underscores + parent_field.field_info, + "convert_underscores", + default_convert_underscores, ) if convert_underscores: alias = get_validation_alias(field) diff --git a/tests/test_multiple_params_models.py b/tests/test_multiple_params_models.py index f8d7c8b8d..021a6b944 100644 --- a/tests/test_multiple_params_models.py +++ b/tests/test_multiple_params_models.py @@ -1,10 +1,9 @@ -from typing import Any, Callable +from typing import Annotated, 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) From ed24198186344643285d2b7a38c4bc4c58abdd27 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Sun, 8 Feb 2026 14:58:27 +0100 Subject: [PATCH 10/10] Fix alias in loc --- 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 2eff34a15..4aadabb9d 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -805,16 +805,19 @@ def request_params_to_args( params_to_process[key] = received_params.get(key) for field in fields: - in_ = getattr(field.field_info, "in_", params.ParamTypes.query) + field_info = field.field_info + assert isinstance(field_info, params.Param), ( + "Params must be subclasses of Param" + ) if lenient_issubclass(field.field_info.annotation, BaseModel): - loc: tuple[str, ...] = (in_.value,) + 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 = (in_.value, field.alias) + loc = (field_info.in_.value, get_validation_alias(field)) v_, errors_ = _validate_value_with_model_field( field=field, value=value, values=values, loc=loc )