atlan-application-sdk-api 3.51.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 (78) hide show
  1. application_sdk/__init__.py +7 -0
  2. application_sdk/_build_info.py +62 -0
  3. application_sdk/_context_errors.py +65 -0
  4. application_sdk/_install.py +130 -0
  5. application_sdk/_logging.py +171 -0
  6. application_sdk/_runtime/__init__.py +29 -0
  7. application_sdk/_runtime/offload.py +1020 -0
  8. application_sdk/_runtime/progress.py +757 -0
  9. application_sdk/clients/__init__.py +63 -0
  10. application_sdk/clients/_interface.py +58 -0
  11. application_sdk/clients/_sql_failure_classifier.py +112 -0
  12. application_sdk/clients/base.py +565 -0
  13. application_sdk/clients/http_errors.py +28 -0
  14. application_sdk/clients/models.py +49 -0
  15. application_sdk/clients/sql.py +1218 -0
  16. application_sdk/clients/sql_errors.py +192 -0
  17. application_sdk/clients/sql_typecasters.py +431 -0
  18. application_sdk/clients/ssl_utils.py +247 -0
  19. application_sdk/common/__init__.py +53 -0
  20. application_sdk/common/_generated_tree.py +321 -0
  21. application_sdk/common/_listing.py +192 -0
  22. application_sdk/common/aws_utils.py +522 -0
  23. application_sdk/common/aws_utils_errors.py +88 -0
  24. application_sdk/common/build_identity.py +120 -0
  25. application_sdk/common/concurrency.py +45 -0
  26. application_sdk/common/errors.py +99 -0
  27. application_sdk/common/filter_matching.py +261 -0
  28. application_sdk/common/logger.py +11 -0
  29. application_sdk/common/models.py +31 -0
  30. application_sdk/common/sql_filters.py +719 -0
  31. application_sdk/common/sql_filters_errors.py +17 -0
  32. application_sdk/common/task_queue.py +431 -0
  33. application_sdk/common/types.py +51 -0
  34. application_sdk/common/utils.py +87 -0
  35. application_sdk/constants.py +1120 -0
  36. application_sdk/contracts/__init__.py +158 -0
  37. application_sdk/contracts/base.py +1178 -0
  38. application_sdk/contracts/events.py +251 -0
  39. application_sdk/contracts/types.py +801 -0
  40. application_sdk/contracts/types_errors.py +17 -0
  41. application_sdk/credentials/__init__.py +326 -0
  42. application_sdk/credentials/errors.py +226 -0
  43. application_sdk/credentials/extra.py +114 -0
  44. application_sdk/credentials/ingress.py +283 -0
  45. application_sdk/credentials/ref.py +376 -0
  46. application_sdk/credentials/spec.py +182 -0
  47. application_sdk/credentials/types.py +339 -0
  48. application_sdk/credentials/utils.py +257 -0
  49. application_sdk/errors/__init__.py +224 -0
  50. application_sdk/errors/base.py +504 -0
  51. application_sdk/errors/categories.py +118 -0
  52. application_sdk/errors/http.py +81 -0
  53. application_sdk/errors/leaves.py +526 -0
  54. application_sdk/errors/wire.py +124 -0
  55. application_sdk/handler/__init__.py +97 -0
  56. application_sdk/handler/_warmup.py +198 -0
  57. application_sdk/handler/asgi.py +252 -0
  58. application_sdk/handler/base.py +281 -0
  59. application_sdk/handler/context.py +212 -0
  60. application_sdk/handler/contracts.py +1166 -0
  61. application_sdk/handler/manifest.py +81 -0
  62. application_sdk/handler/metadata_routes.py +699 -0
  63. application_sdk/handler/object_routes.py +418 -0
  64. application_sdk/handler/request_contract.py +91 -0
  65. application_sdk/handler/routes.py +1118 -0
  66. application_sdk/handler/service_errors.py +37 -0
  67. application_sdk/server/__init__.py +15 -0
  68. application_sdk/server/fastapi/models.py +338 -0
  69. application_sdk/templates/__init__.py +52 -0
  70. application_sdk/templates/contracts/__init__.py +111 -0
  71. application_sdk/templates/contracts/base_metadata_extraction.py +32 -0
  72. application_sdk/templates/contracts/incremental_sql.py +563 -0
  73. application_sdk/templates/contracts/sql_metadata.py +824 -0
  74. application_sdk/templates/contracts/sql_query.py +105 -0
  75. application_sdk/version.py +40 -0
  76. atlan_application_sdk_api-3.51.0.dist-info/METADATA +230 -0
  77. atlan_application_sdk_api-3.51.0.dist-info/RECORD +78 -0
  78. atlan_application_sdk_api-3.51.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,418 @@
