From: Sebastián Ramírez Date: Mon, 27 Jul 2026 17:16:40 +0000 (+0200) Subject: ⚡️ Avoid flattening dependencies for OpenAPI (#16076) X-Git-Tag: 0.140.7~4 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=7bcb78d10d818b05791a34b41d6e84f0e8871013;p=thirdparty%2Ffastapi%2Ffastapi.git ⚡️ Avoid flattening dependencies for OpenAPI (#16076) --- diff --git a/fastapi/dependencies/models.py b/fastapi/dependencies/models.py index 58eae0eaf..5c7cbc82b 100644 --- a/fastapi/dependencies/models.py +++ b/fastapi/dependencies/models.py @@ -134,10 +134,6 @@ def _get_security_scheme(*, dependant: Dependant) -> SecurityBase: return unwrapped -def _get_security_dependencies(*, dependant: Dependant) -> list[Dependant]: - return [dep for dep in dependant.dependencies if _is_security_scheme(dependant=dep)] - - @lru_cache(maxsize=_CALLABLE_CLASSIFICATION_CACHE_SIZE) def _is_gen_callable_cached(call_identity: _CallIdentity) -> bool: call = call_identity.call diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index dedb39432..01820e6a4 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -144,81 +144,6 @@ def get_parameterless_sub_dependant(*, depends: params.Depends, path: str) -> De ) -def get_flat_dependant( - dependant: Dependant, - *, - skip_repeats: bool = False, - visited: list[DependencyCacheKey] | None = None, - parent_oauth_scopes: list[str] | None = None, - _uses_scopes_cache: _UsesScopesCache | None = None, -) -> Dependant: - track_visited = ( - skip_repeats or visited is not None or _uses_scopes_cache is not None - ) - if track_visited: - if visited is None: - visited = [] - if _uses_scopes_cache is None: - _uses_scopes_cache = {} - visited.append( - _get_cache_key( - dependant=dependant, - uses_scopes_cache=_uses_scopes_cache, - ) - ) - use_parent_oauth_scopes = (parent_oauth_scopes or []) + ( - _get_oauth_scopes(dependant=dependant) - ) - - flat_dependant = Dependant( - path_params=dependant.path_params.copy(), - query_params=dependant.query_params.copy(), - header_params=dependant.header_params.copy(), - cookie_params=dependant.cookie_params.copy(), - body_params=dependant.body_params.copy(), - name=dependant.name, - call=dependant.call, - request_param_name=dependant.request_param_name, - websocket_param_name=dependant.websocket_param_name, - http_connection_param_name=dependant.http_connection_param_name, - response_param_name=dependant.response_param_name, - background_tasks_param_name=dependant.background_tasks_param_name, - security_scopes_param_name=dependant.security_scopes_param_name, - own_oauth_scopes=dependant.own_oauth_scopes, - parent_oauth_scopes=use_parent_oauth_scopes, - use_cache=dependant.use_cache, - path=dependant.path, - scope=dependant.scope, - ) - for sub_dependant in dependant.dependencies: - if skip_repeats: - assert visited is not None - if ( - _get_cache_key( - dependant=sub_dependant, - uses_scopes_cache=_uses_scopes_cache, - ) - in visited - ): - continue - flat_sub = get_flat_dependant( - sub_dependant, - skip_repeats=skip_repeats, - visited=visited, - parent_oauth_scopes=_get_oauth_scopes(dependant=flat_dependant), - _uses_scopes_cache=_uses_scopes_cache, - ) - flat_dependant.dependencies.append(flat_sub) - flat_dependant.path_params.extend(flat_sub.path_params) - flat_dependant.query_params.extend(flat_sub.query_params) - flat_dependant.header_params.extend(flat_sub.header_params) - flat_dependant.cookie_params.extend(flat_sub.cookie_params) - flat_dependant.body_params.extend(flat_sub.body_params) - flat_dependant.dependencies.extend(flat_sub.dependencies) - - return flat_dependant - - def _get_flat_body_params(dependant: Dependant) -> list[ModelField]: body_params: list[ModelField] = [] dependants = [dependant] diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index 392b2ed35..74caac44a 100644 --- a/fastapi/openapi/utils.py +++ b/fastapi/openapi/utils.py @@ -3,6 +3,7 @@ import http.client import inspect import warnings from collections.abc import Sequence +from dataclasses import dataclass, field from typing import Any, Literal, cast from fastapi import routing @@ -17,13 +18,14 @@ from fastapi._compat import ( from fastapi.datastructures import DefaultPlaceholder, _Unset from fastapi.dependencies.models import ( Dependant, + _get_cache_key, _get_oauth_scopes, - _get_security_dependencies, _get_security_scheme, + _is_security_scheme, + _UsesScopesCache, ) from fastapi.dependencies.utils import ( _get_flat_fields_from_params, - get_flat_dependant, get_flat_params, get_validation_alias, ) @@ -34,7 +36,7 @@ from fastapi.openapi.models import OpenAPI from fastapi.params import Body, ParamTypes from fastapi.responses import Response from fastapi.sse import _SSE_EVENT_SCHEMA -from fastapi.types import ModelNameMap +from fastapi.types import DependencyCacheKey, ModelNameMap from fastapi.utils import ( deep_dict_update, generate_operation_id_for_path, @@ -83,13 +85,57 @@ status_code_ranges: dict[str, str] = { } -def get_openapi_security_definitions( - flat_dependant: Dependant, +@dataclass +class _OpenAPIDependencyData: + path_params: list[ModelField] = field(default_factory=list) + query_params: list[ModelField] = field(default_factory=list) + header_params: list[ModelField] = field(default_factory=list) + cookie_params: list[ModelField] = field(default_factory=list) + security_dependencies: list[tuple[Dependant, list[str]]] = field( + default_factory=list + ) + + +def _get_openapi_dependency_data(dependant: Dependant) -> _OpenAPIDependencyData: + dependency_data = _OpenAPIDependencyData() + visited: list[DependencyCacheKey] = [] + uses_scopes_cache: _UsesScopesCache = {} + dependants: list[tuple[Dependant, list[str], bool]] = [(dependant, [], True)] + while dependants: + current_dependant, parent_oauth_scopes, is_root = dependants.pop() + cache_key = _get_cache_key( + dependant=current_dependant, + uses_scopes_cache=uses_scopes_cache, + ) + if cache_key in visited: + continue + visited.append(cache_key) + dependency_data.path_params.extend(current_dependant.path_params) + dependency_data.query_params.extend(current_dependant.query_params) + dependency_data.header_params.extend(current_dependant.header_params) + dependency_data.cookie_params.extend(current_dependant.cookie_params) + oauth_scopes = parent_oauth_scopes.copy() + for scope in _get_oauth_scopes(dependant=current_dependant): + if scope not in oauth_scopes: + oauth_scopes.append(scope) + if not is_root and _is_security_scheme(dependant=current_dependant): + dependency_data.security_dependencies.append( + (current_dependant, oauth_scopes) + ) + dependants.extend( + (sub_dependant, oauth_scopes, False) + for sub_dependant in reversed(current_dependant.dependencies) + ) + return dependency_data + + +def _get_openapi_security_definitions( + security_dependencies: list[tuple[Dependant, list[str]]], ) -> tuple[dict[str, Any], list[dict[str, Any]]]: security_definitions = {} # Use a dict to merge scopes for same security scheme operation_security_dict: dict[str, list[str]] = {} - for security_dependency in _get_security_dependencies(dependant=flat_dependant): + for security_dependency, oauth_scopes in security_dependencies: security_scheme = _get_security_scheme(dependant=security_dependency) security_definition = jsonable_encoder( security_scheme.model, @@ -101,7 +147,7 @@ def get_openapi_security_definitions( # Merge scopes for the same security scheme if security_name not in operation_security_dict: operation_security_dict[security_name] = [] - for scope in _get_oauth_scopes(dependant=security_dependency): + for scope in oauth_scopes: if scope not in operation_security_dict[security_name]: operation_security_dict[security_name].append(scope) operation_security = [ @@ -112,7 +158,7 @@ def get_openapi_security_definitions( def _get_openapi_operation_parameters( *, - flat_dependant: Dependant, + dependency_data: _OpenAPIDependencyData, model_name_map: ModelNameMap, field_mapping: dict[ tuple[ModelField, Literal["validation", "serialization"]], dict[str, Any] @@ -120,10 +166,10 @@ def _get_openapi_operation_parameters( separate_input_output_schemas: bool = True, ) -> list[dict[str, Any]]: parameters = [] - 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) - cookie_params = _get_flat_fields_from_params(flat_dependant.cookie_params) + path_params = _get_flat_fields_from_params(dependency_data.path_params) + query_params = _get_flat_fields_from_params(dependency_data.query_params) + header_params = _get_flat_fields_from_params(dependency_data.header_params) + cookie_params = _get_flat_fields_from_params(dependency_data.cookie_params) parameter_groups = [ (ParamTypes.path, path_params), (ParamTypes.query, query_params), @@ -131,8 +177,8 @@ def _get_openapi_operation_parameters( (ParamTypes.cookie, cookie_params), ] default_convert_underscores = True - if len(flat_dependant.header_params) == 1: - first_field = flat_dependant.header_params[0] + if len(dependency_data.header_params) == 1: + first_field = dependency_data.header_params[0] if lenient_issubclass(first_field.field_info.annotation, BaseModel): default_convert_underscores = getattr( first_field.field_info, "convert_underscores", True @@ -283,14 +329,14 @@ def get_openapi_path( assert current_response_class, "A response class is needed to generate OpenAPI" route_response_media_type: str | None = current_response_class.media_type if route.include_in_schema: - flat_dependant = get_flat_dependant(route.dependant, skip_repeats=True) + dependency_data = _get_openapi_dependency_data(route.dependant) all_route_params = [ field for fields in ( - flat_dependant.path_params, - flat_dependant.query_params, - flat_dependant.header_params, - flat_dependant.cookie_params, + dependency_data.path_params, + dependency_data.query_params, + dependency_data.header_params, + dependency_data.cookie_params, ) for field in _get_flat_fields_from_params(fields) ] @@ -299,15 +345,17 @@ def get_openapi_path( route=route, method=method, operation_ids=operation_ids ) parameters: list[dict[str, Any]] = [] - security_definitions, operation_security = get_openapi_security_definitions( - flat_dependant=flat_dependant + security_definitions, operation_security = ( + _get_openapi_security_definitions( + security_dependencies=dependency_data.security_dependencies + ) ) if operation_security: operation.setdefault("security", []).extend(operation_security) if security_definitions: security_schemes.update(security_definitions) operation_parameters = _get_openapi_operation_parameters( - flat_dependant=flat_dependant, + dependency_data=dependency_data, model_name_map=model_name_map, field_mapping=field_mapping, separate_input_output_schemas=separate_input_output_schemas, diff --git a/tests/test_dependency_models.py b/tests/test_dependency_models.py index 9ba600944..2f857b3ec 100644 --- a/tests/test_dependency_models.py +++ b/tests/test_dependency_models.py @@ -6,7 +6,6 @@ from fastapi.dependencies.models import ( _get_cache_key, _get_computed_scope, _get_oauth_scopes, - _get_security_dependencies, _get_security_scheme, _is_async_gen_callable, _is_async_gen_callable_cached, @@ -146,7 +145,6 @@ def test_derived_values_are_not_stored_on_dependant() -> None: assert _get_oauth_scopes(dependant=dependant) == [] assert not _uses_scopes(dependant=dependant, cache=uses_scopes_cache) assert not _uses_scopes(dependant=dependant, cache=uses_scopes_cache) - assert _get_security_dependencies(dependant=dependant) == [] assert _get_computed_scope(dependant=dependant) is None assert _get_cache_key(dependant=dependant) == (async_dependency, (), "") @@ -160,7 +158,6 @@ def test_security_scheme_helpers() -> None: assert _is_security_scheme(dependant=security_dependant) assert _get_security_scheme(dependant=security_dependant) is security_scheme - assert _get_security_dependencies(dependant=dependant) == [security_dependant] assert _uses_scopes(dependant=dependant)