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.
Files changed (93) hide show
  1. python_ddd_framework/authorization/checking.py +35 -0
  2. python_ddd_framework/developer_kit/generation.py +0 -9
  3. python_ddd_framework/developer_kit/templates/module/cookiecutter.json +1 -1
  4. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/README.md +29 -77
  5. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/module.py.jinja +5 -72
  6. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/module.py.jinja +1 -3
  7. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/module.py.jinja +2 -3
  8. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/module.py.jinja +8 -62
  9. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/module.py.jinja +1 -2
  10. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md +12 -8
  11. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/README.md +22 -10
  12. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/backend/src/host/main.py.jinja +4 -1
  13. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +40 -6
  14. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +177 -30
  15. python_ddd_framework/fastapi/__init__.py +2 -0
  16. python_ddd_framework/fastapi/access.py +127 -0
  17. python_ddd_framework/fastapi/access_options.py +13 -0
  18. python_ddd_framework/fastapi/action.py +6 -0
  19. python_ddd_framework/fastapi/adapter.py +25 -10
  20. python_ddd_framework/fastapi/health.py +20 -11
  21. python_ddd_framework/fastapi/manual_action.py +33 -2
  22. python_ddd_framework/fastapi/module.py +2 -0
  23. python_ddd_framework/fastapi/realtime/authentication.py +21 -10
  24. python_ddd_framework/fastapi/realtime/options.py +0 -1
  25. python_ddd_framework/fastapi/realtime/runtime.py +41 -26
  26. python_ddd_framework/fastapi/request_context.py +10 -2
  27. python_ddd_framework/fastapi/routing.py +13 -6
  28. python_ddd_framework/fastapi/server.py +9 -0
  29. python_ddd_framework/fastapi/service_endpoints.py +5 -0
  30. python_ddd_framework/fastapi/streaming.py +95 -0
  31. python_ddd_framework/fastapi/transfer.py +138 -3
  32. python_ddd_framework/invocation/dispatcher.py +2 -24
  33. python_ddd_framework/invocation/scopes.py +5 -3
  34. python_ddd_framework/observability/formatting.py +1 -0
  35. python_ddd_framework/sqlalchemy/migration.py +17 -1
  36. python_ddd_framework/unit_of_work/manager.py +11 -2
  37. {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.1.dist-info}/METADATA +38 -9
  38. {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.1.dist-info}/RECORD +42 -89
  39. {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.1.dist-info}/WHEEL +1 -1
  40. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/__init__.py.jinja +0 -1
  41. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/handler.py.jinja +0 -45
  42. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/payload.py.jinja +0 -8
  43. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/__init__.py.jinja +0 -1
  44. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/handler.py.jinja +0 -35
  45. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/payload.py.jinja +0 -5
  46. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/schedule.py.jinja +0 -15
  47. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_workers/order_maintenance_worker.py.jinja +0 -28
  48. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_workers/order_statistics_worker.py.jinja +0 -26
  49. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/caching/order_cache.py.jinja +0 -9
  50. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/caching/statistics_cache.py.jinja +0 -9
  51. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/event_handlers/order_changed_handler.py.jinja +0 -23
  52. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_integration_service.py.jinja +0 -71
  53. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_observation_handler.py.jinja +0 -16
  54. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/integration_services/order_reporting_service.py.jinja +0 -16
  55. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/interceptors/order_timing_interceptor.py.jinja +0 -17
  56. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/options/order_options.py.jinja +0 -7
  57. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_approval_service.py.jinja +0 -65
  58. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_management_service.py.jinja +0 -35
  59. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_query_service.py.jinja +0 -41
  60. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/setting_handlers/approval_setting_observer.py.jinja +0 -17
  61. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/inputs/approve_order.py.jinja +0 -6
  62. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/inputs/create_order.py.jinja +0 -7
  63. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/integration_services/order_reporting_service.py.jinja +0 -7
  64. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_approval_service.py.jinja +0 -13
  65. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_management_service.py.jinja +0 -10
  66. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_query_service.py.jinja +0 -12
  67. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/views/order_statistics_snapshot.py.jinja +0 -9
  68. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/views/order_view.py.jinja +0 -12
  69. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/entities/order.py.jinja +0 -48
  70. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/events/order_changed.py.jinja +0 -13
  71. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/repositories/order_repository.py.jinja +0 -17
  72. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/seeding/order_seed_contributor.py.jinja +0 -22
  73. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/services/order_approval_service.py.jinja +0 -23
  74. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/settings/approval_settings.py.jinja +0 -17
  75. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/value_objects/order_title.py.jinja +0 -10
  76. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/constants/order_constants.py.jinja +0 -3
  77. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/enums/order_status.py.jinja +0 -6
  78. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/errors/order_errors.py.jinja +0 -11
  79. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/messages/order_messages.py.jinja +0 -26
  80. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/messages/order_observation.py.jinja +0 -13
  81. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions/order_permission_provider.py.jinja +0 -19
  82. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions/order_permissions.py.jinja +0 -7
  83. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/value_objects/money.py.jinja +0 -18
  84. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/filters/export_filter.py.jinja +0 -15
  85. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/models/refresh_orders.py.jinja +0 -5
  86. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/routers/order_files.py.jinja +0 -76
  87. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/websockets/order_socket.py.jinja +0 -44
  88. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/models/order_model.py.jinja +0 -29
  89. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/order_repository.py.jinja +0 -81
  90. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/tests/test_domain.py.jinja +0 -19
  91. {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.1.dist-info}/entry_points.txt +0 -0
  92. {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.1.dist-info}/licenses/LICENSE +0 -0
  93. {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 BusinessError, FrameworkError, ResourceConflictError, ResourceNotFoundError
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
- if self._application.state is ApplicationState.COMPOSED:
197
- await self._application.stop()
204
+ await self._application.stop()
198
205
  finally:
199
- self._clear_container_bridge(app)
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._clear_container_bridge(app)
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._clear_container_bridge(app)
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 _clear_container_bridge(app: FastAPI) -> None:
262
- if hasattr(app.state, _DISHKA_CONTAINER_BRIDGE):
263
- delattr(app.state, _DISHKA_CONTAINER_BRIDGE)
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 _create_health_router(application: Application) -> APIRouter:
17
- router = APIRouter(tags=["health"])
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
- async def ready() -> JSONResponse:
23
- is_ready = application.is_ready
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
- return await owner.invoke(definition.endpoint, values, filters, invoke)
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 AccessTokenAuthenticator, CurrentUser
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 authenticator.authenticate_access_token(token)
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
- authenticator,
58
- token,
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 AccessTokenAuthenticator, CurrentUser, UnauthenticatedError
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
- authenticator: AccessTokenAuthenticator
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
- authenticator: AccessTokenAuthenticator,
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
- authenticator=authenticator,
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
- refreshed = await state.authenticator.authenticate_access_token(state.access_token)
348
- except Exception as error:
349
- _log_connection_failure("authentication", error)
350
- await self._request_close(state, _WebSocketCloseCode.POLICY_VIOLATION)
351
- raise UnauthenticatedError from None
352
- if (
353
- not refreshed.is_authenticated
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
- await state.websocket.send_text(frame.text)
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=self._options.authentication_refresh_seconds,
397
+ timeout=state.access.delay,
386
398
  )
387
399
  return
388
400
  except TimeoutError:
389
401
  pass
390
402
  try:
391
- await self._refresh_identity(state)
392
- except UnauthenticatedError:
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