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'