python-ddd-framework 0.6.0__py3-none-any.whl → 0.7.1__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/adapter.py +25 -10
- python_ddd_framework/fastapi/health.py +20 -11
- 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 +13 -6
- 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/sqlalchemy/migration.py +17 -1
- python_ddd_framework/unit_of_work/manager.py +11 -2
- {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.1.dist-info}/METADATA +38 -9
- {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.1.dist-info}/RECORD +42 -89
- {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.1.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.1.dist-info}/entry_points.txt +0 -0
- {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.1.dist-info}/licenses/LICENSE +0 -0
- {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.1.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
|
|
|
@@ -15,7 +15,13 @@ from starlette.middleware import Middleware
|
|
|
15
15
|
from ..application import Application
|
|
16
16
|
from ..application_services import ApplicationServiceValidationError
|
|
17
17
|
from ..authorization import PermissionDeniedError, UnauthenticatedError
|
|
18
|
-
from ..errors import
|
|
18
|
+
from ..errors import (
|
|
19
|
+
BusinessError,
|
|
20
|
+
FrameworkError,
|
|
21
|
+
InvalidApplicationStateError,
|
|
22
|
+
ResourceConflictError,
|
|
23
|
+
ResourceNotFoundError,
|
|
24
|
+
)
|
|
19
25
|
from ..lifecycle import ApplicationState
|
|
20
26
|
from .contracts import RouterCatalog
|
|
21
27
|
from .error_handlers import (
|
|
@@ -30,6 +36,7 @@ from .error_handlers import (
|
|
|
30
36
|
)
|
|
31
37
|
from .errors import FastApiAdapterError
|
|
32
38
|
from .filters import ServiceFilter, _validate_filters
|
|
39
|
+
from .health import _APPLICATION_STATE_ATTRIBUTE
|
|
33
40
|
from .middleware import DiMiddleware, _compile_middlewares
|
|
34
41
|
from .module import FastApiModule
|
|
35
42
|
from .realtime.runtime import _FastApiRealtimeRuntime
|
|
@@ -174,6 +181,7 @@ class FastApiAdapter:
|
|
|
174
181
|
app.state.python_ddd_framework_filters = self._filters
|
|
175
182
|
app.state.python_ddd_framework_upload_limits = self._upload_limits
|
|
176
183
|
app.state.dishka_container = self._application._container_for_integration()
|
|
184
|
+
setattr(app.state, _APPLICATION_STATE_ATTRIBUTE, self._application)
|
|
177
185
|
app.user_middleware = [*middlewares, Middleware(_RequestTaskMiddleware)]
|
|
178
186
|
app.add_middleware(_RequestContextMiddleware, application=self._application)
|
|
179
187
|
_install_tracing(self._application, app)
|
|
@@ -191,17 +199,19 @@ class FastApiAdapter:
|
|
|
191
199
|
try:
|
|
192
200
|
self._validate_start_target(app)
|
|
193
201
|
except BaseException:
|
|
194
|
-
if self._installed_app is app:
|
|
202
|
+
if self._installed_app is app and self._application.state is ApplicationState.COMPOSED:
|
|
195
203
|
try:
|
|
196
|
-
|
|
197
|
-
await self._application.stop()
|
|
204
|
+
await self._application.stop()
|
|
198
205
|
finally:
|
|
199
|
-
self.
|
|
206
|
+
self._clear_application_bridges(app)
|
|
200
207
|
raise
|
|
201
208
|
try:
|
|
202
209
|
await self._application.start()
|
|
210
|
+
except InvalidApplicationStateError:
|
|
211
|
+
# Core 在取得转换 ownership 前拒绝重复/并发进入;不能撤销有效 lifespan 的桥。
|
|
212
|
+
raise
|
|
203
213
|
except BaseException:
|
|
204
|
-
self.
|
|
214
|
+
self._clear_application_bridges(app)
|
|
205
215
|
raise
|
|
206
216
|
|
|
207
217
|
try:
|
|
@@ -214,7 +224,7 @@ class FastApiAdapter:
|
|
|
214
224
|
await self._application.stop()
|
|
215
225
|
finally:
|
|
216
226
|
# Container 的 close 始终属于 Application;这里只撤销 Web 引用。
|
|
217
|
-
self.
|
|
227
|
+
self._clear_application_bridges(app)
|
|
218
228
|
|
|
219
229
|
def _validate_install_target(self, app: FastAPI) -> None:
|
|
220
230
|
if not isinstance(app, FastAPI):
|
|
@@ -225,6 +235,8 @@ class FastApiAdapter:
|
|
|
225
235
|
raise FastApiAdapterError("FastAPI instance already has a Python DDD Framework adapter")
|
|
226
236
|
if hasattr(app.state, _DISHKA_CONTAINER_BRIDGE):
|
|
227
237
|
raise FastApiAdapterError("FastAPI instance already has a Dishka container bridge")
|
|
238
|
+
if hasattr(app.state, _APPLICATION_STATE_ATTRIBUTE):
|
|
239
|
+
raise FastApiAdapterError("FastAPI instance already has an Application binding")
|
|
228
240
|
if app.middleware_stack is not None:
|
|
229
241
|
raise FastApiAdapterError("FastAPI middleware stack was already built before install")
|
|
230
242
|
if self._application.state is not ApplicationState.COMPOSED:
|
|
@@ -239,6 +251,8 @@ class FastApiAdapter:
|
|
|
239
251
|
)
|
|
240
252
|
if getattr(app.state, _INSTALLATION_MARKER, None) != str(self._application.id):
|
|
241
253
|
raise FastApiAdapterError("FastAPI installation marker changed before startup")
|
|
254
|
+
if getattr(app.state, _APPLICATION_STATE_ATTRIBUTE, None) is not self._application:
|
|
255
|
+
raise FastApiAdapterError("FastAPI Application binding changed before startup")
|
|
242
256
|
if app.state.python_ddd_framework_filters != self._filters:
|
|
243
257
|
raise FastApiAdapterError("HTTP filters changed after adapter installation")
|
|
244
258
|
if tuple(app.user_middleware) != self._installed_middleware:
|
|
@@ -258,9 +272,10 @@ class FastApiAdapter:
|
|
|
258
272
|
raise FastApiAdapterError("Dishka container bridge changed before startup")
|
|
259
273
|
|
|
260
274
|
@staticmethod
|
|
261
|
-
def
|
|
262
|
-
|
|
263
|
-
|
|
275
|
+
def _clear_application_bridges(app: FastAPI) -> None:
|
|
276
|
+
for attribute in (_DISHKA_CONTAINER_BRIDGE, _APPLICATION_STATE_ATTRIBUTE):
|
|
277
|
+
if hasattr(app.state, attribute):
|
|
278
|
+
delattr(app.state, attribute)
|
|
264
279
|
|
|
265
280
|
async def _shutdown_realtime(self) -> None:
|
|
266
281
|
try:
|
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from fastapi import APIRouter
|
|
5
|
+
from fastapi import APIRouter, Request
|
|
6
6
|
from starlette.responses import JSONResponse
|
|
7
7
|
|
|
8
8
|
from ..application import Application
|
|
9
|
+
from .errors import FastApiAdapterError
|
|
10
|
+
|
|
11
|
+
_APPLICATION_STATE_ATTRIBUTE = "_python_ddd_framework_application"
|
|
9
12
|
|
|
10
13
|
HEALTH_LIVE_PATH = "/health/live"
|
|
11
14
|
HEALTH_READY_PATH = "/health/ready"
|
|
@@ -13,18 +16,24 @@ HEALTH_LIVE_OPERATION_ID = "python_ddd_framework_health_live"
|
|
|
13
16
|
HEALTH_READY_OPERATION_ID = "python_ddd_framework_health_ready"
|
|
14
17
|
|
|
15
18
|
|
|
16
|
-
def
|
|
17
|
-
|
|
19
|
+
async def live() -> JSONResponse:
|
|
20
|
+
return JSONResponse(status_code=200, content={"status": "live"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def ready(request: Request) -> JSONResponse:
|
|
24
|
+
# FastAPI 可长期缓存 callable;应用引用只在当前请求期间从实例状态取得。
|
|
25
|
+
application = getattr(request.app.state, _APPLICATION_STATE_ATTRIBUTE, None)
|
|
26
|
+
if not isinstance(application, Application):
|
|
27
|
+
raise FastApiAdapterError("FastAPI Application binding is unavailable")
|
|
28
|
+
is_ready = application.is_ready
|
|
29
|
+
return JSONResponse(
|
|
30
|
+
status_code=200 if is_ready else 503,
|
|
31
|
+
content={"status": "ready" if is_ready else "not_ready"},
|
|
32
|
+
)
|
|
18
33
|
|
|
19
|
-
async def live() -> JSONResponse:
|
|
20
|
-
return JSONResponse(status_code=200, content={"status": "live"})
|
|
21
34
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return JSONResponse(
|
|
25
|
-
status_code=200 if is_ready else 503,
|
|
26
|
-
content={"status": "ready" if is_ready else "not_ready"},
|
|
27
|
-
)
|
|
35
|
+
def _create_health_router() -> APIRouter:
|
|
36
|
+
router = APIRouter(tags=["health"])
|
|
28
37
|
|
|
29
38
|
router.add_api_route(
|
|
30
39
|
HEALTH_LIVE_PATH,
|
|
@@ -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
|