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,699 @@
1
+ """The metadata routes, served from an app's generated contract files.
2
+
3
+ ``/workflows/v1/configmap/{id}``, ``/workflows/v1/configmaps``, the manifest
4
+ (``/workflows/v1/manifest`` and the unversioned ``/manifest``, GET and POST) and
5
+ ``/workflows/v1/input-contract`` read only the files the contract toolkit
6
+ generates into ``app/generated/``. The worker's handler service and the
7
+ consolidated API host (:func:`application_sdk.handler.asgi.build_asgi_app`)
8
+ both register them through :func:`register_metadata_routes`, so an app answers
9
+ the same from either place.
10
+
11
+ The two surfaces differ only in what a :class:`MetadataSource` resolves:
12
+
13
+ * the worker resolves entry points from its app registry, stamps the manifest
14
+ with the task queue its own worker polls, and runs an app's
15
+ ``compute_manifest`` hooks;
16
+ * the host has no app registry. It resolves entry points from the generated
17
+ tree, derives the task queue from the app's name and deployment exactly as the
18
+ worker does, and serves static manifests only: ``gen_app_api.py check``
19
+ refuses an app with a ``compute_manifest`` hook, so no hooked app reaches it.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import importlib
25
+ import json
26
+ import os
27
+ from collections.abc import Awaitable, Callable
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import Any, cast
31
+
32
+ import orjson
33
+ from fastapi import FastAPI, HTTPException, Request
34
+ from fastapi.responses import JSONResponse, Response
35
+
36
+ from application_sdk._logging import get_logger
37
+ from application_sdk.common._generated_tree import (
38
+ MANIFEST_STEM,
39
+ choose_form_configmap,
40
+ eligible_form_configmaps,
41
+ names_entrypoint,
42
+ )
43
+ from application_sdk.common.build_identity import (
44
+ BUILD_IDENTITY_CONFIGMAP_ID,
45
+ build_identity,
46
+ )
47
+ from application_sdk.common.task_queue import resolve_manifest_tokens
48
+ from application_sdk.handler.routes import (
49
+ _ENTRYPOINT_NAME_RE,
50
+ _wrap_response,
51
+ entrypoint_module_segment,
52
+ )
53
+
54
+ # Named for application_sdk.handler.service, not this module: these routes moved
55
+ # from there and keep its logger name (the worker also passes that module's
56
+ # logger as ``MetadataSource.log``), so their log lines are unchanged.
57
+ logger = get_logger("application_sdk.handler.service")
58
+
59
+ # Heracles URL-encodes the whole setup-form payload into ``fe_inputs`` on the
60
+ # GET. A typical form is ~1.6 KB decoded and a heavy multi-select (lists of
61
+ # qualified names / GUIDs / tags) is ~5 KB — so 8 KB leaves headroom without
62
+ # rejecting legitimate forms. Larger payloads use the POST body.
63
+ MAX_FE_INPUTS_BYTES = 8192
64
+
65
+ #: ``async def compute_manifest(manifest: dict, fe_inputs: dict) -> dict``.
66
+ ComputeManifest = Callable[[dict[str, Any], dict[str, Any]], Awaitable[Any]]
67
+
68
+
69
+ def decode_fe_inputs(
70
+ raw: str | None, max_bytes: int = MAX_FE_INPUTS_BYTES
71
+ ) -> dict[str, Any]:
72
+ """Decode the ``fe_inputs`` query payload (JSON, sent URL-encoded by
73
+ Heracles for dynamic-manifest apps). Returns ``{}`` when absent; raises
74
+ ``HTTPException(413)`` when it exceeds ``max_bytes`` and
75
+ ``HTTPException(400)`` when present but malformed."""
76
+ if not raw:
77
+ return {}
78
+ if len(raw.encode("utf-8")) > max_bytes:
79
+ raise HTTPException(
80
+ status_code=413,
81
+ detail=f"fe_inputs exceeds the {max_bytes}-byte limit",
82
+ )
83
+ try:
84
+ # orjson.JSONDecodeError subclasses json.JSONDecodeError.
85
+ parsed = orjson.loads(raw)
86
+ except json.JSONDecodeError as exc:
87
+ # A fixed message: no parser or exception text in a response body.
88
+ raise HTTPException(
89
+ status_code=400, detail="fe_inputs is not valid JSON"
90
+ ) from exc
91
+ if not isinstance(parsed, dict):
92
+ raise HTTPException(status_code=400, detail="fe_inputs must be a JSON object")
93
+ return parsed
94
+
95
+
96
+ def entrypoint_form(
97
+ generated_dir: Path, entrypoint: str
98
+ ) -> tuple[Path | None, list[Path]]:
99
+ """The generated setup form served for *entrypoint*, and the files it was
100
+ chosen from.
101
+
102
+ Searches both generated layouts: multi-entrypoint apps nest each form under
103
+ ``<generated_dir>/<entrypoint>/``, single-entrypoint apps emit it flat in
104
+ ``generated_dir``. The nested directory wins. Within a directory
105
+ :func:`choose_form_configmap` decides. ``(None, [])`` when no eligible form
106
+ exists.
107
+ """
108
+ for search_dir in (generated_dir / entrypoint, generated_dir):
109
+ candidates = eligible_form_configmaps(search_dir)
110
+ target = choose_form_configmap(candidates, entrypoint)
111
+ if target is not None:
112
+ return target, candidates
113
+ return None, []
114
+
115
+
116
+ def tree_entrypoints(generated_dir: Path) -> list[str]:
117
+ """The entry points the generated tree has a manifest for, sorted.
118
+
119
+ Each ``<generated_dir>/<entrypoint>/manifest.json`` is one (the
120
+ multi-entry-point layout). Read from disk: what the host knows of an app's
121
+ entry points without its registry.
122
+ """
123
+ if not generated_dir.is_dir():
124
+ return []
125
+ return sorted(p.parent.name for p in generated_dir.glob("*/manifest.json"))
126
+
127
+
128
+ def tree_default_entrypoint(generated_dir: Path) -> str | None:
129
+ """The entry point a request that names none resolves to, from disk.
130
+
131
+ A single-entry-point app (flat ``manifest.json`` or a flat form) resolves
132
+ to ``""``, the app-level entry point. A multi-entry-point app resolves only
133
+ when it has exactly one; otherwise there is no default, as on the worker
134
+ when the builder marks none.
135
+ """
136
+ if not generated_dir.is_dir():
137
+ return None
138
+ if (generated_dir / "manifest.json").is_file() or eligible_form_configmaps(
139
+ generated_dir
140
+ ):
141
+ return ""
142
+ nested = tree_entrypoints(generated_dir)
143
+ return nested[0] if len(nested) == 1 else None
144
+
145
+
146
+ def tree_input_contract(
147
+ generated_dir: Path, app_package: str, entrypoint: str | None
148
+ ) -> Any:
149
+ """The generated ``AppInputContract`` for *entrypoint*, resolved from disk.
150
+
151
+ Looks where the contract toolkit writes it: ``generated/<ep>/_input.py``
152
+ (or the bundle relocation ``<package>/<ep>/_input.py``) for a nested entry
153
+ point, ``generated/_input.py`` for the app-level one. Raises
154
+ ``HTTPException(404)`` when there is none.
155
+ """
156
+ default = tree_default_entrypoint(generated_dir)
157
+ selected = default if entrypoint is None else entrypoint
158
+ if selected is None:
159
+ raise HTTPException(
160
+ status_code=400,
161
+ detail="This app has several entry points; pass ?entrypoint=.",
162
+ )
163
+ nested = selected and (generated_dir / selected / "manifest.json").is_file()
164
+ if nested:
165
+ segment = entrypoint_module_segment(selected)
166
+ candidates = (
167
+ f"{app_package}.generated.{segment}._input",
168
+ f"{app_package}.{segment}._input",
169
+ )
170
+ else:
171
+ candidates = (f"{app_package}.generated._input",)
172
+ for module_path in candidates:
173
+ try:
174
+ module = importlib.import_module(module_path)
175
+ except ImportError: # conformance: ignore[E008,E014] optional generated module; continue to next candidate
176
+ continue
177
+ contract = getattr(module, "AppInputContract", None)
178
+ if contract is not None and hasattr(contract, "model_json_schema"):
179
+ return contract
180
+ raise HTTPException(status_code=404, detail="Entry point not found")
181
+
182
+
183
+ @dataclass
184
+ class MetadataSource:
185
+ """What the metadata routes read for one app.
186
+
187
+ Every attribute that can change at runtime is a callable, read per
188
+ request, so a process that reconfigures (or a test that repoints the
189
+ generated dir) is seen without re-registering the routes.
190
+
191
+ Args:
192
+ generated_dir: The app's generated contract directory.
193
+ app_name: The app's name: the build-identity configmap reports it, and
194
+ the manifest's ``{app_name}`` token is filled from it.
195
+ app_package: The import root the generated ``_input.py`` modules live
196
+ under (``app`` on the worker, ``atlan_<app>_api`` on the host).
197
+ task_queue: The queue the app's worker polls, stamped into the served
198
+ manifest. ``None`` derives it from the app name and deployment, as
199
+ the worker itself does.
200
+ deployment_name: Fallback for the manifest's ``{deployment_name}``.
201
+ fallback_app_name: Fills ``{app_name}`` when ``app_name()`` is empty.
202
+ default_entrypoint: Entry point a request that names none resolves to,
203
+ raising ``HTTPException`` when there is none; ``None`` reads it
204
+ from the generated tree.
205
+ explicit_entrypoints: Entry points to try, in order, when the default
206
+ has no manifest; ``None`` reads them from the generated tree.
207
+ input_contract: Resolves ``?entrypoint=`` to its input model, raising
208
+ ``HTTPException``; ``None`` resolves from the generated tree.
209
+ compute_manifest: The entry point's ``compute_manifest`` hook, or
210
+ ``None``.
211
+ programmatic_manifest: An ``AppManifest`` built in Python, served when
212
+ no entry point is named.
213
+ log: The logger the routes report through.
214
+ max_fe_inputs_bytes: The GET ``fe_inputs`` cap.
215
+ from_process_env: Whether this process's own ``ATLAN_APPLICATION_NAME``
216
+ / ``ATLAN_DEPLOYMENT_NAME`` describe this app. ``False`` on the
217
+ consolidated API host, where they describe the host.
218
+ """
219
+
220
+ generated_dir: Callable[[], Path]
221
+ app_name: Callable[[], str]
222
+ app_package: str = "app"
223
+ task_queue: Callable[[], str | None] = lambda: None
224
+ deployment_name: Callable[[], str] = lambda: ""
225
+ fallback_app_name: str = ""
226
+ default_entrypoint: Callable[[], str] | None = None
227
+ explicit_entrypoints: Callable[[], list[str]] | None = None
228
+ input_contract: Callable[[str | None], Any] | None = None
229
+ compute_manifest: Callable[[str], ComputeManifest | None] = field(
230
+ default=lambda _entrypoint: None
231
+ )
232
+ programmatic_manifest: Any = None
233
+ log: Callable[[], Any] = lambda: logger
234
+ max_fe_inputs_bytes: Callable[[], int] = lambda: MAX_FE_INPUTS_BYTES
235
+ from_process_env: bool = True
236
+
237
+
238
+ def register_metadata_routes(app: FastAPI, source: MetadataSource) -> None:
239
+ """Register the configmap, manifest and input-contract routes on *app*."""
240
+
241
+ def _default_entrypoint(log: Any, context: str) -> str | None:
242
+ """The default entry point, or ``None`` (logged) when there is none."""
243
+ if source.default_entrypoint is None:
244
+ ep_name = tree_default_entrypoint(source.generated_dir())
245
+ if ep_name is None:
246
+ log.warning(context, source.app_name())
247
+ return ep_name
248
+ try:
249
+ return source.default_entrypoint()
250
+ except HTTPException:
251
+ # The registry says why there is no default. Warning, not debug:
252
+ # exc_info-worthy telemetry an operator should see in production.
253
+ log.warning(context, source.app_name(), exc_info=True)
254
+ return None
255
+
256
+ def _explicit_entrypoints() -> list[str]:
257
+ if source.explicit_entrypoints is not None:
258
+ return source.explicit_entrypoints()
259
+ return tree_entrypoints(source.generated_dir())
260
+
261
+ # ------------------------------------------------------------------
262
+ # ConfigMaps
263
+ # ------------------------------------------------------------------
264
+
265
+ @app.get("/workflows/v1/configmap/{config_map_id}")
266
+ async def get_configmap(config_map_id: str) -> JSONResponse:
267
+ log = source.log()
268
+ generated_dir = source.generated_dir()
269
+ # 0. The reserved build-identity id (FND-1684).
270
+ #
271
+ # Answered BEFORE the generated-file scan, deliberately: an app that
272
+ # happens to ship `atlan-build-identity.json` would otherwise shadow the
273
+ # one fact only a running process can report — and it would shadow it
274
+ # with a committed file, which cannot tell this build apart from one
275
+ # shipped months ago. Served on THIS route because
276
+ # `/api/service/configmaps/{name}` is already proxied by Heracles.
277
+ #
278
+ # `build_id` is "" for an image that carries no stamp. That is a valid
279
+ # answer, not an error: the reader has to distinguish "this process
280
+ # reports a different build" from "this process cannot report one".
281
+ if config_map_id == BUILD_IDENTITY_CONFIGMAP_ID:
282
+ identity: dict[str, Any] = {
283
+ "build_id": build_identity(),
284
+ "app_name": source.app_name(),
285
+ }
286
+ return JSONResponse(
287
+ content=_wrap_response(
288
+ cast(
289
+ "dict[str, Any]",
290
+ {
291
+ "kind": "ConfigMap",
292
+ "apiVersion": "v1",
293
+ "metadata": {"name": config_map_id},
294
+ # Same envelope as a real configmap — `data.config`
295
+ # is a JSON string — with the parsed keys repeated
296
+ # alongside it for a client that wants them.
297
+ "data": {
298
+ "config": orjson.dumps(identity).decode(),
299
+ **identity,
300
+ },
301
+ },
302
+ ),
303
+ message="Build identity fetched successfully",
304
+ )
305
+ )
306
+
307
+ # 1. Direct match against any generated configmap file stem. The setup
308
+ # form normally requests the form file by its stem.
309
+ available_configmaps: list[str] = []
310
+ target: Path | None = None
311
+ if generated_dir.exists():
312
+ for json_file in generated_dir.rglob("*.json"):
313
+ available_configmaps.append(json_file.stem)
314
+ if json_file.stem == config_map_id:
315
+ target = json_file
316
+ break
317
+
318
+ # 2. Default-entrypoint fallback. The marketplace UI sometimes builds
319
+ # the configmap URL from the app/marketplace id (e.g.
320
+ # "atlan-snowflake") rather than the form stem. Resolve to the app's
321
+ # default entrypoint and serve its form. When there is none, or it
322
+ # has no eligible form, target stays None and the route 404s.
323
+ if target is None:
324
+ ep_name = _default_entrypoint(
325
+ log, "No default entrypoint for app %s; serving configmap 404"
326
+ )
327
+ if ep_name is not None:
328
+ # Searches the nested layout first, then the flat one, so an
329
+ # app-id request resolves for either shape. Within a directory
330
+ # `choose_form_configmap` picks a form named for the entrypoint,
331
+ # else the only eligible file, else the alphabetically first.
332
+ # That last step is a compatibility guess; the warning below is
333
+ # what makes a wrong guess visible before anyone opens the wizard.
334
+ target, candidates = entrypoint_form(generated_dir, ep_name)
335
+ if target is not None:
336
+ if len(candidates) > 1 and not names_entrypoint(
337
+ target.stem, ep_name
338
+ ):
339
+ # conformance: ignore[L009] logs caller-invisible context (the rejected candidates) that no HTTP response carries.
340
+ log.warning(
341
+ "ConfigMap form chosen alphabetically for entrypoint "
342
+ "%s: %d generated files are eligible and none is "
343
+ "named for it, so %s.json was served as the setup "
344
+ "form (candidates=%s). If that is the wrong file, "
345
+ "name the form <entrypoint>.json or "
346
+ "<source>-<entrypoint>.json in the app's contract.",
347
+ ep_name,
348
+ len(candidates),
349
+ target.stem,
350
+ [c.stem for c in candidates],
351
+ )
352
+
353
+ if target is not None:
354
+ with open(target, encoding="utf-8") as f:
355
+ raw = json.load(f)
356
+ data: dict[str, Any] = {
357
+ "config": orjson.dumps(raw.get("config", raw)).decode()
358
+ }
359
+ # Some configmaps (e.g., connector apps) carry a top-level
360
+ # `defaultConnectorType`; surface it alongside `config` when present.
361
+ default_connector_type = raw.get("defaultConnectorType")
362
+ if default_connector_type is not None:
363
+ data["defaultConnectorType"] = default_connector_type
364
+ configmap = {
365
+ "kind": "ConfigMap",
366
+ "apiVersion": "v1",
367
+ "metadata": {"name": config_map_id},
368
+ "data": data,
369
+ }
370
+ return JSONResponse(
371
+ content=_wrap_response(
372
+ cast("dict[str, Any]", configmap),
373
+ message="ConfigMap fetched successfully",
374
+ )
375
+ )
376
+
377
+ # conformance: ignore[L009] logs caller-invisible context (the available configmaps) that the generic 404 HTTPException detail does not carry.
378
+ log.warning(
379
+ "ConfigMap not found: requested=%s available=%s",
380
+ config_map_id,
381
+ sorted(available_configmaps),
382
+ )
383
+ raise HTTPException(
384
+ status_code=404, detail=f"ConfigMap '{config_map_id}' not found"
385
+ )
386
+
387
+ @app.get("/workflows/v1/configmaps")
388
+ async def list_configmaps() -> JSONResponse:
389
+ # Everything the sibling route serves by exact stem, minus the DAG
390
+ # manifest, which `/workflows/v1/manifest` owns. Deliberately NOT
391
+ # `is_form_configmap`: the credential templates it excludes are ones
392
+ # the UI genuinely fetches by name.
393
+ generated_dir = source.generated_dir()
394
+ seen: set[str] = set()
395
+ configmap_ids: list[str] = []
396
+ if generated_dir.exists():
397
+ for json_file in generated_dir.rglob("*.json"):
398
+ stem = json_file.stem
399
+ if stem == MANIFEST_STEM or stem in seen:
400
+ continue
401
+ seen.add(stem)
402
+ configmap_ids.append(stem)
403
+ return JSONResponse(
404
+ content=_wrap_response(
405
+ cast("dict[str, Any]", {"configmaps": configmap_ids}),
406
+ message="ConfigMaps listed successfully",
407
+ )
408
+ )
409
+
410
+ # ------------------------------------------------------------------
411
+ # Manifest
412
+ # ------------------------------------------------------------------
413
+
414
+ def _resolve_manifest_placeholders(raw: bytes, origin: str) -> bytes:
415
+ """Substitute the manifest's ``{app_name}`` / ``{deployment_name}`` tokens.
416
+
417
+ Delegates to :func:`resolve_manifest_tokens` with the queue the app's
418
+ worker polls, so the served ``task_queue`` is stamped from it rather
419
+ than re-derived and hoped to match (FND-195 / CONNECT-183). An
420
+ ``{app_name}`` token means the committed manifest predates the
421
+ toolkit's bake — a WARNING even when this fills it. With nothing to fill
422
+ it from, the token is served as-is and logged at ERROR: a literal
423
+ ``{app_name}`` in the DAG is greppable, whereas an invented name reads
424
+ as a legitimate queue and hangs the run silently.
425
+ """
426
+ log = source.log()
427
+ resolution = resolve_manifest_tokens(
428
+ raw,
429
+ task_queue=source.task_queue() or None,
430
+ app_name=source.app_name() or source.fallback_app_name or None,
431
+ deployment_fallback=source.deployment_name() or "default",
432
+ from_process_env=source.from_process_env,
433
+ )
434
+ if resolution.unresolved_app_name:
435
+ # conformance: ignore[L009] not a request-boundary handler — the manifest is
436
+ # still served (the token is the diagnostic); this log is the operator's signal.
437
+ log.error(
438
+ "Manifest for %s carries an unresolved {app_name} token and no app "
439
+ "name is available (neither a registered app class nor "
440
+ "ATLAN_APPLICATION_NAME), so the SDK is serving the literal token "
441
+ "rather than inventing a name that would look legitimate and be "
442
+ "polled by nobody. Failure attribution for runs started from this "
443
+ "manifest will be blank, and any task_queue the SDK could not stamp "
444
+ "(stamped queue: %r) targets no worker. Set ATLAN_APPLICATION_NAME "
445
+ "on the deployment, or regenerate the contract so the toolkit bakes "
446
+ "the app name.",
447
+ origin,
448
+ resolution.task_queue,
449
+ )
450
+ elif resolution.had_app_name_token:
451
+ log.warning(
452
+ "Manifest for %s shipped an unbaked {app_name} token; filled with "
453
+ "%r for this response. The committed manifest is stale — bump the "
454
+ "contract toolkit and regenerate contracts, since any writer of "
455
+ "this DAG that does not go through this route gets no such fallback.",
456
+ origin,
457
+ resolution.app_name,
458
+ )
459
+ return resolution.raw
460
+
461
+ async def _serve_entrypoint_manifest(
462
+ entrypoint_name: str,
463
+ fe_inputs: str | dict[str, Any] | None,
464
+ ) -> Response:
465
+ """Read <entrypoint>/manifest.json, substitute placeholders, run the
466
+ optional ``compute_manifest`` hook, and return the Response.
467
+
468
+ ``fe_inputs`` arrives either as the raw URL-encoded JSON string (GET,
469
+ capped) or an already-parsed dict (POST body, uncapped). It stays
470
+ undecoded until the hook needs it.
471
+ """
472
+ log = source.log()
473
+ generated_dir = source.generated_dir()
474
+ # {entrypoint_name → manifest_path} from glob(), never from user input,
475
+ # so the path that reaches read_bytes() is not tainted by the HTTP param.
476
+ registry: dict[str, Path] = {
477
+ p.parent.name: p for p in generated_dir.glob("*/manifest.json")
478
+ }
479
+ ep_manifest = registry.get(entrypoint_name)
480
+ if ep_manifest is None:
481
+ # Single-entrypoint apps emit the manifest FLAT, with no
482
+ # per-entrypoint subdir. Heracles/AE always sends ?entrypoint= on
483
+ # submit, so without this fallback a flat app 404s and every run
484
+ # submit fails.
485
+ flat_manifest = generated_dir / "manifest.json"
486
+ if flat_manifest.is_file():
487
+ ep_manifest = flat_manifest
488
+ else:
489
+ raise HTTPException(
490
+ status_code=404,
491
+ detail=f"No manifest found for entrypoint {entrypoint_name!r}",
492
+ )
493
+ raw = _resolve_manifest_placeholders(
494
+ ep_manifest.read_bytes(), f"entrypoint {entrypoint_name!r}"
495
+ )
496
+
497
+ # Dynamic-manifest hook: hand the static manifest and the decoded
498
+ # `fe_inputs` to the app's `compute_manifest`, and serve what it
499
+ # returns. Apps without one get the static manifest unchanged.
500
+ compute = source.compute_manifest(entrypoint_name)
501
+ if compute is not None:
502
+ # Decode lazily and only here: an app with no hook has always
503
+ # ignored fe_inputs, so decoding at the route would start 413-ing
504
+ # static apps that work today.
505
+ fe_inputs_decoded = (
506
+ fe_inputs
507
+ if isinstance(fe_inputs, dict)
508
+ else decode_fe_inputs(fe_inputs, source.max_fe_inputs_bytes())
509
+ )
510
+ manifest_dict = orjson.loads(raw)
511
+ try:
512
+ computed = await compute(manifest_dict, fe_inputs_decoded)
513
+ except HTTPException:
514
+ raise
515
+ except Exception:
516
+ # Don't leak the hook's internals (stack/SQL/paths) to the
517
+ # caller — mirror the other handler routes' generic 500.
518
+ # conformance: ignore[L009] boundary handler logs the real exception (exc_info) then raises a sanitized HTTPException `from None`; the log is the only server-side record of the actual failure.
519
+ log.error(
520
+ "compute_manifest hook failed for entrypoint %r",
521
+ entrypoint_name,
522
+ exc_info=True,
523
+ )
524
+ raise HTTPException(
525
+ status_code=500, detail="Internal server error"
526
+ ) from None
527
+ if not isinstance(computed, dict):
528
+ # conformance: ignore[L009] logs caller-invisible context (the actual returned type) that the generic 500 HTTPException detail omits.
529
+ log.error(
530
+ "compute_manifest for entrypoint %r returned %s, expected dict",
531
+ entrypoint_name,
532
+ type(computed).__name__,
533
+ )
534
+ raise HTTPException(status_code=500, detail="Internal server error")
535
+ # Reconcile the hook's output too: it replaces the manifest
536
+ # wholesale, so a token it re-introduced would otherwise be served
537
+ # unreconciled. Idempotent, so a no-op unless the hook added one.
538
+ raw = _resolve_manifest_placeholders(
539
+ orjson.dumps(computed),
540
+ f"entrypoint {entrypoint_name!r} (compute_manifest output)",
541
+ )
542
+
543
+ return Response(content=raw, media_type="application/json")
544
+
545
+ async def _serve_manifest(
546
+ entrypoint: str | None,
547
+ fe_inputs: str | dict[str, Any] | None,
548
+ ) -> Response:
549
+ """Resolve the entrypoint and serve its manifest (GET and POST alike)."""
550
+ log = source.log()
551
+ generated_dir = source.generated_dir()
552
+ if entrypoint:
553
+ # Reject obviously-malformed names before touching disk.
554
+ if not _ENTRYPOINT_NAME_RE.match(entrypoint):
555
+ raise HTTPException(status_code=400, detail="Invalid entrypoint name")
556
+ return await _serve_entrypoint_manifest(entrypoint, fe_inputs)
557
+
558
+ # No entrypoint param: single-entrypoint path.
559
+ if source.programmatic_manifest is not None:
560
+ # A programmatic AppManifest has no contract-toolkit bake behind it,
561
+ # so it gets the same reconciliation as the files.
562
+ raw = _resolve_manifest_placeholders(
563
+ source.programmatic_manifest.model_dump_json().encode(),
564
+ "programmatic manifest",
565
+ )
566
+ return Response(content=raw, media_type="application/json")
567
+ manifest_path = generated_dir / "manifest.json"
568
+ if manifest_path.exists():
569
+ raw = _resolve_manifest_placeholders(
570
+ manifest_path.read_bytes(), "root manifest"
571
+ )
572
+ return Response(content=raw, media_type="application/json")
573
+
574
+ # Default-entrypoint fallback: multi-entrypoint apps have no root
575
+ # manifest by design. Try the default first, then every explicit
576
+ # entrypoint alphabetically — the same tie-break the framework uses
577
+ # when several @entrypoints carry no marked default.
578
+ default_ep = _default_entrypoint(
579
+ log,
580
+ "No default entrypoint for app %s; trying explicit-entrypoint "
581
+ "fallback before serving 404",
582
+ )
583
+ candidates: list[str] = []
584
+ if default_ep:
585
+ candidates.append(default_ep)
586
+ candidates.extend(e for e in _explicit_entrypoints() if e not in candidates)
587
+ for cand in candidates:
588
+ try:
589
+ return await _serve_entrypoint_manifest(cand, fe_inputs)
590
+ except HTTPException as exc:
591
+ if exc.status_code != 404:
592
+ raise
593
+ # This candidate has no manifest file on disk — try the next.
594
+
595
+ raise HTTPException(status_code=404, detail="No manifest available")
596
+
597
+ @app.get("/workflows/v1/manifest")
598
+ async def get_manifest(
599
+ entrypoint: str | None = None,
600
+ fe_inputs: str | None = None,
601
+ ) -> Response:
602
+ """Serve a manifest with ``fe_inputs`` as a query parameter.
603
+
604
+ Retained for the playground, connector integration tests and the
605
+ AE/marketplace probes, which send no ``fe_inputs``. Callers that do
606
+ should use ``POST``: the query string caps the payload (CSA-539).
607
+ """
608
+ return await _serve_manifest(entrypoint, fe_inputs)
609
+
610
+ @app.post("/workflows/v1/manifest")
611
+ async def post_manifest(request: Request) -> Response:
612
+ """Serve a manifest with ``fe_inputs`` in the request body.
613
+
614
+ Body: ``{"entrypoint": str | null, "fe_inputs": {...}, "user_id": str | null}``
615
+ — all optional. ``{}``, ``null`` and an absent body equal a bare GET.
616
+ ``fe_inputs`` must be a JSON object. ``user_id`` is accepted and ignored.
617
+ """
618
+ raw = await request.body()
619
+ if not raw.strip():
620
+ # Absent/empty body means "no inputs". Callers fall back only on
621
+ # 405, so 400-ing this would hard-fail them.
622
+ body: Any = {}
623
+ else:
624
+ try:
625
+ body = await request.json()
626
+ except ValueError as exc:
627
+ # A fixed message: no parser or exception text in a response
628
+ # body. ValueError covers JSONDecodeError and a non-UTF-8 body.
629
+ raise HTTPException(
630
+ status_code=400, detail="Request body is not valid JSON"
631
+ ) from exc
632
+ if body is None:
633
+ body = {}
634
+ if not isinstance(body, dict):
635
+ raise HTTPException(
636
+ status_code=400, detail="Request body must be a JSON object"
637
+ )
638
+
639
+ entrypoint = body.get("entrypoint")
640
+ if entrypoint is not None and not isinstance(entrypoint, str):
641
+ raise HTTPException(status_code=400, detail="entrypoint must be a string")
642
+
643
+ fe_inputs = body.get("fe_inputs")
644
+ if fe_inputs is None:
645
+ fe_inputs = {}
646
+ if not isinstance(fe_inputs, dict):
647
+ raise HTTPException(
648
+ status_code=400, detail="fe_inputs must be a JSON object"
649
+ )
650
+
651
+ return await _serve_manifest(entrypoint, fe_inputs)
652
+
653
+ # Backwards-compatibility alias — TACTICAL. Heracles / Automation Engine
654
+ # still call the v2-era unversioned /manifest (BLDX-804).
655
+
656
+ @app.get("/manifest", include_in_schema=False)
657
+ async def get_manifest_legacy(
658
+ entrypoint: str | None = None,
659
+ fe_inputs: str | None = None,
660
+ ) -> Response:
661
+ """Unversioned alias for ``GET /workflows/v1/manifest``. Do not add callers."""
662
+ return await get_manifest(entrypoint=entrypoint, fe_inputs=fe_inputs)
663
+
664
+ @app.post("/manifest", include_in_schema=False)
665
+ async def post_manifest_legacy(request: Request) -> Response:
666
+ """Unversioned alias for ``POST /workflows/v1/manifest``. Do not add callers."""
667
+ return await post_manifest(request)
668
+
669
+ # ------------------------------------------------------------------
670
+ # Input contract
671
+ # ------------------------------------------------------------------
672
+
673
+ @app.get("/workflows/v1/input-contract")
674
+ async def get_input_contract(entrypoint: str | None = None) -> Response:
675
+ """Return the input contract (JSON Schema) for an entry point.
676
+
677
+ The app's machine-readable input contract: Heracles uses it to validate
678
+ caller inputs and to find credential fields (a ``$ref`` to the
679
+ ``CredentialRef`` / ``AgentCredentialSpec`` def). An explicit
680
+ ``?entrypoint=`` selects it; a single-entry-point app resolves
681
+ automatically; a multi-entry-point app requires the param.
682
+ """
683
+ if entrypoint is not None and not _ENTRYPOINT_NAME_RE.match(entrypoint):
684
+ raise HTTPException(status_code=400, detail="Invalid entrypoint name")
685
+ if source.input_contract is not None:
686
+ contract = source.input_contract(entrypoint)
687
+ else:
688
+ contract = tree_input_contract(
689
+ source.generated_dir(), source.app_package, entrypoint
690
+ )
691
+ return Response(
692
+ content=orjson.dumps(contract.model_json_schema()),
693
+ media_type="application/json",
694
+ )
695
+
696
+
697
+ def generated_dir_of(package_dir: str | os.PathLike[str]) -> Path:
698
+ """Where an app package built by ``gen_app_api.py`` ships its generated files."""
699
+ return Path(package_dir) / "generated"