From 7c853ca87ad9613fb33b306631fbe090e71e1604 Mon Sep 17 00:00:00 2001 From: Maksim Zayats Date: Tue, 17 Mar 2026 00:53:42 +0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20Fix=20parameter=20handling=20to?= =?UTF-8?q?=20support=20flat=20fields=20extraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/dependencies/utils.py | 95 +++++++++++++++++++---------------- fastapi/openapi/utils.py | 64 +++++++++++++++-------- 2 files changed, 94 insertions(+), 65 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 6b14dac8dc..0c0a4ddc75 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -189,16 +189,32 @@ def get_flat_dependant( return flat_dependant +@dataclass +class _FlatParamField: + field: ModelField + original_field: ModelField + + +def _is_model_param(field: ModelField) -> bool: + return lenient_issubclass(field.field_info.annotation, BaseModel) + + +def _get_flat_param_fields(fields: Sequence[ModelField]) -> list[_FlatParamField]: + flat_fields: list[_FlatParamField] = [] + for field in fields: + if _is_model_param(field): + annotation = cast(type[BaseModel], field.field_info.annotation) + flat_fields.extend( + _FlatParamField(field=sub_field, original_field=field) + for sub_field in get_cached_model_fields(annotation) + ) + else: + flat_fields.append(_FlatParamField(field=field, original_field=field)) + return flat_fields + + 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 + return [flat_field.field for flat_field in _get_flat_param_fields(fields)] def get_flat_params(dependant: Dependant) -> list[ModelField]: @@ -791,32 +807,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] = {} - + flat_fields = _get_flat_param_fields(fields) + model_fields = [field for field in fields if _is_model_param(field)] + processed_extras: dict[str, Any] = {} + params_by_field: dict[int, dict[str, Any]] = { + id(field): {} for field in model_fields + } processed_keys = set() - for field in fields_to_extract: + for flat_field in flat_fields: + field = flat_field.field + original_field = flat_field.original_field 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 + field.field_info, + "convert_underscores", + getattr(original_field.field_info, "convert_underscores", True), ) if convert_underscores: alias = get_validation_alias(field) @@ -824,7 +833,8 @@ def request_params_to_args( alias = alias.replace("_", "-") value = _get_multidict_value(field, received_params, alias=alias) if value is not None: - params_to_process[get_validation_alias(field)] = value + if _is_model_param(original_field): + params_by_field[id(original_field)][get_validation_alias(field)] = value processed_keys.add(alias or get_validation_alias(field)) for key in received_params.keys(): @@ -832,30 +842,27 @@ def request_params_to_args( if isinstance(received_params, (ImmutableMultiDict, Headers)): value = received_params.getlist(key) if isinstance(value, list) and (len(value) == 1): - params_to_process[key] = value[0] + processed_extras[key] = value[0] else: - params_to_process[key] = value + processed_extras[key] = value else: - params_to_process[key] = received_params.get(key) + processed_extras[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_ + if len(model_fields) == 1: + params_by_field[id(model_fields[0])].update(processed_extras) 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)) + loc: tuple[str, ...] + if _is_model_param(field): + loc = (field_info.in_.value,) + value = params_by_field[id(field)] + else: + loc = (field_info.in_.value, get_validation_alias(field)) + value = _get_multidict_value(field, received_params) v_, errors_ = _validate_value_with_model_field( field=field, value=value, values=values, loc=loc ) diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index 8f1852b0cc..e372a5b438 100644 --- a/fastapi/openapi/utils.py +++ b/fastapi/openapi/utils.py @@ -17,7 +17,9 @@ from fastapi._compat import ( from fastapi.datastructures import DefaultPlaceholder, _Unset from fastapi.dependencies.models import Dependant from fastapi.dependencies.utils import ( + _FlatParamField, _get_flat_fields_from_params, + _get_flat_param_fields, get_flat_dependant, get_flat_params, get_validation_alias, @@ -35,7 +37,6 @@ from fastapi.utils import ( generate_operation_id_for_path, is_body_allowed_for_status_code, ) -from pydantic import BaseModel from starlette.responses import JSONResponse from starlette.routing import BaseRoute @@ -117,21 +118,13 @@ def _get_openapi_operation_parameters( flat_dependant = get_flat_dependant(dependant, skip_repeats=True) path_params = _get_flat_fields_from_params(flat_dependant.path_params) query_params = _get_flat_fields_from_params(flat_dependant.query_params) - header_params = _get_flat_fields_from_params(flat_dependant.header_params) + header_params = _get_flat_param_fields(flat_dependant.header_params) cookie_params = _get_flat_fields_from_params(flat_dependant.cookie_params) parameter_groups = [ (ParamTypes.path, path_params), (ParamTypes.query, query_params), - (ParamTypes.header, header_params), (ParamTypes.cookie, cookie_params), ] - default_convert_underscores = True - if len(flat_dependant.header_params) == 1: - first_field = flat_dependant.header_params[0] - if lenient_issubclass(first_field.field_info.annotation, BaseModel): - default_convert_underscores = getattr( - first_field.field_info, "convert_underscores", True - ) for param_type, param_group in parameter_groups: for param in param_group: field_info = param.field_info @@ -145,17 +138,6 @@ def _get_openapi_operation_parameters( separate_input_output_schemas=separate_input_output_schemas, ) name = get_validation_alias(param) - convert_underscores = getattr( - param.field_info, - "convert_underscores", - default_convert_underscores, - ) - if ( - param_type == ParamTypes.header - and name == param.name - and convert_underscores - ): - name = param.name.replace("_", "-") parameter = { "name": name, @@ -174,6 +156,46 @@ def _get_openapi_operation_parameters( if getattr(field_info, "deprecated", None): parameter["deprecated"] = True parameters.append(parameter) + for header_param in header_params: + assert isinstance(header_param, _FlatParamField) + field_info = header_param.field.field_info + # field_info = cast(Param, field_info) + if not getattr(field_info, "include_in_schema", True): + continue + param_schema = get_schema_from_model_field( + field=header_param.field, + model_name_map=model_name_map, + field_mapping=field_mapping, + separate_input_output_schemas=separate_input_output_schemas, + ) + name = get_validation_alias(header_param.field) + convert_underscores = getattr( + header_param.field.field_info, + "convert_underscores", + getattr( + header_param.original_field.field_info, "convert_underscores", True + ), + ) + if name == header_param.field.name and convert_underscores: + name = header_param.field.name.replace("_", "-") + + parameter = { + "name": name, + "in": ParamTypes.header.value, + "required": header_param.field.field_info.is_required(), + "schema": param_schema, + } + if field_info.description: + parameter["description"] = field_info.description + openapi_examples = getattr(field_info, "openapi_examples", None) + example = getattr(field_info, "example", None) + if openapi_examples: + parameter["examples"] = jsonable_encoder(openapi_examples) + elif example is not _Unset: + parameter["example"] = jsonable_encoder(example) + if getattr(field_info, "deprecated", None): + parameter["deprecated"] = True + parameters.append(parameter) return parameters