Files
vdm/contracts/codegen/gen_openrpc.py
T
samiandClaude Opus 5 53421d6cb8 proto: freeze the wire contract at 1.0.0
Schemas for the whole v1 surface: 38 methods, 9 events, 25 named types and the
JSON-RPC envelope, with x-privileged / x-transports / x-deadlineMs / x-errors
annotations that both generators emit as data rather than prose.

Four generators over one IR (contracts/codegen/schema_ir.py), so the C++ structs,
the TypeScript types and the OpenRPC document cannot disagree about what the
contract says:

  gen_cpp.py             -> core/generated/velox_proto.{hpp,cpp}
  gen_ts.py              -> extension/src/shared/protocol/
  gen_openrpc.py         -> contracts/openrpc.json
  gen_cpp_conformance.py -> tests/conformance/cpp/fixture_dispatcher.hpp

Inbound parsing never throws: parse<T>() returns std::expected<T, ParseError> and
nlohmann's throwing ADL from_json is deliberately not emitted. Schema constraints
(minimum, maxLength, pattern, ...) become real runtime checks in both languages —
the daemon does not trust the extension and the extension does not trust the
daemon.

59 golden fixtures: a success case per method, 12 error cases, 9 events. Replayed
by tests/conformance/ against both the generated C++ and a live server over both
transports. tools/mockd serves the same fixtures with unhappy-path flags so the
GUI and EXT lanes never wait for veloxd.

run.sh also proves capture.offer fails open: with a daemon answering slower than
750 ms the client gives up and lets Firefox take the download.

core/generated/ is libveloxproto, a separate target from libveloxcore, which
still never sees JSON — see docs/adr/0009.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
2026-09-09 19:55:54 +04:00

161 lines
6.3 KiB
Python

#!/usr/bin/env python3
"""Emit contracts/openrpc.json from contracts/schema/.
This is the document humans read. It is generated, not written, so it cannot drift from
the schemas the code is generated from — the failure mode where the docs say one thing and
the wire does another is designed out rather than policed.
JSON-RPC named parameters are modelled as OpenRPC `by-name` params: each property of a
method's params object becomes one entry, which is what a reader expects to see.
Server-to-client notifications are not expressible in OpenRPC 1.2, so they are emitted
under a top-level `x-events` key alongside their payload schemas.
Run: python3 contracts/codegen/gen_openrpc.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from schema_ir import ID_PREFIX, SCHEMA_ROOT, Loader # noqa: E402
ROOT = SCHEMA_ROOT.parent
OUT = ROOT / "openrpc.json"
STRIP = {"$schema", "$id", "title"}
def rewrite(node: object) -> object:
"""Point every $ref at #/components/schemas/<Name> and drop per-file keywords."""
if isinstance(node, list):
return [rewrite(n) for n in node]
if not isinstance(node, dict):
return node
out: dict[str, object] = {}
for k, v in node.items():
if k == "$ref" and isinstance(v, str):
if v.startswith("#/$defs/"):
# envelope.schema.json's internal refs land under the x-envelope key.
out["$ref"] = "#/x-envelope/" + v[len("#/$defs/"):]
continue
if not v.startswith(ID_PREFIX + "types/"):
raise SystemExit(f"openrpc: unexpected $ref target {v}")
name = v[len(ID_PREFIX + "types/"):].removesuffix(".schema.json")
out["$ref"] = f"#/components/schemas/{name}"
continue
if k in STRIP:
continue
out[k] = rewrite(v)
return out
def main() -> int:
loader = Loader()
version = (ROOT / "VERSION").read_text().strip()
schemas: dict[str, object] = {}
for sid, doc in sorted(loader.by_id.items()):
if "/types/" not in sid:
continue
name = doc["title"]
body = rewrite({k: v for k, v in doc.items() if k not in STRIP})
assert isinstance(body, dict)
body["title"] = name
schemas[name] = body
methods = []
for sid, doc in sorted(loader.by_id.items()):
if "/methods/" not in sid:
continue
params_schema = doc["properties"]["params"]
# Expand a params object into by-name entries. A $ref'd params object is resolved
# first so the reader sees the fields, not just a type name.
resolved = params_schema
if "$ref" in resolved:
resolved = loader.by_id[resolved["$ref"]]
params = []
required = set(resolved.get("required", []))
for prop, sub in resolved.get("properties", {}).items():
entry: dict[str, object] = {"name": prop, "schema": rewrite(sub)}
if prop in required:
entry["required"] = True
if isinstance(sub, dict) and sub.get("description"):
entry["description"] = sub["description"]
params.append(entry)
method: dict[str, object] = {
"name": doc["title"],
"summary": doc.get("description", "").split(".")[0] + ".",
"description": doc.get("description", ""),
"paramStructure": "by-name",
"params": params,
"result": {"name": f"{doc['title']}Result", "schema": rewrite(doc["properties"]["result"])},
"x-privileged": doc.get("x-privileged", False),
"x-transports": doc.get("x-transports", []),
"x-deadlineMs": doc.get("x-deadlineMs"),
}
if doc.get("x-errors"):
code_doc = {e["value"]: e["doc"] for e in
loader.by_id[ID_PREFIX + "types/ErrorCode.schema.json"]["x-enum"]}
method["errors"] = [{"code": c, "message": code_doc.get(c, "")} for c in doc["x-errors"]]
if doc.get("x-wsRestrictions"):
method["x-wsRestrictions"] = doc["x-wsRestrictions"]
methods.append(method)
events = []
for sid, doc in sorted(loader.by_id.items()):
if "/events/" not in sid:
continue
events.append({
"name": doc["title"],
"description": doc.get("description", ""),
"params": rewrite(doc["properties"]["params"]),
"x-maxRateHz": doc.get("x-maxRateHz"),
})
envelope = loader.by_id[ID_PREFIX + "envelope.schema.json"]
out = {
"openrpc": "1.2.6",
"info": {
"title": "Velox Download Manager",
"version": version,
"description": (
"The wire contract between veloxd and every client: the Qt GUI, the CLI, "
"the native-messaging host and the Firefox extension. One JSON-RPC 2.0 "
"payload set over four framings; only the framing differs.\n\n"
"GENERATED from contracts/schema/ by contracts/codegen/gen_openrpc.py. "
"Do not edit by hand."
),
"license": {"name": "See repository LICENSE"},
},
"servers": [
{"name": "unix-socket", "url": "unix:$XDG_RUNTIME_DIR/velox/velox.sock",
"description": "NDJSON. GUI, CLI and nmhost. Peer credentials checked via SO_PEERCRED; same UID only, no token."},
{"name": "loopback-ws", "url": "ws://127.0.0.1:52000",
"description": "One JSON message per text frame. Extension fallback. Bound to 127.0.0.1 only, Origin-checked, token-authenticated, rate-limited. Port is the first free one in 52000-52016."},
],
"methods": methods,
"components": {"schemas": schemas},
"x-events": events,
"x-envelope": rewrite(envelope.get("$defs", {})),
"x-transports": {
"uds": "Unix domain socket, newline-delimited JSON.",
"ws": "Loopback WebSocket, one JSON message per text frame. Privileged methods are refused here with -32003.",
},
}
OUT.write_text(json.dumps(out, indent=2) + "\n")
print(f"gen_openrpc: {len(methods)} methods, {len(events)} events, {len(schemas)} schemas -> {OUT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())