urich 0.1.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.
- urich-0.1.0/LICENSE +21 -0
- urich-0.1.0/PKG-INFO +101 -0
- urich-0.1.0/README.md +73 -0
- urich-0.1.0/pyproject.toml +42 -0
- urich-0.1.0/setup.cfg +4 -0
- urich-0.1.0/src/urich/__init__.py +13 -0
- urich-0.1.0/src/urich/cli/__init__.py +3 -0
- urich-0.1.0/src/urich/cli/main.py +109 -0
- urich-0.1.0/src/urich/cli/templates.py +151 -0
- urich-0.1.0/src/urich/core/__init__.py +13 -0
- urich-0.1.0/src/urich/core/app.py +107 -0
- urich-0.1.0/src/urich/core/config.py +20 -0
- urich-0.1.0/src/urich/core/container.py +70 -0
- urich-0.1.0/src/urich/core/module.py +16 -0
- urich-0.1.0/src/urich/core/openapi.py +143 -0
- urich-0.1.0/src/urich/core/routing.py +32 -0
- urich-0.1.0/src/urich/ddd/__init__.py +4 -0
- urich-0.1.0/src/urich/ddd/commands.py +14 -0
- urich-0.1.0/src/urich/ddd/domain_module.py +146 -0
- urich-0.1.0/src/urich/discovery/__init__.py +4 -0
- urich-0.1.0/src/urich/discovery/discovery_module.py +36 -0
- urich-0.1.0/src/urich/discovery/protocol.py +29 -0
- urich-0.1.0/src/urich/domain/__init__.py +16 -0
- urich-0.1.0/src/urich/domain/aggregate.py +25 -0
- urich-0.1.0/src/urich/domain/entity.py +16 -0
- urich-0.1.0/src/urich/domain/events.py +47 -0
- urich-0.1.0/src/urich/domain/repository.py +21 -0
- urich-0.1.0/src/urich/domain/value_object.py +8 -0
- urich-0.1.0/src/urich/events/__init__.py +11 -0
- urich-0.1.0/src/urich/events/event_bus_module.py +40 -0
- urich-0.1.0/src/urich/events/outbox.py +62 -0
- urich-0.1.0/src/urich/events/protocol.py +18 -0
- urich-0.1.0/src/urich/rpc/__init__.py +4 -0
- urich-0.1.0/src/urich/rpc/protocol.py +18 -0
- urich-0.1.0/src/urich/rpc/rpc_module.py +106 -0
- urich-0.1.0/src/urich.egg-info/PKG-INFO +101 -0
- urich-0.1.0/src/urich.egg-info/SOURCES.txt +39 -0
- urich-0.1.0/src/urich.egg-info/dependency_links.txt +1 -0
- urich-0.1.0/src/urich.egg-info/entry_points.txt +2 -0
- urich-0.1.0/src/urich.egg-info/requires.txt +11 -0
- urich-0.1.0/src/urich.egg-info/top_level.txt +1 -0
urich-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Korolev Roman Urich
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
urich-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: urich
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async DDD framework for microservices on Starlette
|
|
5
|
+
Project-URL: Homepage, https://github.com/KashN9sh/urich
|
|
6
|
+
Project-URL: Repository, https://github.com/KashN9sh/urich
|
|
7
|
+
Keywords: asgi,ddd,microservices,starlette,cqrs
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: starlette>=0.41.0
|
|
19
|
+
Requires-Dist: pydantic>=2.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
23
|
+
Requires-Dist: httpx; extra == "dev"
|
|
24
|
+
Requires-Dist: uvicorn; extra == "dev"
|
|
25
|
+
Provides-Extra: cli
|
|
26
|
+
Requires-Dist: typer>=0.9.0; extra == "cli"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# Urich
|
|
30
|
+
|
|
31
|
+
Async DDD framework for microservices on Starlette. The application is composed from module objects via `app.register(module)` — similar to FastAPI with routers, but one consistent style for domain, events, RPC and discovery.
|
|
32
|
+
|
|
33
|
+
## Idea
|
|
34
|
+
|
|
35
|
+
- **One object = one building block:** DomainModule, EventBusModule, OutboxModule, DiscoveryModule, RpcModule. All configured via fluent API and attached with `app.register(module)`.
|
|
36
|
+
- **DDD:** Bounded context as DomainModule with `.aggregate()`, `.repository()`, `.command()`, `.query()`, `.on_event()`. Commands and queries get HTTP routes automatically.
|
|
37
|
+
- **No lock-in:** Protocols (EventBus, ServiceDiscovery, RpcTransport) in core; implementations (Redis, Consul, HTTP+JSON) supplied by the user or optional out-of-the-box adapters.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install urich
|
|
43
|
+
# CLI for generating skeletons:
|
|
44
|
+
pip install "urich[cli]"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from urich import Application
|
|
51
|
+
from urich.ddd import DomainModule
|
|
52
|
+
|
|
53
|
+
# One object = full bounded context
|
|
54
|
+
from orders.module import orders_module
|
|
55
|
+
|
|
56
|
+
app = Application()
|
|
57
|
+
app.register(orders_module)
|
|
58
|
+
|
|
59
|
+
# Run: python -m uvicorn main:app --reload (or: pip install uvicorn && uvicorn main:app --reload)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Routes by convention: `POST /orders/commands/create_order`, `GET /orders/queries/get_order`.
|
|
63
|
+
|
|
64
|
+
## OpenAPI / Swagger
|
|
65
|
+
|
|
66
|
+
After registering all modules, call `app.openapi(title="My API", version="0.1.0")`. Then:
|
|
67
|
+
|
|
68
|
+
- **GET /openapi.json** — OpenAPI 3.0 spec
|
|
69
|
+
- **GET /docs** — Swagger UI
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
app = Application()
|
|
73
|
+
# ... app.register(module) ...
|
|
74
|
+
app.openapi(title="My API", version="0.1.0")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## CLI
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
urich create-app myapp
|
|
81
|
+
cd myapp
|
|
82
|
+
urich add-context orders --dir .
|
|
83
|
+
urich add-aggregate orders Order --dir .
|
|
84
|
+
# In main.py: from orders.module import orders_module; app.register(orders_module)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Module structure (DomainModule)
|
|
88
|
+
|
|
89
|
+
- **domain** — aggregate (AggregateRoot), domain events (DomainEvent).
|
|
90
|
+
- **application** — commands/queries (Command/Query), handlers (one per command/query).
|
|
91
|
+
- **infrastructure** — repository interface and implementation (e.g. in-memory for prototypes).
|
|
92
|
+
- **module.py** — one object `DomainModule("orders").aggregate(...).repository(...).command(...).query(...).on_event(...)`; register in the app with `app.register(orders_module)`.
|
|
93
|
+
|
|
94
|
+
## Other modules
|
|
95
|
+
|
|
96
|
+
- **EventBusModule** — `.adapter(impl)` or `.in_memory()`; in container as EventBus.
|
|
97
|
+
- **OutboxModule** — `.storage(...)` and `.publisher(...)`; contracts in core.
|
|
98
|
+
- **DiscoveryModule** — `.static({"svc": "http://..."})` or `.adapter(impl)`; ServiceDiscovery protocol.
|
|
99
|
+
- **RpcModule** — `.server(path="/rpc")` and `.client(discovery=..., transport=...)`; optional JsonHttpRpcTransport (requires httpx).
|
|
100
|
+
|
|
101
|
+
Full composition example: `examples/ecommerce/main.py`.
|
urich-0.1.0/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Urich
|
|
2
|
+
|
|
3
|
+
Async DDD framework for microservices on Starlette. The application is composed from module objects via `app.register(module)` — similar to FastAPI with routers, but one consistent style for domain, events, RPC and discovery.
|
|
4
|
+
|
|
5
|
+
## Idea
|
|
6
|
+
|
|
7
|
+
- **One object = one building block:** DomainModule, EventBusModule, OutboxModule, DiscoveryModule, RpcModule. All configured via fluent API and attached with `app.register(module)`.
|
|
8
|
+
- **DDD:** Bounded context as DomainModule with `.aggregate()`, `.repository()`, `.command()`, `.query()`, `.on_event()`. Commands and queries get HTTP routes automatically.
|
|
9
|
+
- **No lock-in:** Protocols (EventBus, ServiceDiscovery, RpcTransport) in core; implementations (Redis, Consul, HTTP+JSON) supplied by the user or optional out-of-the-box adapters.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install urich
|
|
15
|
+
# CLI for generating skeletons:
|
|
16
|
+
pip install "urich[cli]"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from urich import Application
|
|
23
|
+
from urich.ddd import DomainModule
|
|
24
|
+
|
|
25
|
+
# One object = full bounded context
|
|
26
|
+
from orders.module import orders_module
|
|
27
|
+
|
|
28
|
+
app = Application()
|
|
29
|
+
app.register(orders_module)
|
|
30
|
+
|
|
31
|
+
# Run: python -m uvicorn main:app --reload (or: pip install uvicorn && uvicorn main:app --reload)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Routes by convention: `POST /orders/commands/create_order`, `GET /orders/queries/get_order`.
|
|
35
|
+
|
|
36
|
+
## OpenAPI / Swagger
|
|
37
|
+
|
|
38
|
+
After registering all modules, call `app.openapi(title="My API", version="0.1.0")`. Then:
|
|
39
|
+
|
|
40
|
+
- **GET /openapi.json** — OpenAPI 3.0 spec
|
|
41
|
+
- **GET /docs** — Swagger UI
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
app = Application()
|
|
45
|
+
# ... app.register(module) ...
|
|
46
|
+
app.openapi(title="My API", version="0.1.0")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## CLI
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
urich create-app myapp
|
|
53
|
+
cd myapp
|
|
54
|
+
urich add-context orders --dir .
|
|
55
|
+
urich add-aggregate orders Order --dir .
|
|
56
|
+
# In main.py: from orders.module import orders_module; app.register(orders_module)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Module structure (DomainModule)
|
|
60
|
+
|
|
61
|
+
- **domain** — aggregate (AggregateRoot), domain events (DomainEvent).
|
|
62
|
+
- **application** — commands/queries (Command/Query), handlers (one per command/query).
|
|
63
|
+
- **infrastructure** — repository interface and implementation (e.g. in-memory for prototypes).
|
|
64
|
+
- **module.py** — one object `DomainModule("orders").aggregate(...).repository(...).command(...).query(...).on_event(...)`; register in the app with `app.register(orders_module)`.
|
|
65
|
+
|
|
66
|
+
## Other modules
|
|
67
|
+
|
|
68
|
+
- **EventBusModule** — `.adapter(impl)` or `.in_memory()`; in container as EventBus.
|
|
69
|
+
- **OutboxModule** — `.storage(...)` and `.publisher(...)`; contracts in core.
|
|
70
|
+
- **DiscoveryModule** — `.static({"svc": "http://..."})` or `.adapter(impl)`; ServiceDiscovery protocol.
|
|
71
|
+
- **RpcModule** — `.server(path="/rpc")` and `.client(discovery=..., transport=...)`; optional JsonHttpRpcTransport (requires httpx).
|
|
72
|
+
|
|
73
|
+
Full composition example: `examples/ecommerce/main.py`.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "urich"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async DDD framework for microservices on Starlette"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"starlette>=0.41.0",
|
|
13
|
+
"pydantic>=2.0",
|
|
14
|
+
]
|
|
15
|
+
keywords = ["asgi", "ddd", "microservices", "starlette", "cqrs"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["pytest", "pytest-asyncio", "httpx", "uvicorn"]
|
|
28
|
+
cli = ["typer>=0.9.0"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/KashN9sh/urich"
|
|
32
|
+
Repository = "https://github.com/KashN9sh/urich"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
urich = "urich.cli.main:main"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["src"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
asyncio_mode = "auto"
|
|
42
|
+
testpaths = ["tests"]
|
urich-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Urich — async DDD framework for microservices.
|
|
3
|
+
Application is composed from module objects via app.register(module).
|
|
4
|
+
"""
|
|
5
|
+
from urich.core import Application, Container, Module, HttpModule, Config
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Application",
|
|
9
|
+
"Container",
|
|
10
|
+
"Module",
|
|
11
|
+
"HttpModule",
|
|
12
|
+
"Config",
|
|
13
|
+
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI for prototyping: create-app, add-context, add-aggregate.
|
|
3
|
+
Generated code composes a DomainModule and registers via app.register(module).
|
|
4
|
+
"""
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import typer
|
|
9
|
+
except ImportError:
|
|
10
|
+
typer = None # type: ignore
|
|
11
|
+
|
|
12
|
+
from urich.cli import templates as T
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Urich CLI: create app and bounded context.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _ensure_typer():
|
|
18
|
+
if typer is None:
|
|
19
|
+
raise SystemExit("CLI requires typer: pip install 'urich[cli]' or pip install typer")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _snake(name: str) -> str:
|
|
23
|
+
return "".join("_" + c.lower() if c.isupper() else c for c in name).lstrip("_")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command()
|
|
27
|
+
def create_app(
|
|
28
|
+
name: str = typer.Argument(..., help="App name (folder)"),
|
|
29
|
+
directory: Path = typer.Option(Path("."), "--dir", "-d", help="Parent directory"),
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Create app skeleton: folder, main.py, config.py."""
|
|
32
|
+
_ensure_typer()
|
|
33
|
+
root = directory / name
|
|
34
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
(root / "main.py").write_text(T.MAIN_PY.format(first_context="orders"), encoding="utf-8")
|
|
36
|
+
(root / "config.py").write_text(
|
|
37
|
+
'"""Config (env/file)."""\n# from dataclasses import dataclass\n# settings = ...\n',
|
|
38
|
+
encoding="utf-8",
|
|
39
|
+
)
|
|
40
|
+
typer.echo(f"Created: {root}/ (main.py, config.py). Add context: urich add-context <name> --dir {root}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command()
|
|
44
|
+
def add_context(
|
|
45
|
+
name: str = typer.Argument(..., help="Bounded context name (e.g. orders)"),
|
|
46
|
+
directory: Path = typer.Option(Path("."), "--dir", "-d", help="App root directory"),
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Add bounded context: folder with domain, application, infrastructure, module (skeleton)."""
|
|
49
|
+
_ensure_typer()
|
|
50
|
+
ctx_dir = directory / name
|
|
51
|
+
ctx_dir.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
ctx_dir.joinpath("domain.py").write_text(
|
|
53
|
+
T.CONTEXT_SKELETON.format(context=name),
|
|
54
|
+
encoding="utf-8",
|
|
55
|
+
)
|
|
56
|
+
ctx_dir.joinpath("application.py").write_text(
|
|
57
|
+
T.CONTEXT_APPLICATION_SKELETON.format(context=name),
|
|
58
|
+
encoding="utf-8",
|
|
59
|
+
)
|
|
60
|
+
ctx_dir.joinpath("infrastructure.py").write_text(
|
|
61
|
+
T.CONTEXT_INFRASTRUCTURE_SKELETON.format(context=name),
|
|
62
|
+
encoding="utf-8",
|
|
63
|
+
)
|
|
64
|
+
ctx_dir.joinpath("module.py").write_text(
|
|
65
|
+
T.CONTEXT_MODULE_SKELETON.format(context=name),
|
|
66
|
+
encoding="utf-8",
|
|
67
|
+
)
|
|
68
|
+
typer.echo(f"Context «{name}»: {ctx_dir}/. Add aggregate: urich add-aggregate {name} <AggregateName> --dir {directory}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command()
|
|
72
|
+
def add_aggregate(
|
|
73
|
+
context: str = typer.Argument(..., help="Context name (folder)"),
|
|
74
|
+
aggregate: str = typer.Argument(..., help="Aggregate name (PascalCase, e.g. Order)"),
|
|
75
|
+
directory: Path = typer.Option(Path("."), "--dir", "-d", help="App root directory"),
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Add aggregate to context: domain, application, infrastructure, module (DomainModule with command/query)."""
|
|
78
|
+
_ensure_typer()
|
|
79
|
+
ctx_dir = directory / context
|
|
80
|
+
if not ctx_dir.is_dir():
|
|
81
|
+
typer.echo(f"Context folder not found: {ctx_dir}. Run first: urich add-context {context} --dir {directory}", err=True)
|
|
82
|
+
raise typer.Exit(1)
|
|
83
|
+
agg_lower = _snake(aggregate)
|
|
84
|
+
ctx_dir.joinpath("domain.py").write_text(
|
|
85
|
+
T.DOMAIN_PY.format(context=context, aggregate=aggregate, aggregate_lower=agg_lower),
|
|
86
|
+
encoding="utf-8",
|
|
87
|
+
)
|
|
88
|
+
ctx_dir.joinpath("application.py").write_text(
|
|
89
|
+
T.APPLICATION_PY.format(context=context, aggregate=aggregate, aggregate_lower=agg_lower),
|
|
90
|
+
encoding="utf-8",
|
|
91
|
+
)
|
|
92
|
+
ctx_dir.joinpath("infrastructure.py").write_text(
|
|
93
|
+
T.INFRASTRUCTURE_PY.format(context=context, aggregate=aggregate, aggregate_lower=agg_lower),
|
|
94
|
+
encoding="utf-8",
|
|
95
|
+
)
|
|
96
|
+
ctx_dir.joinpath("module.py").write_text(
|
|
97
|
+
T.MODULE_PY.format(context=context, aggregate=aggregate, aggregate_lower=agg_lower),
|
|
98
|
+
encoding="utf-8",
|
|
99
|
+
)
|
|
100
|
+
typer.echo(f"Aggregate «{aggregate}» in «{context}»: {ctx_dir}/. In main.py: from {context}.module import {context}_module; app.register({context}_module)")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main() -> None:
|
|
104
|
+
"""Entry point for the urich console command."""
|
|
105
|
+
app()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
main()
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Skeleton templates for generating bounded context and aggregate."""
|
|
2
|
+
|
|
3
|
+
DOMAIN_PY = '''"""Domain {context}: aggregate and events."""
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from urich.domain import AggregateRoot, DomainEvent
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class {aggregate}Created(DomainEvent):
|
|
10
|
+
"""Event: {aggregate} created."""
|
|
11
|
+
{aggregate_lower}_id: str
|
|
12
|
+
# add fields
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class {aggregate}(AggregateRoot):
|
|
16
|
+
def __init__(self, id: str):
|
|
17
|
+
super().__init__(id=id)
|
|
18
|
+
self.raise_event({aggregate}Created({aggregate_lower}_id=id))
|
|
19
|
+
'''
|
|
20
|
+
|
|
21
|
+
APPLICATION_PY = '''"""Application layer: commands, queries, handlers."""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from urich.ddd import Command, Query
|
|
26
|
+
from urich.domain import EventBus
|
|
27
|
+
|
|
28
|
+
from .domain import {aggregate}, {aggregate}Created
|
|
29
|
+
from .infrastructure import I{aggregate}Repository
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Create{aggregate}(Command):
|
|
34
|
+
{aggregate_lower}_id: str
|
|
35
|
+
# add fields
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Get{aggregate}(Query):
|
|
40
|
+
{aggregate_lower}_id: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Create{aggregate}Handler:
|
|
44
|
+
def __init__(self, repo: I{aggregate}Repository, event_bus: EventBus):
|
|
45
|
+
self._repo = repo
|
|
46
|
+
self._event_bus = event_bus
|
|
47
|
+
|
|
48
|
+
async def __call__(self, cmd: Create{aggregate}) -> str:
|
|
49
|
+
agg = {aggregate}(id=cmd.{aggregate_lower}_id)
|
|
50
|
+
await self._repo.add(agg)
|
|
51
|
+
for e in agg.collect_pending_events():
|
|
52
|
+
await self._event_bus.publish(e)
|
|
53
|
+
return agg.id
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Get{aggregate}Handler:
|
|
57
|
+
def __init__(self, repo: I{aggregate}Repository):
|
|
58
|
+
self._repo = repo
|
|
59
|
+
|
|
60
|
+
async def __call__(self, query: Get{aggregate}):
|
|
61
|
+
agg = await self._repo.get(query.{aggregate_lower}_id)
|
|
62
|
+
if agg is None:
|
|
63
|
+
return None
|
|
64
|
+
return {{"id": agg.id}}
|
|
65
|
+
'''
|
|
66
|
+
|
|
67
|
+
INFRASTRUCTURE_PY = '''"""Infrastructure: repository implementation."""
|
|
68
|
+
from __future__ import annotations
|
|
69
|
+
|
|
70
|
+
from typing import Optional
|
|
71
|
+
from urich.domain import Repository
|
|
72
|
+
|
|
73
|
+
from .domain import {aggregate}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class I{aggregate}Repository(Repository["{aggregate}"]):
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class {aggregate}RepositoryImpl(I{aggregate}Repository):
|
|
81
|
+
def __init__(self):
|
|
82
|
+
self._store: dict[str, {aggregate}] = {{}}
|
|
83
|
+
|
|
84
|
+
async def get(self, id: str) -> Optional[{aggregate}]:
|
|
85
|
+
return self._store.get(id)
|
|
86
|
+
|
|
87
|
+
async def add(self, aggregate: {aggregate}) -> None:
|
|
88
|
+
self._store[aggregate.id] = aggregate
|
|
89
|
+
|
|
90
|
+
async def save(self, aggregate: {aggregate}) -> None:
|
|
91
|
+
self._store[aggregate.id] = aggregate
|
|
92
|
+
'''
|
|
93
|
+
|
|
94
|
+
MODULE_PY = '''"""One object = bounded context «{context}»."""
|
|
95
|
+
from urich.ddd import DomainModule
|
|
96
|
+
|
|
97
|
+
from .domain import {aggregate}, {aggregate}Created
|
|
98
|
+
from .application import Create{aggregate}, Create{aggregate}Handler, Get{aggregate}, Get{aggregate}Handler
|
|
99
|
+
from .infrastructure import I{aggregate}Repository, {aggregate}RepositoryImpl
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def on_{aggregate_lower}_created(event: {aggregate}Created) -> None:
|
|
103
|
+
"""Handler: when {aggregate} is created."""
|
|
104
|
+
...
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
{context}_module = (
|
|
108
|
+
DomainModule("{context}")
|
|
109
|
+
.aggregate({aggregate})
|
|
110
|
+
.repository(I{aggregate}Repository, {aggregate}RepositoryImpl)
|
|
111
|
+
.command(Create{aggregate}, Create{aggregate}Handler)
|
|
112
|
+
.query(Get{aggregate}, Get{aggregate}Handler)
|
|
113
|
+
.on_event({aggregate}Created, on_{aggregate_lower}_created)
|
|
114
|
+
)
|
|
115
|
+
'''
|
|
116
|
+
|
|
117
|
+
MAIN_PY = '''"""Entry point: app is composed from modules."""
|
|
118
|
+
from urich import Application
|
|
119
|
+
|
|
120
|
+
# from {first_context}.module import {first_context}_module
|
|
121
|
+
|
|
122
|
+
app = Application()
|
|
123
|
+
# app.register({first_context}_module)
|
|
124
|
+
|
|
125
|
+
# Run: uvicorn main:app --reload
|
|
126
|
+
'''
|
|
127
|
+
|
|
128
|
+
CONTEXT_SKELETON = '''"""Domain {context}."""
|
|
129
|
+
from urich.domain import AggregateRoot, DomainEvent
|
|
130
|
+
|
|
131
|
+
# Add aggregates and events or run: urich add-aggregate {context} <AggregateName>
|
|
132
|
+
'''
|
|
133
|
+
|
|
134
|
+
CONTEXT_APPLICATION_SKELETON = '''"""Application layer for context {context}."""
|
|
135
|
+
from urich.ddd import Command, Query
|
|
136
|
+
|
|
137
|
+
# Add commands, queries and handlers (or generate via add-aggregate)
|
|
138
|
+
'''
|
|
139
|
+
|
|
140
|
+
CONTEXT_INFRASTRUCTURE_SKELETON = '''"""Infrastructure for context {context}."""
|
|
141
|
+
from urich.domain import Repository
|
|
142
|
+
|
|
143
|
+
# Add repository interfaces and implementations
|
|
144
|
+
'''
|
|
145
|
+
|
|
146
|
+
CONTEXT_MODULE_SKELETON = '''"""Bounded context «{context}» — no aggregates yet."""
|
|
147
|
+
from urich.ddd import DomainModule
|
|
148
|
+
|
|
149
|
+
# Add .aggregate(), .repository(), .command(), .query(), .on_event() after add-aggregate
|
|
150
|
+
{context}_module = DomainModule("{context}")
|
|
151
|
+
'''
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from urich.core.app import Application
|
|
2
|
+
from urich.core.container import Container
|
|
3
|
+
from urich.core.module import Module
|
|
4
|
+
from urich.core.routing import HttpModule
|
|
5
|
+
from urich.core.config import Config
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Application",
|
|
9
|
+
"Container",
|
|
10
|
+
"Module",
|
|
11
|
+
"HttpModule",
|
|
12
|
+
"Config",
|
|
13
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Application — Starlette wrapper; app is composed from modules via app.register(module)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from starlette.applications import Starlette
|
|
7
|
+
from starlette.routing import Route
|
|
8
|
+
|
|
9
|
+
from urich.core.container import Container
|
|
10
|
+
from urich.core.module import Module
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Application:
|
|
14
|
+
"""
|
|
15
|
+
Application. Composed from modules via register(module).
|
|
16
|
+
Each module is an object with register_into(app).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, config: Any = None) -> None:
|
|
20
|
+
self._starlette = Starlette(routes=[])
|
|
21
|
+
self._modules: list[Module] = []
|
|
22
|
+
self._container = Container()
|
|
23
|
+
self._route_schemas: dict[tuple[str, str], dict[str, Any]] = {} # (path, method) -> OpenAPI op extras
|
|
24
|
+
if config is not None:
|
|
25
|
+
self._container.register_instance(type(config), config)
|
|
26
|
+
self._container.register_instance("config", config)
|
|
27
|
+
|
|
28
|
+
def register(self, module: Module) -> Application:
|
|
29
|
+
"""Register a module (DomainModule, EventBusModule, routes, etc.). Returns self for chaining."""
|
|
30
|
+
module.register_into(self)
|
|
31
|
+
self._modules.append(module)
|
|
32
|
+
return self
|
|
33
|
+
|
|
34
|
+
def add_route(
|
|
35
|
+
self,
|
|
36
|
+
path: str,
|
|
37
|
+
endpoint: Any,
|
|
38
|
+
methods: list[str] | None = None,
|
|
39
|
+
*,
|
|
40
|
+
openapi_body_schema: dict[str, Any] | None = None,
|
|
41
|
+
openapi_parameters: list[dict[str, Any]] | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Add an HTTP route. Optional openapi_body_schema / openapi_parameters for Swagger."""
|
|
44
|
+
if methods is None:
|
|
45
|
+
methods = ["GET"]
|
|
46
|
+
route = Route(path, endpoint, methods=methods)
|
|
47
|
+
self._starlette.routes.append(route)
|
|
48
|
+
for method in methods:
|
|
49
|
+
key = (path, method.lower())
|
|
50
|
+
if key not in self._route_schemas:
|
|
51
|
+
self._route_schemas[key] = {}
|
|
52
|
+
if method.lower() == "get" and openapi_parameters is not None:
|
|
53
|
+
self._route_schemas[key]["parameters"] = openapi_parameters
|
|
54
|
+
if method.lower() == "post" and openapi_body_schema is not None:
|
|
55
|
+
self._route_schemas[key]["requestBody"] = {
|
|
56
|
+
"required": True,
|
|
57
|
+
"content": {"application/json": {"schema": openapi_body_schema}},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
def mount(self, path: str, app: Starlette) -> None:
|
|
61
|
+
"""Mount a sub-app at prefix. Called by modules from register_into."""
|
|
62
|
+
from starlette.routing import Mount
|
|
63
|
+
self._starlette.routes.append(Mount(path, app=app))
|
|
64
|
+
|
|
65
|
+
def openapi(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
title: str = "API",
|
|
69
|
+
version: str = "0.1.0",
|
|
70
|
+
docs_path: str = "/docs",
|
|
71
|
+
openapi_path: str = "/openapi.json",
|
|
72
|
+
) -> Application:
|
|
73
|
+
"""Add OpenAPI spec and Swagger UI. Call after all modules are registered. Returns self."""
|
|
74
|
+
from urich.core.openapi import build_openapi_spec, SWAGGER_UI_HTML
|
|
75
|
+
from starlette.responses import HTMLResponse, JSONResponse
|
|
76
|
+
|
|
77
|
+
spec = build_openapi_spec(
|
|
78
|
+
self._starlette.routes,
|
|
79
|
+
title=title,
|
|
80
|
+
version=version,
|
|
81
|
+
route_schemas=self._route_schemas,
|
|
82
|
+
)
|
|
83
|
+
self._openapi_spec = spec # type: ignore[attr-defined]
|
|
84
|
+
|
|
85
|
+
async def openapi_endpoint(request: Any) -> Any:
|
|
86
|
+
return JSONResponse(spec)
|
|
87
|
+
|
|
88
|
+
async def docs_endpoint(request: Any) -> Any:
|
|
89
|
+
return HTMLResponse(SWAGGER_UI_HTML.replace("/openapi.json", openapi_path))
|
|
90
|
+
|
|
91
|
+
self.add_route(openapi_path, openapi_endpoint, methods=["GET"])
|
|
92
|
+
self.add_route(docs_path, docs_endpoint, methods=["GET"])
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def container(self) -> Container:
|
|
97
|
+
"""DI container: registration and resolution of dependencies."""
|
|
98
|
+
return self._container
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def starlette(self) -> Starlette:
|
|
102
|
+
"""Underlying Starlette ASGI app (e.g. for middleware)."""
|
|
103
|
+
return self._starlette
|
|
104
|
+
|
|
105
|
+
async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
|
|
106
|
+
"""ASGI: uvicorn.run(app) works directly."""
|
|
107
|
+
await self._starlette(scope, receive, send)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Single config object: user passes it when creating the app; available via DI."""
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Config:
|
|
7
|
+
"""
|
|
8
|
+
Application config. User creates their own class or instance
|
|
9
|
+
and passes to Application(config=...); then available via container.resolve(Config).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def load_from_env(cls, prefix: str = "APP_", **defaults: Any) -> dict[str, Any]:
|
|
14
|
+
"""Load from os.environ with prefix and defaults. Returns dict for MyConfig(**Config.load_from_env())."""
|
|
15
|
+
result = dict(defaults)
|
|
16
|
+
for key, value in os.environ.items():
|
|
17
|
+
if key.startswith(prefix):
|
|
18
|
+
name = key[len(prefix):].lower()
|
|
19
|
+
result[name] = value
|
|
20
|
+
return result
|