cloud-dog-api-kit 0.13.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.
- cloud_dog_api_kit/__init__.py +170 -0
- cloud_dog_api_kit/a2a/__init__.py +53 -0
- cloud_dog_api_kit/a2a/card.py +138 -0
- cloud_dog_api_kit/a2a/events.py +1123 -0
- cloud_dog_api_kit/a2a/gateway.py +105 -0
- cloud_dog_api_kit/a2a/skill_audit.py +107 -0
- cloud_dog_api_kit/auth/__init__.py +35 -0
- cloud_dog_api_kit/auth/dependency.py +121 -0
- cloud_dog_api_kit/auth/rbac.py +107 -0
- cloud_dog_api_kit/auth/service_auth.py +54 -0
- cloud_dog_api_kit/clients/__init__.py +29 -0
- cloud_dog_api_kit/clients/circuit_breaker.py +39 -0
- cloud_dog_api_kit/clients/http_client.py +127 -0
- cloud_dog_api_kit/clients/retry.py +83 -0
- cloud_dog_api_kit/compat/__init__.py +37 -0
- cloud_dog_api_kit/compat/envelope.py +120 -0
- cloud_dog_api_kit/compat/profile.py +102 -0
- cloud_dog_api_kit/compat/routes.py +90 -0
- cloud_dog_api_kit/config.py +54 -0
- cloud_dog_api_kit/correlation/__init__.py +50 -0
- cloud_dog_api_kit/correlation/context.py +118 -0
- cloud_dog_api_kit/correlation/middleware.py +133 -0
- cloud_dog_api_kit/envelopes/__init__.py +37 -0
- cloud_dog_api_kit/envelopes/error.py +87 -0
- cloud_dog_api_kit/envelopes/success.py +84 -0
- cloud_dog_api_kit/errors/__init__.py +51 -0
- cloud_dog_api_kit/errors/exceptions.py +184 -0
- cloud_dog_api_kit/errors/handler.py +102 -0
- cloud_dog_api_kit/errors/taxonomy.py +62 -0
- cloud_dog_api_kit/factory.py +157 -0
- cloud_dog_api_kit/idempotency/__init__.py +28 -0
- cloud_dog_api_kit/idempotency/middleware.py +118 -0
- cloud_dog_api_kit/idempotency/store.py +100 -0
- cloud_dog_api_kit/lifecycle/__init__.py +39 -0
- cloud_dog_api_kit/lifecycle/hooks.py +75 -0
- cloud_dog_api_kit/lifecycle/shutdown.py +178 -0
- cloud_dog_api_kit/mcp/__init__.py +122 -0
- cloud_dog_api_kit/mcp/async_jobs.py +126 -0
- cloud_dog_api_kit/mcp/client_sdk.py +235 -0
- cloud_dog_api_kit/mcp/client_transport/__init__.py +47 -0
- cloud_dog_api_kit/mcp/client_transport/base.py +98 -0
- cloud_dog_api_kit/mcp/client_transport/exceptions.py +37 -0
- cloud_dog_api_kit/mcp/client_transport/http_jsonrpc.py +405 -0
- cloud_dog_api_kit/mcp/client_transport/legacy_sse.py +320 -0
- cloud_dog_api_kit/mcp/client_transport/stdio.py +322 -0
- cloud_dog_api_kit/mcp/client_transport/streamable_http.py +748 -0
- cloud_dog_api_kit/mcp/contract.py +113 -0
- cloud_dog_api_kit/mcp/error_mapper.py +84 -0
- cloud_dog_api_kit/mcp/gateway.py +117 -0
- cloud_dog_api_kit/mcp/legacy_sse.py +129 -0
- cloud_dog_api_kit/mcp/session.py +96 -0
- cloud_dog_api_kit/mcp/sync_handler.py +269 -0
- cloud_dog_api_kit/mcp/tool_audit.py +136 -0
- cloud_dog_api_kit/mcp/tool_router.py +180 -0
- cloud_dog_api_kit/mcp/transport.py +1041 -0
- cloud_dog_api_kit/middleware/__init__.py +39 -0
- cloud_dog_api_kit/middleware/cors.py +74 -0
- cloud_dog_api_kit/middleware/logging.py +98 -0
- cloud_dog_api_kit/middleware/request_size_limit.py +86 -0
- cloud_dog_api_kit/middleware/timeout.py +78 -0
- cloud_dog_api_kit/middleware/timing.py +52 -0
- cloud_dog_api_kit/openapi/__init__.py +30 -0
- cloud_dog_api_kit/openapi/customise.py +69 -0
- cloud_dog_api_kit/openapi/route.py +46 -0
- cloud_dog_api_kit/routers/__init__.py +41 -0
- cloud_dog_api_kit/routers/crud.py +173 -0
- cloud_dog_api_kit/routers/health.py +160 -0
- cloud_dog_api_kit/routers/jobs.py +69 -0
- cloud_dog_api_kit/routers/version.py +46 -0
- cloud_dog_api_kit/schemas/__init__.py +36 -0
- cloud_dog_api_kit/schemas/envelopes.py +37 -0
- cloud_dog_api_kit/schemas/filters.py +103 -0
- cloud_dog_api_kit/schemas/pagination.py +148 -0
- cloud_dog_api_kit/streaming/__init__.py +28 -0
- cloud_dog_api_kit/streaming/events.py +47 -0
- cloud_dog_api_kit/streaming/jsonl.py +68 -0
- cloud_dog_api_kit/streaming/sse.py +102 -0
- cloud_dog_api_kit/testing/__init__.py +46 -0
- cloud_dog_api_kit/testing/conformance.py +156 -0
- cloud_dog_api_kit/testing/fixtures.py +90 -0
- cloud_dog_api_kit/testing/flows/__init__.py +32 -0
- cloud_dog_api_kit/testing/flows/auth_flow.py +41 -0
- cloud_dog_api_kit/testing/flows/crud_flow.py +50 -0
- cloud_dog_api_kit/testing/flows/job_flow.py +42 -0
- cloud_dog_api_kit/testing/flows/streaming_flow.py +42 -0
- cloud_dog_api_kit/traceability_ids.py +84 -0
- cloud_dog_api_kit/versioning/__init__.py +30 -0
- cloud_dog_api_kit/versioning/header.py +52 -0
- cloud_dog_api_kit/web/__init__.py +7 -0
- cloud_dog_api_kit/web/proxy.py +222 -0
- cloud_dog_api_kit/webhook/__init__.py +29 -0
- cloud_dog_api_kit/webhook/signature.py +149 -0
- cloud_dog_api_kit-0.13.0.dist-info/METADATA +27 -0
- cloud_dog_api_kit-0.13.0.dist-info/RECORD +98 -0
- cloud_dog_api_kit-0.13.0.dist-info/WHEEL +4 -0
- cloud_dog_api_kit-0.13.0.dist-info/licenses/LICENCE +190 -0
- cloud_dog_api_kit-0.13.0.dist-info/licenses/LICENSE +176 -0
- cloud_dog_api_kit-0.13.0.dist-info/licenses/NOTICE +7 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# cloud_dog_api_kit — Error handler registration for FastAPI
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Registers exception handlers that convert APIError subclasses,
|
|
20
|
+
# RequestValidationError, and unhandled exceptions into standard error envelopes.
|
|
21
|
+
# Related requirements: FR2.2, CS1.1, CS1.5
|
|
22
|
+
# Related architecture: CC1.3
|
|
23
|
+
|
|
24
|
+
"""Error handler registration for FastAPI applications."""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from fastapi import FastAPI, Request
|
|
32
|
+
from fastapi.exceptions import RequestValidationError
|
|
33
|
+
from starlette.responses import JSONResponse
|
|
34
|
+
|
|
35
|
+
from cloud_dog_api_kit.errors.exceptions import APIError
|
|
36
|
+
from cloud_dog_api_kit.envelopes.error import error_envelope
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger("cloud_dog_api_kit.errors")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def register_error_handlers(app: FastAPI) -> None:
|
|
42
|
+
"""Register standard error handlers on a FastAPI application.
|
|
43
|
+
|
|
44
|
+
Converts:
|
|
45
|
+
- APIError subclasses into the standard error envelope with correct HTTP status.
|
|
46
|
+
- RequestValidationError into an INVALID_REQUEST envelope with field-level details.
|
|
47
|
+
- Unhandled Exception into a generic 500 with no leaked internals.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
app: The FastAPI application instance.
|
|
51
|
+
|
|
52
|
+
Related tests: UT1.5_ErrorHandler, ST1.2_ErrorFlowEndToEnd, SEC1.6_ErrorSanitisation
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
@app.exception_handler(APIError)
|
|
56
|
+
async def _api_error_handler(request: Request, exc: APIError) -> JSONResponse:
|
|
57
|
+
request_id = getattr(request.state, "request_id", "")
|
|
58
|
+
correlation_id = getattr(request.state, "correlation_id", None)
|
|
59
|
+
body = error_envelope(
|
|
60
|
+
code=exc.code,
|
|
61
|
+
message=exc.message,
|
|
62
|
+
details=exc.details,
|
|
63
|
+
retryable=exc.retryable,
|
|
64
|
+
request_id=request_id,
|
|
65
|
+
correlation_id=correlation_id,
|
|
66
|
+
)
|
|
67
|
+
return JSONResponse(status_code=exc.status_code, content=body)
|
|
68
|
+
|
|
69
|
+
@app.exception_handler(RequestValidationError)
|
|
70
|
+
async def _validation_error_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
71
|
+
request_id = getattr(request.state, "request_id", "")
|
|
72
|
+
correlation_id = getattr(request.state, "correlation_id", None)
|
|
73
|
+
field_errors: dict[str, Any] = {}
|
|
74
|
+
for error in exc.errors():
|
|
75
|
+
loc = ".".join(str(part) for part in error.get("loc", []))
|
|
76
|
+
field_errors[loc] = error.get("msg", "Validation error")
|
|
77
|
+
body = error_envelope(
|
|
78
|
+
code="INVALID_REQUEST",
|
|
79
|
+
message="Validation failure",
|
|
80
|
+
details=field_errors,
|
|
81
|
+
retryable=False,
|
|
82
|
+
request_id=request_id,
|
|
83
|
+
correlation_id=correlation_id,
|
|
84
|
+
)
|
|
85
|
+
return JSONResponse(status_code=422, content=body)
|
|
86
|
+
|
|
87
|
+
@app.exception_handler(Exception)
|
|
88
|
+
async def _generic_error_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
89
|
+
request_id = getattr(request.state, "request_id", "")
|
|
90
|
+
correlation_id = getattr(request.state, "correlation_id", None)
|
|
91
|
+
logger.exception(
|
|
92
|
+
"Unhandled exception",
|
|
93
|
+
extra={"request_id": request_id, "path": request.url.path},
|
|
94
|
+
)
|
|
95
|
+
body = error_envelope(
|
|
96
|
+
code="INTERNAL_ERROR",
|
|
97
|
+
message="An internal error occurred",
|
|
98
|
+
retryable=False,
|
|
99
|
+
request_id=request_id,
|
|
100
|
+
correlation_id=correlation_id,
|
|
101
|
+
)
|
|
102
|
+
return JSONResponse(status_code=500, content=body)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# cloud_dog_api_kit — Error taxonomy
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Stable error code taxonomy (9 codes) per PS-20.
|
|
20
|
+
# Related requirements: FR1.3
|
|
21
|
+
# Related architecture: SA1, CC1.3
|
|
22
|
+
|
|
23
|
+
"""Error code taxonomy for cloud_dog_api_kit."""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class ErrorTaxonomyEntry:
|
|
32
|
+
"""One error taxonomy entry."""
|
|
33
|
+
|
|
34
|
+
code: str
|
|
35
|
+
http_status: int
|
|
36
|
+
retryable: bool
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
UNAUTHENTICATED = ErrorTaxonomyEntry(code="UNAUTHENTICATED", http_status=401, retryable=False)
|
|
40
|
+
UNAUTHORISED = ErrorTaxonomyEntry(code="UNAUTHORISED", http_status=403, retryable=False)
|
|
41
|
+
NOT_FOUND = ErrorTaxonomyEntry(code="NOT_FOUND", http_status=404, retryable=False)
|
|
42
|
+
CONFLICT = ErrorTaxonomyEntry(code="CONFLICT", http_status=409, retryable=False)
|
|
43
|
+
INVALID_REQUEST = ErrorTaxonomyEntry(code="INVALID_REQUEST", http_status=422, retryable=False)
|
|
44
|
+
RATE_LIMITED = ErrorTaxonomyEntry(code="RATE_LIMITED", http_status=429, retryable=True)
|
|
45
|
+
TIMEOUT = ErrorTaxonomyEntry(code="TIMEOUT", http_status=504, retryable=True)
|
|
46
|
+
UPSTREAM_ERROR = ErrorTaxonomyEntry(code="UPSTREAM_ERROR", http_status=502, retryable=True)
|
|
47
|
+
INTERNAL_ERROR = ErrorTaxonomyEntry(code="INTERNAL_ERROR", http_status=500, retryable=False)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
ALL_ENTRIES: tuple[ErrorTaxonomyEntry, ...] = (
|
|
51
|
+
UNAUTHENTICATED,
|
|
52
|
+
UNAUTHORISED,
|
|
53
|
+
NOT_FOUND,
|
|
54
|
+
CONFLICT,
|
|
55
|
+
INVALID_REQUEST,
|
|
56
|
+
RATE_LIMITED,
|
|
57
|
+
TIMEOUT,
|
|
58
|
+
UPSTREAM_ERROR,
|
|
59
|
+
INTERNAL_ERROR,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
BY_CODE: dict[str, ErrorTaxonomyEntry] = {e.code: e for e in ALL_ENTRIES}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# cloud_dog_api_kit — FastAPI application factory
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: `create_app()` wires standard middleware, error handlers,
|
|
20
|
+
# health routes, and version endpoint for Cloud-Dog services.
|
|
21
|
+
# Related requirements: FR10.4, FR12.1, FR6.1
|
|
22
|
+
# Related architecture: SA1
|
|
23
|
+
|
|
24
|
+
"""FastAPI application factory for cloud_dog_api_kit."""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from contextlib import asynccontextmanager
|
|
29
|
+
from typing import Any, Callable
|
|
30
|
+
|
|
31
|
+
from fastapi import FastAPI
|
|
32
|
+
|
|
33
|
+
from cloud_dog_api_kit.correlation import CorrelationMiddleware
|
|
34
|
+
from cloud_dog_api_kit.errors import register_error_handlers
|
|
35
|
+
from cloud_dog_logging.middleware.audit import AuditMiddleware
|
|
36
|
+
from cloud_dog_api_kit.lifecycle import (
|
|
37
|
+
GracefulShutdownManager,
|
|
38
|
+
LifecycleHooks,
|
|
39
|
+
ShutdownDrainMiddleware,
|
|
40
|
+
install_shutdown_signal_handlers,
|
|
41
|
+
)
|
|
42
|
+
from cloud_dog_api_kit.middleware import RequestLoggingMiddleware, TimingMiddleware, TimeoutMiddleware, configure_cors
|
|
43
|
+
from cloud_dog_api_kit.middleware.request_size_limit import RequestSizeLimitMiddleware
|
|
44
|
+
from cloud_dog_api_kit.routers.health import create_health_router
|
|
45
|
+
from cloud_dog_api_kit.versioning import VersionHeaderMiddleware
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def create_app(
|
|
49
|
+
title: str,
|
|
50
|
+
version: str = "0.0.0",
|
|
51
|
+
description: str = "",
|
|
52
|
+
api_prefix: str = "/api/v1",
|
|
53
|
+
base_path: str = "",
|
|
54
|
+
health_checks: dict[str, Callable] | None = None,
|
|
55
|
+
auth_verify_fn: Callable | None = None,
|
|
56
|
+
enable_request_logging: bool = True,
|
|
57
|
+
enable_cors: bool = True,
|
|
58
|
+
cors_origins: list[str] | None = None,
|
|
59
|
+
enable_docs: bool = True,
|
|
60
|
+
enable_streaming: bool = False,
|
|
61
|
+
lifecycle_hooks: LifecycleHooks | None = None,
|
|
62
|
+
enable_health: bool = True,
|
|
63
|
+
max_request_body_bytes: int | None = None,
|
|
64
|
+
timeout_seconds: float = 30.0,
|
|
65
|
+
shutdown_drain_timeout_seconds: float = 5.0,
|
|
66
|
+
register_signal_handlers_on_startup: bool = True,
|
|
67
|
+
enable_audit_logging: bool = True,
|
|
68
|
+
) -> FastAPI:
|
|
69
|
+
"""Create a fully configured FastAPI application.
|
|
70
|
+
|
|
71
|
+
Wires:
|
|
72
|
+
- Error handlers (APIError, validation, unhandled)
|
|
73
|
+
- Correlation ID middleware (X-Request-Id, X-Correlation-Id, X-App-Id)
|
|
74
|
+
- Request logging middleware
|
|
75
|
+
- CORS middleware (production-safe defaults)
|
|
76
|
+
- Health/ready/live/status routes
|
|
77
|
+
- /api/v1/version endpoint
|
|
78
|
+
- X-API-Version response header
|
|
79
|
+
- Swagger UI / ReDoc (disableable)
|
|
80
|
+
- Reverse proxy base path via ``root_path`` (DC-11)
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
base_path: URL prefix when behind a reverse proxy (e.g. "/api").
|
|
84
|
+
Passed as FastAPI ``root_path`` so generated URLs, OpenAPI docs,
|
|
85
|
+
and redirects include the prefix. Default "" (no prefix).
|
|
86
|
+
"""
|
|
87
|
+
hooks = lifecycle_hooks or LifecycleHooks()
|
|
88
|
+
shutdown_manager = GracefulShutdownManager(drain_timeout_seconds=shutdown_drain_timeout_seconds)
|
|
89
|
+
|
|
90
|
+
@asynccontextmanager
|
|
91
|
+
async def _lifespan(app: FastAPI):
|
|
92
|
+
await hooks.run_startup(app)
|
|
93
|
+
if register_signal_handlers_on_startup:
|
|
94
|
+
install_shutdown_signal_handlers(shutdown_manager)
|
|
95
|
+
try:
|
|
96
|
+
yield
|
|
97
|
+
finally:
|
|
98
|
+
await shutdown_manager.initiate_shutdown()
|
|
99
|
+
await hooks.run_shutdown(app)
|
|
100
|
+
|
|
101
|
+
app = FastAPI(
|
|
102
|
+
title=title,
|
|
103
|
+
version=version,
|
|
104
|
+
description=description,
|
|
105
|
+
root_path=base_path,
|
|
106
|
+
docs_url="/docs" if enable_docs else None,
|
|
107
|
+
redoc_url="/redoc" if enable_docs else None,
|
|
108
|
+
lifespan=_lifespan,
|
|
109
|
+
)
|
|
110
|
+
app.state.lifecycle_hooks = hooks
|
|
111
|
+
app.state.shutdown_manager = shutdown_manager
|
|
112
|
+
|
|
113
|
+
register_error_handlers(app)
|
|
114
|
+
|
|
115
|
+
# Middleware ordering: outermost first.
|
|
116
|
+
app.add_middleware(VersionHeaderMiddleware, version="v1")
|
|
117
|
+
if enable_request_logging:
|
|
118
|
+
app.add_middleware(RequestLoggingMiddleware)
|
|
119
|
+
if enable_audit_logging:
|
|
120
|
+
app.add_middleware(AuditMiddleware)
|
|
121
|
+
app.add_middleware(TimingMiddleware)
|
|
122
|
+
app.add_middleware(TimeoutMiddleware, timeout_seconds=timeout_seconds)
|
|
123
|
+
if max_request_body_bytes is not None:
|
|
124
|
+
app.add_middleware(RequestSizeLimitMiddleware, max_bytes=max_request_body_bytes)
|
|
125
|
+
app.add_middleware(ShutdownDrainMiddleware, manager=shutdown_manager)
|
|
126
|
+
app.add_middleware(CorrelationMiddleware)
|
|
127
|
+
if enable_cors:
|
|
128
|
+
configure_cors(app, allowed_origins=cors_origins)
|
|
129
|
+
|
|
130
|
+
# Health routes. /status can be protected if a verify function is supplied.
|
|
131
|
+
# Set enable_health=False when the project defines its own custom health endpoints.
|
|
132
|
+
if enable_health:
|
|
133
|
+
from cloud_dog_api_kit.auth.dependency import create_auth_dependency
|
|
134
|
+
|
|
135
|
+
auth_dep = None
|
|
136
|
+
if auth_verify_fn:
|
|
137
|
+
auth_dep = create_auth_dependency(api_key_verify_fn=auth_verify_fn)
|
|
138
|
+
|
|
139
|
+
app.include_router(
|
|
140
|
+
create_health_router(
|
|
141
|
+
application_name=title,
|
|
142
|
+
version=version,
|
|
143
|
+
checks=health_checks,
|
|
144
|
+
auth_dependency=auth_dep,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@app.get(f"{api_prefix}/version", tags=["system"])
|
|
149
|
+
async def api_version() -> dict[str, Any]:
|
|
150
|
+
"""Return the API version information."""
|
|
151
|
+
return {
|
|
152
|
+
"application": title,
|
|
153
|
+
"version": version,
|
|
154
|
+
"api_version": "v1",
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return app
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# cloud_dog_api_kit — Idempotency middleware and store
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Idempotency-Key header support with pluggable storage backend.
|
|
20
|
+
# Related requirements: FR4.4
|
|
21
|
+
# Related architecture: CC1.16
|
|
22
|
+
|
|
23
|
+
"""Idempotency middleware and store for cloud_dog_api_kit."""
|
|
24
|
+
|
|
25
|
+
from cloud_dog_api_kit.idempotency.middleware import IdempotencyMiddleware
|
|
26
|
+
from cloud_dog_api_kit.idempotency.store import IdempotencyStore, InMemoryIdempotencyStore
|
|
27
|
+
|
|
28
|
+
__all__ = ["IdempotencyMiddleware", "IdempotencyStore", "InMemoryIdempotencyStore"]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# cloud_dog_api_kit — Idempotency middleware
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: ASGI middleware that honours Idempotency-Key headers for
|
|
20
|
+
# creation endpoints, caching and replaying responses.
|
|
21
|
+
# Related requirements: FR4.4
|
|
22
|
+
# Related architecture: CC1.16
|
|
23
|
+
|
|
24
|
+
"""Idempotency middleware for cloud_dog_api_kit."""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
from typing import Any, Callable
|
|
30
|
+
|
|
31
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
32
|
+
from starlette.requests import Request
|
|
33
|
+
from starlette.responses import JSONResponse, Response
|
|
34
|
+
|
|
35
|
+
from cloud_dog_api_kit.idempotency.store import InMemoryIdempotencyStore
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class IdempotencyMiddleware(BaseHTTPMiddleware):
|
|
39
|
+
"""Middleware that honours Idempotency-Key headers.
|
|
40
|
+
|
|
41
|
+
First request with a key executes normally and caches the response.
|
|
42
|
+
Subsequent requests with the same key return the cached response.
|
|
43
|
+
Expired keys re-execute. Missing keys pass through.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
app: The ASGI application.
|
|
47
|
+
store: Pluggable idempotency store. Defaults to in-memory.
|
|
48
|
+
ttl_seconds: Time-to-live for cached responses. Defaults to 86400 (24h).
|
|
49
|
+
|
|
50
|
+
Related tests: UT1.28_IdempotencyMiddleware, ST1.10_IdempotencyEndToEnd
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
app: Any,
|
|
56
|
+
store: Any | None = None,
|
|
57
|
+
ttl_seconds: int = 86400,
|
|
58
|
+
) -> None:
|
|
59
|
+
super().__init__(app)
|
|
60
|
+
self._store = store or InMemoryIdempotencyStore()
|
|
61
|
+
self._ttl = ttl_seconds
|
|
62
|
+
|
|
63
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
64
|
+
"""Process request with idempotency key support.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
request: The incoming HTTP request.
|
|
68
|
+
call_next: The next middleware or handler.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The HTTP response (cached or fresh).
|
|
72
|
+
"""
|
|
73
|
+
# Only apply to POST/PUT methods
|
|
74
|
+
if request.method not in ("POST", "PUT"):
|
|
75
|
+
return await call_next(request)
|
|
76
|
+
|
|
77
|
+
idem_key = request.headers.get("idempotency-key", "").strip()
|
|
78
|
+
if not idem_key:
|
|
79
|
+
return await call_next(request)
|
|
80
|
+
|
|
81
|
+
# Check cache
|
|
82
|
+
cached = await self._store.get(idem_key)
|
|
83
|
+
if cached is not None:
|
|
84
|
+
return JSONResponse(
|
|
85
|
+
status_code=cached.get("status_code", 200),
|
|
86
|
+
content=cached.get("body"),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Execute request
|
|
90
|
+
response = await call_next(request)
|
|
91
|
+
|
|
92
|
+
# Cache the response body
|
|
93
|
+
if 200 <= response.status_code < 300:
|
|
94
|
+
body_bytes = b""
|
|
95
|
+
async for chunk in response.body_iterator:
|
|
96
|
+
if isinstance(chunk, bytes):
|
|
97
|
+
body_bytes += chunk
|
|
98
|
+
else:
|
|
99
|
+
body_bytes += chunk.encode("utf-8")
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
body_json = json.loads(body_bytes)
|
|
103
|
+
except (json.JSONDecodeError, ValueError):
|
|
104
|
+
body_json = body_bytes.decode("utf-8")
|
|
105
|
+
|
|
106
|
+
await self._store.set(
|
|
107
|
+
idem_key,
|
|
108
|
+
{"status_code": response.status_code, "body": body_json},
|
|
109
|
+
self._ttl,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return JSONResponse(
|
|
113
|
+
status_code=response.status_code,
|
|
114
|
+
content=body_json,
|
|
115
|
+
headers=dict(response.headers),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return response
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# cloud_dog_api_kit — Idempotency store protocol and in-memory implementation
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Pluggable idempotency store with in-memory default implementation.
|
|
20
|
+
# Related requirements: FR4.4
|
|
21
|
+
# Related architecture: CC1.16
|
|
22
|
+
|
|
23
|
+
"""Idempotency store protocol and in-memory implementation."""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import time
|
|
28
|
+
from typing import Protocol
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class IdempotencyStore(Protocol):
|
|
32
|
+
"""Protocol for idempotency key storage backends.
|
|
33
|
+
|
|
34
|
+
Related tests: UT1.29_IdempotencyStore
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
async def get(self, key: str) -> dict | None:
|
|
38
|
+
"""Get a cached response by idempotency key.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
key: The idempotency key.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The cached response dict, or None if not found or expired.
|
|
45
|
+
"""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
async def set(self, key: str, response: dict, ttl: int) -> None:
|
|
49
|
+
"""Store a response with the given idempotency key.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
key: The idempotency key.
|
|
53
|
+
response: The response dict to cache.
|
|
54
|
+
ttl: Time-to-live in seconds.
|
|
55
|
+
"""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class InMemoryIdempotencyStore:
|
|
60
|
+
"""In-memory idempotency store for development and testing.
|
|
61
|
+
|
|
62
|
+
Stores responses in a dictionary with TTL-based expiry.
|
|
63
|
+
|
|
64
|
+
Related tests: UT1.29_IdempotencyStore
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
self._store: dict[str, tuple[dict, float]] = {}
|
|
69
|
+
|
|
70
|
+
async def get(self, key: str) -> dict | None:
|
|
71
|
+
"""Get a cached response, returning None if expired.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
key: The idempotency key.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The cached response dict or None.
|
|
78
|
+
"""
|
|
79
|
+
entry = self._store.get(key)
|
|
80
|
+
if entry is None:
|
|
81
|
+
return None
|
|
82
|
+
response, expiry = entry
|
|
83
|
+
if time.monotonic() > expiry:
|
|
84
|
+
del self._store[key]
|
|
85
|
+
return None
|
|
86
|
+
return response
|
|
87
|
+
|
|
88
|
+
async def set(self, key: str, response: dict, ttl: int) -> None:
|
|
89
|
+
"""Store a response with TTL.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
key: The idempotency key.
|
|
93
|
+
response: The response dict.
|
|
94
|
+
ttl: Time-to-live in seconds.
|
|
95
|
+
"""
|
|
96
|
+
self._store[key] = (response, time.monotonic() + ttl)
|
|
97
|
+
|
|
98
|
+
def clear(self) -> None:
|
|
99
|
+
"""Clear all stored entries."""
|
|
100
|
+
self._store.clear()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# cloud_dog_api_kit — Lifecycle integration exports
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Public lifecycle and graceful shutdown exports.
|
|
20
|
+
# Related requirements: FR18.2, FR18.9
|
|
21
|
+
# Related architecture: SA1
|
|
22
|
+
|
|
23
|
+
"""Lifecycle exports for cloud_dog_api_kit."""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from cloud_dog_api_kit.lifecycle.hooks import LifecycleHooks
|
|
28
|
+
from cloud_dog_api_kit.lifecycle.shutdown import (
|
|
29
|
+
GracefulShutdownManager,
|
|
30
|
+
ShutdownDrainMiddleware,
|
|
31
|
+
install_shutdown_signal_handlers,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"GracefulShutdownManager",
|
|
36
|
+
"LifecycleHooks",
|
|
37
|
+
"ShutdownDrainMiddleware",
|
|
38
|
+
"install_shutdown_signal_handlers",
|
|
39
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# cloud_dog_api_kit — Startup lifecycle hook definitions
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Deterministic startup/shutdown hook model for create_app()
|
|
20
|
+
# lifecycle integration.
|
|
21
|
+
# Related requirements: FR18.2
|
|
22
|
+
# Related architecture: SA1
|
|
23
|
+
|
|
24
|
+
"""Lifecycle hook definitions for cloud_dog_api_kit."""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import inspect
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Awaitable, Callable
|
|
31
|
+
|
|
32
|
+
from fastapi import FastAPI
|
|
33
|
+
|
|
34
|
+
LifecycleCallback = Callable[[FastAPI], Awaitable[None] | None]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _run_callback(phase: str, app: FastAPI, callback: LifecycleCallback | None) -> None:
|
|
38
|
+
"""Run a lifecycle callback if configured."""
|
|
39
|
+
if callback is None:
|
|
40
|
+
return
|
|
41
|
+
result = callback(app)
|
|
42
|
+
if inspect.isawaitable(result):
|
|
43
|
+
await result
|
|
44
|
+
if result is not None and not inspect.isawaitable(result):
|
|
45
|
+
# Guard against accidental non-None return values in lifecycle callbacks.
|
|
46
|
+
raise TypeError(f"Lifecycle callback for {phase} must return None or awaitable None")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(slots=True)
|
|
50
|
+
class LifecycleHooks:
|
|
51
|
+
"""Startup/shutdown lifecycle hook collection.
|
|
52
|
+
|
|
53
|
+
Phases:
|
|
54
|
+
- `on_pre_db`: pre-database bootstrap phase.
|
|
55
|
+
- `on_post_db`: post-database, pre-router phase.
|
|
56
|
+
- `on_post_router`: post-router registration, pre-serving phase.
|
|
57
|
+
- `on_shutdown`: graceful shutdown callback.
|
|
58
|
+
|
|
59
|
+
Related tests: UT1.38_StartupLifecycleHooks, ST1.13_StartupLifecycleIntegration
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
on_pre_db: LifecycleCallback | None = None
|
|
63
|
+
on_post_db: LifecycleCallback | None = None
|
|
64
|
+
on_post_router: LifecycleCallback | None = None
|
|
65
|
+
on_shutdown: LifecycleCallback | None = None
|
|
66
|
+
|
|
67
|
+
async def run_startup(self, app: FastAPI) -> None:
|
|
68
|
+
"""Run startup phases in deterministic order."""
|
|
69
|
+
await _run_callback("on_pre_db", app, self.on_pre_db)
|
|
70
|
+
await _run_callback("on_post_db", app, self.on_post_db)
|
|
71
|
+
await _run_callback("on_post_router", app, self.on_post_router)
|
|
72
|
+
|
|
73
|
+
async def run_shutdown(self, app: FastAPI) -> None:
|
|
74
|
+
"""Run shutdown hook."""
|
|
75
|
+
await _run_callback("on_shutdown", app, self.on_shutdown)
|