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
56 changes: 40 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,25 @@ app = Application(modules=[service_a], injector=injector) # several application
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))`.
- An exception handler is a method marked with `@winter.web.exception_handler` whose first argument is
annotated with the exceptions it handles; the class is created by the injector. `ExceptionHandler`,
`exception_handlers_registry` with `add_handler` and `set_default_handler`, and the handler argument of
`@raises` are gone: `@raises(Exc)` only declares the response for OpenAPI.

```python
class FileStorageExceptionHandlers:
@winter.web.exception_handler
@winter.web.response_status(HTTPStatus.BAD_GATEWAY)
def handle_download(self, exception: FileStorageDownloadException) -> ErrorDTO:
...
```

- Exception handlers belong to applications: a handler, like a `@problem` exception, handles exceptions in the
applications whose definitions include it, in every endpoint, whether declared with `@raises` or not. Define it
in a scanned module or add it, e.g. `app.add(winter.web.problem(status=404)(NotFoundException))`.
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.
- `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 @@ -227,8 +244,6 @@ from dataclasses import dataclass
from http import HTTPStatus
from typing import List

from django.http import HttpRequest

import winter.web


Expand All @@ -249,32 +264,41 @@ class TodoNotFoundException(Exception):
class TodoNotFoundException(Exception):
invalid_index: int

# When we want to override global handler and customize response body. Expected output below:
# {index: 1, 'message': 'Access denied'}
# When we want to build the response body ourselves. Expected output below:
# {'index': 1, 'message': 'Access denied'}
class TodoAccessDeniedException(Exception):
def __init__(self, index: int):
self.index = index


@dataclass
class ErrorDTO:
index: int
message: str


class TodoNotFoundExceptionCustomHandler(winter.web.ExceptionHandler):
@winter.web.response_status(HTTPStatus.NOT_FOUND)
def handle(self, request: HttpRequest, exception: TodoNotFoundException) -> ErrorDTO:
return ErrorDTO(index=exception.invalid_index, message='Access denied')
class TodoExceptionHandlers:
# Handles the exception its first argument is annotated with, and its subclasses, everywhere in the
# application. The class is created by the injector. A handler of Exception replaces the default 500 response.
@winter.web.exception_handler
@winter.web.response_status(HTTPStatus.FORBIDDEN)
def handle_access_denied(self, exception: TodoAccessDeniedException) -> ErrorDTO:
return ErrorDTO(index=exception.index, message='Access denied')


todo_list: List[str] = []


class TodoProblemExistsExampleAPI:
@winter.web.route_get('global/{todo_index}/')
def get_todo_with_global_handling(self, todo_index: int):
@winter.web.route_get('problem/{todo_index}/')
@winter.web.raises(TodoNotFoundException) # Declares the response in the OpenAPI spec
def get_todo(self, todo_index: int):
raise TodoNotFoundException(invalid_index=todo_index)

@winter.web.route_get('custom/{todo_index}/')
@winter.web.raises(TodoNotFoundException, handler_cls=TodoNotFoundExceptionCustomHandler)
def get_todo_with_custom_handling(self, todo_index: int):
raise TodoNotFoundException(invalid_index=todo_index)
@winter.web.route_get('denied/{todo_index}/')
@winter.web.raises(TodoAccessDeniedException)
def get_denied_todo(self, todo_index: int):
raise TodoAccessDeniedException(index=todo_index)

```

Expand Down
22 changes: 10 additions & 12 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,19 +81,18 @@ at least two of them.
## Current deviations

- **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.
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`.
- **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
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, and the `@problem` handlers go to the application's registry. The `initialize`
functions then disappear.
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 @@ -105,9 +104,8 @@ 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.
- **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.
- **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.
- **This document** describes intent and is updated when the intent changes, not when the code
moves. Deviations are listed above while they exist.
29 changes: 9 additions & 20 deletions tests/api/api_with_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,28 +23,22 @@ class CustomExceptionDTO:
message: str


class CustomExceptionHandler(winter.web.ExceptionHandler):
class ExceptionHandlers:
@winter.web.exception_handler
@response_status(400)
def handle(self, exception: CustomException) -> CustomExceptionDTO:
def handle_custom(self, exception: CustomException) -> CustomExceptionDTO:
return CustomExceptionDTO(exception.message)


class AnotherExceptionHandler(winter.web.ExceptionHandler):
@response_status(401)
def handle(self, exception: CustomException) -> int:
return 21


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


winter.web.exception_handlers_registry.add_handler(CustomException, CustomExceptionHandler)
winter.web.exception_handlers_registry.add_handler(WithUnknownArgumentException, WithUnknownArgumentExceptionHandler)


class ChildCustomException(CustomException):
pass

Expand Down Expand Up @@ -76,11 +70,6 @@ def not_declared_but_thrown(self) -> str:
def declared_but_no_handler(self) -> str:
raise ExceptionWithoutHandler()

@raises(CustomException, AnotherExceptionHandler)
@route_get('exception_with_custom_handler/')
def with_custom_handler(self) -> str:
raise CustomException('message')

@raises(WithUnknownArgumentException)
@raises(CustomException) # second "raises" just for 100% test coverage
@route_get('with_unknown_argument_exception/')
Expand Down
25 changes: 1 addition & 24 deletions tests/api/api_with_problem_exceptions.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
from http import HTTPStatus
from typing import Dict

import dataclasses

import winter.web
from winter.ddd.exceptions import NotFoundException
from winter.web.exceptions import RequestDataDecodeException
from winter.web import route_get, route, raises, response_status
from winter.web import route_get, route, raises


@winter.web.problem(status=HTTPStatus.FORBIDDEN)
Expand Down Expand Up @@ -38,27 +37,10 @@ class AllFieldConstProblemExistsException(Exception):
pass


