python-ddd-framework 0.6.0__py3-none-any.whl → 0.7.0__py3-none-any.whl
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.
- python_ddd_framework/authorization/checking.py +35 -0
- python_ddd_framework/developer_kit/generation.py +0 -9
- python_ddd_framework/developer_kit/templates/module/cookiecutter.json +1 -1
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/README.md +29 -77
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/module.py.jinja +5 -72
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/module.py.jinja +1 -3
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/module.py.jinja +2 -3
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/module.py.jinja +8 -62
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/module.py.jinja +1 -2
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md +12 -8
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/README.md +22 -10
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/backend/src/host/main.py.jinja +4 -1
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +40 -6
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +177 -30
- python_ddd_framework/fastapi/__init__.py +2 -0
- python_ddd_framework/fastapi/access.py +127 -0
- python_ddd_framework/fastapi/access_options.py +13 -0
- python_ddd_framework/fastapi/action.py +6 -0
- python_ddd_framework/fastapi/manual_action.py +33 -2
- python_ddd_framework/fastapi/module.py +2 -0
- python_ddd_framework/fastapi/realtime/authentication.py +21 -10
- python_ddd_framework/fastapi/realtime/options.py +0 -1
- python_ddd_framework/fastapi/realtime/runtime.py +41 -26
- python_ddd_framework/fastapi/request_context.py +10 -2
- python_ddd_framework/fastapi/routing.py +12 -5
- python_ddd_framework/fastapi/server.py +9 -0
- python_ddd_framework/fastapi/service_endpoints.py +5 -0
- python_ddd_framework/fastapi/streaming.py +95 -0
- python_ddd_framework/fastapi/transfer.py +138 -3
- python_ddd_framework/invocation/dispatcher.py +2 -24
- python_ddd_framework/invocation/scopes.py +5 -3
- python_ddd_framework/observability/formatting.py +1 -0
- python_ddd_framework/unit_of_work/manager.py +11 -2
- {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/METADATA +29 -8
- {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/RECORD +39 -86
- {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/WHEEL +1 -1
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/__init__.py.jinja +0 -1
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/handler.py.jinja +0 -45
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/payload.py.jinja +0 -8
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/__init__.py.jinja +0 -1
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/handler.py.jinja +0 -35
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/payload.py.jinja +0 -5
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/schedule.py.jinja +0 -15
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_workers/order_maintenance_worker.py.jinja +0 -28
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_workers/order_statistics_worker.py.jinja +0 -26
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/caching/order_cache.py.jinja +0 -9
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/caching/statistics_cache.py.jinja +0 -9
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/event_handlers/order_changed_handler.py.jinja +0 -23
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_integration_service.py.jinja +0 -71
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_observation_handler.py.jinja +0 -16
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/integration_services/order_reporting_service.py.jinja +0 -16
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/interceptors/order_timing_interceptor.py.jinja +0 -17
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/options/order_options.py.jinja +0 -7
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_approval_service.py.jinja +0 -65
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_management_service.py.jinja +0 -35
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_query_service.py.jinja +0 -41
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/setting_handlers/approval_setting_observer.py.jinja +0 -17
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/inputs/approve_order.py.jinja +0 -6
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/inputs/create_order.py.jinja +0 -7
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/integration_services/order_reporting_service.py.jinja +0 -7
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_approval_service.py.jinja +0 -13
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_management_service.py.jinja +0 -10
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_query_service.py.jinja +0 -12
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/views/order_statistics_snapshot.py.jinja +0 -9
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/views/order_view.py.jinja +0 -12
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/entities/order.py.jinja +0 -48
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/events/order_changed.py.jinja +0 -13
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/repositories/order_repository.py.jinja +0 -17
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/seeding/order_seed_contributor.py.jinja +0 -22
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/services/order_approval_service.py.jinja +0 -23
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/settings/approval_settings.py.jinja +0 -17
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/value_objects/order_title.py.jinja +0 -10
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/constants/order_constants.py.jinja +0 -3
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/enums/order_status.py.jinja +0 -6
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/errors/order_errors.py.jinja +0 -11
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/messages/order_messages.py.jinja +0 -26
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/messages/order_observation.py.jinja +0 -13
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions/order_permission_provider.py.jinja +0 -19
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions/order_permissions.py.jinja +0 -7
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/value_objects/money.py.jinja +0 -18
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/filters/export_filter.py.jinja +0 -15
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/models/refresh_orders.py.jinja +0 -5
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/routers/order_files.py.jinja +0 -76
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/websockets/order_socket.py.jinja +0 -44
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/models/order_model.py.jinja +0 -29
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/order_repository.py.jinja +0 -81
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/tests/test_domain.py.jinja +0 -19
- {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/entry_points.txt +0 -0
- {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/licenses/LICENSE +0 -0
- {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/licenses/src/python_ddd_framework/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Optional FastAPI adapter public capability;不由 Kernel 根包重导出。"""
|
|
2
2
|
|
|
3
3
|
from . import http as http
|
|
4
|
+
from .access_options import FastApiAccessOptions
|
|
4
5
|
from .adapter import FastApiAdapter
|
|
5
6
|
from .contracts import (
|
|
6
7
|
ApplicationServiceRouteOverride,
|
|
@@ -28,6 +29,7 @@ __all__ = (
|
|
|
28
29
|
"ActionFilterContext",
|
|
29
30
|
"ApplicationServiceRouteOverride",
|
|
30
31
|
"DiMiddleware",
|
|
32
|
+
"FastApiAccessOptions",
|
|
31
33
|
"FastApiAdapter",
|
|
32
34
|
"FastApiAdapterError",
|
|
33
35
|
"FastApiModule",
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""持续访问的身份快照与串行复验;连接、取消和发送仍由各传输 owner 管理。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from dishka import AsyncContainer, Scope
|
|
9
|
+
from starlette.requests import Request
|
|
10
|
+
|
|
11
|
+
from ..authorization import AccessTokenAuthenticator, CurrentUser, UnauthenticatedError
|
|
12
|
+
from ..authorization.checking import _check_authorization
|
|
13
|
+
from ..authorization.contracts import _AuthorizationRequirement
|
|
14
|
+
from ..invocation.runtime import _InvocationRuntime
|
|
15
|
+
from ..invocation.scopes import _managed_request
|
|
16
|
+
from ..options import Options
|
|
17
|
+
from .access_options import FastApiAccessOptions
|
|
18
|
+
|
|
19
|
+
_LOGGER = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _AccessRefresh:
|
|
23
|
+
"""单连接复验状态:串行检查并锁定失败事实,传输的停止与释放由调用方拥有。"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
parent: AsyncContainer,
|
|
28
|
+
runtime: _InvocationRuntime,
|
|
29
|
+
user: CurrentUser,
|
|
30
|
+
token: str | None,
|
|
31
|
+
correlation_id: str,
|
|
32
|
+
requirement: _AuthorizationRequirement,
|
|
33
|
+
interval: int,
|
|
34
|
+
*,
|
|
35
|
+
context: dict[type[object], object] | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._parent = parent
|
|
38
|
+
# 仅保存传输 owner 提供的原生 scope 输入,不继承长 REQUEST 的服务缓存。
|
|
39
|
+
self._context = context
|
|
40
|
+
self._runtime = runtime
|
|
41
|
+
self._token = token
|
|
42
|
+
self._correlation_id = correlation_id
|
|
43
|
+
self._requirement = requirement
|
|
44
|
+
self._interval = interval
|
|
45
|
+
self._lock = asyncio.Lock()
|
|
46
|
+
self._deadline = 0.0
|
|
47
|
+
self.user = user
|
|
48
|
+
self.failure: Exception | None = None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def delay(self) -> float:
|
|
52
|
+
return max(0.0, self._deadline - asyncio.get_running_loop().time())
|
|
53
|
+
|
|
54
|
+
async def check(self, *, force: bool = False) -> CurrentUser:
|
|
55
|
+
"""到期发送与后台检查共用一把锁;失败后所有调用观察同一终态。"""
|
|
56
|
+
async with self._lock:
|
|
57
|
+
if self.failure is not None:
|
|
58
|
+
raise self.failure
|
|
59
|
+
if not force and self.delay > 0:
|
|
60
|
+
return self.user
|
|
61
|
+
try:
|
|
62
|
+
user = await self._check()
|
|
63
|
+
except Exception as error:
|
|
64
|
+
# 失败事实先于传输关闭发布,发送侧不会在慢客户端恢复后继续提交旧数据。
|
|
65
|
+
self.failure = error
|
|
66
|
+
_LOGGER.warning(
|
|
67
|
+
"continuous access check failed",
|
|
68
|
+
extra={"access_exception_type": type(error).__name__},
|
|
69
|
+
)
|
|
70
|
+
raise
|
|
71
|
+
# 从成功完成时开始计时;检查与 scope 清理耗时不占用下一周期。
|
|
72
|
+
self.user = user
|
|
73
|
+
self._deadline = asyncio.get_running_loop().time() + self._interval
|
|
74
|
+
return user
|
|
75
|
+
|
|
76
|
+
async def _check(self) -> CurrentUser:
|
|
77
|
+
"""重建短 scope 内的认证/授权依赖,身份绑定只覆盖本次权限检查。"""
|
|
78
|
+
# 每周期新 REQUEST;不复用长连接容器内缓存的 CurrentUser/PermissionChecker。
|
|
79
|
+
async with _managed_request(self._parent, context=self._context) as container:
|
|
80
|
+
with self._runtime.detached_execution(container):
|
|
81
|
+
user = CurrentUser.anonymous()
|
|
82
|
+
if self._token is not None:
|
|
83
|
+
with self._runtime.authenticating():
|
|
84
|
+
authenticator = await container.get(AccessTokenAuthenticator)
|
|
85
|
+
user = await authenticator.authenticate_access_token(self._token)
|
|
86
|
+
if not user.is_authenticated or (
|
|
87
|
+
self.user.is_authenticated
|
|
88
|
+
and (
|
|
89
|
+
user.user_id != self.user.user_id
|
|
90
|
+
or user.session_id != self.user.session_id
|
|
91
|
+
)
|
|
92
|
+
):
|
|
93
|
+
raise UnauthenticatedError
|
|
94
|
+
caller = self._runtime.bind_caller(user, self._correlation_id)
|
|
95
|
+
try:
|
|
96
|
+
await _check_authorization(
|
|
97
|
+
container, self._runtime.current_user, self._requirement
|
|
98
|
+
)
|
|
99
|
+
finally:
|
|
100
|
+
self._runtime.reset_caller(caller)
|
|
101
|
+
return user
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def _http_access(
|
|
105
|
+
request: Request, requirement: _AuthorizationRequirement
|
|
106
|
+
) -> _AccessRefresh | None:
|
|
107
|
+
"""HTTP owner 提供请求上下文;每轮复验从 APP 新建独立 REQUEST。"""
|
|
108
|
+
if requirement.allows_anonymous or not requirement.requires_authenticated_user:
|
|
109
|
+
return None
|
|
110
|
+
container: AsyncContainer = request.state.dishka_container
|
|
111
|
+
parent = container
|
|
112
|
+
while parent.scope is not Scope.APP:
|
|
113
|
+
assert parent.parent_container is not None
|
|
114
|
+
parent = parent.parent_container
|
|
115
|
+
user, correlation = request.state.python_ddd_framework_identity
|
|
116
|
+
options = await container.get(Options[FastApiAccessOptions])
|
|
117
|
+
return _AccessRefresh(
|
|
118
|
+
parent,
|
|
119
|
+
await container.get(_InvocationRuntime),
|
|
120
|
+
user,
|
|
121
|
+
getattr(request.state, "python_ddd_framework_access_token", None),
|
|
122
|
+
correlation,
|
|
123
|
+
requirement,
|
|
124
|
+
options.value.authentication_refresh_seconds,
|
|
125
|
+
# 原生请求对象是 scope 输入,不共享长 REQUEST 已缓存的认证器或检查器。
|
|
126
|
+
context={Request: request},
|
|
127
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""HTTP 与 WebSocket 共享的持续访问检查配置。"""
|
|
2
|
+
|
|
3
|
+
from pydantic import ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
from ..options import BaseOptions
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FastApiAccessOptions(BaseOptions):
|
|
9
|
+
"""由 FastApiModule 声明、Host 配置的 HTTP/WebSocket 共用复验周期。"""
|
|
10
|
+
|
|
11
|
+
model_config = ConfigDict(extra="forbid")
|
|
12
|
+
|
|
13
|
+
authentication_refresh_seconds: int = Field(default=60, ge=1)
|
|
@@ -59,6 +59,11 @@ class _HttpActionOwner:
|
|
|
59
59
|
self.failure: BaseException | None = None
|
|
60
60
|
self.short_circuited = False
|
|
61
61
|
self.audits: list[Callable[[BaseException | None], Awaitable[None]]] = []
|
|
62
|
+
# SSE 可提前创建生产任务;准备完成与获准发送是两道屏障,均通过后才读取源。
|
|
63
|
+
self.prepared = asyncio.Event()
|
|
64
|
+
self.sending = asyncio.Event()
|
|
65
|
+
request.state.python_ddd_framework_stream_ready = self.sending
|
|
66
|
+
self.stream_result: object = None
|
|
62
67
|
|
|
63
68
|
@classmethod
|
|
64
69
|
async def install(cls, request: Request) -> _HttpActionOwner:
|
|
@@ -158,6 +163,7 @@ class _HttpActionOwner:
|
|
|
158
163
|
self.request.state.python_ddd_framework_action = None
|
|
159
164
|
if primary is not None:
|
|
160
165
|
raise primary
|
|
166
|
+
self.prepared.set()
|
|
161
167
|
return False
|
|
162
168
|
|
|
163
169
|
|
|
@@ -13,13 +13,16 @@ from fastapi.routing import APIRoute
|
|
|
13
13
|
from starlette.concurrency import run_in_threadpool
|
|
14
14
|
from starlette.requests import Request
|
|
15
15
|
from starlette.responses import Response
|
|
16
|
+
from starlette.types import Receive, Scope, Send
|
|
16
17
|
|
|
17
18
|
from ..application_services.dispatcher import _ApplicationServiceDispatcher
|
|
18
19
|
from ..authorization.contracts import _AuthorizationRequirement
|
|
19
20
|
from ..unit_of_work.options import UnitOfWorkDeclaration
|
|
21
|
+
from .access import _http_access
|
|
20
22
|
from .action import _HttpActionOwner, _request_parameter
|
|
21
23
|
from .filters import ServiceFilter, _ordered_filters
|
|
22
24
|
from .parameters import _HttpParameterValidator
|
|
25
|
+
from .streaming import _PreparedStream, _stream_endpoint
|
|
23
26
|
from .transfer import _manage_stream
|
|
24
27
|
from .uploads import _prepare_upload
|
|
25
28
|
|
|
@@ -40,6 +43,20 @@ class _ActionRoute(DishkaRoute):
|
|
|
40
43
|
super().__init__(path, endpoint, **kwargs)
|
|
41
44
|
setattr(self.endpoint, _ACTION, self._definition)
|
|
42
45
|
|
|
46
|
+
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
47
|
+
# 原生 SSE task group 会把准备/依赖退出错误包成单成员 group。
|
|
48
|
+
# 只去掉单成员包装,复合失败仍整体传播;外层原生 handler 保留发送前状态映射。
|
|
49
|
+
try:
|
|
50
|
+
await super().handle(scope, receive, send)
|
|
51
|
+
except BaseExceptionGroup as group:
|
|
52
|
+
error: BaseException = group
|
|
53
|
+
while isinstance(error, BaseExceptionGroup) and len(error.exceptions) == 1:
|
|
54
|
+
error = error.exceptions[0]
|
|
55
|
+
if error is group:
|
|
56
|
+
raise
|
|
57
|
+
# 不把 group 设为其自身成员的 cause;尤其保留清理失败原有的取消 cause。
|
|
58
|
+
raise error from error.__cause__
|
|
59
|
+
|
|
43
60
|
def get_route_handler(self) -> Callable[[Request], Any]:
|
|
44
61
|
native = super().get_route_handler()
|
|
45
62
|
definition = self._definition
|
|
@@ -49,8 +66,14 @@ class _ActionRoute(DishkaRoute):
|
|
|
49
66
|
try:
|
|
50
67
|
dispatcher = await owner.container.get(_ApplicationServiceDispatcher)
|
|
51
68
|
await dispatcher._authorize(definition.authorization)
|
|
69
|
+
request.state.python_ddd_framework_access = await _http_access(
|
|
70
|
+
request, definition.authorization
|
|
71
|
+
)
|
|
52
72
|
await _prepare_upload(request, definition.endpoint, self)
|
|
53
73
|
result = await native(request)
|
|
74
|
+
if isinstance(owner.stream_result, Response):
|
|
75
|
+
# Filter 短路返回的 Response 优先于生成器路由构造的原生响应。
|
|
76
|
+
result = owner.stream_result
|
|
54
77
|
_manage_stream(request, result)
|
|
55
78
|
owner.response_ready = True
|
|
56
79
|
return result
|
|
@@ -69,6 +92,9 @@ def _wrap_action(endpoint: Callable[..., Any], definition: _ActionDefinition) ->
|
|
|
69
92
|
request_name = request_parameter.name
|
|
70
93
|
if injected_request:
|
|
71
94
|
parameters += (request_parameter,)
|
|
95
|
+
streaming = inspect.isgeneratorfunction(definition.endpoint) or inspect.isasyncgenfunction(
|
|
96
|
+
definition.endpoint
|
|
97
|
+
)
|
|
72
98
|
|
|
73
99
|
@wraps(endpoint)
|
|
74
100
|
async def target(**values: Any) -> Any:
|
|
@@ -89,13 +115,18 @@ def _wrap_action(endpoint: Callable[..., Any], definition: _ActionDefinition) ->
|
|
|
89
115
|
filters = _ordered_filters(
|
|
90
116
|
getattr(request.app.state, "python_ddd_framework_filters", ()), definition.filters
|
|
91
117
|
)
|
|
92
|
-
|
|
118
|
+
result = await owner.invoke(definition.endpoint, values, filters, invoke)
|
|
119
|
+
if streaming:
|
|
120
|
+
# 准备阶段只创建源;function 依赖与 Action/UoW 退出后才能开始迭代。
|
|
121
|
+
owner.stream_result = result
|
|
122
|
+
return _PreparedStream(owner, result)
|
|
123
|
+
return result
|
|
93
124
|
|
|
94
125
|
target.__signature__ = signature.replace(parameters=parameters) # type: ignore[attr-defined]
|
|
95
126
|
target.__annotations__ = {p.name: p.annotation for p in parameters}
|
|
96
127
|
target.__annotations__["return"] = signature.return_annotation
|
|
97
128
|
setattr(target, _ACTION, definition)
|
|
98
|
-
return target
|
|
129
|
+
return _stream_endpoint(target, signature) if streaming else target
|
|
99
130
|
|
|
100
131
|
|
|
101
132
|
def _action_route_type(native: type[APIRoute]) -> type[APIRoute]:
|
|
@@ -5,10 +5,12 @@ from dishka.integrations.fastapi import FastapiProvider
|
|
|
5
5
|
from ..application_services.module import ApplicationServicesModule
|
|
6
6
|
from ..lifecycle import ConfigureContext
|
|
7
7
|
from ..modularity import AppModule
|
|
8
|
+
from .access_options import FastApiAccessOptions
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
class FastApiModule(AppModule):
|
|
11
12
|
dependencies = (ApplicationServicesModule,)
|
|
12
13
|
|
|
13
14
|
def configure(self, context: ConfigureContext) -> None:
|
|
15
|
+
context.configure(FastApiAccessOptions, section="fastapi_access")
|
|
14
16
|
context.services.contribute(FastapiProvider(), reason="Native FastAPI request context")
|
|
@@ -15,8 +15,13 @@ from dishka.exceptions import NoFactoryError
|
|
|
15
15
|
from fastapi import WebSocket, WebSocketException
|
|
16
16
|
from starlette.websockets import WebSocketDisconnect
|
|
17
17
|
|
|
18
|
-
from ...authorization import
|
|
18
|
+
from ...authorization import CurrentUser
|
|
19
|
+
from ...authorization.contracts import _authorization_requirement
|
|
19
20
|
from ...invocation.contracts import _is_safe_correlation_id
|
|
21
|
+
from ...invocation.runtime import _InvocationRuntime
|
|
22
|
+
from ...options import Options
|
|
23
|
+
from ..access import _AccessRefresh
|
|
24
|
+
from ..access_options import FastApiAccessOptions
|
|
20
25
|
from .connection import WebSocketConnection, _WebSocketCloseCode
|
|
21
26
|
from .options import FastApiRealtimeOptions
|
|
22
27
|
from .runtime import _FastApiRealtimeRuntime, _RealtimeAdmissionError
|
|
@@ -34,16 +39,24 @@ async def websocket_connection(websocket: WebSocket) -> AsyncIterator[WebSocketC
|
|
|
34
39
|
try:
|
|
35
40
|
session = cast(AsyncContainer, websocket.state.dishka_container)
|
|
36
41
|
runtime = await session.get(_FastApiRealtimeRuntime)
|
|
37
|
-
authenticator = cast(
|
|
38
|
-
AccessTokenAuthenticator,
|
|
39
|
-
await session.get(AccessTokenAuthenticator),
|
|
40
|
-
)
|
|
41
42
|
except (AttributeError, NoFactoryError):
|
|
42
43
|
raise WebSocketException(code=_WebSocketCloseCode.POLICY_VIOLATION) from None
|
|
43
44
|
|
|
44
45
|
token = _credential(websocket, runtime.options)
|
|
46
|
+
correlation = _correlation_id(websocket)
|
|
47
|
+
options = await session.get(Options[FastApiAccessOptions])
|
|
48
|
+
# 握手与持续复验使用同一 endpoint 要求,不把调用过的方法权限累积为连接权限。
|
|
49
|
+
access = _AccessRefresh(
|
|
50
|
+
session,
|
|
51
|
+
await session.get(_InvocationRuntime),
|
|
52
|
+
CurrentUser.anonymous(),
|
|
53
|
+
token,
|
|
54
|
+
correlation,
|
|
55
|
+
_authorization_requirement(object, websocket.scope["endpoint"]),
|
|
56
|
+
options.value.authentication_refresh_seconds,
|
|
57
|
+
)
|
|
45
58
|
try:
|
|
46
|
-
current_user = await
|
|
59
|
+
current_user = await access.check()
|
|
47
60
|
except Exception as error:
|
|
48
61
|
_log_handshake_failure("authentication", error)
|
|
49
62
|
raise WebSocketException(code=_WebSocketCloseCode.POLICY_VIOLATION) from None
|
|
@@ -54,10 +67,8 @@ async def websocket_connection(websocket: WebSocket) -> AsyncIterator[WebSocketC
|
|
|
54
67
|
connection = await runtime.connect(
|
|
55
68
|
websocket,
|
|
56
69
|
session,
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
current_user,
|
|
60
|
-
_correlation_id(websocket),
|
|
70
|
+
access,
|
|
71
|
+
correlation,
|
|
61
72
|
)
|
|
62
73
|
except _RealtimeAdmissionError as error:
|
|
63
74
|
raise WebSocketException(code=error.code) from None
|
|
@@ -15,7 +15,6 @@ class FastApiRealtimeOptions(BaseOptions):
|
|
|
15
15
|
model_config = ConfigDict(extra="forbid")
|
|
16
16
|
|
|
17
17
|
allowed_origins: tuple[str, ...] = ()
|
|
18
|
-
authentication_refresh_seconds: int = Field(default=60, ge=1)
|
|
19
18
|
outbound_queue_capacity: int = Field(default=64, ge=1)
|
|
20
19
|
max_connections: int = Field(default=128, ge=1)
|
|
21
20
|
max_connections_per_user: int = Field(default=8, ge=1)
|
|
@@ -8,10 +8,11 @@ from dataclasses import dataclass
|
|
|
8
8
|
from typing import cast
|
|
9
9
|
from uuid import UUID, uuid4
|
|
10
10
|
|
|
11
|
+
from anyio import CancelScope
|
|
11
12
|
from dishka import AsyncContainer
|
|
12
13
|
from fastapi import WebSocket
|
|
13
14
|
|
|
14
|
-
from ...authorization import
|
|
15
|
+
from ...authorization import CurrentUser
|
|
15
16
|
from ...invocation.runtime import _InvocationRuntime
|
|
16
17
|
from ...options import Options
|
|
17
18
|
from ...realtime import (
|
|
@@ -21,6 +22,7 @@ from ...realtime import (
|
|
|
21
22
|
RealtimeSnapshot,
|
|
22
23
|
)
|
|
23
24
|
from ...realtime.messages import _encode_envelope, _message_envelope
|
|
25
|
+
from ..access import _AccessRefresh
|
|
24
26
|
from .connection import (
|
|
25
27
|
WebSocketConnection,
|
|
26
28
|
_FrameworkProblemMessage,
|
|
@@ -45,8 +47,7 @@ class _CloseFrame:
|
|
|
45
47
|
class _ConnectionState:
|
|
46
48
|
connection_id: UUID
|
|
47
49
|
websocket: WebSocket
|
|
48
|
-
|
|
49
|
-
access_token: str
|
|
50
|
+
access: _AccessRefresh
|
|
50
51
|
current_user: CurrentUser
|
|
51
52
|
correlation_id: str
|
|
52
53
|
outbound: asyncio.Queue[_TextFrame | _CloseFrame]
|
|
@@ -58,6 +59,7 @@ class _ConnectionState:
|
|
|
58
59
|
closing: bool = False
|
|
59
60
|
peer_disconnected: bool = False
|
|
60
61
|
close_code: int = int(_WebSocketCloseCode.NORMAL)
|
|
62
|
+
send_cancel_scope: CancelScope | None = None
|
|
61
63
|
|
|
62
64
|
|
|
63
65
|
class _RealtimeAdmissionError(Exception):
|
|
@@ -106,11 +108,10 @@ class _FastApiRealtimeRuntime:
|
|
|
106
108
|
self,
|
|
107
109
|
websocket: WebSocket,
|
|
108
110
|
session: AsyncContainer,
|
|
109
|
-
|
|
110
|
-
access_token: str,
|
|
111
|
-
current_user: CurrentUser,
|
|
111
|
+
access: _AccessRefresh,
|
|
112
112
|
correlation_id: str,
|
|
113
113
|
) -> WebSocketConnection:
|
|
114
|
+
current_user = await access.check()
|
|
114
115
|
if not current_user.is_authenticated or current_user.user_id is None:
|
|
115
116
|
raise _RealtimeAdmissionError(_WebSocketCloseCode.POLICY_VIOLATION)
|
|
116
117
|
endpoint_task = asyncio.current_task()
|
|
@@ -119,8 +120,7 @@ class _FastApiRealtimeRuntime:
|
|
|
119
120
|
state = _ConnectionState(
|
|
120
121
|
connection_id=uuid4(),
|
|
121
122
|
websocket=websocket,
|
|
122
|
-
|
|
123
|
-
access_token=access_token,
|
|
123
|
+
access=access,
|
|
124
124
|
current_user=current_user,
|
|
125
125
|
correlation_id=correlation_id,
|
|
126
126
|
# 多出的一个槽只保留给 close frame;业务容量仍严格等于 Options。
|
|
@@ -179,7 +179,6 @@ class _FastApiRealtimeRuntime:
|
|
|
179
179
|
task.cancel()
|
|
180
180
|
if tasks:
|
|
181
181
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
182
|
-
state.access_token = ""
|
|
183
182
|
state.closed.set()
|
|
184
183
|
|
|
185
184
|
async def finish(
|
|
@@ -324,12 +323,16 @@ class _FastApiRealtimeRuntime:
|
|
|
324
323
|
*,
|
|
325
324
|
discard_pending: bool = False,
|
|
326
325
|
) -> None:
|
|
327
|
-
if state.closing:
|
|
326
|
+
if state.closing and not discard_pending:
|
|
328
327
|
return
|
|
328
|
+
# 在任何 await 前关闭入队准入;授权失效仍可覆盖正在排队的正常关闭。
|
|
329
329
|
state.closing = True
|
|
330
330
|
state.close_code = int(code)
|
|
331
331
|
evicted = 0
|
|
332
332
|
if discard_pending:
|
|
333
|
+
# 撤权同时取消在途慢发送并丢弃待发帧,writer 继续负责协议关闭。
|
|
334
|
+
if state.send_cancel_scope is not None:
|
|
335
|
+
state.send_cancel_scope.cancel()
|
|
333
336
|
while True:
|
|
334
337
|
try:
|
|
335
338
|
frame = state.outbound.get_nowait()
|
|
@@ -344,18 +347,13 @@ class _FastApiRealtimeRuntime:
|
|
|
344
347
|
|
|
345
348
|
async def _refresh_identity(self, state: _ConnectionState) -> CurrentUser:
|
|
346
349
|
try:
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
await self._request_close(
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
or refreshed.user_id != state.current_user.user_id
|
|
355
|
-
or refreshed.session_id != state.current_user.session_id
|
|
356
|
-
):
|
|
357
|
-
await self._request_close(state, _WebSocketCloseCode.POLICY_VIOLATION)
|
|
358
|
-
raise UnauthenticatedError
|
|
350
|
+
# 每次业务调用都强制复验连接要求;方法自身权限仍归受控调用链判断。
|
|
351
|
+
refreshed = await state.access.check(force=True)
|
|
352
|
+
except Exception:
|
|
353
|
+
await self._request_close(
|
|
354
|
+
state, _WebSocketCloseCode.POLICY_VIOLATION, discard_pending=True
|
|
355
|
+
)
|
|
356
|
+
raise
|
|
359
357
|
state.current_user = refreshed
|
|
360
358
|
return refreshed
|
|
361
359
|
|
|
@@ -364,7 +362,20 @@ class _FastApiRealtimeRuntime:
|
|
|
364
362
|
while True:
|
|
365
363
|
frame = await state.outbound.get()
|
|
366
364
|
if isinstance(frame, _TextFrame):
|
|
367
|
-
|
|
365
|
+
try:
|
|
366
|
+
# 到期复验先于发送;独立 watchdog 可取消被网络背压阻塞的这一帧。
|
|
367
|
+
with CancelScope() as scope:
|
|
368
|
+
state.send_cancel_scope = scope
|
|
369
|
+
state.current_user = await state.access.check()
|
|
370
|
+
await state.websocket.send_text(frame.text)
|
|
371
|
+
except Exception:
|
|
372
|
+
if state.access.failure is None:
|
|
373
|
+
raise
|
|
374
|
+
await self._request_close(
|
|
375
|
+
state, _WebSocketCloseCode.POLICY_VIOLATION, discard_pending=True
|
|
376
|
+
)
|
|
377
|
+
finally:
|
|
378
|
+
state.send_cancel_scope = None
|
|
368
379
|
continue
|
|
369
380
|
await state.websocket.close(code=int(frame.code))
|
|
370
381
|
return
|
|
@@ -378,18 +389,22 @@ class _FastApiRealtimeRuntime:
|
|
|
378
389
|
|
|
379
390
|
async def _watchdog(self, state: _ConnectionState) -> None:
|
|
380
391
|
try:
|
|
392
|
+
# 不依赖接收或发送进度;所有入口共享同一个复验锁、期限和失败状态。
|
|
381
393
|
while not state.closing:
|
|
382
394
|
try:
|
|
383
395
|
await asyncio.wait_for(
|
|
384
396
|
state.closed.wait(),
|
|
385
|
-
timeout=
|
|
397
|
+
timeout=state.access.delay,
|
|
386
398
|
)
|
|
387
399
|
return
|
|
388
400
|
except TimeoutError:
|
|
389
401
|
pass
|
|
390
402
|
try:
|
|
391
|
-
await
|
|
392
|
-
except
|
|
403
|
+
state.current_user = await state.access.check()
|
|
404
|
+
except Exception:
|
|
405
|
+
await self._request_close(
|
|
406
|
+
state, _WebSocketCloseCode.POLICY_VIOLATION, discard_pending=True
|
|
407
|
+
)
|
|
393
408
|
return
|
|
394
409
|
except asyncio.CancelledError:
|
|
395
410
|
raise
|
|
@@ -103,10 +103,12 @@ class _RequestContextMiddleware:
|
|
|
103
103
|
current_user = CurrentUser.anonymous()
|
|
104
104
|
status_code = 500
|
|
105
105
|
failure: BaseException | None = None
|
|
106
|
+
response_started = False
|
|
106
107
|
|
|
107
108
|
async def send_with_context(message: Message) -> None:
|
|
108
|
-
nonlocal status_code
|
|
109
|
+
nonlocal status_code, response_started
|
|
109
110
|
if message["type"] == "http.response.start":
|
|
111
|
+
response_started = True
|
|
110
112
|
status_code = cast(int, message["status"])
|
|
111
113
|
headers = list(message.get("headers", ()))
|
|
112
114
|
if not any(name.lower() == _CORRELATION_HEADER for name, _ in headers):
|
|
@@ -132,6 +134,9 @@ class _RequestContextMiddleware:
|
|
|
132
134
|
await self._app(scope, receive, send_with_context)
|
|
133
135
|
except UnauthenticatedError as original_error:
|
|
134
136
|
failure = original_error
|
|
137
|
+
if response_started:
|
|
138
|
+
# 响应头已发出时不能再构造第二个 401;传输 owner 负责终止流与清理。
|
|
139
|
+
raise
|
|
135
140
|
status_code = 401
|
|
136
141
|
response = JSONResponse(
|
|
137
142
|
status_code=401,
|
|
@@ -171,7 +176,10 @@ class _RequestContextMiddleware:
|
|
|
171
176
|
except NoFactoryError:
|
|
172
177
|
# Host 收到认证凭据却没有验证 authority 时必须 fail closed。
|
|
173
178
|
raise UnauthenticatedError from None
|
|
174
|
-
|
|
179
|
+
user = await authenticator.authenticate_access_token(token)
|
|
180
|
+
# 保留原凭据供长连接重新认证;握手身份快照不能代替后续有效性检查。
|
|
181
|
+
Request(scope).state.python_ddd_framework_access_token = token
|
|
182
|
+
return user
|
|
175
183
|
|
|
176
184
|
async def _write_audit(
|
|
177
185
|
self,
|
|
@@ -14,6 +14,7 @@ from fastapi.routing import APIRoute, APIWebSocketRoute, RouteContext, iter_rout
|
|
|
14
14
|
from starlette.routing import BaseRoute, WebSocketRoute
|
|
15
15
|
|
|
16
16
|
from ..application import Application
|
|
17
|
+
from ..authorization.contracts import _authorization_requirement
|
|
17
18
|
from ..authorization.module import AuthorizationModule
|
|
18
19
|
from ..diagnostics import SourceLocation
|
|
19
20
|
from ..diagnostics.source import _source_file_line
|
|
@@ -106,16 +107,22 @@ def _build_route_plan(
|
|
|
106
107
|
route.operation_id = cast(_OpenApiRouteView, context).unique_id
|
|
107
108
|
for context in iter_route_contexts(router.routes):
|
|
108
109
|
route = context.original_route
|
|
109
|
-
|
|
110
|
+
requirement = (
|
|
111
|
+
route._definition.authorization
|
|
112
|
+
if isinstance(route, _ActionRoute)
|
|
113
|
+
else _authorization_requirement(object, route.endpoint)
|
|
114
|
+
if isinstance(route, APIWebSocketRoute)
|
|
115
|
+
else None
|
|
116
|
+
)
|
|
117
|
+
if requirement is not None:
|
|
118
|
+
# 声明错误在路由安装时失败,不推迟到首个 HTTP 请求或 WebSocket 握手。
|
|
110
119
|
unknown = tuple(
|
|
111
120
|
permission
|
|
112
|
-
for permission in
|
|
121
|
+
for permission in requirement.permissions
|
|
113
122
|
if permission not in application.modules.instance(AuthorizationModule).catalog
|
|
114
123
|
)
|
|
115
124
|
if unknown:
|
|
116
|
-
raise FastApiAdapterError(
|
|
117
|
-
f"HTTP action references unknown permissions: {unknown}"
|
|
118
|
-
)
|
|
125
|
+
raise FastApiAdapterError(f"endpoint references unknown permissions: {unknown}")
|
|
119
126
|
routers.append((router, prefix))
|
|
120
127
|
route_start = len(preview.routes)
|
|
121
128
|
preview.include_router(router, prefix=prefix)
|
|
@@ -153,11 +153,18 @@ def run_host(
|
|
|
153
153
|
reload: bool = False,
|
|
154
154
|
project: Path | None = None,
|
|
155
155
|
environment: str | None = None,
|
|
156
|
+
timeout_graceful_shutdown: int = 10,
|
|
156
157
|
) -> None:
|
|
157
158
|
"""通过原生 Uvicorn 运行 Host factory,并守卫单实例、reload 与实际退出。"""
|
|
158
159
|
# 保留原生 Config/Server/ChangeReload,平台仅补进程输出边界,不接管 ASGI 生命周期。
|
|
159
160
|
# reload 的父进程持锁直到所有子进程 join;子进程不重复占用主 Host 位置。
|
|
160
161
|
# 此边界早于 factory 加载和运行期初始化,DB CLI/TestApplication 不经过这里。
|
|
162
|
+
if (
|
|
163
|
+
not isinstance(timeout_graceful_shutdown, int)
|
|
164
|
+
or isinstance(timeout_graceful_shutdown, bool)
|
|
165
|
+
or timeout_graceful_shutdown < 0
|
|
166
|
+
):
|
|
167
|
+
raise ValueError("timeout_graceful_shutdown must be a non-negative integer")
|
|
161
168
|
project = Path.cwd() if project is None else project
|
|
162
169
|
environment = (
|
|
163
170
|
environment if environment is not None else os.getenv(ENVIRONMENT_VARIABLE, "development")
|
|
@@ -172,6 +179,8 @@ def run_host(
|
|
|
172
179
|
reload=reload,
|
|
173
180
|
log_config=None,
|
|
174
181
|
access_log=False,
|
|
182
|
+
# 超时交给 Uvicorn 请求取消;平台仍等待实际清理与生命周期排空完成。
|
|
183
|
+
timeout_graceful_shutdown=timeout_graceful_shutdown,
|
|
175
184
|
)
|
|
176
185
|
shutdown_requested = (
|
|
177
186
|
multiprocessing.get_context("spawn").Event() if config.should_reload else None
|
|
@@ -16,6 +16,7 @@ from ..application_services.catalog import _CompiledApplicationService
|
|
|
16
16
|
from ..application_services.dispatcher import _ApplicationServiceDispatcher
|
|
17
17
|
from ..diagnostics import SourceLocation
|
|
18
18
|
from ..invocation.methods import _CompiledMethod
|
|
19
|
+
from .access import _http_access
|
|
19
20
|
from .action import _HttpActionOwner, _request_parameter
|
|
20
21
|
from .errors import FastApiAdapterError
|
|
21
22
|
from .filters import _filters_for, _ordered_filters
|
|
@@ -69,6 +70,10 @@ class _ApplicationServiceRoute(DishkaRoute):
|
|
|
69
70
|
dispatcher = await container.get(_ApplicationServiceDispatcher)
|
|
70
71
|
|
|
71
72
|
async def bind() -> Response:
|
|
73
|
+
# 约定式服务返回原生流 Response 时,也沿用同一持续授权与传输 owner。
|
|
74
|
+
request.state.python_ddd_framework_access = await _http_access(
|
|
75
|
+
request, method.authorization
|
|
76
|
+
)
|
|
72
77
|
await _prepare_upload(request, method.implementation_method, self)
|
|
73
78
|
return await native_handler(request)
|
|
74
79
|
|