cloudwire 0.1.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.
cloudwire/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CloudWire — scan and visualize your AWS infrastructure."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """CloudWire backend package."""
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from threading import Lock
5
+ from typing import Any, Dict, List
6
+
7
+ import networkx as nx
8
+
9
+
10
+ class GraphStore:
11
+ def __init__(self) -> None:
12
+ self.graph = nx.DiGraph()
13
+ self.metadata: Dict[str, Any] = {
14
+ "last_scan_at": None,
15
+ "region": None,
16
+ "scanned_services": [],
17
+ "warnings": [],
18
+ }
19
+ self._lock = Lock()
20
+
21
+ def reset(self, *, region: str, services: List[str]) -> None:
22
+ with self._lock:
23
+ self.graph = nx.DiGraph()
24
+ self.metadata = {
25
+ "last_scan_at": datetime.now(timezone.utc).isoformat(),
26
+ "region": region,
27
+ "scanned_services": services,
28
+ "warnings": [],
29
+ }
30
+
31
+ def add_warning(self, warning: str) -> None:
32
+ with self._lock:
33
+ self.metadata.setdefault("warnings", []).append(warning)
34
+
35
+ def update_metadata(self, **kwargs: Any) -> None:
36
+ with self._lock:
37
+ self.metadata.update(kwargs)
38
+
39
+ def add_node(self, node_id: str, **attrs: Any) -> None:
40
+ with self._lock:
41
+ current = self.graph.nodes[node_id] if self.graph.has_node(node_id) else {}
42
+ merged = {**current, **attrs}
43
+ merged["id"] = node_id
44
+ self.graph.add_node(node_id, **merged)
45
+
46
+ def add_edge(self, source: str, target: str, **attrs: Any) -> None:
47
+ with self._lock:
48
+ current = self.graph.get_edge_data(source, target, default={})
49
+ merged = {**current, **attrs}
50
+ self.graph.add_edge(source, target, **merged)
51
+
52
+ def _serialize_node(self, node_id: str, attrs: Dict[str, Any]) -> Dict[str, Any]:
53
+ payload = {"id": node_id}
54
+ payload.update(attrs)
55
+ return payload
56
+
57
+ def _serialize_edge(self, source: str, target: str, attrs: Dict[str, Any]) -> Dict[str, Any]:
58
+ payload = {"id": f"{source}__{target}", "source": source, "target": target}
59
+ payload.update(attrs)
60
+ return payload
61
+
62
+ def get_graph_payload(self) -> Dict[str, Any]:
63
+ with self._lock:
64
+ nodes = [self._serialize_node(node_id, attrs) for node_id, attrs in self.graph.nodes(data=True)]
65
+ edges = [
66
+ self._serialize_edge(source, target, attrs)
67
+ for source, target, attrs in self.graph.edges(data=True)
68
+ ]
69
+ metadata = dict(self.metadata)
70
+ metadata["node_count"] = len(nodes)
71
+ metadata["edge_count"] = len(edges)
72
+ return {"nodes": nodes, "edges": edges, "metadata": metadata}
73
+
74
+ def get_resource_payload(self, resource_id: str) -> Dict[str, Any]:
75
+ with self._lock:
76
+ if not self.graph.has_node(resource_id):
77
+ raise KeyError(resource_id)
78
+
79
+ node = self._serialize_node(resource_id, dict(self.graph.nodes[resource_id]))
80
+ incoming = [
81
+ self._serialize_edge(source, resource_id, dict(attrs))
82
+ for source, _, attrs in self.graph.in_edges(resource_id, data=True)
83
+ ]
84
+ outgoing = [
85
+ self._serialize_edge(resource_id, target, dict(attrs))
86
+ for _, target, attrs in self.graph.out_edges(resource_id, data=True)
87
+ ]
88
+ return {"node": node, "incoming": incoming, "outgoing": outgoing}
cloudwire/app/main.py ADDED
@@ -0,0 +1,453 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from contextlib import asynccontextmanager
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ import boto3
9
+ from botocore.config import Config
10
+ from botocore.exceptions import (
11
+ BotoCoreError,
12
+ ClientError,
13
+ ConnectTimeoutError,
14
+ CredentialRetrievalError,
15
+ EndpointConnectionError,
16
+ NoCredentialsError,
17
+ PartialCredentialsError,
18
+ ReadTimeoutError,
19
+ )
20
+ from fastapi import APIRouter, FastAPI, HTTPException, Query, Request, status
21
+ from fastapi.exceptions import RequestValidationError
22
+ from fastapi.responses import FileResponse, JSONResponse
23
+ from fastapi.staticfiles import StaticFiles
24
+
25
+ from .models import (
26
+ APIErrorResponse,
27
+ GraphResponse,
28
+ ResourceResponse,
29
+ ScanJobCreateResponse,
30
+ ScanJobStatusResponse,
31
+ ScanRequest,
32
+ )
33
+ from .scan_jobs import ScanJobStore
34
+ from .scanner import AWSGraphScanner, ScanCancelledError, ScanExecutionOptions
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Static-file directory (cloudwire/static/ relative to this package)
40
+ # ---------------------------------------------------------------------------
41
+ _STATIC_DIR = Path(__file__).parent.parent / "static"
42
+
43
+
44
+ class APIError(Exception):
45
+ def __init__(
46
+ self,
47
+ *,
48
+ status_code: int,
49
+ code: str,
50
+ message: str,
51
+ details: Optional[Any] = None,
52
+ ) -> None:
53
+ super().__init__(message)
54
+ self.status_code = status_code
55
+ self.code = code
56
+ self.message = message
57
+ self.details = details
58
+
59
+
60
+ def _error_payload(code: str, message: str, details: Optional[Any] = None) -> Dict[str, Any]:
61
+ return {
62
+ "error": {
63
+ "code": code,
64
+ "message": message,
65
+ "details": details,
66
+ }
67
+ }
68
+
69
+
70
+ def _normalize_services(services: List[str]) -> List[str]:
71
+ aliases = {
72
+ "api-gateway": "apigateway",
73
+ "apigw": "apigateway",
74
+ "event-bridge": "eventbridge",
75
+ "events": "eventbridge",
76
+ }
77
+ normalized = []
78
+ for service in services:
79
+ key = aliases.get(service.lower().strip(), service.lower().strip())
80
+ if key and key not in normalized:
81
+ normalized.append(key)
82
+ return normalized
83
+
84
+
85
+ def _resolve_option(value: Optional[bool], default: bool) -> bool:
86
+ return default if value is None else value
87
+
88
+
89
+ def _resolve_scan_options(payload: ScanRequest) -> ScanExecutionOptions:
90
+ default_iam = payload.mode == "deep"
91
+ default_describes = payload.mode == "deep"
92
+ return ScanExecutionOptions(
93
+ mode=payload.mode,
94
+ include_iam_inference=_resolve_option(payload.include_iam_inference, default_iam),
95
+ include_resource_describes=_resolve_option(payload.include_resource_describes, default_describes),
96
+ )
97
+
98
+
99
+ def _cache_ttl_seconds(mode: str) -> int:
100
+ return 300 if mode == "quick" else 1800
101
+
102
+
103
+ def _friendly_exception_message(exc: Exception) -> str:
104
+ if isinstance(exc, (NoCredentialsError, PartialCredentialsError, CredentialRetrievalError)):
105
+ return "AWS credentials were not found. Set AWS credentials or run saml2aws login before scanning."
106
+ if isinstance(exc, (EndpointConnectionError, ConnectTimeoutError, ReadTimeoutError)):
107
+ return "Unable to reach the AWS API endpoint for the selected region."
108
+ if isinstance(exc, ClientError):
109
+ code = exc.response.get("Error", {}).get("Code", "")
110
+ if code in {"ExpiredToken", "ExpiredTokenException", "RequestExpired"}:
111
+ return "Your AWS session has expired. Refresh credentials and try again."
112
+ if code in {"AccessDenied", "AccessDeniedException", "UnauthorizedOperation"}:
113
+ return "AWS access was denied for this operation. Verify the assumed role permissions."
114
+ message = exc.response.get("Error", {}).get("Message")
115
+ return message or f"AWS API request failed with {code or 'ClientError'}."
116
+ if isinstance(exc, BotoCoreError):
117
+ return "The AWS SDK failed to complete the request."
118
+ return str(exc) or "Unexpected server error."
119
+
120
+
121
+ def _resolve_account_id(region: str) -> str:
122
+ session = boto3.session.Session(region_name=region)
123
+ client = session.client(
124
+ "sts",
125
+ config=Config(
126
+ retries={"mode": "adaptive", "max_attempts": 10},
127
+ max_pool_connections=8,
128
+ connect_timeout=3,
129
+ read_timeout=10,
130
+ ),
131
+ )
132
+ try:
133
+ identity = client.get_caller_identity()
134
+ return str(identity.get("Account", "unknown"))
135
+ except (NoCredentialsError, PartialCredentialsError, CredentialRetrievalError) as exc:
136
+ raise APIError(
137
+ status_code=status.HTTP_401_UNAUTHORIZED,
138
+ code="aws_credentials_missing",
139
+ message=_friendly_exception_message(exc),
140
+ ) from exc
141
+ except ClientError as exc:
142
+ aws_code = exc.response.get("Error", {}).get("Code", "")
143
+ status_code = (
144
+ status.HTTP_403_FORBIDDEN
145
+ if aws_code in {"AccessDenied", "AccessDeniedException", "UnauthorizedOperation"}
146
+ else status.HTTP_401_UNAUTHORIZED
147
+ if aws_code in {"ExpiredToken", "ExpiredTokenException", "RequestExpired"}
148
+ else status.HTTP_502_BAD_GATEWAY
149
+ )
150
+ raise APIError(
151
+ status_code=status_code,
152
+ code="aws_account_lookup_failed",
153
+ message=_friendly_exception_message(exc),
154
+ details={"aws_error_code": aws_code or None, "region": region},
155
+ ) from exc
156
+ except (EndpointConnectionError, ConnectTimeoutError, ReadTimeoutError) as exc:
157
+ raise APIError(
158
+ status_code=status.HTTP_502_BAD_GATEWAY,
159
+ code="aws_endpoint_unreachable",
160
+ message=_friendly_exception_message(exc),
161
+ details={"region": region},
162
+ ) from exc
163
+ except BotoCoreError as exc:
164
+ raise APIError(
165
+ status_code=status.HTTP_502_BAD_GATEWAY,
166
+ code="aws_client_error",
167
+ message=_friendly_exception_message(exc),
168
+ details={"region": region},
169
+ ) from exc
170
+
171
+
172
+ job_store = ScanJobStore(max_workers=4)
173
+
174
+
175
+ @asynccontextmanager
176
+ async def lifespan(_app: FastAPI):
177
+ yield
178
+ job_store.shutdown()
179
+
180
+
181
+ app = FastAPI(title="CloudWire API", version="0.1.0", lifespan=lifespan)
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # Exception handlers
185
+ # ---------------------------------------------------------------------------
186
+
187
+ @app.exception_handler(APIError)
188
+ async def api_error_handler(_: Request, exc: APIError) -> JSONResponse:
189
+ return JSONResponse(
190
+ status_code=exc.status_code,
191
+ content=_error_payload(exc.code, exc.message, exc.details),
192
+ )
193
+
194
+
195
+ @app.exception_handler(HTTPException)
196
+ async def http_exception_handler(_: Request, exc: HTTPException) -> JSONResponse:
197
+ detail = exc.detail
198
+ if isinstance(detail, dict) and "error" in detail:
199
+ payload = detail
200
+ elif isinstance(detail, str):
201
+ payload = _error_payload("http_error", detail)
202
+ else:
203
+ payload = _error_payload("http_error", "Request failed.", detail)
204
+ return JSONResponse(status_code=exc.status_code, content=payload)
205
+
206
+
207
+ @app.exception_handler(RequestValidationError)
208
+ async def validation_exception_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
209
+ return JSONResponse(
210
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
211
+ content=_error_payload(
212
+ "validation_error",
213
+ "Request validation failed.",
214
+ exc.errors(),
215
+ ),
216
+ )
217
+
218
+
219
+ @app.exception_handler(Exception)
220
+ async def unexpected_exception_handler(_: Request, exc: Exception) -> JSONResponse:
221
+ logger.exception("Unhandled API exception", exc_info=exc)
222
+ return JSONResponse(
223
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
224
+ content=_error_payload("internal_error", "Unexpected server error."),
225
+ )
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Scan runner (background thread)
230
+ # ---------------------------------------------------------------------------
231
+
232
+ def _run_scan_job(
233
+ *,
234
+ job_id: str,
235
+ region: str,
236
+ services: List[str],
237
+ account_id: str,
238
+ options: ScanExecutionOptions,
239
+ ) -> None:
240
+ job_store.mark_running(job_id)
241
+ if job_store.is_cancel_requested(job_id):
242
+ job_store.mark_cancelled(job_id)
243
+ return
244
+ job = job_store.get_job(job_id)
245
+ scanner = AWSGraphScanner(job.graph_store, options=options)
246
+
247
+ def on_progress(event: str, service: str, services_done: int, services_total: int) -> None:
248
+ job_store.update_progress(
249
+ job_id,
250
+ event=event,
251
+ current_service=service,
252
+ services_done=services_done,
253
+ services_total=services_total,
254
+ )
255
+
256
+ try:
257
+ scanner.scan(
258
+ region=region,
259
+ services=services,
260
+ account_id=account_id,
261
+ progress_callback=on_progress,
262
+ should_cancel=lambda: job_store.is_cancel_requested(job_id),
263
+ )
264
+ if job_store.is_cancel_requested(job_id):
265
+ job_store.mark_cancelled(job_id)
266
+ return
267
+ job_store.mark_completed(job_id, ttl_seconds=_cache_ttl_seconds(options.mode))
268
+ except ScanCancelledError:
269
+ job.graph_store.add_warning("Scan cancelled by user request.")
270
+ job_store.mark_cancelled(job_id)
271
+ except Exception as exc:
272
+ logger.exception("Scan job %s failed with unhandled exception", job_id)
273
+ message = _friendly_exception_message(exc)
274
+ job.graph_store.add_warning(f"scan failed: {message}")
275
+ job_store.mark_failed(job_id, message)
276
+
277
+
278
+ # ---------------------------------------------------------------------------
279
+ # API routes (all under /api prefix)
280
+ # ---------------------------------------------------------------------------
281
+
282
+ api = APIRouter(prefix="/api")
283
+
284
+
285
+ @api.get("/health")
286
+ def health() -> Dict[str, Any]:
287
+ return {"service": "cloudwire", "status": "ok"}
288
+
289
+
290
+ @api.get("/graph", response_model=GraphResponse, responses={500: {"model": APIErrorResponse}})
291
+ def get_graph() -> Dict[str, Any]:
292
+ return job_store.get_latest_graph_payload()
293
+
294
+
295
+ @api.get(
296
+ "/resource/{resource_id}",
297
+ response_model=ResourceResponse,
298
+ responses={404: {"model": APIErrorResponse}, 500: {"model": APIErrorResponse}},
299
+ )
300
+ def get_resource(resource_id: str, job_id: Optional[str] = Query(default=None)) -> Dict[str, Any]:
301
+ try:
302
+ return job_store.get_resource_payload(resource_id, job_id=job_id)
303
+ except KeyError as exc:
304
+ raise APIError(
305
+ status_code=status.HTTP_404_NOT_FOUND,
306
+ code="resource_not_found",
307
+ message=f"Resource '{resource_id}' was not found in the selected graph.",
308
+ details={"resource_id": resource_id, "job_id": job_id},
309
+ ) from exc
310
+
311
+
312
+ @api.post(
313
+ "/scan",
314
+ response_model=ScanJobCreateResponse,
315
+ status_code=status.HTTP_202_ACCEPTED,
316
+ responses={
317
+ 401: {"model": APIErrorResponse},
318
+ 403: {"model": APIErrorResponse},
319
+ 422: {"model": APIErrorResponse},
320
+ 502: {"model": APIErrorResponse},
321
+ 500: {"model": APIErrorResponse},
322
+ },
323
+ )
324
+ def create_scan_job(payload: ScanRequest) -> Dict[str, Any]:
325
+ services = _normalize_services(payload.services)
326
+ options = _resolve_scan_options(payload)
327
+ account_id = _resolve_account_id(payload.region)
328
+
329
+ cache_key = ScanJobStore.build_cache_key(
330
+ account_id=account_id,
331
+ region=payload.region,
332
+ services=services,
333
+ mode=options.mode,
334
+ include_iam_inference=options.include_iam_inference,
335
+ include_resource_describes=options.include_resource_describes,
336
+ )
337
+ reusable_job_id, cached = job_store.find_reusable_job(
338
+ cache_key=cache_key,
339
+ force_refresh=payload.force_refresh,
340
+ )
341
+ if reusable_job_id:
342
+ status_payload = job_store.get_status_payload(reusable_job_id)
343
+ return {
344
+ "job_id": reusable_job_id,
345
+ "status": status_payload["status"],
346
+ "cached": cached,
347
+ "status_url": f"/api/scan/{reusable_job_id}",
348
+ "graph_url": f"/api/scan/{reusable_job_id}/graph",
349
+ }
350
+
351
+ job = job_store.create_job(
352
+ cache_key=cache_key,
353
+ account_id=account_id,
354
+ region=payload.region,
355
+ services=services,
356
+ mode=options.mode,
357
+ include_iam_inference=options.include_iam_inference,
358
+ include_resource_describes=options.include_resource_describes,
359
+ )
360
+ job_store.submit_job(
361
+ job.id,
362
+ lambda: _run_scan_job(
363
+ job_id=job.id,
364
+ region=payload.region,
365
+ services=services,
366
+ account_id=account_id,
367
+ options=options,
368
+ ),
369
+ )
370
+ return {
371
+ "job_id": job.id,
372
+ "status": job.status,
373
+ "cached": False,
374
+ "status_url": f"/api/scan/{job.id}",
375
+ "graph_url": f"/api/scan/{job.id}/graph",
376
+ }
377
+
378
+
379
+ @api.get(
380
+ "/scan/{job_id}",
381
+ response_model=ScanJobStatusResponse,
382
+ responses={404: {"model": APIErrorResponse}, 500: {"model": APIErrorResponse}},
383
+ )
384
+ def get_scan_job(job_id: str) -> Dict[str, Any]:
385
+ try:
386
+ return job_store.get_status_payload(job_id)
387
+ except KeyError as exc:
388
+ raise APIError(
389
+ status_code=status.HTTP_404_NOT_FOUND,
390
+ code="job_not_found",
391
+ message=f"Scan job '{job_id}' was not found.",
392
+ details={"job_id": job_id},
393
+ ) from exc
394
+
395
+
396
+ @api.get(
397
+ "/scan/{job_id}/graph",
398
+ response_model=GraphResponse,
399
+ responses={404: {"model": APIErrorResponse}, 500: {"model": APIErrorResponse}},
400
+ )
401
+ def get_scan_job_graph(job_id: str) -> Dict[str, Any]:
402
+ try:
403
+ return job_store.get_graph_payload(job_id)
404
+ except KeyError as exc:
405
+ raise APIError(
406
+ status_code=status.HTTP_404_NOT_FOUND,
407
+ code="job_not_found",
408
+ message=f"Scan job '{job_id}' was not found.",
409
+ details={"job_id": job_id},
410
+ ) from exc
411
+
412
+
413
+ @api.post(
414
+ "/scan/{job_id}/stop",
415
+ response_model=ScanJobStatusResponse,
416
+ status_code=status.HTTP_202_ACCEPTED,
417
+ responses={404: {"model": APIErrorResponse}, 500: {"model": APIErrorResponse}},
418
+ )
419
+ def stop_scan_job(job_id: str) -> Dict[str, Any]:
420
+ try:
421
+ job_store.request_cancel(job_id)
422
+ return job_store.get_status_payload(job_id)
423
+ except KeyError as exc:
424
+ raise APIError(
425
+ status_code=status.HTTP_404_NOT_FOUND,
426
+ code="job_not_found",
427
+ message=f"Scan job '{job_id}' was not found.",
428
+ details={"job_id": job_id},
429
+ ) from exc
430
+
431
+
432
+ app.include_router(api)
433
+
434
+ # ---------------------------------------------------------------------------
435
+ # Static file serving — must be registered AFTER all API routes
436
+ # ---------------------------------------------------------------------------
437
+
438
+ if _STATIC_DIR.is_dir() and ((_STATIC_DIR / "assets").is_dir()):
439
+ app.mount("/assets", StaticFiles(directory=str(_STATIC_DIR / "assets")), name="assets")
440
+
441
+
442
+ @app.get("/{full_path:path}", include_in_schema=False)
443
+ def spa_fallback(full_path: str) -> FileResponse:
444
+ index = _STATIC_DIR / "index.html"
445
+ if not index.is_file():
446
+ return JSONResponse(
447
+ status_code=503,
448
+ content=_error_payload(
449
+ "frontend_not_built",
450
+ "Frontend assets not found. Run `make build` to compile the UI.",
451
+ ),
452
+ )
453
+ return FileResponse(str(index))
@@ -0,0 +1,83 @@
1
+ from typing import Any, Dict, List, Literal, Optional
2
+
3
+ from pydantic import BaseModel, Field, field_validator
4
+
5
+
6
+ DEFAULT_SERVICES = ["apigateway", "lambda", "sqs", "eventbridge", "dynamodb"]
7
+ ScanMode = Literal["quick", "deep"]
8
+ JobStatus = Literal["queued", "running", "completed", "failed", "cancelled"]
9
+
10
+
11
+ class ScanRequest(BaseModel):
12
+ region: str = "us-east-1"
13
+ services: List[str] = Field(default_factory=lambda: DEFAULT_SERVICES.copy())
14
+ mode: ScanMode = "quick"
15
+ force_refresh: bool = False
16
+ include_iam_inference: Optional[bool] = None
17
+ include_resource_describes: Optional[bool] = None
18
+
19
+ @field_validator("region")
20
+ @classmethod
21
+ def validate_region(cls, value: str) -> str:
22
+ cleaned = value.strip()
23
+ if not cleaned:
24
+ raise ValueError("region must not be empty")
25
+ return cleaned
26
+
27
+ @field_validator("services")
28
+ @classmethod
29
+ def validate_services(cls, value: List[str]) -> List[str]:
30
+ cleaned = [service.strip() for service in value if service and service.strip()]
31
+ if not cleaned:
32
+ raise ValueError("at least one AWS service must be selected")
33
+ return cleaned
34
+
35
+
36
+ class GraphResponse(BaseModel):
37
+ nodes: List[Dict[str, Any]]
38
+ edges: List[Dict[str, Any]]
39
+ metadata: Dict[str, Any]
40
+
41
+
42
+ class ResourceResponse(BaseModel):
43
+ node: Dict[str, Any]
44
+ incoming: List[Dict[str, Any]]
45
+ outgoing: List[Dict[str, Any]]
46
+
47
+
48
+ class ScanJobCreateResponse(BaseModel):
49
+ job_id: str
50
+ status: JobStatus
51
+ cached: bool
52
+ status_url: str
53
+ graph_url: str
54
+
55
+
56
+ class ScanJobStatusResponse(BaseModel):
57
+ job_id: str
58
+ status: JobStatus
59
+ cancellation_requested: bool = False
60
+ mode: ScanMode
61
+ region: str
62
+ services: List[str]
63
+ progress_percent: int
64
+ current_service: Optional[str] = None
65
+ services_done: int
66
+ services_total: int
67
+ node_count: int
68
+ edge_count: int
69
+ warnings: List[str]
70
+ created_at: str
71
+ started_at: Optional[str] = None
72
+ finished_at: Optional[str] = None
73
+ error: Optional[str] = None
74
+
75
+
76
+ class APIErrorDetail(BaseModel):
77
+ code: str
78
+ message: str
79
+ details: Optional[Any] = None
80
+
81
+
82
+ class APIErrorResponse(BaseModel):
83
+ error: APIErrorDetail