snakeboot 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- snakeboot/__init__.py +12 -0
- snakeboot/component_scan/__init__.py +27 -0
- snakeboot/component_scan/application_root.py +56 -0
- snakeboot/component_scan/component_scan.py +104 -0
- snakeboot/django/__init__.py +5 -0
- snakeboot/django/django_atomic_transaction_manager.py +24 -0
- snakeboot/events/__init__.py +33 -0
- snakeboot/events/application_event_listener.py +30 -0
- snakeboot/events/application_event_publisher.py +80 -0
- snakeboot/events/application_event_publisher_injectable.py +24 -0
- snakeboot/events/event_listener_markers.py +41 -0
- snakeboot/py.typed +0 -0
- snakeboot/rest/__init__.py +47 -0
- snakeboot/rest/authenticated_principal.py +15 -0
- snakeboot/rest/controller_endpoint_factory.py +72 -0
- snakeboot/rest/error_answer.py +14 -0
- snakeboot/rest/rest_controller.py +25 -0
- snakeboot/rest/rest_controller_advice.py +38 -0
- snakeboot/rest/rest_controller_errors.py +14 -0
- snakeboot/rest/rest_controller_mappings.py +64 -0
- snakeboot/rest/rest_controller_mounter.py +121 -0
- snakeboot/rest/rest_controller_scanner.py +42 -0
- snakeboot/rest/route_markers.py +45 -0
- snakeboot/testing/__init__.py +12 -0
- snakeboot/testing/declared_classes.py +33 -0
- snakeboot/testing/recording_transaction_manager.py +62 -0
- snakeboot/transaction/__init__.py +17 -0
- snakeboot/transaction/after_commit.py +13 -0
- snakeboot/transaction/transaction_manager.py +25 -0
- snakeboot/transaction/transaction_manager_not_installed_error.py +6 -0
- snakeboot/transaction/transaction_manager_registry.py +36 -0
- snakeboot/transaction/transactional.py +32 -0
- snakeboot-0.1.2.dist-info/METADATA +144 -0
- snakeboot-0.1.2.dist-info/RECORD +36 -0
- snakeboot-0.1.2.dist-info/WHEEL +4 -0
- snakeboot-0.1.2.dist-info/licenses/LICENSE +21 -0
snakeboot/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""snakeboot: Spring Boot ergonomics for hexagonal Python applications.
|
|
2
|
+
|
|
3
|
+
snakeboot.transaction @transactional, after_commit, TransactionManager (no dependencies)
|
|
4
|
+
snakeboot.component_scan @application_root, ComponentScan (no dependencies)
|
|
5
|
+
snakeboot.events ApplicationEventPublisher, @transactional_event_listener (wireup)
|
|
6
|
+
snakeboot.rest @rest_controller, @route, @rest_controller_advice (extra: ninja)
|
|
7
|
+
snakeboot.django DjangoAtomicTransactionManager (extra: django)
|
|
8
|
+
snakeboot.testing RecordingTransactionManager, declared_classes (no dependencies)
|
|
9
|
+
|
|
10
|
+
The libraries imitate the basics of Spring Boot, not Spring itself: each one is a few
|
|
11
|
+
small classes a reader can follow, and none knows anything about the application's domain.
|
|
12
|
+
"""
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Technical library: one component scan for dependency injection and routing.
|
|
2
|
+
|
|
3
|
+
@application_root # @SpringBootApplication: scan from this package down
|
|
4
|
+
class MyApplication: ...
|
|
5
|
+
|
|
6
|
+
# in settings.py, inside the setting wireup needs anyway:
|
|
7
|
+
WIREUP = WireupSettings(injectables=[*ComponentScan.of(MyApplication).module_names()])
|
|
8
|
+
|
|
9
|
+
Standard library only: it finds module names without importing them, so settings can use it
|
|
10
|
+
before Django has started.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from snakeboot.component_scan.application_root import application_root
|
|
14
|
+
from snakeboot.component_scan.component_scan import (
|
|
15
|
+
DJANGO_ENTRY_POINTS,
|
|
16
|
+
SNAKEBOOT_AUTO_CONFIGURATION,
|
|
17
|
+
ComponentScan,
|
|
18
|
+
InvalidApplicationRootError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DJANGO_ENTRY_POINTS",
|
|
23
|
+
"SNAKEBOOT_AUTO_CONFIGURATION",
|
|
24
|
+
"ComponentScan",
|
|
25
|
+
"InvalidApplicationRootError",
|
|
26
|
+
"application_root",
|
|
27
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from typing import overload
|
|
4
|
+
|
|
5
|
+
from snakeboot.component_scan.component_scan import (
|
|
6
|
+
APPLICATION_ROOT_ATTRIBUTE,
|
|
7
|
+
DJANGO_ENTRY_POINTS,
|
|
8
|
+
ComponentScan,
|
|
9
|
+
InvalidApplicationRootError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@overload
|
|
14
|
+
def application_root[C: type](application_class: C, /) -> C: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@overload
|
|
18
|
+
def application_root[C: type](
|
|
19
|
+
*, exclude: tuple[str, ...] = (), default_exclude: tuple[str, ...] = DJANGO_ENTRY_POINTS
|
|
20
|
+
) -> Callable[[C], C]: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def application_root[C: type](
|
|
24
|
+
application_class: C | None = None,
|
|
25
|
+
/,
|
|
26
|
+
*,
|
|
27
|
+
exclude: tuple[str, ...] = (),
|
|
28
|
+
default_exclude: tuple[str, ...] = DJANGO_ENTRY_POINTS,
|
|
29
|
+
) -> C | Callable[[C], C]:
|
|
30
|
+
"""Marks the root of the application, like Spring's `@SpringBootApplication`.
|
|
31
|
+
|
|
32
|
+
The component scan starts in the package of the marked class and covers everything
|
|
33
|
+
below it, so the class belongs in the top package (`myapp/myapp_application.py`).
|
|
34
|
+
Use it bare (`@application_root`) or with own exclusions on top of the Django defaults
|
|
35
|
+
(`@application_root(exclude=(...))`). Settings read the result with `ComponentScan.of(...)`.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def declare(declared_class: C) -> C:
|
|
39
|
+
scan = ComponentScan((_package_of(declared_class),), exclude=exclude, default_exclude=default_exclude)
|
|
40
|
+
setattr(declared_class, APPLICATION_ROOT_ATTRIBUTE, scan)
|
|
41
|
+
return declared_class
|
|
42
|
+
|
|
43
|
+
return declare if application_class is None else declare(application_class)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _package_of(declared_class: type) -> str:
|
|
47
|
+
module = sys.modules.get(declared_class.__module__)
|
|
48
|
+
if module is not None and hasattr(module, "__path__"):
|
|
49
|
+
return module.__name__
|
|
50
|
+
package, _, _ = declared_class.__module__.rpartition(".")
|
|
51
|
+
if not package:
|
|
52
|
+
raise InvalidApplicationRootError(
|
|
53
|
+
f"{declared_class.__name__} is defined in {declared_class.__module__!r}, outside any package; "
|
|
54
|
+
"an application root lives in the top package it scans."
|
|
55
|
+
)
|
|
56
|
+
return package
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import pkgutil
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from fnmatch import fnmatchcase
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
DJANGO_ENTRY_POINTS: Final = ("*.wsgi", "*.asgi", "*.migrations")
|
|
10
|
+
"""Modules every Django project has that are never components.
|
|
11
|
+
|
|
12
|
+
`wsgi.py` and `asgi.py` start Django when they are imported; a scan that imported them
|
|
13
|
+
during start-up would start Django a second time, which Django refuses. Migrations hold
|
|
14
|
+
no components. These are conventions of Django, so the scan needs no Django import to know them.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
SNAKEBOOT_AUTO_CONFIGURATION: Final = ("snakeboot.events.application_event_publisher_injectable",)
|
|
18
|
+
"""Modules of snakeboot itself that register components, like Spring Boot's `@EnableAutoConfiguration`.
|
|
19
|
+
|
|
20
|
+
Spring Boot always provides an `ApplicationEventPublisher`; so does snakeboot, through this
|
|
21
|
+
module. The scan adds it to every application without the application naming it. The names
|
|
22
|
+
are strings: the scan still imports nothing and depends on no other part of snakeboot.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
APPLICATION_ROOT_ATTRIBUTE: Final = "__component_scan__"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InvalidApplicationRootError(TypeError):
|
|
29
|
+
"""Raised at start-up when a class cannot be, or is not, the root of a component scan.
|
|
30
|
+
|
|
31
|
+
The application root decides what the application is made of; a wrong one must stop
|
|
32
|
+
the start-up instead of silently scanning nothing.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class ComponentScan:
|
|
38
|
+
"""Which modules make up the application, like `@ComponentScan(basePackages, excludeFilters)` in Spring.
|
|
39
|
+
|
|
40
|
+
Spring Boot scans from the package of the `@SpringBootApplication` class downwards and
|
|
41
|
+
skips what is never a component through its default filters. Here the base packages are
|
|
42
|
+
named, `default_exclude` skips Django's entry points, and `exclude` adds the application's
|
|
43
|
+
own exclusions (rarely needed). `default_exclude=()` turns the defaults off, like Spring's
|
|
44
|
+
`useDefaultFilters = false`. `auto_configuration` adds the modules through which snakeboot
|
|
45
|
+
registers its own components; an exclusion pattern can remove one, `auto_configuration=()`
|
|
46
|
+
removes them all.
|
|
47
|
+
|
|
48
|
+
`module_names()` lists plain modules only, never packages. Given a package, wireup
|
|
49
|
+
and the controller scanner import every file below it themselves, past any exclusion;
|
|
50
|
+
given only modules, both look exactly where this scan points. Finding the names
|
|
51
|
+
imports nothing, so settings can call it before Django has started.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
base_packages: tuple[str, ...]
|
|
55
|
+
exclude: tuple[str, ...] = ()
|
|
56
|
+
default_exclude: tuple[str, ...] = DJANGO_ENTRY_POINTS
|
|
57
|
+
auto_configuration: tuple[str, ...] = SNAKEBOOT_AUTO_CONFIGURATION
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def of(cls, application_class: type) -> "ComponentScan":
|
|
61
|
+
"""Returns the scan that `@application_root` declared on `application_class`."""
|
|
62
|
+
scan = vars(application_class).get(APPLICATION_ROOT_ATTRIBUTE)
|
|
63
|
+
if not isinstance(scan, ComponentScan):
|
|
64
|
+
raise InvalidApplicationRootError(f"{application_class.__name__} is not an @application_root.")
|
|
65
|
+
return scan
|
|
66
|
+
|
|
67
|
+
def module_names(self) -> list[str]:
|
|
68
|
+
"""Returns the dotted names of every module under the base packages, plus the auto-configuration, sorted.
|
|
69
|
+
|
|
70
|
+
Excluded modules are left out, auto-configuration included.
|
|
71
|
+
"""
|
|
72
|
+
scanned = (name for base_package in self.base_packages for name in self._modules_under(base_package))
|
|
73
|
+
return sorted({name for name in (*scanned, *self.auto_configuration) if not self.is_excluded(name)})
|
|
74
|
+
|
|
75
|
+
def is_excluded(self, module_name: str) -> bool:
|
|
76
|
+
"""Tells whether a default or an own exclusion pattern matches the module or one of its parent packages.
|
|
77
|
+
|
|
78
|
+
A pattern is a dotted name with `fnmatch` wildcards: `myapp.composition.django_project`
|
|
79
|
+
excludes that package and everything below it, `*.migrations` every migrations package.
|
|
80
|
+
"""
|
|
81
|
+
return any(
|
|
82
|
+
fnmatchcase(module_name, pattern) or fnmatchcase(module_name, f"{pattern}.*")
|
|
83
|
+
for pattern in (*self.default_exclude, *self.exclude)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def _modules_under(self, package_name: str) -> Iterator[str]:
|
|
87
|
+
spec = importlib.util.find_spec(package_name)
|
|
88
|
+
if spec is None:
|
|
89
|
+
raise ModuleNotFoundError(
|
|
90
|
+
f"The component scan cannot find the package {package_name!r}.", name=package_name
|
|
91
|
+
)
|
|
92
|
+
if spec.submodule_search_locations is None:
|
|
93
|
+
yield package_name
|
|
94
|
+
return
|
|
95
|
+
for location in spec.submodule_search_locations:
|
|
96
|
+
yield from self._modules_in_directory(Path(location), package_name)
|
|
97
|
+
|
|
98
|
+
def _modules_in_directory(self, directory: Path, package_name: str) -> Iterator[str]:
|
|
99
|
+
for module in pkgutil.iter_modules([str(directory)]):
|
|
100
|
+
name = f"{package_name}.{module.name}"
|
|
101
|
+
if module.ispkg:
|
|
102
|
+
yield from self._modules_in_directory(directory / module.name, name)
|
|
103
|
+
else:
|
|
104
|
+
yield name
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from contextlib import AbstractContextManager
|
|
3
|
+
from typing import override
|
|
4
|
+
|
|
5
|
+
from django.db import transaction
|
|
6
|
+
|
|
7
|
+
from snakeboot.transaction import TransactionManager
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DjangoAtomicTransactionManager(TransactionManager):
|
|
11
|
+
"""Opens transactions with Django's `transaction.atomic`, for the default database.
|
|
12
|
+
|
|
13
|
+
It lives in composition because choosing the transaction technology is a wiring
|
|
14
|
+
decision: the transaction library stays free of Django, and no bounded context
|
|
15
|
+
owns the database connection. Nested `@transactional` calls become savepoints.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@override
|
|
19
|
+
def atomic(self) -> AbstractContextManager[None]:
|
|
20
|
+
return transaction.atomic()
|
|
21
|
+
|
|
22
|
+
@override
|
|
23
|
+
def on_commit(self, callback: Callable[[], None]) -> None:
|
|
24
|
+
transaction.on_commit(callback, robust=True)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Technical library: in-process application events, like Spring's `ApplicationEventPublisher`.
|
|
2
|
+
|
|
3
|
+
@application_event_listener # a @Component with listener methods
|
|
4
|
+
class ParticipantNotificationListener:
|
|
5
|
+
def __init__(self, notifications: NotificationPort) -> None: # constructor injection
|
|
6
|
+
self._notifications = notifications
|
|
7
|
+
|
|
8
|
+
@transactional_event_listener(WaitlistedParticipantPromoted) # @TransactionalEventListener
|
|
9
|
+
def notify_promoted(self, event: WaitlistedParticipantPromoted) -> None: ...
|
|
10
|
+
|
|
11
|
+
# in a @transactional use case, after saving the aggregate:
|
|
12
|
+
self._events.publish_all(workshop.pull_events())
|
|
13
|
+
|
|
14
|
+
Events are facts, never requests: a caller that needs an outcome calls a facade instead.
|
|
15
|
+
The library knows wireup and `snakeboot.transaction`, but no framework and no bounded context.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from snakeboot.events.application_event_listener import (
|
|
19
|
+
ApplicationEventListener,
|
|
20
|
+
InvalidApplicationEventListenerError,
|
|
21
|
+
application_event_listener,
|
|
22
|
+
)
|
|
23
|
+
from snakeboot.events.application_event_publisher import ApplicationEventPublisher
|
|
24
|
+
from snakeboot.events.event_listener_markers import event_listener, transactional_event_listener
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"ApplicationEventListener",
|
|
28
|
+
"ApplicationEventPublisher",
|
|
29
|
+
"InvalidApplicationEventListenerError",
|
|
30
|
+
"application_event_listener",
|
|
31
|
+
"event_listener",
|
|
32
|
+
"transactional_event_listener",
|
|
33
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
|
|
3
|
+
from wireup import injectable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ApplicationEventListener(Protocol):
|
|
7
|
+
"""The type under which wireup collects every listener, so the publisher receives them all.
|
|
8
|
+
|
|
9
|
+
A listener is any class whose methods carry `@event_listener` or `@transactional_event_listener`;
|
|
10
|
+
it does not inherit from this protocol. Wireup injects `Sequence[ApplicationEventListener]`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InvalidApplicationEventListenerError(TypeError):
|
|
15
|
+
"""Raised at start-up when a class or method cannot listen to application events.
|
|
16
|
+
|
|
17
|
+
Like a broken controller, a broken listener must stop the application from starting,
|
|
18
|
+
not fail silently at the first event.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def application_event_listener[C: type](listener_class: C) -> C:
|
|
23
|
+
"""Declares a listener: a wireup singleton whose marked methods receive published events.
|
|
24
|
+
|
|
25
|
+
Spring finds `@EventListener` methods on any bean. Here the class says it listens, so wireup
|
|
26
|
+
registers it as an `ApplicationEventListener` (qualified by its name, so several can exist)
|
|
27
|
+
and the component scan finds it like every other injectable.
|
|
28
|
+
"""
|
|
29
|
+
qualifier = f"{listener_class.__module__}.{listener_class.__qualname__}"
|
|
30
|
+
return injectable(as_type=ApplicationEventListener, qualifier=qualifier)(listener_class)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from collections.abc import Callable, Iterable
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from functools import partial
|
|
5
|
+
from typing import Any, Final
|
|
6
|
+
|
|
7
|
+
from snakeboot.events.application_event_listener import InvalidApplicationEventListenerError
|
|
8
|
+
from snakeboot.events.event_listener_markers import LISTENER_MAPPING_ATTRIBUTE, ListenerMapping
|
|
9
|
+
from snakeboot.transaction import after_commit
|
|
10
|
+
|
|
11
|
+
_SELF_AND_EVENT: Final = 2
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class _Subscription:
|
|
16
|
+
"""One listener method and the events it receives."""
|
|
17
|
+
|
|
18
|
+
name: str
|
|
19
|
+
receive: Callable[[object], None]
|
|
20
|
+
mapping: ListenerMapping
|
|
21
|
+
|
|
22
|
+
def deliver(self, event: object) -> None:
|
|
23
|
+
if self.mapping.after_commit:
|
|
24
|
+
after_commit(partial(self.receive, event))
|
|
25
|
+
else:
|
|
26
|
+
self.receive(event)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ApplicationEventPublisher:
|
|
30
|
+
"""Hands each event to the listener methods that asked for its type (Spring's `ApplicationEventPublisher`).
|
|
31
|
+
|
|
32
|
+
It is the Observer pattern for facts: the use case that publishes does not know who
|
|
33
|
+
reacts, and a new reaction is a new listener, not a change to the use case. Delivery is
|
|
34
|
+
synchronous and in-process, in a stable order (by listener name). A listener for a base
|
|
35
|
+
class (`WorkshopEvent`) receives every subclass.
|
|
36
|
+
|
|
37
|
+
The library registers it with wireup (`application_event_publisher_injectable`), connected
|
|
38
|
+
to every listener wireup found, or to none; tests build it with the listeners they need.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, listeners: Iterable[object]) -> None:
|
|
42
|
+
self._subscriptions = sorted(
|
|
43
|
+
(subscription for listener in listeners for subscription in self._subscriptions_of(listener)),
|
|
44
|
+
key=lambda subscription: subscription.name,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def publish(self, event: object) -> None:
|
|
48
|
+
"""Delivers `event` to every listener method for its type; immediately or after the commit."""
|
|
49
|
+
for subscription in self._subscriptions:
|
|
50
|
+
if isinstance(event, subscription.mapping.event_types):
|
|
51
|
+
subscription.deliver(event)
|
|
52
|
+
|
|
53
|
+
def publish_all(self, events: Iterable[object]) -> None:
|
|
54
|
+
"""Publishes `events` in the order they happened."""
|
|
55
|
+
for event in events:
|
|
56
|
+
self.publish(event)
|
|
57
|
+
|
|
58
|
+
def _subscriptions_of(self, listener: object) -> list[_Subscription]:
|
|
59
|
+
listener_class = type(listener)
|
|
60
|
+
subscriptions = [
|
|
61
|
+
_Subscription(
|
|
62
|
+
name=f"{listener_class.__module__}.{listener_class.__qualname__}.{name}",
|
|
63
|
+
receive=self._receiver(listener, name, method),
|
|
64
|
+
mapping=mapping,
|
|
65
|
+
)
|
|
66
|
+
for name, method in inspect.getmembers(listener_class, inspect.isfunction)
|
|
67
|
+
if (mapping := getattr(method, LISTENER_MAPPING_ATTRIBUTE, None)) is not None
|
|
68
|
+
]
|
|
69
|
+
if not subscriptions:
|
|
70
|
+
raise InvalidApplicationEventListenerError(f"{listener_class.__name__} has no listener methods.")
|
|
71
|
+
return subscriptions
|
|
72
|
+
|
|
73
|
+
def _receiver(self, listener: object, name: str, method: Callable[..., Any]) -> Callable[[object], None]:
|
|
74
|
+
owner = f"{type(listener).__name__}.{name}"
|
|
75
|
+
if inspect.iscoroutinefunction(method):
|
|
76
|
+
raise InvalidApplicationEventListenerError(f"{owner} is async; listeners are synchronous.")
|
|
77
|
+
if len(inspect.signature(method).parameters) != _SELF_AND_EVENT:
|
|
78
|
+
raise InvalidApplicationEventListenerError(f"{owner} must take exactly one argument: the event.")
|
|
79
|
+
receive: Callable[[object], None] = getattr(listener, name)
|
|
80
|
+
return receive
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Registers the one ApplicationEventPublisher with wireup, so no application has to.
|
|
2
|
+
|
|
3
|
+
Spring always provides an `ApplicationEventPublisher`, with or without listeners; this
|
|
4
|
+
module is the library's equivalent. A component scan that covers the library finds it;
|
|
5
|
+
otherwise an application lists this module among wireup's injectables.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
from wireup import injectable
|
|
11
|
+
|
|
12
|
+
from snakeboot.events.application_event_listener import ApplicationEventListener
|
|
13
|
+
from snakeboot.events.application_event_publisher import ApplicationEventPublisher
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@injectable
|
|
17
|
+
def application_event_publisher(listeners: Sequence[ApplicationEventListener] = ()) -> ApplicationEventPublisher:
|
|
18
|
+
"""Provides the publisher, connected to every `@application_event_listener` wireup knows.
|
|
19
|
+
|
|
20
|
+
Wireup only creates `Sequence[ApplicationEventListener]` when at least one listener
|
|
21
|
+
exists. Without the default, an application without listeners would not even start;
|
|
22
|
+
with it, wireup leaves the parameter out and the publisher simply has nobody to tell.
|
|
23
|
+
"""
|
|
24
|
+
return ApplicationEventPublisher(listeners)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any, Final
|
|
4
|
+
|
|
5
|
+
LISTENER_MAPPING_ATTRIBUTE: Final = "__application_event_listener_mapping__"
|
|
6
|
+
|
|
7
|
+
type MethodMarker = Callable[[Callable[..., Any]], Callable[..., Any]]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class ListenerMapping:
|
|
12
|
+
"""What a listener marker records on a method: the events it receives and when."""
|
|
13
|
+
|
|
14
|
+
event_types: tuple[type, ...]
|
|
15
|
+
after_commit: bool
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def event_listener(*event_types: type) -> MethodMarker:
|
|
19
|
+
"""Marks a method that receives `event_types` at once, inside the publisher's transaction (`@EventListener`).
|
|
20
|
+
|
|
21
|
+
Use it for work that must commit or roll back together with the change that caused the event.
|
|
22
|
+
"""
|
|
23
|
+
return _mark(event_types, after_commit=False)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def transactional_event_listener(*event_types: type) -> MethodMarker:
|
|
27
|
+
"""Marks a method that receives `event_types` once the transaction has committed.
|
|
28
|
+
|
|
29
|
+
Spring: `@TransactionalEventListener(phase = AFTER_COMMIT)`. Use it for effects outside the
|
|
30
|
+
database, such as notifications: they are delivered through `after_commit`, so a rolled-back
|
|
31
|
+
change never reaches anyone, and a failing listener cannot undo the committed change.
|
|
32
|
+
"""
|
|
33
|
+
return _mark(event_types, after_commit=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _mark(event_types: tuple[type, ...], *, after_commit: bool) -> MethodMarker:
|
|
37
|
+
def record(method: Callable[..., Any]) -> Callable[..., Any]:
|
|
38
|
+
setattr(method, LISTENER_MAPPING_ATTRIBUTE, ListenerMapping(event_types, after_commit))
|
|
39
|
+
return method
|
|
40
|
+
|
|
41
|
+
return record
|
snakeboot/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Technical library: Spring-style singleton REST controllers on Django Ninja, wired by wireup.
|
|
2
|
+
|
|
3
|
+
@rest_controller("/workshops", tags=["workshops"]) # @RestController + @RequestMapping
|
|
4
|
+
class WorkshopRestController:
|
|
5
|
+
def __init__(self, enrollment: EnrollmentFacadePort) -> None: # constructor injection
|
|
6
|
+
self._enrollment = enrollment
|
|
7
|
+
|
|
8
|
+
@route.get("/{workshop_id}", response=WorkshopViewDTO) # @GetMapping
|
|
9
|
+
def get_workshop(self, request: HttpRequest, workshop_id: str) -> WorkshopViewDTO: ...
|
|
10
|
+
|
|
11
|
+
@rest_controller_advice # @RestControllerAdvice
|
|
12
|
+
class EnrollmentRestErrorAdvice:
|
|
13
|
+
@exception_handler(WorkshopNotFoundError, status=404) # @ExceptionHandler
|
|
14
|
+
def answer_not_found(self, error: Exception) -> dict[str, str]:
|
|
15
|
+
return error_answer(error)
|
|
16
|
+
|
|
17
|
+
Composition mounts every controller and advice it finds in the modules wireup scans:
|
|
18
|
+
|
|
19
|
+
RestControllerMounter().mount(api, scan=settings.WIREUP.injectables or ())
|
|
20
|
+
|
|
21
|
+
It knows Django Ninja and wireup, but nothing of the application (extra: snakeboot[ninja]).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from snakeboot.rest.authenticated_principal import authenticated_principal
|
|
25
|
+
from snakeboot.rest.controller_endpoint_factory import ControllerEndpointFactory
|
|
26
|
+
from snakeboot.rest.error_answer import error_answer
|
|
27
|
+
from snakeboot.rest.rest_controller import rest_controller
|
|
28
|
+
from snakeboot.rest.rest_controller_advice import exception_handler, rest_controller_advice
|
|
29
|
+
from snakeboot.rest.rest_controller_errors import InvalidRestControllerError, NoAuthenticatedPrincipalError
|
|
30
|
+
from snakeboot.rest.rest_controller_mounter import MountedRestComponents, RestControllerMounter
|
|
31
|
+
from snakeboot.rest.rest_controller_scanner import RestControllerScanner
|
|
32
|
+
from snakeboot.rest.route_markers import route
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"ControllerEndpointFactory",
|
|
36
|
+
"InvalidRestControllerError",
|
|
37
|
+
"MountedRestComponents",
|
|
38
|
+
"NoAuthenticatedPrincipalError",
|
|
39
|
+
"RestControllerMounter",
|
|
40
|
+
"RestControllerScanner",
|
|
41
|
+
"authenticated_principal",
|
|
42
|
+
"error_answer",
|
|
43
|
+
"exception_handler",
|
|
44
|
+
"rest_controller",
|
|
45
|
+
"rest_controller_advice",
|
|
46
|
+
"route",
|
|
47
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from django.http import HttpRequest
|
|
2
|
+
|
|
3
|
+
from snakeboot.rest.rest_controller_errors import NoAuthenticatedPrincipalError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def authenticated_principal(request: HttpRequest) -> str:
|
|
7
|
+
"""Returns the id that the API's authentication stored on `request.auth` (Spring: `@AuthenticationPrincipal`).
|
|
8
|
+
|
|
9
|
+
Ninja adds `auth` to the request at run time, so a type checker cannot see it. This is
|
|
10
|
+
the one place that reads it, so controllers stay fully typed.
|
|
11
|
+
"""
|
|
12
|
+
principal = request.auth # type: ignore[attr-defined]
|
|
13
|
+
if not isinstance(principal, str):
|
|
14
|
+
raise NoAuthenticatedPrincipalError(f"Expected the id of a signed-in principal, got {principal!r}.")
|
|
15
|
+
return principal
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import inspect
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from django.http import HttpRequest, HttpResponse
|
|
7
|
+
from ninja import NinjaAPI
|
|
8
|
+
from wireup.integration.django import get_request_container
|
|
9
|
+
|
|
10
|
+
from snakeboot.rest.rest_controller_errors import InvalidRestControllerError
|
|
11
|
+
|
|
12
|
+
ControllerResolver = Callable[[type], object]
|
|
13
|
+
ErrorAnswer = Callable[[HttpRequest, Exception], HttpResponse]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def resolve_from_wireup_request_scope(controller_class: type) -> object:
|
|
17
|
+
"""Asks wireup for the controller during the current request; a singleton is the same object every time."""
|
|
18
|
+
return get_request_container().get(controller_class)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ControllerEndpointFactory:
|
|
22
|
+
"""Turns one controller or advice method into a function Django Ninja can call.
|
|
23
|
+
|
|
24
|
+
Ninja inspects a view's signature to read path, query and body parameters. A method
|
|
25
|
+
has `self` in front, which Ninja would take for a query parameter, so the endpoint
|
|
26
|
+
shows Ninja the signature *without* `self`. When called, the endpoint asks the
|
|
27
|
+
resolver for the controller instance and calls the method on it.
|
|
28
|
+
|
|
29
|
+
Resolving per call (not once at start-up) keeps two things working: wireup's
|
|
30
|
+
request scope under both WSGI and ASGI, and wireup overrides in tests.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, resolve_controller: ControllerResolver = resolve_from_wireup_request_scope) -> None:
|
|
34
|
+
self._resolve_controller = resolve_controller
|
|
35
|
+
|
|
36
|
+
def create(self, controller_class: type, method: Callable[..., Any]) -> Callable[..., Any]:
|
|
37
|
+
"""Returns the Ninja view for `method` of `controller_class`."""
|
|
38
|
+
signature = inspect.signature(method)
|
|
39
|
+
parameters_after_self = self._parameters_after_self(controller_class, method)
|
|
40
|
+
resolve_controller = self._resolve_controller
|
|
41
|
+
|
|
42
|
+
@functools.wraps(method)
|
|
43
|
+
def endpoint(*args: Any, **kwargs: Any) -> Any:
|
|
44
|
+
return method(resolve_controller(controller_class), *args, **kwargs)
|
|
45
|
+
|
|
46
|
+
endpoint.__signature__ = signature.replace(parameters=parameters_after_self) # type: ignore[attr-defined]
|
|
47
|
+
return endpoint
|
|
48
|
+
|
|
49
|
+
def create_error_answer(
|
|
50
|
+
self, advice_class: type, method: Callable[..., Any], *, api: NinjaAPI, status: int
|
|
51
|
+
) -> ErrorAnswer:
|
|
52
|
+
"""Returns the Ninja exception handler that answers with `status` and the body `method` builds."""
|
|
53
|
+
self._parameters_after_self(advice_class, method)
|
|
54
|
+
resolve_advice = self._resolve_controller
|
|
55
|
+
|
|
56
|
+
def answer(request: HttpRequest, error: Exception) -> HttpResponse:
|
|
57
|
+
body = method(resolve_advice(advice_class), error)
|
|
58
|
+
return api.create_response(request, body, status=status)
|
|
59
|
+
|
|
60
|
+
return answer
|
|
61
|
+
|
|
62
|
+
def _parameters_after_self(self, owner: type, method: Callable[..., Any]) -> list[inspect.Parameter]:
|
|
63
|
+
if inspect.iscoroutinefunction(method):
|
|
64
|
+
raise InvalidRestControllerError(
|
|
65
|
+
f"{owner.__name__}.{method.__name__} is async; controller endpoints are synchronous."
|
|
66
|
+
)
|
|
67
|
+
parameters = list(inspect.signature(method).parameters.values())
|
|
68
|
+
if not parameters or parameters[0].name != "self":
|
|
69
|
+
raise InvalidRestControllerError(
|
|
70
|
+
f"{owner.__name__}.{method.__name__} must be a regular method taking `self` first."
|
|
71
|
+
)
|
|
72
|
+
return parameters[1:]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Final
|
|
3
|
+
|
|
4
|
+
_WORD_BOUNDARY: Final = re.compile(r"(?<!^)(?=[A-Z])")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def error_answer(error: Exception) -> dict[str, str]:
|
|
8
|
+
"""Builds the error body `{"code": ..., "detail": ...}`, the shape of Spring's `ProblemDetail` in small.
|
|
9
|
+
|
|
10
|
+
`code` is derived from the class name (`AlreadyRegisteredError` -> `already_registered`),
|
|
11
|
+
so a client can rely on it; `detail` is the message, meant for developers.
|
|
12
|
+
"""
|
|
13
|
+
name = type(error).__name__.removesuffix("Error")
|
|
14
|
+
return {"code": _WORD_BOUNDARY.sub("_", name).lower(), "detail": str(error)}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from collections.abc import Callable, Sequence
|
|
2
|
+
|
|
3
|
+
from wireup import injectable
|
|
4
|
+
|
|
5
|
+
from snakeboot.rest.rest_controller_errors import InvalidRestControllerError
|
|
6
|
+
from snakeboot.rest.rest_controller_mappings import CONTROLLER_MAPPING_ATTRIBUTE, ControllerMapping
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def rest_controller[C: type](prefix: str, *, tags: Sequence[str] = ()) -> Callable[[C], C]:
|
|
10
|
+
"""Declares a REST controller, like Spring's `@RestController` with `@RequestMapping(prefix)`.
|
|
11
|
+
|
|
12
|
+
The class becomes a wireup singleton (do not add `@injectable` yourself): its
|
|
13
|
+
constructor receives facade ports by type, it is created once, and the container
|
|
14
|
+
refuses to start when a dependency is missing. RestControllerMounter finds it by
|
|
15
|
+
scanning and turns its `@route` methods into Ninja endpoints under `prefix`.
|
|
16
|
+
"""
|
|
17
|
+
if prefix != "" and not prefix.startswith("/"):
|
|
18
|
+
raise InvalidRestControllerError(f"A controller prefix starts with '/', got {prefix!r}.")
|
|
19
|
+
|
|
20
|
+
def declare(controller_class: C) -> C:
|
|
21
|
+
mapping = ControllerMapping(prefix=prefix, tags=tuple(tags) or (controller_class.__name__,))
|
|
22
|
+
setattr(controller_class, CONTROLLER_MAPPING_ATTRIBUTE, mapping)
|
|
23
|
+
return injectable(controller_class)
|
|
24
|
+
|
|
25
|
+
return declare
|