From 85c20a3f37366186a1880b24795d4263469c5c90 Mon Sep 17 00:00:00 2001 From: Alexander Egorov Date: Sat, 24 Oct 2026 15:17:43 +0200 Subject: [PATCH 1/3] feat(application)!: compose an application in Application, build Django URLs from it Building an application was spread over find_package_routes in urls.py, the setup() functions of winter.web, winter.django and winter.openapi, and set_injector. Their order was manual: setup() saw only the classes imported by then, so a @problem exception could silently get no handler. Application imports every module of the given packages on construction (by default, those of the directory of the module that creates it) and holds their classes and functions as its definitions, plus whatever is added with app.add(). Several applications may share one injector. winter.django.create_urls(app) builds the URL patterns from it, and winter.openapi gets generate_openapi(app, ...), serve_spec and serve_swagger_ui; the served spec and Swagger UI work under whatever prefix the application is included at. The behavioural registries are still process-global: the isolation test is rewritten on observable behaviour and stays xfail. BREAKING CHANGE: WinterApplication, find_package_routes, create_django_urls_from_routes and the setup() functions of winter.web, winter.django and winter.openapi are removed. A @problem exception gets its handler only when it belongs to an application. generate_openapi takes an Application instead of routes. Co-Authored-By: Claude Opus 5.5 --- README.md | 35 ++++- docs/architecture.md | 16 ++- .../application/caller_directory/__init__.py | 0 tests/application/caller_directory/api.py | 9 ++ tests/application/caller_directory/app.py | 13 ++ .../caller_directory/not_a_package/broken.py | 1 + .../caller_directory/subpackage/__init__.py | 0 .../caller_directory/subpackage/deep.py | 2 + tests/application/test_application.py | 129 +++++++++++++++--- tests/application/unimportable/__init__.py | 0 tests/application/unimportable/broken.py | 1 + tests/apps.py | 17 +-- tests/components/test_component.py | 18 ++- tests/components/test_component_method.py | 6 +- .../test_route_parameter_inspector.py | 3 + tests/openapi/test_add_url_segment_as_tag.py | 4 +- .../test_api_request_and_response_spec.py | 16 +-- .../openapi/test_api_with_media_types_spec.py | 6 +- tests/openapi/test_exception_spec.py | 6 +- tests/openapi/test_metadata_spec.py | 4 +- .../test_page_position_argument_spec.py | 8 +- .../test_query_and_path_parameter_spec.py | 22 +-- tests/openapi/test_serving.py | 108 +++++++++++++++ tests/openapi/test_validators.py | 38 +++++- tests/test_application_scope.py | 58 ++++---- tests/urls.py | 16 ++- tests/web/test_autodiscovery.py | 16 +-- winter/application/__init__.py | 2 +- winter/application/application.py | 90 +++++++++--- winter/django/__init__.py | 13 +- winter/django/view.py | 28 +++- winter/openapi/__init__.py | 21 +-- winter/openapi/generator.py | 31 +++++ winter/openapi/initialization.py | 31 +++++ winter/openapi/serving.py | 54 ++++++++ winter/openapi/validators.py | 10 +- winter/web/__init__.py | 37 +---- winter/web/autodiscovery.py | 28 ++-- winter/web/exceptions/problem_handling.py | 29 ++-- winter/web/initialization.py | 64 +++++++++ 40 files changed, 750 insertions(+), 240 deletions(-) create mode 100644 tests/application/caller_directory/__init__.py create mode 100644 tests/application/caller_directory/api.py create mode 100644 tests/application/caller_directory/app.py create mode 100644 tests/application/caller_directory/not_a_package/broken.py create mode 100644 tests/application/caller_directory/subpackage/__init__.py create mode 100644 tests/application/caller_directory/subpackage/deep.py create mode 100644 tests/application/unimportable/__init__.py create mode 100644 tests/application/unimportable/broken.py create mode 100644 tests/openapi/test_serving.py create mode 100644 winter/openapi/initialization.py create mode 100644 winter/openapi/serving.py create mode 100644 winter/web/initialization.py diff --git a/README.md b/README.md index 565ea4d7..96f0e51f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,32 @@ Web Framework with focus on python typing, dataclasses and modular design Package structure and layer rules: [docs/architecture.md](docs/architecture.md). +## Upgrading to Winter 34 + +An application is composed explicitly in a `winter.application.Application`, which imports every module of +the given packages on construction (by default, those of the directory of the module that creates it), and +adapters build from it. `find_package_routes`, `create_django_urls_from_routes`, `WinterApplication` and the +`setup()` functions of `winter.web`, `winter.django` and `winter.openapi` are gone. + +```python +import service_a +import winter.django +from winter.application import Application + +app = Application(modules=[service_a], injector=injector) # several applications may share one injector +urlpatterns = winter.django.create_urls(app) +``` + +- A `@problem` exception gets its handler only when it belongs to an application: define it in a scanned + module or add it, e.g. `app.add(winter.web.problem(status=404)(NotFoundException))`. +- `winter.django.create_urls` installs the Django throttling identity on its first call: replace it with + `winter.web.set_throttling_identity_resolver` after that. +- OpenAPI: `generate_openapi(app, title=..., version=...)` returns the spec and checks `@raises` annotations + of the application (`allow_missing_raises_annotation=True` skips the check). + `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. + ## Upgrading to Winter 33 `winter` is a namespace package now: the `import winter` facade is gone, use `winter.web` instead @@ -67,14 +93,15 @@ class HelloWorld: return 'Hello, world!' ``` -To use it with Django: +To use it with Django, in `urls.py`: ```python -from winter.web.autodiscovery import find_package_routes +import some_package import winter.django +from winter.application import Application -routes = find_package_routes('some_package.sub_package') +app = Application(modules=[some_package]) urlpatterns = [ - *winter.django.create_django_urls_from_routes(routes), + *winter.django.create_urls(app), ] ``` diff --git a/docs/architecture.md b/docs/architecture.md index 581e97bf..56673a8e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,10 +80,11 @@ at least two of them. ## Current deviations -- **Framework behaviour is still process-global.** `winter.application.get_injector` is a - service locator, and the behavioural registries of `winter.web` and `winter.openapi` are module - singletons. `tests/test_application_scope.py` states the intended behaviour and is marked - `xfail` until an `Application` instance owns that state. +- **Framework behaviour is still process-global.** An `Application` holds the code of an + application and its injector, but the behavioural registries of `winter.web` and `winter.openapi` + are module singletons, and the application installs its injector as the service locator + `winter.application.get_injector`. `tests/test_application_scope.py` states the intended behaviour + and is marked `xfail` until an `Application` instance owns that state. - **`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 @@ -95,8 +96,9 @@ at least two of them. - **import-linter** (`lint-imports`, configured in `pyproject.toml`) checks the layer rules. There are no `ignore_imports`, and none may be added. -- **North-star test** `tests/test_application_scope.py` is marked `xfail(strict=True)`. It - creates two `Application` instances with different exception handlers and asserts they do - not see each other. `strict` forces the marker to be removed the moment it passes. +- **Isolation test** `tests/test_application_scope.py` is marked `xfail(strict=True)`. It + builds two applications in one process and asserts that the handler of a `@problem` exception + of one does not handle that exception in the other. `strict` forces the marker to be removed + the moment it passes. - **This document** describes intent and is updated when the intent changes, not when the code moves. Deviations are listed above while they exist. diff --git a/tests/application/caller_directory/__init__.py b/tests/application/caller_directory/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/application/caller_directory/api.py b/tests/application/caller_directory/api.py new file mode 100644 index 00000000..6bcf4d6b --- /dev/null +++ b/tests/application/caller_directory/api.py @@ -0,0 +1,9 @@ +from collections import OrderedDict # noqa: F401 imported, not defined here + + +class API: + pass + + +def function(): # pragma: no cover + pass diff --git a/tests/application/caller_directory/app.py b/tests/application/caller_directory/app.py new file mode 100644 index 00000000..fdda5311 --- /dev/null +++ b/tests/application/caller_directory/app.py @@ -0,0 +1,13 @@ +from winter.application import Application + +print(f'executing {__name__}') + + +class DefinedBeforeApplication: + pass + + +app = Application() + +if __name__ == '__main__': + print(*(definition.__qualname__ for definition in app.definitions)) diff --git a/tests/application/caller_directory/not_a_package/broken.py b/tests/application/caller_directory/not_a_package/broken.py new file mode 100644 index 00000000..ffba1ca5 --- /dev/null +++ b/tests/application/caller_directory/not_a_package/broken.py @@ -0,0 +1 @@ +raise RuntimeError('A directory without __init__.py is not a package and must not be scanned') diff --git a/tests/application/caller_directory/subpackage/__init__.py b/tests/application/caller_directory/subpackage/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/application/caller_directory/subpackage/deep.py b/tests/application/caller_directory/subpackage/deep.py new file mode 100644 index 00000000..f446e38b --- /dev/null +++ b/tests/application/caller_directory/subpackage/deep.py @@ -0,0 +1,2 @@ +class Deep: + pass diff --git a/tests/application/test_application.py b/tests/application/test_application.py index a26a424b..1c04064f 100644 --- a/tests/application/test_application.py +++ b/tests/application/test_application.py @@ -1,27 +1,124 @@ -import winter.application -import winter.components +import subprocess +import sys +from pathlib import Path +import pytest +from injector import Injector -def test_autodiscover(): +from winter.application import Application +from winter.application import get_injector +from winter.application import set_injector - @winter.components.component - class SimpleComponent: - pass - winter_app = winter.application.WinterApplication() +@pytest.fixture(autouse=True) +def restore_injector(): + # Application installs its injector globally; the rest of the suite runs with the one from conftest + injector = get_injector() + yield + set_injector(injector) - winter_app.autodiscover() - assert SimpleComponent in winter_app.components +class Defined: + pass -def test_autodiscover_with_pre_add(): +def defined(): # pragma: no cover + pass - class SimpleComponent: - pass - winter_app = winter.application.WinterApplication() - winter_app.add_component(SimpleComponent) - winter_app.autodiscover() +def test_definitions_are_classes_and_functions_defined_in_scanned_modules(): + import tests.application.caller_directory.api + from tests.application.caller_directory.api import API + from tests.application.caller_directory.api import function - assert SimpleComponent in winter_app.components + # Act + app = Application(modules=[tests.application.caller_directory.api]) + + # Assert + assert app.definitions == (API, function) + + +def test_package_is_scanned_with_its_submodules(): + import tests.application.caller_directory.subpackage + from tests.application.caller_directory.subpackage.deep import Deep + + # Act + app = Application(modules=[tests.application.caller_directory.subpackage]) + + # Assert + assert app.definitions == (Deep,) + + +def test_default_modules_are_those_of_caller_directory(): + from tests.application.caller_directory.api import API + from tests.application.caller_directory.api import function + from tests.application.caller_directory.subpackage.deep import Deep + + # Act + from tests.application.caller_directory.app import DefinedBeforeApplication + from tests.application.caller_directory.app import app + + # Assert + assert app.definitions == (API, function, DefinedBeforeApplication, Deep) + + +def test_caller_run_as_main_is_not_executed_twice(): + repository = Path(__file__).parents[2] + + # Act + result = subprocess.run( + [sys.executable, '-m', 'tests.application.caller_directory.app'], + cwd=repository, + capture_output=True, + text=True, + check=True, + ) + + # Assert + assert result.stdout.splitlines() == ['executing __main__', 'API function DefinedBeforeApplication Deep'] + + +def test_unimportable_module_fails_construction(): + import tests.application.unimportable + + # Act + with pytest.raises(RuntimeError, match='Unimportable') as exception_info: + Application(modules=[tests.application.unimportable]) + + # Assert + assert exception_info.value.__notes__ == [ + 'Application imports every module of tests.application.unimportable, so each of them must be importable', + ] + + +def test_add(): + app = Application(modules=[]) + + # Act + app.add(Defined) + app.add(defined) + app.add(Defined) + + # Assert + assert app.definitions == (Defined, defined) + + +def test_new_injector_is_installed(): + # Act + app = Application(modules=[]) + + # Assert + assert isinstance(app.injector, Injector) + assert get_injector() is app.injector + + +def test_given_injector_is_shared(): + injector = Injector() + + # Act + app_1 = Application(modules=[], injector=injector) + app_2 = Application(modules=[], injector=injector) + + # Assert + assert app_1.injector is injector + assert app_2.injector is injector diff --git a/tests/application/unimportable/__init__.py b/tests/application/unimportable/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/application/unimportable/broken.py b/tests/application/unimportable/broken.py new file mode 100644 index 00000000..509b7392 --- /dev/null +++ b/tests/application/unimportable/broken.py @@ -0,0 +1 @@ +raise RuntimeError('Unimportable') diff --git a/tests/apps.py b/tests/apps.py index a762084d..5eb27ddc 100644 --- a/tests/apps.py +++ b/tests/apps.py @@ -1,9 +1,7 @@ -from http import HTTPStatus - from django.apps import AppConfig from tests.web.interceptors import HelloWorldInterceptor -from winter.ddd import NotFoundException +import winter.web from winter.web import InMemoryThrottlingStorage from winter.web import exception_handlers_registry from winter.web import interceptor_registry @@ -14,23 +12,12 @@ class TestAppConfig(AppConfig): name = 'tests' def ready(self): - # define this import for force initialization all modules and to register Exceptions + # Importing the URLs creates the application and its URL patterns from .urls import urlpatterns # noqa: F401 - import winter - import winter.django - import winter.openapi interceptor_registry.add_interceptor(HelloWorldInterceptor()) - winter.openapi.setup() - - # Mapping a domain exception to an HTTP status is application wiring, not framework behaviour - winter.web.problem(status=HTTPStatus.NOT_FOUND)(NotFoundException) - winter.web.setup() - # The Redis storage is covered in tests/redis; the sample application throttles in memory. winter.web.set_throttling_storage(InMemoryThrottlingStorage()) - winter.django.setup() - exception_handlers_registry.set_default_handler(DefaultExceptionHandler) # for 100% test coverage diff --git a/tests/components/test_component.py b/tests/components/test_component.py index 844e1376..07d0d537 100644 --- a/tests/components/test_component.py +++ b/tests/components/test_component.py @@ -3,7 +3,6 @@ import winter.components from winter.components import Component -from winter.application import WinterApplication @dataclasses.dataclass(frozen=True) @@ -16,12 +15,10 @@ def simple_annotation(param: str): def test_is_component(): - winter_app = WinterApplication() - class SimpleComponent: pass - winter_app.add_component(SimpleComponent) + winter.components.component(SimpleComponent) assert winter.components.is_component(SimpleComponent) @@ -75,3 +72,16 @@ def test_register_with_instance(): assert str(exception.value) == f'Need class. Got: {instance}' assert exception.value.args == (f'Need class. Got: {instance}',) + + +def test_register_returns_existing_component(): + class SimpleComponent: + pass + + component = Component.register(SimpleComponent) + + # Act + registered_again = Component.register(SimpleComponent) + + # Assert + assert registered_again is component diff --git a/tests/components/test_component_method.py b/tests/components/test_component_method.py index 01a4cb5a..944ee815 100644 --- a/tests/components/test_component_method.py +++ b/tests/components/test_component_method.py @@ -1,13 +1,12 @@ import inspect +from winter.components import Component from winter.components import ComponentMethod from winter.components import ComponentMethodArgument -from winter.application import WinterApplication from winter.components import component_method def test_all_attributes(): - winter_app = WinterApplication() argument_type = int argument_name = 'number' method_name = 'method' @@ -18,12 +17,11 @@ class SimpleComponent: def method(self, number: argument_type): return self, number - winter_app.add_component(SimpleComponent) simple_component = SimpleComponent() cls_method = SimpleComponent.method - assert cls_method.component == winter_app.components[SimpleComponent] + assert cls_method.component == Component.get_by_cls(SimpleComponent) assert cls_method.name == method_name assert inspect.ismethod(simple_component.method) assert cls_method.func == simple_component.method.__func__ diff --git a/tests/openapi/inspectors/test_route_parameter_inspector.py b/tests/openapi/inspectors/test_route_parameter_inspector.py index 59dfcb2a..81f1de15 100644 --- a/tests/openapi/inspectors/test_route_parameter_inspector.py +++ b/tests/openapi/inspectors/test_route_parameter_inspector.py @@ -2,9 +2,12 @@ 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/tests/openapi/test_add_url_segment_as_tag.py b/tests/openapi/test_add_url_segment_as_tag.py index e014d715..9a110f8b 100644 --- a/tests/openapi/test_add_url_segment_as_tag.py +++ b/tests/openapi/test_add_url_segment_as_tag.py @@ -1,6 +1,6 @@ import winter.web from winter.web.routing import get_route -from winter.openapi import generate_openapi +from winter.openapi.generator import generate_openapi_for_routes def test_add_url_segment_as_tag_false(): @@ -11,7 +11,7 @@ def get_resource(self): # pragma: no cover route = get_route(_TestAPI.get_resource) # Act - result = generate_openapi( + result = generate_openapi_for_routes( title='title', version='1.0.0', description='description', diff --git a/tests/openapi/test_api_request_and_response_spec.py b/tests/openapi/test_api_request_and_response_spec.py index 8e92346e..07dff3df 100644 --- a/tests/openapi/test_api_request_and_response_spec.py +++ b/tests/openapi/test_api_request_and_response_spec.py @@ -24,7 +24,7 @@ from winter.typing import TypeWrapper from winter.pagination import Page from winter.web.routing import get_route -from winter.openapi import generate_openapi +from winter.openapi.generator import generate_openapi_for_routes from winter.openapi.generator import CanNotInspectType @@ -464,7 +464,7 @@ def simple_method(self) -> type_hint: # pragma: no cover 'tags': ['types'], } # Act - result = generate_openapi(title='title', version='1.0.0', routes=[route]) + result = generate_openapi_for_routes(title='title', version='1.0.0', routes=[route]) # Assert method_info = result['paths']['/types/']['get'] @@ -482,7 +482,7 @@ def with_invalid_return_type(self) -> object: # pragma: no cover with pytest.raises(CanNotInspectType) as e: # Act - generate_openapi(title='title', version='1.0.0', routes=[route]) + generate_openapi_for_routes(title='title', version='1.0.0', routes=[route]) assert repr(e.value) == ( "CanNotInspectType(test_api_request_and_response_spec._TestAPI.with_invalid_return_type: " @@ -567,7 +567,7 @@ def simple_method(self, data: type_hint): # pragma: no cover route = get_route(_TestAPI.simple_method) # Act - result = generate_openapi(title='title', version='1.0.0', routes=[route]) + result = generate_openapi_for_routes(title='title', version='1.0.0', routes=[route]) # Assert method_info = result['paths']['/types/']['post']['requestBody'] @@ -612,7 +612,7 @@ def simple_method(self, data: RequestBodyWithUnionUndefined): # pragma: no cove route = get_route(_TestAPI.simple_method) # Act - result = generate_openapi(title='title', version='1.0.0', routes=[route]) + result = generate_openapi_for_routes(title='title', version='1.0.0', routes=[route]) # Assert method_info = result['paths']['/types/']['post']['requestBody'] @@ -673,7 +673,7 @@ def method_request_body_undefined_1(self, data: DataclassWithUndefined): def method_request_body_undefined_2(self, data: DataclassWithUndefined): pass - result = generate_openapi( + result = generate_openapi_for_routes( title='title', version='1.0.0', routes=[ @@ -992,7 +992,7 @@ def method_1(self) -> Page[str]: def method_2(self) -> Page[str]: pass - result = generate_openapi( + result = generate_openapi_for_routes( title='title', version='1.0.0', routes=[ @@ -1091,7 +1091,7 @@ def method_2(self) -> DuplicateTypes.Dataclass: pass with pytest.raises(ValueError) as e: - generate_openapi( + generate_openapi_for_routes( title='title', version='1.0.0', routes=[ diff --git a/tests/openapi/test_api_with_media_types_spec.py b/tests/openapi/test_api_with_media_types_spec.py index ddddd989..3ef0783d 100644 --- a/tests/openapi/test_api_with_media_types_spec.py +++ b/tests/openapi/test_api_with_media_types_spec.py @@ -3,7 +3,7 @@ import winter.web from winter.web import MediaType from winter.web.routing import get_route -from winter.openapi import generate_openapi +from winter.openapi.generator import generate_openapi_for_routes def test_generate_spec_for_media_type_produces(): @@ -15,7 +15,7 @@ def get_xml(self) -> str: # pragma: no cover route = get_route(_TestAPI.get_xml) # Act - result = generate_openapi(title='title', version='1.0.0', routes=[route]) + result = generate_openapi_for_routes(title='title', version='1.0.0', routes=[route]) # Assert paths = result['paths'] @@ -51,7 +51,7 @@ def get_xml(self, body: Data): # pragma: no cover route = get_route(_TestAPI.get_xml) # Act - result = generate_openapi(title='title', version='1.0.0', routes=[route]) + result = generate_openapi_for_routes(title='title', version='1.0.0', routes=[route]) # Assert paths = result['paths'] diff --git a/tests/openapi/test_exception_spec.py b/tests/openapi/test_exception_spec.py index d4816b14..9103429c 100644 --- a/tests/openapi/test_exception_spec.py +++ b/tests/openapi/test_exception_spec.py @@ -5,7 +5,7 @@ import winter.web from winter.web.routing import get_route -from winter.openapi import generate_openapi +from winter.openapi.generator import generate_openapi_for_routes from winter.openapi.annotations import global_exception @@ -22,7 +22,7 @@ def get_resource(self): # pragma: no cover route = get_route(_TestAPI.get_resource) # Act - result = generate_openapi( + result = generate_openapi_for_routes( title='title', version='1.0.0', routes=[route], @@ -105,7 +105,7 @@ def get_resource(self): # pragma: no cover route = get_route(_TestAPI.get_resource) # Act - result = generate_openapi( + result = generate_openapi_for_routes( title='title', version='1.0.0', routes=[route], diff --git a/tests/openapi/test_metadata_spec.py b/tests/openapi/test_metadata_spec.py index 80eee03d..24925436 100644 --- a/tests/openapi/test_metadata_spec.py +++ b/tests/openapi/test_metadata_spec.py @@ -1,6 +1,6 @@ import winter.web from winter.web.routing import get_route -from winter.openapi import generate_openapi +from winter.openapi.generator import generate_openapi_for_routes def test_generate_openapi_with_all_args_spec(): @@ -12,7 +12,7 @@ def get_resource(self): # pragma: no cover route = get_route(_TestAPI.get_resource) tags = [{'name': 'tag_value'}] # Act - result = generate_openapi( + result = generate_openapi_for_routes( title='title', version='1.0.0', description='description', diff --git a/tests/openapi/test_page_position_argument_spec.py b/tests/openapi/test_page_position_argument_spec.py index 44f933e0..897099c8 100644 --- a/tests/openapi/test_page_position_argument_spec.py +++ b/tests/openapi/test_page_position_argument_spec.py @@ -3,7 +3,7 @@ import winter.web from winter.pagination import PagePosition from winter.web.routing import get_route -from winter.openapi import generate_openapi +from winter.openapi.generator import generate_openapi_for_routes def test_generate_page_position_argument_spec(): @@ -15,7 +15,7 @@ def method(self, arg1: PagePosition): # pragma: no cover route = get_route(_TestAPI.method) # Act - result = generate_openapi(title="title", version="1.0.0", routes=[route]) + result = generate_openapi_for_routes(title="title", version="1.0.0", routes=[route]) # Assert parameters = result["paths"]["/pageable_resource"]["get"]["parameters"] @@ -52,7 +52,7 @@ def method(self, arg1: object): # pragma: no cover route = get_route(_TestAPI.method) # Act - result = generate_openapi(title="title", version="1.0.0", routes=[route]) + result = generate_openapi_for_routes(title="title", version="1.0.0", routes=[route]) # Assert parameters = result["paths"]["/pageable_resource"]["get"]["parameters"] @@ -98,7 +98,7 @@ def method(self, arg1: PagePosition): # pragma: no cover expected_order_by_parameter['schema']['default'] = default_sort # Act - result = generate_openapi(title="title", version="1.0.0", routes=[route]) + result = generate_openapi_for_routes(title="title", version="1.0.0", routes=[route]) # Assert parameters = result["paths"]["/ordered_resource"]["get"]["parameters"] diff --git a/tests/openapi/test_query_and_path_parameter_spec.py b/tests/openapi/test_query_and_path_parameter_spec.py index ad7d7707..d7bdd0b2 100644 --- a/tests/openapi/test_query_and_path_parameter_spec.py +++ b/tests/openapi/test_query_and_path_parameter_spec.py @@ -7,7 +7,7 @@ import winter.web from winter.web.routing import get_route -from winter.openapi import generate_openapi +from winter.openapi.generator import generate_openapi_for_routes from winter.openapi.generator import CanNotInspectType @@ -74,7 +74,9 @@ def simple_method( } route = get_route(_TestAPI.simple_method) # Act - result = generate_openapi(title='title', version='1.0.0', description='Winter api description', routes=[route]) + result = generate_openapi_for_routes( + title='title', version='1.0.0', description='Winter api description', routes=[route], + ) # Assert parameters = result["paths"]["/resource/"]["get"]["parameters"] @@ -108,7 +110,7 @@ def simple_method( } route = get_route(_TestAPI.simple_method) # Act - result = generate_openapi(title='title', version='1.0.0', routes=[route]) + result = generate_openapi_for_routes(title='title', version='1.0.0', routes=[route]) # Assert parameters = result["paths"]["/resource/"]["get"]["parameters"] @@ -149,7 +151,9 @@ def simple_method( } route = get_route(_TestAPI.simple_method) # Act - result = generate_openapi(title='title', version='1.0.0', description='Winter api description', routes=[route]) + result = generate_openapi_for_routes( + title='title', version='1.0.0', description='Winter api description', routes=[route], + ) # Assert parameters = result["paths"]["/resource/"]["get"]["parameters"] @@ -182,7 +186,7 @@ def simple_method_with_path_param( } # Act - result = generate_openapi(title='title', version='1.0.0', routes=[route]) + result = generate_openapi_for_routes(title='title', version='1.0.0', routes=[route]) # Assert parameters = result["paths"]["/{param}/"]["post"]["parameters"] @@ -204,7 +208,7 @@ def api_method( # Act with pytest.raises(Exception) as exc_info: - generate_openapi(title='title', version='1.0.0', routes=[route]) + generate_openapi_for_routes(title='title', version='1.0.0', routes=[route]) # Assert assert str(exc_info.value) == 'Argument "test" not found in _TestAPI.api_method, but listed in query parameters' @@ -225,7 +229,7 @@ def api_method( # Act with pytest.raises(Exception) as exc_info: - generate_openapi(title='title', version='1.0.0', routes=[route]) + generate_openapi_for_routes(title='title', version='1.0.0', routes=[route]) # Assert assert str(exc_info.value) == 'Path variable "test" not found in method _TestAPI.api_method' @@ -258,7 +262,7 @@ def simple_method( } route = get_route(_TestAPI.simple_method) # Act - result = generate_openapi(title='title', version='1.0.0', routes=[route]) + result = generate_openapi_for_routes(title='title', version='1.0.0', routes=[route]) # Assert parameters = result["paths"]["/resource/"]["get"]["parameters"] @@ -296,7 +300,7 @@ def simple_method( route = get_route(_TestAPI.simple_method) # Act with pytest.raises(CanNotInspectType) as e: - generate_openapi(title='title', version='1.0.0', routes=[route]) + generate_openapi_for_routes(title='title', version='1.0.0', routes=[route]) # Assert assert str(e.value) == ( diff --git a/tests/openapi/test_serving.py b/tests/openapi/test_serving.py new file mode 100644 index 00000000..63c0e14c --- /dev/null +++ b/tests/openapi/test_serving.py @@ -0,0 +1,108 @@ +import json +from http import HTTPStatus + +import pytest +from django.test import RequestFactory + +import winter.django +import winter.openapi +import winter.web +from winter.application import Application +from winter.application import get_injector + + +@winter.web.problem(status=HTTPStatus.CONFLICT) +class _UndeclaredException(Exception): + pass + + +class _API: + @winter.web.route_get('v1/items/') + def get_items(self) -> list[int]: # pragma: no cover + return [] + + @winter.web.route_get('v1/orders/') + def get_orders(self) -> list[int]: # pragma: no cover + return [] + + +def _application(*definitions) -> Application: + app = Application(modules=[], injector=get_injector()) + for definition in definitions: + app.add(definition) + return app + + +def _get(app: Application, route_name: str, path: str): + [url] = [url for url in winter.django.create_urls(app) if url.name == route_name] + return url.callback(RequestFactory().get(path)) + + +def test_generate_openapi_of_application(): + app = _application(_API) + + # Act + spec = winter.openapi.generate_openapi(app, title='title', version='1.0.0') + + # Assert + assert list(spec['paths']) == ['/items/', '/orders/'] + assert spec['servers'] == [{'url': '/v1'}] + + +def test_generate_openapi_requires_raises_annotations(): + app = _application(_API, _UndeclaredException) + + # Act + with pytest.raises(AssertionError, match='_UndeclaredException'): + winter.openapi.generate_openapi(app, title='title', version='1.0.0') + + +def test_generate_openapi_allows_missing_raises_annotations(): + app = _application(_API, _UndeclaredException) + + # Act + spec = winter.openapi.generate_openapi(app, title='title', version='1.0.0', allow_missing_raises_annotation=True) + + # Assert + assert list(spec['paths']) == ['/items/', '/orders/'] + + +def test_serve_spec_prefixes_servers_with_mount_path(): + app = _application(_API) + spec = winter.openapi.generate_openapi(app, title='title', version='1.0.0') + winter.openapi.serve_spec(app, spec, url='openapi.json') + + # Act + response = _get(app, 'OpenAPISpec.get', '/service/openapi.json') + + # Assert + assert response.status_code == HTTPStatus.OK + assert response['content-type'] == 'application/json' + served_spec = json.loads(response.content) + assert served_spec['servers'] == [{'url': '/service/v1'}] + assert served_spec['paths'] == spec['paths'] + assert spec['servers'] == [{'url': '/v1'}] + + +def test_serve_spec_without_servers(): + app = _application() + winter.openapi.serve_spec(app, {'openapi': '3.0.3'}, url='docs/openapi.json') + + # Act + response = _get(app, 'OpenAPISpec.get', '/service/docs/openapi.json') + + # Assert + assert json.loads(response.content) == {'openapi': '3.0.3', 'servers': [{'url': '/service/'}]} + + +def test_serve_swagger_ui_points_to_spec_under_mount_path(): + app = _application() + winter.openapi.serve_swagger_ui(app, spec_url='openapi.json', url='docs/') + + # Act + response = _get(app, 'SwaggerUI.get', '/service/docs/') + + # Assert + assert response.status_code == HTTPStatus.OK + assert response['content-type'] == 'text/html' + assert "url: '/service/openapi.json'" in response.content.decode() diff --git a/tests/openapi/test_validators.py b/tests/openapi/test_validators.py index ddb798c8..e56b0174 100644 --- a/tests/openapi/test_validators.py +++ b/tests/openapi/test_validators.py @@ -2,10 +2,13 @@ import pytest +import winter.openapi import winter.web +from winter.application import Application +from winter.application import get_injector from winter.web import problem from winter.web.routing import get_route -from winter.openapi import generate_openapi +from winter.openapi.generator import generate_openapi_for_routes from winter.openapi import validate_missing_raises_annotations @@ -13,10 +16,12 @@ def test_validate_missing_raises_annotations_with_missed_raises_and_not_global_e @problem(HTTPStatus.BAD_REQUEST) class MissingException(Exception): pass + app = Application(modules=[], injector=get_injector()) + app.add(MissingException) expected_error_message = f'You are missing declaration for next exceptions: {MissingException.__name__}' with pytest.raises(AssertionError, match=expected_error_message): - validate_missing_raises_annotations() + validate_missing_raises_annotations(app) @pytest.mark.parametrize('validate', [True, False]) @@ -33,4 +38,31 @@ def endpoint(self): # pragma: no cover route = get_route(_TestAPI.endpoint) # Act - generate_openapi(title='title', version='1.0.0', routes=[route], validate=validate) + generate_openapi_for_routes(title='title', version='1.0.0', routes=[route], validate=validate) + + +def test_validate_missing_raises_annotations_with_declared_and_global_exceptions(): + @problem(HTTPStatus.BAD_REQUEST) + class DeclaredException(Exception): + pass + + @winter.openapi.global_exception + @problem(HTTPStatus.BAD_REQUEST) + class GlobalException(Exception): + pass + + class _TestAPI: + @winter.web.raises(DeclaredException) + @winter.web.route_get('/test-api/') + def endpoint(self): # pragma: no cover + pass + + def not_a_component(): # pragma: no cover + pass + + app = Application(modules=[], injector=get_injector()) + for definition in [DeclaredException, GlobalException, _TestAPI, not_a_component]: + app.add(definition) + + # Act + validate_missing_raises_annotations(app) diff --git a/tests/test_application_scope.py b/tests/test_application_scope.py index cc7a6aa0..2f697a59 100644 --- a/tests/test_application_scope.py +++ b/tests/test_application_scope.py @@ -1,36 +1,46 @@ """ -North-star test for docs/architecture.md, principle 2: framework behaviour is never -process-global. Two Application instances in one process must not share behavioural -registries. Marked xfail(strict=True): it turns green when the Application scope lands, -and strict forces the marker to be removed at that moment. `raises` limits the expected failure to -`Application` not existing yet or the registries still being shared; any other error is a real failure. +Isolation test for docs/architecture.md, principle 2: framework behaviour is never process-global. +Two applications in one process must not share behaviour: the handler of a @problem exception of one application +does not handle that exception in another. Marked xfail(strict=True) while the exception handlers are +process-global; strict forces the marker to be removed the moment the test passes. """ +from http import HTTPStatus + import pytest +from django.test import RequestFactory + +import winter.django +import winter.web +from winter.application import Application +from winter.application import get_injector +@winter.web.problem(status=HTTPStatus.CONFLICT) class _ErrorA(Exception): pass +class _API: + @winter.web.route_get('raise-a/') + def raise_a(self): + raise _ErrorA() + + @pytest.mark.xfail( strict=True, - raises=(ImportError, AssertionError), - reason='North star: behavioural registries are scoped to an Application instance', + raises=AssertionError, + reason='Isolation: exception handlers are scoped to an Application instance', ) -def test_two_applications_do_not_share_exception_handlers(): # pragma: no cover - from winter.application import Application - from winter.web.exceptions.handlers import DefaultExceptionHandler - from winter.web.exceptions.handlers import ExceptionHandler - from winter.web.exceptions.handlers import ExceptionHandlersRegistry - - class HandlerA(ExceptionHandler): - def handle(self, exception: Exception, **kwargs): - return 'a' - - app_1 = Application() - app_2 = Application() - - app_1.get(ExceptionHandlersRegistry).add_handler(_ErrorA, HandlerA) - - assert isinstance(app_1.get(ExceptionHandlersRegistry).get_handler(_ErrorA), HandlerA) - assert isinstance(app_2.get(ExceptionHandlersRegistry).get_handler(_ErrorA), DefaultExceptionHandler) +def test_problem_handler_of_one_application_does_not_handle_exceptions_in_another(): + app_1 = Application(modules=[], injector=get_injector()) + app_1.add(_ErrorA) + app_2 = Application(modules=[], injector=get_injector()) + app_2.add(_API) + winter.django.create_urls(app_1) + [url] = winter.django.create_urls(app_2) + + # Act + response = url.callback(RequestFactory().get('/raise-a/')) + + # Assert + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR diff --git a/tests/urls.py b/tests/urls.py index 0f3a1cce..09d16674 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -1,5 +1,13 @@ -from winter.web import find_package_routes -from winter.django import create_django_urls_from_routes +from http import HTTPStatus -routes = find_package_routes('tests.api') -urlpatterns = create_django_urls_from_routes(routes) +import tests.api +import winter.django +import winter.web +from winter.application import Application +from winter.application import get_injector +from winter.ddd import NotFoundException + +app = Application(modules=[tests.api], injector=get_injector()) +# Mapping a domain exception to an HTTP status is application wiring, not framework behaviour +app.add(winter.web.problem(status=HTTPStatus.NOT_FOUND)(NotFoundException)) +urlpatterns = winter.django.create_urls(app) diff --git a/tests/web/test_autodiscovery.py b/tests/web/test_autodiscovery.py index 43395824..90050fbc 100644 --- a/tests/web/test_autodiscovery.py +++ b/tests/web/test_autodiscovery.py @@ -1,17 +1,15 @@ import winter.web +from winter.application import Application +from winter.application import get_injector -def test_find_package_routes(): - # Act - routes = winter.web.find_package_routes('tests.api.package') +def test_find_routes(): + import tests.api.package - # Assert - assert [route.url_path for route in routes] == ['api_1/', 'api_2/', 'api_2/', 'api_3/'] + app = Application(modules=[tests.api.package], injector=get_injector()) - -def test_find_module_routes(): # Act - routes = winter.web.find_package_routes('tests.api.package.api_2') + routes = winter.web.find_routes(app) # Assert - assert [route.url_path for route in routes] == ['api_2/', 'api_2/'] + assert [route.url_path for route in routes] == ['api_1/', 'api_2/', 'api_2/', 'api_3/'] diff --git a/winter/application/__init__.py b/winter/application/__init__.py index 9b192406..e15d1804 100644 --- a/winter/application/__init__.py +++ b/winter/application/__init__.py @@ -1,3 +1,3 @@ -from .application import WinterApplication +from .application import Application from .injection import get_injector from .injection import set_injector diff --git a/winter/application/application.py b/winter/application/application.py index 7a904eb3..deda81cd 100644 --- a/winter/application/application.py +++ b/winter/application/application.py @@ -1,25 +1,79 @@ +import importlib +import inspect +import os +import pkgutil +import sys import types -from typing import Mapping -from typing import Type +from collections.abc import Sequence +from typing import Any +from typing import Literal -from winter.components import Component +from injector import Injector +from .injection import set_injector +from .module_discovery import import_recursively -class WinterApplication: +Definition = type | types.FunctionType - def __init__(self): - self._components = {} + +class Application: + """ + The code of an application and the injector it runs with, in one object that adapters build from. + + The modules are scanned on construction: each one is imported together with its submodules, and the + classes and functions defined in them become the application's definitions. A directory without + `__init__.py` is not a package and is not scanned. By default, the modules are those of the directory + of the module that creates the Application. + """ + + def __init__( + self, + modules: Sequence[types.ModuleType] | Literal['caller_directory'] = 'caller_directory', + injector: Injector | Literal['new'] = 'new', + ): + self.injector = Injector() if injector == 'new' else injector + if modules == 'caller_directory': + module_names = _caller_directory(sys._getframe(1).f_globals) + else: + module_names = [module.__name__ for module in modules] + self._definitions: dict[Definition, None] = {} + for module_name in module_names: + for module in _import_recursively(module_name): + for definition in _defined_in(module): + self.add(definition) + # The request pipeline still takes the injector from winter.application.get_injector + set_injector(self.injector) @property - def components(self) -> Mapping[Type, Component]: - return types.MappingProxyType(self._components) - - def add_component(self, cls: Type) -> Type: - Component.register(cls) - self._components[cls] = Component.get_by_cls(cls) - return cls - - def autodiscover(self) -> None: - for component_cls in Component.get_all(): - if component_cls not in self._components: - self.add_component(component_cls) + def definitions(self) -> tuple[Definition, ...]: + """Classes and functions of the application, in the order they were scanned or added.""" + return tuple(self._definitions) + + def add(self, definition: Definition) -> None: + """Adds a class or a function that is not defined in the scanned modules, such as a generated one.""" + self._definitions.setdefault(definition, None) + + +def _caller_directory(caller_globals: dict[str, Any]) -> list[str]: + path = caller_globals['__file__'] + package = caller_globals['__package__'] + prefix = f'{package}.' if package else '' + caller_name = prefix + inspect.getmodulename(path) + module_names = [prefix + module.name for module in pkgutil.iter_modules([os.path.dirname(path)])] + # Run with `python -m`, the caller is __main__: importing it by its own name would execute it a second time + return [caller_globals['__name__'] if name == caller_name else name for name in module_names] + + +def _import_recursively(module_name: str) -> list[types.ModuleType]: + try: + module = importlib.import_module(module_name) + return [module, *import_recursively(module_name)] if hasattr(module, '__path__') else [module] + except Exception as error: + error.add_note(f'Application imports every module of {module_name}, so each of them must be importable') + raise + + +def _defined_in(module: types.ModuleType): + for value in vars(module).values(): + if (inspect.isclass(value) or inspect.isfunction(value)) and value.__module__ == module.__name__: + yield value diff --git a/winter/django/__init__.py b/winter/django/__init__.py index 5270020f..37b30338 100644 --- a/winter/django/__init__.py +++ b/winter/django/__init__.py @@ -1,19 +1,8 @@ -from django.http.response import HttpResponseBase - -from winter.web import arguments_resolver -from winter.web import register_native_response_type -from winter.web import set_throttling_identity_resolver from . import json_encoders # noqa: F401 registers the Django lazy string encoder from .http_request_argument_resolver import HttpRequestArgumentResolver from .output_template import output_template from .request import DjangoRequest from .reverse import reverse from .throttling import django_user_identity -from .view import create_django_urls_from_routes +from .view import create_urls from .view import to_django_response - - -def setup(): - arguments_resolver.add_argument_resolver(HttpRequestArgumentResolver()) - register_native_response_type(HttpResponseBase) - set_throttling_identity_resolver(django_user_identity) diff --git a/winter/django/view.py b/winter/django/view.py index 52fdc8e8..1097b5fa 100644 --- a/winter/django/view.py +++ b/winter/django/view.py @@ -8,17 +8,43 @@ from django.urls import URLPattern from django.urls import re_path +import winter.web.initialization +from winter.application import Application from winter.web import Response +from winter.web import arguments_resolver from winter.web import handle_request +from winter.web import register_native_response_type +from winter.web import set_throttling_identity_resolver from winter.web.routing import Route from winter.web.urls import rewrite_uritemplate_with_regexps +from .http_request_argument_resolver import HttpRequestArgumentResolver from .request import DjangoRequest +from .throttling import django_user_identity if TYPE_CHECKING: from django.views.generic import View -def create_django_urls_from_routes(routes: List[Route]) -> List[URLPattern]: +def create_urls(app: Application) -> List[URLPattern]: + """URL patterns of the routes of an application, relative to where they are included in the urlpatterns.""" + winter.web.initialization.initialize(app) + _initialize_process() + return _create_urls_from_routes(winter.web.find_routes(app)) + + +_process_initialized = False + + +def _initialize_process(): + global _process_initialized + if not _process_initialized: + arguments_resolver.add_argument_resolver(HttpRequestArgumentResolver()) + register_native_response_type(HttpResponseBase) + set_throttling_identity_resolver(django_user_identity) + _process_initialized = True + + +def _create_urls_from_routes(routes: List[Route]) -> List[URLPattern]: django_urls = [] grouped_routes = defaultdict(list) diff --git a/winter/openapi/__init__.py b/winter/openapi/__init__.py index 3ef02b50..5b63b4c9 100644 --- a/winter/openapi/__init__.py +++ b/winter/openapi/__init__.py @@ -1,9 +1,3 @@ -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 import PagePositionArgumentResolver -from winter.web.pagination.limits import MaximumLimitValueExceeded from .annotations import global_exception from .annotations import register_global_exception from .generator import generate_openapi @@ -16,18 +10,7 @@ from .inspectors import get_route_parameters_inspectors from .inspectors import inspect_page from .inspectors import register_route_parameters_inspector +from .serving import serve_spec +from .serving import serve_swagger_ui from .swagger_ui import get_swagger_ui_html from .validators import validate_missing_raises_annotations - - -def setup(allow_missing_raises_annotation: bool = False): - 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())) - if not allow_missing_raises_annotation: # pragma: no cover - validate_missing_raises_annotations() diff --git a/winter/openapi/generator.py b/winter/openapi/generator.py index d1f2861a..73ca5441 100644 --- a/winter/openapi/generator.py +++ b/winter/openapi/generator.py @@ -29,6 +29,7 @@ from openapi_pydantic.v3.v3_0 import Server from openapi_pydantic.v3.v3_0 import Tag +from winter.application import Application from winter.components import ComponentMethod from winter.web import MediaType from winter.web.default_response_status import get_response_status @@ -36,11 +37,14 @@ from winter.web.request_body_annotation import RequestBodyAnnotation from winter.web.routing import Route from winter.web.routing import RouteAnnotation +from winter.web import find_routes from winter.openapi.inspection.inspection import inspect_type 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 .validators import validate_missing_raises_annotations class SchemaRegistry: @@ -134,6 +138,32 @@ def build_schema(self, type_info: TypeInfo, *, output: bool) -> Schema: def generate_openapi( + app: Application, + *, + title: str, + version: str, + description: Optional[str] = None, + tags: Optional[List[Dict[str, Any]]] = None, + validate: bool = True, + add_url_segment_as_tag: bool = True, + 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( + title=title, + version=version, + routes=find_routes(app), + description=description, + tags=tags, + validate=validate, + add_url_segment_as_tag=add_url_segment_as_tag, + ) + + +def generate_openapi_for_routes( title: str, version: str, routes: Sequence[Route], @@ -142,6 +172,7 @@ def generate_openapi( validate: bool = True, add_url_segment_as_tag: bool = True, ) -> Dict[str, Any]: + initialize() routes = list(routes) routes.sort(key=lambda r: r.url_path) schema_registry = SchemaRegistry() diff --git a/winter/openapi/initialization.py b/winter/openapi/initialization.py new file mode 100644 index 00000000..957f3880 --- /dev/null +++ b/winter/openapi/initialization.py @@ -0,0 +1,31 @@ +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 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.""" + 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/serving.py b/winter/openapi/serving.py new file mode 100644 index 00000000..695d7e8a --- /dev/null +++ b/winter/openapi/serving.py @@ -0,0 +1,54 @@ +import json +from functools import cache +from http import HTTPStatus +from typing import Any +from typing import Dict + +import winter.web +from winter.application import Application +from .swagger_ui import get_swagger_ui_html + + +def serve_spec(app: Application, spec: Dict[str, Any], *, url: str): + """ + Adds a route that serves the spec to the application. `url` is relative to the application root, like the url + of any route. The servers of the spec are prefixed with the path the application is mounted at, so the spec stays + right wherever the application is included. + """ + + @cache + def body(mount_path: str) -> bytes: + servers = spec.get('servers') or [{'url': '/'}] + servers = [{**server, 'url': _join(mount_path, server['url'])} for server in servers] + return json.dumps({**spec, 'servers': servers}).encode() + + class OpenAPISpec: + @winter.web.route_get(url) + def get(self, request: winter.web.Request) -> winter.web.Response: + headers = {'content-type': 'application/json'} + return winter.web.Response(HTTPStatus.OK, body(_mount_path(request, url)), headers) + + app.add(OpenAPISpec) + + +def serve_swagger_ui(app: Application, *, spec_url: str, url: str): + """ + Adds a route with Swagger UI to the application, for the spec served at `spec_url`. + Both urls are relative to the application root. + """ + + class SwaggerUI: + @winter.web.route_get(url) + def get(self, request: winter.web.Request) -> winter.web.Response: + html = get_swagger_ui_html(openapi_url=_mount_path(request, url) + spec_url) + return winter.web.Response(HTTPStatus.OK, html.encode(), {'content-type': 'text/html'}) + + app.add(SwaggerUI) + + +def _mount_path(request: winter.web.Request, url: str) -> str: + return request.path[:len(request.path) - len(url)] + + +def _join(mount_path: str, url: str) -> str: + return mount_path.rstrip('/') + '/' + url.lstrip('/') diff --git a/winter/openapi/validators.py b/winter/openapi/validators.py index a5dd9bd0..8c168aed 100644 --- a/winter/openapi/validators.py +++ b/winter/openapi/validators.py @@ -3,10 +3,12 @@ from typing import Type +from winter.application import Application from winter.openapi.annotations import GlobalExceptionAnnotation -def validate_missing_raises_annotations(): +def validate_missing_raises_annotations(app: Application): + """Every @problem exception of the application is declared with @raises by one of its API methods or is global.""" from winter.web.exceptions.problem_annotation import ProblemAnnotation from winter.web.exceptions.raises import ExceptionAnnotation from winter.components import Component @@ -14,7 +16,11 @@ def validate_missing_raises_annotations(): all_problem_exception: Set[Type[Exception]] = set() global_exceptions: Set[Type[Exception]] = set() declared_raises_exceptions: Set[Type[Exception]] = set() - for cls, component in Component.get_all().items(): + components = Component.get_all() + for cls in app.definitions: + component = components.get(cls) + if component is None: + continue if component.annotations.get_one_or_none(ProblemAnnotation): all_problem_exception.add(cls) if component.annotations.get_one_or_none(GlobalExceptionAnnotation): diff --git a/winter/web/__init__.py b/winter/web/__init__.py index 8de94469..6dcd662a 100644 --- a/winter/web/__init__.py +++ b/winter/web/__init__.py @@ -3,7 +3,7 @@ from .argument_resolver import ArgumentResolver from .argument_resolver import ArgumentsResolver from .argument_resolver import arguments_resolver -from .autodiscovery import find_package_routes +from .autodiscovery import find_routes from .exceptions import ExceptionHandler from .exceptions import exception_handlers_registry from .exceptions import problem @@ -42,38 +42,3 @@ from .throttling import set_throttling_storage from .throttling import throttling from .urls import register_url_regexp - - -def setup(): - from .exceptions import RedirectException - from .exceptions.problem_handling import autodiscover_problem_annotations - from .exceptions.problem_handling import ProblemExceptionHandlerGenerator - from .exceptions.problem_handling import ProblemExceptionMapper - from .exceptions.problem_handling_info import ProblemHandlingInfo - from .exception_handlers import RedirectExceptionHandler - from .pagination.page_processor_resolver import PageOutputProcessorResolver - from .pagination.page_position_argument_resolver import PagePositionArgumentResolver - from .path_parameters_argument_resolver import PathParametersArgumentResolver - from .query_parameters.query_parameter_argument_resolver import QueryParameterArgumentResolver - from .query_parameters.query_parameters_argument_resolver import QueryParametersArgumentResolver - from .request_argument_resolver import RequestArgumentResolver - from .request_header_resolver import RequestHeaderArgumentResolver - from .response_header_serializers import DateTimeResponseHeaderSerializer - from .response_header_serializers import LastModifiedResponseHeaderSerializer - - register_output_processor_resolver(PageOutputProcessorResolver()) - response_headers_serializer.add_serializer(DateTimeResponseHeaderSerializer()) - response_headers_serializer.add_serializer(LastModifiedResponseHeaderSerializer()) - arguments_resolver.add_argument_resolver(QueryParameterArgumentResolver()) - arguments_resolver.add_argument_resolver(QueryParametersArgumentResolver()) - arguments_resolver.add_argument_resolver(PathParametersArgumentResolver()) - arguments_resolver.add_argument_resolver(RequestBodyArgumentResolver()) - arguments_resolver.add_argument_resolver(ResponseHeaderArgumentResolver()) - arguments_resolver.add_argument_resolver(PagePositionArgumentResolver()) - arguments_resolver.add_argument_resolver(RequestHeaderArgumentResolver()) - arguments_resolver.add_argument_resolver(RequestArgumentResolver()) - - exception_mapper = ProblemExceptionMapper() - exception_handler_generator = ProblemExceptionHandlerGenerator(exception_mapper) - autodiscover_problem_annotations(exception_handler_generator) - exception_handlers_registry.add_handler(RedirectException, RedirectExceptionHandler, auto_handle=True) diff --git a/winter/web/autodiscovery.py b/winter/web/autodiscovery.py index 70ff29ce..df161545 100644 --- a/winter/web/autodiscovery.py +++ b/winter/web/autodiscovery.py @@ -1,24 +1,18 @@ from typing import List +from winter.application import Application from winter.components import Component -from winter.application.module_discovery import get_all_classes -from winter.application.module_discovery import import_recursively from .routing import Route from .routing import get_route -def find_package_routes(package_name: str) -> List[Route]: - """Routes of the API classes in a package and its subpackages, or in a single module.""" - import_recursively(package_name) - routes = [] - - for class_name, cls in get_all_classes(package_name): - component = Component.get_by_cls(cls) - - for method in component.methods: - route = get_route(method) - - if route is not None: - routes.append(route) - - return routes +def find_routes(app: Application) -> List[Route]: + """Routes of the API classes among the definitions of an application.""" + components = Component.get_all() + return [ + route + for definition in app.definitions + if definition in components + for method in components[definition].methods + if (route := get_route(method)) is not None + ] diff --git a/winter/web/exceptions/problem_handling.py b/winter/web/exceptions/problem_handling.py index 1edf4e21..1b92d34b 100644 --- a/winter/web/exceptions/problem_handling.py +++ b/winter/web/exceptions/problem_handling.py @@ -4,6 +4,8 @@ from dataclasses import dataclass from dataclasses import is_dataclass from typing import Dict +from typing import Iterable +from typing import Set from typing import Type @@ -93,16 +95,17 @@ def _try_cut_exception_name_postfix(cls, exception_cls: Type[Exception]) -> str: return re.sub('Exception$', '', exception_cls.__name__) -def autodiscover_problem_annotations(handler_generator: ProblemExceptionHandlerGenerator): - handled_problems: Dict[Type[Exception], ProblemAnnotation] = { - cls: component.annotations.get_one(ProblemAnnotation) - for cls, component in Component.get_all().items() - if component.annotations.get_one_or_none(ProblemAnnotation) - } - for exception_class, problem_annotation in handled_problems.items(): - handler_class = handler_generator.generate(exception_class, problem_annotation.handling_info) - exception_handlers_registry.add_handler( - exception_class, - handler_class, - auto_handle=True, - ) +_problems_with_handlers: Set[Type[Exception]] = set() + + +def add_problem_handlers(definitions: Iterable, handler_generator: ProblemExceptionHandlerGenerator): + """Registers a handler for each @problem exception among the definitions that has none yet.""" + components = Component.get_all() + for definition in definitions: + component = components.get(definition) + problem_annotation = component and component.annotations.get_one_or_none(ProblemAnnotation) + if problem_annotation is None or definition in _problems_with_handlers: + continue + handler_class = handler_generator.generate(definition, problem_annotation.handling_info) + exception_handlers_registry.add_handler(definition, handler_class, auto_handle=True) + _problems_with_handlers.add(definition) diff --git a/winter/web/initialization.py b/winter/web/initialization.py new file mode 100644 index 00000000..7ab4c5cf --- /dev/null +++ b/winter/web/initialization.py @@ -0,0 +1,64 @@ +from winter.application import Application +from .argument_resolver import arguments_resolver +from .exception_handlers import RedirectExceptionHandler +from .exceptions import RedirectException +from .exceptions import RequestDataDecodeException +from .exceptions import ThrottleException +from .exceptions import UnsupportedMediaTypeException +from .exceptions import exception_handlers_registry +from .exceptions.problem_handling import ProblemExceptionHandlerGenerator +from .exceptions.problem_handling import ProblemExceptionMapper +from .exceptions.problem_handling import add_problem_handlers +from .output_processor import register_output_processor_resolver +from .pagination.limits import MaximumLimitValueExceeded +from .pagination.page_position_argument_resolver import PagePositionArgumentResolver +from .pagination.page_processor_resolver import PageOutputProcessorResolver +from .path_parameters_argument_resolver import PathParametersArgumentResolver +from .query_parameters.query_parameter_argument_resolver import QueryParameterArgumentResolver +from .query_parameters.query_parameters_argument_resolver import QueryParametersArgumentResolver +from .request_argument_resolver import RequestArgumentResolver +from .request_body_resolver import RequestBodyArgumentResolver +from .request_header_resolver import RequestHeaderArgumentResolver +from .response_header_resolver import ResponseHeaderArgumentResolver +from .response_header_serializer import response_headers_serializer +from .response_header_serializers import DateTimeResponseHeaderSerializer +from .response_header_serializers import LastModifiedResponseHeaderSerializer + +_problem_handler_generator = ProblemExceptionHandlerGenerator(ProblemExceptionMapper()) +_process_initialized = False + + +def initialize(app: Application): + """ + Prepares winter.web to handle the requests of an application. Adapters call it before building their routes. + + The registries are still process-global: the standard resolvers are registered on the first call, + the handlers of the application's @problem exceptions on every call. + """ + global _process_initialized + if not _process_initialized: + _initialize_process() + _process_initialized = True + add_problem_handlers(app.definitions, _problem_handler_generator) + + +def _initialize_process(): + register_output_processor_resolver(PageOutputProcessorResolver()) + response_headers_serializer.add_serializer(DateTimeResponseHeaderSerializer()) + response_headers_serializer.add_serializer(LastModifiedResponseHeaderSerializer()) + arguments_resolver.add_argument_resolver(QueryParameterArgumentResolver()) + arguments_resolver.add_argument_resolver(QueryParametersArgumentResolver()) + arguments_resolver.add_argument_resolver(PathParametersArgumentResolver()) + arguments_resolver.add_argument_resolver(RequestBodyArgumentResolver()) + arguments_resolver.add_argument_resolver(ResponseHeaderArgumentResolver()) + arguments_resolver.add_argument_resolver(PagePositionArgumentResolver()) + arguments_resolver.add_argument_resolver(RequestHeaderArgumentResolver()) + arguments_resolver.add_argument_resolver(RequestArgumentResolver()) + framework_problems = [ + ThrottleException, + RequestDataDecodeException, + UnsupportedMediaTypeException, + MaximumLimitValueExceeded, + ] + add_problem_handlers(framework_problems, _problem_handler_generator) + exception_handlers_registry.add_handler(RedirectException, RedirectExceptionHandler, auto_handle=True) From 88527fb3b3bd925b47aa7bd0dfa11f7219f387ce Mon Sep 17 00:00:00 2001 From: Alexander Egorov Date: Sat, 24 Oct 2026 15:33:39 +0200 Subject: [PATCH 2/3] docs: state how the initialize functions move into Application The adapters initialise winter.web, the Django adapter and winter.openapi once per process. The architecture document now lists this as a deviation and describes the target: each Application owns these registries in a child injector, adapters add their entries to that application only, and the initialize functions disappear. Each of them points to that deviation. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 9 +++++++++ winter/django/view.py | 1 + winter/openapi/initialization.py | 5 ++++- winter/web/initialization.py | 5 +++-- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 56673a8e..6ac670a9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -85,6 +85,15 @@ at least two of them. are module singletons, and the application installs its injector as the service locator `winter.application.get_injector`. `tests/test_application_scope.py` states the intended behaviour and is marked `xfail` until an `Application` instance owns that state. +- **Models are initialised once per process.** `winter.web.initialization.initialize`, the Django + adapter's `_initialize_process` and `winter.openapi.initialization.initialize` fill those module + singletons with the standard resolvers, inspectors and handlers on first use, and add the handlers + of each application's `@problem` exceptions to the same global registry. In the target, each + `Application` owns these registries in an injector of its own, a child of the injector it is given, + and they start with the standard entries. Building from an application, an adapter adds its own + entries (the Django request resolver, the native response type, the throttling identity) to that + application only, and the `@problem` handlers go to the application's registry. The `initialize` + functions then disappear. - **`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/winter/django/view.py b/winter/django/view.py index 1097b5fa..ff82e35e 100644 --- a/winter/django/view.py +++ b/winter/django/view.py @@ -36,6 +36,7 @@ def create_urls(app: Application) -> List[URLPattern]: def _initialize_process(): + """Process-global until the registries move into Application: docs/architecture.md, Current deviations.""" global _process_initialized if not _process_initialized: arguments_resolver.add_argument_resolver(HttpRequestArgumentResolver()) diff --git a/winter/openapi/initialization.py b/winter/openapi/initialization.py index 957f3880..10b612ef 100644 --- a/winter/openapi/initialization.py +++ b/winter/openapi/initialization.py @@ -16,7 +16,10 @@ def initialize(): - """Registers the standard inspectors and global exceptions once per process, before the first spec is generated.""" + """ + Registers the standard inspectors and global exceptions once per process, before the first spec is generated. + Process-global until the registries move into Application: docs/architecture.md, Current deviations. + """ global _initialized if _initialized: return diff --git a/winter/web/initialization.py b/winter/web/initialization.py index 7ab4c5cf..a0a57209 100644 --- a/winter/web/initialization.py +++ b/winter/web/initialization.py @@ -32,8 +32,9 @@ def initialize(app: Application): """ Prepares winter.web to handle the requests of an application. Adapters call it before building their routes. - The registries are still process-global: the standard resolvers are registered on the first call, - the handlers of the application's @problem exceptions on every call. + The standard resolvers are registered on the first call, the handlers of the application's @problem + exceptions on every call. + Process-global until the registries move into Application: docs/architecture.md, Current deviations. """ global _process_initialized if not _process_initialized: From b7abbfdd511374058bf8a69093b0380433339447 Mon Sep 17 00:00:00 2001 From: Alexander Egorov Date: Sat, 24 Oct 2026 21:10:54 +0200 Subject: [PATCH 3/3] refactor(web): keep find_routes for Winter's adapters, out of the public API Co-Authored-By: Claude Opus 5.5 --- tests/web/test_autodiscovery.py | 4 ++-- winter/django/view.py | 3 ++- winter/openapi/generator.py | 2 +- winter/web/__init__.py | 1 - 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/web/test_autodiscovery.py b/tests/web/test_autodiscovery.py index 90050fbc..041c7251 100644 --- a/tests/web/test_autodiscovery.py +++ b/tests/web/test_autodiscovery.py @@ -1,6 +1,6 @@ -import winter.web from winter.application import Application from winter.application import get_injector +from winter.web.autodiscovery import find_routes def test_find_routes(): @@ -9,7 +9,7 @@ def test_find_routes(): app = Application(modules=[tests.api.package], injector=get_injector()) # Act - routes = winter.web.find_routes(app) + routes = find_routes(app) # Assert assert [route.url_path for route in routes] == ['api_1/', 'api_2/', 'api_2/', 'api_3/'] diff --git a/winter/django/view.py b/winter/django/view.py index ff82e35e..0baa8e13 100644 --- a/winter/django/view.py +++ b/winter/django/view.py @@ -15,6 +15,7 @@ from winter.web import handle_request from winter.web import register_native_response_type from winter.web import set_throttling_identity_resolver +from winter.web.autodiscovery import find_routes from winter.web.routing import Route from winter.web.urls import rewrite_uritemplate_with_regexps from .http_request_argument_resolver import HttpRequestArgumentResolver @@ -29,7 +30,7 @@ def create_urls(app: Application) -> List[URLPattern]: """URL patterns of the routes of an application, relative to where they are included in the urlpatterns.""" winter.web.initialization.initialize(app) _initialize_process() - return _create_urls_from_routes(winter.web.find_routes(app)) + return _create_urls_from_routes(find_routes(app)) _process_initialized = False diff --git a/winter/openapi/generator.py b/winter/openapi/generator.py index 73ca5441..525c8ca6 100644 --- a/winter/openapi/generator.py +++ b/winter/openapi/generator.py @@ -37,7 +37,7 @@ from winter.web.request_body_annotation import RequestBodyAnnotation from winter.web.routing import Route from winter.web.routing import RouteAnnotation -from winter.web import find_routes +from winter.web.autodiscovery import find_routes from winter.openapi.inspection.inspection import inspect_type from .inspection import DataTypes from .inspection import TypeInfo diff --git a/winter/web/__init__.py b/winter/web/__init__.py index 6dcd662a..409df52e 100644 --- a/winter/web/__init__.py +++ b/winter/web/__init__.py @@ -3,7 +3,6 @@ from .argument_resolver import ArgumentResolver from .argument_resolver import ArgumentsResolver from .argument_resolver import arguments_resolver -from .autodiscovery import find_routes from .exceptions import ExceptionHandler from .exceptions import exception_handlers_registry from .exceptions import problem