python-ddd-framework 0.4.0__py3-none-any.whl → 0.6.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.
- python_ddd_framework/application/runtime.py +46 -19
- python_ddd_framework/application_services/bindings.py +0 -64
- python_ddd_framework/application_services/dispatcher.py +1 -1
- python_ddd_framework/application_services/invocation.py +7 -500
- python_ddd_framework/application_services/module.py +16 -5
- python_ddd_framework/authorization/module.py +1 -1
- python_ddd_framework/background_execution/local.py +20 -1
- python_ddd_framework/background_execution/processes.py +11 -1
- python_ddd_framework/background_jobs/execution.py +1 -1
- python_ddd_framework/background_jobs/pgqueuer/enqueue.py +1 -1
- python_ddd_framework/background_workers/execution.py +1 -1
- python_ddd_framework/background_workers/runtime.py +11 -2
- python_ddd_framework/caching/unit_of_work.py +2 -4
- python_ddd_framework/cli/project.py +11 -10
- python_ddd_framework/developer_kit/generation.py +46 -36
- python_ddd_framework/developer_kit/publication.py +442 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/README.md +42 -3
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_integration_service.py.jinja +31 -7
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_observation_handler.py.jinja +2 -2
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/order_repository.py.jinja +3 -1
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md +21 -3
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +18 -1
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +74 -3
- python_ddd_framework/diagnostics/source.py +14 -1
- python_ddd_framework/domain/aggregates.py +5 -12
- python_ddd_framework/domain/event_values.py +124 -0
- python_ddd_framework/events/runtime.py +2 -7
- python_ddd_framework/events/unit_of_work.py +1 -4
- python_ddd_framework/fastapi/action.py +2 -1
- python_ddd_framework/fastapi/adapter.py +11 -117
- python_ddd_framework/fastapi/application_services.py +17 -402
- python_ddd_framework/fastapi/error_handlers.py +128 -0
- python_ddd_framework/fastapi/realtime/runtime.py +1 -1
- python_ddd_framework/fastapi/request_context.py +2 -5
- python_ddd_framework/fastapi/routing.py +4 -10
- python_ddd_framework/fastapi/service_endpoints.py +182 -0
- python_ddd_framework/fastapi/service_routes.py +237 -0
- python_ddd_framework/fastapi/transfer.py +1 -1
- python_ddd_framework/hosted_services/bridge.py +76 -21
- python_ddd_framework/hosted_services/contracts.py +5 -1
- python_ddd_framework/hosted_services/runtime.py +71 -17
- python_ddd_framework/identity/__init__.py +21 -13
- python_ddd_framework/identity/application.py +12 -66
- python_ddd_framework/identity/contracts.py +38 -411
- python_ddd_framework/identity/domain_rules.py +17 -0
- python_ddd_framework/identity/dtos.py +152 -0
- python_ddd_framework/identity/extensions.py +9 -0
- python_ddd_framework/identity/management.py +28 -25
- python_ddd_framework/identity/models.py +86 -0
- python_ddd_framework/identity/module.py +3 -4
- python_ddd_framework/identity/options.py +54 -0
- python_ddd_framework/identity/seeding.py +59 -0
- python_ddd_framework/identity/services.py +1 -1
- python_ddd_framework/identity/sqlalchemy/module.py +9 -6
- python_ddd_framework/identity/sqlalchemy/repository.py +4 -5
- python_ddd_framework/identity/sqlalchemy/session_security.py +1 -3
- python_ddd_framework/identity/sqlalchemy/stores.py +35 -47
- python_ddd_framework/identity/stores.py +120 -0
- python_ddd_framework/identity/tokens.py +1 -1
- python_ddd_framework/invocation/callables.py +1 -1
- python_ddd_framework/invocation/dispatcher.py +1 -1
- python_ddd_framework/invocation/entrypoints.py +2 -1
- python_ddd_framework/invocation/function_runtime.py +2 -2
- python_ddd_framework/invocation/interception.py +1 -1
- python_ddd_framework/invocation/managed_proxy.py +2 -1
- python_ddd_framework/invocation/managed_services.py +8 -14
- python_ddd_framework/invocation/methods.py +2 -7
- python_ddd_framework/invocation/module.py +1 -1
- python_ddd_framework/invocation/runtime.py +483 -0
- python_ddd_framework/invocation/scopes.py +91 -0
- python_ddd_framework/lifecycle/participants.py +4 -0
- python_ddd_framework/messaging/channel.py +35 -8
- python_ddd_framework/messaging/contracts.py +9 -0
- python_ddd_framework/messaging/module.py +13 -0
- python_ddd_framework/messaging/runtime.py +17 -2
- python_ddd_framework/modularity/discovery.py +2 -7
- python_ddd_framework/modularity/graph.py +2 -8
- python_ddd_framework/observability/logging.py +1 -1
- python_ddd_framework/observability/tracing.py +1 -1
- python_ddd_framework/redis/cache.py +2 -0
- python_ddd_framework/redis/notification_runtime.py +1 -1
- python_ddd_framework/services/arbitration.py +1 -20
- python_ddd_framework/services/composition.py +71 -2
- python_ddd_framework/services/provider.py +23 -1
- python_ddd_framework/services/relocation.py +96 -0
- python_ddd_framework/settings/refresh.py +1 -1
- python_ddd_framework/settings/sqlalchemy/store.py +1 -1
- python_ddd_framework/sqlalchemy/metadata.py +11 -413
- python_ddd_framework/sqlalchemy/metadata_builder.py +234 -0
- python_ddd_framework/sqlalchemy/metadata_fingerprint.py +108 -0
- python_ddd_framework/sqlalchemy/metadata_ownership.py +73 -0
- python_ddd_framework/sqlalchemy/migration.py +2 -1
- python_ddd_framework/sqlalchemy/migration_sources.py +38 -0
- python_ddd_framework/sqlalchemy/module.py +2 -1
- python_ddd_framework/sqlalchemy/unit_of_work.py +1 -1
- python_ddd_framework/unit_of_work/contracts.py +68 -7
- python_ddd_framework/unit_of_work/manager.py +4 -7
- python_ddd_framework/unit_of_work/module.py +1 -1
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/METADATA +82 -4
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/RECORD +104 -85
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/WHEEL +1 -1
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/entry_points.txt +0 -0
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/licenses/src/python_ddd_framework/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +0 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""HTTP 错误状态映射与安全字段输出;安装和退出仍归 Adapter。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import cast, get_args
|
|
7
|
+
|
|
8
|
+
from fastapi.exceptions import RequestValidationError
|
|
9
|
+
from pydantic_core.core_schema import ErrorType
|
|
10
|
+
from starlette.requests import Request
|
|
11
|
+
from starlette.responses import JSONResponse
|
|
12
|
+
|
|
13
|
+
from ..application_services import ApplicationServiceValidationError
|
|
14
|
+
from ..errors import BusinessError, FrameworkError
|
|
15
|
+
|
|
16
|
+
# 保留已有日志类别,避免纯职责搬迁改变 Host 的日志筛选行为。
|
|
17
|
+
_LOGGER = logging.getLogger(f"{__package__}.adapter")
|
|
18
|
+
_SAFE_REQUEST_LOCATION_ROOTS = frozenset({"body", "cookie", "header", "path", "query"})
|
|
19
|
+
_TRUSTED_VALIDATION_ERROR_TYPES = frozenset(get_args(ErrorType))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def _business_error_handler(request: Request, error: Exception) -> JSONResponse:
|
|
23
|
+
business_error = cast(BusinessError, error)
|
|
24
|
+
return JSONResponse(
|
|
25
|
+
status_code=400,
|
|
26
|
+
content={
|
|
27
|
+
"error": {
|
|
28
|
+
"code": business_error.definition.code,
|
|
29
|
+
"message": business_error.definition.message,
|
|
30
|
+
"fields": [field.name for field in business_error.fields],
|
|
31
|
+
**(
|
|
32
|
+
{
|
|
33
|
+
"data": business_error.data.model_dump(
|
|
34
|
+
mode="json", by_alias=True, warnings="error"
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
if business_error.data is not None
|
|
38
|
+
else {}
|
|
39
|
+
),
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def _framework_error_handler(request: Request, error: Exception) -> JSONResponse:
|
|
46
|
+
framework_error = cast(FrameworkError, error)
|
|
47
|
+
_LOGGER.error(
|
|
48
|
+
"Unhandled FrameworkError for %s %s",
|
|
49
|
+
request.method,
|
|
50
|
+
request.url.path,
|
|
51
|
+
exc_info=(type(framework_error), framework_error, framework_error.__traceback__),
|
|
52
|
+
)
|
|
53
|
+
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def _application_service_validation_handler(
|
|
57
|
+
request: Request,
|
|
58
|
+
error: Exception,
|
|
59
|
+
) -> JSONResponse:
|
|
60
|
+
validation_error = cast(ApplicationServiceValidationError, error)
|
|
61
|
+
return JSONResponse(
|
|
62
|
+
status_code=422,
|
|
63
|
+
content={
|
|
64
|
+
"detail": [
|
|
65
|
+
{
|
|
66
|
+
"loc": list(issue.location),
|
|
67
|
+
"msg": issue.message,
|
|
68
|
+
"type": issue.error_type,
|
|
69
|
+
}
|
|
70
|
+
for issue in validation_error.issues
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _request_validation_handler(request: Request, error: Exception) -> JSONResponse:
|
|
77
|
+
validation_error = cast(RequestValidationError, error)
|
|
78
|
+
return JSONResponse(
|
|
79
|
+
status_code=422,
|
|
80
|
+
content={
|
|
81
|
+
"detail": [
|
|
82
|
+
{
|
|
83
|
+
"loc": list(_safe_request_validation_location(detail.get("loc"))),
|
|
84
|
+
"msg": "Input validation failed",
|
|
85
|
+
"type": _trusted_request_validation_error_type(detail.get("type")),
|
|
86
|
+
}
|
|
87
|
+
for detail in validation_error.errors()
|
|
88
|
+
]
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _safe_request_validation_location(location: object) -> tuple[str, ...]:
|
|
94
|
+
if (
|
|
95
|
+
isinstance(location, tuple)
|
|
96
|
+
and location
|
|
97
|
+
and isinstance(location[0], str)
|
|
98
|
+
and location[0] in _SAFE_REQUEST_LOCATION_ROOTS
|
|
99
|
+
):
|
|
100
|
+
# 后续 segment 可能来自用户控制的 mapping key;只发布 FastAPI 自有位置类别。
|
|
101
|
+
return (location[0],)
|
|
102
|
+
return ()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _trusted_request_validation_error_type(error_type: object) -> str:
|
|
106
|
+
if isinstance(error_type, str) and error_type in _TRUSTED_VALIDATION_ERROR_TYPES:
|
|
107
|
+
return error_type
|
|
108
|
+
return "value_error"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def _unauthenticated_handler(request: Request, error: Exception) -> JSONResponse:
|
|
112
|
+
return JSONResponse(
|
|
113
|
+
status_code=401,
|
|
114
|
+
content={"detail": "Not authenticated"},
|
|
115
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def _permission_denied_handler(request: Request, error: Exception) -> JSONResponse:
|
|
120
|
+
return JSONResponse(status_code=403, content={"detail": "Forbidden"})
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def _resource_conflict_handler(request: Request, error: Exception) -> JSONResponse:
|
|
124
|
+
return JSONResponse(status_code=409, content={"detail": "Resource conflict"})
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def _resource_not_found_handler(request: Request, error: Exception) -> JSONResponse:
|
|
128
|
+
return JSONResponse(status_code=404, content={"detail": "Resource not found"})
|
|
@@ -11,8 +11,8 @@ from uuid import UUID, uuid4
|
|
|
11
11
|
from dishka import AsyncContainer
|
|
12
12
|
from fastapi import WebSocket
|
|
13
13
|
|
|
14
|
-
from ...application_services.invocation import _InvocationRuntime
|
|
15
14
|
from ...authorization import AccessTokenAuthenticator, CurrentUser, UnauthenticatedError
|
|
15
|
+
from ...invocation.runtime import _InvocationRuntime
|
|
16
16
|
from ...options import Options
|
|
17
17
|
from ...realtime import (
|
|
18
18
|
RealtimeDeliveryReport,
|
|
@@ -18,15 +18,12 @@ from starlette.responses import JSONResponse
|
|
|
18
18
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
19
19
|
|
|
20
20
|
from ..application import Application
|
|
21
|
-
from ..application_services.invocation import (
|
|
22
|
-
_close_action,
|
|
23
|
-
_InvocationRuntime,
|
|
24
|
-
_raise_scope_failure,
|
|
25
|
-
)
|
|
26
21
|
from ..auditing import AuditSink, RequestAuditRecord
|
|
27
22
|
from ..authorization import AccessTokenAuthenticator, CurrentUser, UnauthenticatedError
|
|
28
23
|
from ..invocation.contracts import _is_safe_correlation_id
|
|
29
24
|
from ..invocation.entrypoints import http_request_lease
|
|
25
|
+
from ..invocation.runtime import _InvocationRuntime
|
|
26
|
+
from ..invocation.scopes import _close_action, _raise_scope_failure
|
|
30
27
|
from ..unit_of_work import UnitOfWorkManager
|
|
31
28
|
from .errors import FastApiAdapterError
|
|
32
29
|
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import inspect
|
|
6
5
|
import re
|
|
7
6
|
from collections.abc import Mapping, Sequence
|
|
8
7
|
from dataclasses import dataclass
|
|
@@ -17,11 +16,9 @@ from starlette.routing import BaseRoute, WebSocketRoute
|
|
|
17
16
|
from ..application import Application
|
|
18
17
|
from ..authorization.module import AuthorizationModule
|
|
19
18
|
from ..diagnostics import SourceLocation
|
|
19
|
+
from ..diagnostics.source import _source_file_line
|
|
20
20
|
from ..modularity import ModuleKey
|
|
21
|
-
from .application_services import
|
|
22
|
-
_ROUTE_SOURCE_ATTRIBUTE,
|
|
23
|
-
_application_service_routers,
|
|
24
|
-
)
|
|
21
|
+
from .application_services import _application_service_routers
|
|
25
22
|
from .contracts import (
|
|
26
23
|
RouterCatalog,
|
|
27
24
|
RouterRegistration,
|
|
@@ -33,6 +30,7 @@ from .health import _create_health_router
|
|
|
33
30
|
from .http_router import HttpRouter
|
|
34
31
|
from .manual_action import _ActionRoute
|
|
35
32
|
from .realtime import websocket_connection
|
|
33
|
+
from .service_endpoints import _ROUTE_SOURCE_ATTRIBUTE
|
|
36
34
|
|
|
37
35
|
_PATH_PARAMETER = re.compile(r"\{[^{}:]+(?P<converter>:[^{}]+)?\}")
|
|
38
36
|
|
|
@@ -335,11 +333,7 @@ def _source_for_context(context: RouteContext, owner_label: str) -> SourceLocati
|
|
|
335
333
|
if isinstance(declared_source, SourceLocation):
|
|
336
334
|
return declared_source
|
|
337
335
|
symbol = getattr(endpoint, "__qualname__", getattr(endpoint, "__name__", None))
|
|
338
|
-
|
|
339
|
-
file_name = inspect.getsourcefile(endpoint)
|
|
340
|
-
_, line = inspect.getsourcelines(endpoint)
|
|
341
|
-
except (OSError, TypeError):
|
|
342
|
-
pass
|
|
336
|
+
file_name, line = _source_file_line(endpoint)
|
|
343
337
|
return SourceLocation(owner=owner_label, file=file_name, line=line, symbol=symbol)
|
|
344
338
|
|
|
345
339
|
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""约定服务 endpoint 的请求执行、原生绑定与 ACTION 生命周期接入。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Awaitable, Callable, Coroutine
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
from dishka import AsyncContainer
|
|
11
|
+
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
|
12
|
+
from starlette.requests import Request
|
|
13
|
+
from starlette.responses import Response
|
|
14
|
+
|
|
15
|
+
from ..application_services.catalog import _CompiledApplicationService
|
|
16
|
+
from ..application_services.dispatcher import _ApplicationServiceDispatcher
|
|
17
|
+
from ..diagnostics import SourceLocation
|
|
18
|
+
from ..invocation.methods import _CompiledMethod
|
|
19
|
+
from .action import _HttpActionOwner, _request_parameter
|
|
20
|
+
from .errors import FastApiAdapterError
|
|
21
|
+
from .filters import _filters_for, _ordered_filters
|
|
22
|
+
from .parameters import _HttpParameterValidator
|
|
23
|
+
from .service_routes import _RouteMethod
|
|
24
|
+
from .transfer import _manage_stream
|
|
25
|
+
from .uploads import _prepare_upload
|
|
26
|
+
|
|
27
|
+
_ROUTE_SOURCE_ATTRIBUTE = "__python_ddd_framework_fastapi_route_source__"
|
|
28
|
+
_INVOCATION_IDENTITY_ATTRIBUTE = "__python_ddd_framework_fastapi_invocation_identity__"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class _ApplicationServiceRouteIdentity:
|
|
33
|
+
service: _CompiledApplicationService
|
|
34
|
+
method: _CompiledMethod
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _ApplicationServiceRoute(DishkaRoute):
|
|
38
|
+
"""在 native FastAPI binding 前建立 catalog-identity invocation boundary。"""
|
|
39
|
+
|
|
40
|
+
__slots__ = ("_compiled_method", "_compiled_service")
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
path: str,
|
|
45
|
+
endpoint: Callable[..., Any],
|
|
46
|
+
**kwargs: Any,
|
|
47
|
+
) -> None:
|
|
48
|
+
identity = getattr(endpoint, _INVOCATION_IDENTITY_ATTRIBUTE, None)
|
|
49
|
+
if not isinstance(identity, _ApplicationServiceRouteIdentity):
|
|
50
|
+
raise FastApiAdapterError("generated Application Service route lost its identity")
|
|
51
|
+
self._compiled_service = identity.service
|
|
52
|
+
self._compiled_method = identity.method
|
|
53
|
+
sources = getattr(endpoint, "__python_ddd_framework_http_sources__", None)
|
|
54
|
+
super().__init__(path, endpoint, **kwargs)
|
|
55
|
+
cast(Any, self.endpoint).__python_ddd_framework_http_sources__ = sources
|
|
56
|
+
# include_router 会以已注入 endpoint 重建 route;显式把 identity 传递给下一次构造。
|
|
57
|
+
setattr(self.endpoint, _INVOCATION_IDENTITY_ATTRIBUTE, identity)
|
|
58
|
+
|
|
59
|
+
def get_route_handler(
|
|
60
|
+
self,
|
|
61
|
+
) -> Callable[[Request], Coroutine[Any, Any, Response]]:
|
|
62
|
+
native_handler = super().get_route_handler()
|
|
63
|
+
service = self._compiled_service
|
|
64
|
+
method = self._compiled_method
|
|
65
|
+
|
|
66
|
+
async def route_handler(request: Request) -> Response:
|
|
67
|
+
owner = await _HttpActionOwner.install(request)
|
|
68
|
+
container = cast(AsyncContainer, request.state.dishka_container)
|
|
69
|
+
dispatcher = await container.get(_ApplicationServiceDispatcher)
|
|
70
|
+
|
|
71
|
+
async def bind() -> Response:
|
|
72
|
+
await _prepare_upload(request, method.implementation_method, self)
|
|
73
|
+
return await native_handler(request)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
result = await dispatcher.invoke_http_request(
|
|
77
|
+
service,
|
|
78
|
+
method,
|
|
79
|
+
bind,
|
|
80
|
+
is_short_circuited=lambda: owner.short_circuited,
|
|
81
|
+
defer_audit=owner.audits.append,
|
|
82
|
+
)
|
|
83
|
+
owner.response_ready = isinstance(result, Response)
|
|
84
|
+
except BaseException as error:
|
|
85
|
+
owner.failure = error
|
|
86
|
+
raise
|
|
87
|
+
if not isinstance(result, Response):
|
|
88
|
+
raise FastApiAdapterError(
|
|
89
|
+
"an interceptor outside HTTP binding must return a native Response"
|
|
90
|
+
)
|
|
91
|
+
_manage_stream(request, result)
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
return route_handler
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _create_endpoint(
|
|
98
|
+
service: _CompiledApplicationService,
|
|
99
|
+
method: _CompiledMethod,
|
|
100
|
+
route_method: _RouteMethod,
|
|
101
|
+
source: SourceLocation,
|
|
102
|
+
) -> Callable[..., Awaitable[object]]:
|
|
103
|
+
"""保留 Protocol 原签名供 FastAPI binding,并注入同一 REQUEST dispatcher。"""
|
|
104
|
+
|
|
105
|
+
contract = service.registration.contract
|
|
106
|
+
method_name = method.name
|
|
107
|
+
parameter_names = {parameter.name for parameter in route_method.parameters}
|
|
108
|
+
dispatcher_parameter_name = "_python_ddd_framework_application_service_dispatcher"
|
|
109
|
+
while dispatcher_parameter_name in parameter_names:
|
|
110
|
+
dispatcher_parameter_name = f"_{dispatcher_parameter_name}"
|
|
111
|
+
|
|
112
|
+
declared_filters = _ordered_filters(
|
|
113
|
+
_filters_for(*reversed(service.registration.implementation.__mro__)),
|
|
114
|
+
_filters_for(method.implementation_method),
|
|
115
|
+
)
|
|
116
|
+
validator = _HttpParameterValidator(
|
|
117
|
+
route_method.signature.replace(parameters=route_method.parameters)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
async def endpoint(**values: object) -> object:
|
|
121
|
+
dispatcher = cast(_ApplicationServiceDispatcher, values.pop(dispatcher_parameter_name))
|
|
122
|
+
request = cast(
|
|
123
|
+
Request,
|
|
124
|
+
values.pop(request_parameter_name)
|
|
125
|
+
if injected_request
|
|
126
|
+
else values[request_parameter_name],
|
|
127
|
+
)
|
|
128
|
+
owner: _HttpActionOwner = request.state.python_ddd_framework_action
|
|
129
|
+
await owner.activate(
|
|
130
|
+
method.descriptor.unit_of_work, method.name, service.registration.connection_name
|
|
131
|
+
)
|
|
132
|
+
filters = _ordered_filters(
|
|
133
|
+
getattr(request.app.state, "python_ddd_framework_filters", ()), declared_filters
|
|
134
|
+
)
|
|
135
|
+
snapshots = validator.capture(values)
|
|
136
|
+
|
|
137
|
+
async def invoke(actual: dict[str, object]) -> object:
|
|
138
|
+
if filters:
|
|
139
|
+
validator.validate_changes(actual, snapshots)
|
|
140
|
+
return await dispatcher.invoke_http(service, method, actual)
|
|
141
|
+
|
|
142
|
+
return await owner.invoke(
|
|
143
|
+
method.implementation_method,
|
|
144
|
+
values,
|
|
145
|
+
filters,
|
|
146
|
+
invoke,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
request_parameter, injected_request = _request_parameter(route_method.parameters)
|
|
150
|
+
request_parameter_name = request_parameter.name
|
|
151
|
+
http_parameters = (
|
|
152
|
+
(*route_method.parameters, request_parameter)
|
|
153
|
+
if injected_request
|
|
154
|
+
else route_method.parameters
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
dispatcher_parameter = inspect.Parameter(
|
|
158
|
+
dispatcher_parameter_name,
|
|
159
|
+
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
160
|
+
annotation=cast(Any, FromDishka)[_ApplicationServiceDispatcher],
|
|
161
|
+
)
|
|
162
|
+
# 动态 endpoint 的 signature/annotations 同时发布,兼容 FastAPI 与 postponed annotations。
|
|
163
|
+
signature = route_method.signature.replace(parameters=(*http_parameters, dispatcher_parameter))
|
|
164
|
+
endpoint_name = f"{contract.__name__}_{method_name}"
|
|
165
|
+
endpoint.__name__ = endpoint_name
|
|
166
|
+
endpoint.__qualname__ = endpoint_name
|
|
167
|
+
endpoint.__module__ = contract.__module__
|
|
168
|
+
cast(Any, endpoint).__signature__ = signature
|
|
169
|
+
endpoint.__annotations__ = {
|
|
170
|
+
**{
|
|
171
|
+
parameter.name: parameter.annotation
|
|
172
|
+
for parameter in (*http_parameters, dispatcher_parameter)
|
|
173
|
+
},
|
|
174
|
+
"return": signature.return_annotation,
|
|
175
|
+
}
|
|
176
|
+
setattr(endpoint, _ROUTE_SOURCE_ATTRIBUTE, source)
|
|
177
|
+
setattr(
|
|
178
|
+
endpoint,
|
|
179
|
+
_INVOCATION_IDENTITY_ATTRIBUTE,
|
|
180
|
+
_ApplicationServiceRouteIdentity(service, method),
|
|
181
|
+
)
|
|
182
|
+
return endpoint
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""约定服务的名称、路径和 HTTP 签名编译;不处理请求生命周期。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import re
|
|
7
|
+
import types
|
|
8
|
+
from dataclasses import dataclass, is_dataclass
|
|
9
|
+
from datetime import date, datetime, time
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Annotated, Any, Literal, Union, get_args, get_origin
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
from dishka.integrations.base import default_parse_dependency
|
|
16
|
+
from fastapi.params import Body, Param, Path, Query
|
|
17
|
+
from fastapi.params import Depends as DependsParameter
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
from pydantic.fields import FieldInfo
|
|
20
|
+
|
|
21
|
+
from .contracts import ApplicationServiceRouteOverride
|
|
22
|
+
from .errors import FastApiAdapterError
|
|
23
|
+
from .http import _metadata
|
|
24
|
+
|
|
25
|
+
_APPLICATION_SERVICE_SUFFIX = "ApplicationService"
|
|
26
|
+
_PATH_PARAMETER = re.compile(r"\{(?P<name>[^{}:]+)(?::[^{}]+)?\}")
|
|
27
|
+
_WORD_BOUNDARY_1 = re.compile(r"(.)([A-Z][a-z]+)")
|
|
28
|
+
_WORD_BOUNDARY_2 = re.compile(r"([a-z0-9])([A-Z])")
|
|
29
|
+
_VERB_PREFIXES: tuple[tuple[str, str], ...] = (
|
|
30
|
+
("get_list", "GET"),
|
|
31
|
+
("get_all", "GET"),
|
|
32
|
+
("get", "GET"),
|
|
33
|
+
("create", "POST"),
|
|
34
|
+
("add", "POST"),
|
|
35
|
+
("insert", "POST"),
|
|
36
|
+
("post", "POST"),
|
|
37
|
+
("update", "PUT"),
|
|
38
|
+
("put", "PUT"),
|
|
39
|
+
("delete", "DELETE"),
|
|
40
|
+
("remove", "DELETE"),
|
|
41
|
+
("patch", "PATCH"),
|
|
42
|
+
)
|
|
43
|
+
_SCALAR_TYPES = (str, int, float, bool, UUID, Decimal, date, datetime, time)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class _RouteMethod:
|
|
48
|
+
signature: inspect.Signature
|
|
49
|
+
parameters: tuple[inspect.Parameter, ...]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _inspect_route_method(
|
|
53
|
+
contract: type[object],
|
|
54
|
+
method_name: str,
|
|
55
|
+
signature: inspect.Signature,
|
|
56
|
+
) -> _RouteMethod:
|
|
57
|
+
"""在真实 FastAPI app 变更前解析并收窄动态 typing boundary。"""
|
|
58
|
+
|
|
59
|
+
# 共享方法编译已解释本 Application 的扩展 DTO;HTTP 不另建字段权威。
|
|
60
|
+
resolved_parameters = tuple(signature.parameters.values())
|
|
61
|
+
parameters = resolved_parameters[1:]
|
|
62
|
+
for parameter in parameters:
|
|
63
|
+
if parameter.kind in (
|
|
64
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
65
|
+
inspect.Parameter.VAR_POSITIONAL,
|
|
66
|
+
inspect.Parameter.VAR_KEYWORD,
|
|
67
|
+
):
|
|
68
|
+
raise FastApiAdapterError(
|
|
69
|
+
f"HTTP method {contract.__module__}.{contract.__qualname__}.{method_name} "
|
|
70
|
+
"cannot use positional-only, *args, or **kwargs parameters"
|
|
71
|
+
)
|
|
72
|
+
if parameter.annotation is inspect.Signature.empty:
|
|
73
|
+
raise FastApiAdapterError(
|
|
74
|
+
f"HTTP method {contract.__module__}.{contract.__qualname__}.{method_name} "
|
|
75
|
+
f"parameter {parameter.name!r} requires a type annotation"
|
|
76
|
+
)
|
|
77
|
+
if signature.return_annotation is inspect.Signature.empty:
|
|
78
|
+
raise FastApiAdapterError(
|
|
79
|
+
f"HTTP method {contract.__module__}.{contract.__qualname__}.{method_name} "
|
|
80
|
+
"requires a return type annotation"
|
|
81
|
+
)
|
|
82
|
+
return _RouteMethod(signature=signature, parameters=parameters)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _query_model_parameter(parameter: inspect.Parameter, verb: str) -> inspect.Parameter:
|
|
86
|
+
hint = parameter.annotation
|
|
87
|
+
metadata = get_args(hint)[1:] if get_origin(hint) is Annotated else ()
|
|
88
|
+
model = get_args(hint)[0] if metadata else hint
|
|
89
|
+
if (
|
|
90
|
+
verb == "GET"
|
|
91
|
+
and isinstance(model, type)
|
|
92
|
+
and issubclass(model, BaseModel)
|
|
93
|
+
and not any(
|
|
94
|
+
isinstance(value, (FieldInfo, DependsParameter))
|
|
95
|
+
for value in (*metadata, parameter.default)
|
|
96
|
+
)
|
|
97
|
+
and default_parse_dependency(parameter, hint) is None
|
|
98
|
+
):
|
|
99
|
+
# 仅补原生 Query 元数据;字段展开、校验和 OpenAPI 均交给 FastAPI/Pydantic。
|
|
100
|
+
return parameter.replace(annotation=Annotated[hint, Query()])
|
|
101
|
+
return parameter
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _uses_non_path_binding(parameter: inspect.Parameter) -> bool:
|
|
105
|
+
metadata = (
|
|
106
|
+
get_args(parameter.annotation)[1:] if get_origin(parameter.annotation) is Annotated else ()
|
|
107
|
+
)
|
|
108
|
+
return any(
|
|
109
|
+
isinstance(item, (Param, Body)) and not isinstance(item, Path)
|
|
110
|
+
for item in (*metadata, parameter.default)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _route_path(
|
|
115
|
+
service_path: str,
|
|
116
|
+
parameters: tuple[inspect.Parameter, ...],
|
|
117
|
+
override_path: str | None,
|
|
118
|
+
*,
|
|
119
|
+
contract: type[object],
|
|
120
|
+
method_name: str,
|
|
121
|
+
) -> str:
|
|
122
|
+
# 只有唯一 required scalar id 才能无歧义进入 convention path;
|
|
123
|
+
# 其余情况保持 query 或要求 override。
|
|
124
|
+
id_candidates = tuple(
|
|
125
|
+
parameter
|
|
126
|
+
for parameter in parameters
|
|
127
|
+
if _is_required_path_parameter(parameter)
|
|
128
|
+
and (parameter.name == "id" or parameter.name.endswith("_id"))
|
|
129
|
+
and _is_scalar_annotation(parameter.annotation)
|
|
130
|
+
and not _uses_non_path_binding(parameter)
|
|
131
|
+
)
|
|
132
|
+
if override_path is None:
|
|
133
|
+
if len(id_candidates) > 1:
|
|
134
|
+
raise FastApiAdapterError(
|
|
135
|
+
f"HTTP method {contract.__module__}.{contract.__qualname__}.{method_name} "
|
|
136
|
+
"has ambiguous required id parameters and requires an explicit path override"
|
|
137
|
+
)
|
|
138
|
+
path = service_path
|
|
139
|
+
if len(id_candidates) == 1:
|
|
140
|
+
path = f"{path}/{{{id_candidates[0].name}}}"
|
|
141
|
+
# 只有集中定义的 CRUD 名称省略动作;审批/取消等领域动作保持其 contract 名称。
|
|
142
|
+
if method_name not in {prefix for prefix, _ in _VERB_PREFIXES}:
|
|
143
|
+
path = f"{path}/{method_name.replace('_', '-')}"
|
|
144
|
+
return path
|
|
145
|
+
|
|
146
|
+
if "?" in override_path or "#" in override_path or "//" in override_path:
|
|
147
|
+
raise FastApiAdapterError("route override path cannot contain query or fragment syntax")
|
|
148
|
+
rendered_parameters = tuple(
|
|
149
|
+
match.group("name") for match in _PATH_PARAMETER.finditer(override_path)
|
|
150
|
+
)
|
|
151
|
+
if override_path.count("{") != len(rendered_parameters) or override_path.count("}") != len(
|
|
152
|
+
rendered_parameters
|
|
153
|
+
):
|
|
154
|
+
raise FastApiAdapterError(f"route override path {override_path!r} is malformed")
|
|
155
|
+
parameters_by_name = {parameter.name: parameter for parameter in parameters}
|
|
156
|
+
for name in rendered_parameters:
|
|
157
|
+
parameter = parameters_by_name.get(name)
|
|
158
|
+
if (
|
|
159
|
+
parameter is None
|
|
160
|
+
or not _is_required_path_parameter(parameter)
|
|
161
|
+
or not _is_scalar_annotation(parameter.annotation)
|
|
162
|
+
or _uses_non_path_binding(parameter)
|
|
163
|
+
):
|
|
164
|
+
raise FastApiAdapterError(
|
|
165
|
+
f"route override path parameter {name!r} must name a required scalar "
|
|
166
|
+
f"Protocol parameter on {contract.__qualname__}.{method_name}"
|
|
167
|
+
)
|
|
168
|
+
if len(set(rendered_parameters)) != len(rendered_parameters):
|
|
169
|
+
raise FastApiAdapterError(f"route override path {override_path!r} repeats a path parameter")
|
|
170
|
+
return override_path
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _is_required_path_parameter(parameter: inspect.Parameter) -> bool:
|
|
174
|
+
return parameter.default is inspect.Signature.empty or isinstance(parameter.default, Path)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _is_scalar_annotation(annotation: object) -> bool:
|
|
178
|
+
current = annotation
|
|
179
|
+
if get_origin(current) is Annotated:
|
|
180
|
+
current = get_args(current)[0]
|
|
181
|
+
origin = get_origin(current)
|
|
182
|
+
if origin in (types.UnionType, Union):
|
|
183
|
+
members = tuple(item for item in get_args(current) if item is not type(None))
|
|
184
|
+
return len(members) == 1 and _is_scalar_annotation(members[0])
|
|
185
|
+
if origin is Literal:
|
|
186
|
+
return all(isinstance(item, (str, int, float, bool)) for item in get_args(current))
|
|
187
|
+
if not isinstance(current, type):
|
|
188
|
+
return False
|
|
189
|
+
if issubclass(current, BaseModel) or is_dataclass(current):
|
|
190
|
+
return False
|
|
191
|
+
return issubclass(current, (*_SCALAR_TYPES, Enum))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _service_path(root_path: str, contract: type[object], segment: str | None = None) -> str:
|
|
195
|
+
service_name = contract.__name__
|
|
196
|
+
if service_name.endswith(_APPLICATION_SERVICE_SUFFIX):
|
|
197
|
+
service_name = service_name[: -len(_APPLICATION_SERVICE_SUFFIX)]
|
|
198
|
+
if not service_name:
|
|
199
|
+
raise FastApiAdapterError("application service contract has no routable name")
|
|
200
|
+
return "/" + "/".join(
|
|
201
|
+
part for part in (root_path, segment or _kebab_case(service_name)) if part
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _operation_id(contract: type[object], method_name: str) -> str:
|
|
206
|
+
service_path = _service_path("", contract).rsplit("/", maxsplit=1)[-1]
|
|
207
|
+
return f"{service_path.replace('-', '_')}_{method_name}"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _kebab_case(value: str) -> str:
|
|
211
|
+
first_pass = _WORD_BOUNDARY_1.sub(r"\1-\2", value)
|
|
212
|
+
return _WORD_BOUNDARY_2.sub(r"\1-\2", first_pass).lower().replace("_", "-")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _conventional_verb(method_name: str) -> str:
|
|
216
|
+
for prefix, verb in _VERB_PREFIXES:
|
|
217
|
+
if method_name == prefix or method_name.startswith(f"{prefix}_"):
|
|
218
|
+
return verb
|
|
219
|
+
return "POST"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _route_projection(
|
|
223
|
+
method: object, override: ApplicationServiceRouteOverride | None
|
|
224
|
+
) -> tuple[dict[str, Any], dict[str, str]]:
|
|
225
|
+
projection = dict(_metadata(method))
|
|
226
|
+
provenance = {key: "method" for key in projection}
|
|
227
|
+
if override is not None:
|
|
228
|
+
from dataclasses import fields
|
|
229
|
+
|
|
230
|
+
for field in fields(override):
|
|
231
|
+
if field.name in ("service", "method", "enabled"):
|
|
232
|
+
continue
|
|
233
|
+
value = getattr(override, field.name)
|
|
234
|
+
if value is not None:
|
|
235
|
+
projection[field.name] = value
|
|
236
|
+
provenance[field.name] = "module_override"
|
|
237
|
+
return projection, provenance
|
|
@@ -14,7 +14,7 @@ from starlette.requests import ClientDisconnect, Request
|
|
|
14
14
|
from starlette.responses import StreamingResponse
|
|
15
15
|
from starlette.types import Send
|
|
16
16
|
|
|
17
|
-
from ..
|
|
17
|
+
from ..invocation.runtime import _InvocationRuntime
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
async def _close_form(form: FormData) -> None:
|