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.
- austial_common-0.1.5/.gitignore +11 -0
- austial_common-0.1.5/PKG-INFO +10 -0
- austial_common-0.1.5/pyproject.toml +28 -0
- austial_common-0.1.5/src/austial/common/__init__.py +106 -0
- austial_common-0.1.5/src/austial/common/decorators/__init__.py +48 -0
- austial_common-0.1.5/src/austial/common/decorators/controllers.py +109 -0
- austial_common-0.1.5/src/austial/common/decorators/filters.py +31 -0
- austial_common-0.1.5/src/austial/common/decorators/guards.py +20 -0
- austial_common-0.1.5/src/austial/common/decorators/injectable.py +68 -0
- austial_common-0.1.5/src/austial/common/decorators/interceptors.py +20 -0
- austial_common-0.1.5/src/austial/common/decorators/modules.py +65 -0
- austial_common-0.1.5/src/austial/common/decorators/params.py +50 -0
- austial_common-0.1.5/src/austial/common/decorators/pipes.py +20 -0
- austial_common-0.1.5/src/austial/common/enums.py +15 -0
- austial_common-0.1.5/src/austial/common/exceptions/__init__.py +29 -0
- austial_common-0.1.5/src/austial/common/exceptions/http_exceptions.py +109 -0
- austial_common-0.1.5/src/austial/common/filters/__init__.py +4 -0
- austial_common-0.1.5/src/austial/common/filters/all_exceptions_filter.py +31 -0
- austial_common-0.1.5/src/austial/common/filters/base.py +28 -0
- austial_common-0.1.5/src/austial/common/guards/__init__.py +3 -0
- austial_common-0.1.5/src/austial/common/guards/base.py +39 -0
- austial_common-0.1.5/src/austial/common/interceptors/__init__.py +4 -0
- austial_common-0.1.5/src/austial/common/interceptors/base.py +30 -0
- austial_common-0.1.5/src/austial/common/interceptors/transform_interceptor.py +19 -0
- austial_common-0.1.5/src/austial/common/logger.py +56 -0
- austial_common-0.1.5/src/austial/common/middleware/__init__.py +4 -0
- austial_common-0.1.5/src/austial/common/middleware/base.py +60 -0
- austial_common-0.1.5/src/austial/common/middleware/logging_middleware.py +24 -0
- austial_common-0.1.5/src/austial/common/pipes/__init__.py +3 -0
- austial_common-0.1.5/src/austial/common/pipes/validation_pipe.py +51 -0
|
@@ -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,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,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,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,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,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
|