austial-common 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.
Files changed (30) hide show
  1. austial_common-0.1.5/.gitignore +11 -0
  2. austial_common-0.1.5/PKG-INFO +10 -0
  3. austial_common-0.1.5/pyproject.toml +28 -0
  4. austial_common-0.1.5/src/austial/common/__init__.py +106 -0
  5. austial_common-0.1.5/src/austial/common/decorators/__init__.py +48 -0
  6. austial_common-0.1.5/src/austial/common/decorators/controllers.py +109 -0
  7. austial_common-0.1.5/src/austial/common/decorators/filters.py +31 -0
  8. austial_common-0.1.5/src/austial/common/decorators/guards.py +20 -0
  9. austial_common-0.1.5/src/austial/common/decorators/injectable.py +68 -0
  10. austial_common-0.1.5/src/austial/common/decorators/interceptors.py +20 -0
  11. austial_common-0.1.5/src/austial/common/decorators/modules.py +65 -0
  12. austial_common-0.1.5/src/austial/common/decorators/params.py +50 -0
  13. austial_common-0.1.5/src/austial/common/decorators/pipes.py +20 -0
  14. austial_common-0.1.5/src/austial/common/enums.py +15 -0
  15. austial_common-0.1.5/src/austial/common/exceptions/__init__.py +29 -0
  16. austial_common-0.1.5/src/austial/common/exceptions/http_exceptions.py +109 -0
  17. austial_common-0.1.5/src/austial/common/filters/__init__.py +4 -0
  18. austial_common-0.1.5/src/austial/common/filters/all_exceptions_filter.py +31 -0
  19. austial_common-0.1.5/src/austial/common/filters/base.py +28 -0
  20. austial_common-0.1.5/src/austial/common/guards/__init__.py +3 -0
  21. austial_common-0.1.5/src/austial/common/guards/base.py +39 -0
  22. austial_common-0.1.5/src/austial/common/interceptors/__init__.py +4 -0
  23. austial_common-0.1.5/src/austial/common/interceptors/base.py +30 -0
  24. austial_common-0.1.5/src/austial/common/interceptors/transform_interceptor.py +19 -0
  25. austial_common-0.1.5/src/austial/common/logger.py +56 -0
  26. austial_common-0.1.5/src/austial/common/middleware/__init__.py +4 -0
  27. austial_common-0.1.5/src/austial/common/middleware/base.py +60 -0
  28. austial_common-0.1.5/src/austial/common/middleware/logging_middleware.py +24 -0
  29. austial_common-0.1.5/src/austial/common/pipes/__init__.py +3 -0
  30. austial_common-0.1.5/src/austial/common/pipes/validation_pipe.py +51 -0
