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,127 @@
|
|
|
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 — Configured HTTP client with retry and timeouts
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Standard HTTP client module for service-to-service calls with
|
|
20
|
+
# configurable timeouts, retry policy with exponential backoff + jitter,
|
|
21
|
+
# and automatic header propagation (X-Request-Id, X-App-Id, API key).
|
|
22
|
+
# Related requirements: FR9.1, FR9.2
|
|
23
|
+
# Related architecture: CC1.15
|
|
24
|
+
|
|
25
|
+
"""Configured HTTP client for service-to-service calls."""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
|
|
33
|
+
from cloud_dog_api_kit.correlation.context import get_request_id, get_app_id
|
|
34
|
+
from cloud_dog_api_kit.clients.retry import RetryPolicy, RetryTransport
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ClientTimeout:
|
|
39
|
+
"""HTTP client timeout configuration.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
connect: Connection timeout in seconds.
|
|
43
|
+
read: Read timeout in seconds.
|
|
44
|
+
total: Total request timeout in seconds.
|
|
45
|
+
|
|
46
|
+
Related tests: UT1.23_HTTPClient
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
connect: float = 5.0
|
|
50
|
+
read: float = 30.0
|
|
51
|
+
total: float = 60.0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def create_http_client(
|
|
55
|
+
base_url: str | None = None,
|
|
56
|
+
timeout: ClientTimeout | None = None,
|
|
57
|
+
retry_policy: RetryPolicy | None = None,
|
|
58
|
+
app_id: str | None = None,
|
|
59
|
+
api_key: str | None = None,
|
|
60
|
+
) -> httpx.AsyncClient:
|
|
61
|
+
"""Create a configured async HTTP client for service-to-service calls.
|
|
62
|
+
|
|
63
|
+
The client automatically propagates X-Request-Id and X-App-Id headers
|
|
64
|
+
and attaches an API key if provided.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
base_url: Base URL for all requests.
|
|
68
|
+
timeout: Timeout configuration. Uses defaults if None.
|
|
69
|
+
retry_policy: Retry policy. Uses defaults if None.
|
|
70
|
+
app_id: Calling service application ID.
|
|
71
|
+
api_key: API key for authentication.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
A configured httpx.AsyncClient.
|
|
75
|
+
|
|
76
|
+
Related tests: UT1.23_HTTPClient, ST1.11_HTTPClientEndToEnd
|
|
77
|
+
"""
|
|
78
|
+
ct = timeout or ClientTimeout()
|
|
79
|
+
policy = retry_policy or RetryPolicy()
|
|
80
|
+
|
|
81
|
+
httpx_timeout = httpx.Timeout(
|
|
82
|
+
timeout=ct.total,
|
|
83
|
+
connect=ct.connect,
|
|
84
|
+
read=ct.read,
|
|
85
|
+
pool=ct.total,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
headers: dict[str, str] = {}
|
|
89
|
+
if app_id:
|
|
90
|
+
headers["X-App-Id"] = app_id
|
|
91
|
+
if api_key:
|
|
92
|
+
headers["X-API-Key"] = api_key
|
|
93
|
+
|
|
94
|
+
return httpx.AsyncClient(
|
|
95
|
+
base_url=base_url or "",
|
|
96
|
+
timeout=httpx_timeout,
|
|
97
|
+
headers=headers,
|
|
98
|
+
transport=RetryTransport(policy=policy, transport=httpx.AsyncHTTPTransport()),
|
|
99
|
+
event_hooks={
|
|
100
|
+
"request": [_inject_correlation_headers],
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def _inject_correlation_headers(request: httpx.Request) -> None:
|
|
106
|
+
"""Event hook to inject correlation headers into outgoing requests.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
request: The outgoing HTTP request.
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
request_id = get_request_id()
|
|
113
|
+
request.headers["X-Request-Id"] = request_id
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
app_id = get_app_id()
|
|
119
|
+
if app_id:
|
|
120
|
+
request.headers["X-App-Id"] = app_id
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def create_retry_transport(policy: RetryPolicy, transport: httpx.AsyncBaseTransport) -> RetryTransport:
|
|
126
|
+
"""Create a retry transport wrapper for httpx."""
|
|
127
|
+
return RetryTransport(policy=policy, transport=transport)
|
|
@@ -0,0 +1,83 @@
|
|
|
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 — Retry policy
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Retry policy with exponential backoff + jitter for HTTP clients.
|
|
20
|
+
# Related requirements: FR9.2
|
|
21
|
+
# Related architecture: SA1
|
|
22
|
+
|
|
23
|
+
"""Retry policy utilities for cloud_dog_api_kit."""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import random
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class RetryPolicy:
|
|
36
|
+
"""Retry policy for idempotent operations.
|
|
37
|
+
|
|
38
|
+
Related tests: UT1.24_RetryPolicy
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
max_retries: int = 3
|
|
42
|
+
backoff_base: float = 0.5
|
|
43
|
+
backoff_max: float = 30.0
|
|
44
|
+
jitter: bool = True
|
|
45
|
+
retry_status_codes: tuple[int, ...] = (502, 503, 504)
|
|
46
|
+
|
|
47
|
+
def get_delay(self, attempt: int) -> float:
|
|
48
|
+
"""Return delay."""
|
|
49
|
+
delay = min(self.backoff_base * (2**attempt), self.backoff_max)
|
|
50
|
+
if self.jitter:
|
|
51
|
+
delay = delay * (0.5 + random.random() * 0.5)
|
|
52
|
+
return delay
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RetryTransport(httpx.AsyncBaseTransport):
|
|
56
|
+
"""HTTP transport wrapper with retry logic."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, policy: RetryPolicy, transport: httpx.AsyncBaseTransport) -> None:
|
|
59
|
+
self._policy = policy
|
|
60
|
+
self._transport = transport
|
|
61
|
+
|
|
62
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
63
|
+
"""Handle handle async request."""
|
|
64
|
+
last_response: httpx.Response | None = None
|
|
65
|
+
last_exc: Exception | None = None
|
|
66
|
+
|
|
67
|
+
for attempt in range(self._policy.max_retries + 1):
|
|
68
|
+
try:
|
|
69
|
+
response = await self._transport.handle_async_request(request)
|
|
70
|
+
if response.status_code not in self._policy.retry_status_codes:
|
|
71
|
+
return response
|
|
72
|
+
last_response = response
|
|
73
|
+
except (httpx.ConnectError, httpx.ReadTimeout) as exc:
|
|
74
|
+
last_exc = exc
|
|
75
|
+
|
|
76
|
+
if attempt < self._policy.max_retries:
|
|
77
|
+
await asyncio.sleep(self._policy.get_delay(attempt))
|
|
78
|
+
|
|
79
|
+
if last_response is not None:
|
|
80
|
+
return last_response
|
|
81
|
+
if last_exc is not None:
|
|
82
|
+
raise last_exc
|
|
83
|
+
raise httpx.ConnectError("All retry attempts exhausted")
|
|
@@ -0,0 +1,37 @@
|
|
|
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 — Compatibility and migration exports
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Migration compatibility middleware exports.
|
|
20
|
+
# Related requirements: FR18.3, FR18.4, FR18.6
|
|
21
|
+
# Related architecture: SA1
|
|
22
|
+
|
|
23
|
+
"""Compatibility middleware exports."""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from cloud_dog_api_kit.compat.envelope import LegacyEnvelopeMiddleware, legacy_envelope_route
|
|
28
|
+
from cloud_dog_api_kit.compat.profile import ProfileContextMiddleware
|
|
29
|
+
from cloud_dog_api_kit.compat.routes import LegacyRouteAdapter, LegacyRouteAdapterMiddleware
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"LegacyEnvelopeMiddleware",
|
|
33
|
+
"LegacyRouteAdapter",
|
|
34
|
+
"LegacyRouteAdapterMiddleware",
|
|
35
|
+
"ProfileContextMiddleware",
|
|
36
|
+
"legacy_envelope_route",
|
|
37
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
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 — Legacy envelope compatibility middleware
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Middleware to wrap legacy non-enveloped responses into standard
|
|
20
|
+
# success/error envelopes during phased migrations.
|
|
21
|
+
# Related requirements: FR18.3
|
|
22
|
+
# Related architecture: SA1
|
|
23
|
+
|
|
24
|
+
"""Legacy response envelope compatibility middleware."""
|
|
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.envelopes import error_envelope, success_envelope
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def legacy_envelope_route(func: Callable) -> Callable:
|
|
39
|
+
"""Mark a route endpoint as legacy-envelope compatible."""
|
|
40
|
+
setattr(func, "__legacy_envelope__", True)
|
|
41
|
+
return func
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LegacyEnvelopeMiddleware(BaseHTTPMiddleware):
|
|
45
|
+
"""Wrap legacy responses in standard envelopes for opt-in routes.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
app: ASGI app.
|
|
49
|
+
opt_in_paths: Exact route paths that require envelope wrapping.
|
|
50
|
+
opt_in_header: Optional request header to force legacy envelope mode.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
app: Any,
|
|
56
|
+
*,
|
|
57
|
+
opt_in_paths: set[str] | None = None,
|
|
58
|
+
opt_in_header: str = "X-Legacy-Envelope",
|
|
59
|
+
) -> None:
|
|
60
|
+
super().__init__(app)
|
|
61
|
+
self._opt_in_paths = set(opt_in_paths or set())
|
|
62
|
+
self._opt_in_header = opt_in_header.lower()
|
|
63
|
+
|
|
64
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
65
|
+
"""Apply compatibility envelopes on opt-in routes."""
|
|
66
|
+
response = await call_next(request)
|
|
67
|
+
if not self._should_wrap(request):
|
|
68
|
+
return response
|
|
69
|
+
if response.status_code == 204:
|
|
70
|
+
return response
|
|
71
|
+
|
|
72
|
+
body_bytes = b""
|
|
73
|
+
async for chunk in response.body_iterator:
|
|
74
|
+
body_bytes += chunk
|
|
75
|
+
|
|
76
|
+
payload: Any
|
|
77
|
+
if body_bytes:
|
|
78
|
+
try:
|
|
79
|
+
payload = json.loads(body_bytes)
|
|
80
|
+
except json.JSONDecodeError:
|
|
81
|
+
payload = {"message": body_bytes.decode("utf-8", errors="replace")}
|
|
82
|
+
else:
|
|
83
|
+
payload = {}
|
|
84
|
+
|
|
85
|
+
request_id = getattr(request.state, "request_id", "")
|
|
86
|
+
correlation_id = getattr(request.state, "correlation_id", None)
|
|
87
|
+
|
|
88
|
+
if isinstance(payload, dict) and "ok" in payload and ("data" in payload or "error" in payload):
|
|
89
|
+
envelope = payload
|
|
90
|
+
elif response.status_code >= 400:
|
|
91
|
+
message = "Request failed"
|
|
92
|
+
details = None
|
|
93
|
+
if isinstance(payload, dict):
|
|
94
|
+
message = str(payload.get("message", payload.get("error", message)))
|
|
95
|
+
details = payload.get("details")
|
|
96
|
+
envelope = error_envelope(
|
|
97
|
+
code="INVALID_REQUEST" if response.status_code < 500 else "INTERNAL_ERROR",
|
|
98
|
+
message=message,
|
|
99
|
+
details=details,
|
|
100
|
+
request_id=request_id,
|
|
101
|
+
correlation_id=correlation_id,
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
envelope = success_envelope(
|
|
105
|
+
data=payload,
|
|
106
|
+
request_id=request_id,
|
|
107
|
+
correlation_id=correlation_id,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
headers = {k: v for k, v in response.headers.items() if k.lower() != "content-length"}
|
|
111
|
+
return JSONResponse(status_code=response.status_code, content=envelope, headers=headers)
|
|
112
|
+
|
|
113
|
+
def _should_wrap(self, request: Request) -> bool:
|
|
114
|
+
"""Determine whether current request should be wrapped."""
|
|
115
|
+
endpoint = request.scope.get("endpoint")
|
|
116
|
+
if endpoint is not None and bool(getattr(endpoint, "__legacy_envelope__", False)):
|
|
117
|
+
return True
|
|
118
|
+
if request.url.path in self._opt_in_paths:
|
|
119
|
+
return True
|
|
120
|
+
return request.headers.get(self._opt_in_header, "").strip().lower() in {"1", "true", "yes"}
|
|
@@ -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 — Profile context middleware
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Middleware that resolves request profile context from headers,
|
|
20
|
+
# path patterns, or query parameters and stores it in request.state.
|
|
21
|
+
# Related requirements: FR18.6
|
|
22
|
+
# Related architecture: SA1
|
|
23
|
+
|
|
24
|
+
"""Profile context resolution middleware."""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
from typing import Any, Callable, Pattern
|
|
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.envelopes.error import error_envelope
|
|
36
|
+
|
|
37
|
+
DEFAULT_PATH_PATTERNS = (
|
|
38
|
+
re.compile(r"/profiles/(?P<profile>[A-Za-z0-9._-]+)"),
|
|
39
|
+
re.compile(r"/profile/(?P<profile>[A-Za-z0-9._-]+)"),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ProfileContextMiddleware(BaseHTTPMiddleware):
|
|
44
|
+
"""Resolve per-request profile context.
|
|
45
|
+
|
|
46
|
+
Resolution order:
|
|
47
|
+
1. Header (default: `X-Profile`)
|
|
48
|
+
2. Path pattern match
|
|
49
|
+
3. Query parameter (default: `profile`)
|
|
50
|
+
4. Default profile
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
app: Any,
|
|
56
|
+
*,
|
|
57
|
+
header_name: str = "X-Profile",
|
|
58
|
+
query_param: str = "profile",
|
|
59
|
+
default_profile: str | None = None,
|
|
60
|
+
allowed_profiles: set[str] | None = None,
|
|
61
|
+
path_patterns: tuple[Pattern[str], ...] | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
super().__init__(app)
|
|
64
|
+
self._header_name = header_name
|
|
65
|
+
self._query_param = query_param
|
|
66
|
+
self._default_profile = default_profile
|
|
67
|
+
self._allowed_profiles = allowed_profiles
|
|
68
|
+
self._path_patterns = path_patterns or DEFAULT_PATH_PATTERNS
|
|
69
|
+
|
|
70
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
71
|
+
"""Resolve profile context and validate allowed profile list."""
|
|
72
|
+
profile = (
|
|
73
|
+
request.headers.get(self._header_name)
|
|
74
|
+
or self._profile_from_path(request.url.path)
|
|
75
|
+
or request.query_params.get(self._query_param)
|
|
76
|
+
or self._default_profile
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if self._allowed_profiles is not None and profile and profile not in self._allowed_profiles:
|
|
80
|
+
request_id = getattr(request.state, "request_id", "")
|
|
81
|
+
correlation_id = getattr(request.state, "correlation_id", None)
|
|
82
|
+
return JSONResponse(
|
|
83
|
+
status_code=400,
|
|
84
|
+
content=error_envelope(
|
|
85
|
+
code="INVALID_REQUEST",
|
|
86
|
+
message="Invalid profile",
|
|
87
|
+
details={"profile": profile},
|
|
88
|
+
request_id=request_id,
|
|
89
|
+
correlation_id=correlation_id,
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
request.state.profile = profile
|
|
94
|
+
return await call_next(request)
|
|
95
|
+
|
|
96
|
+
def _profile_from_path(self, path: str) -> str | None:
|
|
97
|
+
"""Extract profile identifier from known path patterns."""
|
|
98
|
+
for pattern in self._path_patterns:
|
|
99
|
+
match = pattern.search(path)
|
|
100
|
+
if match:
|
|
101
|
+
return match.group("profile")
|
|
102
|
+
return None
|
|
@@ -0,0 +1,90 @@
|
|
|
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 — Legacy route migration adapter
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Route migration adapter that maps legacy non-versioned paths to
|
|
20
|
+
# versioned API paths and adds deprecation headers.
|
|
21
|
+
# Related requirements: FR18.4
|
|
22
|
+
# Related architecture: SA1
|
|
23
|
+
|
|
24
|
+
"""Legacy route migration helpers."""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Any, Callable
|
|
30
|
+
|
|
31
|
+
from fastapi import FastAPI
|
|
32
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
33
|
+
from starlette.requests import Request
|
|
34
|
+
from starlette.responses import RedirectResponse, Response
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class LegacyRouteAdapter:
|
|
39
|
+
"""Adapter mapping legacy routes to versioned paths."""
|
|
40
|
+
|
|
41
|
+
route_map: dict[str, str]
|
|
42
|
+
sunset: str | None = None
|
|
43
|
+
link: str | None = None
|
|
44
|
+
redirect: bool = False
|
|
45
|
+
deprecation: str = "true"
|
|
46
|
+
methods: set[str] = field(default_factory=lambda: {"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"})
|
|
47
|
+
|
|
48
|
+
def resolve(self, path: str) -> str | None:
|
|
49
|
+
"""Resolve a legacy path to a versioned path."""
|
|
50
|
+
return self.route_map.get(path)
|
|
51
|
+
|
|
52
|
+
def deprecation_headers(self) -> dict[str, str]:
|
|
53
|
+
"""Build response headers for legacy route responses."""
|
|
54
|
+
headers = {"Deprecation": self.deprecation}
|
|
55
|
+
if self.sunset:
|
|
56
|
+
headers["Sunset"] = self.sunset
|
|
57
|
+
if self.link:
|
|
58
|
+
headers["Link"] = self.link
|
|
59
|
+
return headers
|
|
60
|
+
|
|
61
|
+
def register(self, app: FastAPI) -> None:
|
|
62
|
+
"""Register legacy route adapter middleware on an app."""
|
|
63
|
+
app.add_middleware(LegacyRouteAdapterMiddleware, adapter=self)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LegacyRouteAdapterMiddleware(BaseHTTPMiddleware):
|
|
67
|
+
"""Middleware implementing legacy route mapping and deprecation headers."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, app: Any, adapter: LegacyRouteAdapter) -> None:
|
|
70
|
+
super().__init__(app)
|
|
71
|
+
self._adapter = adapter
|
|
72
|
+
|
|
73
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
74
|
+
"""Map legacy route to versioned path and add deprecation headers."""
|
|
75
|
+
target_path = self._adapter.resolve(request.url.path)
|
|
76
|
+
if target_path is None or request.method.upper() not in self._adapter.methods:
|
|
77
|
+
return await call_next(request)
|
|
78
|
+
|
|
79
|
+
headers = self._adapter.deprecation_headers()
|
|
80
|
+
if self._adapter.redirect:
|
|
81
|
+
query = request.url.query
|
|
82
|
+
location = target_path if not query else f"{target_path}?{query}"
|
|
83
|
+
return RedirectResponse(url=location, status_code=307, headers=headers)
|
|
84
|
+
|
|
85
|
+
request.scope["path"] = target_path
|
|
86
|
+
request.scope["raw_path"] = target_path.encode("utf-8")
|
|
87
|
+
response = await call_next(request)
|
|
88
|
+
for name, value in headers.items():
|
|
89
|
+
response.headers[name] = value
|
|
90
|
+
return response
|
|
@@ -0,0 +1,54 @@
|
|
|
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 — Configuration model
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Settings model for wiring cloud_dog_api_kit in a service. This
|
|
20
|
+
# module intentionally keeps configuration minimal and provider-agnostic.
|
|
21
|
+
# Related requirements: FR10.3, FR11.1, FR13.1
|
|
22
|
+
# Related architecture: SA1
|
|
23
|
+
|
|
24
|
+
"""Configuration model for cloud_dog_api_kit."""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from pydantic import BaseModel, Field
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class APIKitSettings(BaseModel):
|
|
32
|
+
"""Settings for cloud_dog_api_kit.
|
|
33
|
+
|
|
34
|
+
These settings can be sourced from `cloud_dog_config` when available, but
|
|
35
|
+
are also usable standalone (e.g. local tests).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
api_prefix: str = Field(default="/api/v1", description="API prefix for versioned routes")
|
|
39
|
+
api_version: str = Field(default="v1", description="API version string for headers and metadata")
|
|
40
|
+
enable_docs: bool = Field(default=True, description="Whether to enable /docs and /redoc")
|
|
41
|
+
enable_request_logging: bool = Field(default=True, description="Whether to enable request logging middleware")
|
|
42
|
+
enable_cors: bool = Field(default=True, description="Whether to enable CORS middleware")
|
|
43
|
+
cors_origins: list[str] = Field(default_factory=list, description="Allowed CORS origins")
|
|
44
|
+
timeout_seconds: float = Field(default=30.0, ge=0.1, description="Request timeout in seconds")
|
|
45
|
+
max_request_body_bytes: int | None = Field(
|
|
46
|
+
default=None,
|
|
47
|
+
ge=1,
|
|
48
|
+
description="Maximum request body size in bytes; None disables size checks",
|
|
49
|
+
)
|
|
50
|
+
shutdown_drain_timeout_seconds: float = Field(
|
|
51
|
+
default=5.0,
|
|
52
|
+
ge=0.0,
|
|
53
|
+
description="Graceful shutdown request-drain timeout in seconds",
|
|
54
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
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 — Correlation ID context and middleware
|
|
16
|
+
#
|
|
17
|
+
# Licence: Proprietary — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog AI
|
|
19
|
+
# Description: Context-local correlation ID, app ID, and host ID management
|
|
20
|
+
# with ASGI middleware for automatic extraction/propagation.
|
|
21
|
+
# Related requirements: FR5.1, FR3.2
|
|
22
|
+
# Related architecture: CC1.8
|
|
23
|
+
|
|
24
|
+
"""Correlation ID context and middleware for cloud_dog_api_kit."""
|
|
25
|
+
|
|
26
|
+
from cloud_dog_api_kit.correlation.context import (
|
|
27
|
+
get_app_id,
|
|
28
|
+
get_correlation_id,
|
|
29
|
+
get_host_id,
|
|
30
|
+
get_request_id,
|
|
31
|
+
set_app_id,
|
|
32
|
+
set_correlation_id,
|
|
33
|
+
set_host_id,
|
|
34
|
+
set_request_id,
|
|
35
|
+
clear_context,
|
|
36
|
+
)
|
|
37
|
+
from cloud_dog_api_kit.correlation.middleware import CorrelationMiddleware
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"get_app_id",
|
|
41
|
+
"get_correlation_id",
|
|
42
|
+
"get_host_id",
|
|
43
|
+
"get_request_id",
|
|
44
|
+
"set_app_id",
|
|
45
|
+
"set_correlation_id",
|
|
46
|
+
"set_host_id",
|
|
47
|
+
"set_request_id",
|
|
48
|
+
"clear_context",
|
|
49
|
+
"CorrelationMiddleware",
|
|
50
|
+
]
|