class ProblemExistsExceptionHandler(winter.web.ExceptionHandler):
@response_status(HTTPStatus.FORBIDDEN)
def handle(self, exception: ProblemExistsException, **kwargs) -> Dict: # pragma: no cover
pass


@dataclasses.dataclass
class CustomExceptionDTO:
message: str


class MyEntity:
pass


class ProblemExistsExceptionCustomHandler(winter.web.ExceptionHandler):
@response_status(HTTPStatus.BAD_REQUEST)
def handle(self, exception: ProblemExistsException) -> CustomExceptionDTO:
return CustomExceptionDTO(message=str(exception))


@route('with-problem-exceptions/')
class APIWithProblemExceptions:

Expand All @@ -81,11 +63,6 @@ def all_field_const_problem_exists_exception(self) -> str:
def problem_exists_auto_handle_exception(self) -> str:
raise InheritorOfProblemExistsException()

@raises(ProblemExistsException, ProblemExistsExceptionCustomHandler)
@route_get('custom_handler_problem_exists_exception/')
def custom_handler_problem_exists_exception(self) -> str:
raise ProblemExistsException()

@route_get('not_found_exception/')
def not_found_exception(self) -> str:
raise NotFoundException(entity_id=1, entity_cls=MyEntity)
Expand Down
15 changes: 3 additions & 12 deletions tests/api/api_with_throttling.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
from http import HTTPStatus

import winter.web
from winter.web import route_post, route_get, raises, response_status, ExceptionHandler
from winter.web import route_post, route_get
from winter.web import Request
from winter.web.exceptions import ThrottleException
from winter.web.throttling import reset


class CustomThrottleExceptionHandler(ExceptionHandler):
@response_status(HTTPStatus.TOO_MANY_REQUESTS)
def handle(self, exception: ThrottleException) -> str:
return 'custom throttle exception'


@route_get('with-throttling/')
class APIWithThrottling:

Expand All @@ -34,10 +26,9 @@ def same_simple_method(self) -> int:
def method_without_throttling(self):
pass

@route_get('custom-handler/')
@route_get('without-user/')
@winter.web.throttling('5/s')
@raises(ThrottleException, CustomThrottleExceptionHandler)
def simple_method_with_custom_handler(self) -> int:
def method_for_request_without_user(self) -> int:
return 1

@route_get('with-reset/{?is_reset}')
Expand Down
4 changes: 0 additions & 4 deletions tests/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@
from tests.web.interceptors import HelloWorldInterceptor
import winter.web
from winter.web import InMemoryThrottlingStorage
from winter.web import exception_handlers_registry
from winter.web import interceptor_registry
from winter.web.exceptions.handlers import DefaultExceptionHandler


class TestAppConfig(AppConfig):
Expand All @@ -19,5 +17,3 @@ def ready(self):

# The Redis storage is covered in tests/redis; the sample application throttles in memory.
winter.web.set_throttling_storage(InMemoryThrottlingStorage())

exception_handlers_registry.set_default_handler(DefaultExceptionHandler) # for 100% test coverage
26 changes: 14 additions & 12 deletions tests/openapi/test_exception_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@

import pytest

import winter.openapi
import winter.web
from winter.application import Application
from winter.application import get_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 @@ -38,7 +41,7 @@ def get_resource(self): # pragma: no cover
'deprecated': False,
'operationId': '_TestAPI.get_resource',
'parameters': [],
'responses': {'200': {'description': ''}},
'responses': {'200': {'description': ''}, '500': {'description': ''}},
'tags': ['resource'],
},
},
Expand Down Expand Up @@ -83,33 +86,32 @@ class ProblemExistsExceptionDTO:
(None, {'description': ''}, {'parameters': {}, 'responses': {}, 'schemas': {}}),
],
)
def test_exception_annotated_on_method_with_custom_handler_with_dto(
def test_response_of_raised_exception_is_that_of_its_handler_in_application(
handler_return_type,
handler_spec,
expected_components,
):
@winter.web.problem(status=HTTPStatus.FORBIDDEN)
class ProblemExistsException(Exception):
pass

class ProblemExistsExceptionHandler(winter.web.ExceptionHandler):
class _Handlers:
@winter.web.exception_handler
@winter.web.response_status(HTTPStatus.FORBIDDEN)
def handle(self, exception: ProblemExistsException, **kwargs) -> handler_return_type: # pragma: no cover
def handle(self, exception: ProblemExistsException) -> handler_return_type: # pragma: no cover
pass

class _TestAPI:
@winter.web.raises(ProblemExistsException, ProblemExistsExceptionHandler)
@winter.web.raises(ProblemExistsException)
@winter.web.route_get('resource')
def get_resource(self): # pragma: no cover
pass

route = get_route(_TestAPI.get_resource)
app = Application(modules=[], injector=get_injector())
app.add(_Handlers)
app.add(_TestAPI)

# Act
result = generate_openapi_for_routes(
title='title',
version='1.0.0',
routes=[route],
)
result = winter.openapi.generate_openapi(app, title='title', version='1.0.0')
# Assert
assert result == {
'components': expected_components,
Expand Down
9 changes: 1 addition & 8 deletions tests/test_application_scope.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
"""
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.
does not handle that exception in another.
"""
from http import HTTPStatus

import pytest
from django.test import RequestFactory

import winter.django
Expand All @@ -26,11 +24,6 @@ def raise_a(self):
raise _ErrorA()


@pytest.mark.xfail(
strict=True,
raises=AssertionError,
reason='Isolation: exception handlers are scoped to an Application instance',
)
def test_problem_handler_of_one_application_does_not_handle_exceptions_in_another():
app_1 = Application(modules=[], injector=get_injector())
app_1.add(_ErrorA)
Expand Down
Loading
Loading