snakeboot 0.5.2__tar.gz → 0.7.0__tar.gz
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-0.5.2 → snakeboot-0.7.0}/PKG-INFO +167 -24
- snakeboot-0.7.0/README.md +355 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/pyproject.toml +221 -4
- {snakeboot-0.5.2 → snakeboot-0.7.0}/pyproject.toml.orig +219 -5
- snakeboot-0.7.0/src/snakeboot/__init__.py +27 -0
- snakeboot-0.7.0/src/snakeboot/boot/__init__.py +16 -0
- snakeboot-0.7.0/src/snakeboot/command_line/__init__.py +31 -0
- snakeboot-0.7.0/src/snakeboot/command_line/command_line.py +67 -0
- snakeboot-0.7.0/src/snakeboot/command_line/command_line_errors.py +14 -0
- snakeboot-0.7.0/src/snakeboot/command_line/command_line_runner.py +94 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/component_scan/__init__.py +8 -6
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/component_scan/application_root.py +3 -3
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/component_scan/component_scan.py +81 -31
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/configuration/__init__.py +13 -6
- snakeboot-0.7.0/src/snakeboot/configuration/cache_properties.py +12 -0
- snakeboot-0.7.0/src/snakeboot/configuration/database_properties.py +19 -0
- snakeboot-0.7.0/src/snakeboot/configuration/rabbitmq_properties.py +39 -0
- snakeboot-0.7.0/src/snakeboot/configuration/scheduling_properties.py +16 -0
- snakeboot-0.7.0/src/snakeboot/configuration/server_properties.py +22 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/django_application/__init__.py +6 -11
- snakeboot-0.7.0/src/snakeboot/django_application/background_work.py +56 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/django_application/django_settings.py +51 -15
- snakeboot-0.7.0/src/snakeboot/django_application/entry_points.py +136 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/django_application/installed_apps.py +5 -4
- snakeboot-0.7.0/src/snakeboot/django_application/management/commands/__init__.py +0 -0
- snakeboot-0.7.0/src/snakeboot/django_application/management/commands/run_lifecycle.py +50 -0
- snakeboot-0.7.0/src/snakeboot/django_ninja/__init__.py +31 -0
- snakeboot-0.7.0/src/snakeboot/django_ninja/event_stream.py +37 -0
- snakeboot-0.7.0/src/snakeboot/django_ninja/ninja_endpoint_factory.py +87 -0
- snakeboot-0.7.0/src/snakeboot/django_ninja/rest_api.py +149 -0
- {snakeboot-0.5.2/src/snakeboot/rest → snakeboot-0.7.0/src/snakeboot/django_ninja}/urls.py +2 -2
- snakeboot-0.7.0/src/snakeboot/lifecycle/__init__.py +33 -0
- snakeboot-0.7.0/src/snakeboot/lifecycle/application_components.py +21 -0
- snakeboot-0.7.0/src/snakeboot/lifecycle/application_lifecycle.py +43 -0
- snakeboot-0.7.0/src/snakeboot/lifecycle/application_lifecycle_injectable.py +26 -0
- snakeboot-0.7.0/src/snakeboot/lifecycle/application_ready_event.py +16 -0
- snakeboot-0.7.0/src/snakeboot/lifecycle/lifecycle.py +44 -0
- snakeboot-0.7.0/src/snakeboot/lifecycle/lifecycle_asgi_application.py +62 -0
- snakeboot-0.7.0/src/snakeboot/lifecycle/task_decorator.py +15 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/oauth2/__init__.py +2 -2
- snakeboot-0.7.0/src/snakeboot/py.typed +0 -0
- snakeboot-0.7.0/src/snakeboot/rabbitmq/__init__.py +43 -0
- snakeboot-0.7.0/src/snakeboot/rabbitmq/message_conversion.py +61 -0
- snakeboot-0.7.0/src/snakeboot/rabbitmq/rabbit_connection_factory.py +37 -0
- snakeboot-0.7.0/src/snakeboot/rabbitmq/rabbit_listener.py +56 -0
- snakeboot-0.7.0/src/snakeboot/rabbitmq/rabbit_listener_containers.py +226 -0
- snakeboot-0.7.0/src/snakeboot/rabbitmq/rabbit_template.py +93 -0
- snakeboot-0.7.0/src/snakeboot/rabbitmq/rabbitmq_injectable.py +42 -0
- snakeboot-0.7.0/src/snakeboot/rest/__init__.py +67 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/rest/bearer_authentication.py +8 -4
- snakeboot-0.7.0/src/snakeboot/rest/controller_scan.py +149 -0
- snakeboot-0.7.0/src/snakeboot/rest/endpoint_signature.py +87 -0
- snakeboot-0.7.0/src/snakeboot/rest/invalid_request_error.py +35 -0
- snakeboot-0.7.0/src/snakeboot/rest/principal.py +17 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/rest/rest_controller.py +1 -1
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/rest/rest_controller_advice.py +3 -3
- snakeboot-0.7.0/src/snakeboot/rest/rest_controller_errors.py +16 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/rest/rest_controller_mappings.py +26 -5
- snakeboot-0.7.0/src/snakeboot/rest/route_markers.py +56 -0
- snakeboot-0.7.0/src/snakeboot/rest/server_sent_events.py +179 -0
- snakeboot-0.7.0/src/snakeboot/scheduling/__init__.py +18 -0
- snakeboot-0.7.0/src/snakeboot/scheduling/scheduled.py +69 -0
- snakeboot-0.7.0/src/snakeboot/scheduling/task_scheduler.py +112 -0
- snakeboot-0.7.0/src/snakeboot/scheduling/task_scheduler_injectable.py +33 -0
- snakeboot-0.5.2/README.md +0 -215
- snakeboot-0.5.2/src/snakeboot/__init__.py +0 -13
- snakeboot-0.5.2/src/snakeboot/configuration/cache_properties.py +0 -16
- snakeboot-0.5.2/src/snakeboot/configuration/database_properties.py +0 -29
- snakeboot-0.5.2/src/snakeboot/configuration/django_properties.py +0 -19
- snakeboot-0.5.2/src/snakeboot/django_application/entry_points.py +0 -61
- snakeboot-0.5.2/src/snakeboot/rest/__init__.py +0 -51
- snakeboot-0.5.2/src/snakeboot/rest/authenticated_principal.py +0 -15
- snakeboot-0.5.2/src/snakeboot/rest/controller_endpoint_factory.py +0 -80
- snakeboot-0.5.2/src/snakeboot/rest/controller_scan.py +0 -131
- snakeboot-0.5.2/src/snakeboot/rest/rest_api.py +0 -91
- snakeboot-0.5.2/src/snakeboot/rest/rest_controller_errors.py +0 -14
- snakeboot-0.5.2/src/snakeboot/rest/route_markers.py +0 -45
- {snakeboot-0.5.2 → snakeboot-0.7.0}/LICENSE +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/configuration/configuration.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/configuration/configuration_errors.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/configuration/configuration_properties.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/configuration/configuration_sources.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/configuration/load_configuration.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/configuration/oauth2_properties.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/configuration/properties_rules.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/configuration/section.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/configuration/section_prefix.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/django/__init__.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/django/django_atomic_transaction_manager.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/django_application/apps.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/django_application/container_sections.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/django_application/django_configurer.py +0 -0
- /snakeboot-0.5.2/src/snakeboot/py.typed → /snakeboot-0.7.0/src/snakeboot/django_application/management/__init__.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/django_application/settings.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/django_application/transaction_manager.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/events/__init__.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/events/application_event_listener.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/events/application_event_publisher.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/events/application_event_publisher_injectable.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/events/event_listener_markers.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/oauth2/jwt_bearer_authentication.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/openapi/__init__.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/openapi/openapi_definition.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/rest/error_answer.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/testing/__init__.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/testing/declared_classes.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/testing/recording_transaction_manager.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/transaction/__init__.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/transaction/after_commit.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/transaction/transaction_manager.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/transaction/transaction_manager_not_installed_error.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/transaction/transaction_manager_registry.py +0 -0
- {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/transaction/transactional.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: snakeboot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Spring Boot ergonomics for hexagonal Python: @transactional, application events, a component scan, typed configuration with profiles and singleton REST controllers on Django Ninja, wired by wireup.
|
|
5
5
|
Keywords: spring-boot,hexagonal-architecture,ddd,django,django-ninja,wireup,dependency-injection,transactional
|
|
6
6
|
Author: Wouter Deketelaere
|
|
@@ -27,6 +27,8 @@ Requires-Dist: django>=6.1.1 ; extra == 'oauth2'
|
|
|
27
27
|
Requires-Dist: django-ninja>=1.7.1 ; extra == 'oauth2'
|
|
28
28
|
Requires-Dist: pydantic-settings>=2.16.0,<3 ; extra == 'oauth2'
|
|
29
29
|
Requires-Dist: pyjwt[crypto]>=2.10.1 ; extra == 'oauth2'
|
|
30
|
+
Requires-Dist: pydantic-settings>=2.16.0,<3 ; extra == 'rabbitmq'
|
|
31
|
+
Requires-Dist: pika>=1.3.2 ; extra == 'rabbitmq'
|
|
30
32
|
Requires-Python: >=3.13
|
|
31
33
|
Project-URL: Homepage, https://github.com/KdG-OCDI/snakeboot
|
|
32
34
|
Project-URL: Repository, https://github.com/KdG-OCDI/snakeboot
|
|
@@ -36,48 +38,65 @@ Provides-Extra: configuration
|
|
|
36
38
|
Provides-Extra: django
|
|
37
39
|
Provides-Extra: ninja
|
|
38
40
|
Provides-Extra: oauth2
|
|
41
|
+
Provides-Extra: rabbitmq
|
|
39
42
|
Description-Content-Type: text/markdown
|
|
40
43
|
|
|
41
44
|
# snakeboot
|
|
42
45
|
|
|
43
|
-
Spring Boot ergonomics for hexagonal Python applications
|
|
44
|
-
|
|
46
|
+
Spring Boot ergonomics for hexagonal Python applications, wired by [wireup](https://github.com/maldoinc/wireup)
|
|
47
|
+
and running on Django and Django Ninja underneath.
|
|
45
48
|
|
|
46
49
|
snakeboot imitates the basics of Spring Boot, not Spring itself. Each part is a handful of small
|
|
47
50
|
classes you can read in an afternoon, and none of them knows anything about your domain.
|
|
48
51
|
|
|
52
|
+
An application speaks snakeboot, not Django: its entry points, controllers, commands, listeners and configuration
|
|
53
|
+
import nothing of Django or Django Ninja, so snakeboot can bind another framework without changing them. Only the
|
|
54
|
+
persistence adapters, which hold the models and queries of the ORM, and the tests that use Django's database, still
|
|
55
|
+
see Django.
|
|
56
|
+
|
|
49
57
|
| snakeboot | Spring Boot | Needs |
|
|
50
58
|
|---|---|---|
|
|
51
59
|
| `@transactional`, `after_commit` | `@Transactional`, `@TransactionalEventListener(AFTER_COMMIT)` | nothing |
|
|
52
60
|
| `@application_root`, `ComponentScan` | `@SpringBootApplication`, `@ComponentScan` | nothing |
|
|
53
61
|
| `ApplicationEventPublisher`, `@event_listener`, `@transactional_event_listener` | the same names | wireup |
|
|
54
|
-
| `@rest_controller`, `@route.get`, `@rest_controller_advice`, `@exception_handler` | `@RestController`, `@GetMapping`, `@RestControllerAdvice`, `@ExceptionHandler` | `snakeboot[ninja]` |
|
|
62
|
+
| `@rest_controller`, `@route.get(status=...)`, `@rest_controller_advice`, `@exception_handler` | `@RestController`, `@GetMapping`, `@ResponseStatus`, `@RestControllerAdvice`, `@ExceptionHandler` | `snakeboot[ninja]` |
|
|
63
|
+
| `Principal` | `@AuthenticationPrincipal` | `snakeboot[ninja]` |
|
|
64
|
+
| `InvalidRequestError`, `RequestProblem` | `MethodArgumentNotValidException` | `snakeboot[ninja]` |
|
|
65
|
+
| `ServerSentEvents`, `ServerSentEvent` | `SseEmitter`, `ServerSentEvent` | `snakeboot[ninja]` |
|
|
55
66
|
| `DjangoAtomicTransactionManager` | `PlatformTransactionManager` for JDBC | `snakeboot[django]` |
|
|
56
67
|
| `@configuration_properties`, `Section`, `load_configuration`, `application-<profile>.toml` | `@ConfigurationProperties`, `@ConfigurationPropertiesScan`, profiles | `snakeboot[configuration]` |
|
|
57
|
-
| `
|
|
58
|
-
| `run`, `wsgi_application`, `asgi_application
|
|
59
|
-
| `
|
|
68
|
+
| `ServerProperties`, `DatabaseProperties`, `CacheProperties` | `server.*`, `spring.datasource.*`, `spring.data.redis.*` | `snakeboot[configuration]` |
|
|
69
|
+
| `snakeboot.boot`: `run`, `wsgi_application`, `asgi_application` | `SpringApplication.run`, auto-configuration | `snakeboot[configuration,django]` |
|
|
70
|
+
| `CommandLineRunner`, `CommandLineError` | `CommandLineRunner`, Spring Shell's `@Command` | `snakeboot[configuration,django]` |
|
|
71
|
+
| `DjangoConfigurer`, `django_settings` | `WebMvcConfigurer` | `snakeboot[configuration,django]` |
|
|
60
72
|
| `BearerAuthentication` | a `SecurityFilterChain` bean | `snakeboot[ninja]` |
|
|
61
73
|
| `JwtBearerAuthentication`, `OAuth2Properties` | `spring-boot-starter-oauth2-resource-server`, `spring.security.oauth2.resourceserver.jwt.*` | `snakeboot[oauth2]` |
|
|
74
|
+
| `Lifecycle`, `ApplicationReadyEvent`, `run_lifecycle` | `SmartLifecycle`, `ApplicationReadyEvent`, an application without the web starter | wireup |
|
|
75
|
+
| `@scheduled`, `SchedulingProperties` | `@Scheduled` | `snakeboot[configuration,django]` |
|
|
76
|
+
| `@rabbit_listener`, `RabbitTemplate`, `RabbitProperties`, `RejectAndDontRequeueError` | `@RabbitListener`, `RabbitTemplate`, `spring.rabbitmq.*`, `AmqpRejectAndDontRequeueException` | `snakeboot[rabbitmq]` |
|
|
62
77
|
| `@openapi_definition` | springdoc's `@OpenAPIDefinition` | nothing |
|
|
63
|
-
| `
|
|
78
|
+
| `snakeboot.django_ninja` | the REST API and `/health` of the application, on Django Ninja | `snakeboot[ninja]` |
|
|
64
79
|
| `RecordingTransactionManager`, `declared_classes` | spring-test | nothing |
|
|
65
80
|
|
|
66
81
|
The transaction, component scan and testing packages import nothing but the standard library,
|
|
67
|
-
so the application layer of a hexagonal application can use `@transactional` without importing Django.
|
|
82
|
+
so the application layer of a hexagonal application can use `@transactional` without importing Django. The REST,
|
|
83
|
+
command line, event and lifecycle packages add wireup, and no web framework: a controller or a command is a module
|
|
84
|
+
that imports without Django.
|
|
68
85
|
|
|
69
86
|
## Install
|
|
70
87
|
|
|
71
88
|
```bash
|
|
72
89
|
uv add "snakeboot[configuration,django,ninja]"
|
|
90
|
+
uv add "snakeboot[configuration,django,ninja,rabbitmq]" # with RabbitMQ
|
|
73
91
|
```
|
|
74
92
|
|
|
75
93
|
## A tour
|
|
76
94
|
|
|
77
95
|
**The application.** A class marked with `@application_root`, like `@SpringBootApplication`, is the application.
|
|
78
96
|
Its component scan decides what goes into the container: the package of the class, or the packages `base_packages`
|
|
79
|
-
names. The scan skips
|
|
80
|
-
provides itself (the event publisher). `run` starts it, like `SpringApplication.run
|
|
97
|
+
names. The scan skips the server entry points (`wsgi.py`, `asgi.py`) and migrations, and adds the components snakeboot
|
|
98
|
+
provides itself (the event publisher, the lifecycle, the scheduler). `run` starts it, like `SpringApplication.run`;
|
|
99
|
+
the entry points import it from `snakeboot.boot`, which stays the same whatever framework runs underneath.
|
|
81
100
|
|
|
82
101
|
```python
|
|
83
102
|
# myapp/myapp_application.py
|
|
@@ -87,9 +106,9 @@ class MyApplication: ...
|
|
|
87
106
|
|
|
88
107
|
|
|
89
108
|
# manage.py and myapp/__main__.py # myapp/wsgi.py, for gunicorn
|
|
90
|
-
run
|
|
109
|
+
from snakeboot.boot import run application = wsgi_application(MyApplication)
|
|
91
110
|
# myapp/asgi.py, for uvicorn (streaming, server-sent events)
|
|
92
|
-
|
|
111
|
+
run(MyApplication) application = asgi_application(MyApplication)
|
|
93
112
|
```
|
|
94
113
|
|
|
95
114
|
The application writes no settings.py and no urls.py: snakeboot owns Django's settings module and builds every
|
|
@@ -110,7 +129,7 @@ class ChatbotProperties(Section):
|
|
|
110
129
|
```
|
|
111
130
|
|
|
112
131
|
**Django, configured.** `django_settings(MyApplication)` builds the settings, like Spring Boot's
|
|
113
|
-
auto-configuration: `SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS` and `LOGGING` from `[
|
|
132
|
+
auto-configuration: `SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS` and `LOGGING` from `[server]`; `DATABASES` from
|
|
114
133
|
`[database]` (PostgreSQL) and `CACHES` from `[cache]` (Redis), when `application.toml` has those tables; the container
|
|
115
134
|
(`WIREUP`) from the component scan; and `INSTALLED_APPS`: snakeboot, wireup and every Django `AppConfig` of the
|
|
116
135
|
component scan. The scan finds them, and the models, by reading the source, so nothing is imported before Django
|
|
@@ -183,8 +202,12 @@ class ParticipantNotificationListener:
|
|
|
183
202
|
```
|
|
184
203
|
|
|
185
204
|
**REST controllers.** Singleton classes with constructor injection. `ControllerScan` finds them in the modules of
|
|
186
|
-
the component scan, the same modules that fill the container, and
|
|
187
|
-
|
|
205
|
+
the component scan, the same modules that fill the container, and `snakeboot.django_ninja` publishes their routes.
|
|
206
|
+
A controller sees no request: an endpoint takes what the call carries (path and query parameters, a body as a
|
|
207
|
+
Pydantic model) and returns the body, whose type is its return annotation; `status=` sets the status of the answer,
|
|
208
|
+
like `@ResponseStatus`. A call that does not fit (a missing field, a wrong type) is an `InvalidRequestError`, which
|
|
209
|
+
an advice answers like any error, as with Spring's `MethodArgumentNotValidException`. Two endpoints for the same URL,
|
|
210
|
+
two answers for one error, or a method that takes `request` stop the start-up.
|
|
188
211
|
|
|
189
212
|
```python
|
|
190
213
|
@rest_controller("/workshops", tags=["workshops"])
|
|
@@ -192,8 +215,11 @@ class WorkshopRestController:
|
|
|
192
215
|
def __init__(self, enrollment: EnrollmentFacadePort) -> None:
|
|
193
216
|
self._enrollment = enrollment
|
|
194
217
|
|
|
195
|
-
@route.get("/{workshop_id}"
|
|
196
|
-
def get_workshop(self,
|
|
218
|
+
@route.get("/{workshop_id}")
|
|
219
|
+
def get_workshop(self, workshop_id: str) -> WorkshopViewDTO: ...
|
|
220
|
+
|
|
221
|
+
@route.post("", status=201)
|
|
222
|
+
def create_workshop(self, body: NewWorkshopDTO) -> WorkshopViewDTO: ...
|
|
197
223
|
|
|
198
224
|
|
|
199
225
|
@rest_controller_advice
|
|
@@ -202,15 +228,36 @@ class EnrollmentRestErrorAdvice:
|
|
|
202
228
|
def answer_not_found(self, error: Exception) -> dict[str, str]:
|
|
203
229
|
return error_answer(error) # {"code": "workshop_not_found", "detail": "..."}
|
|
204
230
|
|
|
231
|
+
@exception_handler(InvalidRequestError, status=422)
|
|
232
|
+
def answer_invalid_request(self, error: Exception) -> dict[str, str]:
|
|
233
|
+
return error_answer(error) # {"code": "invalid_request", "detail": "body.body.title: Field required"}
|
|
234
|
+
```
|
|
205
235
|
|
|
206
|
-
|
|
207
|
-
|
|
236
|
+
**Server-sent events.** An endpoint that returns `ServerSentEvents` streams, like one that returns Spring's
|
|
237
|
+
`SseEmitter`: the web framework serves it as `text/event-stream`, uncached and unbuffered, and under ASGI a watching
|
|
238
|
+
client holds no thread. `ServerSentEvents.watching` covers what most streams do: it reads a value once in the call
|
|
239
|
+
(so an unknown id answers 404 as usual), then every `every`, and sends it whenever it changed, until `until` holds.
|
|
240
|
+
A silence of 15 seconds gets a comment that keeps proxies open, and a stream ends after 30 minutes, when the
|
|
241
|
+
browser's `EventSource` reconnects. The reads run as the framework runs synchronous code, between fresh database
|
|
242
|
+
connections; `ServerSentEvents(source)` takes an async iterable of `ServerSentEvent`s for any other stream.
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
@route.get("/games/{game_id}/events")
|
|
246
|
+
def watch_game(self, game_id: str) -> ServerSentEvents:
|
|
247
|
+
return ServerSentEvents.watching(
|
|
248
|
+
lambda: self._get_game.execute(GetGameRequestDTO(game_id=game_id)),
|
|
249
|
+
every=timedelta(milliseconds=500),
|
|
250
|
+
until=lambda game: game.status in FINAL_STATUSES,
|
|
251
|
+
as_event=lambda game: ServerSentEvent(game, event="game", id=str(game.version)), # data as JSON
|
|
252
|
+
)
|
|
208
253
|
```
|
|
209
254
|
|
|
210
255
|
**Authentication.** An adapter that extends `BearerAuthentication` is a component of the container, like a
|
|
211
256
|
`SecurityFilterChain` bean: its constructor receives sections and ports by type. With one in the component scan, every
|
|
212
|
-
endpoint needs `Authorization: Bearer <token>`, the OpenAPI document says so, and `
|
|
213
|
-
|
|
257
|
+
endpoint needs `Authorization: Bearer <token>`, the OpenAPI document says so, and a parameter of type `Principal`
|
|
258
|
+
receives whom the token belongs to, like `@AuthenticationPrincipal`. `/health` stays open; an endpoint opts out with
|
|
259
|
+
`public=True`. A public endpoint that asks for a `Principal`, or a `Principal` in an API without authentication,
|
|
260
|
+
stops the start-up.
|
|
214
261
|
|
|
215
262
|
```python
|
|
216
263
|
class KeycloakTokens(BearerAuthentication):
|
|
@@ -219,6 +266,12 @@ class KeycloakTokens(BearerAuthentication):
|
|
|
219
266
|
|
|
220
267
|
@override
|
|
221
268
|
def authenticate(self, token: str) -> str | None: ... # the subject of a valid token, or None for 401
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# in a controller
|
|
272
|
+
@route.post("/{workshop_id}/registrations", status=201)
|
|
273
|
+
def register(self, workshop_id: str, participant: Principal) -> RegistrationDTO:
|
|
274
|
+
return self._enrollment.register(workshop_id, participant_id=participant.name)
|
|
222
275
|
```
|
|
223
276
|
|
|
224
277
|
**Tokens of an authorization server.** For the JWT access tokens of Keycloak or another OAuth2 server, extend
|
|
@@ -238,10 +291,100 @@ jwks_uri = "http://keycloak:8080/realms/kareldegames/protocol/openid-connect/cer
|
|
|
238
291
|
audience = "tictactoe" # the `aud` the tokens must hold
|
|
239
292
|
```
|
|
240
293
|
|
|
294
|
+
**What runs beside the requests.** Django knows no moment "the server starts"; uvicorn does, and says so with ASGI's
|
|
295
|
+
`lifespan`, which `asgi_application` answers. Then every `Lifecycle` component starts, like a `SmartLifecycle` bean,
|
|
296
|
+
and `ApplicationReadyEvent` goes to the event listeners; when uvicorn stops, they stop. Management commands
|
|
297
|
+
(`migrate`), WSGI and the tests start nothing. `python -m myapp run_lifecycle` runs the same lifecycle without a web
|
|
298
|
+
server, until SIGTERM: a process that only listens and schedules, like Spring Boot without the web starter.
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
@application_event_listener
|
|
302
|
+
class RegisterOnStartup:
|
|
303
|
+
@event_listener(ApplicationReadyEvent) # once, when the application has started
|
|
304
|
+
def register(self, _event: ApplicationReadyEvent) -> None: ...
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
**Commands.** A subclass of `CommandLineRunner` that names its command is a command of the application, like a
|
|
308
|
+
`CommandLineRunner` bean: a component whose constructor receives use cases and ports, with arguments declared as with
|
|
309
|
+
argparse in a classmethod, because they are parsed before the command is built: `--help` answers at once, even when
|
|
310
|
+
the command depends on a server that is down. `python -m myapp self_play --games 20` starts the application and runs
|
|
311
|
+
it; `python -m myapp help` lists the commands next to the framework's own (`migrate`, `run_lifecycle`), and
|
|
312
|
+
`help self_play` prints its help, which is its docstring. Unlike Spring Boot, which runs every runner at each start,
|
|
313
|
+
the first argument chooses one, as with Spring Shell's `@Command`.
|
|
314
|
+
A `CommandLineError` stops the command with its message and exit status 1; two commands of one name stop the start.
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
class SelfPlayCommand(CommandLineRunner, command="self_play"):
|
|
318
|
+
"""Lets the AI levels play each other and prints how strong each one is."""
|
|
319
|
+
|
|
320
|
+
def __init__(self, self_play: SelfPlayUseCase) -> None:
|
|
321
|
+
self._self_play = self_play
|
|
322
|
+
|
|
323
|
+
@classmethod
|
|
324
|
+
@override
|
|
325
|
+
def add_arguments(cls, parser: ArgumentParser) -> None:
|
|
326
|
+
parser.add_argument("--games", type=int, default=20, help="Games per pairing.")
|
|
327
|
+
|
|
328
|
+
@override
|
|
329
|
+
def run(self, arguments: Namespace) -> None:
|
|
330
|
+
if arguments.games < 1:
|
|
331
|
+
raise CommandLineError("Play at least one game.")
|
|
332
|
+
print(self._self_play.execute(SelfPlayRequestDTO(games=arguments.games)))
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
**Scheduled methods.** `@scheduled` on a method of a component runs it periodically, like `@Scheduled`: with a fixed
|
|
336
|
+
rate or a fixed delay, on one thread, each run between fresh database connections. A method that raises is logged and
|
|
337
|
+
runs again at its next turn. `[scheduling] auto_startup = false` keeps a process from running them.
|
|
338
|
+
|
|
339
|
+
```python
|
|
340
|
+
@injectable
|
|
341
|
+
class AbortExpiredGamesJob:
|
|
342
|
+
@scheduled(fixed_delay=timedelta(seconds=2))
|
|
343
|
+
def abort_expired_games(self) -> None: ...
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
**RabbitMQ.** With the table `[rabbitmq]` (extra `snakeboot[rabbitmq]`, through pika), `@rabbit_listener` makes a
|
|
347
|
+
method of a component consume a queue, like `@RabbitListener` with `@QueueBinding`: the durable topic exchange, the
|
|
348
|
+
queue and the binding are declared at start-up, the JSON body becomes the pydantic model, `dict` or `bytes` the method
|
|
349
|
+
takes, and each listener has a thread and a connection of its own. Returning acknowledges the message;
|
|
350
|
+
`RejectAndDontRequeueError` or an unreadable body refuses it for good; another failure is tried three times, a second
|
|
351
|
+
apart, then refused, as Spring Boot's `retry.enabled` does. The `RabbitTemplate` sends persistent JSON, confirmed by the
|
|
352
|
+
broker. `[rabbitmq.listener] auto_startup = false` keeps a process from consuming. Under uvicorn every worker process
|
|
353
|
+
runs its own listeners and scheduled methods, like Spring's `concurrency`: RabbitMQ gives each message to one of them,
|
|
354
|
+
and a scheduled method runs once per process.
|
|
355
|
+
|
|
356
|
+
```python
|
|
357
|
+
@injectable
|
|
358
|
+
class GameStartedListener:
|
|
359
|
+
def __init__(self, start_game: StartGameUseCase) -> None:
|
|
360
|
+
self._start_game = start_game
|
|
361
|
+
|
|
362
|
+
@rabbit_listener(queue="tictactoe.game-started", exchange="games", routing_key="game.tictactoe.started")
|
|
363
|
+
def on_game_started(self, message: GameStartedMessage) -> None: ... # a pydantic model
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# an adapter that sends
|
|
367
|
+
self._rabbit.convert_and_send("games", "game.tictactoe.finished", event, message_id=f"{game_id}.finished")
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
```toml
|
|
371
|
+
[rabbitmq]
|
|
372
|
+
host = "rabbitmq"
|
|
373
|
+
port = 5672
|
|
374
|
+
username = "guest" # the password comes from RABBITMQ__PASSWORD
|
|
375
|
+
virtual_host = "/"
|
|
376
|
+
|
|
377
|
+
[rabbitmq.listener]
|
|
378
|
+
auto_startup = true
|
|
379
|
+
```
|
|
380
|
+
|
|
241
381
|
## Design rules
|
|
242
382
|
|
|
243
|
-
* Imitate the basics, not all of Spring: no AOP, no
|
|
244
|
-
* Fail at start-up, not at the first request: a broken controller, listener, root or configuration stops the
|
|
383
|
+
* Imitate the basics, not all of Spring: no AOP, no cron, no message broker other than RabbitMQ.
|
|
384
|
+
* Fail at start-up, not at the first request: a broken controller, command, listener, root or configuration stops the
|
|
385
|
+
application.
|
|
386
|
+
* An application imports snakeboot, not the framework underneath: only the `django*` packages of snakeboot know Django
|
|
387
|
+
or Django Ninja, so another framework means another binding, not other applications.
|
|
245
388
|
* Each package knows as little as possible; `uv run lint-imports` enforces it.
|
|
246
389
|
|
|
247
390
|
## Develop
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# snakeboot
|
|
2
|
+
|
|
3
|
+
Spring Boot ergonomics for hexagonal Python applications, wired by [wireup](https://github.com/maldoinc/wireup)
|
|
4
|
+
and running on Django and Django Ninja underneath.
|
|
5
|
+
|
|
6
|
+
snakeboot imitates the basics of Spring Boot, not Spring itself. Each part is a handful of small
|
|
7
|
+
classes you can read in an afternoon, and none of them knows anything about your domain.
|
|
8
|
+
|
|
9
|
+
An application speaks snakeboot, not Django: its entry points, controllers, commands, listeners and configuration
|
|
10
|
+
import nothing of Django or Django Ninja, so snakeboot can bind another framework without changing them. Only the
|
|
11
|
+
persistence adapters, which hold the models and queries of the ORM, and the tests that use Django's database, still
|
|
12
|
+
see Django.
|
|
13
|
+
|
|
14
|
+
| snakeboot | Spring Boot | Needs |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| `@transactional`, `after_commit` | `@Transactional`, `@TransactionalEventListener(AFTER_COMMIT)` | nothing |
|
|
17
|
+
| `@application_root`, `ComponentScan` | `@SpringBootApplication`, `@ComponentScan` | nothing |
|
|
18
|
+
| `ApplicationEventPublisher`, `@event_listener`, `@transactional_event_listener` | the same names | wireup |
|
|
19
|
+
| `@rest_controller`, `@route.get(status=...)`, `@rest_controller_advice`, `@exception_handler` | `@RestController`, `@GetMapping`, `@ResponseStatus`, `@RestControllerAdvice`, `@ExceptionHandler` | `snakeboot[ninja]` |
|
|
20
|
+
| `Principal` | `@AuthenticationPrincipal` | `snakeboot[ninja]` |
|
|
21
|
+
| `InvalidRequestError`, `RequestProblem` | `MethodArgumentNotValidException` | `snakeboot[ninja]` |
|
|
22
|
+
| `ServerSentEvents`, `ServerSentEvent` | `SseEmitter`, `ServerSentEvent` | `snakeboot[ninja]` |
|
|
23
|
+
| `DjangoAtomicTransactionManager` | `PlatformTransactionManager` for JDBC | `snakeboot[django]` |
|
|
24
|
+
| `@configuration_properties`, `Section`, `load_configuration`, `application-<profile>.toml` | `@ConfigurationProperties`, `@ConfigurationPropertiesScan`, profiles | `snakeboot[configuration]` |
|
|
25
|
+
| `ServerProperties`, `DatabaseProperties`, `CacheProperties` | `server.*`, `spring.datasource.*`, `spring.data.redis.*` | `snakeboot[configuration]` |
|
|
26
|
+
| `snakeboot.boot`: `run`, `wsgi_application`, `asgi_application` | `SpringApplication.run`, auto-configuration | `snakeboot[configuration,django]` |
|
|
27
|
+
| `CommandLineRunner`, `CommandLineError` | `CommandLineRunner`, Spring Shell's `@Command` | `snakeboot[configuration,django]` |
|
|
28
|
+
| `DjangoConfigurer`, `django_settings` | `WebMvcConfigurer` | `snakeboot[configuration,django]` |
|
|
29
|
+
| `BearerAuthentication` | a `SecurityFilterChain` bean | `snakeboot[ninja]` |
|
|
30
|
+
| `JwtBearerAuthentication`, `OAuth2Properties` | `spring-boot-starter-oauth2-resource-server`, `spring.security.oauth2.resourceserver.jwt.*` | `snakeboot[oauth2]` |
|
|
31
|
+
| `Lifecycle`, `ApplicationReadyEvent`, `run_lifecycle` | `SmartLifecycle`, `ApplicationReadyEvent`, an application without the web starter | wireup |
|
|
32
|
+
| `@scheduled`, `SchedulingProperties` | `@Scheduled` | `snakeboot[configuration,django]` |
|
|
33
|
+
| `@rabbit_listener`, `RabbitTemplate`, `RabbitProperties`, `RejectAndDontRequeueError` | `@RabbitListener`, `RabbitTemplate`, `spring.rabbitmq.*`, `AmqpRejectAndDontRequeueException` | `snakeboot[rabbitmq]` |
|
|
34
|
+
| `@openapi_definition` | springdoc's `@OpenAPIDefinition` | nothing |
|
|
35
|
+
| `snakeboot.django_ninja` | the REST API and `/health` of the application, on Django Ninja | `snakeboot[ninja]` |
|
|
36
|
+
| `RecordingTransactionManager`, `declared_classes` | spring-test | nothing |
|
|
37
|
+
|
|
38
|
+
The transaction, component scan and testing packages import nothing but the standard library,
|
|
39
|
+
so the application layer of a hexagonal application can use `@transactional` without importing Django. The REST,
|
|
40
|
+
command line, event and lifecycle packages add wireup, and no web framework: a controller or a command is a module
|
|
41
|
+
that imports without Django.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv add "snakeboot[configuration,django,ninja]"
|
|
47
|
+
uv add "snakeboot[configuration,django,ninja,rabbitmq]" # with RabbitMQ
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## A tour
|
|
51
|
+
|
|
52
|
+
**The application.** A class marked with `@application_root`, like `@SpringBootApplication`, is the application.
|
|
53
|
+
Its component scan decides what goes into the container: the package of the class, or the packages `base_packages`
|
|
54
|
+
names. The scan skips the server entry points (`wsgi.py`, `asgi.py`) and migrations, and adds the components snakeboot
|
|
55
|
+
provides itself (the event publisher, the lifecycle, the scheduler). `run` starts it, like `SpringApplication.run`;
|
|
56
|
+
the entry points import it from `snakeboot.boot`, which stays the same whatever framework runs underneath.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
# myapp/myapp_application.py
|
|
60
|
+
@openapi_definition(title="My service", version="1.0.0")
|
|
61
|
+
@application_root(base_packages=("myapp_core",))
|
|
62
|
+
class MyApplication: ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# manage.py and myapp/__main__.py # myapp/wsgi.py, for gunicorn
|
|
66
|
+
from snakeboot.boot import run application = wsgi_application(MyApplication)
|
|
67
|
+
# myapp/asgi.py, for uvicorn (streaming, server-sent events)
|
|
68
|
+
run(MyApplication) application = asgi_application(MyApplication)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The application writes no settings.py and no urls.py: snakeboot owns Django's settings module and builds every
|
|
72
|
+
setting from the application class and its `application.toml`, next to it (`django_settings`, below). Nothing can
|
|
73
|
+
change them after snakeboot. The URLs are the REST API under `/api/`, described by `@openapi_definition`, and
|
|
74
|
+
`/health`.
|
|
75
|
+
|
|
76
|
+
**Typed configuration.** A `Section` (a frozen Pydantic model that refuses unknown keys, the base class of every
|
|
77
|
+
configuration model) is bound to one table of `application.toml`, like `@ConfigurationProperties`. A section is a
|
|
78
|
+
component of the module that declares it: when the component scan puts that module in the container, the section
|
|
79
|
+
comes along, like `@ConfigurationPropertiesScan`, and an adapter asks for it by type.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
@configuration_properties("chatbot")
|
|
83
|
+
class ChatbotProperties(Section):
|
|
84
|
+
model: str # no default: the value is in application.toml
|
|
85
|
+
llm: LanguageModelProperties # a nested Section is the table [chatbot.llm]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Django, configured.** `django_settings(MyApplication)` builds the settings, like Spring Boot's
|
|
89
|
+
auto-configuration: `SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS` and `LOGGING` from `[server]`; `DATABASES` from
|
|
90
|
+
`[database]` (PostgreSQL) and `CACHES` from `[cache]` (Redis), when `application.toml` has those tables; the container
|
|
91
|
+
(`WIREUP`) from the component scan; and `INSTALLED_APPS`: snakeboot, wireup and every Django `AppConfig` of the
|
|
92
|
+
component scan. The scan finds them, and the models, by reading the source, so nothing is imported before Django
|
|
93
|
+
starts; a model outside every app stops the start. When Django starts, `snakeboot.django_application` reads the
|
|
94
|
+
sections declared in the modules of the container, checks every table and, with a database, installs the transaction
|
|
95
|
+
manager of `@transactional`.
|
|
96
|
+
|
|
97
|
+
Django apps and middleware from outside the project come from a `DjangoConfigurer`, like a `WebMvcConfigurer`.
|
|
98
|
+
snakeboot finds it in the component scan or next to the application class, as it finds an `AppConfig`, and imports
|
|
99
|
+
its module while it builds the settings, so that module holds strings only:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
class CorsConfigurer(DjangoConfigurer):
|
|
103
|
+
installed_apps = ("corsheaders",)
|
|
104
|
+
middleware = ("corsheaders.middleware.CorsMiddleware",) # after wireup's, before CommonMiddleware
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
A test that needs a double takes the settings and replaces that one setting:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
# settings_under_test.py, the DJANGO_SETTINGS_MODULE of the tests
|
|
111
|
+
globals().update(django_settings(MyApplication))
|
|
112
|
+
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Without Django, `load_configuration(ChatbotProperties, package="myapp")` reads the sections it is given.
|
|
116
|
+
|
|
117
|
+
The values come from three places, a later one overriding an earlier one key by key:
|
|
118
|
+
|
|
119
|
+
| Source | Holds | Spring Boot |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| `application.toml` in the package | every value the application runs with in production | `application.yml` |
|
|
122
|
+
| `application-<profile>.toml`, when `SNAKEBOOT_PROFILE` names one | what differs on a developer machine, say | `application-local.yml`, `SPRING_PROFILES_ACTIVE` |
|
|
123
|
+
| environment variables `SECTION__KEY__SUBKEY` | secrets and addresses of one deployment | relaxed binding |
|
|
124
|
+
|
|
125
|
+
Nothing else is read: no `.env` file, no default in code, no command line. The start stops when a model does
|
|
126
|
+
not extend `Section`, changes its `model_config` or has a default; when a file or a value is missing; when a key
|
|
127
|
+
or a table is unknown (a typo in `application.toml` or in `CHATBOT__LLM__MODLE`). Tables are merged, not replaced,
|
|
128
|
+
so put the variant without keys of its own in `application.toml` when a profile or the environment may choose
|
|
129
|
+
another.
|
|
130
|
+
|
|
131
|
+
**Transactions.** A use case declares its boundary; composition chooses the technology.
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
@injectable
|
|
135
|
+
class CancelRegistrationUseCase:
|
|
136
|
+
@transactional
|
|
137
|
+
def execute(self, request: CancelRegistrationRequestDTO) -> None:
|
|
138
|
+
workshop = self._workshops.get(...)
|
|
139
|
+
workshop.cancel_registration(...)
|
|
140
|
+
self._workshops.save(workshop)
|
|
141
|
+
self._events.publish_all(workshop.pull_events())
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# at start-up; snakeboot.django_application does this itself when the application has a database
|
|
145
|
+
TransactionManagerRegistry.install(DjangoAtomicTransactionManager())
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Application events.** Listeners react to facts after the commit, so a rolled-back change never
|
|
149
|
+
reaches anyone, and a failing listener cannot undo a committed one.
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
@application_event_listener
|
|
153
|
+
class ParticipantNotificationListener:
|
|
154
|
+
def __init__(self, notifications: NotificationPort) -> None:
|
|
155
|
+
self._notifications = notifications
|
|
156
|
+
|
|
157
|
+
@transactional_event_listener(WaitlistedParticipantPromoted)
|
|
158
|
+
def notify_promoted(self, event: WaitlistedParticipantPromoted) -> None: ...
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**REST controllers.** Singleton classes with constructor injection. `ControllerScan` finds them in the modules of
|
|
162
|
+
the component scan, the same modules that fill the container, and `snakeboot.django_ninja` publishes their routes.
|
|
163
|
+
A controller sees no request: an endpoint takes what the call carries (path and query parameters, a body as a
|
|
164
|
+
Pydantic model) and returns the body, whose type is its return annotation; `status=` sets the status of the answer,
|
|
165
|
+
like `@ResponseStatus`. A call that does not fit (a missing field, a wrong type) is an `InvalidRequestError`, which
|
|
166
|
+
an advice answers like any error, as with Spring's `MethodArgumentNotValidException`. Two endpoints for the same URL,
|
|
167
|
+
two answers for one error, or a method that takes `request` stop the start-up.
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
@rest_controller("/workshops", tags=["workshops"])
|
|
171
|
+
class WorkshopRestController:
|
|
172
|
+
def __init__(self, enrollment: EnrollmentFacadePort) -> None:
|
|
173
|
+
self._enrollment = enrollment
|
|
174
|
+
|
|
175
|
+
@route.get("/{workshop_id}")
|
|
176
|
+
def get_workshop(self, workshop_id: str) -> WorkshopViewDTO: ...
|
|
177
|
+
|
|
178
|
+
@route.post("", status=201)
|
|
179
|
+
def create_workshop(self, body: NewWorkshopDTO) -> WorkshopViewDTO: ...
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@rest_controller_advice
|
|
183
|
+
class EnrollmentRestErrorAdvice:
|
|
184
|
+
@exception_handler(WorkshopNotFoundError, status=404)
|
|
185
|
+
def answer_not_found(self, error: Exception) -> dict[str, str]:
|
|
186
|
+
return error_answer(error) # {"code": "workshop_not_found", "detail": "..."}
|
|
187
|
+
|
|
188
|
+
@exception_handler(InvalidRequestError, status=422)
|
|
189
|
+
def answer_invalid_request(self, error: Exception) -> dict[str, str]:
|
|
190
|
+
return error_answer(error) # {"code": "invalid_request", "detail": "body.body.title: Field required"}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**Server-sent events.** An endpoint that returns `ServerSentEvents` streams, like one that returns Spring's
|
|
194
|
+
`SseEmitter`: the web framework serves it as `text/event-stream`, uncached and unbuffered, and under ASGI a watching
|
|
195
|
+
client holds no thread. `ServerSentEvents.watching` covers what most streams do: it reads a value once in the call
|
|
196
|
+
(so an unknown id answers 404 as usual), then every `every`, and sends it whenever it changed, until `until` holds.
|
|
197
|
+
A silence of 15 seconds gets a comment that keeps proxies open, and a stream ends after 30 minutes, when the
|
|
198
|
+
browser's `EventSource` reconnects. The reads run as the framework runs synchronous code, between fresh database
|
|
199
|
+
connections; `ServerSentEvents(source)` takes an async iterable of `ServerSentEvent`s for any other stream.
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
@route.get("/games/{game_id}/events")
|
|
203
|
+
def watch_game(self, game_id: str) -> ServerSentEvents:
|
|
204
|
+
return ServerSentEvents.watching(
|
|
205
|
+
lambda: self._get_game.execute(GetGameRequestDTO(game_id=game_id)),
|
|
206
|
+
every=timedelta(milliseconds=500),
|
|
207
|
+
until=lambda game: game.status in FINAL_STATUSES,
|
|
208
|
+
as_event=lambda game: ServerSentEvent(game, event="game", id=str(game.version)), # data as JSON
|
|
209
|
+
)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**Authentication.** An adapter that extends `BearerAuthentication` is a component of the container, like a
|
|
213
|
+
`SecurityFilterChain` bean: its constructor receives sections and ports by type. With one in the component scan, every
|
|
214
|
+
endpoint needs `Authorization: Bearer <token>`, the OpenAPI document says so, and a parameter of type `Principal`
|
|
215
|
+
receives whom the token belongs to, like `@AuthenticationPrincipal`. `/health` stays open; an endpoint opts out with
|
|
216
|
+
`public=True`. A public endpoint that asks for a `Principal`, or a `Principal` in an API without authentication,
|
|
217
|
+
stops the start-up.
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
class KeycloakTokens(BearerAuthentication):
|
|
221
|
+
def __init__(self, properties: SecurityProperties) -> None:
|
|
222
|
+
self._properties = properties
|
|
223
|
+
|
|
224
|
+
@override
|
|
225
|
+
def authenticate(self, token: str) -> str | None: ... # the subject of a valid token, or None for 401
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# in a controller
|
|
229
|
+
@route.post("/{workshop_id}/registrations", status=201)
|
|
230
|
+
def register(self, workshop_id: str, participant: Principal) -> RegistrationDTO:
|
|
231
|
+
return self._enrollment.register(workshop_id, participant_id=participant.name)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
**Tokens of an authorization server.** For the JWT access tokens of Keycloak or another OAuth2 server, extend
|
|
235
|
+
`JwtBearerAuthentication` (extra `snakeboot[oauth2]`) and add the table `[oauth2]`, like Spring Boot's resource
|
|
236
|
+
server. It fetches the issuer's public keys from `jwks_uri` and caches them, and accepts a token only when the
|
|
237
|
+
signature, the issuer, the audience and the expiry are right; its `sub` is the principal.
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
class PlayerTokens(JwtBearerAuthentication):
|
|
241
|
+
"""The access tokens that Keycloak gives the platform's players."""
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
```toml
|
|
245
|
+
[oauth2]
|
|
246
|
+
issuer_uri = "http://localhost:8180/realms/kareldegames" # the `iss` of the tokens
|
|
247
|
+
jwks_uri = "http://keycloak:8080/realms/kareldegames/protocol/openid-connect/certs"
|
|
248
|
+
audience = "tictactoe" # the `aud` the tokens must hold
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
**What runs beside the requests.** Django knows no moment "the server starts"; uvicorn does, and says so with ASGI's
|
|
252
|
+
`lifespan`, which `asgi_application` answers. Then every `Lifecycle` component starts, like a `SmartLifecycle` bean,
|
|
253
|
+
and `ApplicationReadyEvent` goes to the event listeners; when uvicorn stops, they stop. Management commands
|
|
254
|
+
(`migrate`), WSGI and the tests start nothing. `python -m myapp run_lifecycle` runs the same lifecycle without a web
|
|
255
|
+
server, until SIGTERM: a process that only listens and schedules, like Spring Boot without the web starter.
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
@application_event_listener
|
|
259
|
+
class RegisterOnStartup:
|
|
260
|
+
@event_listener(ApplicationReadyEvent) # once, when the application has started
|
|
261
|
+
def register(self, _event: ApplicationReadyEvent) -> None: ...
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**Commands.** A subclass of `CommandLineRunner` that names its command is a command of the application, like a
|
|
265
|
+
`CommandLineRunner` bean: a component whose constructor receives use cases and ports, with arguments declared as with
|
|
266
|
+
argparse in a classmethod, because they are parsed before the command is built: `--help` answers at once, even when
|
|
267
|
+
the command depends on a server that is down. `python -m myapp self_play --games 20` starts the application and runs
|
|
268
|
+
it; `python -m myapp help` lists the commands next to the framework's own (`migrate`, `run_lifecycle`), and
|
|
269
|
+
`help self_play` prints its help, which is its docstring. Unlike Spring Boot, which runs every runner at each start,
|
|
270
|
+
the first argument chooses one, as with Spring Shell's `@Command`.
|
|
271
|
+
A `CommandLineError` stops the command with its message and exit status 1; two commands of one name stop the start.
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
class SelfPlayCommand(CommandLineRunner, command="self_play"):
|
|
275
|
+
"""Lets the AI levels play each other and prints how strong each one is."""
|
|
276
|
+
|
|
277
|
+
def __init__(self, self_play: SelfPlayUseCase) -> None:
|
|
278
|
+
self._self_play = self_play
|
|
279
|
+
|
|
280
|
+
@classmethod
|
|
281
|
+
@override
|
|
282
|
+
def add_arguments(cls, parser: ArgumentParser) -> None:
|
|
283
|
+
parser.add_argument("--games", type=int, default=20, help="Games per pairing.")
|
|
284
|
+
|
|
285
|
+
@override
|
|
286
|
+
def run(self, arguments: Namespace) -> None:
|
|
287
|
+
if arguments.games < 1:
|
|
288
|
+
raise CommandLineError("Play at least one game.")
|
|
289
|
+
print(self._self_play.execute(SelfPlayRequestDTO(games=arguments.games)))
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**Scheduled methods.** `@scheduled` on a method of a component runs it periodically, like `@Scheduled`: with a fixed
|
|
293
|
+
rate or a fixed delay, on one thread, each run between fresh database connections. A method that raises is logged and
|
|
294
|
+
runs again at its next turn. `[scheduling] auto_startup = false` keeps a process from running them.
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
@injectable
|
|
298
|
+
class AbortExpiredGamesJob:
|
|
299
|
+
@scheduled(fixed_delay=timedelta(seconds=2))
|
|
300
|
+
def abort_expired_games(self) -> None: ...
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
**RabbitMQ.** With the table `[rabbitmq]` (extra `snakeboot[rabbitmq]`, through pika), `@rabbit_listener` makes a
|
|
304
|
+
method of a component consume a queue, like `@RabbitListener` with `@QueueBinding`: the durable topic exchange, the
|
|
305
|
+
queue and the binding are declared at start-up, the JSON body becomes the pydantic model, `dict` or `bytes` the method
|
|
306
|
+
takes, and each listener has a thread and a connection of its own. Returning acknowledges the message;
|
|
307
|
+
`RejectAndDontRequeueError` or an unreadable body refuses it for good; another failure is tried three times, a second
|
|
308
|
+
apart, then refused, as Spring Boot's `retry.enabled` does. The `RabbitTemplate` sends persistent JSON, confirmed by the
|
|
309
|
+
broker. `[rabbitmq.listener] auto_startup = false` keeps a process from consuming. Under uvicorn every worker process
|
|
310
|
+
runs its own listeners and scheduled methods, like Spring's `concurrency`: RabbitMQ gives each message to one of them,
|
|
311
|
+
and a scheduled method runs once per process.
|
|
312
|
+
|
|
313
|
+
```python
|
|
314
|
+
@injectable
|
|
315
|
+
class GameStartedListener:
|
|
316
|
+
def __init__(self, start_game: StartGameUseCase) -> None:
|
|
317
|
+
self._start_game = start_game
|
|
318
|
+
|
|
319
|
+
@rabbit_listener(queue="tictactoe.game-started", exchange="games", routing_key="game.tictactoe.started")
|
|
320
|
+
def on_game_started(self, message: GameStartedMessage) -> None: ... # a pydantic model
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# an adapter that sends
|
|
324
|
+
self._rabbit.convert_and_send("games", "game.tictactoe.finished", event, message_id=f"{game_id}.finished")
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
```toml
|
|
328
|
+
[rabbitmq]
|
|
329
|
+
host = "rabbitmq"
|
|
330
|
+
port = 5672
|
|
331
|
+
username = "guest" # the password comes from RABBITMQ__PASSWORD
|
|
332
|
+
virtual_host = "/"
|
|
333
|
+
|
|
334
|
+
[rabbitmq.listener]
|
|
335
|
+
auto_startup = true
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## Design rules
|
|
339
|
+
|
|
340
|
+
* Imitate the basics, not all of Spring: no AOP, no cron, no message broker other than RabbitMQ.
|
|
341
|
+
* Fail at start-up, not at the first request: a broken controller, command, listener, root or configuration stops the
|
|
342
|
+
application.
|
|
343
|
+
* An application imports snakeboot, not the framework underneath: only the `django*` packages of snakeboot know Django
|
|
344
|
+
or Django Ninja, so another framework means another binding, not other applications.
|
|
345
|
+
* Each package knows as little as possible; `uv run lint-imports` enforces it.
|
|
346
|
+
|
|
347
|
+
## Develop
|
|
348
|
+
|
|
349
|
+
```bash
|
|
350
|
+
uv sync
|
|
351
|
+
uv run pre-commit install
|
|
352
|
+
uv run pytest # tests, including the import contracts
|
|
353
|
+
uv run ruff check # Clean Code rules
|
|
354
|
+
uv run mypy # strict
|
|
355
|
+
```
|