feat: normalize workflow OpenRPC manifests
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
from .model import ContractManifest, JsonSchema, JsonValue, ManifestError
|
||||||
|
from .normalize import manifest_from_openrpc
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ContractManifest",
|
||||||
|
"JsonSchema",
|
||||||
|
"JsonValue",
|
||||||
|
"ManifestError",
|
||||||
|
"manifest_from_openrpc",
|
||||||
|
]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
type JsonScalar = None | bool | int | float | str
|
||||||
|
type JsonValue = JsonScalar | list[JsonValue] | dict[str, JsonValue]
|
||||||
|
type JsonSchema = dict[str, JsonValue]
|
||||||
|
|
||||||
|
|
||||||
|
class ManifestSource(TypedDict):
|
||||||
|
format: str
|
||||||
|
openrpc_version: str
|
||||||
|
|
||||||
|
|
||||||
|
class ManifestParameter(TypedDict):
|
||||||
|
name: str
|
||||||
|
required: bool
|
||||||
|
schema: JsonSchema
|
||||||
|
|
||||||
|
|
||||||
|
class ManifestResult(TypedDict):
|
||||||
|
schema: JsonSchema
|
||||||
|
|
||||||
|
|
||||||
|
class ManifestOperation(TypedDict):
|
||||||
|
method: str
|
||||||
|
namespace: list[str]
|
||||||
|
action: str
|
||||||
|
params: list[ManifestParameter]
|
||||||
|
result: ManifestResult
|
||||||
|
errors: list[JsonSchema]
|
||||||
|
|
||||||
|
|
||||||
|
class ManifestComponents(TypedDict):
|
||||||
|
schemas: dict[str, JsonSchema]
|
||||||
|
errors: dict[str, JsonValue]
|
||||||
|
|
||||||
|
|
||||||
|
class ContractManifest(TypedDict):
|
||||||
|
manifest_version: int
|
||||||
|
source: ManifestSource
|
||||||
|
operations: list[ManifestOperation]
|
||||||
|
components: ManifestComponents
|
||||||
|
|
||||||
|
|
||||||
|
class ManifestError(ValueError):
|
||||||
|
"""Report an invalid source contract with its exact OpenRPC document path."""
|
||||||
|
|
||||||
|
def __init__(self, path: str, message: str) -> None:
|
||||||
|
self.path = path
|
||||||
|
self.message = message
|
||||||
|
super().__init__(f"{path}: {message}")
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
from .model import (
|
||||||
|
ContractManifest,
|
||||||
|
JsonSchema,
|
||||||
|
JsonValue,
|
||||||
|
ManifestError,
|
||||||
|
ManifestOperation,
|
||||||
|
ManifestParameter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: object, path: str) -> Mapping[str, object]:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ManifestError(path, "expected an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _list(value: object, path: str) -> list[object]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise ManifestError(path, "expected an array")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _string(value: object, path: str) -> str:
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise ManifestError(path, "expected a non-empty string")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _boolean(value: object, path: str) -> bool:
|
||||||
|
if not isinstance(value, bool):
|
||||||
|
raise ManifestError(path, "expected a boolean")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _json_value(value: object, path: str) -> JsonValue:
|
||||||
|
# Generated titles are removed recursively; every other schema keyword/value stays opaque.
|
||||||
|
if value is None or isinstance(value, bool | int | float | str):
|
||||||
|
return value
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_json_value(item, f"{path}[{index}]") for index, item in enumerate(value)]
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
normalized: dict[str, JsonValue] = {}
|
||||||
|
for key, item in value.items():
|
||||||
|
if not isinstance(key, str):
|
||||||
|
raise ManifestError(path, "expected string object keys")
|
||||||
|
if key != "title":
|
||||||
|
normalized[key] = _json_value(item, f"{path}.{key}")
|
||||||
|
return normalized
|
||||||
|
raise ManifestError(path, "expected a JSON value")
|
||||||
|
|
||||||
|
|
||||||
|
def _schema(value: object, path: str) -> JsonSchema:
|
||||||
|
normalized = _json_value(value, path)
|
||||||
|
if not isinstance(normalized, dict):
|
||||||
|
raise ManifestError(path, "expected a schema object")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_from_openrpc(document: Mapping[str, object]) -> ContractManifest:
|
||||||
|
"""Normalize an OpenRPC document into the stable workflow contract shape."""
|
||||||
|
openrpc_version = _string(document.get("openrpc"), "$.openrpc")
|
||||||
|
methods = _list(document.get("methods"), "$.methods")
|
||||||
|
components = _mapping(document.get("components"), "$.components")
|
||||||
|
schemas = _mapping(components.get("schemas"), "$.components.schemas")
|
||||||
|
component_errors = _mapping(components.get("errors"), "$.components.errors")
|
||||||
|
|
||||||
|
operations: list[ManifestOperation] = []
|
||||||
|
for method_index, method_value in enumerate(methods):
|
||||||
|
method_path = f"$.methods[{method_index}]"
|
||||||
|
method = _mapping(method_value, method_path)
|
||||||
|
method_name = _string(method.get("name"), f"{method_path}.name")
|
||||||
|
segments = method_name.split(".")
|
||||||
|
if len(segments) < 2 or any(not segment for segment in segments):
|
||||||
|
raise ManifestError(
|
||||||
|
f"{method_path}.name",
|
||||||
|
"expected a method name with non-empty dot-separated segments",
|
||||||
|
)
|
||||||
|
|
||||||
|
params: list[ManifestParameter] = []
|
||||||
|
for parameter_index, parameter_value in enumerate(
|
||||||
|
_list(method.get("params"), f"{method_path}.params")
|
||||||
|
):
|
||||||
|
parameter_path = f"{method_path}.params[{parameter_index}]"
|
||||||
|
parameter = _mapping(parameter_value, parameter_path)
|
||||||
|
params.append(
|
||||||
|
{
|
||||||
|
"name": _string(parameter.get("name"), f"{parameter_path}.name"),
|
||||||
|
"required": _boolean(
|
||||||
|
parameter.get("required"), f"{parameter_path}.required"
|
||||||
|
),
|
||||||
|
"schema": _schema(
|
||||||
|
parameter.get("schema"), f"{parameter_path}.schema"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result_path = f"{method_path}.result"
|
||||||
|
result = _mapping(method.get("result"), result_path)
|
||||||
|
errors: list[JsonSchema] = []
|
||||||
|
for error_index, error_value in enumerate(
|
||||||
|
_list(method.get("errors"), f"{method_path}.errors")
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
_schema(error_value, f"{method_path}.errors[{error_index}]")
|
||||||
|
)
|
||||||
|
|
||||||
|
operations.append(
|
||||||
|
{
|
||||||
|
"method": method_name,
|
||||||
|
"namespace": segments[:-1],
|
||||||
|
"action": segments[-1],
|
||||||
|
"params": params,
|
||||||
|
"result": {"schema": _schema(result.get("schema"), f"{result_path}.schema")},
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_schemas = {
|
||||||
|
key: _schema(schemas[key], f"$.components.schemas.{key}")
|
||||||
|
for key in sorted(schemas)
|
||||||
|
}
|
||||||
|
normalized_errors = {
|
||||||
|
key: _json_value(component_errors[key], f"$.components.errors.{key}")
|
||||||
|
for key in sorted(component_errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"manifest_version": 1,
|
||||||
|
"source": {"format": "openrpc", "openrpc_version": openrpc_version},
|
||||||
|
"operations": sorted(operations, key=lambda operation: operation["method"]),
|
||||||
|
"components": {"schemas": normalized_schemas, "errors": normalized_errors},
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def synthetic_openrpc_document() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"openrpc": "1.2.6",
|
||||||
|
"info": {"title": "ignored", "version": "0"},
|
||||||
|
"methods": [
|
||||||
|
{
|
||||||
|
"name": "workflow.zeta.run",
|
||||||
|
"params": [],
|
||||||
|
"result": {
|
||||||
|
"name": "workflow.zeta.run_Result",
|
||||||
|
"schema": {"$ref": "#/components/schemas/ZetaResult"},
|
||||||
|
},
|
||||||
|
"errors": [{"$ref": "#/components/errors/5000"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "workflow.alpha.inspect",
|
||||||
|
"params": [
|
||||||
|
{
|
||||||
|
"name": "optional_nullable",
|
||||||
|
"required": False,
|
||||||
|
"schema": {
|
||||||
|
"title": "Optional Nullable",
|
||||||
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||||
|
"x-future-keyword": {
|
||||||
|
"title": "removed recursively",
|
||||||
|
"value": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "required_closed",
|
||||||
|
"required": True,
|
||||||
|
"schema": {
|
||||||
|
"title": "Required Closed",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"result": {
|
||||||
|
"name": "workflow.alpha.inspect_Result",
|
||||||
|
"schema": {"$ref": "#/components/schemas/AlphaResult"},
|
||||||
|
},
|
||||||
|
"errors": [{"$ref": "#/components/errors/5000"}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"components": {
|
||||||
|
"schemas": {
|
||||||
|
"ZetaResult": {
|
||||||
|
"title": "Zeta Result",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"extension": {"additionalProperties": True}},
|
||||||
|
},
|
||||||
|
"FreeJson": {},
|
||||||
|
"AlphaResult": {
|
||||||
|
"title": "Alpha Result",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"mode": {"title": "Mode", "const": "alpha"},
|
||||||
|
"payload": {},
|
||||||
|
},
|
||||||
|
"required": ["mode", "payload"],
|
||||||
|
"if": {"properties": {"mode": {"const": "alpha"}}},
|
||||||
|
"then": {"required": ["payload"]},
|
||||||
|
"not": {"required": ["forbidden"]},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"5000": {
|
||||||
|
"code": 5000,
|
||||||
|
"message": "Workflow operation failed",
|
||||||
|
"data": {
|
||||||
|
"title": "Error Data",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from wf_contract_manifest import manifest_from_openrpc
|
||||||
|
|
||||||
|
from .fixtures import synthetic_openrpc_document
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalizes_operations_and_components_deterministically() -> None:
|
||||||
|
manifest = manifest_from_openrpc(synthetic_openrpc_document())
|
||||||
|
|
||||||
|
assert manifest["manifest_version"] == 1
|
||||||
|
assert manifest["source"] == {
|
||||||
|
"format": "openrpc",
|
||||||
|
"openrpc_version": "1.2.6",
|
||||||
|
}
|
||||||
|
assert [operation["method"] for operation in manifest["operations"]] == [
|
||||||
|
"workflow.alpha.inspect",
|
||||||
|
"workflow.zeta.run",
|
||||||
|
]
|
||||||
|
assert list(manifest["components"]["schemas"]) == [
|
||||||
|
"AlphaResult",
|
||||||
|
"FreeJson",
|
||||||
|
"ZetaResult",
|
||||||
|
]
|
||||||
|
assert list(manifest["components"]["errors"]) == ["5000"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_preserves_parameter_order_optionality_and_nullability() -> None:
|
||||||
|
operation = manifest_from_openrpc(synthetic_openrpc_document())["operations"][0]
|
||||||
|
|
||||||
|
assert [parameter["name"] for parameter in operation["params"]] == [
|
||||||
|
"optional_nullable",
|
||||||
|
"required_closed",
|
||||||
|
]
|
||||||
|
assert operation["params"][0]["required"] is False
|
||||||
|
assert operation["params"][0]["schema"]["anyOf"] == [
|
||||||
|
{"type": "string"},
|
||||||
|
{"type": "null"},
|
||||||
|
]
|
||||||
|
assert operation["params"][1]["required"] is True
|
||||||
|
assert operation["params"][1]["schema"]["additionalProperties"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_removes_only_titles_and_preserves_unknown_schema_keywords() -> None:
|
||||||
|
manifest = manifest_from_openrpc(synthetic_openrpc_document())
|
||||||
|
optional_schema = manifest["operations"][0]["params"][0]["schema"]
|
||||||
|
|
||||||
|
assert "title" not in optional_schema
|
||||||
|
assert optional_schema["x-future-keyword"] == {"value": 1}
|
||||||
|
assert manifest["components"]["schemas"]["FreeJson"] == {}
|
||||||
|
assert manifest["components"]["schemas"]["ZetaResult"]["properties"] == {
|
||||||
|
"extension": {"additionalProperties": True}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_preserves_conditional_schema_keywords() -> None:
|
||||||
|
alpha = manifest_from_openrpc(synthetic_openrpc_document())["components"]["schemas"][
|
||||||
|
"AlphaResult"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert alpha["if"] == {"properties": {"mode": {"const": "alpha"}}}
|
||||||
|
assert alpha["then"] == {"required": ["payload"]}
|
||||||
|
assert alpha["not"] == {"required": ["forbidden"]}
|
||||||
Reference in New Issue
Block a user