austial-core 0.1.5__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.
- austial_core-0.1.5/.gitignore +11 -0
- austial_core-0.1.5/PKG-INFO +10 -0
- austial_core-0.1.5/pyproject.toml +28 -0
- austial_core-0.1.5/src/austial/core/__init__.py +19 -0
- austial_core-0.1.5/src/austial/core/app.py +155 -0
- austial_core-0.1.5/src/austial/core/container.py +161 -0
- austial_core-0.1.5/src/austial/core/dynamic_module.py +22 -0
- austial_core-0.1.5/src/austial/core/exception_dispatch.py +23 -0
- austial_core-0.1.5/src/austial/core/factory.py +43 -0
- austial_core-0.1.5/src/austial/core/metadata.py +65 -0
- austial_core-0.1.5/src/austial/core/module_scanner.py +62 -0
- austial_core-0.1.5/src/austial/core/reflector.py +38 -0
- austial_core-0.1.5/src/austial/core/router_builder.py +184 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: austial-core
|
|
3
|
+
Version: 0.1.5
|
|
4
|
+
Summary: The DI container, module scanner, router and application bootstrap -- mirrors @nestjs/core.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: austial-common
|
|
7
|
+
Requires-Dist: fastapi>=0.115.0
|
|
8
|
+
Requires-Dist: python-multipart>=0.0.9
|
|
9
|
+
Requires-Dist: starlette
|
|
10
|
+
Requires-Dist: uvicorn[standard]>=0.30.0
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "austial-core"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "The DI container, module scanner, router and application bootstrap -- mirrors @nestjs/core."
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"austial-common",
|
|
8
|
+
"fastapi>=0.115.0",
|
|
9
|
+
"starlette",
|
|
10
|
+
"uvicorn[standard]>=0.30.0",
|
|
11
|
+
"python-multipart>=0.0.9",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
16
|
+
build-backend = "hatchling.build"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["src/austial"]
|
|
20
|
+
|
|
21
|
+
[tool.uv.sources]
|
|
22
|
+
austial-common = { workspace = true }
|
|
23
|
+
|
|
24
|
+
[tool.hatch.version]
|
|
25
|
+
source = "vcs"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.version.raw-options]
|
|
28
|
+
root = "../.."
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Kept intentionally lightweight (no imports of ``app``/``factory``/``router_builder``)
|
|
2
|
+
so that low-level modules like ``austial.core.metadata`` can be imported from
|
|
3
|
+
deep within ``austial.common.decorators`` without triggering a circular import.
|
|
4
|
+
``AustialApplication``/``AustialFactory`` are imported directly from
|
|
5
|
+
``austial.core.app`` / ``austial.core.factory`` by ``austial/__init__.py``,
|
|
6
|
+
*after* ``austial.common`` has fully loaded.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from austial.core.container import CircularDependencyError, Container, ProviderNotFoundError
|
|
10
|
+
from austial.core.dynamic_module import DynamicModule
|
|
11
|
+
from austial.core.reflector import Reflector
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Container",
|
|
15
|
+
"ProviderNotFoundError",
|
|
16
|
+
"CircularDependencyError",
|
|
17
|
+
"DynamicModule",
|
|
18
|
+
"Reflector",
|
|
19
|
+
]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""``AustialApplication`` -- mirrors the object returned by
|
|
2
|
+
``NestFactory.create()``. Wraps a real FastAPI/Starlette app so it's a drop-in
|
|
3
|
+
ASGI target (``uvicorn src.main:app``) *and* offers Nest's programmatic API
|
|
4
|
+
(``app.listen()``, ``app.use_global_pipes()``, ...)."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import uvicorn
|
|
11
|
+
from fastapi import FastAPI
|
|
12
|
+
from fastapi.exceptions import RequestValidationError
|
|
13
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
14
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
15
|
+
from starlette.requests import Request
|
|
16
|
+
from starlette.responses import Response
|
|
17
|
+
|
|
18
|
+
from austial.common.exceptions.http_exceptions import BadRequestException
|
|
19
|
+
from austial.common.filters.all_exceptions_filter import AllExceptionsFilter
|
|
20
|
+
from austial.common.logger import Logger
|
|
21
|
+
from austial.core.container import Container
|
|
22
|
+
from austial.core.exception_dispatch import dispatch_exception
|
|
23
|
+
from austial.core.router_builder import RouterBuilder
|
|
24
|
+
|
|
25
|
+
_logger = Logger("NestApplication")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AustialApplication:
|
|
29
|
+
def __init__(self, container: Container, fastapi_app: FastAPI, controllers: list, middleware_bindings: list):
|
|
30
|
+
self._container = container
|
|
31
|
+
self._fastapi_app = fastapi_app
|
|
32
|
+
self._controllers = controllers
|
|
33
|
+
self._middleware_bindings = middleware_bindings
|
|
34
|
+
|
|
35
|
+
self._global_guards: list = []
|
|
36
|
+
self._global_pipes: list = []
|
|
37
|
+
self._global_interceptors: list = []
|
|
38
|
+
self._global_filters: list = []
|
|
39
|
+
self._routes_built = False
|
|
40
|
+
|
|
41
|
+
self._install_fallback_exception_handlers()
|
|
42
|
+
self._install_middleware()
|
|
43
|
+
|
|
44
|
+
# -- Nest-shaped configuration API ------------------------------------------------
|
|
45
|
+
def use_global_pipes(self, *pipes: Any) -> AustialApplication:
|
|
46
|
+
self._global_pipes.extend(pipes)
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def use_global_guards(self, *guards: Any) -> AustialApplication:
|
|
50
|
+
self._global_guards.extend(guards)
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def use_global_interceptors(self, *interceptors: Any) -> AustialApplication:
|
|
54
|
+
self._global_interceptors.extend(interceptors)
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def use_global_filters(self, *filters: Any) -> AustialApplication:
|
|
58
|
+
self._global_filters.extend(filters)
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def enable_cors(self, **kwargs: Any) -> AustialApplication:
|
|
62
|
+
defaults = dict(allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], allow_credentials=True)
|
|
63
|
+
defaults.update(kwargs)
|
|
64
|
+
self._fastapi_app.add_middleware(CORSMiddleware, **defaults) # type: ignore[arg-type]
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
def get(self, token: Any) -> Any:
|
|
68
|
+
"""Fetches a resolved provider straight out of the DI container,
|
|
69
|
+
mirroring Nest's ``app.get(Token)``."""
|
|
70
|
+
return self._container.resolve(token)
|
|
71
|
+
|
|
72
|
+
def get_http_adapter(self) -> FastAPI:
|
|
73
|
+
"""Returns the underlying FastAPI instance, mirroring Nest's
|
|
74
|
+
``app.getHttpAdapter().getInstance()``."""
|
|
75
|
+
return self._fastapi_app
|
|
76
|
+
|
|
77
|
+
# -- lifecycle ---------------------------------------------------------------------
|
|
78
|
+
async def init(self) -> AustialApplication:
|
|
79
|
+
"""Builds routes without starting a server -- mirrors Nest's
|
|
80
|
+
``await app.init()``, the entry point ``@nestjs/testing`` e2e tests use
|
|
81
|
+
alongside ``request(app.getHttpServer())``."""
|
|
82
|
+
self._build_routes()
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def _build_routes(self) -> None:
|
|
86
|
+
if self._routes_built:
|
|
87
|
+
return
|
|
88
|
+
builder = RouterBuilder(
|
|
89
|
+
self._container,
|
|
90
|
+
global_guards=self._global_guards,
|
|
91
|
+
global_pipes=self._global_pipes,
|
|
92
|
+
global_interceptors=self._global_interceptors,
|
|
93
|
+
global_filters=self._global_filters,
|
|
94
|
+
)
|
|
95
|
+
router = builder.build(self._controllers)
|
|
96
|
+
self._fastapi_app.include_router(router)
|
|
97
|
+
self._routes_built = True
|
|
98
|
+
|
|
99
|
+
def _install_fallback_exception_handlers(self) -> None:
|
|
100
|
+
async def handle_validation_error(request: Request, exc: RequestValidationError) -> Response:
|
|
101
|
+
converted = BadRequestException({"message": exc.errors(), "error": "Bad Request"})
|
|
102
|
+
filters = self._global_filters or [AllExceptionsFilter()]
|
|
103
|
+
return await dispatch_exception(converted, request, filters)
|
|
104
|
+
|
|
105
|
+
async def handle_uncaught(request: Request, exc: Exception) -> Response:
|
|
106
|
+
filters = self._global_filters or [AllExceptionsFilter()]
|
|
107
|
+
return await dispatch_exception(exc, request, filters)
|
|
108
|
+
|
|
109
|
+
self._fastapi_app.add_exception_handler(RequestValidationError, handle_validation_error) # type: ignore[arg-type]
|
|
110
|
+
self._fastapi_app.add_exception_handler(Exception, handle_uncaught)
|
|
111
|
+
|
|
112
|
+
def _install_middleware(self) -> None:
|
|
113
|
+
if not self._middleware_bindings:
|
|
114
|
+
return
|
|
115
|
+
bindings = self._middleware_bindings
|
|
116
|
+
|
|
117
|
+
class _MiddlewareDispatcher(BaseHTTPMiddleware):
|
|
118
|
+
async def dispatch(self, request: Request, call_next):
|
|
119
|
+
applicable = [b for b in bindings if b.matches(request.url.path)]
|
|
120
|
+
if not applicable:
|
|
121
|
+
return await call_next(request)
|
|
122
|
+
|
|
123
|
+
chain = call_next
|
|
124
|
+
|
|
125
|
+
def make_step(mw_instance, downstream):
|
|
126
|
+
async def step(req: Request):
|
|
127
|
+
return await mw_instance.use(req, lambda: downstream(req))
|
|
128
|
+
|
|
129
|
+
return step
|
|
130
|
+
|
|
131
|
+
for binding in reversed(applicable):
|
|
132
|
+
for mw_instance in reversed(binding.middlewares):
|
|
133
|
+
chain = make_step(mw_instance, chain)
|
|
134
|
+
|
|
135
|
+
return await chain(request)
|
|
136
|
+
|
|
137
|
+
self._fastapi_app.add_middleware(_MiddlewareDispatcher)
|
|
138
|
+
|
|
139
|
+
async def listen(self, port: int = 8000, host: str = "0.0.0.0") -> None:
|
|
140
|
+
"""Boots an ASGI server in-process, mirroring Nest's ``await app.listen(3000)``."""
|
|
141
|
+
await self.init()
|
|
142
|
+
self._print_startup_banner(port)
|
|
143
|
+
config = uvicorn.Config(self._fastapi_app, host=host, port=port, log_level="warning")
|
|
144
|
+
server = uvicorn.Server(config)
|
|
145
|
+
await server.serve()
|
|
146
|
+
|
|
147
|
+
def _print_startup_banner(self, port: int) -> None:
|
|
148
|
+
_logger.log("Starting Austial application...", context="NestFactory")
|
|
149
|
+
_logger.log("AppModule dependencies initialized", context="InstanceLoader")
|
|
150
|
+
_logger.log(f"Austial application successfully started on port {port}", context="NestApplication")
|
|
151
|
+
|
|
152
|
+
# -- ASGI passthrough, so `uvicorn src.main:app` works too --------------------------
|
|
153
|
+
async def __call__(self, scope, receive, send) -> None:
|
|
154
|
+
self._build_routes()
|
|
155
|
+
await self._fastapi_app(scope, receive, send)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""The DI container -- mirrors Nest's ``IoC`` container.
|
|
2
|
+
|
|
3
|
+
Providers are resolved from constructor type hints, recursively, with a
|
|
4
|
+
singleton cache by default (``Scope.TRANSIENT`` opts a provider out of
|
|
5
|
+
caching). Supports Nest's three "custom provider" shapes as plain dicts:
|
|
6
|
+
``{"provide": TOKEN, "useValue": ...}``, ``{"provide": TOKEN, "useClass": ...}``,
|
|
7
|
+
``{"provide": TOKEN, "useFactory": fn, "inject": [...]}``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import inspect
|
|
13
|
+
import typing
|
|
14
|
+
from collections.abc import Callable, Sequence
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from austial.common.decorators.injectable import InjectMarker, OptionalMarker
|
|
19
|
+
from austial.common.enums import Scope
|
|
20
|
+
from austial.core.metadata import INJECTABLE_METADATA, get_metadata
|
|
21
|
+
|
|
22
|
+
_UNSET = object()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ProviderNotFoundError(Exception):
|
|
26
|
+
def __init__(self, token: Any):
|
|
27
|
+
name = getattr(token, "__name__", token)
|
|
28
|
+
super().__init__(
|
|
29
|
+
f"Nest can't resolve dependency '{name}'. Is it registered in a "
|
|
30
|
+
f"module's `providers=[...]` (or `exports=[...]` of an imported module)?"
|
|
31
|
+
)
|
|
32
|
+
self.token = token
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CircularDependencyError(Exception):
|
|
36
|
+
def __init__(self, token: Any):
|
|
37
|
+
name = getattr(token, "__name__", token)
|
|
38
|
+
super().__init__(f"Circular dependency detected while resolving '{name}'.")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ProviderDefinition:
|
|
43
|
+
token: Any
|
|
44
|
+
use_class: type | None = None
|
|
45
|
+
use_value: Any = _UNSET
|
|
46
|
+
use_factory: Callable | None = None
|
|
47
|
+
inject: Sequence[Any] = field(default_factory=list)
|
|
48
|
+
scope: Scope = Scope.DEFAULT
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Container:
|
|
52
|
+
"""One container per application. Every module's providers/controllers
|
|
53
|
+
register into the same container (Nest's DI graph is application-wide
|
|
54
|
+
too, scoped by module `exports`/`imports` only at the *visibility* level,
|
|
55
|
+
which Austial does not currently enforce for simplicity)."""
|
|
56
|
+
|
|
57
|
+
def __init__(self):
|
|
58
|
+
self._definitions: dict[Any, ProviderDefinition] = {}
|
|
59
|
+
self._singletons: dict[Any, Any] = {}
|
|
60
|
+
|
|
61
|
+
def register(self, provider: Any) -> ProviderDefinition:
|
|
62
|
+
definition = self._normalize(provider)
|
|
63
|
+
self._definitions[definition.token] = definition
|
|
64
|
+
return definition
|
|
65
|
+
|
|
66
|
+
def has(self, token: Any) -> bool:
|
|
67
|
+
return token in self._definitions or token in self._singletons
|
|
68
|
+
|
|
69
|
+
def set_instance(self, token: Any, instance: Any) -> None:
|
|
70
|
+
"""Registers an already-built instance under `token` (used for
|
|
71
|
+
controllers, which the router builder constructs itself)."""
|
|
72
|
+
self._singletons[token] = instance
|
|
73
|
+
self._definitions.setdefault(token, ProviderDefinition(token=token, use_value=instance))
|
|
74
|
+
|
|
75
|
+
def _normalize(self, provider: Any) -> ProviderDefinition:
|
|
76
|
+
if isinstance(provider, dict):
|
|
77
|
+
token = provider["provide"]
|
|
78
|
+
if "useValue" in provider:
|
|
79
|
+
return ProviderDefinition(token=token, use_value=provider["useValue"])
|
|
80
|
+
if "useClass" in provider:
|
|
81
|
+
use_class = provider["useClass"]
|
|
82
|
+
scope = get_metadata(INJECTABLE_METADATA, use_class, {}).get("scope", Scope.DEFAULT)
|
|
83
|
+
return ProviderDefinition(token=token, use_class=use_class, scope=scope)
|
|
84
|
+
if "useFactory" in provider:
|
|
85
|
+
return ProviderDefinition(
|
|
86
|
+
token=token, use_factory=provider["useFactory"], inject=provider.get("inject", [])
|
|
87
|
+
)
|
|
88
|
+
raise ValueError(f"Invalid provider definition: {provider!r}")
|
|
89
|
+
# Plain class shorthand, e.g. `providers=[HealthService]`.
|
|
90
|
+
scope = get_metadata(INJECTABLE_METADATA, provider, {}).get("scope", Scope.DEFAULT)
|
|
91
|
+
return ProviderDefinition(token=provider, use_class=provider, scope=scope)
|
|
92
|
+
|
|
93
|
+
def resolve(self, token: Any, *, _resolving: frozenset = frozenset()) -> Any:
|
|
94
|
+
if token in self._singletons:
|
|
95
|
+
return self._singletons[token]
|
|
96
|
+
|
|
97
|
+
definition = self._definitions.get(token)
|
|
98
|
+
if definition is None:
|
|
99
|
+
if isinstance(token, type):
|
|
100
|
+
# Auto-register unregistered `@Injectable` classes encountered
|
|
101
|
+
# only as a nested constructor dependency -- keeps things
|
|
102
|
+
# working even if a provider was forgotten in `providers=[]`.
|
|
103
|
+
definition = self.register(token)
|
|
104
|
+
else:
|
|
105
|
+
raise ProviderNotFoundError(token)
|
|
106
|
+
|
|
107
|
+
if token in _resolving:
|
|
108
|
+
raise CircularDependencyError(token)
|
|
109
|
+
next_resolving = _resolving | {token}
|
|
110
|
+
|
|
111
|
+
instance = self._instantiate(definition, next_resolving)
|
|
112
|
+
if definition.scope == Scope.DEFAULT:
|
|
113
|
+
self._singletons[token] = instance
|
|
114
|
+
return instance
|
|
115
|
+
|
|
116
|
+
def _instantiate(self, definition: ProviderDefinition, resolving: frozenset) -> Any:
|
|
117
|
+
if definition.use_value is not _UNSET:
|
|
118
|
+
return definition.use_value
|
|
119
|
+
if definition.use_factory is not None:
|
|
120
|
+
deps = [self.resolve(tok, _resolving=resolving) for tok in definition.inject]
|
|
121
|
+
return definition.use_factory(*deps)
|
|
122
|
+
assert definition.use_class is not None
|
|
123
|
+
kwargs = self._resolve_constructor_args(definition.use_class, resolving)
|
|
124
|
+
return definition.use_class(**kwargs)
|
|
125
|
+
|
|
126
|
+
def _resolve_constructor_args(self, cls: type, resolving: frozenset) -> dict:
|
|
127
|
+
init = cls.__init__ # type: ignore[misc] # deliberately dynamic: `cls` is any registered provider class
|
|
128
|
+
if init is object.__init__:
|
|
129
|
+
return {}
|
|
130
|
+
try:
|
|
131
|
+
hints = typing.get_type_hints(init)
|
|
132
|
+
except Exception:
|
|
133
|
+
hints = {}
|
|
134
|
+
signature = inspect.signature(init)
|
|
135
|
+
kwargs: dict[str, Any] = {}
|
|
136
|
+
for name, param in signature.parameters.items():
|
|
137
|
+
if name == "self" or param.kind in (
|
|
138
|
+
inspect.Parameter.VAR_POSITIONAL,
|
|
139
|
+
inspect.Parameter.VAR_KEYWORD,
|
|
140
|
+
):
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
default = param.default
|
|
144
|
+
if isinstance(default, InjectMarker):
|
|
145
|
+
kwargs[name] = self.resolve(default.token, _resolving=resolving)
|
|
146
|
+
continue
|
|
147
|
+
if isinstance(default, OptionalMarker):
|
|
148
|
+
hint = hints.get(name)
|
|
149
|
+
try:
|
|
150
|
+
kwargs[name] = self.resolve(hint, _resolving=resolving) if hint else None
|
|
151
|
+
except ProviderNotFoundError:
|
|
152
|
+
kwargs[name] = None
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
hint = hints.get(name)
|
|
156
|
+
if hint is not None:
|
|
157
|
+
kwargs[name] = self.resolve(hint, _resolving=resolving)
|
|
158
|
+
elif default is inspect.Parameter.empty:
|
|
159
|
+
raise ProviderNotFoundError(name)
|
|
160
|
+
# else: no annotation, has its own default -> let the class use it.
|
|
161
|
+
return kwargs
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""``DynamicModule`` -- mirrors Nest's dynamic module pattern
|
|
2
|
+
(``ConfigModule.forRoot()``, ``TypeOrmModule.forRootAsync()``, ...).
|
|
3
|
+
|
|
4
|
+
A module class exposes a classmethod (conventionally ``for_root`` /
|
|
5
|
+
``for_root_async`` / ``register``) that returns a ``DynamicModule`` describing
|
|
6
|
+
extra providers/controllers/imports/exports to merge in at that import site,
|
|
7
|
+
instead of (or in addition to) whatever static ``@Module(...)`` metadata the
|
|
8
|
+
class itself carries.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class DynamicModule:
|
|
18
|
+
module: type | None = None
|
|
19
|
+
imports: list = field(default_factory=list)
|
|
20
|
+
controllers: list = field(default_factory=list)
|
|
21
|
+
providers: list = field(default_factory=list)
|
|
22
|
+
exports: list = field(default_factory=list)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Shared "find the right exception filter and call it" logic, used both
|
|
2
|
+
inside route handlers (:mod:`austial.core.router_builder`) and as the
|
|
3
|
+
application-level fallback for errors FastAPI itself raises before a route
|
|
4
|
+
handler ever runs (e.g. request validation failures)."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
from starlette.requests import Request
|
|
11
|
+
from starlette.responses import Response
|
|
12
|
+
|
|
13
|
+
from austial.common.filters.base import ArgumentsHost
|
|
14
|
+
from austial.core.metadata import CATCH_METADATA, get_metadata
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def dispatch_exception(exc: BaseException, request: Request, filters: Sequence) -> Response:
|
|
18
|
+
host = ArgumentsHost(request)
|
|
19
|
+
for exception_filter in filters:
|
|
20
|
+
catch_types = get_metadata(CATCH_METADATA, type(exception_filter), ())
|
|
21
|
+
if not catch_types or isinstance(exc, catch_types):
|
|
22
|
+
return await exception_filter.catch(exc, host)
|
|
23
|
+
raise exc
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""``AustialFactory`` -- mirrors ``NestFactory``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
|
|
7
|
+
from austial.common.middleware.base import MiddlewareBinding, MiddlewareConsumer
|
|
8
|
+
from austial.core.app import AustialApplication
|
|
9
|
+
from austial.core.container import Container
|
|
10
|
+
from austial.core.module_scanner import scan_module
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AustialFactory:
|
|
14
|
+
@staticmethod
|
|
15
|
+
def create(
|
|
16
|
+
root_module: type,
|
|
17
|
+
*,
|
|
18
|
+
title: str = "Austial",
|
|
19
|
+
version: str = "0.1.0",
|
|
20
|
+
description: str = "Built with the Austial framework.",
|
|
21
|
+
) -> AustialApplication:
|
|
22
|
+
container = Container()
|
|
23
|
+
scan_result = scan_module(root_module, container)
|
|
24
|
+
|
|
25
|
+
fastapi_app = FastAPI(title=title, version=version, description=description)
|
|
26
|
+
|
|
27
|
+
middleware_bindings: list[MiddlewareBinding] = []
|
|
28
|
+
for module_cls in scan_result.modules:
|
|
29
|
+
configure = getattr(module_cls, "configure", None)
|
|
30
|
+
if configure is None:
|
|
31
|
+
continue
|
|
32
|
+
module_instance = container.resolve(module_cls)
|
|
33
|
+
consumer = MiddlewareConsumer()
|
|
34
|
+
module_instance.configure(consumer)
|
|
35
|
+
resolved_bindings = []
|
|
36
|
+
for binding in consumer.bindings:
|
|
37
|
+
resolved_middlewares = [
|
|
38
|
+
container.resolve(mw) if isinstance(mw, type) else mw for mw in binding.middlewares
|
|
39
|
+
]
|
|
40
|
+
resolved_bindings.append(MiddlewareBinding(middlewares=resolved_middlewares, routes=binding.routes))
|
|
41
|
+
middleware_bindings.extend(resolved_bindings)
|
|
42
|
+
|
|
43
|
+
return AustialApplication(container, fastapi_app, scan_result.controllers, middleware_bindings)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Metadata storage, modeled after Nest's `@SetMetadata()` / `Reflect.defineMetadata`.
|
|
3
|
+
|
|
4
|
+
Every decorator in Austial (``@Module``, ``@Controller``, ``@Get``, ``@UseGuards``,
|
|
5
|
+
...) ultimately calls :func:`set_metadata` to stash data directly on the
|
|
6
|
+
decorated class or function. :class:`Reflector` (see ``reflector.py``) is the
|
|
7
|
+
public, Nest-shaped API for reading it back out.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
_METADATA_ATTR = "__austial_metadata__"
|
|
15
|
+
|
|
16
|
+
# Well-known metadata keys used across the framework.
|
|
17
|
+
MODULE_METADATA = "austial:module"
|
|
18
|
+
CONTROLLER_METADATA = "austial:controller"
|
|
19
|
+
INJECTABLE_METADATA = "austial:injectable"
|
|
20
|
+
ROUTE_METADATA = "austial:route"
|
|
21
|
+
GUARDS_METADATA = "austial:guards"
|
|
22
|
+
INTERCEPTORS_METADATA = "austial:interceptors"
|
|
23
|
+
PIPES_METADATA = "austial:pipes"
|
|
24
|
+
FILTERS_METADATA = "austial:filters"
|
|
25
|
+
HTTP_CODE_METADATA = "austial:http_code"
|
|
26
|
+
HEADER_METADATA = "austial:header"
|
|
27
|
+
INJECT_TOKENS_METADATA = "austial:inject_tokens"
|
|
28
|
+
CATCH_METADATA = "austial:catch"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _bucket(target: Any) -> dict:
|
|
32
|
+
bucket = target.__dict__.get(_METADATA_ATTR)
|
|
33
|
+
if bucket is None:
|
|
34
|
+
bucket = {}
|
|
35
|
+
setattr(target, _METADATA_ATTR, bucket)
|
|
36
|
+
return bucket
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def set_metadata(key: str, value: Any):
|
|
40
|
+
"""Decorator factory: attaches ``value`` under ``key`` on the decorated target.
|
|
41
|
+
|
|
42
|
+
Mirrors Nest's ``@SetMetadata(key, value)``. Can decorate classes or
|
|
43
|
+
functions/methods, and is also usable directly as ``set_metadata(k, v)(target)``.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def decorator(target):
|
|
47
|
+
_bucket(target)[key] = value
|
|
48
|
+
return target
|
|
49
|
+
|
|
50
|
+
return decorator
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_metadata(key: str, target: Any, default: Any = None) -> Any:
|
|
54
|
+
"""Reads a single metadata value previously stashed with :func:`set_metadata`."""
|
|
55
|
+
if target is None:
|
|
56
|
+
return default
|
|
57
|
+
bucket = getattr(target, _METADATA_ATTR, None)
|
|
58
|
+
if bucket is None:
|
|
59
|
+
return default
|
|
60
|
+
return bucket.get(key, default)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def has_metadata(key: str, target: Any) -> bool:
|
|
64
|
+
bucket = getattr(target, _METADATA_ATTR, None)
|
|
65
|
+
return bucket is not None and key in bucket
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Walks the ``@Module`` import graph from the root ``AppModule``, registering
|
|
2
|
+
every provider/controller into the DI :class:`~austial.core.container.Container`
|
|
3
|
+
and collecting the flat list of controllers + modules for the router builder
|
|
4
|
+
and the middleware system."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from austial.core.container import Container
|
|
12
|
+
from austial.core.dynamic_module import DynamicModule
|
|
13
|
+
from austial.core.metadata import MODULE_METADATA, get_metadata
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ScanResult:
|
|
18
|
+
controllers: list = field(default_factory=list)
|
|
19
|
+
modules: list = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def scan_module(root_module: type, container: Container) -> ScanResult:
|
|
23
|
+
visited: set[type] = set()
|
|
24
|
+
controllers: list = []
|
|
25
|
+
modules_in_order: list = []
|
|
26
|
+
|
|
27
|
+
def visit(entry: Any) -> None:
|
|
28
|
+
if isinstance(entry, DynamicModule):
|
|
29
|
+
for provider in entry.providers:
|
|
30
|
+
container.register(provider)
|
|
31
|
+
for controller in entry.controllers:
|
|
32
|
+
container.register(controller)
|
|
33
|
+
controllers.append(controller)
|
|
34
|
+
for imported in entry.imports:
|
|
35
|
+
visit(imported)
|
|
36
|
+
if entry.module is not None:
|
|
37
|
+
visit(entry.module)
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
module_cls = entry
|
|
41
|
+
if module_cls in visited:
|
|
42
|
+
return
|
|
43
|
+
visited.add(module_cls)
|
|
44
|
+
|
|
45
|
+
metadata = get_metadata(MODULE_METADATA, module_cls)
|
|
46
|
+
if metadata is None:
|
|
47
|
+
raise TypeError(f"{module_cls!r} is not decorated with @Module(...)")
|
|
48
|
+
|
|
49
|
+
for imported in metadata.imports:
|
|
50
|
+
visit(imported)
|
|
51
|
+
|
|
52
|
+
for provider in metadata.providers:
|
|
53
|
+
container.register(provider)
|
|
54
|
+
|
|
55
|
+
for controller in metadata.controllers:
|
|
56
|
+
container.register(controller)
|
|
57
|
+
controllers.append(controller)
|
|
58
|
+
|
|
59
|
+
modules_in_order.append(module_cls)
|
|
60
|
+
|
|
61
|
+
visit(root_module)
|
|
62
|
+
return ScanResult(controllers=controllers, modules=modules_in_order)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Reflector: the public, Nest-shaped API for reading decorator metadata back out."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from austial.core.metadata import get_metadata
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Reflector:
|
|
12
|
+
"""Mirrors ``@nestjs/core``'s ``Reflector``.
|
|
13
|
+
|
|
14
|
+
Usable as an injectable (constructor-injected into guards/interceptors)
|
|
15
|
+
or via its static helpers.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def get(self, key: str, target: Any, default: Any = None) -> Any:
|
|
19
|
+
return get_metadata(key, target, default)
|
|
20
|
+
|
|
21
|
+
def get_all_and_merge(self, key: str, targets: Sequence[Any]) -> list:
|
|
22
|
+
"""Merges array-valued metadata (e.g. guards/interceptors) across
|
|
23
|
+
class + method, matching Nest's ``getAllAndMerge``."""
|
|
24
|
+
merged: list = []
|
|
25
|
+
for target in targets:
|
|
26
|
+
value = get_metadata(key, target, [])
|
|
27
|
+
if value:
|
|
28
|
+
merged.extend(value)
|
|
29
|
+
return merged
|
|
30
|
+
|
|
31
|
+
def get_all_and_override(self, key: str, targets: Sequence[Any]) -> Any:
|
|
32
|
+
"""Returns the first non-empty metadata value found, method before
|
|
33
|
+
class, matching Nest's ``getAllAndOverride``."""
|
|
34
|
+
for target in targets:
|
|
35
|
+
value = get_metadata(key, target, None)
|
|
36
|
+
if value is not None:
|
|
37
|
+
return value
|
|
38
|
+
return None
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Walks the resolved controller list and builds real ``fastapi.APIRouter``s
|
|
2
|
+
out of them, wiring the full Nest-style request pipeline around each handler:
|
|
3
|
+
|
|
4
|
+
guards -> pipes -> interceptors (wrapping) -> handler -> exception filters
|
|
5
|
+
|
|
6
|
+
This is the piece that makes ``@Controller``/``@Get``/``@UseGuards``/etc. do
|
|
7
|
+
something, instead of just being inert metadata.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import inspect
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, Request, Response
|
|
16
|
+
|
|
17
|
+
from austial.common.decorators.controllers import ControllerMetadata, RouteMetadata
|
|
18
|
+
from austial.common.exceptions.http_exceptions import ForbiddenException
|
|
19
|
+
from austial.common.filters.all_exceptions_filter import AllExceptionsFilter
|
|
20
|
+
from austial.common.guards.base import ExecutionContext
|
|
21
|
+
from austial.common.interceptors.base import CallHandler
|
|
22
|
+
from austial.common.logger import Logger
|
|
23
|
+
from austial.common.pipes.validation_pipe import ArgumentMetadata
|
|
24
|
+
from austial.core.container import Container
|
|
25
|
+
from austial.core.exception_dispatch import dispatch_exception
|
|
26
|
+
from austial.core.metadata import (
|
|
27
|
+
CONTROLLER_METADATA,
|
|
28
|
+
FILTERS_METADATA,
|
|
29
|
+
GUARDS_METADATA,
|
|
30
|
+
HEADER_METADATA,
|
|
31
|
+
HTTP_CODE_METADATA,
|
|
32
|
+
INTERCEPTORS_METADATA,
|
|
33
|
+
PIPES_METADATA,
|
|
34
|
+
ROUTE_METADATA,
|
|
35
|
+
get_metadata,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_logger = Logger("RoutesResolver")
|
|
39
|
+
|
|
40
|
+
_REQUEST_PARAM_NAME = "__austial_request__"
|
|
41
|
+
_RESPONSE_PARAM_NAME = "__austial_response__"
|
|
42
|
+
_DEFAULT_STATUS = {"POST": 201}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RouterBuilder:
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
container: Container,
|
|
49
|
+
*,
|
|
50
|
+
global_guards: list | None = None,
|
|
51
|
+
global_pipes: list | None = None,
|
|
52
|
+
global_interceptors: list | None = None,
|
|
53
|
+
global_filters: list | None = None,
|
|
54
|
+
):
|
|
55
|
+
self._container = container
|
|
56
|
+
self._global_guards = global_guards or []
|
|
57
|
+
self._global_pipes = global_pipes or []
|
|
58
|
+
self._global_interceptors = global_interceptors or []
|
|
59
|
+
self._global_filters = global_filters or []
|
|
60
|
+
|
|
61
|
+
def build(self, controllers: list[type]) -> APIRouter:
|
|
62
|
+
root_router = APIRouter()
|
|
63
|
+
for controller_cls in controllers:
|
|
64
|
+
self._register_controller(root_router, controller_cls)
|
|
65
|
+
return root_router
|
|
66
|
+
|
|
67
|
+
def _register_controller(self, root_router: APIRouter, controller_cls: type) -> None:
|
|
68
|
+
controller_meta: ControllerMetadata = get_metadata(CONTROLLER_METADATA, controller_cls) or ControllerMetadata()
|
|
69
|
+
instance = self._container.resolve(controller_cls)
|
|
70
|
+
tags = controller_meta.tags or [controller_cls.__name__.replace("Controller", "") or controller_cls.__name__]
|
|
71
|
+
router = APIRouter(prefix=controller_meta.prefix, tags=tags)
|
|
72
|
+
|
|
73
|
+
for name, member in vars(controller_cls).items():
|
|
74
|
+
if not callable(member):
|
|
75
|
+
continue
|
|
76
|
+
route_meta: RouteMetadata | None = get_metadata(ROUTE_METADATA, member)
|
|
77
|
+
if route_meta is None:
|
|
78
|
+
continue
|
|
79
|
+
bound_handler = getattr(instance, name)
|
|
80
|
+
self._register_route(router, controller_meta, controller_cls, bound_handler, route_meta)
|
|
81
|
+
full_path = f"{controller_meta.prefix}{route_meta.path}" or "/"
|
|
82
|
+
_logger.log(f"Mapped {{{route_meta.method}, {full_path}}} route", context="RoutesResolver")
|
|
83
|
+
|
|
84
|
+
root_router.include_router(router)
|
|
85
|
+
|
|
86
|
+
def _resolve_chain(
|
|
87
|
+
self,
|
|
88
|
+
key: str,
|
|
89
|
+
controller_cls: type,
|
|
90
|
+
handler: Callable,
|
|
91
|
+
globals_list: list,
|
|
92
|
+
default: list | None = None,
|
|
93
|
+
) -> list:
|
|
94
|
+
raw = list(globals_list)
|
|
95
|
+
raw += get_metadata(key, controller_cls, [])
|
|
96
|
+
raw += get_metadata(key, handler, [])
|
|
97
|
+
if not raw and default:
|
|
98
|
+
raw = list(default)
|
|
99
|
+
resolved = []
|
|
100
|
+
for item in raw:
|
|
101
|
+
resolved.append(self._container.resolve(item) if isinstance(item, type) else item)
|
|
102
|
+
return resolved
|
|
103
|
+
|
|
104
|
+
def _register_route(
|
|
105
|
+
self,
|
|
106
|
+
router: APIRouter,
|
|
107
|
+
controller_meta: ControllerMetadata,
|
|
108
|
+
controller_cls: type,
|
|
109
|
+
handler: Callable,
|
|
110
|
+
route_meta: RouteMetadata,
|
|
111
|
+
) -> None:
|
|
112
|
+
guards = self._resolve_chain(GUARDS_METADATA, controller_cls, handler, self._global_guards)
|
|
113
|
+
interceptors = self._resolve_chain(INTERCEPTORS_METADATA, controller_cls, handler, self._global_interceptors)
|
|
114
|
+
pipes = self._resolve_chain(PIPES_METADATA, controller_cls, handler, self._global_pipes)
|
|
115
|
+
filters = self._resolve_chain(
|
|
116
|
+
FILTERS_METADATA, controller_cls, handler, self._global_filters, default=[AllExceptionsFilter()]
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
status_code = get_metadata(HTTP_CODE_METADATA, handler) or _DEFAULT_STATUS.get(route_meta.method, 200)
|
|
120
|
+
headers_meta = get_metadata(HEADER_METADATA, handler, {})
|
|
121
|
+
|
|
122
|
+
endpoint = self._build_endpoint(controller_cls, handler, guards, interceptors, pipes, filters, headers_meta)
|
|
123
|
+
|
|
124
|
+
path = route_meta.path or ("/" if not controller_meta.prefix else "")
|
|
125
|
+
router.add_api_route(path, endpoint, methods=[route_meta.method], status_code=status_code)
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _wrap_interceptor(interceptor, context: ExecutionContext, next_call: Callable):
|
|
129
|
+
async def wrapped():
|
|
130
|
+
return await interceptor.intercept(context, CallHandler(next_call))
|
|
131
|
+
|
|
132
|
+
return wrapped
|
|
133
|
+
|
|
134
|
+
def _build_endpoint(
|
|
135
|
+
self,
|
|
136
|
+
controller_cls: type,
|
|
137
|
+
handler: Callable,
|
|
138
|
+
guards: list,
|
|
139
|
+
interceptors: list,
|
|
140
|
+
pipes: list,
|
|
141
|
+
filters: list,
|
|
142
|
+
headers_meta: dict,
|
|
143
|
+
) -> Callable:
|
|
144
|
+
original_sig = inspect.signature(handler) # bound method -> `self` already excluded
|
|
145
|
+
request_param = inspect.Parameter(_REQUEST_PARAM_NAME, kind=inspect.Parameter.KEYWORD_ONLY, annotation=Request)
|
|
146
|
+
response_param = inspect.Parameter(
|
|
147
|
+
_RESPONSE_PARAM_NAME, kind=inspect.Parameter.KEYWORD_ONLY, annotation=Response
|
|
148
|
+
)
|
|
149
|
+
new_sig = original_sig.replace(parameters=[*original_sig.parameters.values(), request_param, response_param])
|
|
150
|
+
is_coroutine = inspect.iscoroutinefunction(handler)
|
|
151
|
+
|
|
152
|
+
async def endpoint(**kwargs):
|
|
153
|
+
request: Request = kwargs.pop(_REQUEST_PARAM_NAME)
|
|
154
|
+
response: Response = kwargs.pop(_RESPONSE_PARAM_NAME)
|
|
155
|
+
context = ExecutionContext(request, controller_cls, handler)
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
for guard in guards:
|
|
159
|
+
if not await guard.can_activate(context):
|
|
160
|
+
raise ForbiddenException("Forbidden resource")
|
|
161
|
+
|
|
162
|
+
for name, value in list(kwargs.items()):
|
|
163
|
+
metadata = ArgumentMetadata(type="custom", data=name, metatype=type(value))
|
|
164
|
+
for pipe in pipes:
|
|
165
|
+
value = pipe.transform(value, metadata)
|
|
166
|
+
kwargs[name] = value
|
|
167
|
+
|
|
168
|
+
async def call_handler():
|
|
169
|
+
return await handler(**kwargs) if is_coroutine else handler(**kwargs)
|
|
170
|
+
|
|
171
|
+
chain = call_handler
|
|
172
|
+
for interceptor in reversed(interceptors):
|
|
173
|
+
chain = self._wrap_interceptor(interceptor, context, chain)
|
|
174
|
+
|
|
175
|
+
result = await chain()
|
|
176
|
+
for key, value in headers_meta.items():
|
|
177
|
+
response.headers[key] = value
|
|
178
|
+
return result
|
|
179
|
+
except Exception as exc: # noqa: BLE001 - dispatched to exception filters below
|
|
180
|
+
return await dispatch_exception(exc, request, filters)
|
|
181
|
+
|
|
182
|
+
endpoint.__signature__ = new_sig # type: ignore[attr-defined] # FastAPI reads this to build the OpenAPI schema
|
|
183
|
+
endpoint.__name__ = getattr(handler, "__name__", "endpoint")
|
|
184
|
+
return endpoint
|