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.
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .env
5
+ *.db
6
+ .pytest_cache/
7
+ .coverage
8
+ htmlcov/
9
+ dist/
10
+ build/
11
+ *.egg-info/
@@ -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