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.
- application_sdk/__init__.py +7 -0
- application_sdk/_build_info.py +62 -0
- application_sdk/_context_errors.py +65 -0
- application_sdk/_install.py +130 -0
- application_sdk/_logging.py +171 -0
- application_sdk/_runtime/__init__.py +29 -0
- application_sdk/_runtime/offload.py +1020 -0
- application_sdk/_runtime/progress.py +757 -0
- application_sdk/clients/__init__.py +63 -0
- application_sdk/clients/_interface.py +58 -0
- application_sdk/clients/_sql_failure_classifier.py +112 -0
- application_sdk/clients/base.py +565 -0
- application_sdk/clients/http_errors.py +28 -0
- application_sdk/clients/models.py +49 -0
- application_sdk/clients/sql.py +1218 -0
- application_sdk/clients/sql_errors.py +192 -0
- application_sdk/clients/sql_typecasters.py +431 -0
- application_sdk/clients/ssl_utils.py +247 -0
- application_sdk/common/__init__.py +53 -0
- application_sdk/common/_generated_tree.py +321 -0
- application_sdk/common/_listing.py +192 -0
- application_sdk/common/aws_utils.py +522 -0
- application_sdk/common/aws_utils_errors.py +88 -0
- application_sdk/common/build_identity.py +120 -0
- application_sdk/common/concurrency.py +45 -0
- application_sdk/common/errors.py +99 -0
- application_sdk/common/filter_matching.py +261 -0
- application_sdk/common/logger.py +11 -0
- application_sdk/common/models.py +31 -0
- application_sdk/common/sql_filters.py +719 -0
- application_sdk/common/sql_filters_errors.py +17 -0
- application_sdk/common/task_queue.py +431 -0
- application_sdk/common/types.py +51 -0
- application_sdk/common/utils.py +87 -0
- application_sdk/constants.py +1120 -0
- application_sdk/contracts/__init__.py +158 -0
- application_sdk/contracts/base.py +1178 -0
- application_sdk/contracts/events.py +251 -0
- application_sdk/contracts/types.py +801 -0
- application_sdk/contracts/types_errors.py +17 -0
- application_sdk/credentials/__init__.py +326 -0
- application_sdk/credentials/errors.py +226 -0
- application_sdk/credentials/extra.py +114 -0
- application_sdk/credentials/ingress.py +283 -0
- application_sdk/credentials/ref.py +376 -0
- application_sdk/credentials/spec.py +182 -0
- application_sdk/credentials/types.py +339 -0
- application_sdk/credentials/utils.py +257 -0
- application_sdk/errors/__init__.py +224 -0
- application_sdk/errors/base.py +504 -0
- application_sdk/errors/categories.py +118 -0
- application_sdk/errors/http.py +81 -0
- application_sdk/errors/leaves.py +526 -0
- application_sdk/errors/wire.py +124 -0
- application_sdk/handler/__init__.py +97 -0
- application_sdk/handler/_warmup.py +198 -0
- application_sdk/handler/asgi.py +252 -0
- application_sdk/handler/base.py +281 -0
- application_sdk/handler/context.py +212 -0
- application_sdk/handler/contracts.py +1166 -0
- application_sdk/handler/manifest.py +81 -0
- application_sdk/handler/metadata_routes.py +699 -0
- application_sdk/handler/object_routes.py +418 -0
- application_sdk/handler/request_contract.py +91 -0
- application_sdk/handler/routes.py +1118 -0
- application_sdk/handler/service_errors.py +37 -0
- application_sdk/server/__init__.py +15 -0
- application_sdk/server/fastapi/models.py +338 -0
- application_sdk/templates/__init__.py +52 -0
- application_sdk/templates/contracts/__init__.py +111 -0
- application_sdk/templates/contracts/base_metadata_extraction.py +32 -0
- application_sdk/templates/contracts/incremental_sql.py +563 -0
- application_sdk/templates/contracts/sql_metadata.py +824 -0
- application_sdk/templates/contracts/sql_query.py +105 -0
- application_sdk/version.py +40 -0
- atlan_application_sdk_api-3.51.0.dist-info/METADATA +230 -0
- atlan_application_sdk_api-3.51.0.dist-info/RECORD +78 -0
- 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
|