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,181 @@
|
|
|
1
|
+
"""Managed WebSocket credential、Origin 与 SESSION dependency boundary。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import ipaddress
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import AsyncIterator, MutableMapping
|
|
9
|
+
from typing import cast
|
|
10
|
+
from urllib.parse import parse_qsl, urlencode
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from dishka import AsyncContainer
|
|
14
|
+
from dishka.exceptions import NoFactoryError
|
|
15
|
+
from fastapi import WebSocket, WebSocketException
|
|
16
|
+
from starlette.websockets import WebSocketDisconnect
|
|
17
|
+
|
|
18
|
+
from ...authorization import AccessTokenAuthenticator, CurrentUser
|
|
19
|
+
from ...invocation.contracts import _is_safe_correlation_id
|
|
20
|
+
from .connection import WebSocketConnection, _WebSocketCloseCode
|
|
21
|
+
from .options import FastApiRealtimeOptions
|
|
22
|
+
from .runtime import _FastApiRealtimeRuntime, _RealtimeAdmissionError
|
|
23
|
+
|
|
24
|
+
_LOGGER = logging.getLogger(__name__)
|
|
25
|
+
_AUTHORIZATION_HEADER = b"authorization"
|
|
26
|
+
_ORIGIN_HEADER = b"origin"
|
|
27
|
+
_CORRELATION_HEADER = b"x-correlation-id"
|
|
28
|
+
_ACCESS_TOKEN_PARAMETER = "access_token"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def websocket_connection(websocket: WebSocket) -> AsyncIterator[WebSocketConnection]:
|
|
32
|
+
"""认证握手并把 managed endpoint 生命周期绑定到 Dishka SESSION。"""
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
session = cast(AsyncContainer, websocket.state.dishka_container)
|
|
36
|
+
runtime = await session.get(_FastApiRealtimeRuntime)
|
|
37
|
+
authenticator = cast(
|
|
38
|
+
AccessTokenAuthenticator,
|
|
39
|
+
await session.get(AccessTokenAuthenticator),
|
|
40
|
+
)
|
|
41
|
+
except (AttributeError, NoFactoryError):
|
|
42
|
+
raise WebSocketException(code=_WebSocketCloseCode.POLICY_VIOLATION) from None
|
|
43
|
+
|
|
44
|
+
token = _credential(websocket, runtime.options)
|
|
45
|
+
try:
|
|
46
|
+
current_user = await authenticator.authenticate_access_token(token)
|
|
47
|
+
except Exception as error:
|
|
48
|
+
_log_handshake_failure("authentication", error)
|
|
49
|
+
raise WebSocketException(code=_WebSocketCloseCode.POLICY_VIOLATION) from None
|
|
50
|
+
if not isinstance(current_user, CurrentUser) or not current_user.is_authenticated:
|
|
51
|
+
raise WebSocketException(code=_WebSocketCloseCode.POLICY_VIOLATION)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
connection = await runtime.connect(
|
|
55
|
+
websocket,
|
|
56
|
+
session,
|
|
57
|
+
authenticator,
|
|
58
|
+
token,
|
|
59
|
+
current_user,
|
|
60
|
+
_correlation_id(websocket),
|
|
61
|
+
)
|
|
62
|
+
except _RealtimeAdmissionError as error:
|
|
63
|
+
raise WebSocketException(code=error.code) from None
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
yield connection
|
|
67
|
+
except WebSocketDisconnect:
|
|
68
|
+
pass
|
|
69
|
+
except asyncio.CancelledError:
|
|
70
|
+
raise
|
|
71
|
+
except BaseException as error:
|
|
72
|
+
_log_handshake_failure("endpoint", error)
|
|
73
|
+
await runtime._request_close(
|
|
74
|
+
connection._state,
|
|
75
|
+
_WebSocketCloseCode.INTERNAL_ERROR,
|
|
76
|
+
)
|
|
77
|
+
finally:
|
|
78
|
+
# receive() 已把真实 peer disconnect 写入 state;framework 发起的 close 仍须由 writer 发送。
|
|
79
|
+
await runtime.finish(connection.id)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _credential(websocket: WebSocket, options: FastApiRealtimeOptions) -> str:
|
|
83
|
+
scope = websocket.scope
|
|
84
|
+
authorization_values = _headers(websocket, _AUTHORIZATION_HEADER)
|
|
85
|
+
origin_values = _headers(websocket, _ORIGIN_HEADER)
|
|
86
|
+
query_token = _remove_query_token(scope)
|
|
87
|
+
if len(authorization_values) > 1 or len(origin_values) > 1:
|
|
88
|
+
raise WebSocketException(code=_WebSocketCloseCode.POLICY_VIOLATION)
|
|
89
|
+
header_token = _bearer_token(authorization_values[0]) if authorization_values else None
|
|
90
|
+
if header_token is not None and query_token is not None:
|
|
91
|
+
raise WebSocketException(code=_WebSocketCloseCode.POLICY_VIOLATION)
|
|
92
|
+
if header_token is None and query_token is None:
|
|
93
|
+
raise WebSocketException(code=_WebSocketCloseCode.POLICY_VIOLATION)
|
|
94
|
+
|
|
95
|
+
origin = origin_values[0] if origin_values else None
|
|
96
|
+
if origin is not None and origin not in options.allowed_origins:
|
|
97
|
+
raise WebSocketException(code=_WebSocketCloseCode.POLICY_VIOLATION)
|
|
98
|
+
if query_token is not None and origin is None:
|
|
99
|
+
raise WebSocketException(code=_WebSocketCloseCode.POLICY_VIOLATION)
|
|
100
|
+
if websocket.scope.get("scheme") != "wss" and not _is_loopback(websocket):
|
|
101
|
+
raise WebSocketException(code=_WebSocketCloseCode.POLICY_VIOLATION)
|
|
102
|
+
token = header_token if header_token is not None else query_token
|
|
103
|
+
assert token is not None
|
|
104
|
+
return token
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _remove_query_token(scope: MutableMapping[str, object]) -> str | None:
|
|
108
|
+
raw = scope.get("query_string", b"")
|
|
109
|
+
if not isinstance(raw, bytes):
|
|
110
|
+
scope["query_string"] = b""
|
|
111
|
+
raise WebSocketException(code=_WebSocketCloseCode.POLICY_VIOLATION)
|
|
112
|
+
try:
|
|
113
|
+
pairs = parse_qsl(
|
|
114
|
+
raw.decode("ascii"),
|
|
115
|
+
keep_blank_values=True,
|
|
116
|
+
encoding="utf-8",
|
|
117
|
+
errors="strict",
|
|
118
|
+
)
|
|
119
|
+
except (UnicodeError, ValueError):
|
|
120
|
+
scope["query_string"] = b""
|
|
121
|
+
raise WebSocketException(code=_WebSocketCloseCode.POLICY_VIOLATION) from None
|
|
122
|
+
token_values = tuple(value for key, value in pairs if key == _ACCESS_TOKEN_PARAMETER)
|
|
123
|
+
retained = tuple((key, value) for key, value in pairs if key != _ACCESS_TOKEN_PARAMETER)
|
|
124
|
+
scope["query_string"] = urlencode(retained).encode("ascii")
|
|
125
|
+
if not token_values:
|
|
126
|
+
return None
|
|
127
|
+
if len(token_values) != 1 or not token_values[0] or token_values[0] != token_values[0].strip():
|
|
128
|
+
raise WebSocketException(code=_WebSocketCloseCode.POLICY_VIOLATION)
|
|
129
|
+
return token_values[0]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _bearer_token(value: str) -> str:
|
|
133
|
+
scheme, separator, token = value.partition(" ")
|
|
134
|
+
if (
|
|
135
|
+
scheme.casefold() != "bearer"
|
|
136
|
+
or not separator
|
|
137
|
+
or not token
|
|
138
|
+
or token != token.strip()
|
|
139
|
+
or " " in token
|
|
140
|
+
):
|
|
141
|
+
raise WebSocketException(code=_WebSocketCloseCode.POLICY_VIOLATION)
|
|
142
|
+
return token
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _headers(websocket: WebSocket, name: bytes) -> tuple[str, ...]:
|
|
146
|
+
headers = cast(tuple[tuple[bytes, bytes], ...], websocket.scope.get("headers", ()))
|
|
147
|
+
return tuple(
|
|
148
|
+
raw.decode("latin-1") for header_name, raw in headers if header_name.lower() == name
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _correlation_id(websocket: WebSocket) -> str:
|
|
153
|
+
values = _headers(websocket, _CORRELATION_HEADER)
|
|
154
|
+
if len(values) == 1:
|
|
155
|
+
supplied = values[0]
|
|
156
|
+
if _is_safe_correlation_id(supplied):
|
|
157
|
+
return supplied
|
|
158
|
+
return str(uuid4())
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _is_loopback(websocket: WebSocket) -> bool:
|
|
162
|
+
client = websocket.scope.get("client")
|
|
163
|
+
if not isinstance(client, tuple) or not client or not isinstance(client[0], str):
|
|
164
|
+
return False
|
|
165
|
+
host = client[0]
|
|
166
|
+
if host.casefold() == "localhost":
|
|
167
|
+
return True
|
|
168
|
+
try:
|
|
169
|
+
return ipaddress.ip_address(host).is_loopback
|
|
170
|
+
except ValueError:
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _log_handshake_failure(component: str, error: BaseException) -> None:
|
|
175
|
+
_LOGGER.warning(
|
|
176
|
+
"managed WebSocket rejected",
|
|
177
|
+
extra={
|
|
178
|
+
"realtime_component": component,
|
|
179
|
+
"realtime_exception_type": type(error).__name__,
|
|
180
|
+
},
|
|
181
|
+
)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""单个 managed WebSocket 的公开收发与 ApplicationService bridge。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
7
|
+
from enum import IntEnum
|
|
8
|
+
from typing import TYPE_CHECKING, Concatenate, ParamSpec, TypeVar, cast
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
from dishka import AsyncContainer
|
|
12
|
+
from pydantic import BaseModel, ValidationError
|
|
13
|
+
from starlette.websockets import WebSocketDisconnect
|
|
14
|
+
|
|
15
|
+
from ...application_services import (
|
|
16
|
+
ApplicationServiceContract,
|
|
17
|
+
ApplicationServiceValidationError,
|
|
18
|
+
)
|
|
19
|
+
from ...application_services.invocation import _ScopedApplicationServiceInvoker
|
|
20
|
+
from ...authorization import CurrentUser, PermissionDeniedError, UnauthenticatedError
|
|
21
|
+
from ...errors import FrameworkError, ResourceConflictError, ResourceNotFoundError
|
|
22
|
+
from ...realtime import RealtimeDeliveryReport
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from .runtime import _ConnectionState, _FastApiRealtimeRuntime
|
|
26
|
+
|
|
27
|
+
_PLATFORM_PROBLEM_MESSAGE_TYPE = "platform.problem.v1"
|
|
28
|
+
_TModel = TypeVar("_TModel", bound=BaseModel)
|
|
29
|
+
_TContract = TypeVar("_TContract", bound=ApplicationServiceContract)
|
|
30
|
+
_P = ParamSpec("_P")
|
|
31
|
+
_R = TypeVar("_R")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _WebSocketCloseCode(IntEnum):
|
|
35
|
+
NORMAL = 1000
|
|
36
|
+
UNSUPPORTED_FRAME = 1003
|
|
37
|
+
POLICY_VIOLATION = 1008
|
|
38
|
+
MESSAGE_TOO_LARGE = 1009
|
|
39
|
+
INTERNAL_ERROR = 1011
|
|
40
|
+
APPLICATION_SHUTDOWN = 1012
|
|
41
|
+
TRY_AGAIN_LATER = 1013
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class WebSocketConnection:
|
|
45
|
+
"""认证连接 facade;不暴露 Dishka container 或 transport state。"""
|
|
46
|
+
|
|
47
|
+
__slots__ = ("_runtime", "_session", "_state")
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
runtime: _FastApiRealtimeRuntime,
|
|
52
|
+
state: _ConnectionState,
|
|
53
|
+
session: AsyncContainer,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._runtime = runtime
|
|
56
|
+
self._state = state
|
|
57
|
+
self._session = session
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def id(self) -> UUID:
|
|
61
|
+
return self._state.connection_id
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def current_user(self) -> CurrentUser:
|
|
65
|
+
return self._state.current_user
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def correlation_id(self) -> str:
|
|
69
|
+
return self._state.correlation_id
|
|
70
|
+
|
|
71
|
+
async def receive(self, model_type: type[_TModel]) -> _TModel:
|
|
72
|
+
"""只接受 text JSON object;无效输入在连接内连续计数。"""
|
|
73
|
+
|
|
74
|
+
if not isinstance(model_type, type) or not issubclass(model_type, BaseModel):
|
|
75
|
+
raise TypeError("model_type must be a Pydantic model class")
|
|
76
|
+
while True:
|
|
77
|
+
if self._state.closing:
|
|
78
|
+
raise WebSocketDisconnect(self._state.close_code)
|
|
79
|
+
message = await self._state.websocket.receive()
|
|
80
|
+
message_kind = message.get("type")
|
|
81
|
+
if message_kind == "websocket.disconnect":
|
|
82
|
+
self._state.peer_disconnected = True
|
|
83
|
+
raise WebSocketDisconnect(message.get("code", int(_WebSocketCloseCode.NORMAL)))
|
|
84
|
+
text = message.get("text")
|
|
85
|
+
if message_kind != "websocket.receive" or not isinstance(text, str):
|
|
86
|
+
await self._runtime._request_close(
|
|
87
|
+
self._state,
|
|
88
|
+
_WebSocketCloseCode.UNSUPPORTED_FRAME,
|
|
89
|
+
)
|
|
90
|
+
raise WebSocketDisconnect(_WebSocketCloseCode.UNSUPPORTED_FRAME)
|
|
91
|
+
try:
|
|
92
|
+
raw = json.loads(text)
|
|
93
|
+
if not isinstance(raw, dict):
|
|
94
|
+
raise ValueError
|
|
95
|
+
value = model_type.model_validate(raw)
|
|
96
|
+
except (TypeError, ValueError, ValidationError):
|
|
97
|
+
self._state.consecutive_invalid_messages += 1
|
|
98
|
+
await self._runtime._enqueue_problem(
|
|
99
|
+
self._state,
|
|
100
|
+
code="validation",
|
|
101
|
+
status=422,
|
|
102
|
+
)
|
|
103
|
+
if (
|
|
104
|
+
self._state.consecutive_invalid_messages
|
|
105
|
+
>= self._runtime.options.max_consecutive_invalid_messages
|
|
106
|
+
):
|
|
107
|
+
await self._runtime._request_close(
|
|
108
|
+
self._state,
|
|
109
|
+
_WebSocketCloseCode.POLICY_VIOLATION,
|
|
110
|
+
)
|
|
111
|
+
raise WebSocketDisconnect(_WebSocketCloseCode.POLICY_VIOLATION) from None
|
|
112
|
+
continue
|
|
113
|
+
self._state.consecutive_invalid_messages = 0
|
|
114
|
+
return value
|
|
115
|
+
|
|
116
|
+
async def invoke(
|
|
117
|
+
self,
|
|
118
|
+
method: Callable[Concatenate[_TContract, _P], Awaitable[_R]],
|
|
119
|
+
/,
|
|
120
|
+
*args: _P.args,
|
|
121
|
+
**kwargs: _P.kwargs,
|
|
122
|
+
) -> _R:
|
|
123
|
+
"""每次调用先复验身份,再从连接 SESSION 建立 REQUEST/ACTION。"""
|
|
124
|
+
|
|
125
|
+
current_user = await self._runtime._refresh_identity(self._state)
|
|
126
|
+
invoker = await self._session.get(_ScopedApplicationServiceInvoker)
|
|
127
|
+
result = await invoker.invoke(
|
|
128
|
+
self._session,
|
|
129
|
+
current_user,
|
|
130
|
+
self._state.correlation_id,
|
|
131
|
+
cast(Callable[..., Awaitable[object]], method),
|
|
132
|
+
cast(tuple[object, ...], args),
|
|
133
|
+
cast(dict[str, object], kwargs),
|
|
134
|
+
)
|
|
135
|
+
return cast(_R, result)
|
|
136
|
+
|
|
137
|
+
async def send(
|
|
138
|
+
self,
|
|
139
|
+
message_type: str,
|
|
140
|
+
payload: Mapping[str, object],
|
|
141
|
+
) -> RealtimeDeliveryReport:
|
|
142
|
+
return await self._runtime.send_to_connection(
|
|
143
|
+
self.id,
|
|
144
|
+
message_type=message_type,
|
|
145
|
+
payload=payload,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
async def send_problem(self, error: FrameworkError) -> RealtimeDeliveryReport:
|
|
149
|
+
if not isinstance(error, FrameworkError):
|
|
150
|
+
raise TypeError("error must be FrameworkError")
|
|
151
|
+
code, status = _problem_descriptor(error)
|
|
152
|
+
return await self._runtime._enqueue_problem(self._state, code=code, status=status)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _problem_descriptor(error: FrameworkError) -> tuple[str, int]:
|
|
156
|
+
if isinstance(error, ApplicationServiceValidationError):
|
|
157
|
+
return "validation", 422
|
|
158
|
+
if isinstance(error, UnauthenticatedError):
|
|
159
|
+
return "authentication", 401
|
|
160
|
+
if isinstance(error, PermissionDeniedError):
|
|
161
|
+
return "forbidden", 403
|
|
162
|
+
if isinstance(error, ResourceNotFoundError):
|
|
163
|
+
return "not_found", 404
|
|
164
|
+
if isinstance(error, ResourceConflictError):
|
|
165
|
+
return "conflict", 409
|
|
166
|
+
return "internal", 500
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""FastAPI realtime Module composition 与 lifecycle。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dishka import Provider, Scope
|
|
6
|
+
|
|
7
|
+
from ...lifecycle import ConfigureContext, InitializeContext, ShutdownContext
|
|
8
|
+
from ...modularity import AppModule
|
|
9
|
+
from ...realtime import RealtimePublisher
|
|
10
|
+
from .options import FastApiRealtimeOptions
|
|
11
|
+
from .runtime import _FastApiRealtimeRuntime, _realtime_publisher
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FastApiRealtimeModule(AppModule):
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._runtime: _FastApiRealtimeRuntime | None = None
|
|
17
|
+
|
|
18
|
+
def configure(self, context: ConfigureContext) -> None:
|
|
19
|
+
context.configure(FastApiRealtimeOptions, section="fastapi_realtime")
|
|
20
|
+
provider = Provider()
|
|
21
|
+
provider.provide(
|
|
22
|
+
_FastApiRealtimeRuntime,
|
|
23
|
+
provides=_FastApiRealtimeRuntime,
|
|
24
|
+
scope=Scope.APP,
|
|
25
|
+
)
|
|
26
|
+
provider.provide(
|
|
27
|
+
_realtime_publisher,
|
|
28
|
+
provides=RealtimePublisher,
|
|
29
|
+
scope=Scope.APP,
|
|
30
|
+
)
|
|
31
|
+
context.services.contribute(provider, reason="FastAPI managed WebSocket runtime")
|
|
32
|
+
|
|
33
|
+
async def initialize(self, context: InitializeContext) -> None:
|
|
34
|
+
runtime = await context.container.get(_FastApiRealtimeRuntime)
|
|
35
|
+
self._runtime = runtime
|
|
36
|
+
await runtime.start_accepting()
|
|
37
|
+
|
|
38
|
+
async def shutdown(self, context: ShutdownContext) -> None:
|
|
39
|
+
runtime = self._runtime
|
|
40
|
+
if runtime is not None:
|
|
41
|
+
await runtime.shutdown()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""FastAPI realtime 的 Host-owned immutable options。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
from pydantic import ConfigDict, Field, field_validator, model_validator
|
|
8
|
+
|
|
9
|
+
from ...options import BaseOptions
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FastApiRealtimeOptions(BaseOptions):
|
|
13
|
+
"""连接容量与安全边界;浏览器 Origin 必须由 Host 显式声明。"""
|
|
14
|
+
|
|
15
|
+
model_config = ConfigDict(extra="forbid")
|
|
16
|
+
|
|
17
|
+
allowed_origins: tuple[str, ...] = ()
|
|
18
|
+
authentication_refresh_seconds: int = Field(default=60, ge=1)
|
|
19
|
+
outbound_queue_capacity: int = Field(default=64, ge=1)
|
|
20
|
+
max_connections: int = Field(default=128, ge=1)
|
|
21
|
+
max_connections_per_user: int = Field(default=8, ge=1)
|
|
22
|
+
max_consecutive_invalid_messages: int = Field(default=3, ge=1)
|
|
23
|
+
shutdown_timeout_seconds: int = Field(default=10, ge=1)
|
|
24
|
+
|
|
25
|
+
@field_validator("allowed_origins")
|
|
26
|
+
@classmethod
|
|
27
|
+
def _validate_allowed_origins(cls, origins: tuple[str, ...]) -> tuple[str, ...]:
|
|
28
|
+
if len(set(origins)) != len(origins):
|
|
29
|
+
raise ValueError("allowed_origins cannot contain duplicates")
|
|
30
|
+
for origin in origins:
|
|
31
|
+
parsed = urlsplit(origin)
|
|
32
|
+
if (
|
|
33
|
+
parsed.scheme not in {"http", "https"}
|
|
34
|
+
or not parsed.hostname
|
|
35
|
+
or parsed.username is not None
|
|
36
|
+
or parsed.password is not None
|
|
37
|
+
or parsed.path
|
|
38
|
+
or parsed.query
|
|
39
|
+
or parsed.fragment
|
|
40
|
+
or origin != f"{parsed.scheme}://{parsed.netloc}"
|
|
41
|
+
or "*" in origin
|
|
42
|
+
):
|
|
43
|
+
raise ValueError("allowed_origins must contain canonical HTTP origins")
|
|
44
|
+
return origins
|
|
45
|
+
|
|
46
|
+
@model_validator(mode="after")
|
|
47
|
+
def _validate_connection_limits(self) -> FastApiRealtimeOptions:
|
|
48
|
+
if self.max_connections_per_user > self.max_connections:
|
|
49
|
+
raise ValueError("max_connections_per_user cannot exceed max_connections")
|
|
50
|
+
return self
|