python-platform 0.1.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_platform/__init__.py +445 -0
- python_platform/application/__init__.py +6 -0
- python_platform/application/build_spec.py +37 -0
- python_platform/application/builder.py +260 -0
- python_platform/application/composition.py +219 -0
- python_platform/application/runtime.py +546 -0
- python_platform/application/runtime_hooks.py +327 -0
- python_platform/application/state.py +216 -0
- python_platform/application_services/__init__.py +54 -0
- python_platform/application_services/catalog.py +500 -0
- python_platform/application_services/contracts.py +188 -0
- python_platform/application_services/dispatcher.py +423 -0
- python_platform/application_services/errors.py +89 -0
- python_platform/application_services/execution.py +199 -0
- python_platform/application_services/interceptors.py +44 -0
- python_platform/application_services/invocation.py +427 -0
- python_platform/application_services/policies.py +46 -0
- python_platform/application_services/seeding.py +72 -0
- python_platform/application_services/signature.py +49 -0
- python_platform/application_services/validation.py +84 -0
- python_platform/auditing/__init__.py +12 -0
- python_platform/auditing/contracts.py +39 -0
- python_platform/auditing/control.py +48 -0
- python_platform/auditing/sqlalchemy/__init__.py +6 -0
- python_platform/auditing/sqlalchemy/migrations/0001_platform_auditing.py +45 -0
- python_platform/auditing/sqlalchemy/migrations/__init__.py +1 -0
- python_platform/auditing/sqlalchemy/models.py +31 -0
- python_platform/auditing/sqlalchemy/module.py +32 -0
- python_platform/auditing/sqlalchemy/store.py +63 -0
- python_platform/authorization/__init__.py +38 -0
- python_platform/authorization/catalog.py +63 -0
- python_platform/authorization/contracts.py +241 -0
- python_platform/authorization/definition_discovery.py +81 -0
- python_platform/authorization/definitions.py +43 -0
- python_platform/authorization/errors.py +40 -0
- python_platform/background_execution/__init__.py +15 -0
- python_platform/background_execution/application.py +41 -0
- python_platform/background_execution/child.py +123 -0
- python_platform/background_execution/contracts.py +39 -0
- python_platform/background_execution/lifecycle.py +22 -0
- python_platform/background_execution/local.py +127 -0
- python_platform/background_execution/locks.py +31 -0
- python_platform/background_execution/management.py +17 -0
- python_platform/background_execution/module.py +10 -0
- python_platform/background_execution/permissions.py +14 -0
- python_platform/background_execution/processes.py +331 -0
- python_platform/background_jobs/__init__.py +31 -0
- python_platform/background_jobs/catalog.py +287 -0
- python_platform/background_jobs/contracts.py +202 -0
- python_platform/background_jobs/errors.py +28 -0
- python_platform/background_jobs/execution.py +201 -0
- python_platform/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +21 -0
- python_platform/background_jobs/pgqueuer/__init__.py +6 -0
- python_platform/background_jobs/pgqueuer/enqueue.py +82 -0
- python_platform/background_jobs/pgqueuer/migrations/0001_pgqueuer_1_3_2.py +63 -0
- python_platform/background_jobs/pgqueuer/migrations/__init__.py +1 -0
- python_platform/background_jobs/pgqueuer/module.py +102 -0
- python_platform/background_jobs/pgqueuer/options.py +70 -0
- python_platform/background_jobs/pgqueuer/runtime.py +479 -0
- python_platform/background_jobs/pgqueuer/sql/pgqueuer_1_3_2_install.sql +118 -0
- python_platform/background_jobs/pgqueuer/supervision.py +201 -0
- python_platform/background_workers/__init__.py +22 -0
- python_platform/background_workers/catalog.py +96 -0
- python_platform/background_workers/contracts.py +124 -0
- python_platform/background_workers/errors.py +20 -0
- python_platform/background_workers/execution.py +133 -0
- python_platform/background_workers/runtime.py +203 -0
- python_platform/caching/__init__.py +11 -0
- python_platform/caching/catalog.py +73 -0
- python_platform/caching/contracts.py +73 -0
- python_platform/caching/errors.py +20 -0
- python_platform/cli/__init__.py +87 -0
- python_platform/cli/errors.py +5 -0
- python_platform/cli/project.py +69 -0
- python_platform/cli/runtime.py +68 -0
- python_platform/configuration/__init__.py +21 -0
- python_platform/configuration/composition.py +65 -0
- python_platform/configuration/contracts.py +68 -0
- python_platform/configuration/dotenv_source.py +51 -0
- python_platform/configuration/environment_source.py +38 -0
- python_platform/configuration/immutability.py +124 -0
- python_platform/configuration/input_shape.py +58 -0
- python_platform/configuration/merge.py +103 -0
- python_platform/configuration/root.py +215 -0
- python_platform/configuration/sources.py +519 -0
- python_platform/configuration/values.py +427 -0
- python_platform/configuration/yaml_source.py +51 -0
- python_platform/developer_kit/__init__.py +1 -0
- python_platform/developer_kit/generation.py +182 -0
- python_platform/developer_kit/project_metadata.py +45 -0
- python_platform/developer_kit/source.py +53 -0
- python_platform/developer_kit/templates/module/cookiecutter.json +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/cache.py.jinja +10 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/events.py.jinja +24 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/integration.py.jinja +46 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/module.py.jinja +29 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/options.py.jinja +8 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/orders.py.jinja +81 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application/tasks.py.jinja +86 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/module.py.jinja +10 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/application_contracts/orders.py.jinja +36 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/module.py.jinja +10 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/orders.py.jinja +57 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/repository.py.jinja +12 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/seeding.py.jinja +19 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain/settings.py.jinja +16 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/definitions.py.jinja +24 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/module.py.jinja +7 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/domain_shared/permissions.py.jinja +15 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/files.py.jinja +74 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/module.py.jinja +58 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/http_api/realtime.py.jinja +37 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/migrations/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/models.py.jinja +26 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/module.py.jinja +32 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repository.py.jinja +49 -0
- python_platform/developer_kit/templates/module/{{cookiecutter.module_name}}/tests/test_domain.py.jinja +18 -0
- python_platform/developer_kit/templates/project/cookiecutter.json +6 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.dockerignore +8 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.env.jinja +13 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.gitignore +6 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/.python-version +1 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/Dockerfile +22 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/README.md +86 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/app.yaml.jinja +16 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/compose.dev.yaml.jinja +24 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/compose.production.yaml.jinja +13 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/pyproject.toml.jinja +28 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/__init__.py.jinja +1 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/main.py.jinja +52 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/src/host/module.py.jinja +38 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/tests/conftest.py.jinja +8 -0
- python_platform/developer_kit/templates/project/{{cookiecutter.project_name}}/tests/host/test_http.py.jinja +101 -0
- python_platform/developer_kit/wiring.py +85 -0
- python_platform/diagnostics/__init__.py +23 -0
- python_platform/diagnostics/journal.py +174 -0
- python_platform/diagnostics/model.py +83 -0
- python_platform/diagnostics/source.py +25 -0
- python_platform/distributed_lock/__init__.py +6 -0
- python_platform/distributed_lock/contracts.py +20 -0
- python_platform/distributed_lock/options.py +19 -0
- python_platform/domain/__init__.py +13 -0
- python_platform/domain/aggregates.py +64 -0
- python_platform/domain/entities.py +33 -0
- python_platform/domain/errors.py +12 -0
- python_platform/domain/value_objects.py +31 -0
- python_platform/errors/__init__.py +75 -0
- python_platform/errors/base.py +13 -0
- python_platform/errors/business.py +53 -0
- python_platform/errors/configuration.py +119 -0
- python_platform/errors/diagnostics.py +12 -0
- python_platform/errors/lifecycle.py +164 -0
- python_platform/errors/modularity.py +100 -0
- python_platform/errors/services.py +100 -0
- python_platform/events/__init__.py +25 -0
- python_platform/events/aggregate.py +39 -0
- python_platform/events/catalog.py +136 -0
- python_platform/events/contracts.py +39 -0
- python_platform/events/contribution.py +128 -0
- python_platform/events/discovery.py +105 -0
- python_platform/events/errors.py +48 -0
- python_platform/events/runtime.py +192 -0
- python_platform/fastapi/__init__.py +49 -0
- python_platform/fastapi/action.py +175 -0
- python_platform/fastapi/adapter.py +381 -0
- python_platform/fastapi/application_services.py +647 -0
- python_platform/fastapi/background.py +42 -0
- python_platform/fastapi/contracts.py +278 -0
- python_platform/fastapi/errors.py +32 -0
- python_platform/fastapi/filters.py +111 -0
- python_platform/fastapi/health.py +44 -0
- python_platform/fastapi/http.py +55 -0
- python_platform/fastapi/http_router.py +212 -0
- python_platform/fastapi/manual_action.py +105 -0
- python_platform/fastapi/middleware.py +93 -0
- python_platform/fastapi/parameters.py +97 -0
- python_platform/fastapi/realtime/__init__.py +13 -0
- python_platform/fastapi/realtime/authentication.py +181 -0
- python_platform/fastapi/realtime/connection.py +166 -0
- python_platform/fastapi/realtime/module.py +41 -0
- python_platform/fastapi/realtime/options.py +50 -0
- python_platform/fastapi/realtime/runtime.py +423 -0
- python_platform/fastapi/request_context.py +244 -0
- python_platform/fastapi/route_integrity.py +88 -0
- python_platform/fastapi/routing.py +410 -0
- python_platform/fastapi/server.py +181 -0
- python_platform/fastapi/settings.py +40 -0
- python_platform/fastapi/tracing.py +62 -0
- python_platform/fastapi/transfer.py +128 -0
- python_platform/fastapi/upload_limits.py +63 -0
- python_platform/fastapi/uploads.py +94 -0
- python_platform/hosted_services/__init__.py +29 -0
- python_platform/hosted_services/bridge.py +420 -0
- python_platform/hosted_services/catalog.py +259 -0
- python_platform/hosted_services/contracts.py +55 -0
- python_platform/hosted_services/errors.py +53 -0
- python_platform/hosted_services/options.py +17 -0
- python_platform/hosted_services/runtime.py +267 -0
- python_platform/hosted_services/state.py +40 -0
- python_platform/hosting/__init__.py +4 -0
- python_platform/hosting/instance.py +40 -0
- python_platform/identity/__init__.py +73 -0
- python_platform/identity/application.py +287 -0
- python_platform/identity/contracts.py +283 -0
- python_platform/identity/errors.py +28 -0
- python_platform/identity/http_api.py +90 -0
- python_platform/identity/module.py +46 -0
- python_platform/identity/passwords.py +34 -0
- python_platform/identity/permissions.py +14 -0
- python_platform/identity/services.py +108 -0
- python_platform/identity/sqlalchemy/__init__.py +5 -0
- python_platform/identity/sqlalchemy/migrations/0001_platform_identity.py +151 -0
- python_platform/identity/sqlalchemy/migrations/0002_physical_delete.py +42 -0
- python_platform/identity/sqlalchemy/migrations/__init__.py +1 -0
- python_platform/identity/sqlalchemy/models.py +85 -0
- python_platform/identity/sqlalchemy/module.py +62 -0
- python_platform/identity/sqlalchemy/stores.py +526 -0
- python_platform/identity/tokens.py +106 -0
- python_platform/invocation/__init__.py +3 -0
- python_platform/invocation/callables.py +171 -0
- python_platform/invocation/contracts.py +33 -0
- python_platform/invocation/entries.py +54 -0
- python_platform/invocation/function_runtime.py +48 -0
- python_platform/invocation/interception.py +184 -0
- python_platform/lifecycle/__init__.py +25 -0
- python_platform/lifecycle/composition.py +35 -0
- python_platform/lifecycle/context.py +31 -0
- python_platform/lifecycle/runtime.py +43 -0
- python_platform/lifecycle/state.py +20 -0
- python_platform/modularity/__init__.py +15 -0
- python_platform/modularity/contracts.py +90 -0
- python_platform/modularity/discovery.py +143 -0
- python_platform/modularity/graph.py +334 -0
- python_platform/modularity/registry.py +100 -0
- python_platform/modularity/selection.py +16 -0
- python_platform/notifications/__init__.py +19 -0
- python_platform/notifications/catalog.py +46 -0
- python_platform/notifications/contracts.py +107 -0
- python_platform/observability/__init__.py +5 -0
- python_platform/observability/context.py +43 -0
- python_platform/observability/export.py +73 -0
- python_platform/observability/formatting.py +89 -0
- python_platform/observability/logging.py +118 -0
- python_platform/observability/options.py +45 -0
- python_platform/observability/tracing.py +80 -0
- python_platform/options/__init__.py +19 -0
- python_platform/options/aliases.py +331 -0
- python_platform/options/contribution.py +62 -0
- python_platform/options/immutability.py +48 -0
- python_platform/options/input_keys.py +214 -0
- python_platform/options/issues.py +335 -0
- python_platform/options/models.py +114 -0
- python_platform/options/registry.py +280 -0
- python_platform/options/schema.py +488 -0
- python_platform/options/validation.py +120 -0
- python_platform/py.typed +0 -0
- python_platform/realtime/__init__.py +12 -0
- python_platform/realtime/contracts.py +28 -0
- python_platform/realtime/diagnostics.py +37 -0
- python_platform/realtime/messages.py +113 -0
- python_platform/redis/__init__.py +6 -0
- python_platform/redis/distributed_lock.py +212 -0
- python_platform/redis/lease_lock.py +36 -0
- python_platform/redis/module.py +50 -0
- python_platform/redis/notification_runtime.py +182 -0
- python_platform/redis/notifications.py +25 -0
- python_platform/redis/options.py +35 -0
- python_platform/redis/runtime.py +116 -0
- python_platform/services/__init__.py +39 -0
- python_platform/services/application_bindings.py +221 -0
- python_platform/services/arbitration.py +381 -0
- python_platform/services/binding.py +102 -0
- python_platform/services/contribution.py +438 -0
- python_platform/services/convention.py +292 -0
- python_platform/services/convention_contracts.py +94 -0
- python_platform/services/exposure.py +23 -0
- python_platform/services/framework_provider.py +178 -0
- python_platform/services/native_graph.py +172 -0
- python_platform/services/provider.py +204 -0
- python_platform/services/registration.py +101 -0
- python_platform/services/runtime.py +298 -0
- python_platform/settings/__init__.py +30 -0
- python_platform/settings/application.py +95 -0
- python_platform/settings/binding.py +33 -0
- python_platform/settings/catalog.py +121 -0
- python_platform/settings/changes.py +14 -0
- python_platform/settings/contracts.py +115 -0
- python_platform/settings/definition_discovery.py +71 -0
- python_platform/settings/definitions.py +41 -0
- python_platform/settings/errors.py +22 -0
- python_platform/settings/handlers.py +43 -0
- python_platform/settings/management.py +57 -0
- python_platform/settings/manager.py +89 -0
- python_platform/settings/module.py +7 -0
- python_platform/settings/notifications.py +39 -0
- python_platform/settings/permissions.py +14 -0
- python_platform/settings/provider.py +74 -0
- python_platform/settings/refresh.py +140 -0
- python_platform/settings/refresh_module.py +45 -0
- python_platform/settings/sqlalchemy/__init__.py +5 -0
- python_platform/settings/sqlalchemy/migrations/0001_platform_settings.py +31 -0
- python_platform/settings/sqlalchemy/migrations/0002_physical_delete.py +32 -0
- python_platform/settings/sqlalchemy/migrations/0003_version_tokens.py +39 -0
- python_platform/settings/sqlalchemy/migrations/__init__.py +1 -0
- python_platform/settings/sqlalchemy/models.py +36 -0
- python_platform/settings/sqlalchemy/module.py +36 -0
- python_platform/settings/sqlalchemy/store.py +86 -0
- python_platform/settings/store.py +16 -0
- python_platform/settings/values.py +39 -0
- python_platform/sqlalchemy/__init__.py +51 -0
- python_platform/sqlalchemy/alembic_runtime/__init__.py +1 -0
- python_platform/sqlalchemy/alembic_runtime/env.py +27 -0
- python_platform/sqlalchemy/alembic_runtime/script.py.mako +14 -0
- python_platform/sqlalchemy/auditing.py +48 -0
- python_platform/sqlalchemy/errors.py +85 -0
- python_platform/sqlalchemy/metadata.py +538 -0
- python_platform/sqlalchemy/migration.py +346 -0
- python_platform/sqlalchemy/module.py +48 -0
- python_platform/sqlalchemy/module_migration.py +148 -0
- python_platform/sqlalchemy/options.py +66 -0
- python_platform/sqlalchemy/repository.py +33 -0
- python_platform/sqlalchemy/runtime.py +85 -0
- python_platform/sqlalchemy/session_provider.py +45 -0
- python_platform/sqlalchemy/unit_of_work.py +97 -0
- python_platform/testing/__init__.py +5 -0
- python_platform/testing/runtime.py +112 -0
- python_platform/unit_of_work/__init__.py +14 -0
- python_platform/unit_of_work/contracts.py +223 -0
- python_platform/unit_of_work/errors.py +27 -0
- python_platform/unit_of_work/manager.py +210 -0
- python_platform/unit_of_work/options.py +92 -0
- python_platform-0.1.1.dist-info/METADATA +122 -0
- python_platform-0.1.1.dist-info/RECORD +344 -0
- python_platform-0.1.1.dist-info/WHEEL +4 -0
- python_platform-0.1.1.dist-info/entry_points.txt +9 -0
- python_platform-0.1.1.dist-info/licenses/LICENSE +7 -0
- python_platform-0.1.1.dist-info/licenses/src/python_platform/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +21 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""Application-owned WebSocket registry、writer 与 shutdown runtime。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import cast
|
|
10
|
+
from uuid import UUID, uuid4
|
|
11
|
+
|
|
12
|
+
from dishka import AsyncContainer
|
|
13
|
+
from fastapi import WebSocket
|
|
14
|
+
|
|
15
|
+
from ...application_services.invocation import _InvocationRuntime
|
|
16
|
+
from ...authorization import AccessTokenAuthenticator, CurrentUser, UnauthenticatedError
|
|
17
|
+
from ...options import Options
|
|
18
|
+
from ...realtime import (
|
|
19
|
+
RealtimeDeliveryReport,
|
|
20
|
+
RealtimeEnvelope,
|
|
21
|
+
RealtimePublisher,
|
|
22
|
+
RealtimeSnapshot,
|
|
23
|
+
)
|
|
24
|
+
from ...realtime.messages import _encode_envelope
|
|
25
|
+
from .connection import (
|
|
26
|
+
_PLATFORM_PROBLEM_MESSAGE_TYPE,
|
|
27
|
+
WebSocketConnection,
|
|
28
|
+
_WebSocketCloseCode,
|
|
29
|
+
)
|
|
30
|
+
from .options import FastApiRealtimeOptions
|
|
31
|
+
|
|
32
|
+
_LOGGER = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class _TextFrame:
|
|
37
|
+
text: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class _CloseFrame:
|
|
42
|
+
code: _WebSocketCloseCode
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(slots=True)
|
|
46
|
+
class _ConnectionState:
|
|
47
|
+
connection_id: UUID
|
|
48
|
+
websocket: WebSocket
|
|
49
|
+
authenticator: AccessTokenAuthenticator
|
|
50
|
+
access_token: str
|
|
51
|
+
current_user: CurrentUser
|
|
52
|
+
correlation_id: str
|
|
53
|
+
outbound: asyncio.Queue[_TextFrame | _CloseFrame]
|
|
54
|
+
endpoint_task: asyncio.Task[object]
|
|
55
|
+
closed: asyncio.Event
|
|
56
|
+
writer_task: asyncio.Task[None] | None = None
|
|
57
|
+
watchdog_task: asyncio.Task[None] | None = None
|
|
58
|
+
consecutive_invalid_messages: int = 0
|
|
59
|
+
closing: bool = False
|
|
60
|
+
peer_disconnected: bool = False
|
|
61
|
+
close_code: int = int(_WebSocketCloseCode.NORMAL)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _RealtimeAdmissionError(Exception):
|
|
65
|
+
def __init__(self, code: _WebSocketCloseCode) -> None:
|
|
66
|
+
self.code = code
|
|
67
|
+
super().__init__(code.name)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _FastApiRealtimeRuntime:
|
|
71
|
+
"""每个 Application 独占的连接 owner;不存在进程级 registry。"""
|
|
72
|
+
|
|
73
|
+
__slots__ = (
|
|
74
|
+
"_accepting",
|
|
75
|
+
"_connections",
|
|
76
|
+
"_dropped",
|
|
77
|
+
"_enqueued",
|
|
78
|
+
"_invocations",
|
|
79
|
+
"_lock",
|
|
80
|
+
"_options",
|
|
81
|
+
"_user_connections",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
options: Options[FastApiRealtimeOptions],
|
|
87
|
+
invocations: _InvocationRuntime,
|
|
88
|
+
) -> None:
|
|
89
|
+
self._options = options.value
|
|
90
|
+
self._invocations = invocations
|
|
91
|
+
self._accepting = False
|
|
92
|
+
self._connections: dict[UUID, _ConnectionState] = {}
|
|
93
|
+
self._user_connections: dict[UUID, set[UUID]] = {}
|
|
94
|
+
self._lock = asyncio.Lock()
|
|
95
|
+
self._enqueued = 0
|
|
96
|
+
self._dropped = 0
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def options(self) -> FastApiRealtimeOptions:
|
|
100
|
+
return self._options
|
|
101
|
+
|
|
102
|
+
async def start_accepting(self) -> None:
|
|
103
|
+
async with self._lock:
|
|
104
|
+
self._accepting = True
|
|
105
|
+
|
|
106
|
+
async def connect(
|
|
107
|
+
self,
|
|
108
|
+
websocket: WebSocket,
|
|
109
|
+
session: AsyncContainer,
|
|
110
|
+
authenticator: AccessTokenAuthenticator,
|
|
111
|
+
access_token: str,
|
|
112
|
+
current_user: CurrentUser,
|
|
113
|
+
correlation_id: str,
|
|
114
|
+
) -> WebSocketConnection:
|
|
115
|
+
if not current_user.is_authenticated or current_user.user_id is None:
|
|
116
|
+
raise _RealtimeAdmissionError(_WebSocketCloseCode.POLICY_VIOLATION)
|
|
117
|
+
endpoint_task = asyncio.current_task()
|
|
118
|
+
if endpoint_task is None:
|
|
119
|
+
raise RuntimeError("managed WebSocket requires an asyncio Task")
|
|
120
|
+
state = _ConnectionState(
|
|
121
|
+
connection_id=uuid4(),
|
|
122
|
+
websocket=websocket,
|
|
123
|
+
authenticator=authenticator,
|
|
124
|
+
access_token=access_token,
|
|
125
|
+
current_user=current_user,
|
|
126
|
+
correlation_id=correlation_id,
|
|
127
|
+
# 多出的一个槽只保留给 close frame;业务容量仍严格等于 Options。
|
|
128
|
+
outbound=asyncio.Queue(maxsize=self._options.outbound_queue_capacity + 1),
|
|
129
|
+
endpoint_task=cast(asyncio.Task[object], endpoint_task),
|
|
130
|
+
closed=asyncio.Event(),
|
|
131
|
+
)
|
|
132
|
+
async with self._lock:
|
|
133
|
+
if not self._accepting:
|
|
134
|
+
raise _RealtimeAdmissionError(_WebSocketCloseCode.APPLICATION_SHUTDOWN)
|
|
135
|
+
user_connections = self._user_connections.setdefault(current_user.user_id, set())
|
|
136
|
+
if (
|
|
137
|
+
len(self._connections) >= self._options.max_connections
|
|
138
|
+
or len(user_connections) >= self._options.max_connections_per_user
|
|
139
|
+
):
|
|
140
|
+
if not user_connections:
|
|
141
|
+
self._user_connections.pop(current_user.user_id, None)
|
|
142
|
+
raise _RealtimeAdmissionError(_WebSocketCloseCode.TRY_AGAIN_LATER)
|
|
143
|
+
self._connections[state.connection_id] = state
|
|
144
|
+
user_connections.add(state.connection_id)
|
|
145
|
+
try:
|
|
146
|
+
await websocket.accept()
|
|
147
|
+
except BaseException:
|
|
148
|
+
await self.disconnect(state.connection_id)
|
|
149
|
+
raise
|
|
150
|
+
state.writer_task = asyncio.create_task(
|
|
151
|
+
self._writer(state),
|
|
152
|
+
name=f"python-platform-ws-writer-{state.connection_id}",
|
|
153
|
+
)
|
|
154
|
+
state.watchdog_task = asyncio.create_task(
|
|
155
|
+
self._watchdog(state),
|
|
156
|
+
name=f"python-platform-ws-watchdog-{state.connection_id}",
|
|
157
|
+
)
|
|
158
|
+
return WebSocketConnection(self, state, session)
|
|
159
|
+
|
|
160
|
+
async def disconnect(self, connection_id: UUID) -> None:
|
|
161
|
+
async with self._lock:
|
|
162
|
+
state = self._connections.pop(connection_id, None)
|
|
163
|
+
if state is None:
|
|
164
|
+
return
|
|
165
|
+
user_id = state.current_user.user_id
|
|
166
|
+
if user_id is not None:
|
|
167
|
+
user_connections = self._user_connections.get(user_id)
|
|
168
|
+
if user_connections is not None:
|
|
169
|
+
user_connections.discard(connection_id)
|
|
170
|
+
if not user_connections:
|
|
171
|
+
self._user_connections.pop(user_id, None)
|
|
172
|
+
state.closing = True
|
|
173
|
+
current_task = asyncio.current_task()
|
|
174
|
+
tasks = tuple(
|
|
175
|
+
task
|
|
176
|
+
for task in (state.writer_task, state.watchdog_task)
|
|
177
|
+
if task is not None and task is not current_task and not task.done()
|
|
178
|
+
)
|
|
179
|
+
for task in tasks:
|
|
180
|
+
task.cancel()
|
|
181
|
+
if tasks:
|
|
182
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
183
|
+
state.access_token = ""
|
|
184
|
+
state.closed.set()
|
|
185
|
+
|
|
186
|
+
async def finish(
|
|
187
|
+
self,
|
|
188
|
+
connection_id: UUID,
|
|
189
|
+
*,
|
|
190
|
+
code: _WebSocketCloseCode = _WebSocketCloseCode.NORMAL,
|
|
191
|
+
) -> None:
|
|
192
|
+
async with self._lock:
|
|
193
|
+
state = self._connections.get(connection_id)
|
|
194
|
+
if state is None:
|
|
195
|
+
return
|
|
196
|
+
if state.peer_disconnected:
|
|
197
|
+
await self.disconnect(connection_id)
|
|
198
|
+
return
|
|
199
|
+
await self._request_close(state, code)
|
|
200
|
+
writer = state.writer_task
|
|
201
|
+
if writer is not None and not writer.done():
|
|
202
|
+
try:
|
|
203
|
+
await asyncio.wait_for(
|
|
204
|
+
asyncio.shield(writer),
|
|
205
|
+
timeout=self._options.shutdown_timeout_seconds,
|
|
206
|
+
)
|
|
207
|
+
except TimeoutError:
|
|
208
|
+
writer.cancel()
|
|
209
|
+
await asyncio.gather(writer, return_exceptions=True)
|
|
210
|
+
await self.disconnect(connection_id)
|
|
211
|
+
|
|
212
|
+
async def send_to_connection(
|
|
213
|
+
self,
|
|
214
|
+
connection_id: UUID,
|
|
215
|
+
*,
|
|
216
|
+
message_type: str,
|
|
217
|
+
payload: Mapping[str, object],
|
|
218
|
+
) -> RealtimeDeliveryReport:
|
|
219
|
+
async with self._lock:
|
|
220
|
+
state = self._connections.get(connection_id)
|
|
221
|
+
states = () if state is None else (state,)
|
|
222
|
+
return await self._send(states, message_type=message_type, payload=payload)
|
|
223
|
+
|
|
224
|
+
async def send_to_user(
|
|
225
|
+
self,
|
|
226
|
+
user_id: UUID,
|
|
227
|
+
*,
|
|
228
|
+
message_type: str,
|
|
229
|
+
payload: Mapping[str, object],
|
|
230
|
+
) -> RealtimeDeliveryReport:
|
|
231
|
+
async with self._lock:
|
|
232
|
+
connection_ids = tuple(self._user_connections.get(user_id, ()))
|
|
233
|
+
states = tuple(
|
|
234
|
+
state
|
|
235
|
+
for connection_id in connection_ids
|
|
236
|
+
if (state := self._connections.get(connection_id)) is not None
|
|
237
|
+
)
|
|
238
|
+
return await self._send(states, message_type=message_type, payload=payload)
|
|
239
|
+
|
|
240
|
+
async def snapshot(self) -> RealtimeSnapshot:
|
|
241
|
+
async with self._lock:
|
|
242
|
+
return RealtimeSnapshot(
|
|
243
|
+
accepting=self._accepting,
|
|
244
|
+
active_connections=len(self._connections),
|
|
245
|
+
active_users=len(self._user_connections),
|
|
246
|
+
enqueued=self._enqueued,
|
|
247
|
+
dropped=self._dropped,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
async def shutdown(self) -> None:
|
|
251
|
+
async with self._lock:
|
|
252
|
+
self._accepting = False
|
|
253
|
+
states = tuple(self._connections.values())
|
|
254
|
+
for state in states:
|
|
255
|
+
await self._request_close(state, _WebSocketCloseCode.APPLICATION_SHUTDOWN)
|
|
256
|
+
waiters = tuple(asyncio.create_task(state.closed.wait()) for state in states)
|
|
257
|
+
if waiters:
|
|
258
|
+
_, pending = await asyncio.wait(
|
|
259
|
+
waiters,
|
|
260
|
+
timeout=self._options.shutdown_timeout_seconds,
|
|
261
|
+
)
|
|
262
|
+
for waiter in pending:
|
|
263
|
+
waiter.cancel()
|
|
264
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
265
|
+
remaining = tuple(state for state in states if not state.closed.is_set())
|
|
266
|
+
for state in remaining:
|
|
267
|
+
if not state.endpoint_task.done():
|
|
268
|
+
state.endpoint_task.cancel()
|
|
269
|
+
if remaining:
|
|
270
|
+
await asyncio.gather(
|
|
271
|
+
*(state.endpoint_task for state in remaining),
|
|
272
|
+
return_exceptions=True,
|
|
273
|
+
)
|
|
274
|
+
for state in remaining:
|
|
275
|
+
await self.disconnect(state.connection_id)
|
|
276
|
+
|
|
277
|
+
async def _send(
|
|
278
|
+
self,
|
|
279
|
+
states: tuple[_ConnectionState, ...],
|
|
280
|
+
*,
|
|
281
|
+
message_type: str,
|
|
282
|
+
payload: Mapping[str, object],
|
|
283
|
+
) -> RealtimeDeliveryReport:
|
|
284
|
+
correlation_id = self._invocations.correlation_id() or str(uuid4())
|
|
285
|
+
text = _encode_envelope(
|
|
286
|
+
RealtimeEnvelope(
|
|
287
|
+
message_type=message_type,
|
|
288
|
+
correlation_id=correlation_id,
|
|
289
|
+
payload=payload,
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
if not states:
|
|
293
|
+
return RealtimeDeliveryReport(matched=0, enqueued=0, dropped=0)
|
|
294
|
+
enqueued = 0
|
|
295
|
+
dropped = 0
|
|
296
|
+
for state in states:
|
|
297
|
+
if state.closing:
|
|
298
|
+
dropped += 1
|
|
299
|
+
continue
|
|
300
|
+
try:
|
|
301
|
+
if state.outbound.qsize() >= self._options.outbound_queue_capacity:
|
|
302
|
+
raise asyncio.QueueFull
|
|
303
|
+
state.outbound.put_nowait(_TextFrame(text))
|
|
304
|
+
enqueued += 1
|
|
305
|
+
except asyncio.QueueFull:
|
|
306
|
+
dropped += 1
|
|
307
|
+
await self._request_close(
|
|
308
|
+
state,
|
|
309
|
+
_WebSocketCloseCode.TRY_AGAIN_LATER,
|
|
310
|
+
discard_pending=True,
|
|
311
|
+
)
|
|
312
|
+
async with self._lock:
|
|
313
|
+
self._enqueued += enqueued
|
|
314
|
+
self._dropped += dropped
|
|
315
|
+
return RealtimeDeliveryReport(
|
|
316
|
+
matched=len(states),
|
|
317
|
+
enqueued=enqueued,
|
|
318
|
+
dropped=dropped,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
async def _enqueue_problem(
|
|
322
|
+
self,
|
|
323
|
+
state: _ConnectionState,
|
|
324
|
+
*,
|
|
325
|
+
code: str,
|
|
326
|
+
status: int,
|
|
327
|
+
) -> RealtimeDeliveryReport:
|
|
328
|
+
return await self._send(
|
|
329
|
+
(state,),
|
|
330
|
+
message_type=_PLATFORM_PROBLEM_MESSAGE_TYPE,
|
|
331
|
+
payload={"code": code, "status": status},
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
async def _request_close(
|
|
335
|
+
self,
|
|
336
|
+
state: _ConnectionState,
|
|
337
|
+
code: _WebSocketCloseCode,
|
|
338
|
+
*,
|
|
339
|
+
discard_pending: bool = False,
|
|
340
|
+
) -> None:
|
|
341
|
+
if state.closing:
|
|
342
|
+
return
|
|
343
|
+
state.closing = True
|
|
344
|
+
state.close_code = int(code)
|
|
345
|
+
evicted = 0
|
|
346
|
+
if discard_pending:
|
|
347
|
+
while True:
|
|
348
|
+
try:
|
|
349
|
+
frame = state.outbound.get_nowait()
|
|
350
|
+
except asyncio.QueueEmpty:
|
|
351
|
+
break
|
|
352
|
+
if isinstance(frame, _TextFrame):
|
|
353
|
+
evicted += 1
|
|
354
|
+
state.outbound.put_nowait(_CloseFrame(code))
|
|
355
|
+
if evicted:
|
|
356
|
+
async with self._lock:
|
|
357
|
+
self._dropped += evicted
|
|
358
|
+
|
|
359
|
+
async def _refresh_identity(self, state: _ConnectionState) -> CurrentUser:
|
|
360
|
+
try:
|
|
361
|
+
refreshed = await state.authenticator.authenticate_access_token(state.access_token)
|
|
362
|
+
except Exception as error:
|
|
363
|
+
_log_connection_failure("authentication", error)
|
|
364
|
+
await self._request_close(state, _WebSocketCloseCode.POLICY_VIOLATION)
|
|
365
|
+
raise UnauthenticatedError from None
|
|
366
|
+
if (
|
|
367
|
+
not refreshed.is_authenticated
|
|
368
|
+
or refreshed.user_id != state.current_user.user_id
|
|
369
|
+
or refreshed.session_id != state.current_user.session_id
|
|
370
|
+
):
|
|
371
|
+
await self._request_close(state, _WebSocketCloseCode.POLICY_VIOLATION)
|
|
372
|
+
raise UnauthenticatedError
|
|
373
|
+
state.current_user = refreshed
|
|
374
|
+
return refreshed
|
|
375
|
+
|
|
376
|
+
async def _writer(self, state: _ConnectionState) -> None:
|
|
377
|
+
try:
|
|
378
|
+
while True:
|
|
379
|
+
frame = await state.outbound.get()
|
|
380
|
+
if isinstance(frame, _TextFrame):
|
|
381
|
+
await state.websocket.send_text(frame.text)
|
|
382
|
+
continue
|
|
383
|
+
await state.websocket.close(code=int(frame.code))
|
|
384
|
+
return
|
|
385
|
+
except asyncio.CancelledError:
|
|
386
|
+
raise
|
|
387
|
+
except Exception as error:
|
|
388
|
+
_log_connection_failure("writer", error)
|
|
389
|
+
state.closing = True
|
|
390
|
+
if not state.peer_disconnected and not state.endpoint_task.done():
|
|
391
|
+
state.endpoint_task.cancel()
|
|
392
|
+
|
|
393
|
+
async def _watchdog(self, state: _ConnectionState) -> None:
|
|
394
|
+
try:
|
|
395
|
+
while not state.closing:
|
|
396
|
+
try:
|
|
397
|
+
await asyncio.wait_for(
|
|
398
|
+
state.closed.wait(),
|
|
399
|
+
timeout=self._options.authentication_refresh_seconds,
|
|
400
|
+
)
|
|
401
|
+
return
|
|
402
|
+
except TimeoutError:
|
|
403
|
+
pass
|
|
404
|
+
try:
|
|
405
|
+
await self._refresh_identity(state)
|
|
406
|
+
except UnauthenticatedError:
|
|
407
|
+
return
|
|
408
|
+
except asyncio.CancelledError:
|
|
409
|
+
raise
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _realtime_publisher(runtime: _FastApiRealtimeRuntime) -> RealtimePublisher:
|
|
413
|
+
return runtime
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _log_connection_failure(component: str, error: Exception) -> None:
|
|
417
|
+
_LOGGER.warning(
|
|
418
|
+
"managed WebSocket operation failed",
|
|
419
|
+
extra={
|
|
420
|
+
"realtime_component": component,
|
|
421
|
+
"realtime_exception_type": type(error).__name__,
|
|
422
|
+
},
|
|
423
|
+
)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""HTTP bearer authentication、task-local identity 与 request audit bridge。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from typing import cast
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from anyio import CancelScope
|
|
12
|
+
from dishka import AsyncContainer
|
|
13
|
+
from dishka import Scope as DIScope
|
|
14
|
+
from dishka.exceptions import NoFactoryError
|
|
15
|
+
from dishka.integrations.starlette import ContainerMiddleware
|
|
16
|
+
from starlette.requests import Request
|
|
17
|
+
from starlette.responses import JSONResponse
|
|
18
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
19
|
+
|
|
20
|
+
from ..application import Application
|
|
21
|
+
from ..application_services.invocation import (
|
|
22
|
+
_close_action,
|
|
23
|
+
_InvocationRuntime,
|
|
24
|
+
_raise_scope_failure,
|
|
25
|
+
)
|
|
26
|
+
from ..auditing import AuditSink, RequestAuditRecord
|
|
27
|
+
from ..authorization import AccessTokenAuthenticator, CurrentUser, UnauthenticatedError
|
|
28
|
+
from ..invocation.contracts import _is_safe_correlation_id
|
|
29
|
+
from ..unit_of_work import UnitOfWorkManager
|
|
30
|
+
from .errors import FastApiAdapterError
|
|
31
|
+
|
|
32
|
+
_LOGGER = logging.getLogger(__name__)
|
|
33
|
+
_CORRELATION_HEADER = b"x-correlation-id"
|
|
34
|
+
_AUTHORIZATION_HEADER = b"authorization"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _RequestScopeMiddleware:
|
|
38
|
+
"""唯一 lease 包住唯一 REQUEST;原生 ASGI 链在相同 task 解析与关闭资源。"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, app: ASGIApp, *, application: Application) -> None:
|
|
41
|
+
self._app = app
|
|
42
|
+
self._application = application
|
|
43
|
+
self._other = ContainerMiddleware(app)
|
|
44
|
+
|
|
45
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
46
|
+
if scope["type"] != "http":
|
|
47
|
+
await self._other(scope, receive, send)
|
|
48
|
+
return
|
|
49
|
+
async with self._application._http_request_lease_for_integration():
|
|
50
|
+
request = Request(scope, receive, send)
|
|
51
|
+
container = self._application._container_for_integration()(
|
|
52
|
+
{Request: request}, scope=DIScope.REQUEST
|
|
53
|
+
)
|
|
54
|
+
request.state.dishka_container = container
|
|
55
|
+
primary: BaseException | None = None
|
|
56
|
+
try:
|
|
57
|
+
await self._app(scope, receive, send)
|
|
58
|
+
except BaseException as error:
|
|
59
|
+
primary = error
|
|
60
|
+
finally:
|
|
61
|
+
# AnyIO 的 level cancellation 不能反复打断资源 finalizer;asyncio 重复取消
|
|
62
|
+
# 仍由既有 scope close 监督处理。lease 要等所有清理完成后才释放。
|
|
63
|
+
with CancelScope(shield=True):
|
|
64
|
+
primary, failures = await _close_action(container, primary)
|
|
65
|
+
request.state.dishka_container = None
|
|
66
|
+
_raise_scope_failure(primary, failures, False)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _RequestTaskMiddleware:
|
|
70
|
+
"""原生 middleware 下游的可信 task 入口,不迁移任何活动数据库状态。"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
73
|
+
self._app = app
|
|
74
|
+
|
|
75
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
76
|
+
if scope["type"] != "http":
|
|
77
|
+
await self._app(scope, receive, send)
|
|
78
|
+
return
|
|
79
|
+
request = Request(scope)
|
|
80
|
+
container = cast(AsyncContainer, request.state.dishka_container)
|
|
81
|
+
runtime = await container.get(_InvocationRuntime)
|
|
82
|
+
manager = await container.get(UnitOfWorkManager)
|
|
83
|
+
if manager.current is not None:
|
|
84
|
+
raise FastApiAdapterError("middleware must finish its UnitOfWork before call_next")
|
|
85
|
+
user, correlation_id = request.state.python_platform_identity
|
|
86
|
+
with runtime.trusted_http_task(container, user, correlation_id, scope["method"]):
|
|
87
|
+
await self._app(scope, receive, send)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _RequestContextMiddleware:
|
|
91
|
+
__slots__ = ("_app", "_application")
|
|
92
|
+
|
|
93
|
+
def __init__(self, app: ASGIApp, *, application: Application) -> None:
|
|
94
|
+
self._app = app
|
|
95
|
+
self._application = application
|
|
96
|
+
|
|
97
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
98
|
+
if scope["type"] != "http":
|
|
99
|
+
await self._app(scope, receive, send)
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
correlation_id = _correlation_id(scope)
|
|
103
|
+
occurred_at = datetime.now(UTC)
|
|
104
|
+
started_ns = time.perf_counter_ns()
|
|
105
|
+
current_user = CurrentUser.anonymous()
|
|
106
|
+
status_code = 500
|
|
107
|
+
failure: BaseException | None = None
|
|
108
|
+
|
|
109
|
+
async def send_with_context(message: Message) -> None:
|
|
110
|
+
nonlocal status_code
|
|
111
|
+
if message["type"] == "http.response.start":
|
|
112
|
+
status_code = cast(int, message["status"])
|
|
113
|
+
headers = list(message.get("headers", ()))
|
|
114
|
+
if not any(name.lower() == _CORRELATION_HEADER for name, _ in headers):
|
|
115
|
+
headers.append((_CORRELATION_HEADER, correlation_id.encode("ascii")))
|
|
116
|
+
message = {**message, "headers": headers}
|
|
117
|
+
await send(message)
|
|
118
|
+
|
|
119
|
+
# lease 必须包住下游 Dishka REQUEST scope;stop 会等到 response、两级 audit 与
|
|
120
|
+
# REQUEST finalizer 全部完成后才开始 Module shutdown/root close。
|
|
121
|
+
container = cast(AsyncContainer, Request(scope).state.dishka_container)
|
|
122
|
+
runtime = await container.get(_InvocationRuntime)
|
|
123
|
+
with runtime.call_scope(container), runtime.http_scope(scope["method"]):
|
|
124
|
+
try:
|
|
125
|
+
with runtime.authenticating():
|
|
126
|
+
current_user = await self._authenticate(scope)
|
|
127
|
+
Request(scope).state.python_platform_identity = (current_user, correlation_id)
|
|
128
|
+
with runtime.trusted_http_task(
|
|
129
|
+
container, current_user, correlation_id, scope["method"]
|
|
130
|
+
):
|
|
131
|
+
await self._app(scope, receive, send_with_context)
|
|
132
|
+
except UnauthenticatedError as original_error:
|
|
133
|
+
failure = original_error
|
|
134
|
+
status_code = 401
|
|
135
|
+
response = JSONResponse(
|
|
136
|
+
status_code=401,
|
|
137
|
+
content={"detail": "Not authenticated"},
|
|
138
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
139
|
+
)
|
|
140
|
+
await response(scope, receive, send_with_context)
|
|
141
|
+
except BaseException as original_error:
|
|
142
|
+
failure = original_error
|
|
143
|
+
raise
|
|
144
|
+
finally:
|
|
145
|
+
await self._write_audit(
|
|
146
|
+
scope,
|
|
147
|
+
current_user=current_user,
|
|
148
|
+
correlation_id=correlation_id,
|
|
149
|
+
occurred_at=occurred_at,
|
|
150
|
+
status_code=status_code,
|
|
151
|
+
duration_ns=time.perf_counter_ns() - started_ns,
|
|
152
|
+
failure=failure
|
|
153
|
+
or getattr(Request(scope).state, "python_platform_transfer_failure", None),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
async def _authenticate(self, scope: Scope) -> CurrentUser:
|
|
157
|
+
authorization = _header(scope, _AUTHORIZATION_HEADER)
|
|
158
|
+
if authorization is None:
|
|
159
|
+
return CurrentUser.anonymous()
|
|
160
|
+
scheme, separator, token = authorization.partition(" ")
|
|
161
|
+
if scheme.casefold() != "bearer" or not separator or not token or token != token.strip():
|
|
162
|
+
raise UnauthenticatedError
|
|
163
|
+
try:
|
|
164
|
+
authenticator = cast(
|
|
165
|
+
AccessTokenAuthenticator,
|
|
166
|
+
await cast(AsyncContainer, Request(scope).state.dishka_container).get(
|
|
167
|
+
AccessTokenAuthenticator
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
except NoFactoryError:
|
|
171
|
+
# Host 收到认证凭据却没有验证 authority 时必须 fail closed。
|
|
172
|
+
raise UnauthenticatedError from None
|
|
173
|
+
return await authenticator.authenticate_access_token(token)
|
|
174
|
+
|
|
175
|
+
async def _write_audit(
|
|
176
|
+
self,
|
|
177
|
+
scope: Scope,
|
|
178
|
+
*,
|
|
179
|
+
current_user: CurrentUser,
|
|
180
|
+
correlation_id: str,
|
|
181
|
+
occurred_at: datetime,
|
|
182
|
+
status_code: int,
|
|
183
|
+
duration_ns: int,
|
|
184
|
+
failure: BaseException | None,
|
|
185
|
+
) -> None:
|
|
186
|
+
try:
|
|
187
|
+
container = self._application._container_for_integration()
|
|
188
|
+
except RuntimeError:
|
|
189
|
+
return
|
|
190
|
+
try:
|
|
191
|
+
sink = await container.get(AuditSink)
|
|
192
|
+
except NoFactoryError:
|
|
193
|
+
return
|
|
194
|
+
except Exception as audit_error:
|
|
195
|
+
_log_audit_failure(audit_error, route_template="<unresolved>")
|
|
196
|
+
return
|
|
197
|
+
route = scope.get("route")
|
|
198
|
+
route_path = getattr(route, "path", None)
|
|
199
|
+
route_template = route_path if isinstance(route_path, str) else "<unmatched>"
|
|
200
|
+
try:
|
|
201
|
+
await sink.write_request(
|
|
202
|
+
RequestAuditRecord(
|
|
203
|
+
occurred_at=occurred_at,
|
|
204
|
+
user_id=current_user.user_id,
|
|
205
|
+
session_id=current_user.session_id,
|
|
206
|
+
correlation_id=correlation_id,
|
|
207
|
+
route_template=route_template,
|
|
208
|
+
status_code=status_code,
|
|
209
|
+
duration_ns=duration_ns,
|
|
210
|
+
exception_type=None if failure is None else type(failure).__name__,
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
except Exception as audit_error:
|
|
214
|
+
_log_audit_failure(audit_error, route_template=route_template)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _correlation_id(scope: Scope) -> str:
|
|
218
|
+
supplied = _header(scope, _CORRELATION_HEADER)
|
|
219
|
+
if _is_safe_correlation_id(supplied):
|
|
220
|
+
return supplied
|
|
221
|
+
return str(uuid4())
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _header(scope: Scope, name: bytes) -> str | None:
|
|
225
|
+
headers = cast(tuple[tuple[bytes, bytes], ...], scope.get("headers", ()))
|
|
226
|
+
for header_name, raw_value in headers:
|
|
227
|
+
if header_name.lower() == name:
|
|
228
|
+
try:
|
|
229
|
+
return raw_value.decode("latin-1")
|
|
230
|
+
except UnicodeDecodeError:
|
|
231
|
+
return None
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _log_audit_failure(error: Exception, *, route_template: str) -> None:
|
|
236
|
+
# 仅记录静态类型与 route template;禁止输出 provider exception 文本。
|
|
237
|
+
_LOGGER.error(
|
|
238
|
+
"request audit write failed",
|
|
239
|
+
exc_info=error,
|
|
240
|
+
extra={
|
|
241
|
+
"audit_exception_type": type(error).__name__,
|
|
242
|
+
"audit_route_template": route_template,
|
|
243
|
+
},
|
|
244
|
+
)
|