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.
Files changed (98) hide show
  1. cloud_dog_api_kit/__init__.py +170 -0
  2. cloud_dog_api_kit/a2a/__init__.py +53 -0
  3. cloud_dog_api_kit/a2a/card.py +138 -0
  4. cloud_dog_api_kit/a2a/events.py +1123 -0
  5. cloud_dog_api_kit/a2a/gateway.py +105 -0
  6. cloud_dog_api_kit/a2a/skill_audit.py +107 -0
  7. cloud_dog_api_kit/auth/__init__.py +35 -0
  8. cloud_dog_api_kit/auth/dependency.py +121 -0
  9. cloud_dog_api_kit/auth/rbac.py +107 -0
  10. cloud_dog_api_kit/auth/service_auth.py +54 -0
  11. cloud_dog_api_kit/clients/__init__.py +29 -0
  12. cloud_dog_api_kit/clients/circuit_breaker.py +39 -0
  13. cloud_dog_api_kit/clients/http_client.py +127 -0
  14. cloud_dog_api_kit/clients/retry.py +83 -0
  15. cloud_dog_api_kit/compat/__init__.py +37 -0
  16. cloud_dog_api_kit/compat/envelope.py +120 -0
  17. cloud_dog_api_kit/compat/profile.py +102 -0
  18. cloud_dog_api_kit/compat/routes.py +90 -0
  19. cloud_dog_api_kit/config.py +54 -0
  20. cloud_dog_api_kit/correlation/__init__.py +50 -0
  21. cloud_dog_api_kit/correlation/context.py +118 -0
  22. cloud_dog_api_kit/correlation/middleware.py +133 -0
  23. cloud_dog_api_kit/envelopes/__init__.py +37 -0
  24. cloud_dog_api_kit/envelopes/error.py +87 -0
  25. cloud_dog_api_kit/envelopes/success.py +84 -0
  26. cloud_dog_api_kit/errors/__init__.py +51 -0
  27. cloud_dog_api_kit/errors/exceptions.py +184 -0
  28. cloud_dog_api_kit/errors/handler.py +102 -0
  29. cloud_dog_api_kit/errors/taxonomy.py +62 -0
  30. cloud_dog_api_kit/factory.py +157 -0
  31. cloud_dog_api_kit/idempotency/__init__.py +28 -0
  32. cloud_dog_api_kit/idempotency/middleware.py +118 -0
  33. cloud_dog_api_kit/idempotency/store.py +100 -0
  34. cloud_dog_api_kit/lifecycle/__init__.py +39 -0
  35. cloud_dog_api_kit/lifecycle/hooks.py +75 -0
  36. cloud_dog_api_kit/lifecycle/shutdown.py +178 -0
  37. cloud_dog_api_kit/mcp/__init__.py +122 -0
  38. cloud_dog_api_kit/mcp/async_jobs.py +126 -0
  39. cloud_dog_api_kit/mcp/client_sdk.py +235 -0
  40. cloud_dog_api_kit/mcp/client_transport/__init__.py +47 -0
  41. cloud_dog_api_kit/mcp/client_transport/base.py +98 -0
  42. cloud_dog_api_kit/mcp/client_transport/exceptions.py +37 -0
  43. cloud_dog_api_kit/mcp/client_transport/http_jsonrpc.py +405 -0
  44. cloud_dog_api_kit/mcp/client_transport/legacy_sse.py +320 -0
  45. cloud_dog_api_kit/mcp/client_transport/stdio.py +322 -0
  46. cloud_dog_api_kit/mcp/client_transport/streamable_http.py +748 -0
  47. cloud_dog_api_kit/mcp/contract.py +113 -0
  48. cloud_dog_api_kit/mcp/error_mapper.py +84 -0
  49. cloud_dog_api_kit/mcp/gateway.py +117 -0
  50. cloud_dog_api_kit/mcp/legacy_sse.py +129 -0
  51. cloud_dog_api_kit/mcp/session.py +96 -0
  52. cloud_dog_api_kit/mcp/sync_handler.py +269 -0
  53. cloud_dog_api_kit/mcp/tool_audit.py +136 -0
  54. cloud_dog_api_kit/mcp/tool_router.py +180 -0
  55. cloud_dog_api_kit/mcp/transport.py +1041 -0
  56. cloud_dog_api_kit/middleware/__init__.py +39 -0
  57. cloud_dog_api_kit/middleware/cors.py +74 -0
  58. cloud_dog_api_kit/middleware/logging.py +98 -0
  59. cloud_dog_api_kit/middleware/request_size_limit.py +86 -0
  60. cloud_dog_api_kit/middleware/timeout.py +78 -0
  61. cloud_dog_api_kit/middleware/timing.py +52 -0
  62. cloud_dog_api_kit/openapi/__init__.py +30 -0
  63. cloud_dog_api_kit/openapi/customise.py +69 -0
  64. cloud_dog_api_kit/openapi/route.py +46 -0
  65. cloud_dog_api_kit/routers/__init__.py +41 -0
  66. cloud_dog_api_kit/routers/crud.py +173 -0
  67. cloud_dog_api_kit/routers/health.py +160 -0
  68. cloud_dog_api_kit/routers/jobs.py +69 -0
  69. cloud_dog_api_kit/routers/version.py +46 -0
  70. cloud_dog_api_kit/schemas/__init__.py +36 -0
  71. cloud_dog_api_kit/schemas/envelopes.py +37 -0
  72. cloud_dog_api_kit/schemas/filters.py +103 -0
  73. cloud_dog_api_kit/schemas/pagination.py +148 -0
  74. cloud_dog_api_kit/streaming/__init__.py +28 -0
  75. cloud_dog_api_kit/streaming/events.py +47 -0
  76. cloud_dog_api_kit/streaming/jsonl.py +68 -0
  77. cloud_dog_api_kit/streaming/sse.py +102 -0
  78. cloud_dog_api_kit/testing/__init__.py +46 -0
  79. cloud_dog_api_kit/testing/conformance.py +156 -0
  80. cloud_dog_api_kit/testing/fixtures.py +90 -0
  81. cloud_dog_api_kit/testing/flows/__init__.py +32 -0
  82. cloud_dog_api_kit/testing/flows/auth_flow.py +41 -0
  83. cloud_dog_api_kit/testing/flows/crud_flow.py +50 -0
  84. cloud_dog_api_kit/testing/flows/job_flow.py +42 -0
  85. cloud_dog_api_kit/testing/flows/streaming_flow.py +42 -0
  86. cloud_dog_api_kit/traceability_ids.py +84 -0
  87. cloud_dog_api_kit/versioning/__init__.py +30 -0
  88. cloud_dog_api_kit/versioning/header.py +52 -0
  89. cloud_dog_api_kit/web/__init__.py +7 -0
  90. cloud_dog_api_kit/web/proxy.py +222 -0
  91. cloud_dog_api_kit/webhook/__init__.py +29 -0
  92. cloud_dog_api_kit/webhook/signature.py +149 -0
  93. cloud_dog_api_kit-0.13.0.dist-info/METADATA +27 -0
  94. cloud_dog_api_kit-0.13.0.dist-info/RECORD +98 -0
  95. cloud_dog_api_kit-0.13.0.dist-info/WHEEL +4 -0
  96. cloud_dog_api_kit-0.13.0.dist-info/licenses/LICENCE +190 -0
  97. cloud_dog_api_kit-0.13.0.dist-info/licenses/LICENSE +176 -0
  98. 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)