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.
Files changed (90) 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/manual_action.py +33 -2
  20. python_ddd_framework/fastapi/module.py +2 -0
  21. python_ddd_framework/fastapi/realtime/authentication.py +21 -10
  22. python_ddd_framework/fastapi/realtime/options.py +0 -1
  23. python_ddd_framework/fastapi/realtime/runtime.py +41 -26
  24. python_ddd_framework/fastapi/request_context.py +10 -2
  25. python_ddd_framework/fastapi/routing.py +12 -5
  26. python_ddd_framework/fastapi/server.py +9 -0
  27. python_ddd_framework/fastapi/service_endpoints.py +5 -0
  28. python_ddd_framework/fastapi/streaming.py +95 -0
  29. python_ddd_framework/fastapi/transfer.py +138 -3
  30. python_ddd_framework/invocation/dispatcher.py +2 -24
  31. python_ddd_framework/invocation/scopes.py +5 -3
  32. python_ddd_framework/observability/formatting.py +1 -0
  33. python_ddd_framework/unit_of_work/manager.py +11 -2
  34. {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/METADATA +29 -8
  35. {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/RECORD +39 -86
  36. {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/WHEEL +1 -1
  37. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/__init__.py.jinja +0 -1
  38. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/handler.py.jinja +0 -45
  39. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_approval/payload.py.jinja +0 -8
  40. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/__init__.py.jinja +0 -1
  41. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/handler.py.jinja +0 -35
  42. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/payload.py.jinja +0 -5
  43. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_jobs/order_statistics/schedule.py.jinja +0 -15
  44. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_workers/order_maintenance_worker.py.jinja +0 -28
  45. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/background_workers/order_statistics_worker.py.jinja +0 -26
  46. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/caching/order_cache.py.jinja +0 -9
  47. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/caching/statistics_cache.py.jinja +0 -9
  48. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/event_handlers/order_changed_handler.py.jinja +0 -23
  49. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_integration_service.py.jinja +0 -71
  50. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_observation_handler.py.jinja +0 -16
  51. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/integration_services/order_reporting_service.py.jinja +0 -16
  52. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/interceptors/order_timing_interceptor.py.jinja +0 -17
  53. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/options/order_options.py.jinja +0 -7
  54. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_approval_service.py.jinja +0 -65
  55. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_management_service.py.jinja +0 -35
  56. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/services/order_query_service.py.jinja +0 -41
  57. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/setting_handlers/approval_setting_observer.py.jinja +0 -17
  58. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/inputs/approve_order.py.jinja +0 -6
  59. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/inputs/create_order.py.jinja +0 -7
  60. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/integration_services/order_reporting_service.py.jinja +0 -7
  61. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_approval_service.py.jinja +0 -13
  62. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_management_service.py.jinja +0 -10
  63. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/services/order_query_service.py.jinja +0 -12
  64. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/views/order_statistics_snapshot.py.jinja +0 -9
  65. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/views/order_view.py.jinja +0 -12
  66. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/entities/order.py.jinja +0 -48
  67. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/events/order_changed.py.jinja +0 -13
  68. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/repositories/order_repository.py.jinja +0 -17
  69. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/seeding/order_seed_contributor.py.jinja +0 -22
  70. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/services/order_approval_service.py.jinja +0 -23
  71. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/settings/approval_settings.py.jinja +0 -17
  72. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/value_objects/order_title.py.jinja +0 -10
  73. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/constants/order_constants.py.jinja +0 -3
  74. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/enums/order_status.py.jinja +0 -6
  75. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/errors/order_errors.py.jinja +0 -11
  76. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/messages/order_messages.py.jinja +0 -26
  77. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/messages/order_observation.py.jinja +0 -13
  78. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions/order_permission_provider.py.jinja +0 -19
  79. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions/order_permissions.py.jinja +0 -7
  80. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/value_objects/money.py.jinja +0 -18
  81. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/filters/export_filter.py.jinja +0 -15
  82. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/models/refresh_orders.py.jinja +0 -5
  83. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/routers/order_files.py.jinja +0 -76
  84. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/websockets/order_socket.py.jinja +0 -44
  85. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/models/order_model.py.jinja +0 -29
  86. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/order_repository.py.jinja +0 -81
  87. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/tests/test_domain.py.jinja +0 -19
  88. {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/entry_points.txt +0 -0
  89. {python_ddd_framework-0.6.0.dist-info → python_ddd_framework-0.7.0.dist-info}/licenses/LICENSE +0 -0
  90. {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
- 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
@@ -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
- return await authenticator.authenticate_access_token(token)
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
- if isinstance(route, _ActionRoute):
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 route._definition.authorization.permissions
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