Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 61 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
6 changes: 0 additions & 6 deletions tests/application/test_injection.py

This file was deleted.

23 changes: 0 additions & 23 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion tests/ddd/test_domain_event_dispatcher.py
Original file line number Diff line number Diff line change
@@ -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()
Expand Down
5 changes: 3 additions & 2 deletions tests/ddd/test_process_domain_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from typing import Union

import pytest
from injector import Injector

from winter.components import ComponentMethod
from winter.ddd import DomainEvent
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions tests/injection.py
Original file line number Diff line number Diff line change
@@ -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])
4 changes: 2 additions & 2 deletions tests/messaging/test_simple_event_publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions tests/openapi/test_exception_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions tests/openapi/test_serving.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions tests/openapi/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__}'

Expand Down Expand Up @@ -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)

Expand Down
10 changes: 5 additions & 5 deletions tests/sqlalchemy/test_sqla_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)))
Expand Down
6 changes: 3 additions & 3 deletions tests/test_application_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions tests/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
4 changes: 2 additions & 2 deletions tests/web/test_autodiscovery.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
4 changes: 2 additions & 2 deletions tests/web/test_exception_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading