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..6ac670a9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,10 +80,20 @@ 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. +- **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 @@ -95,8 +105,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..041c7251 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 +from winter.web.autodiscovery import find_routes -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 = 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..0baa8e13 100644 --- a/winter/django/view.py +++ b/winter/django/view.py @@ -8,17 +8,45 @@ 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.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 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(find_routes(app)) + + +_process_initialized = False + + +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()) + 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..525c8ca6 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.autodiscovery 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..10b612ef --- /dev/null +++ b/winter/openapi/initialization.py @@ -0,0 +1,34 @@ +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. + Process-global until the registries move into 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/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..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_package_routes from .exceptions import ExceptionHandler from .exceptions import exception_handlers_registry from .exceptions import problem @@ -42,38 +41,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..a0a57209 --- /dev/null +++ b/winter/web/initialization.py @@ -0,0 +1,65 @@ +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 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: + _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)