feat: generate canonical workflow contract
This commit is contained in:
@@ -1,3 +1,11 @@
|
|||||||
|
from .generate import generate_manifest
|
||||||
|
from .io import (
|
||||||
|
DEFAULT_MANIFEST_PATH,
|
||||||
|
ManifestDriftError,
|
||||||
|
canonical_manifest_json,
|
||||||
|
check_manifest,
|
||||||
|
write_manifest,
|
||||||
|
)
|
||||||
from .model import ContractManifest, JsonSchema, JsonValue, ManifestError
|
from .model import ContractManifest, JsonSchema, JsonValue, ManifestError
|
||||||
from .normalize import manifest_from_openrpc
|
from .normalize import manifest_from_openrpc
|
||||||
|
|
||||||
@@ -7,4 +15,10 @@ __all__ = [
|
|||||||
"JsonValue",
|
"JsonValue",
|
||||||
"ManifestError",
|
"ManifestError",
|
||||||
"manifest_from_openrpc",
|
"manifest_from_openrpc",
|
||||||
|
"generate_manifest",
|
||||||
|
"DEFAULT_MANIFEST_PATH",
|
||||||
|
"ManifestDriftError",
|
||||||
|
"canonical_manifest_json",
|
||||||
|
"check_manifest",
|
||||||
|
"write_manifest",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from wf_server import build_local_static_workflow_server
|
||||||
|
from wf_transport_rpc_http import create_rpc_app
|
||||||
|
|
||||||
|
from .model import ContractManifest
|
||||||
|
from .normalize import manifest_from_openrpc
|
||||||
|
|
||||||
|
|
||||||
|
def generate_manifest() -> ContractManifest:
|
||||||
|
"""Compose the real server against an isolated store and normalize OpenRPC."""
|
||||||
|
with TemporaryDirectory(prefix="wf-contract-manifest-") as directory:
|
||||||
|
server = build_local_static_workflow_server(Path(directory) / "store")
|
||||||
|
document = cast(dict[str, object], create_rpc_app(server).get_openrpc())
|
||||||
|
# Normalization deliberately drops framework metadata that could carry
|
||||||
|
# process-local paths or transport details.
|
||||||
|
return manifest_from_openrpc(document)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .model import ContractManifest
|
||||||
|
|
||||||
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
DEFAULT_MANIFEST_PATH = REPOSITORY_ROOT / "contracts" / "workflow-api.manifest.json"
|
||||||
|
|
||||||
|
|
||||||
|
class ManifestDriftError(RuntimeError):
|
||||||
|
"""Indicate that the checked manifest differs from the generated contract."""
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_manifest_json(manifest: ContractManifest) -> str:
|
||||||
|
try:
|
||||||
|
return json.dumps(manifest, ensure_ascii=False, indent=2) + "\n"
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise ValueError(f"manifest is not canonically serializable: {error}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def write_manifest(
|
||||||
|
manifest: ContractManifest, path: Path = DEFAULT_MANIFEST_PATH
|
||||||
|
) -> Path:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(canonical_manifest_json(manifest), encoding="utf-8", newline="\n")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def check_manifest(
|
||||||
|
manifest: ContractManifest, path: Path = DEFAULT_MANIFEST_PATH
|
||||||
|
) -> None:
|
||||||
|
expected = canonical_manifest_json(manifest).encode("utf-8")
|
||||||
|
try:
|
||||||
|
actual = path.read_bytes()
|
||||||
|
except FileNotFoundError as error:
|
||||||
|
raise ManifestDriftError(
|
||||||
|
f"{path} is missing; run `python -m wf_contract_manifest write`"
|
||||||
|
) from error
|
||||||
|
if actual != expected:
|
||||||
|
raise ManifestDriftError(
|
||||||
|
f"{path} is stale; run `python -m wf_contract_manifest write`"
|
||||||
|
)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from wf_contract_manifest import generate_manifest
|
||||||
|
|
||||||
|
UNION_RESULTS = {
|
||||||
|
"InspectCapabilityResult",
|
||||||
|
"PatchDraftResult",
|
||||||
|
"ValidateDraftResult",
|
||||||
|
"CompileDraftWorkspaceResult",
|
||||||
|
"CreateArtifactFromWorkspaceResult",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_generates_the_complete_real_workflow_contract() -> None:
|
||||||
|
manifest = generate_manifest()
|
||||||
|
schemas = manifest["components"]["schemas"]
|
||||||
|
|
||||||
|
assert len(manifest["operations"]) == 70
|
||||||
|
assert len({operation["method"] for operation in manifest["operations"]}) == 70
|
||||||
|
assert len(schemas) == 126
|
||||||
|
assert len(manifest["components"]["errors"]) == 1
|
||||||
|
assert all(
|
||||||
|
set(operation["result"]["schema"]) == {"$ref"}
|
||||||
|
for operation in manifest["operations"]
|
||||||
|
)
|
||||||
|
assert {name for name in UNION_RESULTS if "anyOf" in schemas[name]} == UNION_RESULTS
|
||||||
|
|
||||||
|
|
||||||
|
def test_generated_contract_preserves_security_and_extension_boundaries() -> None:
|
||||||
|
schemas = generate_manifest()["components"]["schemas"]
|
||||||
|
|
||||||
|
auth_result_names = [
|
||||||
|
name for name in schemas if "Auth" in name and name.endswith("Result")
|
||||||
|
]
|
||||||
|
assert auth_result_names
|
||||||
|
for name in auth_result_names:
|
||||||
|
properties = schemas[name].get("properties", {})
|
||||||
|
assert isinstance(properties, dict)
|
||||||
|
assert "payload" not in properties
|
||||||
|
|
||||||
|
assert schemas["SourceDiagnosisResult"]["additionalProperties"] is True
|
||||||
|
assert schemas["RegistryEntryPayload"]["additionalProperties"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_generated_contract_contains_no_temporary_or_transport_state() -> None:
|
||||||
|
serialized = str(generate_manifest())
|
||||||
|
|
||||||
|
assert "TemporaryDirectory" not in serialized
|
||||||
|
assert "\\\\Temp\\\\" not in serialized
|
||||||
|
assert "127.0.0.1" not in serialized
|
||||||
|
assert '"/rpc"' not in serialized
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from wf_contract_manifest import (
|
||||||
|
ManifestDriftError,
|
||||||
|
canonical_manifest_json,
|
||||||
|
check_manifest,
|
||||||
|
manifest_from_openrpc,
|
||||||
|
write_manifest,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .fixtures import synthetic_openrpc_document
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest():
|
||||||
|
return manifest_from_openrpc(synthetic_openrpc_document())
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_json_is_stable_utf8_text_with_trailing_newline() -> None:
|
||||||
|
first = canonical_manifest_json(_manifest())
|
||||||
|
second = canonical_manifest_json(_manifest())
|
||||||
|
|
||||||
|
assert first == second
|
||||||
|
assert first.endswith("\n")
|
||||||
|
assert ' "manifest_version": 1' in first
|
||||||
|
assert "\\u" not in first
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_and_check_round_trip(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "workflow-api.manifest.json"
|
||||||
|
|
||||||
|
assert write_manifest(_manifest(), path) == path
|
||||||
|
check_manifest(_manifest(), path)
|
||||||
|
|
||||||
|
assert path.read_bytes() == canonical_manifest_json(_manifest()).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_reports_drift_without_mutating_the_file(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "workflow-api.manifest.json"
|
||||||
|
path.write_text("stale\n", encoding="utf-8")
|
||||||
|
before = path.read_bytes()
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ManifestDriftError, match="python -m wf_contract_manifest write"
|
||||||
|
):
|
||||||
|
check_manifest(_manifest(), path)
|
||||||
|
|
||||||
|
assert path.read_bytes() == before
|
||||||
Reference in New Issue
Block a user