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
455 lines
17 KiB
Python
455 lines
17 KiB
Python
"""Load contracts/schema/ and lower it into a small IR the generators emit from.
|
|
|
|
There is deliberately only one of these. gen_cpp.py, gen_ts.py and gen_openrpc.py all
|
|
consume the same IR, so the C++ structs, the TypeScript types and the human-readable
|
|
API document cannot disagree about what the contract says.
|
|
|
|
The IR covers exactly the JSON Schema subset the contract is allowed to use. Anything
|
|
outside it raises SchemaError at generation time rather than producing subtly wrong code:
|
|
a contract that cannot be generated from is a contract bug, and it should stop the build.
|
|
|
|
Supported subset
|
|
----------------
|
|
type: object / string / integer / number / boolean / array, and ["X", "null"]
|
|
object with `properties` -> struct
|
|
object with `additionalProperties: <schema>` -> map<string, T>
|
|
string with `enum` -> enum
|
|
integer with `enum` + x-enum -> named integer enum
|
|
array with `items` -> vector<T>
|
|
$ref to a types/*.schema.json -> named type
|
|
oneOf: [{$ref}, {type: null}] -> nullable named type
|
|
{} -> opaque JSON
|
|
|
|
Not supported, on purpose: allOf, anyOf, general oneOf, patternProperties, tuple items,
|
|
recursive types. If the contract needs one of these, extend this file in the same PR.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
SCHEMA_ROOT = Path(__file__).resolve().parent.parent / "schema"
|
|
ID_PREFIX = "https://velox.dev/schema/"
|
|
|
|
|
|
class SchemaError(Exception):
|
|
"""A schema the generators refuse to guess about."""
|
|
|
|
|
|
# --------------------------------------------------------------------------- IR
|
|
|
|
|
|
# Value constraints the generators enforce at runtime. The wire is never trusted, so a
|
|
# `maximum` in the schema has to be a check in the generated code, not just documentation.
|
|
CONSTRAINT_KEYS = ("minimum", "maximum", "minLength", "maxLength", "pattern",
|
|
"minItems", "maxItems")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TypeRef:
|
|
"""A reference to a type from a field. `kind` drives every emitter's switch."""
|
|
|
|
kind: str # named | string | integer | number | boolean | array | map | json
|
|
name: str | None = None # kind == "named"
|
|
inner: "TypeRef | None" = None # kind in ("array", "map")
|
|
constraints: tuple[tuple[str, object], ...] = ()
|
|
|
|
@property
|
|
def limits(self) -> dict[str, object]:
|
|
return dict(self.constraints)
|
|
|
|
|
|
@dataclass
|
|
class Field:
|
|
name: str
|
|
type: TypeRef
|
|
required: bool
|
|
nullable: bool
|
|
doc: str = ""
|
|
|
|
@property
|
|
def optional(self) -> bool:
|
|
"""Whether the emitted field needs an optional/undefined-able representation."""
|
|
return (not self.required) or self.nullable
|
|
|
|
|
|
@dataclass
|
|
class EnumValue:
|
|
name: str # identifier form, e.g. RetryWait
|
|
wire: object # the value on the wire: "retry_wait" or -32010
|
|
doc: str = ""
|
|
|
|
|
|
@dataclass
|
|
class TypeDef:
|
|
name: str
|
|
kind: str # struct | string_enum | int_enum | map_alias
|
|
doc: str = ""
|
|
fields: list[Field] = field(default_factory=list)
|
|
values: list[EnumValue] = field(default_factory=list)
|
|
alias: TypeRef | None = None
|
|
source: str = "" # relative path, for the "do not edit" banner
|
|
|
|
|
|
@dataclass
|
|
class Method:
|
|
name: str # download.addBatch
|
|
doc: str
|
|
params: TypeRef
|
|
result: TypeRef
|
|
privileged: bool
|
|
transports: list[str]
|
|
deadline_ms: int
|
|
errors: list[int] = field(default_factory=list)
|
|
ws_restrictions: list[str] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class Event:
|
|
name: str # event.task.progress
|
|
doc: str
|
|
params: TypeRef
|
|
max_rate_hz: float | None = None
|
|
|
|
|
|
@dataclass
|
|
class Contract:
|
|
version: str
|
|
types: list[TypeDef]
|
|
methods: list[Method]
|
|
events: list[Event]
|
|
error_codes: list[EnumValue]
|
|
|
|
|
|
# ------------------------------------------------------------------ name helpers
|
|
|
|
|
|
def pascal(text: str) -> str:
|
|
"""download.addBatch -> DownloadAddBatch ; retry_wait -> RetryWait."""
|
|
parts = re.split(r"[.\-_ ]+", text)
|
|
out = []
|
|
for part in parts:
|
|
if not part:
|
|
continue
|
|
out.append(part[0].upper() + part[1:])
|
|
return "".join(out)
|
|
|
|
|
|
def enum_ident(value: str) -> str:
|
|
ident = pascal(value)
|
|
if not ident:
|
|
raise SchemaError(f"cannot derive an identifier from enum value {value!r}")
|
|
if ident[0].isdigit():
|
|
ident = "V" + ident
|
|
return ident
|
|
|
|
|
|
# ---------------------------------------------------------------------- loading
|
|
|
|
|
|
class Loader:
|
|
def __init__(self, root: Path = SCHEMA_ROOT):
|
|
self.root = root
|
|
self.by_id: dict[str, dict] = {}
|
|
self.source_of: dict[str, str] = {}
|
|
for path in sorted(root.rglob("*.schema.json")):
|
|
with path.open() as fh:
|
|
doc = json.load(fh)
|
|
sid = doc.get("$id")
|
|
if not sid:
|
|
raise SchemaError(f"{path} has no $id")
|
|
if sid in self.by_id:
|
|
raise SchemaError(f"duplicate $id {sid} in {path}")
|
|
self.by_id[sid] = doc
|
|
self.source_of[sid] = str(path.relative_to(root.parent))
|
|
|
|
self.types: dict[str, TypeDef] = {}
|
|
self._order: list[str] = []
|
|
|
|
# -- ref handling -------------------------------------------------------
|
|
|
|
def _resolve_ref(self, ref: str) -> dict:
|
|
if ref.startswith("#"):
|
|
raise SchemaError(f"local $ref {ref} is not supported outside envelope.schema.json")
|
|
if ref not in self.by_id:
|
|
raise SchemaError(f"unknown $ref {ref}")
|
|
return self.by_id[ref]
|
|
|
|
@staticmethod
|
|
def _split_nullable(node: dict) -> tuple[dict, bool]:
|
|
"""Normalise the two ways the contract spells 'or null'."""
|
|
if "oneOf" in node:
|
|
branches = node["oneOf"]
|
|
non_null = [b for b in branches if b.get("type") != "null"]
|
|
nulls = [b for b in branches if b.get("type") == "null"]
|
|
if len(branches) != 2 or len(non_null) != 1 or len(nulls) != 1:
|
|
raise SchemaError(
|
|
"oneOf is only supported as [<schema>, {type: null}]; got "
|
|
+ json.dumps(branches)[:200]
|
|
)
|
|
merged = dict(non_null[0])
|
|
for key in ("description",):
|
|
if key in node and key not in merged:
|
|
merged[key] = node[key]
|
|
return merged, True
|
|
|
|
t = node.get("type")
|
|
if isinstance(t, list):
|
|
non_null = [x for x in t if x != "null"]
|
|
if len(non_null) != 1:
|
|
raise SchemaError(f"union type {t} is only supported as [X, 'null']")
|
|
node = dict(node)
|
|
node["type"] = non_null[0]
|
|
# An enum listing null alongside its values means the same thing.
|
|
if "enum" in node:
|
|
node["enum"] = [v for v in node["enum"] if v is not None]
|
|
return node, True
|
|
|
|
if "enum" in node and None in node["enum"]:
|
|
node = dict(node)
|
|
node["enum"] = [v for v in node["enum"] if v is not None]
|
|
return node, True
|
|
|
|
return node, False
|
|
|
|
# -- lowering -----------------------------------------------------------
|
|
|
|
def type_ref(self, node: dict, hint: str) -> tuple[TypeRef, bool]:
|
|
"""Lower a schema node to a TypeRef. `hint` names any struct we must synthesise."""
|
|
node, nullable = self._split_nullable(node)
|
|
limits = tuple((k, node[k]) for k in CONSTRAINT_KEYS if k in node)
|
|
|
|
if "$ref" in node:
|
|
target = self._resolve_ref(node["$ref"])
|
|
return TypeRef("named", name=self.named_type(node["$ref"], target)), nullable
|
|
|
|
if not node or node.keys() <= {"description"}:
|
|
return TypeRef("json"), True
|
|
|
|
t = node.get("type")
|
|
if t == "string":
|
|
if "enum" in node:
|
|
return TypeRef("named", name=self._synth_string_enum(hint, node)), nullable
|
|
return TypeRef("string", constraints=limits), nullable
|
|
if t == "integer":
|
|
return TypeRef("integer", constraints=limits), nullable
|
|
if t == "number":
|
|
return TypeRef("number", constraints=limits), nullable
|
|
if t == "boolean":
|
|
return TypeRef("boolean", constraints=limits), nullable
|
|
if t == "array":
|
|
items = node.get("items")
|
|
if items is None:
|
|
raise SchemaError(f"array without items at {hint}")
|
|
inner, _ = self.type_ref(items, hint + "Item")
|
|
return TypeRef("array", inner=inner, constraints=limits), nullable
|
|
if t == "object":
|
|
if "properties" in node:
|
|
return TypeRef("named", name=self._synth_struct(hint, node)), nullable
|
|
ap = node.get("additionalProperties")
|
|
if isinstance(ap, dict):
|
|
inner, _ = self.type_ref(ap, hint + "Value")
|
|
return TypeRef("map", inner=inner), nullable
|
|
return TypeRef("json"), nullable
|
|
if "const" in node:
|
|
return TypeRef("string", constraints=limits), nullable
|
|
|
|
raise SchemaError(f"unsupported schema node at {hint}: {json.dumps(node)[:200]}")
|
|
|
|
def _register(self, td: TypeDef) -> str:
|
|
existing = self.types.get(td.name)
|
|
if existing is not None:
|
|
if existing.kind != td.kind:
|
|
raise SchemaError(f"type name collision on {td.name}")
|
|
return td.name
|
|
self.types[td.name] = td
|
|
self._order.append(td.name)
|
|
return td.name
|
|
|
|
def _synth_string_enum(self, name: str, node: dict) -> str:
|
|
values = [
|
|
EnumValue(name=enum_ident(v), wire=v)
|
|
for v in node["enum"]
|
|
if v is not None
|
|
]
|
|
return self._register(TypeDef(name=name, kind="string_enum",
|
|
doc=node.get("description", ""), values=values))
|
|
|
|
def _synth_struct(self, name: str, node: dict) -> str:
|
|
if node.get("additionalProperties", False) is not False:
|
|
raise SchemaError(
|
|
f"{name}: object schemas must set additionalProperties:false — the daemon is "
|
|
"not allowed to trust unknown fields on the wire"
|
|
)
|
|
required = set(node.get("required", []))
|
|
fields: list[Field] = []
|
|
for prop, sub in node.get("properties", {}).items():
|
|
ref, nullable = self.type_ref(sub, name + pascal(prop))
|
|
fields.append(Field(name=prop, type=ref, required=prop in required,
|
|
nullable=nullable, doc=sub.get("description", "")))
|
|
return self._register(TypeDef(name=name, kind="struct",
|
|
doc=node.get("description", ""), fields=fields))
|
|
|
|
def named_type(self, sid: str, doc: dict) -> str:
|
|
"""Lower a top-level types/*.schema.json into a TypeDef and return its name."""
|
|
name = doc.get("title")
|
|
if not name:
|
|
raise SchemaError(f"{sid} has no title")
|
|
if name in self.types:
|
|
return name
|
|
|
|
source = self.source_of.get(sid, "")
|
|
node, _ = self._split_nullable(doc)
|
|
t = node.get("type")
|
|
|
|
if t == "string" and "enum" in node:
|
|
# Placeholder first: enums cannot recurse, but registering early keeps the
|
|
# ordering stable and mirrors the struct path below.
|
|
values = [EnumValue(name=enum_ident(v), wire=v) for v in node["enum"] if v is not None]
|
|
td = TypeDef(name, "string_enum", node.get("description", ""), values=values, source=source)
|
|
return self._register(td)
|
|
|
|
if t == "integer" and "x-enum" in node:
|
|
values = [EnumValue(name=e["name"], wire=e["value"], doc=e.get("doc", ""))
|
|
for e in node["x-enum"]]
|
|
td = TypeDef(name, "int_enum", node.get("description", ""), values=values, source=source)
|
|
return self._register(td)
|
|
|
|
if t == "object" and "properties" not in node:
|
|
ap = node.get("additionalProperties")
|
|
if not isinstance(ap, dict):
|
|
raise SchemaError(f"{sid}: object type with neither properties nor a typed additionalProperties")
|
|
inner, _ = self.type_ref(ap, name + "Value")
|
|
td = TypeDef(name, "map_alias", node.get("description", ""),
|
|
alias=TypeRef("map", inner=inner), source=source)
|
|
return self._register(td)
|
|
|
|
if t == "object":
|
|
# Reserve the name before descending so a nested synth cannot steal it.
|
|
self.types[name] = TypeDef(name, "struct", node.get("description", ""), source=source)
|
|
self._order.append(name)
|
|
required = set(node.get("required", []))
|
|
if node.get("additionalProperties", False) is not False:
|
|
raise SchemaError(f"{sid}: object schemas must set additionalProperties:false")
|
|
fields = []
|
|
for prop, sub in node.get("properties", {}).items():
|
|
ref, nullable = self.type_ref(sub, name + pascal(prop))
|
|
fields.append(Field(prop, ref, prop in required, nullable, sub.get("description", "")))
|
|
self.types[name].fields = fields
|
|
return name
|
|
|
|
raise SchemaError(f"{sid}: unsupported top-level type {t!r}")
|
|
|
|
|
|
# ---------------------------------------------------------------------- driver
|
|
|
|
|
|
def load() -> Contract:
|
|
loader = Loader()
|
|
root = loader.root.parent
|
|
|
|
version = (root / "VERSION").read_text().strip()
|
|
|
|
# Named types first, so their names win over any synthesised ones.
|
|
for sid in sorted(loader.by_id):
|
|
if "/types/" in sid:
|
|
loader.named_type(sid, loader.by_id[sid])
|
|
|
|
methods: list[Method] = []
|
|
for sid in sorted(loader.by_id):
|
|
if "/methods/" not in sid:
|
|
continue
|
|
doc = loader.by_id[sid]
|
|
name = doc["title"]
|
|
base = pascal(name)
|
|
props = doc.get("properties", {})
|
|
for half in ("params", "result"):
|
|
if half not in props:
|
|
raise SchemaError(f"{sid}: method schema must define both params and result")
|
|
params, _ = loader.type_ref(props["params"], base + "Params")
|
|
result, _ = loader.type_ref(props["result"], base + "Result")
|
|
transports = doc.get("x-transports")
|
|
if not transports:
|
|
raise SchemaError(f"{sid}: x-transports is required")
|
|
if "x-privileged" not in doc:
|
|
raise SchemaError(f"{sid}: x-privileged is required")
|
|
methods.append(Method(
|
|
name=name, doc=doc.get("description", ""), params=params, result=result,
|
|
privileged=bool(doc["x-privileged"]), transports=list(transports),
|
|
deadline_ms=int(doc.get("x-deadlineMs", 5000)),
|
|
errors=list(doc.get("x-errors", [])),
|
|
ws_restrictions=list(doc.get("x-wsRestrictions", [])),
|
|
))
|
|
|
|
events: list[Event] = []
|
|
for sid in sorted(loader.by_id):
|
|
if "/events/" not in sid:
|
|
continue
|
|
doc = loader.by_id[sid]
|
|
name = doc["title"]
|
|
ident = pascal(name[len("event."):] if name.startswith("event.") else name) + "Event"
|
|
params, _ = loader.type_ref(doc["properties"]["params"], ident)
|
|
events.append(Event(name=name, doc=doc.get("description", ""), params=params,
|
|
max_rate_hz=doc.get("x-maxRateHz")))
|
|
|
|
error_codes = loader.types["ErrorCode"].values
|
|
ordered = [loader.types[n] for n in loader._order]
|
|
return Contract(version=version, types=ordered, methods=methods, events=events,
|
|
error_codes=error_codes)
|
|
|
|
|
|
def dependencies(td: TypeDef) -> set[str]:
|
|
"""Named types `td` mentions directly."""
|
|
out: set[str] = set()
|
|
|
|
def walk(ref: TypeRef | None) -> None:
|
|
if ref is None:
|
|
return
|
|
if ref.kind == "named" and ref.name:
|
|
out.add(ref.name)
|
|
walk(ref.inner)
|
|
|
|
for f in td.fields:
|
|
walk(f.type)
|
|
walk(td.alias)
|
|
return out
|
|
|
|
|
|
def topo_sorted(types: list[TypeDef]) -> list[TypeDef]:
|
|
"""Definition order for languages that need a type declared before it is used.
|
|
|
|
The contract forbids recursive types, so a cycle here means a schema bug and is
|
|
raised rather than broken arbitrarily.
|
|
"""
|
|
by_name = {t.name: t for t in types}
|
|
state: dict[str, int] = {}
|
|
order: list[TypeDef] = []
|
|
|
|
def visit(name: str, trail: list[str]) -> None:
|
|
mark = state.get(name, 0)
|
|
if mark == 2:
|
|
return
|
|
if mark == 1:
|
|
raise SchemaError("recursive type: " + " -> ".join(trail + [name]))
|
|
state[name] = 1
|
|
for dep in sorted(dependencies(by_name[name])):
|
|
if dep in by_name:
|
|
visit(dep, trail + [name])
|
|
state[name] = 2
|
|
order.append(by_name[name])
|
|
|
|
for t in types:
|
|
visit(t.name, [])
|
|
return order
|
|
|
|
|
|
if __name__ == "__main__":
|
|
c = load()
|
|
print(f"contract {c.version}: {len(c.types)} types, {len(c.methods)} methods, {len(c.events)} events")
|
|
for t in c.types:
|
|
detail = f"{len(t.fields)} fields" if t.kind == "struct" else f"{len(t.values)} values"
|
|
print(f" {t.kind:12} {t.name:34} {detail}")
|