From 1d89e75bc9f8e9a8e3bbb0d341db3a016e1201fd Mon Sep 17 00:00:00 2001 From: Alexander Egorov Date: Tue, 27 Oct 2026 16:55:22 +0100 Subject: [PATCH] feat(openapi)!: fixed route parameter inspectors, @type_inspector, no initialization The parameters of a route come from its path, query and PagePosition arguments, mirroring the arguments winter.web maps from the request, so the global list of route parameter inspectors and its registration are gone. Type inspectors and @global_exception stay: they are metadata about types. register_type_inspector becomes the decorator @type_inspector, and register_global_exception is gone. winter.openapi.initialization is gone: its marks of Winter's own exceptions as global no longer affected the check of @raises, which looks at the application's definitions only, and Page already had its inspector. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++ docs/architecture.md | 26 +++++------- .../test_route_parameter_inspector.py | 13 ------ winter/openapi/__init__.py | 10 +---- winter/openapi/annotations/__init__.py | 1 - .../openapi/annotations/global_exception.py | 5 +-- winter/openapi/generator.py | 18 ++++++--- winter/openapi/initialization.py | 34 ---------------- winter/openapi/inspection/__init__.py | 2 +- winter/openapi/inspection/inspection.py | 18 +++++---- winter/openapi/inspectors/__init__.py | 3 -- winter/openapi/inspectors/page_inspector.py | 4 +- .../inspectors/route_parameters_inspector.py | 18 --------- .../inspectors/standard_types_inspectors.py | 40 +++++++++---------- 14 files changed, 63 insertions(+), 135 deletions(-) delete mode 100644 tests/openapi/inspectors/test_route_parameter_inspector.py delete mode 100644 winter/openapi/initialization.py diff --git a/README.md b/README.md index 8b78c1e6..10fa732e 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,12 @@ urlpatterns = winter.django.create_urls(app) `winter.openapi.serve_spec(app, spec, url='openapi.json')` and `winter.openapi.serve_swagger_ui(app, spec_url='openapi.json', url='docs/')` add the routes that serve them; the served spec and Swagger UI work under whatever prefix the application is included at. +- OpenAPI: `@winter.openapi.type_inspector(Answer)` replaces `register_type_inspector` and is a decorator only; it + also describes the subclasses of the type. `register_global_exception` is gone: `@winter.openapi.global_exception` + remains. The parameters of a route come from its path, query and `PagePosition` arguments only: + `register_route_parameters_inspector`, `get_route_parameters_inspectors` and `winter.openapi.initialization` are + gone, and the inspectors and `inspect_page` are no longer exported. A parameter that no argument stands for, such + as a header read by a request-scoped provider, is added to the spec that `generate_openapi` returns. ## Upgrading to Winter 33 diff --git a/docs/architecture.md b/docs/architecture.md index c4a08615..61b82a41 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -68,12 +68,12 @@ at least two of them. subpackage can later ship as its own distribution from its own repository without any import path changing. 2. **Framework behaviour is never process-global.** Every behavioural registry (exception - handlers, interceptors, throttling configuration, OpenAPI inspectors) - belongs to the application object an adapter builds from, such as `winter.web.WebApplication`, - and the values that its methods receive come from its injector, so two applications can live - in one process. - Declarative metadata about types (component annotations, JSON encoders for a type) may be - global, like the classes it describes. + handlers, interceptors, throttling configuration) belongs to the application object an adapter + builds from, such as `winter.web.WebApplication`, and the values that its methods receive come + from its injector, so two applications can live in one process. What belongs to an adapter, such + as the response types of its framework, the adapter passes to `winter.web.handle_request` itself. + Declarative metadata about types (component annotations, JSON encoders and OpenAPI type + inspectors for a type) may be global, like the classes it describes. 3. **Glue between models, or between adapters, is application code.** Mapping a domain `NotFoundException` to HTTP 404, or mounting the Swagger UI route, is done by the application that composes the pieces, not by the framework. @@ -82,17 +82,9 @@ at least two of them. ## Current deviations -- **Framework behaviour is still process-global.** A `winter.web.WebApplication` holds the code of - a web application, its injector and its interceptors. The adapters derive its exception handlers - from its definitions, handle each of its requests in a child of its injector and throttle in the - storage that its injector binds; what belongs to an adapter, such as the response types of its - framework, it passes to `handle_request` itself. The OpenAPI inspectors and global exceptions of - `winter.openapi` are module singletons, and `winter.sqlalchemy`, `winter.ddd` and - `winter.messaging` take the injector from the service locator `winter.application.get_injector`. -- **`winter.openapi` is initialised once per process.** `winter.openapi.initialization.initialize` - fills those module singletons with the standard inspectors and global exceptions on first use. In - the target, `generate_openapi` is given them, starting with the standard entries, and `initialize` - disappears. +- **`winter.sqlalchemy`, `winter.ddd` and `winter.messaging` take the injector from a service + locator,** `winter.application.get_injector`, instead of being given the injector of an + application. - **`winter.wsgi`, `winter.asgi` and `winter.rabbitmq` do not exist yet.** HTTP is served through `winter.django` only, and `winter.messaging` has no transport adapter. - **All dependencies are mandatory.** Django, SQLAlchemy, Redis and pydantic are installed with diff --git a/tests/openapi/inspectors/test_route_parameter_inspector.py b/tests/openapi/inspectors/test_route_parameter_inspector.py deleted file mode 100644 index 81f1de15..00000000 --- a/tests/openapi/inspectors/test_route_parameter_inspector.py +++ /dev/null @@ -1,13 +0,0 @@ -import logging - -from winter.openapi import PathParametersInspector -from winter.openapi import register_route_parameters_inspector -from winter.openapi.initialization import initialize - - -def test_add_same_inspector_write_warning_log(caplog): - initialize() - - with caplog.at_level(logging.WARNING): - register_route_parameters_inspector(PathParametersInspector()) - assert 'PathParametersInspector already registered' in caplog.text diff --git a/winter/openapi/__init__.py b/winter/openapi/__init__.py index 5b63b4c9..eac25e1f 100644 --- a/winter/openapi/__init__.py +++ b/winter/openapi/__init__.py @@ -1,15 +1,7 @@ from .annotations import global_exception -from .annotations import register_global_exception from .generator import generate_openapi from .inspection.inspection import inspect_type -from .inspection.inspection import register_type_inspector -from .inspectors import PagePositionArgumentsInspector -from .inspectors import PathParametersInspector -from .inspectors import QueryParametersInspector -from .inspectors import RouteParametersInspector -from .inspectors import get_route_parameters_inspectors -from .inspectors import inspect_page -from .inspectors import register_route_parameters_inspector +from .inspection.inspection import type_inspector from .serving import serve_spec from .serving import serve_swagger_ui from .swagger_ui import get_swagger_ui_html diff --git a/winter/openapi/annotations/__init__.py b/winter/openapi/annotations/__init__.py index 2c8c3d3c..7c88fc0f 100644 --- a/winter/openapi/annotations/__init__.py +++ b/winter/openapi/annotations/__init__.py @@ -1,3 +1,2 @@ from .global_exception import GlobalExceptionAnnotation from .global_exception import global_exception -from .global_exception import register_global_exception diff --git a/winter/openapi/annotations/global_exception.py b/winter/openapi/annotations/global_exception.py index 63e07bc2..2ecb1498 100644 --- a/winter/openapi/annotations/global_exception.py +++ b/winter/openapi/annotations/global_exception.py @@ -11,10 +11,7 @@ class GlobalExceptionAnnotation: def global_exception(exception_class: Type[Exception]) -> Type[Exception]: - return register_global_exception(exception_class) - - -def register_global_exception(exception_class: Type[Exception]) -> Type[Exception]: + """The exception may be raised by any API method, so the check of @raises does not require it to be declared.""" assert issubclass(exception_class, Exception), f'Class "{exception_class}" must be a subclass of Exception' annotation = GlobalExceptionAnnotation(exception_cls=exception_class) annotation_decorator = annotate(annotation, unique=True) diff --git a/winter/openapi/generator.py b/winter/openapi/generator.py index 52f1093f..5160bf0a 100644 --- a/winter/openapi/generator.py +++ b/winter/openapi/generator.py @@ -37,6 +37,7 @@ from winter.web.exception_handlers import find_exception_handlers from winter.web.exceptions import ExceptionHandlers from winter.web.exceptions.raises import get_raises +from winter.web.pagination.page_position_argument_resolver import PagePositionArgumentResolver from winter.web.request_body_annotation import RequestBodyAnnotation from winter.web.routing import Route from winter.web.routing import RouteAnnotation @@ -45,8 +46,9 @@ from .inspection import DataTypes from .inspection import TypeInfo from .inspection.inspection import InspectorNotFound -from .initialization import initialize -from .inspectors import get_route_parameters_inspectors +from .inspectors import PagePositionArgumentsInspector +from .inspectors import PathParametersInspector +from .inspectors import QueryParametersInspector from .validators import validate_missing_raises_annotations @@ -152,7 +154,6 @@ def generate_openapi( allow_missing_raises_annotation: bool = False, ) -> Dict[str, Any]: """OpenAPI specification of the routes of an application, as a JSON-compatible dict.""" - initialize() if not allow_missing_raises_annotation: validate_missing_raises_annotations(app) return generate_openapi_for_routes( @@ -178,7 +179,6 @@ def generate_openapi_for_routes( add_url_segment_as_tag: bool = True, ) -> Dict[str, Any]: """The responses of the raised exceptions come from the exception handlers, the built-in ones by default.""" - initialize() if exception_handlers is None: exception_handlers = built_in_exception_handlers() routes = list(routes) @@ -329,9 +329,17 @@ def get_url_path_tag(url_path: str, path_prefix: str) -> Optional[str]: return url_path_tag +# The parameters of a route mirror the arguments that winter.web maps from the request +_route_parameters_inspectors = ( + PathParametersInspector(), + QueryParametersInspector(), + PagePositionArgumentsInspector(PagePositionArgumentResolver()), +) + + def get_route_parameters(route: Route, schema_registry: SchemaRegistry) -> List[Parameter]: parameters = [] - for inspector in get_route_parameters_inspectors(): + for inspector in _route_parameters_inspectors: parameters += inspector.inspect_parameters(route, schema_registry) return parameters diff --git a/winter/openapi/initialization.py b/winter/openapi/initialization.py deleted file mode 100644 index 0557ecb5..00000000 --- a/winter/openapi/initialization.py +++ /dev/null @@ -1,34 +0,0 @@ -from winter.pagination import Page -from winter.web.exceptions import RequestDataDecodeException -from winter.web.exceptions import ThrottleException -from winter.web.exceptions import UnsupportedMediaTypeException -from winter.web.pagination.page_position_argument_resolver import PagePositionArgumentResolver -from winter.web.pagination.limits import MaximumLimitValueExceeded -from .annotations import register_global_exception -from .inspection.inspection import register_type_inspector -from .inspectors import PagePositionArgumentsInspector -from .inspectors import PathParametersInspector -from .inspectors import QueryParametersInspector -from .inspectors import inspect_page -from .inspectors import register_route_parameters_inspector - -_initialized = False - - -def initialize(): - """ - Registers the standard inspectors and global exceptions once per process, before the first spec is generated. - Process-global until these registries belong to an application: docs/architecture.md, Current deviations. - """ - global _initialized - if _initialized: - return - register_global_exception(MaximumLimitValueExceeded) - register_global_exception(ThrottleException) - register_global_exception(RequestDataDecodeException) - register_global_exception(UnsupportedMediaTypeException) - register_type_inspector(Page, func=inspect_page) - register_route_parameters_inspector(PathParametersInspector()) - register_route_parameters_inspector(QueryParametersInspector()) - register_route_parameters_inspector(PagePositionArgumentsInspector(PagePositionArgumentResolver())) - _initialized = True diff --git a/winter/openapi/inspection/__init__.py b/winter/openapi/inspection/__init__.py index f7e28838..1e1831ec 100644 --- a/winter/openapi/inspection/__init__.py +++ b/winter/openapi/inspection/__init__.py @@ -1,5 +1,5 @@ from .data_formats import DataFormat from .data_types import DataTypes from .inspection import inspect_type -from .inspection import register_type_inspector +from .inspection import type_inspector from .type_info import TypeInfo diff --git a/winter/openapi/inspection/inspection.py b/winter/openapi/inspection/inspection.py index 950552c7..9ac07208 100644 --- a/winter/openapi/inspection/inspection.py +++ b/winter/openapi/inspection/inspection.py @@ -15,14 +15,16 @@ ] = {} -def register_type_inspector(*types_: Type, checker: Callable = None, func: Callable = None): - if func is None: - return lambda func: register_type_inspector(*types_, checker=checker, func=func) - - for type_ in types_: - callables = _inspectors_by_type.setdefault(type_, []) - callables.append((func, checker)) - return func +def type_inspector(*types_: Type, checker: Callable = None): + """ + The decorated function describes the types, and their subclasses, in OpenAPI schemas. A checker limits it to + the type hints for which it returns true. + """ + def decorator(func: Callable) -> Callable: + for type_ in types_: + _inspectors_by_type.setdefault(type_, []).append((func, checker)) + return func + return decorator class InspectorNotFound(Exception): diff --git a/winter/openapi/inspectors/__init__.py b/winter/openapi/inspectors/__init__.py index 23667b67..05449be1 100644 --- a/winter/openapi/inspectors/__init__.py +++ b/winter/openapi/inspectors/__init__.py @@ -3,6 +3,3 @@ from .path_parameters_inspector import PathParametersInspector from .query_parameters_inspector import QueryParametersInspector from .route_parameters_inspector import RouteParametersInspector -from .route_parameters_inspector import get_route_parameters_inspectors -from .route_parameters_inspector import get_route_parameters_inspectors -from .route_parameters_inspector import register_route_parameters_inspector diff --git a/winter/openapi/inspectors/page_inspector.py b/winter/openapi/inspectors/page_inspector.py index 2f092efe..e452f45f 100644 --- a/winter/openapi/inspectors/page_inspector.py +++ b/winter/openapi/inspectors/page_inspector.py @@ -7,7 +7,7 @@ from winter.pagination import Page from winter.openapi.inspection.type_info import TypeInfo from winter.openapi.inspectors.standard_types_inspectors import inspect_type -from winter.openapi.inspectors.standard_types_inspectors import register_type_inspector +from winter.openapi.inspectors.standard_types_inspectors import type_inspector def create_dataclass(page_type: Type) -> Type: @@ -52,7 +52,7 @@ def create_dataclass(page_type: Type) -> Type: # noinspection PyUnusedLocal -@register_type_inspector(Page) +@type_inspector(Page) def inspect_page(hint_class) -> TypeInfo: if hint_class not in page_to_dataclass_map: page_to_dataclass_map[hint_class] = create_dataclass(hint_class) diff --git a/winter/openapi/inspectors/route_parameters_inspector.py b/winter/openapi/inspectors/route_parameters_inspector.py index 38dda122..a6c790a4 100644 --- a/winter/openapi/inspectors/route_parameters_inspector.py +++ b/winter/openapi/inspectors/route_parameters_inspector.py @@ -1,5 +1,4 @@ import abc -import logging from typing import List from typing import TYPE_CHECKING @@ -16,20 +15,3 @@ class RouteParametersInspector(abc.ABC): # pragma: no cover @abc.abstractmethod def inspect_parameters(self, route: 'Route', schema_registry: 'SchemaRegistry') -> List[Parameter]: return [] - - -_route_parameters_inspectors: List[RouteParametersInspector] = [] - - -def register_route_parameters_inspector(inspector: RouteParametersInspector): - inspector_classes = [inspector.__class__ for inspector in get_route_parameters_inspectors()] - - if inspector.__class__ in inspector_classes: - logging.warning(f'{inspector.__class__.__name__} already registered') - return - - _route_parameters_inspectors.append(inspector) - - -def get_route_parameters_inspectors() -> List[RouteParametersInspector]: - return _route_parameters_inspectors diff --git a/winter/openapi/inspectors/standard_types_inspectors.py b/winter/openapi/inspectors/standard_types_inspectors.py index d425c658..956e9fff 100644 --- a/winter/openapi/inspectors/standard_types_inspectors.py +++ b/winter/openapi/inspectors/standard_types_inspectors.py @@ -21,84 +21,84 @@ from winter.openapi.inspection.data_formats import DataFormat from winter.openapi.inspection.data_types import DataTypes from winter.openapi.inspection.inspection import inspect_type -from winter.openapi.inspection.inspection import register_type_inspector +from winter.openapi.inspection.inspection import type_inspector from winter.openapi.inspection.type_info import TypeInfo # noinspection PyUnusedLocal -@register_type_inspector(bool) +@type_inspector(bool) def inspect_bool(hint_class) -> TypeInfo: return TypeInfo(type_=DataTypes.BOOLEAN, hint_class=hint_class) # noinspection PyUnusedLocal -@register_type_inspector(int) +@type_inspector(int) def inspect_int(hint_class) -> TypeInfo: return TypeInfo(type_=DataTypes.INTEGER, hint_class=hint_class, format_=DataFormat.INT32) # noinspection PyUnusedLocal -@register_type_inspector(bytes) +@type_inspector(bytes) def inspect_bytes(hint_class) -> TypeInfo: return TypeInfo(type_=DataTypes.STRING, hint_class=hint_class, format_=DataFormat.BASE64) # noinspection PyUnusedLocal -@register_type_inspector(str) +@type_inspector(str) def inspect_str(hint_class) -> TypeInfo: return TypeInfo(type_=DataTypes.STRING, hint_class=hint_class) # noinspection PyUnusedLocal -@register_type_inspector(float) +@type_inspector(float) def inspect_float(hint_class) -> TypeInfo: return TypeInfo(type_=DataTypes.NUMBER, format_=DataFormat.FLOAT, hint_class=hint_class) # noinspection PyUnusedLocal -@register_type_inspector(dict) +@type_inspector(dict) def inspect_dict(hint_class) -> TypeInfo: return TypeInfo(type_=DataTypes.OBJECT, hint_class=hint_class) # noinspection PyUnusedLocal -@register_type_inspector(object, checker=is_type_var) +@type_inspector(object, checker=is_type_var) def inspect_type_var(hint_class) -> TypeInfo: return TypeInfo(type_=DataTypes.ANY, hint_class=hint_class) # noinspection PyUnusedLocal -@register_type_inspector(object, checker=is_any) +@type_inspector(object, checker=is_any) def inspect_any(hint_class) -> TypeInfo: return TypeInfo(type_=DataTypes.ANY, hint_class=hint_class) # noinspection PyUnusedLocal -@register_type_inspector(decimal.Decimal) +@type_inspector(decimal.Decimal) def inspect_decimal(hint_class) -> TypeInfo: return TypeInfo(type_=DataTypes.STRING, hint_class=hint_class, format_=DataFormat.DECIMAL) # noinspection PyUnusedLocal -@register_type_inspector(uuid.UUID) +@type_inspector(uuid.UUID) def inspect_uuid(hint_class) -> TypeInfo: return TypeInfo(type_=DataTypes.STRING, hint_class=hint_class, format_=DataFormat.UUID) # noinspection PyUnusedLocal -@register_type_inspector(datetime.datetime) +@type_inspector(datetime.datetime) def inspect_datetime(hint_class) -> TypeInfo: return TypeInfo(type_=DataTypes.STRING, hint_class=hint_class, format_=DataFormat.DATETIME) # noinspection PyUnusedLocal -@register_type_inspector(datetime.date) +@type_inspector(datetime.date) def inspect_date(hint_class) -> TypeInfo: return TypeInfo(type_=DataTypes.STRING, hint_class=hint_class, format_=DataFormat.DATE) # noinspection PyUnusedLocal -@register_type_inspector(list, tuple, set, Iterable) +@type_inspector(list, tuple, set, Iterable) def inspect_iterable(hint_class) -> TypeInfo: args = typing.get_args(hint_class) if not args: @@ -112,7 +112,7 @@ def inspect_iterable(hint_class) -> TypeInfo: return TypeInfo(type_=DataTypes.ARRAY, hint_class=hint_class, child=child_schema) -@register_type_inspector(enum.IntEnum, StrEnum, enum.Enum) +@type_inspector(enum.IntEnum, StrEnum, enum.Enum) def inspect_enum(enum_class: Type[enum.Enum]) -> TypeInfo: enum_values = [entry.value for entry in enum_class] # Try to infer type_ based on enum values @@ -127,7 +127,7 @@ def inspect_enum(enum_class: Type[enum.Enum]) -> TypeInfo: return schema -@register_type_inspector(object, checker=is_optional) +@type_inspector(object, checker=is_optional) def inspect_optional(hint_class) -> TypeInfo: child_type = hint_class.__args__[0] schema = inspect_type(child_type) @@ -135,7 +135,7 @@ def inspect_optional(hint_class) -> TypeInfo: return schema -@register_type_inspector(object, checker=dataclasses.is_dataclass) +@type_inspector(object, checker=dataclasses.is_dataclass) def inspect_dataclass(hint_class) -> TypeInfo: cls = hint_class if isinstance(hint_class, type) else type(hint_class) title = cls.__name__ @@ -165,14 +165,14 @@ def inspect_dataclass(hint_class) -> TypeInfo: ) -@register_type_inspector(object, checker=has_nested_type) +@type_inspector(object, checker=has_nested_type) def inspect_type_wrapper(hint_class) -> TypeInfo: nested_type = hint_class._nested_type schema = inspect_type(nested_type) return schema -@register_type_inspector( +@type_inspector( typing.NewType, checker=lambda instance: getattr(instance, '__supertype__', None) is not None, ) @@ -184,7 +184,7 @@ def can_be_undefined(type_): return is_union(type_) and Undefined in get_union_args(type_) -@register_type_inspector(object, checker=can_be_undefined) +@type_inspector(object, checker=can_be_undefined) def inspect_can_be_undefined(hint_class) -> TypeInfo: union_args = get_union_args(hint_class) assert len(union_args) == 2, 'Union with Undefined must have 2 args'