Skip to content

Commit 2a0e0e3

Browse files
OnkarVO7claude
andcommitted
feat(input-contract): serve generated AppInputContract over thin runtime input_type (BLDX-1354)
The /workflows/v1/input-contract endpoint returned ep.input_type — the type the @entrypoint run() method accepts. For contract-toolkit apps that is a thin runtime wrapper (e.g. bigquery's CrawlInput/MinerInput, extra="allow", just two fields) rather than the rich generated AppInputContract (all configured fields + CredentialRef-typed credential fields) at app/generated/{entrypoint}/_input.py. Result: validation no-ops, no field/default discovery, and Heracles can't detect credentials by $ref. Auto-discover the generated AppInputContract by convention (app/generated/{entrypoint}/_input.py, or app/generated/_input.py for single-ep apps) and serve it; fall back to the runtime input_type when no generated contract is importable, so apps not using the contract-toolkit are unchanged. Covered by new tests: generated contract preferred when present (and per-entry fallback for an entrypoint without one), plus a direct fallback unit test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c421fb7 commit 2a0e0e3

2 files changed

Lines changed: 116 additions & 1 deletion

File tree

application_sdk/handler/service.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,50 @@ def _resolve_app_entrypoint(
555555
return app_meta, ep
556556

557557

558+
def _published_input_contract(ep: Any) -> Any:
559+
"""Resolve the entry point's *published* input contract.
560+
561+
Apps generated by the contract-toolkit ship a rich, declarative
562+
``AppInputContract`` — every configured field plus ``CredentialRef``-typed
563+
credential fields — at ``app/generated/{entrypoint}/_input.py`` (or
564+
``app/generated/_input.py`` for a single-entry-point app). That is distinct
565+
from the thin runtime wrapper the ``@entrypoint`` method accepts (which uses
566+
``extra="allow"`` just to carry the AE config dict and hydration state), and
567+
it is the schema Heracles needs to validate caller inputs and discover
568+
credential fields by their ``CredentialRef``/``AgentCredentialSpec`` ``$ref``.
569+
570+
Prefer that generated contract by convention; fall back to the entry point's
571+
runtime ``input_type`` when no generated contract is importable (so apps that
572+
don't use the contract-toolkit keep working unchanged).
573+
"""
574+
import importlib # noqa: PLC0415 — cold path: only on /input-contract
575+
576+
ep_module = ep.name.replace("-", "_")
577+
for module_path in (
578+
f"app.generated.{ep_module}._input",
579+
"app.generated._input",
580+
):
581+
try:
582+
module = importlib.import_module(module_path)
583+
except ImportError:
584+
continue
585+
contract = getattr(module, "AppInputContract", None)
586+
if contract is not None and hasattr(contract, "model_json_schema"):
587+
logger.debug(
588+
"input-contract: using generated AppInputContract from %s for entrypoint %s",
589+
module_path,
590+
ep.name,
591+
)
592+
return contract
593+
logger.debug(
594+
"input-contract: no generated AppInputContract for entrypoint %s; "
595+
"falling back to runtime input_type %s",
596+
ep.name,
597+
getattr(ep.input_type, "__name__", ep.input_type),
598+
)
599+
return ep.input_type
600+
601+
558602
_WORKFLOW_SENSITIVE_FIELDS = {
559603
"username",
560604
"password",
@@ -1548,7 +1592,10 @@ async def get_input_contract(entrypoint: str | None = None) -> Response:
15481592
_workflow_config.app_name, entrypoint, unknown_ep_status=404
15491593
)
15501594

1551-
input_type = ep.input_type
1595+
# Prefer the generated AppInputContract (rich, validatable, credential
1596+
# refs) over the entry point's thin runtime input_type. See
1597+
# _published_input_contract.
1598+
input_type = _published_input_contract(ep)
15521599
if input_type is None:
15531600
raise HTTPException(
15541601
status_code=500, detail="Entry point has no input type."

tests/unit/handler/test_service.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4719,6 +4719,74 @@ async def run(self, input: _RoutingInput) -> _RoutingOutput:
47194719
)
47204720
assert resp.status_code == 400
47214721

4722+
@staticmethod
4723+
def _inject_generated_contract(ep_module: str, contract_cls: type):
4724+
"""Register a fake app/generated/{ep}/_input.py:AppInputContract in
4725+
sys.modules so _published_input_contract can import it. Returns the list
4726+
of injected module paths for cleanup."""
4727+
import sys
4728+
import types
4729+
4730+
paths = ["app", "app.generated", f"app.generated.{ep_module}"]
4731+
for p in paths:
4732+
sys.modules.setdefault(p, types.ModuleType(p))
4733+
leaf = f"app.generated.{ep_module}._input"
4734+
mod = types.ModuleType(leaf)
4735+
mod.AppInputContract = contract_cls
4736+
sys.modules[leaf] = mod
4737+
return paths + [leaf]
4738+
4739+
def test_prefers_generated_app_input_contract(self) -> None:
4740+
"""When app/generated/{ep}/_input.py:AppInputContract is importable, the
4741+
endpoint returns its (rich) schema, not the thin runtime input_type."""
4742+
import sys
4743+
4744+
from application_sdk.app.base import App
4745+
from application_sdk.app.entrypoint import entrypoint
4746+
4747+
class _GenContract(Input, allow_unbounded_fields=True): # type: ignore[call-arg]
4748+
region: str = "region-us"
4749+
4750+
class _MultiEpGen(App):
4751+
@entrypoint
4752+
async def extract(self, input: _RoutingInput) -> _RoutingOutput:
4753+
return _RoutingOutput()
4754+
4755+
@entrypoint
4756+
async def load(self, input: _RoutingInput) -> _RoutingOutput:
4757+
return _RoutingOutput()
4758+
4759+
injected = self._inject_generated_contract("extract", _GenContract)
4760+
try:
4761+
resp = self._client(_MultiEpGen).get(
4762+
"/workflows/v1/input-contract?entrypoint=extract"
4763+
)
4764+
assert resp.status_code == 200
4765+
# rich generated contract, not the thin _RoutingInput
4766+
assert resp.json() == _GenContract.model_json_schema()
4767+
assert "region" in resp.json()["properties"]
4768+
# the entrypoint with no generated module still falls back
4769+
resp_load = self._client(_MultiEpGen).get(
4770+
"/workflows/v1/input-contract?entrypoint=load"
4771+
)
4772+
assert resp_load.json() == _RoutingInput.model_json_schema()
4773+
finally:
4774+
for p in injected:
4775+
sys.modules.pop(p, None)
4776+
4777+
def test_published_contract_falls_back_to_input_type(self) -> None:
4778+
"""No generated module → _published_input_contract returns input_type."""
4779+
from application_sdk.app.entrypoint import EntryPointMetadata
4780+
from application_sdk.handler.service import _published_input_contract
4781+
4782+
ep = EntryPointMetadata(
4783+
name="no-generated-here",
4784+
input_type=_RoutingInput,
4785+
output_type=_RoutingOutput,
4786+
method_name="no_generated_here",
4787+
)
4788+
assert _published_input_contract(ep) is _RoutingInput
4789+
47224790

47234791
class TestDefaultEntrypoint:
47244792
"""Tests for default-entrypoint resolution (@entrypoint(default=True))."""

0 commit comments

Comments
 (0)