diff --git a/README.md b/README.md index ca4a4377..5875e1de 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,13 @@ urlpatterns = winter.django.create_urls(app) (`@winter.json.json_encoder` adds the encoder of a type), or a `winter.web.Response`, e.g. a rendered template: `Response(HTTPStatus.OK, render_to_string('report.html', context).encode(), {'content-type': 'text/html'})`. - The `meta` of a `Page` has no `previous` and `next` links any more, and `winter.web.pagination.utils` with - `get_previous_page_url` and `get_next_page_url` is gone. -- The request pipeline takes the injector from the application. `winter.application.set_injector` is needed only - by `winter.sqlalchemy`, `winter.ddd` and `winter.messaging`. + `get_previous_page_url` and `get_next_page_url` is gone. A subclass of `Page` adds them where clients need them, + see [Extending Page class](#extending-page-class). +- `winter.application.set_injector` and `get_injector` are gone: nothing in Winter takes the injector from a global. + The request pipeline takes it from the application, and `winter.messaging`, `winter.ddd` and `winter.sqlalchemy` + from the injector that creates their objects. `DomainEventDispatcher` takes the injector that creates the handlers: + `DomainEventDispatcher(injector)`, or let the injector create it. Code that called `get_injector()` declares an + `injector: Injector` argument of its `@inject` constructor instead. - Throttling counts in the storage that the injector of the application binds, e.g. `binder.bind(ThrottlingStorage, to=RedisThrottlingStorage(configuration))`; an application with `@throttling` and no storage fails on startup. The Django adapter throttles by the Django user, by the client address otherwise. @@ -276,6 +280,60 @@ class Example: ) ``` +The fields of a subclass go to the meta of the JSON and of the OpenAPI spec, so a subclass can add links +to the previous and the next pages, built from the request: +```python +from dataclasses import dataclass +from typing import Generic +from typing import Optional +from typing import TypeVar + +from furl import furl + +import winter.web +from winter.pagination import Page +from winter.pagination import PagePosition + + +T = TypeVar('T') + +@dataclass(frozen=True) +class LinkedPage(Page, Generic[T]): + previous: Optional[str] + next: Optional[str] + + @classmethod + def of(cls, page: Page[T], request: winter.web.Request) -> 'LinkedPage[T]': + limit = page.position.limit + offset = page.position.offset or 0 + has_previous = limit is not None and offset > 0 + has_next = limit is not None and offset + limit < page.total_count + return cls( + total_count=page.total_count, + items=page.items, + position=page.position, + previous=_page_url(request, limit, max(offset - limit, 0)) if has_previous else None, + next=_page_url(request, limit, offset + limit) if has_next else None, + ) + + +def _page_url(request: winter.web.Request, limit: int, offset: int) -> str: + url = furl(request.full_path) + url.args['limit'] = limit + if offset: + url.args['offset'] = offset + else: + url.args.pop('offset', None) + return url.tostr() + + +class TodoAPI: + @winter.web.route_get('todos/') + def get_todos(self, request: winter.web.Request, page_position: PagePosition) -> LinkedPage[int]: + page = Page(total_count=3, items=[1, 2, 3], position=page_position) + return LinkedPage.of(page, request) +``` + ## Exception handling ```python diff --git a/docs/architecture.md b/docs/architecture.md index 61b82a41..7ee0bc63 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -57,7 +57,7 @@ independent of each other. Dashed packages are planned and do not exist yet. |---|---|---| | Adapters | Bind the models to external technologies and standards. Independent of each other. | `django`, `openapi`, `sqlalchemy`, `redis`, `wsgi` (planned), `asgi` (planned), `rabbitmq` (planned) | | Models | Framework-agnostic descriptions of what an application does. Independent of each other. | `web` (HTTP), `ddd` (domain: aggregates, repositories, domain events), `messaging` (events) | -| Foundations | Shared vocabulary and mechanisms. Unaware of the layers above. May use each other. | `components` (declarative metadata), `application` (discovery of definitions, injector), `json`, `pagination`, `typing` (what the stdlib `typing` lacks) | +| Foundations | Shared vocabulary and mechanisms. Unaware of the layers above. May use each other. | `components` (declarative metadata), `application` (discovery of definitions), `json`, `pagination`, `typing` (what the stdlib `typing` lacks) | A package belongs to Foundations when it knows nothing about models or adapters and is used by at least two of them. @@ -82,9 +82,6 @@ at least two of them. ## Current deviations -- **`winter.sqlalchemy`, `winter.ddd` and `winter.messaging` take the injector from a service - locator,** `winter.application.get_injector`, instead of being given the injector of an - application. - **`winter.wsgi`, `winter.asgi` and `winter.rabbitmq` do not exist yet.** HTTP is served through `winter.django` only, and `winter.messaging` has no transport adapter. - **All dependencies are mandatory.** Django, SQLAlchemy, Redis and pydantic are installed with diff --git a/tests/application/test_injection.py b/tests/application/test_injection.py deleted file mode 100644 index cf69174f..00000000 --- a/tests/application/test_injection.py +++ /dev/null @@ -1,6 +0,0 @@ -from winter.application import get_injector - - -def test_get_injector(): - injector = get_injector() - assert injector is not None diff --git a/tests/conftest.py b/tests/conftest.py index 052e9547..df30327e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,15 +1,5 @@ import django import pytest -from injector import CallableProvider -from injector import Injector -from injector import Module -from injector import singleton -from sqlalchemy import create_engine -from sqlalchemy.engine import Engine - -from winter.application import set_injector -from winter.web import InMemoryThrottlingStorage -from winter.web import ThrottlingStorage def pytest_configure(): @@ -38,22 +28,9 @@ def pytest_configure(): ], SECRET_KEY='Test secret key', ) - injector = Injector([Configuration]) - set_injector(injector) django.setup() -class Configuration(Module): - def configure(self, binder): - binder.bind(Engine, to=CallableProvider(make_engine), scope=singleton) - # The Redis storage is covered in tests/redis; the sample application throttles in memory - binder.bind(ThrottlingStorage, to=InMemoryThrottlingStorage, scope=singleton) - - -def make_engine(): - return create_engine('sqlite://') - - @pytest.fixture(scope='session') def wsgi(): from django.core.wsgi import get_wsgi_application diff --git a/tests/ddd/test_domain_event_dispatcher.py b/tests/ddd/test_domain_event_dispatcher.py index d80563db..cdfd1084 100644 --- a/tests/ddd/test_domain_event_dispatcher.py +++ b/tests/ddd/test_domain_event_dispatcher.py @@ -1,10 +1,12 @@ +from injector import Injector + from winter.ddd import DomainEventDispatcher from tests.ddd.test_domain_event_dispatcher_fixture.events import DomainEvent1 from tests.ddd.test_domain_event_dispatcher_fixture.events import DomainEvent2 def test_add_handlers_from_package(): - dispatcher = DomainEventDispatcher() + dispatcher = DomainEventDispatcher(Injector()) dispatcher.add_handlers_from_package('tests.ddd.test_domain_event_dispatcher_fixture') event1 = DomainEvent1() event2 = DomainEvent2() diff --git a/tests/ddd/test_process_domain_events.py b/tests/ddd/test_process_domain_events.py index e092805c..989c1601 100644 --- a/tests/ddd/test_process_domain_events.py +++ b/tests/ddd/test_process_domain_events.py @@ -2,6 +2,7 @@ from typing import Union import pytest +from injector import Injector from winter.components import ComponentMethod from winter.ddd import DomainEvent @@ -109,7 +110,7 @@ def test_process_domain_events(): another_domain_event = AnotherCustomEvent() yet_another_domain_event = YetAnotherCustomEvent() - dispatcher = DomainEventDispatcher() + dispatcher = DomainEventDispatcher(Injector()) dispatcher.add_handlers_from_class(_TestHandler) # Act @@ -138,7 +139,7 @@ def test_order_process_domain_events(): OrderHandler2.handled_domain_events = handled_events OrderHandler3.handled_domain_events = handled_events - dispatcher = DomainEventDispatcher() + dispatcher = DomainEventDispatcher(Injector()) dispatcher.add_handlers_from_class(OrderHandler1) dispatcher.add_handlers_from_class(OrderHandler2) dispatcher.add_handlers_from_class(OrderHandler3) diff --git a/tests/injection.py b/tests/injection.py new file mode 100644 index 00000000..91ae9526 --- /dev/null +++ b/tests/injection.py @@ -0,0 +1,24 @@ +from injector import CallableProvider +from injector import Injector +from injector import Module +from injector import singleton +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine + +from winter.web import InMemoryThrottlingStorage +from winter.web import ThrottlingStorage + + +class Configuration(Module): + def configure(self, binder): + binder.bind(Engine, to=CallableProvider(make_engine), scope=singleton) + # The Redis storage is covered in tests/redis; the sample application throttles in memory + binder.bind(ThrottlingStorage, to=InMemoryThrottlingStorage, scope=singleton) + + +def make_engine(): + return create_engine('sqlite://') + + +# The injector of the sample application in tests/urls.py, which the tests share +app_injector = Injector([Configuration]) diff --git a/tests/messaging/test_simple_event_publisher.py b/tests/messaging/test_simple_event_publisher.py index c7f34bb3..c75a4519 100644 --- a/tests/messaging/test_simple_event_publisher.py +++ b/tests/messaging/test_simple_event_publisher.py @@ -2,10 +2,10 @@ from typing import Union from injector import ClassProvider +from injector import Injector from injector import singleton from winter.components import annotate -from winter.application import get_injector from winter.messaging import EventSubscriptionRegistry from winter.messaging import SimpleEventPublisher from winter.messaging import event_handler @@ -76,7 +76,7 @@ def handler9(self, events: list[Event1 | Event3]): def test_simple_event_publisher(): - injector = get_injector() + injector = Injector() injector.binder.bind(EventHandlers, to=ClassProvider(EventHandlers), scope=singleton) injector.binder.bind(EventSubscriptionRegistry, to=ClassProvider(EventSubscriptionRegistry), scope=singleton) diff --git a/tests/openapi/test_exception_spec.py b/tests/openapi/test_exception_spec.py index 5d271ec2..e9de06c9 100644 --- a/tests/openapi/test_exception_spec.py +++ b/tests/openapi/test_exception_spec.py @@ -5,7 +5,7 @@ import winter.openapi import winter.web -from winter.application import get_injector +from tests.injection import app_injector from winter.web.routing import get_route from winter.openapi.generator import generate_openapi_for_routes from winter.openapi.annotations import global_exception @@ -105,7 +105,7 @@ class _TestAPI: def get_resource(self): # pragma: no cover pass - app = winter.web.WebApplication(modules=[], injector=get_injector()) + app = winter.web.WebApplication(modules=[], injector=app_injector) app.add(_Handlers) app.add(_TestAPI) diff --git a/tests/openapi/test_serving.py b/tests/openapi/test_serving.py index 732654bf..a795a5cd 100644 --- a/tests/openapi/test_serving.py +++ b/tests/openapi/test_serving.py @@ -7,7 +7,7 @@ import winter.django import winter.openapi import winter.web -from winter.application import get_injector +from tests.injection import app_injector @winter.web.problem(status=HTTPStatus.CONFLICT) @@ -26,7 +26,7 @@ def get_orders(self) -> list[int]: # pragma: no cover def _application(*definitions) -> winter.web.WebApplication: - app = winter.web.WebApplication(modules=[], injector=get_injector()) + app = winter.web.WebApplication(modules=[], injector=app_injector) for definition in definitions: app.add(definition) return app diff --git a/tests/openapi/test_validators.py b/tests/openapi/test_validators.py index 22501e5b..4865839e 100644 --- a/tests/openapi/test_validators.py +++ b/tests/openapi/test_validators.py @@ -4,7 +4,7 @@ import winter.openapi import winter.web -from winter.application import get_injector +from tests.injection import app_injector from winter.web import problem from winter.web.routing import get_route from winter.openapi.generator import generate_openapi_for_routes @@ -15,7 +15,7 @@ def test_validate_missing_raises_annotations_with_missed_raises_and_not_global_e @problem(HTTPStatus.BAD_REQUEST) class MissingException(Exception): pass - app = winter.web.WebApplication(modules=[], injector=get_injector()) + app = winter.web.WebApplication(modules=[], injector=app_injector) app.add(MissingException) expected_error_message = f'You are missing declaration for next exceptions: {MissingException.__name__}' @@ -59,7 +59,7 @@ def endpoint(self): # pragma: no cover def not_a_component(): # pragma: no cover pass - app = winter.web.WebApplication(modules=[], injector=get_injector()) + app = winter.web.WebApplication(modules=[], injector=app_injector) for definition in [DeclaredException, GlobalException, _TestAPI, not_a_component]: app.add(definition) diff --git a/tests/sqlalchemy/test_sqla_crud.py b/tests/sqlalchemy/test_sqla_crud.py index 5bb083a2..23368002 100644 --- a/tests/sqlalchemy/test_sqla_crud.py +++ b/tests/sqlalchemy/test_sqla_crud.py @@ -5,6 +5,7 @@ import pytest import sqlalchemy as sa from injector import ClassProvider +from injector import Injector from injector import inject from sqlalchemy import Column from sqlalchemy import Integer @@ -14,7 +15,7 @@ from sqlalchemy.engine import Engine from sqlalchemy.orm import mapper -from winter.application import get_injector +from tests.injection import app_injector from winter.ddd import CRUDRepository from winter.ddd.exceptions import NotFoundException from winter.ddd import AggregateRoot @@ -97,14 +98,13 @@ def on_marked_as_done(self, event: MarkedAsDoneDomainEvent): @pytest.fixture() def fixture(): - return get_injector().get(Fixture) + return app_injector.get(Fixture) class Fixture: @inject - def __init__(self, engine: Engine): - injector = get_injector() - domain_event_dispatcher = DomainEventDispatcher() + def __init__(self, engine: Engine, injector: Injector): + domain_event_dispatcher = DomainEventDispatcher(injector) domain_event_dispatcher.add_handlers_from_class(DomainEventHandlers) injector.binder.bind(DomainEventDispatcher, domain_event_dispatcher) injector.binder.bind(MyRepository, to=ClassProvider(sqla_crud(MyRepository))) diff --git a/tests/test_application_scope.py b/tests/test_application_scope.py index fcc3fbb2..651796d6 100644 --- a/tests/test_application_scope.py +++ b/tests/test_application_scope.py @@ -12,7 +12,7 @@ import winter.django import winter.web -from winter.application import get_injector +from tests.injection import app_injector @winter.web.problem(status=HTTPStatus.CONFLICT) @@ -27,9 +27,9 @@ def raise_a(self): def test_problem_handler_of_one_application_does_not_handle_exceptions_in_another(): - app_1 = winter.web.WebApplication(modules=[], injector=get_injector()) + app_1 = winter.web.WebApplication(modules=[], injector=app_injector) app_1.add(_ErrorA) - app_2 = winter.web.WebApplication(modules=[], injector=get_injector()) + app_2 = winter.web.WebApplication(modules=[], injector=app_injector) app_2.add(_API) winter.django.create_urls(app_1) [url] = winter.django.create_urls(app_2) diff --git a/tests/urls.py b/tests/urls.py index 5c0d83c2..e0eb1172 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -3,11 +3,11 @@ import tests.api import winter.django import winter.web -from winter.application import get_injector +from tests.injection import app_injector from winter.ddd import NotFoundException from tests.web.interceptors import HelloWorldInterceptor -app = winter.web.WebApplication(modules=[tests.api], injector=get_injector(), interceptors=[HelloWorldInterceptor]) +app = winter.web.WebApplication(modules=[tests.api], injector=app_injector, interceptors=[HelloWorldInterceptor]) # 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 f422146f..32324a3b 100644 --- a/tests/web/test_autodiscovery.py +++ b/tests/web/test_autodiscovery.py @@ -1,12 +1,12 @@ import winter.web -from winter.application import get_injector +from tests.injection import app_injector from winter.web.autodiscovery import find_routes def test_find_routes(): import tests.api.package - app = winter.web.WebApplication(modules=[tests.api.package], injector=get_injector()) + app = winter.web.WebApplication(modules=[tests.api.package], injector=app_injector) # Act routes = find_routes(app) diff --git a/tests/web/test_exception_handlers.py b/tests/web/test_exception_handlers.py index aa3f152a..cb0a1e2d 100644 --- a/tests/web/test_exception_handlers.py +++ b/tests/web/test_exception_handlers.py @@ -5,7 +5,7 @@ import winter.django import winter.web -from winter.application import get_injector +from tests.injection import app_injector from winter.web.exceptions import RedirectException @@ -35,7 +35,7 @@ def raise_(self): def _get(*definitions): - app = winter.web.WebApplication(modules=[], injector=get_injector()) + app = winter.web.WebApplication(modules=[], injector=app_injector) for definition in definitions: app.add(definition) [url] = winter.django.create_urls(app) diff --git a/tests/web/test_web_application.py b/tests/web/test_web_application.py index 8a193c02..dcd8da54 100644 --- a/tests/web/test_web_application.py +++ b/tests/web/test_web_application.py @@ -2,7 +2,6 @@ from injector import Injector import winter.web -from winter.application import get_injector class _Defined: @@ -37,13 +36,14 @@ def test_add(): assert app.definitions == (_Defined, _defined) -def test_new_injector_is_not_installed_globally(): +def test_new_injector_is_own_injector_of_application(): # Act - app = winter.web.WebApplication(modules=[]) + app_1 = winter.web.WebApplication(modules=[]) + app_2 = winter.web.WebApplication(modules=[]) # Assert - assert isinstance(app.injector, Injector) - assert get_injector() is not app.injector + assert isinstance(app_1.injector, Injector) + assert app_1.injector is not app_2.injector def test_given_injector_is_shared(): diff --git a/winter/application/__init__.py b/winter/application/__init__.py index c004ca43..9ec7fa1f 100644 --- a/winter/application/__init__.py +++ b/winter/application/__init__.py @@ -1,4 +1,2 @@ from .definitions import Definition from .definitions import find_definitions -from .injection import get_injector -from .injection import set_injector diff --git a/winter/application/injection.py b/winter/application/injection.py deleted file mode 100644 index ac19b1e9..00000000 --- a/winter/application/injection.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import Optional - -from injector import Injector - -_injector: Optional[Injector] = None - - -def set_injector(injector: Injector) -> None: - global _injector - _injector = injector - - -def get_injector() -> Injector: - global _injector - return _injector diff --git a/winter/ddd/domain_event_dispatcher.py b/winter/ddd/domain_event_dispatcher.py index 7b1525de..e7b85b42 100644 --- a/winter/ddd/domain_event_dispatcher.py +++ b/winter/ddd/domain_event_dispatcher.py @@ -4,9 +4,11 @@ from typing import List from typing import Type +from injector import Injector +from injector import inject + from winter.components import Component from winter.components import ComponentMethod -from winter.application import get_injector from winter.application.module_discovery import get_all_classes from winter.application.module_discovery import import_recursively from .domain_event import DomainEvent @@ -15,7 +17,10 @@ class DomainEventDispatcher: - def __init__(self): + @inject + def __init__(self, injector: Injector): + """The handlers of the events are created by the injector.""" + self._injector = injector self.event_type_to_subscription_map: Dict[Type[DomainEvent], List[DomainEventSubscription]] = {} def add_handler(self, handler: ComponentMethod): @@ -43,10 +48,8 @@ def dispatch(self, events: Iterable[DomainEvent]): for domain_event_subscription in domain_event_subscriptions: events_grouped_by_subscription.setdefault(domain_event_subscription, []).append(event) - injector = get_injector() - for domain_event_subscription, events in events_grouped_by_subscription.items(): - handler_instance = injector.get(domain_event_subscription.handler_class) + handler_instance = self._injector.get(domain_event_subscription.handler_class) if domain_event_subscription.collection: self._execute_handler(domain_event_subscription.handler_method, handler_instance, events) else: diff --git a/winter/messaging/event_dispacher.py b/winter/messaging/event_dispacher.py index ad6652d5..48a2bc5c 100644 --- a/winter/messaging/event_dispacher.py +++ b/winter/messaging/event_dispacher.py @@ -2,9 +2,9 @@ from typing import Dict from typing import List +from injector import Injector from injector import inject -from winter.application import get_injector from .event import Event from .event_subscription import EventSubscription from .event_subscription_registry import EventSubscriptionRegistry @@ -12,16 +12,16 @@ class EventDispatcher: @inject - def __init__(self, subscription_registry: EventSubscriptionRegistry) -> None: + def __init__(self, subscription_registry: EventSubscriptionRegistry, injector: Injector) -> None: + """The handlers of the events are created by the injector that creates the dispatcher.""" self._subscription_registry = subscription_registry + self._injector = injector def dispatch(self, event: Event): self.dispatch_many([event]) def dispatch_many(self, events: List[Event]): events_grouped_by_subscription: Dict[EventSubscription, List[Event]] = {} - injector = get_injector() - for event in events: event_type = type(event) event_subscriptions = self._subscription_registry.get_subscriptions(event_type) @@ -30,7 +30,7 @@ def dispatch_many(self, events: List[Event]): events_grouped_by_subscription.setdefault(event_subscription, []).append(event) for event_subscription, events in events_grouped_by_subscription.items(): - handler_instance = injector.get(event_subscription.handler_method.component.component_cls) + handler_instance = self._injector.get(event_subscription.handler_method.component.component_cls) if event_subscription.collection: self._execute_handler(event_subscription.handler_method.func, handler_instance, events) diff --git a/winter/sqlalchemy/repository.py b/winter/sqlalchemy/repository.py index f39ba30d..dbc04704 100644 --- a/winter/sqlalchemy/repository.py +++ b/winter/sqlalchemy/repository.py @@ -3,6 +3,7 @@ from typing import Optional from typing import TypeVar +from injector import Injector from injector import inject from sqlalchemy import and_ from sqlalchemy import exists @@ -14,7 +15,6 @@ from sqlalchemy.orm import sessionmaker from sqlalchemy.orm.exc import UnmappedClassError -from winter.application import get_injector from winter.ddd import CRUDRepository from winter.ddd.exceptions import NotFoundException from winter.ddd import AggregateRoot @@ -166,8 +166,7 @@ def __init__(self, engine: Engine, domain_event_dispatcher: DomainEventDispatche class RepositoryImpl(DefaultCRUDRepositoryImpl, *repository_subclasses): @inject - def __init__(self): - injector = get_injector() + def __init__(self, injector: Injector): injector.call_with_injection(DefaultCRUDRepositoryImpl.__init__, self) for subclass in repository_subclasses: injector.call_with_injection(subclass.__init__, self)