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
|
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|
|
5
5
|
from collections import deque
|
|
6
6
|
from contextvars import ContextVar
|
|
7
7
|
from dataclasses import dataclass, field
|
|
8
|
-
from typing import Protocol
|
|
9
8
|
from uuid import UUID, uuid4
|
|
10
9
|
|
|
11
10
|
from dishka import AsyncContainer, Scope
|
|
@@ -29,10 +28,6 @@ class _EventEnvelope:
|
|
|
29
28
|
handled: set[object] = field(default_factory=set)
|
|
30
29
|
|
|
31
30
|
|
|
32
|
-
class _EventHandler(Protocol):
|
|
33
|
-
async def handle(self, event: object) -> None: ...
|
|
34
|
-
|
|
35
|
-
|
|
36
31
|
class _LocalEventQueue:
|
|
37
32
|
"""队列只归一个具体 UoW;handler resolution 仍使用 Dishka 原生 scope。"""
|
|
38
33
|
|
|
@@ -76,7 +71,7 @@ class _LocalEventQueue:
|
|
|
76
71
|
)
|
|
77
72
|
assert request is not None
|
|
78
73
|
# 每个 UoW 的 handler scope 独立;手动 UoW 的原调用 ACTION 可能已返回。
|
|
79
|
-
from ..
|
|
74
|
+
from ..invocation.scopes import _managed_action
|
|
80
75
|
|
|
81
76
|
async with _managed_action(request, self._context, committed=lambda: False) as action:
|
|
82
77
|
await self._drain_domain(action)
|
|
@@ -176,8 +171,8 @@ class _LocalEventQueue:
|
|
|
176
171
|
previous = self._current
|
|
177
172
|
self._current = envelope
|
|
178
173
|
try:
|
|
179
|
-
from ..application_services.invocation import _InvocationRuntime
|
|
180
174
|
from ..invocation.interception import _EntryInterceptionCatalog
|
|
175
|
+
from ..invocation.runtime import _InvocationRuntime
|
|
181
176
|
|
|
182
177
|
entries = await container.get(_EntryInterceptionCatalog)
|
|
183
178
|
runtime = await container.get(_InvocationRuntime)
|
|
@@ -19,7 +19,4 @@ class _LocalEventLifecycleFactory:
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def _local_event_queue(work: UnitOfWork) -> _LocalEventQueue:
|
|
22
|
-
|
|
23
|
-
if isinstance(lifecycle, _LocalEventQueue):
|
|
24
|
-
return lifecycle
|
|
25
|
-
raise RuntimeError("Local event lifecycle is not configured for this UnitOfWork")
|
|
22
|
+
return work._lifecycle(_LocalEventQueue)
|
|
@@ -14,8 +14,9 @@ from anyio import CancelScope
|
|
|
14
14
|
from dishka import AsyncContainer
|
|
15
15
|
from starlette.requests import Request
|
|
16
16
|
|
|
17
|
-
from ..application_services.invocation import _InvocationRuntime, _InvocationState, _managed_action
|
|
18
17
|
from ..invocation import ApplicationInvocationContext
|
|
18
|
+
from ..invocation.runtime import _InvocationRuntime, _InvocationState
|
|
19
|
+
from ..invocation.scopes import _managed_action
|
|
19
20
|
from ..unit_of_work import UnitOfWork, UnitOfWorkManager
|
|
20
21
|
from ..unit_of_work.options import _DEFAULT_CONNECTION_NAME, UnitOfWorkDeclaration
|
|
21
22
|
from .errors import FastApiAdapterError
|
|
@@ -2,19 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import logging
|
|
6
5
|
from collections.abc import AsyncIterator, Iterator, Sequence
|
|
7
6
|
from contextlib import asynccontextmanager, contextmanager
|
|
8
|
-
from typing import Any
|
|
7
|
+
from typing import Any
|
|
9
8
|
|
|
10
9
|
from dishka.exceptions import NoFactoryError
|
|
11
10
|
from fastapi import FastAPI
|
|
12
11
|
from fastapi.exception_handlers import request_validation_exception_handler
|
|
13
12
|
from fastapi.exceptions import RequestValidationError
|
|
14
|
-
from pydantic_core.core_schema import ErrorType
|
|
15
13
|
from starlette.middleware import Middleware
|
|
16
|
-
from starlette.requests import Request
|
|
17
|
-
from starlette.responses import JSONResponse
|
|
18
14
|
|
|
19
15
|
from ..application import Application
|
|
20
16
|
from ..application_services import ApplicationServiceValidationError
|
|
@@ -22,6 +18,16 @@ from ..authorization import PermissionDeniedError, UnauthenticatedError
|
|
|
22
18
|
from ..errors import BusinessError, FrameworkError, ResourceConflictError, ResourceNotFoundError
|
|
23
19
|
from ..lifecycle import ApplicationState
|
|
24
20
|
from .contracts import RouterCatalog
|
|
21
|
+
from .error_handlers import (
|
|
22
|
+
_application_service_validation_handler,
|
|
23
|
+
_business_error_handler,
|
|
24
|
+
_framework_error_handler,
|
|
25
|
+
_permission_denied_handler,
|
|
26
|
+
_request_validation_handler,
|
|
27
|
+
_resource_conflict_handler,
|
|
28
|
+
_resource_not_found_handler,
|
|
29
|
+
_unauthenticated_handler,
|
|
30
|
+
)
|
|
25
31
|
from .errors import FastApiAdapterError
|
|
26
32
|
from .filters import ServiceFilter, _validate_filters
|
|
27
33
|
from .middleware import DiMiddleware, _compile_middlewares
|
|
@@ -41,11 +47,8 @@ from .routing import (
|
|
|
41
47
|
from .tracing import _install_tracing, _tracing_rollback
|
|
42
48
|
from .upload_limits import UploadLimits
|
|
43
49
|
|
|
44
|
-
_LOGGER = logging.getLogger(__name__)
|
|
45
50
|
_INSTALLATION_MARKER = "_python_ddd_framework_fastapi_installed"
|
|
46
51
|
_DISHKA_CONTAINER_BRIDGE = "dishka_container"
|
|
47
|
-
_SAFE_REQUEST_LOCATION_ROOTS = frozenset({"body", "cookie", "header", "path", "query"})
|
|
48
|
-
_TRUSTED_VALIDATION_ERROR_TYPES = frozenset(get_args(ErrorType))
|
|
49
52
|
|
|
50
53
|
|
|
51
54
|
class FastApiAdapter:
|
|
@@ -296,112 +299,3 @@ def _installation_state(app: FastAPI) -> Iterator[None]:
|
|
|
296
299
|
namespace.clear()
|
|
297
300
|
namespace.update(snapshot)
|
|
298
301
|
raise
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
async def _business_error_handler(request: Request, error: Exception) -> JSONResponse:
|
|
302
|
-
business_error = cast(BusinessError, error)
|
|
303
|
-
return JSONResponse(
|
|
304
|
-
status_code=400,
|
|
305
|
-
content={
|
|
306
|
-
"error": {
|
|
307
|
-
"code": business_error.definition.code,
|
|
308
|
-
"message": business_error.definition.message,
|
|
309
|
-
"fields": [field.name for field in business_error.fields],
|
|
310
|
-
**(
|
|
311
|
-
{
|
|
312
|
-
"data": business_error.data.model_dump(
|
|
313
|
-
mode="json", by_alias=True, warnings="error"
|
|
314
|
-
)
|
|
315
|
-
}
|
|
316
|
-
if business_error.data is not None
|
|
317
|
-
else {}
|
|
318
|
-
),
|
|
319
|
-
}
|
|
320
|
-
},
|
|
321
|
-
)
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
async def _framework_error_handler(request: Request, error: Exception) -> JSONResponse:
|
|
325
|
-
framework_error = cast(FrameworkError, error)
|
|
326
|
-
_LOGGER.error(
|
|
327
|
-
"Unhandled FrameworkError for %s %s",
|
|
328
|
-
request.method,
|
|
329
|
-
request.url.path,
|
|
330
|
-
exc_info=(type(framework_error), framework_error, framework_error.__traceback__),
|
|
331
|
-
)
|
|
332
|
-
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
async def _application_service_validation_handler(
|
|
336
|
-
request: Request,
|
|
337
|
-
error: Exception,
|
|
338
|
-
) -> JSONResponse:
|
|
339
|
-
validation_error = cast(ApplicationServiceValidationError, error)
|
|
340
|
-
return JSONResponse(
|
|
341
|
-
status_code=422,
|
|
342
|
-
content={
|
|
343
|
-
"detail": [
|
|
344
|
-
{
|
|
345
|
-
"loc": list(issue.location),
|
|
346
|
-
"msg": issue.message,
|
|
347
|
-
"type": issue.error_type,
|
|
348
|
-
}
|
|
349
|
-
for issue in validation_error.issues
|
|
350
|
-
]
|
|
351
|
-
},
|
|
352
|
-
)
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
async def _request_validation_handler(request: Request, error: Exception) -> JSONResponse:
|
|
356
|
-
validation_error = cast(RequestValidationError, error)
|
|
357
|
-
return JSONResponse(
|
|
358
|
-
status_code=422,
|
|
359
|
-
content={
|
|
360
|
-
"detail": [
|
|
361
|
-
{
|
|
362
|
-
"loc": list(_safe_request_validation_location(detail.get("loc"))),
|
|
363
|
-
"msg": "Input validation failed",
|
|
364
|
-
"type": _trusted_request_validation_error_type(detail.get("type")),
|
|
365
|
-
}
|
|
366
|
-
for detail in validation_error.errors()
|
|
367
|
-
]
|
|
368
|
-
},
|
|
369
|
-
)
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
def _safe_request_validation_location(location: object) -> tuple[str, ...]:
|
|
373
|
-
if (
|
|
374
|
-
isinstance(location, tuple)
|
|
375
|
-
and location
|
|
376
|
-
and isinstance(location[0], str)
|
|
377
|
-
and location[0] in _SAFE_REQUEST_LOCATION_ROOTS
|
|
378
|
-
):
|
|
379
|
-
# 后续 segment 可能来自用户控制的 mapping key;只发布 FastAPI 自有位置类别。
|
|
380
|
-
return (location[0],)
|
|
381
|
-
return ()
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
def _trusted_request_validation_error_type(error_type: object) -> str:
|
|
385
|
-
if isinstance(error_type, str) and error_type in _TRUSTED_VALIDATION_ERROR_TYPES:
|
|
386
|
-
return error_type
|
|
387
|
-
return "value_error"
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
async def _unauthenticated_handler(request: Request, error: Exception) -> JSONResponse:
|
|
391
|
-
return JSONResponse(
|
|
392
|
-
status_code=401,
|
|
393
|
-
content={"detail": "Not authenticated"},
|
|
394
|
-
headers={"WWW-Authenticate": "Bearer"},
|
|
395
|
-
)
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
async def _permission_denied_handler(request: Request, error: Exception) -> JSONResponse:
|
|
399
|
-
return JSONResponse(status_code=403, content={"detail": "Forbidden"})
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
async def _resource_conflict_handler(request: Request, error: Exception) -> JSONResponse:
|
|
403
|
-
return JSONResponse(status_code=409, content={"detail": "Resource conflict"})
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
async def _resource_not_found_handler(request: Request, error: Exception) -> JSONResponse:
|
|
407
|
-
return JSONResponse(status_code=404, content={"detail": "Resource not found"})
|
|
@@ -1,76 +1,39 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""按正式 Module 暴露声明组装约定服务 Router。"""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
from collections.abc import Awaitable, Callable, Coroutine, Mapping
|
|
9
|
-
from dataclasses import dataclass, is_dataclass
|
|
10
|
-
from datetime import date, datetime, time
|
|
11
|
-
from decimal import Decimal
|
|
12
|
-
from enum import Enum
|
|
13
|
-
from typing import Annotated, Any, Literal, Union, cast, get_args, get_origin
|
|
14
|
-
from uuid import UUID
|
|
5
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, cast
|
|
15
8
|
|
|
16
|
-
from dishka import AsyncContainer
|
|
17
|
-
from dishka.integrations.base import default_parse_dependency
|
|
18
|
-
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
|
19
9
|
from fastapi import APIRouter, Depends
|
|
20
|
-
from fastapi.params import Body, Param, Path, Query
|
|
21
|
-
from fastapi.params import Depends as DependsParameter
|
|
22
10
|
from fastapi.security import HTTPBearer
|
|
23
|
-
from pydantic import BaseModel
|
|
24
|
-
from pydantic.fields import FieldInfo
|
|
25
|
-
from starlette.requests import Request
|
|
26
|
-
from starlette.responses import Response
|
|
27
11
|
from starlette.routing import compile_path
|
|
28
12
|
|
|
29
13
|
from ..application import Application
|
|
30
|
-
from ..application_services import
|
|
31
|
-
ApplicationServiceRegistration,
|
|
32
|
-
)
|
|
33
|
-
from ..application_services.catalog import _CompiledApplicationService
|
|
34
|
-
from ..application_services.dispatcher import _ApplicationServiceDispatcher
|
|
14
|
+
from ..application_services import ApplicationServiceRegistration
|
|
35
15
|
from ..application_services.module import ApplicationServicesModule
|
|
36
|
-
from ..diagnostics import SourceLocation
|
|
37
|
-
from ..invocation.methods import _CompiledMethod
|
|
38
16
|
from ..modularity import AppModule, ModuleDescriptor, ModuleKey
|
|
39
|
-
from .action import _HttpActionOwner, _request_parameter
|
|
40
17
|
from .contracts import (
|
|
41
18
|
ApplicationServiceRouteOverride,
|
|
42
19
|
_application_service_exposure_for,
|
|
43
20
|
_ApplicationServiceExposure,
|
|
44
21
|
)
|
|
45
22
|
from .errors import FastApiAdapterError
|
|
46
|
-
from .filters import _filters_for,
|
|
47
|
-
from .http import
|
|
23
|
+
from .filters import _filters_for, _validate_filters
|
|
24
|
+
from .http import _remote, _service_segment
|
|
48
25
|
from .http_router import _compile_parameter
|
|
49
|
-
from .
|
|
50
|
-
from .
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
_VERB_PREFIXES: tuple[tuple[str, str], ...] = (
|
|
60
|
-
("get_list", "GET"),
|
|
61
|
-
("get_all", "GET"),
|
|
62
|
-
("get", "GET"),
|
|
63
|
-
("create", "POST"),
|
|
64
|
-
("add", "POST"),
|
|
65
|
-
("insert", "POST"),
|
|
66
|
-
("post", "POST"),
|
|
67
|
-
("update", "PUT"),
|
|
68
|
-
("put", "PUT"),
|
|
69
|
-
("delete", "DELETE"),
|
|
70
|
-
("remove", "DELETE"),
|
|
71
|
-
("patch", "PATCH"),
|
|
26
|
+
from .service_endpoints import _ApplicationServiceRoute, _create_endpoint
|
|
27
|
+
from .service_routes import (
|
|
28
|
+
_conventional_verb,
|
|
29
|
+
_inspect_route_method,
|
|
30
|
+
_operation_id,
|
|
31
|
+
_query_model_parameter,
|
|
32
|
+
_route_path,
|
|
33
|
+
_route_projection,
|
|
34
|
+
_RouteMethod,
|
|
35
|
+
_service_path,
|
|
72
36
|
)
|
|
73
|
-
_SCALAR_TYPES = (str, int, float, bool, UUID, Decimal, date, datetime, time)
|
|
74
37
|
|
|
75
38
|
|
|
76
39
|
@dataclass(frozen=True, slots=True)
|
|
@@ -79,78 +42,6 @@ class _GeneratedApplicationServiceRouter:
|
|
|
79
42
|
router: APIRouter
|
|
80
43
|
|
|
81
44
|
|
|
82
|
-
@dataclass(frozen=True, slots=True)
|
|
83
|
-
class _RouteMethod:
|
|
84
|
-
signature: inspect.Signature
|
|
85
|
-
parameters: tuple[inspect.Parameter, ...]
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
@dataclass(frozen=True, slots=True)
|
|
89
|
-
class _ApplicationServiceRouteIdentity:
|
|
90
|
-
service: _CompiledApplicationService
|
|
91
|
-
method: _CompiledMethod
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
class _ApplicationServiceRoute(DishkaRoute):
|
|
95
|
-
"""在 native FastAPI binding 前建立 catalog-identity invocation boundary。"""
|
|
96
|
-
|
|
97
|
-
__slots__ = ("_compiled_method", "_compiled_service")
|
|
98
|
-
|
|
99
|
-
def __init__(
|
|
100
|
-
self,
|
|
101
|
-
path: str,
|
|
102
|
-
endpoint: Callable[..., Any],
|
|
103
|
-
**kwargs: Any,
|
|
104
|
-
) -> None:
|
|
105
|
-
identity = getattr(endpoint, _INVOCATION_IDENTITY_ATTRIBUTE, None)
|
|
106
|
-
if not isinstance(identity, _ApplicationServiceRouteIdentity):
|
|
107
|
-
raise FastApiAdapterError("generated Application Service route lost its identity")
|
|
108
|
-
self._compiled_service = identity.service
|
|
109
|
-
self._compiled_method = identity.method
|
|
110
|
-
sources = getattr(endpoint, "__python_ddd_framework_http_sources__", None)
|
|
111
|
-
super().__init__(path, endpoint, **kwargs)
|
|
112
|
-
cast(Any, self.endpoint).__python_ddd_framework_http_sources__ = sources
|
|
113
|
-
# include_router 会以已注入 endpoint 重建 route;显式把 identity 传递给下一次构造。
|
|
114
|
-
setattr(self.endpoint, _INVOCATION_IDENTITY_ATTRIBUTE, identity)
|
|
115
|
-
|
|
116
|
-
def get_route_handler(
|
|
117
|
-
self,
|
|
118
|
-
) -> Callable[[Request], Coroutine[Any, Any, Response]]:
|
|
119
|
-
native_handler = super().get_route_handler()
|
|
120
|
-
service = self._compiled_service
|
|
121
|
-
method = self._compiled_method
|
|
122
|
-
|
|
123
|
-
async def route_handler(request: Request) -> Response:
|
|
124
|
-
owner = await _HttpActionOwner.install(request)
|
|
125
|
-
container = cast(AsyncContainer, request.state.dishka_container)
|
|
126
|
-
dispatcher = await container.get(_ApplicationServiceDispatcher)
|
|
127
|
-
|
|
128
|
-
async def bind() -> Response:
|
|
129
|
-
await _prepare_upload(request, method.implementation_method, self)
|
|
130
|
-
return await native_handler(request)
|
|
131
|
-
|
|
132
|
-
try:
|
|
133
|
-
result = await dispatcher.invoke_http_request(
|
|
134
|
-
service,
|
|
135
|
-
method,
|
|
136
|
-
bind,
|
|
137
|
-
is_short_circuited=lambda: owner.short_circuited,
|
|
138
|
-
defer_audit=owner.audits.append,
|
|
139
|
-
)
|
|
140
|
-
owner.response_ready = isinstance(result, Response)
|
|
141
|
-
except BaseException as error:
|
|
142
|
-
owner.failure = error
|
|
143
|
-
raise
|
|
144
|
-
if not isinstance(result, Response):
|
|
145
|
-
raise FastApiAdapterError(
|
|
146
|
-
"an interceptor outside HTTP binding must return a native Response"
|
|
147
|
-
)
|
|
148
|
-
_manage_stream(request, result)
|
|
149
|
-
return result
|
|
150
|
-
|
|
151
|
-
return route_handler
|
|
152
|
-
|
|
153
|
-
|
|
154
45
|
def _application_service_routers(
|
|
155
46
|
application: Application,
|
|
156
47
|
) -> tuple[_GeneratedApplicationServiceRouter, ...]:
|
|
@@ -373,282 +264,6 @@ def _index_overrides(
|
|
|
373
264
|
return indexed
|
|
374
265
|
|
|
375
266
|
|
|
376
|
-
def _inspect_route_method(
|
|
377
|
-
contract: type[object],
|
|
378
|
-
method_name: str,
|
|
379
|
-
signature: inspect.Signature,
|
|
380
|
-
) -> _RouteMethod:
|
|
381
|
-
"""在真实 FastAPI app 变更前解析并收窄动态 typing boundary。"""
|
|
382
|
-
|
|
383
|
-
# 共享方法编译已解释本 Application 的扩展 DTO;HTTP 不另建字段权威。
|
|
384
|
-
resolved_parameters = tuple(signature.parameters.values())
|
|
385
|
-
parameters = resolved_parameters[1:]
|
|
386
|
-
for parameter in parameters:
|
|
387
|
-
if parameter.kind in (
|
|
388
|
-
inspect.Parameter.POSITIONAL_ONLY,
|
|
389
|
-
inspect.Parameter.VAR_POSITIONAL,
|
|
390
|
-
inspect.Parameter.VAR_KEYWORD,
|
|
391
|
-
):
|
|
392
|
-
raise FastApiAdapterError(
|
|
393
|
-
f"HTTP method {contract.__module__}.{contract.__qualname__}.{method_name} "
|
|
394
|
-
"cannot use positional-only, *args, or **kwargs parameters"
|
|
395
|
-
)
|
|
396
|
-
if parameter.annotation is inspect.Signature.empty:
|
|
397
|
-
raise FastApiAdapterError(
|
|
398
|
-
f"HTTP method {contract.__module__}.{contract.__qualname__}.{method_name} "
|
|
399
|
-
f"parameter {parameter.name!r} requires a type annotation"
|
|
400
|
-
)
|
|
401
|
-
if signature.return_annotation is inspect.Signature.empty:
|
|
402
|
-
raise FastApiAdapterError(
|
|
403
|
-
f"HTTP method {contract.__module__}.{contract.__qualname__}.{method_name} "
|
|
404
|
-
"requires a return type annotation"
|
|
405
|
-
)
|
|
406
|
-
return _RouteMethod(signature=signature, parameters=parameters)
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
def _query_model_parameter(parameter: inspect.Parameter, verb: str) -> inspect.Parameter:
|
|
410
|
-
hint = parameter.annotation
|
|
411
|
-
metadata = get_args(hint)[1:] if get_origin(hint) is Annotated else ()
|
|
412
|
-
model = get_args(hint)[0] if metadata else hint
|
|
413
|
-
if (
|
|
414
|
-
verb == "GET"
|
|
415
|
-
and isinstance(model, type)
|
|
416
|
-
and issubclass(model, BaseModel)
|
|
417
|
-
and not any(
|
|
418
|
-
isinstance(value, (FieldInfo, DependsParameter))
|
|
419
|
-
for value in (*metadata, parameter.default)
|
|
420
|
-
)
|
|
421
|
-
and default_parse_dependency(parameter, hint) is None
|
|
422
|
-
):
|
|
423
|
-
# 仅补原生 Query 元数据;字段展开、校验和 OpenAPI 均交给 FastAPI/Pydantic。
|
|
424
|
-
return parameter.replace(annotation=Annotated[hint, Query()])
|
|
425
|
-
return parameter
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
def _uses_non_path_binding(parameter: inspect.Parameter) -> bool:
|
|
429
|
-
metadata = (
|
|
430
|
-
get_args(parameter.annotation)[1:] if get_origin(parameter.annotation) is Annotated else ()
|
|
431
|
-
)
|
|
432
|
-
return any(
|
|
433
|
-
isinstance(item, (Param, Body)) and not isinstance(item, Path)
|
|
434
|
-
for item in (*metadata, parameter.default)
|
|
435
|
-
)
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
def _route_path(
|
|
439
|
-
service_path: str,
|
|
440
|
-
parameters: tuple[inspect.Parameter, ...],
|
|
441
|
-
override_path: str | None,
|
|
442
|
-
*,
|
|
443
|
-
contract: type[object],
|
|
444
|
-
method_name: str,
|
|
445
|
-
) -> str:
|
|
446
|
-
# 只有唯一 required scalar id 才能无歧义进入 convention path;
|
|
447
|
-
# 其余情况保持 query 或要求 override。
|
|
448
|
-
id_candidates = tuple(
|
|
449
|
-
parameter
|
|
450
|
-
for parameter in parameters
|
|
451
|
-
if _is_required_path_parameter(parameter)
|
|
452
|
-
and (parameter.name == "id" or parameter.name.endswith("_id"))
|
|
453
|
-
and _is_scalar_annotation(parameter.annotation)
|
|
454
|
-
and not _uses_non_path_binding(parameter)
|
|
455
|
-
)
|
|
456
|
-
if override_path is None:
|
|
457
|
-
if len(id_candidates) > 1:
|
|
458
|
-
raise FastApiAdapterError(
|
|
459
|
-
f"HTTP method {contract.__module__}.{contract.__qualname__}.{method_name} "
|
|
460
|
-
"has ambiguous required id parameters and requires an explicit path override"
|
|
461
|
-
)
|
|
462
|
-
path = service_path
|
|
463
|
-
if len(id_candidates) == 1:
|
|
464
|
-
path = f"{path}/{{{id_candidates[0].name}}}"
|
|
465
|
-
# 只有集中定义的 CRUD 名称省略动作;审批/取消等领域动作保持其 contract 名称。
|
|
466
|
-
if method_name not in {prefix for prefix, _ in _VERB_PREFIXES}:
|
|
467
|
-
path = f"{path}/{method_name.replace('_', '-')}"
|
|
468
|
-
return path
|
|
469
|
-
|
|
470
|
-
if "?" in override_path or "#" in override_path or "//" in override_path:
|
|
471
|
-
raise FastApiAdapterError("route override path cannot contain query or fragment syntax")
|
|
472
|
-
rendered_parameters = tuple(
|
|
473
|
-
match.group("name") for match in _PATH_PARAMETER.finditer(override_path)
|
|
474
|
-
)
|
|
475
|
-
if override_path.count("{") != len(rendered_parameters) or override_path.count("}") != len(
|
|
476
|
-
rendered_parameters
|
|
477
|
-
):
|
|
478
|
-
raise FastApiAdapterError(f"route override path {override_path!r} is malformed")
|
|
479
|
-
parameters_by_name = {parameter.name: parameter for parameter in parameters}
|
|
480
|
-
for name in rendered_parameters:
|
|
481
|
-
parameter = parameters_by_name.get(name)
|
|
482
|
-
if (
|
|
483
|
-
parameter is None
|
|
484
|
-
or not _is_required_path_parameter(parameter)
|
|
485
|
-
or not _is_scalar_annotation(parameter.annotation)
|
|
486
|
-
or _uses_non_path_binding(parameter)
|
|
487
|
-
):
|
|
488
|
-
raise FastApiAdapterError(
|
|
489
|
-
f"route override path parameter {name!r} must name a required scalar "
|
|
490
|
-
f"Protocol parameter on {contract.__qualname__}.{method_name}"
|
|
491
|
-
)
|
|
492
|
-
if len(set(rendered_parameters)) != len(rendered_parameters):
|
|
493
|
-
raise FastApiAdapterError(f"route override path {override_path!r} repeats a path parameter")
|
|
494
|
-
return override_path
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
def _is_required_path_parameter(parameter: inspect.Parameter) -> bool:
|
|
498
|
-
return parameter.default is inspect.Signature.empty or isinstance(parameter.default, Path)
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
def _is_scalar_annotation(annotation: object) -> bool:
|
|
502
|
-
current = annotation
|
|
503
|
-
if get_origin(current) is Annotated:
|
|
504
|
-
current = get_args(current)[0]
|
|
505
|
-
origin = get_origin(current)
|
|
506
|
-
if origin in (types.UnionType, Union):
|
|
507
|
-
members = tuple(item for item in get_args(current) if item is not type(None))
|
|
508
|
-
return len(members) == 1 and _is_scalar_annotation(members[0])
|
|
509
|
-
if origin is Literal:
|
|
510
|
-
return all(isinstance(item, (str, int, float, bool)) for item in get_args(current))
|
|
511
|
-
if not isinstance(current, type):
|
|
512
|
-
return False
|
|
513
|
-
if issubclass(current, BaseModel) or is_dataclass(current):
|
|
514
|
-
return False
|
|
515
|
-
return issubclass(current, (*_SCALAR_TYPES, Enum))
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
def _service_path(root_path: str, contract: type[object], segment: str | None = None) -> str:
|
|
519
|
-
service_name = contract.__name__
|
|
520
|
-
if service_name.endswith(_APPLICATION_SERVICE_SUFFIX):
|
|
521
|
-
service_name = service_name[: -len(_APPLICATION_SERVICE_SUFFIX)]
|
|
522
|
-
if not service_name:
|
|
523
|
-
raise FastApiAdapterError("application service contract has no routable name")
|
|
524
|
-
return "/" + "/".join(
|
|
525
|
-
part for part in (root_path, segment or _kebab_case(service_name)) if part
|
|
526
|
-
)
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
def _operation_id(contract: type[object], method_name: str) -> str:
|
|
530
|
-
service_path = _service_path("", contract).rsplit("/", maxsplit=1)[-1]
|
|
531
|
-
return f"{service_path.replace('-', '_')}_{method_name}"
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
def _kebab_case(value: str) -> str:
|
|
535
|
-
first_pass = _WORD_BOUNDARY_1.sub(r"\1-\2", value)
|
|
536
|
-
return _WORD_BOUNDARY_2.sub(r"\1-\2", first_pass).lower().replace("_", "-")
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
def _conventional_verb(method_name: str) -> str:
|
|
540
|
-
for prefix, verb in _VERB_PREFIXES:
|
|
541
|
-
if method_name == prefix or method_name.startswith(f"{prefix}_"):
|
|
542
|
-
return verb
|
|
543
|
-
return "POST"
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
def _create_endpoint(
|
|
547
|
-
service: _CompiledApplicationService,
|
|
548
|
-
method: _CompiledMethod,
|
|
549
|
-
route_method: _RouteMethod,
|
|
550
|
-
source: SourceLocation,
|
|
551
|
-
) -> Callable[..., Awaitable[object]]:
|
|
552
|
-
"""保留 Protocol 原签名供 FastAPI binding,并注入同一 REQUEST dispatcher。"""
|
|
553
|
-
|
|
554
|
-
contract = service.registration.contract
|
|
555
|
-
method_name = method.name
|
|
556
|
-
parameter_names = {parameter.name for parameter in route_method.parameters}
|
|
557
|
-
dispatcher_parameter_name = "_python_ddd_framework_application_service_dispatcher"
|
|
558
|
-
while dispatcher_parameter_name in parameter_names:
|
|
559
|
-
dispatcher_parameter_name = f"_{dispatcher_parameter_name}"
|
|
560
|
-
|
|
561
|
-
declared_filters = _ordered_filters(
|
|
562
|
-
_filters_for(*reversed(service.registration.implementation.__mro__)),
|
|
563
|
-
_filters_for(method.implementation_method),
|
|
564
|
-
)
|
|
565
|
-
validator = _HttpParameterValidator(
|
|
566
|
-
route_method.signature.replace(parameters=route_method.parameters)
|
|
567
|
-
)
|
|
568
|
-
|
|
569
|
-
async def endpoint(**values: object) -> object:
|
|
570
|
-
dispatcher = cast(_ApplicationServiceDispatcher, values.pop(dispatcher_parameter_name))
|
|
571
|
-
request = cast(
|
|
572
|
-
Request,
|
|
573
|
-
values.pop(request_parameter_name)
|
|
574
|
-
if injected_request
|
|
575
|
-
else values[request_parameter_name],
|
|
576
|
-
)
|
|
577
|
-
owner: _HttpActionOwner = request.state.python_ddd_framework_action
|
|
578
|
-
await owner.activate(
|
|
579
|
-
method.descriptor.unit_of_work, method.name, service.registration.connection_name
|
|
580
|
-
)
|
|
581
|
-
filters = _ordered_filters(
|
|
582
|
-
getattr(request.app.state, "python_ddd_framework_filters", ()), declared_filters
|
|
583
|
-
)
|
|
584
|
-
snapshots = validator.capture(values)
|
|
585
|
-
|
|
586
|
-
async def invoke(actual: dict[str, object]) -> object:
|
|
587
|
-
if filters:
|
|
588
|
-
validator.validate_changes(actual, snapshots)
|
|
589
|
-
return await dispatcher.invoke_http(service, method, actual)
|
|
590
|
-
|
|
591
|
-
return await owner.invoke(
|
|
592
|
-
method.implementation_method,
|
|
593
|
-
values,
|
|
594
|
-
filters,
|
|
595
|
-
invoke,
|
|
596
|
-
)
|
|
597
|
-
|
|
598
|
-
request_parameter, injected_request = _request_parameter(route_method.parameters)
|
|
599
|
-
request_parameter_name = request_parameter.name
|
|
600
|
-
http_parameters = (
|
|
601
|
-
(*route_method.parameters, request_parameter)
|
|
602
|
-
if injected_request
|
|
603
|
-
else route_method.parameters
|
|
604
|
-
)
|
|
605
|
-
|
|
606
|
-
dispatcher_parameter = inspect.Parameter(
|
|
607
|
-
dispatcher_parameter_name,
|
|
608
|
-
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
609
|
-
annotation=cast(Any, FromDishka)[_ApplicationServiceDispatcher],
|
|
610
|
-
)
|
|
611
|
-
# 动态 endpoint 的 signature/annotations 同时发布,兼容 FastAPI 与 postponed annotations。
|
|
612
|
-
signature = route_method.signature.replace(parameters=(*http_parameters, dispatcher_parameter))
|
|
613
|
-
endpoint_name = f"{contract.__name__}_{method_name}"
|
|
614
|
-
endpoint.__name__ = endpoint_name
|
|
615
|
-
endpoint.__qualname__ = endpoint_name
|
|
616
|
-
endpoint.__module__ = contract.__module__
|
|
617
|
-
cast(Any, endpoint).__signature__ = signature
|
|
618
|
-
endpoint.__annotations__ = {
|
|
619
|
-
**{
|
|
620
|
-
parameter.name: parameter.annotation
|
|
621
|
-
for parameter in (*http_parameters, dispatcher_parameter)
|
|
622
|
-
},
|
|
623
|
-
"return": signature.return_annotation,
|
|
624
|
-
}
|
|
625
|
-
setattr(endpoint, _ROUTE_SOURCE_ATTRIBUTE, source)
|
|
626
|
-
setattr(
|
|
627
|
-
endpoint,
|
|
628
|
-
_INVOCATION_IDENTITY_ATTRIBUTE,
|
|
629
|
-
_ApplicationServiceRouteIdentity(service, method),
|
|
630
|
-
)
|
|
631
|
-
return endpoint
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
def _route_projection(
|
|
635
|
-
method: object, override: ApplicationServiceRouteOverride | None
|
|
636
|
-
) -> tuple[dict[str, Any], dict[str, str]]:
|
|
637
|
-
projection = dict(_metadata(method))
|
|
638
|
-
provenance = {key: "method" for key in projection}
|
|
639
|
-
if override is not None:
|
|
640
|
-
from dataclasses import fields
|
|
641
|
-
|
|
642
|
-
for field in fields(override):
|
|
643
|
-
if field.name in ("service", "method", "enabled"):
|
|
644
|
-
continue
|
|
645
|
-
value = getattr(override, field.name)
|
|
646
|
-
if value is not None:
|
|
647
|
-
projection[field.name] = value
|
|
648
|
-
provenance[field.name] = "module_override"
|
|
649
|
-
return projection, provenance
|
|
650
|
-
|
|
651
|
-
|
|
652
267
|
def _exposure_targets(
|
|
653
268
|
http_descriptor: ModuleDescriptor,
|
|
654
269
|
exposure: _ApplicationServiceExposure,
|