@@ -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-common
3
+ Version: 0.1.5
4
+ Summary: Decorators, exceptions, pipes, guards and other symbols shared across Austial -- mirrors @nestjs/common.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: austial-core
7
+ Requires-Dist: fastapi>=0.115.0
8
+ Requires-Dist: pydantic>=2.7.0
9
+ Requires-Dist: rich>=13.7.0
10
+ Requires-Dist: starlette
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "austial-common"
3
+ dynamic = ["version"]
4
+ description = "Decorators, exceptions, pipes, guards and other symbols shared across Austial -- mirrors @nestjs/common."
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "austial-core",
8
+ "fastapi>=0.115.0",
9
+ "pydantic>=2.7.0",
10
+ "rich>=13.7.0",
11
+ "starlette",
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-core = { workspace = true }
23
+
24
+ [tool.hatch.version]
25
+ source = "vcs"
26
+
27
+ [tool.hatch.version.raw-options]
28
+ root = "../.."
@@ -0,0 +1,106 @@
1
+ from austial.common.decorators import (
2
+ Body,
3
+ Catch,
4
+ Controller,
5
+ Delete,
6
+ Get,
7
+ Global,
8
+ Head,
9
+ Header,
10
+ Headers,
11
+ HttpCode,
12
+ Inject,
13
+ Injectable,
14
+ Module,
15
+ Optional_,
16
+ Options,
17
+ Param,
18
+ Patch,
19
+ Post,
20
+ Put,
21
+ Query,
22
+ Req,
23
+ Res,
24
+ UseFilters,
25
+ UseGuards,
26
+ UseInterceptors,
27
+ UsePipes,
28
+ )
29
+ from austial.common.enums import Scope
30
+ from austial.common.exceptions import (
31
+ BadRequestException,
32
+ ConflictException,
33
+ ForbiddenException,
34
+ HttpException,
35
+ InternalServerErrorException,
36
+ MethodNotAllowedException,
37
+ NotFoundException,
38
+ NotImplementedException,
39
+ ServiceUnavailableException,
40
+ TooManyRequestsException,
41
+ UnauthorizedException,
42
+ UnprocessableEntityException,
43
+ )
44
+ from austial.common.filters import AllExceptionsFilter, ArgumentsHost, ExceptionFilter
45
+ from austial.common.guards import CanActivate, ExecutionContext
46
+ from austial.common.interceptors import CallHandler, NestInterceptor, TransformInterceptor
47
+ from austial.common.logger import Logger
48
+ from austial.common.middleware import LoggingMiddleware, MiddlewareConsumer, NestMiddleware
49
+ from austial.common.pipes import ArgumentMetadata, PipeTransform, ValidationPipe
50
+
51
+ __all__ = [
52
+ "Body",
53
+ "Catch",
54
+ "Controller",
55
+ "Delete",
56
+ "Get",
57
+ "Global",
58
+ "Head",
59
+ "Header",
60
+ "Headers",
61
+ "HttpCode",
62
+ "Inject",
63
+ "Injectable",
64
+ "Module",
65
+ "Options",
66
+ "Optional_",
67
+ "Param",
68
+ "Patch",
69
+ "Post",
70
+ "Put",
71
+ "Query",
72
+ "Req",
73
+ "Res",
74
+ "UseFilters",
75
+ "UseGuards",
76
+ "UseInterceptors",
77
+ "UsePipes",
78
+ "Scope",
79
+ "BadRequestException",
80
+ "ConflictException",
81
+ "ForbiddenException",
82
+ "HttpException",
83
+ "InternalServerErrorException",
84
+ "MethodNotAllowedException",
85
+ "NotFoundException",
86
+ "NotImplementedException",
87
+ "ServiceUnavailableException",
88
+ "TooManyRequestsException",
89
+ "UnauthorizedException",
90
+ "UnprocessableEntityException",
91
+ "AllExceptionsFilter",
92
+ "ArgumentsHost",
93
+ "ExceptionFilter",
94
+ "CanActivate",
95
+ "ExecutionContext",
96
+ "CallHandler",
97
+ "NestInterceptor",
98
+ "TransformInterceptor",
99
+ "Logger",
100
+ "LoggingMiddleware",
101
+ "MiddlewareConsumer",
102
+ "NestMiddleware",
103
+ "ArgumentMetadata",
104
+ "PipeTransform",
105
+ "ValidationPipe",
106
+ ]
@@ -0,0 +1,48 @@
1
+ from austial.common.decorators.controllers import (
2
+ Controller,
3
+ Delete,
4
+ Get,
5
+ Head,
6
+ Header,
7
+ HttpCode,
8
+ Options,
9
+ Patch,
10
+ Post,
11
+ Put,
12
+ )
13
+ from austial.common.decorators.filters import Catch, UseFilters
14
+ from austial.common.decorators.guards import UseGuards
15
+ from austial.common.decorators.injectable import Inject, Injectable, Optional_
16
+ from austial.common.decorators.interceptors import UseInterceptors
17
+ from austial.common.decorators.modules import Global, Module
18
+ from austial.common.decorators.params import Body, Headers, Param, Query, Req, Res
19
+ from austial.common.decorators.pipes import UsePipes
20
+
21
+ __all__ = [
22
+ "Controller",
23
+ "Delete",
24
+ "Get",
25
+ "Head",
26
+ "Header",
27
+ "HttpCode",
28
+ "Options",
29
+ "Patch",
30
+ "Post",
31
+ "Put",
32
+ "Catch",
33
+ "UseFilters",
34
+ "UseGuards",
35
+ "Inject",
36
+ "Injectable",
37
+ "Optional_",
38
+ "UseInterceptors",
39
+ "Global",
40
+ "Module",
41
+ "Body",
42
+ "Headers",
43
+ "Param",
44
+ "Query",
45
+ "Req",
46
+ "Res",
47
+ "UsePipes",
48
+ ]
@@ -0,0 +1,109 @@
1
+ """``@Controller()`` and HTTP method decorators -- mirrors ``@nestjs/common``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from typing import TypeVar
9
+
10
+ from austial.core.metadata import (
11
+ CONTROLLER_METADATA,
12
+ HEADER_METADATA,
13
+ HTTP_CODE_METADATA,
14
+ ROUTE_METADATA,
15
+ set_metadata,
16
+ )
17
+
18
+ T = TypeVar("T")
19
+
20
+ _EXPRESS_PARAM_RE = re.compile(r":([A-Za-z_][A-Za-z0-9_]*)")
21
+
22
+
23
+ def _to_fastapi_path(path: str) -> str:
24
+ """Translates Nest/Express-style ``:id`` path params into FastAPI/Starlette's
25
+ ``{id}`` syntax, so route paths can be written exactly like in Nest, e.g.
26
+ ``@Get(":id")`` or ``@Get("users/:id/posts/:postId")``."""
27
+ return _EXPRESS_PARAM_RE.sub(r"{\1}", path)
28
+
29
+
30
+ @dataclass
31
+ class ControllerMetadata:
32
+ prefix: str = ""
33
+ tags: list | None = None
34
+
35
+
36
+ @dataclass
37
+ class RouteMetadata:
38
+ method: str
39
+ path: str = ""
40
+
41
+
42
+ def Controller(prefix: str = "", *, tags: list | None = None) -> Callable[[T], T]:
43
+ """Class decorator: declares a controller and its route prefix.
44
+
45
+ Example::
46
+
47
+ @Controller("health")
48
+ class HealthController:
49
+ def __init__(self, health_service: HealthService):
50
+ self.health_service = health_service
51
+
52
+ @Get()
53
+ async def check(self):
54
+ ...
55
+ """
56
+ prefix = "/" + prefix.strip("/") if prefix else ""
57
+
58
+ def decorator(cls: T) -> T:
59
+ set_metadata(CONTROLLER_METADATA, ControllerMetadata(prefix=prefix, tags=tags))(cls)
60
+ return cls
61
+
62
+ return decorator
63
+
64
+
65
+ def _route_decorator(method: str):
66
+ def factory(path: str = "") -> Callable:
67
+ path = _to_fastapi_path(path)
68
+ normalized = "/" + path.strip("/") if path else ""
69
+
70
+ def decorator(fn):
71
+ set_metadata(ROUTE_METADATA, RouteMetadata(method=method, path=normalized))(fn)
72
+ return fn
73
+
74
+ return decorator
75
+
76
+ return factory
77
+
78
+
79
+ Get = _route_decorator("GET")
80
+ Post = _route_decorator("POST")
81
+ Put = _route_decorator("PUT")
82
+ Patch = _route_decorator("PATCH")
83
+ Delete = _route_decorator("DELETE")
84
+ Options = _route_decorator("OPTIONS")
85
+ Head = _route_decorator("HEAD")
86
+
87
+
88
+ def HttpCode(status_code: int) -> Callable:
89
+ """Method decorator overriding the default 2xx status code, mirrors
90
+ Nest's ``@HttpCode()``."""
91
+
92
+ def decorator(fn):
93
+ set_metadata(HTTP_CODE_METADATA, status_code)(fn)
94
+ return fn
95
+
96
+ return decorator
97
+
98
+
99
+ def Header(key: str, value: str) -> Callable:
100
+ """Method decorator adding a static response header, mirrors Nest's ``@Header()``.
101
+ Stackable -- multiple ``@Header`` calls accumulate."""
102
+
103
+ def decorator(fn):
104
+ headers: dict[str, str] = dict(getattr(fn, "__austial_metadata__", {}).get(HEADER_METADATA, {}))
105
+ headers[key] = value
106
+ set_metadata(HEADER_METADATA, headers)(fn)
107
+ return fn
108
+
109
+ return decorator
@@ -0,0 +1,31 @@
1
+ """``@UseFilters()`` / ``@Catch()`` -- mirrors ``@nestjs/common``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from austial.core.metadata import CATCH_METADATA, FILTERS_METADATA, get_metadata, set_metadata
9
+
10
+
11
+ def UseFilters(*filters: Any) -> Callable:
12
+ """Attaches one or more ``ExceptionFilter``s (classes or instances) to a
13
+ controller or a single route handler."""
14
+
15
+ def decorator(target):
16
+ existing = list(get_metadata(FILTERS_METADATA, target, []))
17
+ set_metadata(FILTERS_METADATA, existing + list(filters))(target)
18
+ return target
19
+
20
+ return decorator
21
+
22
+
23
+ def Catch(*exceptions: type[BaseException]) -> Callable:
24
+ """Class decorator declaring which exception types a filter handles.
25
+ An empty call (``@Catch()``) catches everything, mirroring Nest."""
26
+
27
+ def decorator(cls):
28
+ set_metadata(CATCH_METADATA, tuple(exceptions))(cls)
29
+ return cls
30
+
31
+ return decorator
@@ -0,0 +1,20 @@
1
+ """``@UseGuards()`` -- mirrors ``@nestjs/common``. Applicable to controllers or handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from austial.core.metadata import GUARDS_METADATA, get_metadata, set_metadata
9
+
10
+
11
+ def UseGuards(*guards: Any) -> Callable:
12
+ """Attaches one or more ``CanActivate`` guards (classes or instances) to a
13
+ controller or a single route handler. Stacks across repeated calls."""
14
+
15
+ def decorator(target):
16
+ existing = list(get_metadata(GUARDS_METADATA, target, []))
17
+ set_metadata(GUARDS_METADATA, existing + list(guards))(target)
18
+ return target
19
+
20
+ return decorator
@@ -0,0 +1,68 @@
1
+ """``@Injectable()`` and ``@Inject(token)`` -- mirrors ``@nestjs/common``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any, TypeVar
7
+
8
+ from austial.common.enums import Scope
9
+ from austial.core.metadata import INJECTABLE_METADATA, set_metadata
10
+
11
+ T = TypeVar("T")
12
+
13
+
14
+ def Injectable(scope: Scope = Scope.DEFAULT) -> Callable[[T], T]:
15
+ """Marks a class as a provider that the DI container can construct.
16
+
17
+ Usable as ``@Injectable()`` or ``@Injectable(scope=Scope.TRANSIENT)``,
18
+ exactly like Nest.
19
+ """
20
+
21
+ def decorator(cls: T) -> T:
22
+ set_metadata(INJECTABLE_METADATA, {"scope": scope})(cls)
23
+ return cls
24
+
25
+ return decorator
26
+
27
+
28
+ def Inject(token: Any):
29
+ """Parameter-level marker for constructor injection by *token* rather than
30
+ by type. Mirrors Nest's ``@Inject(TOKEN)``.
31
+
32
+ Usage::
33
+
34
+ class CatsService:
35
+ def __init__(self, @Inject("CATS_CONFIG") config: dict): ...
36
+
37
+ Python has no parameter decorators, so instead this is used as a *default
38
+ value* combined with a type-hint-less parameter, e.g.::
39
+
40
+ class CatsService:
41
+ def __init__(self, config = Inject("CATS_CONFIG")): ...
42
+
43
+ The container recognises :class:`InjectMarker` sentinels returned here and
44
+ resolves them by token instead of by annotation type.
45
+ """
46
+ return InjectMarker(token)
47
+
48
+
49
+ class InjectMarker:
50
+ __slots__ = ("token",)
51
+
52
+ def __init__(self, token: Any):
53
+ self.token = token
54
+
55
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
56
+ return f"InjectMarker({self.token!r})"
57
+
58
+
59
+ def Optional_() -> OptionalMarker: # noqa: N802 - mirrors Nest's @Optional()
60
+ return OptionalMarker()
61
+
62
+
63
+ class OptionalMarker:
64
+ """Marks a constructor param as optional; unresolved deps become ``None``
65
+ instead of raising, mirroring Nest's ``@Optional()``."""
66
+
67
+ def __repr__(self) -> str: # pragma: no cover
68
+ return "OptionalMarker()"
@@ -0,0 +1,20 @@
1
+ """``@UseInterceptors()`` -- mirrors ``@nestjs/common``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from austial.core.metadata import INTERCEPTORS_METADATA, get_metadata, set_metadata
9
+
10
+
11
+ def UseInterceptors(*interceptors: Any) -> Callable:
12
+ """Attaches one or more ``NestInterceptor``s (classes or instances) to a
13
+ controller or a single route handler."""
14
+
15
+ def decorator(target):
16
+ existing = list(get_metadata(INTERCEPTORS_METADATA, target, []))
17
+ set_metadata(INTERCEPTORS_METADATA, existing + list(interceptors))(target)
18
+ return target
19
+
20
+ return decorator
@@ -0,0 +1,65 @@
1
+ """``@Module()`` -- mirrors ``@nestjs/common``'s ``@Module()`` decorator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, TypeVar
8
+
9
+ from austial.core.metadata import MODULE_METADATA, set_metadata
10
+
11
+ T = TypeVar("T")
12
+
13
+
14
+ @dataclass
15
+ class ModuleMetadata:
16
+ imports: list[Any] = field(default_factory=list)
17
+ controllers: list[type] = field(default_factory=list)
18
+ providers: list[Any] = field(default_factory=list)
19
+ exports: list[Any] = field(default_factory=list)
20
+ is_global: bool = False
21
+
22
+
23
+ def Module(
24
+ *,
25
+ imports: Sequence[Any] = (),
26
+ controllers: Sequence[type] = (),
27
+ providers: Sequence[Any] = (),
28
+ exports: Sequence[Any] = (),
29
+ ) -> Callable[[T], T]:
30
+ """Class decorator declaring a module's imports/controllers/providers/exports.
31
+
32
+ Example::
33
+
34
+ @Module(
35
+ imports=[ConfigModule.for_root()],
36
+ controllers=[HealthController],
37
+ providers=[HealthService],
38
+ exports=[HealthService],
39
+ )
40
+ class HealthModule:
41
+ pass
42
+ """
43
+
44
+ def decorator(cls: T) -> T:
45
+ metadata = ModuleMetadata(
46
+ imports=list(imports),
47
+ controllers=list(controllers),
48
+ providers=list(providers),
49
+ exports=list(exports),
50
+ )
51
+ set_metadata(MODULE_METADATA, metadata)(cls)
52
+ return cls
53
+
54
+ return decorator
55
+
56
+
57
+ def Global(cls: T) -> T:
58
+ """Class decorator marking a module's providers as available application-wide
59
+ without needing to be re-imported, mirroring Nest's ``@Global()``."""
60
+ from austial.core.metadata import get_metadata
61
+
62
+ metadata: ModuleMetadata = get_metadata(MODULE_METADATA, cls)
63
+ if metadata is not None:
64
+ metadata.is_global = True
65
+ return cls
@@ -0,0 +1,50 @@
1
+ """Parameter helpers -- mirrors Nest's ``@Param()``/``@Query()``/``@Body()``/etc.
2
+
3
+ Python has no parameter decorators, so instead of annotating the parameter,
4
+ these are used as **default values** in the handler signature, e.g.::
5
+
6
+ @Get(":id")
7
+ async def find_one(self, id: str = Param("id")):
8
+ ...
9
+
10
+ @Post()
11
+ async def create(self, dto: CreateCatDto = Body()):
12
+ ...
13
+
14
+ Under the hood these simply return FastAPI's own ``Path``/``Query``/``Body``/
15
+ ``Header`` markers, since FastAPI + pydantic already do exactly what Nest's
16
+ param decorators + ``ValidationPipe`` do. ``Req``/``Res`` are just re-exports
17
+ of Starlette's ``Request``/``Response`` for type-hinting convenience.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ from fastapi import Body as _Body
25
+ from fastapi import Header as _Header
26
+ from fastapi import Path as _Path
27
+ from fastapi import Query as _Query
28
+ from fastapi import Request as Req # noqa: F401 (re-export, Nest-style alias)
29
+ from fastapi import Response as Res # noqa: F401 (re-export, Nest-style alias)
30
+
31
+
32
+ def Param(name: str | None = None, *, default: Any = ..., **kwargs) -> Any:
33
+ """Equivalent to Nest's ``@Param('id')``: binds a path parameter."""
34
+ return _Path(default=default, alias=name, **kwargs)
35
+
36
+
37
+ def Query(name: str | None = None, *, default: Any = None, **kwargs) -> Any:
38
+ """Equivalent to Nest's ``@Query('search')``: binds a query-string parameter."""
39
+ return _Query(default=default, alias=name, **kwargs)
40
+
41
+
42
+ def Body(*, default: Any = ..., embed: bool = False, **kwargs) -> Any:
43
+ """Equivalent to Nest's ``@Body()``: binds (and, via pydantic, validates)
44
+ the request body against the parameter's type annotation."""
45
+ return _Body(default=default, embed=embed, **kwargs)
46
+
47
+
48
+ def Headers(name: str | None = None, *, default: Any = None, **kwargs) -> Any:
49
+ """Equivalent to Nest's ``@Headers('x-api-key')``: binds a request header."""
50
+ return _Header(default=default, alias=name, **kwargs)
@@ -0,0 +1,20 @@
1
+ """``@UsePipes()`` -- mirrors ``@nestjs/common``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from austial.core.metadata import PIPES_METADATA, get_metadata, set_metadata
9
+
10
+
11
+ def UsePipes(*pipes: Any) -> Callable:
12
+ """Attaches one or more ``PipeTransform``s (classes or instances) to a
13
+ controller or a single route handler."""
14
+
15
+ def decorator(target):
16
+ existing = list(get_metadata(PIPES_METADATA, target, []))
17
+ set_metadata(PIPES_METADATA, existing + list(pipes))(target)
18
+ return target
19
+
20
+ return decorator
@@ -0,0 +1,15 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class Scope(StrEnum):
5
+ """Provider scope, mirrors ``@nestjs/common``'s ``Scope``.
6
+
7
+ Only ``DEFAULT`` (singleton, one instance per application) and
8
+ ``TRANSIENT`` (a new instance every time it's resolved) are supported.
9
+ Nest's ``REQUEST`` scope is intentionally not implemented in v1 -- FastAPI's
10
+ async request model doesn't map onto it cleanly, and it's rarely needed
11
+ outside multi-tenant setups.
12
+ """
13
+
14
+ DEFAULT = "DEFAULT"
15
+ TRANSIENT = "TRANSIENT"
@@ -0,0 +1,29 @@
1
+ from austial.common.exceptions.http_exceptions import (
2
+ BadRequestException,
3
+ ConflictException,
4
+ ForbiddenException,
5
+ HttpException,
6
+ InternalServerErrorException,
7
+ MethodNotAllowedException,
8
+ NotFoundException,
9
+ NotImplementedException,
10
+ ServiceUnavailableException,
11
+ TooManyRequestsException,
12
+ UnauthorizedException,
13
+ UnprocessableEntityException,
14
+ )
15
+
16
+ __all__ = [
17
+ "BadRequestException",
18
+ "ConflictException",
19
+ "ForbiddenException",
20
+ "HttpException",
21
+ "InternalServerErrorException",
22
+ "MethodNotAllowedException",
23
+ "NotFoundException",
24
+ "NotImplementedException",
25
+ "ServiceUnavailableException",
26
+ "TooManyRequestsException",
27
+ "UnauthorizedException",
28
+ "UnprocessableEntityException",
29
+ ]
@@ -0,0 +1,109 @@
1
+ """HTTP exceptions -- mirrors ``@nestjs/common``'s exception hierarchy.
2
+
3
+ All of these carry a ``status_code`` and a Nest-shaped ``response`` payload
4
+ (``{"statusCode": ..., "message": ..., "error": ...}``) so the default
5
+ :class:`~austial.common.filters.all_exceptions_filter.AllExceptionsFilter`
6
+ can render them exactly like Nest's built-in filter does.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+
14
+ class HttpException(Exception):
15
+ def __init__(self, message: str | dict | None = None, status_code: int = 500, error: str | None = None):
16
+ self.status_code = status_code
17
+ if isinstance(message, dict):
18
+ self.response: dict[str, Any] = {"statusCode": status_code, **message}
19
+ else:
20
+ self.response = {
21
+ "statusCode": status_code,
22
+ "message": message or _default_message(status_code),
23
+ "error": error or _default_message(status_code),
24
+ }
25
+ super().__init__(str(self.response.get("message")))
26
+
27
+ def get_status(self) -> int:
28
+ return self.status_code
29
+
30
+ def get_response(self) -> dict:
31
+ return self.response
32
+
33
+
34
+ def _default_message(status_code: int) -> str:
35
+ return _REASON_PHRASES.get(status_code, "Error")
36
+
37
+
38
+ _REASON_PHRASES = {
39
+ 400: "Bad Request",
40
+ 401: "Unauthorized",
41
+ 403: "Forbidden",
42
+ 404: "Not Found",
43
+ 405: "Method Not Allowed",
44
+ 406: "Not Acceptable",
45
+ 408: "Request Timeout",
46
+ 409: "Conflict",
47
+ 410: "Gone",
48
+ 422: "Unprocessable Entity",
49
+ 429: "Too Many Requests",
50
+ 500: "Internal Server Error",
51
+ 501: "Not Implemented",
52
+ 502: "Bad Gateway",
53
+ 503: "Service Unavailable",
54
+ }
55
+
56
+
57
+ class BadRequestException(HttpException):
58
+ def __init__(self, message: str | dict | None = None):
59
+ super().__init__(message, 400)
60
+
61
+
62
+ class UnauthorizedException(HttpException):
63
+ def __init__(self, message: str | dict | None = None):
64
+ super().__init__(message, 401)
65
+
66
+
67
+ class ForbiddenException(HttpException):
68
+ def __init__(self, message: str | dict | None = None):
69
+ super().__init__(message, 403)
70
+
71
+
72
+ class NotFoundException(HttpException):
73
+ def __init__(self, message: str | dict | None = None):
74
+ super().__init__(message, 404)
75
+
76
+
77
+ class MethodNotAllowedException(HttpException):
78
+ def __init__(self, message: str | dict | None = None):
79
+ super().__init__(message, 405)
80
+
81
+
82
+ class ConflictException(HttpException):
83
+ def __init__(self, message: str | dict | None = None):
84
+ super().__init__(message, 409)
85
+
86
+
87
+ class UnprocessableEntityException(HttpException):
88
+ def __init__(self, message: str | dict | None = None):
89
+ super().__init__(message, 422)
90
+
91
+
92
+ class TooManyRequestsException(HttpException):
93
+ def __init__(self, message: str | dict | None = None):
94
+ super().__init__(message, 429)
95
+
96
+
97
+ class InternalServerErrorException(HttpException):
98
+ def __init__(self, message: str | dict | None = None):
99
+ super().__init__(message, 500)
100
+
101
+
102
+ class NotImplementedException(HttpException):
103
+ def __init__(self, message: str | dict | None = None):
104
+ super().__init__(message, 501)
105
+
106
+
107
+ class ServiceUnavailableException(HttpException):
108
+ def __init__(self, message: str | dict | None = None):
109
+ super().__init__(message, 503)
@@ -0,0 +1,4 @@
1
+ from austial.common.filters.all_exceptions_filter import AllExceptionsFilter
2
+ from austial.common.filters.base import ArgumentsHost, ExceptionFilter
3
+
4
+ __all__ = ["AllExceptionsFilter", "ArgumentsHost", "ExceptionFilter"]
@@ -0,0 +1,31 @@
1
+ """``AllExceptionsFilter`` -- the default global exception filter.
2
+
3
+ Renders errors in the exact shape Nest's built-in filter produces::
4
+
5
+ {"statusCode": 404, "message": "Not Found", "error": "Not Found",
6
+ "timestamp": "...", "path": "/cats/42"}
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import UTC, datetime
12
+
13
+ from starlette.responses import JSONResponse
14
+
15
+ from austial.common.exceptions.http_exceptions import HttpException
16
+ from austial.common.filters.base import ArgumentsHost, ExceptionFilter
17
+
18
+
19
+ class AllExceptionsFilter(ExceptionFilter):
20
+ async def catch(self, exception: BaseException, host: ArgumentsHost) -> JSONResponse:
21
+ request = host.get_request()
22
+ if isinstance(exception, HttpException):
23
+ status_code = exception.get_status()
24
+ body = dict(exception.get_response())
25
+ else:
26
+ status_code = 500
27
+ body = {"statusCode": 500, "message": "Internal Server Error", "error": "Internal Server Error"}
28
+
29
+ body.setdefault("timestamp", datetime.now(UTC).isoformat())
30
+ body.setdefault("path", request.url.path)
31
+ return JSONResponse(status_code=status_code, content=body)
@@ -0,0 +1,28 @@
1
+ """``ExceptionFilter`` + ``ArgumentsHost`` -- mirrors ``@nestjs/common``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ from starlette.requests import Request
8
+ from starlette.responses import Response
9
+
10
+
11
+ class ArgumentsHost:
12
+ """Minimal stand-in for Nest's ``ArgumentsHost`` -- gives filters access to
13
+ the underlying HTTP request."""
14
+
15
+ def __init__(self, request: Request):
16
+ self._request = request
17
+
18
+ def switch_to_http(self) -> ArgumentsHost:
19
+ return self
20
+
21
+ def get_request(self) -> Request:
22
+ return self._request
23
+
24
+
25
+ class ExceptionFilter(ABC):
26
+ @abstractmethod
27
+ async def catch(self, exception: BaseException, host: ArgumentsHost) -> Response:
28
+ raise NotImplementedError
@@ -0,0 +1,3 @@
1
+ from austial.common.guards.base import CanActivate, ExecutionContext
2
+
3
+ __all__ = ["CanActivate", "ExecutionContext"]
@@ -0,0 +1,39 @@
1
+ """``CanActivate`` + ``ExecutionContext`` -- mirrors ``@nestjs/common``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any
7
+
8
+ from starlette.requests import Request
9
+
10
+
11
+ class ExecutionContext:
12
+ """Gives guards/interceptors/filters access to the current request and to
13
+ the controller class + handler function being invoked, mirroring Nest's
14
+ ``ExecutionContext``."""
15
+
16
+ def __init__(self, request: Request, controller_class: type, handler: Any):
17
+ self._request = request
18
+ self._controller_class = controller_class
19
+ self._handler = handler
20
+
21
+ def switch_to_http(self) -> ExecutionContext:
22
+ return self
23
+
24
+ def get_request(self) -> Request:
25
+ return self._request
26
+
27
+ def get_class(self) -> type:
28
+ return self._controller_class
29
+
30
+ def get_handler(self) -> Any:
31
+ return self._handler
32
+
33
+
34
+ class CanActivate(ABC):
35
+ """Base class for guards. Implement ``can_activate`` to allow/deny a request."""
36
+
37
+ @abstractmethod
38
+ async def can_activate(self, context: ExecutionContext) -> bool:
39
+ raise NotImplementedError
@@ -0,0 +1,4 @@
1
+ from austial.common.interceptors.base import CallHandler, NestInterceptor
2
+ from austial.common.interceptors.transform_interceptor import TransformInterceptor
3
+
4
+ __all__ = ["CallHandler", "NestInterceptor", "TransformInterceptor"]
@@ -0,0 +1,30 @@
1
+ """``NestInterceptor`` + ``CallHandler`` -- mirrors ``@nestjs/common``.
2
+
3
+ Nest interceptors wrap an RxJS ``Observable``; Austial has no RxJS, so
4
+ ``CallHandler.handle()`` is simply an ``async`` continuation representing
5
+ "run the rest of the pipeline (further interceptors, then the handler) and
6
+ give me its return value". Interceptors can inspect/replace the result, time
7
+ the call, catch+rethrow, etc. -- same capabilities, async/await shaped.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from abc import ABC, abstractmethod
13
+ from collections.abc import Awaitable, Callable
14
+ from typing import Any
15
+
16
+ from austial.common.guards.base import ExecutionContext
17
+
18
+
19
+ class CallHandler:
20
+ def __init__(self, next_call: Callable[[], Awaitable[Any]]):
21
+ self._next_call = next_call
22
+
23
+ async def handle(self) -> Any:
24
+ return await self._next_call()
25
+
26
+
27
+ class NestInterceptor(ABC):
28
+ @abstractmethod
29
+ async def intercept(self, context: ExecutionContext, next: CallHandler) -> Any:
30
+ raise NotImplementedError
@@ -0,0 +1,19 @@
1
+ """``TransformInterceptor`` -- the canonical Nest "wrap every response" example.
2
+
3
+ Wraps every successful controller response in a ``{"data": ..., "timestamp": ...}``
4
+ envelope, exactly like the interceptor from the official NestJS docs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import UTC, datetime
10
+ from typing import Any
11
+
12
+ from austial.common.guards.base import ExecutionContext
13
+ from austial.common.interceptors.base import CallHandler, NestInterceptor
14
+
15
+
16
+ class TransformInterceptor(NestInterceptor):
17
+ async def intercept(self, context: ExecutionContext, next: CallHandler) -> Any:
18
+ result = await next.handle()
19
+ return {"data": result, "timestamp": datetime.now(UTC).isoformat()}
@@ -0,0 +1,56 @@
1
+ """``Logger`` -- mirrors ``@nestjs/common``'s ``Logger``, including the
2
+ familiar colored ``[Austial] 12345 - 07/11/2026, 10:00:00 AM LOG [Context] message``
3
+ startup banner style."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from datetime import datetime
9
+
10
+ from rich.console import Console
11
+
12
+ _console = Console()
13
+
14
+ _LEVEL_COLORS = {
15
+ "LOG": "green",
16
+ "ERROR": "red",
17
+ "WARN": "yellow",
18
+ "DEBUG": "magenta",
19
+ "VERBOSE": "cyan",
20
+ }
21
+
22
+
23
+ class Logger:
24
+ """Usable standalone (``Logger("HealthController").log("...")``) or as a
25
+ class-level helper via the static methods, same dual API as Nest's."""
26
+
27
+ app_name = "Austial"
28
+
29
+ def __init__(self, context: str | None = None):
30
+ self.context = context or "Austial"
31
+
32
+ def _write(self, level: str, message: str, context: str | None = None) -> None:
33
+ color = _LEVEL_COLORS.get(level, "white")
34
+ timestamp = datetime.now().strftime("%m/%d/%Y, %I:%M:%S %p")
35
+ ctx = context or self.context
36
+ _console.print(
37
+ f"[bold {color}][{self.app_name}][/bold {color}] "
38
+ f"[dim]{os.getpid():<6}[/dim] - {timestamp} "
39
+ f"[bold {color}]{level:<7}[/bold {color}] "
40
+ f"[yellow][{ctx}][/yellow] {message}"
41
+ )
42
+
43
+ def log(self, message: str, context: str | None = None) -> None:
44
+ self._write("LOG", message, context)
45
+
46
+ def error(self, message: str, context: str | None = None) -> None:
47
+ self._write("ERROR", message, context)
48
+
49
+ def warn(self, message: str, context: str | None = None) -> None:
50
+ self._write("WARN", message, context)
51
+
52
+ def debug(self, message: str, context: str | None = None) -> None:
53
+ self._write("DEBUG", message, context)
54
+
55
+ def verbose(self, message: str, context: str | None = None) -> None:
56
+ self._write("VERBOSE", message, context)
@@ -0,0 +1,4 @@
1
+ from austial.common.middleware.base import MiddlewareBinding, MiddlewareConsumer, NestMiddleware
2
+ from austial.common.middleware.logging_middleware import LoggingMiddleware
3
+
4
+ __all__ = ["MiddlewareBinding", "MiddlewareConsumer", "NestMiddleware", "LoggingMiddleware"]
@@ -0,0 +1,60 @@
1
+ """``NestMiddleware`` + ``MiddlewareConsumer`` -- mirrors ``@nestjs/common``.
2
+
3
+ A module opts into middleware by defining a ``configure(self, consumer)``
4
+ method (mirroring Nest's ``NestModule`` interface)::
5
+
6
+ class AppModule:
7
+ def configure(self, consumer: MiddlewareConsumer) -> None:
8
+ consumer.apply(LoggingMiddleware).for_routes("*")
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from abc import ABC, abstractmethod
14
+ from collections.abc import Awaitable, Callable, Sequence
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ from starlette.requests import Request
19
+ from starlette.responses import Response
20
+
21
+
22
+ class NestMiddleware(ABC):
23
+ @abstractmethod
24
+ async def use(self, request: Request, call_next: Callable[[], Awaitable[Response]]) -> Response:
25
+ raise NotImplementedError
26
+
27
+
28
+ @dataclass
29
+ class MiddlewareBinding:
30
+ middlewares: Sequence[Any]
31
+ routes: Sequence[str]
32
+
33
+ def matches(self, path: str) -> bool:
34
+ for route in self.routes:
35
+ if route in ("*", "(.*)"):
36
+ return True
37
+ if path == route or path.startswith(route.rstrip("*")):
38
+ return True
39
+ return False
40
+
41
+
42
+ class _RouteBinder:
43
+ def __init__(self, consumer: MiddlewareConsumer, middlewares: Sequence[Any]):
44
+ self._consumer = consumer
45
+ self._middlewares = middlewares
46
+
47
+ def for_routes(self, *routes: str) -> MiddlewareConsumer:
48
+ self._consumer.bindings.append(MiddlewareBinding(middlewares=self._middlewares, routes=routes or ("*",)))
49
+ return self._consumer
50
+
51
+
52
+ class MiddlewareConsumer:
53
+ """Passed into a module's ``configure()`` hook; collects
54
+ ``apply(...).for_routes(...)`` bindings for the factory to wire up."""
55
+
56
+ def __init__(self):
57
+ self.bindings: list[MiddlewareBinding] = []
58
+
59
+ def apply(self, *middlewares: Any) -> _RouteBinder:
60
+ return _RouteBinder(self, middlewares)
@@ -0,0 +1,24 @@
1
+ """``LoggingMiddleware`` -- the canonical Nest "log every request" example
2
+ (nestjs.com/docs/middleware uses the exact same one, just in TypeScript)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import time
7
+ from collections.abc import Awaitable, Callable
8
+
9
+ from starlette.requests import Request
10
+ from starlette.responses import Response
11
+
12
+ from austial.common.logger import Logger
13
+ from austial.common.middleware.base import NestMiddleware
14
+
15
+ _logger = Logger("HTTP")
16
+
17
+
18
+ class LoggingMiddleware(NestMiddleware):
19
+ async def use(self, request: Request, call_next: Callable[[], Awaitable[Response]]) -> Response:
20
+ start = time.perf_counter()
21
+ response = await call_next()
22
+ duration_ms = (time.perf_counter() - start) * 1000
23
+ _logger.log(f"{request.method} {request.url.path} {response.status_code} - {duration_ms:.1f}ms")
24
+ return response
@@ -0,0 +1,3 @@
1
+ from austial.common.pipes.validation_pipe import ArgumentMetadata, PipeTransform, ValidationPipe
2
+
3
+ __all__ = ["ArgumentMetadata", "PipeTransform", "ValidationPipe"]
@@ -0,0 +1,51 @@
1
+ """``PipeTransform`` + ``ValidationPipe`` -- mirrors ``@nestjs/common``.
2
+
3
+ FastAPI/pydantic already parse and validate the request body against a
4
+ handler's type hints (that's Austial's equivalent of Nest's ``class-validator``
5
+ integration). The pipe pipeline still runs for real on top of that, mirroring
6
+ Nest's "pipes transform/validate arguments right before the handler runs":
7
+ each resolved argument is passed through ``transform()`` for every pipe in
8
+ scope (global -> controller -> route), in order.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import builtins
14
+ from abc import ABC, abstractmethod
15
+ from dataclasses import dataclass
16
+ from typing import Any, Literal
17
+
18
+ from pydantic import BaseModel, ValidationError
19
+
20
+ from austial.common.exceptions.http_exceptions import BadRequestException
21
+
22
+
23
+ @dataclass
24
+ class ArgumentMetadata:
25
+ # Field name intentionally mirrors Nest's `ArgumentMetadata.type` -- annotate
26
+ # `metatype` via `builtins.type` to avoid this field shadowing the builtin.
27
+ type: Literal["body", "query", "param", "custom"]
28
+ data: str | None
29
+ metatype: builtins.type | None
30
+
31
+
32
+ class PipeTransform(ABC):
33
+ @abstractmethod
34
+ def transform(self, value: Any, metadata: ArgumentMetadata) -> Any:
35
+ raise NotImplementedError
36
+
37
+
38
+ class ValidationPipe(PipeTransform):
39
+ """Re-validates pydantic-model arguments, raising Nest-shaped
40
+ ``BadRequestException``s on failure -- the workhorse pipe almost every
41
+ Nest app registers globally via ``app.useGlobalPipes(new ValidationPipe())``."""
42
+
43
+ def transform(self, value: Any, metadata: ArgumentMetadata) -> Any:
44
+ if isinstance(value, BaseModel):
45
+ try:
46
+ type(value).model_validate(value.model_dump())
47
+ except ValidationError as exc:
48
+ raise BadRequestException(
49
+ {"message": [str(e["msg"]) for e in exc.errors()], "error": "Bad Request"}
50
+ ) from exc
51
+ return value