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
50 changes: 49 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ urlpatterns = winter.django.create_urls(app)
The handler of the nearest class in the exception's MRO handles it; two handlers of one exception class in an
application fail on startup. A handler of the application replaces the built-in handler of the same exception
class: a handler of `Exception` replaces the default 500 response.
- Arguments come from the request or from the injector of the application, see
[Values from the injector](#values-from-the-injector). Custom argument resolvers are gone: `ArgumentResolver`,
`ArgumentsResolver`, `arguments_resolver`, `RequestBodyArgumentResolver`, `ResponseHeaderArgumentResolver`,
`winter.web.pagination.PagePositionArgumentResolver` and `winter.django.HttpRequestArgumentResolver`. A resolver
of a type becomes a provider of that type marked `@winter.web.request_scope`. An argument of an API method, an
interceptor or an exception handler that is neither mapped from the request nor bound in the injector fails on
startup instead of on the first request.
- An argument named `response_headers` of an exception handler no longer receives the headers of the response:
declare a `ResponseHeader[T]` with `@winter.web.response_header`, as in an API method.
- The request pipeline takes the injector from the application. `winter.application.set_injector` is needed only
by `winter.sqlalchemy`, `winter.ddd` and `winter.messaging`.
- `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
Expand Down Expand Up @@ -328,7 +339,8 @@ class HelloWorldInterceptor(Interceptor):

The only way now to register an interceptor is to add it to InterceptorRegistry singleton.

Don't forget to import this adding during app initialization.
Don't forget to import this adding during app initialization, before the URL patterns are created: they check
the arguments of the registered interceptors on startup.
```python
from winter.web import interceptor_registry
from .interceptors import HelloWorldInterceptor
Expand All @@ -339,6 +351,42 @@ class SomeInitializationClassInMyApplication:

```

## Values from the injector
An argument that is not mapped from the request (path and query parameters, `@request_body`, `@request_header`,
`@response_header`, `PagePosition`) comes from the injector of the application, which must bind its type:
otherwise the application fails on startup. The dependencies of the class itself go to `__init__`, where the
injector creates them without bindings. A provider marked `@winter.web.request_scope` is called once per request,
and its value exists only while Winter handles a request. Every request binds `winter.web.Request` and, under
Django, `django.http.HttpRequest`.
```python
import injector
import winter.web
from django.http import HttpRequest
from winter.application import Application


class AccountModule(injector.Module):
@injector.provider
@winter.web.request_scope
def account(self, request: HttpRequest, accounts: AccountQueries) -> Account:
return accounts.get(request.user.pk)


class ProfileAPI:
@injector.inject
def __init__(self, profiles: ProfileQueries):
self._profiles = profiles

@winter.web.route_get('profile/')
def get(self, account: Account) -> ProfileDTO:
return self._profiles.get(account)


app = Application(modules=[profiles], injector=injector.Injector([AccountModule()]))
```

Interceptors and exception handlers of a request get the same request-scoped values as its API method.

## Undefined JSON fields

By default, if a JSON request contains a field that is not defined in a dataclass, an exception will be thrown.
Expand Down
29 changes: 16 additions & 13 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,9 @@ at least two of them.
subpackage can later ship as its own distribution from its own repository without any
import path changing.
2. **Framework behaviour is never process-global.** Every behavioural registry (exception
handlers, interceptors, argument resolvers, throttling configuration, OpenAPI inspectors)
belongs to an `Application` instance, so two applications can live in one process.
handlers, interceptors, output processors, throttling configuration, OpenAPI inspectors)
belongs to an `Application` instance, and the values that its methods receive come from its
injector, so two applications can live in one process.
Declarative metadata about types (component annotations, JSON encoders for a type) may be
global, like the classes it describes.
3. **Glue between models, or between adapters, is application code.** Mapping a domain
Expand All @@ -81,18 +82,19 @@ at least two of them.
## Current deviations

- **Framework behaviour is still process-global.** An `Application` holds the code of an
application and its injector, and the adapters derive its exception handlers from its definitions.
The other behavioural registries of `winter.web` and `winter.openapi` (interceptors, argument
resolvers, output processors, response header serializers, native response types, throttling,
OpenAPI inspectors) are module singletons, and the application installs its injector as the
service locator `winter.application.get_injector`.
application and its injector. The adapters derive its exception handlers from its definitions and
handle each of its requests in a child of its injector. The other behavioural registries of
`winter.web` and `winter.openapi` (interceptors, output processors, response header serializers,
native response types, throttling, OpenAPI inspectors) are module singletons, and
`winter.sqlalchemy`, `winter.ddd` and `winter.messaging` take the injector from the service
locator `winter.application.get_injector`.
- **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 and inspectors on first use. 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. The `initialize` functions then disappear.
singletons with the standard output processors, serializers and inspectors on first use. 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 native response type, the throttling identity) to that application only. 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
Expand All @@ -106,6 +108,7 @@ at least two of them.
There are no `ignore_imports`, and none may be added.
- **Isolation test** `tests/test_application_scope.py` 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. Each registry that moves into `Application` adds its case there.
the other, and that the arguments of an API method come from the injector of the application
that serves it. Each registry that moves into `Application` adds its case there.
- **This document** describes intent and is updated when the intent changes, not when the code
moves. Deviations are listed above while they exist.
19 changes: 0 additions & 19 deletions tests/api/api_with_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,6 @@ def __init__(self, message: str):
self.message = message


class WithUnknownArgumentException(Exception):
pass


class ExceptionWithoutHandler(Exception):
pass

Expand All @@ -29,15 +25,6 @@ class ExceptionHandlers:
def handle_custom(self, exception: CustomException) -> CustomExceptionDTO:
return CustomExceptionDTO(exception.message)

@winter.web.exception_handler
@response_status(400)
def handle_with_unknown_argument(
self,
exception: WithUnknownArgumentException,
unknown_argument: int,
) -> str: # pragma: no cover
pass


class ChildCustomException(CustomException):
pass
Expand Down Expand Up @@ -69,9 +56,3 @@ def not_declared_but_thrown(self) -> str:
@raises(ExceptionWithoutHandler)
def declared_but_no_handler(self) -> str:
raise ExceptionWithoutHandler()

@raises(WithUnknownArgumentException)
@raises(CustomException) # second "raises" just for 100% test coverage
@route_get('with_unknown_argument_exception/')
def with_unknown_argument_handler(self) -> str:
raise WithUnknownArgumentException()
4 changes: 0 additions & 4 deletions tests/api/api_with_response_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,3 @@ def two_headers(self, header1: ResponseHeader[str], header2: ResponseHeader[str]
header1.set('header1')
header2.set('header2')
return 'OK'

@winter.web.route_get('header-without-annotation/')
def header_without_annotation(self, header: ResponseHeader[str]) -> str: # pragma: no cover
pass
13 changes: 2 additions & 11 deletions tests/application/test_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,6 @@

from winter.application import Application
from winter.application import get_injector
from winter.application import set_injector


@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)


class Defined:
Expand Down Expand Up @@ -103,13 +94,13 @@ def test_add():
assert app.definitions == (Defined, defined)


def test_new_injector_is_installed():
def test_new_injector_is_not_installed_globally():
# Act
app = Application(modules=[])

# Assert
assert isinstance(app.injector, Injector)
assert get_injector() is app.injector
assert get_injector() is not app.injector


def test_given_injector_is_shared():
Expand Down
5 changes: 3 additions & 2 deletions tests/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ class TestAppConfig(AppConfig):
name = 'tests'

def ready(self):
# Before the URL patterns, which check the arguments of the interceptors on startup
interceptor_registry.add_interceptor(HelloWorldInterceptor())

# Importing the URLs creates the application and its URL patterns
from .urls import urlpatterns # noqa: F401

interceptor_registry.add_interceptor(HelloWorldInterceptor())

# The Redis storage is covered in tests/redis; the sample application throttles in memory.
winter.web.set_throttling_storage(InMemoryThrottlingStorage())
34 changes: 33 additions & 1 deletion tests/test_application_scope.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
"""
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.
does not handle that exception in another, and the arguments of an API method come from the injector of the
application that serves it.
"""
from http import HTTPStatus

from django.test import RequestFactory
from injector import Injector

import winter.django
import winter.web
Expand Down Expand Up @@ -37,3 +39,33 @@ def test_problem_handler_of_one_application_does_not_handle_exceptions_in_anothe

# Assert
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR


class _Name:
def __init__(self, value: str):
self.value = value


class _NameAPI:
@winter.web.route_get('name/')
def get(self, name: _Name) -> str:
return name.value


def _application_with_name(value: str) -> Application:
app = Application(modules=[], injector=Injector([lambda binder: binder.bind(_Name, to=_Name(value))]))
app.add(_NameAPI)
return app


def test_argument_of_api_method_comes_from_injector_of_its_application():
[url_1] = winter.django.create_urls(_application_with_name('one'))
[url_2] = winter.django.create_urls(_application_with_name('two'))

# Act
response_1 = url_1.callback(RequestFactory().get('/name/'))
response_2 = url_2.callback(RequestFactory().get('/name/'))

# Assert
assert response_1.content == b'"one"'
assert response_2.content == b'"two"'
Loading
Loading