1
+ """The config and file-upload routes, over the app's object store.
2
+
3
+ ``/workflows/v1/config/{id}`` (GET and POST) and ``/workflows/v1/file`` read and
4
+ write the tenant object store. Heracles writes a credential's non-secret fields
5
+ through ``POST /workflows/v1/config/{guid}?type=credentials`` on credential
6
+ create and update, and on every run submit before it fetches the manifest; the
7
+ app's worker reads them back at run time. ``/workflows/v1/file`` stores a file
8
+ from a setup form's file field. The worker's handler service and the
9
+ consolidated API host register the same routes through
10
+ :func:`register_object_routes`, and differ only in the store behind them:
11
+
12
+ * the worker uses its Dapr-configured ``application_sdk.storage`` store;
13
+ * the host has no Dapr, so it uses :class:`S3ObjectStore`, configured from the
14
+ chart-injected ``ATLAN_S3_BUCKET`` / ``ATLAN_S3_REGION`` with the pod's ambient AWS
15
+ identity (the ``aws`` extra). Without ``ATLAN_S3_BUCKET`` the routes answer 503,
16
+ exactly as the worker does without a store.
17
+
18
+ Both write a config to ``persistent-artifacts/apps/{app_name}/{type}/{id}/config.json``.
19
+ On the host ``app_name`` is the hosted app's name, never the host's own, so the
20
+ key is the one that app's worker reads.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import mimetypes
26
+ import os
27
+ import re
28
+ import shutil
29
+ import tempfile
30
+ import warnings
31
+ from collections.abc import Awaitable, Callable, Mapping
32
+ from dataclasses import dataclass
33
+ from datetime import UTC, datetime
34
+ from pathlib import PurePosixPath
35
+ from typing import Annotated, Any, Protocol, cast
36
+ from uuid import uuid4
37
+
38
+ import orjson
39
+ from fastapi import FastAPI, File, Form, HTTPException
40
+ from fastapi import Path as PathParam
41
+ from fastapi import Query, Request, UploadFile
42
+ from fastapi.responses import JSONResponse
43
+
44
+ from application_sdk._logging import get_logger
45
+ from application_sdk._runtime.offload import run_in_thread
46
+ from application_sdk.handler.contracts import FileUploadResponse
47
+ from application_sdk.handler.routes import _wrap_response
48
+ from application_sdk.handler.service_errors import (
49
+ InvalidConfigIdError,
50
+ InvalidConfigTypeError,
51
+ TempPathEscapeError,
52
+ )
53
+
54
+ logger = get_logger(__name__)
55
+
56
+ # Allowed characters for config_id and config_type path components. Prevents
57
+ # path traversal (no slashes, dots, or percent-encoding) when these values are
58
+ # interpolated into object-store keys.
59
+ CONFIG_KEY_PATTERN = r"^[a-zA-Z0-9_\-]{1,128}$"
60
+ _CONFIG_KEY_RE = re.compile(CONFIG_KEY_PATTERN)
61
+
62
+ # An upload prefix: one or more '/'-separated segments of letters, digits, '_',
63
+ # '-' and '.', none of them '.' or '..', with no leading, trailing or doubled
64
+ # '/'. It comes from the browser (Heracles forwards the form field unchanged), so
65
+ # it is untrusted input that becomes part of an object-store key.
66
+ _UPLOAD_PREFIX_SEGMENT_RE = re.compile(r"[A-Za-z0-9_.\-]+")
67
+ _MAX_UPLOAD_PREFIX = 256
68
+
69
+
70
+ def valid_upload_prefix(prefix: str) -> bool:
71
+ """Whether *prefix* is a safe relative object-store prefix (see above)."""
72
+ if not prefix or len(prefix) > _MAX_UPLOAD_PREFIX:
73
+ return False
74
+ return all(
75
+ segment not in (".", "..") and _UPLOAD_PREFIX_SEGMENT_RE.fullmatch(segment)
76
+ for segment in prefix.split("/")
77
+ )
78
+
79
+
80
+ # Strips any character that is not a simple alphanumeric from file extensions
81
+ # before they become part of an object-store key.
82
+ SAFE_EXT_RE = re.compile(r"[^a-zA-Z0-9]")
83
+
84
+
85
+ def config_key(app_name: str, config_id: str, config_type: str = "workflows") -> str:
86
+ """The object-store key of one app's config (the v2 statestore convention).
87
+
88
+ ``persistent-artifacts/apps/{app_name}/{type}/{id}/config.json``.
89
+ """
90
+ if not _CONFIG_KEY_RE.match(config_id):
91
+ raise InvalidConfigIdError(config_id=config_id)
92
+ if not _CONFIG_KEY_RE.match(config_type):
93
+ raise InvalidConfigTypeError(config_type=config_type)
94
+ return f"persistent-artifacts/apps/{app_name}/{config_type}/{config_id}/config.json"
95
+
96
+
97
+ def validated_temp_path(path: str) -> str:
98
+ """Resolve *path* and require it to lie strictly inside the system temp directory.
99
+
100
+ Every temp path here comes from :func:`tempfile.mkstemp`; this makes the
101
+ invariant explicit so SAST tools can confirm no user-supplied value can
102
+ escape the temp directory. The temp directory itself is rejected;
103
+ :func:`tempfile.mkstemp` never returns it. Returns the resolved path. The
104
+ worker's handler service uses this same guard.
105
+ """
106
+ tmp_root = os.path.realpath(tempfile.gettempdir())
107
+ real = os.path.realpath(path)
108
+ if not real.startswith(tmp_root + os.sep):
109
+ raise TempPathEscapeError()
110
+ return real
111
+
112
+
113
+ @dataclass
114
+ class ObjectRoutesSource:
115
+ """The store operations the routes need, for one app.
116
+
117
+ Args:
118
+ configured: Whether a store is configured. ``False`` makes every route
119
+ answer 503.
120
+ load_config: ``(config_id, config_type)`` → the stored config, or
121
+ ``None`` when it is missing. Raises when the store cannot answer
122
+ (the route answers 503).
123
+ save_config: ``(config_id, body, config_type)`` → ``True`` once stored,
124
+ ``False`` when no store is configured. Raises on a failed write.
125
+ upload_file: ``(key, local_path)`` → stores the file at ``key``.
126
+ upload_namespace: When set, every upload key is placed under it. The
127
+ consolidated host sets the app's own
128
+ ``persistent-artifacts/apps/<app>/uploads``: one identity writes to
129
+ the shared bucket for every hosted app, so a request routed to one
130
+ app must not be able to write under another's prefix.
131
+ """
132
+
133
+ configured: Callable[[], bool]
134
+ load_config: Callable[[str, str], Awaitable[dict[str, Any] | None]]
135
+ save_config: Callable[[str, dict[str, Any], str], Awaitable[bool]]
136
+ upload_file: Callable[[str, str], Awaitable[None]]
137
+ upload_namespace: str = ""
138
+
139
+
140
+ def register_object_routes(app: FastAPI, source: ObjectRoutesSource) -> None:
141
+ """Register the config and file-upload routes on *app*."""
142
+
143
+ @app.get("/workflows/v1/config/{config_id}")
144
+ async def get_workflow_config(
145
+ config_id: Annotated[str, PathParam(pattern=CONFIG_KEY_PATTERN)],
146
+ type: Annotated[str, Query(pattern=CONFIG_KEY_PATTERN)] = "workflows",
147
+ ) -> JSONResponse:
148
+ """Fetch workflow config from object store."""
149
+ try:
150
+ config = await source.load_config(config_id, type)
151
+ except Exception as exc:
152
+ # A missing key loads as None (404 below). Anything else (IAM,
153
+ # network, a 5xx, an unreadable object) means the store could not
154
+ # answer, which is not the same as the config being absent.
155
+ logger.warning(
156
+ "Object-store config load failed for config_id=%s type=%s",
157
+ config_id,
158
+ type,
159
+ exc_info=True,
160
+ )
161
+ raise HTTPException(
162
+ status_code=503, detail="Object store unavailable"
163
+ ) from exc
164
+
165
+ if config is None and not source.configured():
166
+ raise HTTPException(
167
+ status_code=503,
168
+ detail="No object store configured",
169
+ )
170
+ if config is None:
171
+ raise HTTPException(
172
+ status_code=404, detail=f"Config not found: {config_id}"
173
+ )
174
+ return JSONResponse(
175
+ content=_wrap_response(
176
+ cast("dict[str, Any]", config),
177
+ message="Workflow configuration fetched successfully",
178
+ )
179
+ )
180
+
181
+ @app.post("/workflows/v1/config/{config_id}")
182
+ async def update_workflow_config(
183
+ config_id: Annotated[str, PathParam(pattern=CONFIG_KEY_PATTERN)],
184
+ request: Request,
185
+ type: Annotated[str, Query(pattern=CONFIG_KEY_PATTERN)] = "workflows",
186
+ ) -> JSONResponse:
187
+ """Save workflow config to object store."""
188
+ body = await request.json()
189
+
190
+ if type == "workflows":
191
+ warnings.warn(
192
+ "Saving config with type='workflows' is deprecated; "
193
+ "use a specific config type instead. "
194
+ "Will be removed in v4.0.",
195
+ DeprecationWarning,
196
+ stacklevel=2,
197
+ )
198
+
199
+ saved = await source.save_config(config_id, body, type)
200
+
201
+ if not saved:
202
+ raise HTTPException(
203
+ status_code=503,
204
+ detail="No object store configured",
205
+ )
206
+ return JSONResponse(
207
+ content=_wrap_response(
208
+ cast("dict[str, Any]", body),
209
+ message="Workflow configuration updated successfully",
210
+ )
211
+ )
212
+
213
+ @app.post("/workflows/v1/file")
214
+ async def upload_file(
215
+ file: UploadFile = File(...),
216
+ filename: str | None = Form(None),
217
+ prefix: str | None = Form(None),
218
+ contentType: str | None = Form(None),
219
+ ) -> JSONResponse:
220
+ if not source.configured():
221
+ raise HTTPException(status_code=503, detail="Storage not configured")
222
+
223
+ raw_name = filename or file.filename or "upload"
224
+ # Strip non-alphanumeric chars and cap at 16 chars for object-store key safety.
225
+ extension = SAFE_EXT_RE.sub("", PurePosixPath(raw_name).suffix.lstrip("."))[:16]
226
+ resolved_type = (
227
+ contentType
228
+ or file.content_type
229
+ or (mimetypes.guess_type(raw_name)[0] if raw_name else None)
230
+ or "application/octet-stream"
231
+ )
232
+
233
+ file_id = str(uuid4())
234
+ effective_prefix = prefix or "workflow_file_upload"
235
+ if source.upload_namespace:
236
+ # The consolidated host: one AWS identity and one bucket serve every
237
+ # hosted app, so a request routed to one app must not choose a key
238
+ # outside that app's namespace. A worker serves only its own app and
239
+ # keeps accepting the prefix as given, as it always has.
240
+ if not valid_upload_prefix(effective_prefix):
241
+ raise HTTPException(
242
+ status_code=400,
243
+ detail="prefix must be relative path segments of letters, "
244
+ "digits, '_', '-' or '.', with no '.' or '..' segment",
245
+ )
246
+ effective_prefix = f"{source.upload_namespace}/{effective_prefix}"
247
+ stored_name = f"{file_id}.{extension}" if extension else file_id
248
+ key = f"{effective_prefix}/{stored_name}"
249
+
250
+ # Stream the upload: drain the spooled temp file to a named temp file,
251
+ # then stream-upload to the object store (avoids materialising in memory).
252
+ # The temp name carries nothing from the request: the extension belongs
253
+ # to the object key only, and the store never reads the local suffix.
254
+ fd, tmp_path = tempfile.mkstemp(suffix=".upload")
255
+ safe_tmp_path = validated_temp_path(tmp_path)
256
+ try:
257
+ os.close(fd)
258
+
259
+ def _drain_to_tmp() -> int:
260
+ with open(safe_tmp_path, "wb") as dst:
261
+ shutil.copyfileobj(file.file, dst)
262
+ return os.path.getsize(safe_tmp_path)
263
+
264
+ file_size = await run_in_thread(_drain_to_tmp)
265
+ await source.upload_file(key, safe_tmp_path)
266
+ finally:
267
+ try:
268
+ os.unlink(safe_tmp_path)
269
+ except FileNotFoundError: # conformance: ignore[E002] temp file already removed; nothing to clean up
270
+ pass
271
+
272
+ now_ms = int(datetime.now(UTC).timestamp() * 1000)
273
+ response_obj = FileUploadResponse(
274
+ id=file_id,
275
+ version="1",
276
+ is_active=True,
277
+ created_at=now_ms,
278
+ updated_at=now_ms,
279
+ file_name=stored_name,
280
+ raw_name=raw_name,
281
+ key=key,
282
+ extension=extension,
283
+ content_type=resolved_type,
284
+ file_size=file_size,
285
+ is_uploaded=True,
286
+ uploaded_at=datetime.now(UTC).isoformat(),
287
+ )
288
+ return JSONResponse(
289
+ content=_wrap_response(
290
+ response_obj.model_dump(by_alias=True),
291
+ message="File uploaded successfully",
292
+ )
293
+ )
294
+
295
+
296
+ def _normalize_key(key: str) -> str:
297
+ """A clean relative object-store key, as the worker's store normalises one."""
298
+ normalized = key.replace("\\", "/").strip("/")
299
+ return "" if normalized == "." else normalized
300
+
301
+
302
+ class S3Client(Protocol):
303
+ """The boto3 S3 client calls :class:`S3ObjectStore` makes.
304
+
305
+ A Protocol rather than the ``mypy_boto3_s3`` client type: boto3 is the
306
+ ``aws`` extra, so it is never imported at module level here.
307
+ """
308
+
309
+ def get_object(self, *, Bucket: str, Key: str) -> Mapping[str, Any]: ...
310
+
311
+ def put_object(
312
+ self, *, Bucket: str, Key: str, Body: bytes, ContentType: str
313
+ ) -> object: ...
314
+
315
+ def upload_file(self, filename: str, bucket: str, key: str, /) -> None: ...
316
+
317
+
318
+ class S3ObjectStore:
319
+ """One S3 bucket through a boto3 client: the host's store.
320
+
321
+ Auth is ambient (node role / IRSA / env), exactly as the fleet's Dapr
322
+ ``bindings.aws.s3`` components authenticate: their specs carry only a
323
+ bucket and a region. No credential value is read here. boto3 calls run on
324
+ the app's own ``run_in_thread`` pool, so S3 latency never blocks the host's
325
+ event loop.
326
+ """
327
+
328
+ def __init__(
329
+ self, bucket: str, region: str | None = None, client: S3Client | None = None
330
+ ):
331
+ if client is None:
332
+ import boto3 # noqa: PLC0415 — the aws extra; loaded only when S3 is configured
333
+
334
+ client = cast("S3Client", boto3.client("s3", region_name=region or None))
335
+ self._client: S3Client = client
336
+ self._bucket = bucket
337
+
338
+ @classmethod
339
+ def from_env(cls) -> S3ObjectStore | None:
340
+ """From ``ATLAN_S3_BUCKET`` / ``ATLAN_S3_REGION``; ``None`` when no bucket is set."""
341
+ bucket = os.environ.get("ATLAN_S3_BUCKET", "").strip()
342
+ if not bucket:
343
+ return None
344
+ try:
345
+ return cls(bucket, os.environ.get("ATLAN_S3_REGION", "").strip() or None)
346
+ except ModuleNotFoundError:
347
+ logger.warning(
348
+ "ATLAN_S3_BUCKET is set but boto3 is not installed, so the config and "
349
+ "file routes answer 503. Install atlan-application-sdk-api[aws].",
350
+ exc_info=True,
351
+ )
352
+ return None
353
+
354
+ async def get_json(self, key: str) -> dict[str, Any] | None:
355
+ """The object at ``key``, or ``None`` when it is missing.
356
+
357
+ Any other failure (IAM, network, throttling, an unreadable object)
358
+ raises: the store could not answer, which is not a missing object.
359
+ """
360
+
361
+ def _get() -> bytes | None:
362
+ try:
363
+ response = self._client.get_object(Bucket=self._bucket, Key=key)
364
+ except Exception as exc:
365
+ code = getattr(exc, "response", {}).get("Error", {}).get("Code", "")
366
+ if code in ("NoSuchKey", "404"):
367
+ return None
368
+ raise
369
+ return response["Body"].read()
370
+
371
+ raw = await run_in_thread(_get)
372
+ return None if raw is None else orjson.loads(raw)
373
+
374
+ async def put_json(self, key: str, body: dict[str, Any]) -> None:
375
+ await run_in_thread(
376
+ self._client.put_object,
377
+ Bucket=self._bucket,
378
+ Key=key,
379
+ Body=orjson.dumps(body),
380
+ ContentType="application/json",
381
+ )
382
+
383
+ async def put_file(self, key: str, local_path: str) -> None:
384
+ await run_in_thread(
385
+ self._client.upload_file, local_path, self._bucket, _normalize_key(key)
386
+ )
387
+
388
+
389
+ def host_object_source(
390
+ app_name: str, store: S3ObjectStore | None
391
+ ) -> ObjectRoutesSource:
392
+ """The routes' store operations for one hosted app."""
393
+
394
+ async def load_config(config_id: str, config_type: str) -> dict[str, Any] | None:
395
+ if store is None:
396
+ return None
397
+ return await store.get_json(config_key(app_name, config_id, config_type))
398
+
399
+ async def save_config(
400
+ config_id: str, body: dict[str, Any], config_type: str
401
+ ) -> bool:
402
+ if store is None:
403
+ return False
404
+ await store.put_json(config_key(app_name, config_id, config_type), body)
405
+ return True
406
+
407
+ async def upload_file(key: str, local_path: str) -> None:
408
+ if store is None: # the route answers 503 before it gets here
409
+ raise HTTPException(status_code=503, detail="Storage not configured")
410
+ await store.put_file(key, local_path)
411
+
412
+ return ObjectRoutesSource(
413
+ configured=lambda: store is not None,
414
+ load_config=load_config,
415
+ save_config=save_config,
416
+ upload_file=upload_file,
417
+ upload_namespace=f"persistent-artifacts/apps/{app_name}/uploads",
418
+ )
@@ -0,0 +1,91 @@
1
+ """Ingress validation that answers 422, not 500.
2
+
3
+ The routes validate their own body (they normalise v2/v3 wire shapes first),
4
+ so FastAPI's own ``RequestValidationError`` path never sees it. A bare
5
+ ``model_validate`` therefore raises a pydantic ``ValidationError`` inside the
6
+ route, which escapes to ``ServerErrorMiddleware`` and reaches the caller as an
7
+ opaque 500 with no hint of which field was wrong -- and on a consolidated host
8
+ a 500 is indistinguishable from a host fault, so it gets triaged as one.
9
+
10
+ Ported from ``application_sdk.handler.service`` so both surfaces agree.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING, Any, TypeVar
16
+
17
+ from pydantic import BaseModel as PydanticBaseModel
18
+ from pydantic import ValidationError
19
+
20
+ if TYPE_CHECKING:
21
+ from fastapi import Request
22
+
23
+ ModelT = TypeVar("ModelT", bound=PydanticBaseModel)
24
+
25
+
26
+ class RequestContractError(Exception):
27
+ """A request body did not fit its typed contract at ingress -> 422.
28
+
29
+ The marker *is* the enforcement. The 422 handler is registered on this
30
+ type, not on pydantic's ``ValidationError``, so an endpoint gets a
31
+ contract-shaped answer only by validating through :func:`validate_request`
32
+ -- which is also the only place that decides a failure is the caller's
33
+ fault. A ``ValidationError`` raised anywhere else (inside a route's error
34
+ boundary, in handler business logic, in an output contract) keeps that
35
+ route's own 500 and can never be mistaken for a malformed request.
36
+
37
+ So the default for a new endpoint is the safe one: a bare
38
+ ``model_validate`` still yields a 500.
39
+ """
40
+
41
+ def __init__(
42
+ self, cause: ValidationError | None = None, *, message: str | None = None
43
+ ) -> None:
44
+ super().__init__(message if cause is None else str(cause))
45
+ #: The pydantic failure, kept so the handler can name the fields. None
46
+ #: when the body never reached pydantic -- unparseable JSON, or a valid
47
+ #: JSON scalar/array where an object was required.
48
+ self.cause = cause
49
+
50
+
51
+ def validate_request(model: type[ModelT], body: dict[str, Any]) -> ModelT:
52
+ """Validate an already-normalised request body into its typed contract.
53
+
54
+ Raises :class:`RequestContractError` so the app-level handler answers 422
55
+ naming the offending field. Use this for anything a *caller* controls; use
56
+ ``model_validate`` directly where a failure would be an internal bug.
57
+ """
58
+ try:
59
+ return model.model_validate(body)
60
+ except ValidationError as exc:
61
+ raise RequestContractError(exc) from exc
62
+
63
+
64
+ async def read_json_object(request: Request) -> dict[str, Any]:
65
+ """Read a request body that must be a JSON object, answering 422 if not.
66
+
67
+ The routes normalise the body before validating, so they have to read it
68
+ themselves -- which put ``await request.json()`` OUTSIDE the route's error
69
+ boundary. Unparseable JSON raised a decode error and a top-level array or
70
+ scalar raised a TypeError inside the normaliser, both escaping as an opaque
71
+ 500. That is precisely what this module exists to prevent, so the read
72
+ belongs behind the same 422 as the validation it feeds.
73
+
74
+ The message never echoes the body: it is caller-controlled and, on these
75
+ routes, routinely a credential.
76
+ """
77
+ try:
78
+ body = await request.json()
79
+ except ValueError as exc:
80
+ # ValueError, not Exception: json.JSONDecodeError is a ValueError, while
81
+ # the body-size cap signals with a bare Exception subclass from inside
82
+ # the ASGI receive. Catching broadly swallowed that signal and answered
83
+ # 422 for an oversize body that must be a 413.
84
+ raise RequestContractError(message="Request body is not valid JSON.") from exc
85
+ if not isinstance(body, dict):
86
+ raise RequestContractError(
87
+ message=(
88
+ "Request body must be a JSON object, got " f"{type(body).__name__}."
89
+ )
90
+ )
91
+ return body