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
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
# contracts/codegen — the generators
|
||||
|
||||
Four generators, one IR. Everything is derived from `contracts/schema/`; nothing here is
|
||||
a second source of truth.
|
||||
|
||||
```
|
||||
schema_ir.py loads schema/ and lowers it to a small IR
|
||||
├── gen_cpp.py -> core/generated/velox_proto.{hpp,cpp}
|
||||
├── gen_ts.py -> extension/src/shared/protocol/*.ts
|
||||
├── gen_openrpc.py -> contracts/openrpc.json
|
||||
└── gen_cpp_conformance.py -> tests/conformance/cpp/fixture_dispatcher.hpp
|
||||
```
|
||||
|
||||
Regenerate everything:
|
||||
|
||||
```sh
|
||||
for g in gen_cpp gen_ts gen_openrpc gen_cpp_conformance; do
|
||||
python3 contracts/codegen/$g.py
|
||||
done
|
||||
```
|
||||
|
||||
`tests/conformance/check_contract.py` re-runs all four and fails if any committed output
|
||||
differs, so stale generated code cannot be merged.
|
||||
|
||||
## The supported JSON Schema subset
|
||||
|
||||
The generators refuse to guess. Anything outside this subset raises `SchemaError` at
|
||||
generation time rather than emitting subtly wrong code — a schema that cannot be generated
|
||||
from is a contract bug, and it should stop the build.
|
||||
|
||||
| Supported | Emitted as |
|
||||
|---|---|
|
||||
| `object` + `properties` | struct / interface |
|
||||
| `object` + typed `additionalProperties` | `std::map<std::string, T>` / `Record<string, T>` |
|
||||
| `string` + `enum` | `enum class` / string-literal union |
|
||||
| `integer` + `enum` + `x-enum` | `enum class : int32_t` / `as const` object |
|
||||
| `array` + `items` | `std::vector<T>` / `T[]` |
|
||||
| `$ref` to a `types/*.schema.json` | the named type |
|
||||
| `["X", "null"]`, or `oneOf: [X, {type: null}]` | `std::optional<T>` / `T \| null` |
|
||||
| `{}` | `nlohmann::json` / `unknown` |
|
||||
| `minimum` `maximum` `minLength` `maxLength` `pattern` `minItems` `maxItems` | runtime checks in both languages |
|
||||
|
||||
Deliberately unsupported: `allOf`, `anyOf`, general `oneOf`, `patternProperties`, tuple
|
||||
`items`, recursive types. If the contract needs one, extend `schema_ir.py` in the same PR
|
||||
that needs it.
|
||||
|
||||
## Two rules the generated code follows
|
||||
|
||||
**Nothing throws on the inbound path.** `gen_cpp.py` emits `parse<T>() ->
|
||||
std::expected<T, ParseError>` and deliberately does *not* emit nlohmann's ADL `from_json`,
|
||||
whose failure mode is an exception. A malformed frame off the wire is an ordinary value the
|
||||
RPC loop handles, not a throw unwinding through the daemon.
|
||||
|
||||
**Constraints are checked, not just documented.** A `maximum` in a schema becomes an `if`
|
||||
in both languages. The daemon is not allowed to trust the extension and the extension is
|
||||
not allowed to trust the daemon — `ws://127.0.0.1` is reachable by every local process, so
|
||||
a type declaration proves nothing at runtime.
|
||||
|
||||
## Absent and null mean the same thing
|
||||
|
||||
Both generators treat a missing field and an explicit `null` identically. A client that
|
||||
omits a nullable field and one that sends `null` get the same result, in both languages.
|
||||
This is stated here because it is the kind of asymmetry that otherwise surfaces as a
|
||||
cross-language bug six months later.
|
||||
@@ -0,0 +1,604 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Emit core/generated/velox_proto.{hpp,cpp} from contracts/schema/.
|
||||
|
||||
Design notes that matter to the CORE and DAEMON lanes:
|
||||
|
||||
* **No exceptions.** nlohmann's own throwing `get<T>()` / ADL `from_json` are deliberately
|
||||
not used and not emitted. Parsing goes through `velox::proto::parse<T>(json)` which
|
||||
returns `std::expected<T, ParseError>`, so a malformed frame from the wire is an ordinary
|
||||
value the RPC loop handles, not a throw unwinding through the transfer path.
|
||||
* **Serialisation is ADL `to_json`,** so `nlohmann::json j = task;` works as expected.
|
||||
Only the outbound direction is allowed to be implicit; the wire is never trusted.
|
||||
* **`libveloxproto`, not `libveloxcore`.** This code includes nlohmann/json, which
|
||||
CLAUDE.md forbids inside core. It is a separate target that core and daemon both link;
|
||||
the layering rule constrains `libveloxcore`, and `core/generated/` is not part of it.
|
||||
See docs/adr/0009-generated-protocol-library.md.
|
||||
|
||||
Run: python3 contracts/codegen/gen_cpp.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 Contract, Field, TypeDef, TypeRef, load, pascal, topo_sorted # noqa: E402
|
||||
|
||||
OUT_DIR = Path(__file__).resolve().parent.parent.parent / "core" / "generated"
|
||||
|
||||
BANNER = """// ---------------------------------------------------------------------------
|
||||
// GENERATED FILE — DO NOT EDIT.
|
||||
//
|
||||
// Source: contracts/schema/**
|
||||
// Generator: contracts/codegen/gen_cpp.py
|
||||
// Contract: v{version}
|
||||
//
|
||||
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
||||
// python3 contracts/codegen/gen_cpp.py
|
||||
// Only lane PROTO commits to contracts/.
|
||||
// ---------------------------------------------------------------------------
|
||||
"""
|
||||
|
||||
|
||||
def cpp_type(ref: TypeRef) -> str:
|
||||
if ref.kind == "named":
|
||||
return ref.name or "void"
|
||||
if ref.kind == "string":
|
||||
return "std::string"
|
||||
if ref.kind == "integer":
|
||||
return "std::int64_t"
|
||||
if ref.kind == "number":
|
||||
return "double"
|
||||
if ref.kind == "boolean":
|
||||
return "bool"
|
||||
if ref.kind == "json":
|
||||
return "nlohmann::json"
|
||||
if ref.kind == "array":
|
||||
return f"std::vector<{cpp_type(ref.inner)}>"
|
||||
if ref.kind == "map":
|
||||
return f"std::map<std::string, {cpp_type(ref.inner)}>"
|
||||
raise AssertionError(ref.kind)
|
||||
|
||||
|
||||
def cpp_constraints(ref: TypeRef, var: str, path: str, indent: str) -> list[str]:
|
||||
"""Range, length and pattern checks. The wire is untrusted: a `maximum` in the schema
|
||||
has to be a check here, or -32602 would never fire for an out-of-range value."""
|
||||
lim = ref.limits
|
||||
if not lim:
|
||||
return []
|
||||
i = indent
|
||||
o: list[str] = []
|
||||
|
||||
def bail(msg: str) -> str:
|
||||
return f'return std::unexpected(ParseError{{std::string({path}), "{msg}"}});'
|
||||
|
||||
if ref.kind in ("integer", "number"):
|
||||
if "minimum" in lim:
|
||||
o.append(f'{i}if ({var} < {lim["minimum"]}) {bail("value is below the minimum of " + str(lim["minimum"]))}')
|
||||
if "maximum" in lim:
|
||||
o.append(f'{i}if ({var} > {lim["maximum"]}) {bail("value is above the maximum of " + str(lim["maximum"]))}')
|
||||
elif ref.kind == "string":
|
||||
if "minLength" in lim:
|
||||
o.append(f'{i}if ({var}.size() < {lim["minLength"]}u) {bail("value is shorter than " + str(lim["minLength"]) + " characters")}')
|
||||
if "maxLength" in lim:
|
||||
o.append(f'{i}if ({var}.size() > {lim["maxLength"]}u) {bail("value is longer than " + str(lim["maxLength"]) + " characters")}')
|
||||
if "pattern" in lim:
|
||||
lit = json.dumps(lim["pattern"])
|
||||
o.append(f"{i}{{")
|
||||
o.append(f"{i} static const std::regex re({lit}, std::regex::ECMAScript);")
|
||||
o.append(f'{i} if (!std::regex_match({var}, re)) {bail("value does not match the required pattern")}')
|
||||
o.append(f"{i}}}")
|
||||
elif ref.kind == "array":
|
||||
if "minItems" in lim:
|
||||
o.append(f'{i}if ({var}.size() < {lim["minItems"]}u) {bail("fewer than " + str(lim["minItems"]) + " items")}')
|
||||
if "maxItems" in lim:
|
||||
o.append(f'{i}if ({var}.size() > {lim["maxItems"]}u) {bail("more than " + str(lim["maxItems"]) + " items")}')
|
||||
return o
|
||||
|
||||
|
||||
def field_type(f: Field) -> str:
|
||||
inner = cpp_type(f.type)
|
||||
return f"std::optional<{inner}>" if f.optional else inner
|
||||
|
||||
|
||||
def ident(name: str) -> str:
|
||||
"""A JSON property name as a C++ member name."""
|
||||
out = name.replace(".", "_").replace("-", "_")
|
||||
reserved = {"class", "delete", "namespace", "operator", "template", "new", "auto"}
|
||||
return out + "_" if out in reserved else out
|
||||
|
||||
|
||||
def doc_comment(text: str, indent: str = "") -> list[str]:
|
||||
if not text:
|
||||
return []
|
||||
lines: list[str] = []
|
||||
words = text.split()
|
||||
cur = ""
|
||||
for w in words:
|
||||
if len(cur) + len(w) + 1 > 92:
|
||||
lines.append(cur)
|
||||
cur = w
|
||||
else:
|
||||
cur = f"{cur} {w}".strip()
|
||||
if cur:
|
||||
lines.append(cur)
|
||||
return [f"{indent}/// {ln}" for ln in lines]
|
||||
|
||||
|
||||
def method_ident(name: str) -> str:
|
||||
return pascal(name)
|
||||
|
||||
|
||||
def handler_name(name: str) -> str:
|
||||
return "on_" + name.replace(".", "_")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ header
|
||||
|
||||
|
||||
def emit_header(c: Contract) -> str:
|
||||
o: list[str] = [BANNER.format(version=c.version), "#pragma once", ""]
|
||||
o += [
|
||||
"#include <cstdint>",
|
||||
"#include <expected>",
|
||||
"#include <map>",
|
||||
"#include <optional>",
|
||||
"#include <string>",
|
||||
"#include <string_view>",
|
||||
"#include <vector>",
|
||||
"",
|
||||
"#include <nlohmann/json.hpp>",
|
||||
"",
|
||||
"// This is libveloxproto, NOT libveloxcore. The layering rule in CLAUDE.md forbids",
|
||||
"// JSON inside the engine; the engine does not link this target. See",
|
||||
"// docs/adr/0009-generated-protocol-library.md.",
|
||||
"namespace velox::proto {",
|
||||
"",
|
||||
f'inline constexpr std::string_view kProtocolVersion = "{c.version}";',
|
||||
"",
|
||||
"/// Why a payload could not be turned into a typed value. `path` is a JSON Pointer",
|
||||
"/// into the offending document, so a conformance failure names the exact field.",
|
||||
"struct ParseError {",
|
||||
" std::string path;",
|
||||
" std::string message;",
|
||||
"};",
|
||||
"",
|
||||
"/// Every parse in this file returns one of these. Nothing here throws.",
|
||||
"template <class T>",
|
||||
"using Result = std::expected<T, ParseError>;",
|
||||
"",
|
||||
"/// Which listener a request arrived on. Decides whether a privileged method is",
|
||||
"/// allowed: see `is_allowed_on`.",
|
||||
"enum class Transport { Uds, Ws };",
|
||||
"",
|
||||
]
|
||||
|
||||
# ---- enums
|
||||
for t in topo_sorted(c.types):
|
||||
if t.kind == "string_enum":
|
||||
o += doc_comment(t.doc)
|
||||
o.append(f"enum class {t.name} {{")
|
||||
for v in t.values:
|
||||
o.append(f" {v.name}, // \"{v.wire}\"")
|
||||
o.append("};")
|
||||
o.append(f"std::string_view to_string({t.name} v) noexcept;")
|
||||
o.append(f"Result<{t.name}> parse_{t.name}(std::string_view s);")
|
||||
o.append("")
|
||||
elif t.kind == "int_enum":
|
||||
o += doc_comment(t.doc)
|
||||
o.append(f"enum class {t.name} : std::int32_t {{")
|
||||
for v in t.values:
|
||||
o += doc_comment(v.doc, " ")
|
||||
o.append(f" {v.name} = {v.wire},")
|
||||
o.append("};")
|
||||
o.append(f"std::string_view to_string({t.name} v) noexcept;")
|
||||
o.append(f"std::optional<{t.name}> {t.name.lower()}_from_int(std::int32_t v) noexcept;")
|
||||
o.append("")
|
||||
elif t.kind == "map_alias":
|
||||
o += doc_comment(t.doc)
|
||||
o.append(f"using {t.name} = {cpp_type(t.alias)};")
|
||||
o.append("")
|
||||
|
||||
# ---- structs
|
||||
for t in topo_sorted(c.types):
|
||||
if t.kind != "struct":
|
||||
continue
|
||||
o += doc_comment(t.doc)
|
||||
o.append(f"struct {t.name} {{")
|
||||
if not t.fields:
|
||||
o.append(" // No fields: this method takes no parameters.")
|
||||
for f in t.fields:
|
||||
o += doc_comment(f.doc, " ")
|
||||
o.append(f" {field_type(f)} {ident(f.name)}{{}};")
|
||||
o.append("};")
|
||||
o.append("")
|
||||
|
||||
o += [
|
||||
"// --- serialisation ---------------------------------------------------------",
|
||||
"// ADL hooks, so `nlohmann::json j = value;` works. Outbound only: there is no",
|
||||
"// generated from_json, because nlohmann's inbound path throws and the wire is",
|
||||
"// never trusted. Use parse<T> below.",
|
||||
"",
|
||||
]
|
||||
for t in topo_sorted(c.types):
|
||||
if t.kind in ("struct", "string_enum", "int_enum"):
|
||||
o.append(f"void to_json(nlohmann::json& j, const {t.name}& v);")
|
||||
o.append("")
|
||||
|
||||
o += [
|
||||
"// --- parsing ---------------------------------------------------------------",
|
||||
"",
|
||||
"/// Turn an untrusted JSON value into a typed one. Specialised below for every",
|
||||
"/// contract type; the primary template is intentionally not defined, so asking",
|
||||
"/// for a type the contract does not have is a compile error, not a runtime one.",
|
||||
"template <class T>",
|
||||
"Result<T> parse(const nlohmann::json& j, std::string_view path = \"\");",
|
||||
"",
|
||||
]
|
||||
for t in topo_sorted(c.types):
|
||||
if t.kind in ("struct", "string_enum", "int_enum", "map_alias"):
|
||||
o.append(f"template <> Result<{t.name}> parse<{t.name}>(const nlohmann::json& j, std::string_view path);")
|
||||
o.append("")
|
||||
|
||||
# ---- method / event enums
|
||||
o += [
|
||||
"// --- method surface --------------------------------------------------------",
|
||||
"",
|
||||
"/// Every method in the contract. Generated, so a daemon cannot answer a method",
|
||||
"/// the contract does not define, and cannot silently fail to answer one it does.",
|
||||
"enum class Method {",
|
||||
]
|
||||
for m in c.methods:
|
||||
o.append(f" {method_ident(m.name)}, // {m.name}")
|
||||
o += ["};", "",
|
||||
"inline constexpr std::size_t kMethodCount = " + str(len(c.methods)) + ";",
|
||||
"",
|
||||
"std::string_view to_string(Method m) noexcept;",
|
||||
"std::optional<Method> method_from_string(std::string_view s) noexcept;",
|
||||
"",
|
||||
"/// True for methods refused over the WebSocket transport with -32003. The",
|
||||
"/// extension is not allowed to reconfigure the daemon or destroy user data.",
|
||||
"bool is_privileged(Method m) noexcept;",
|
||||
"bool is_allowed_on(Method m, Transport t) noexcept;",
|
||||
"",
|
||||
"/// The contract's answer deadline. capture.offer's 750 ms is the one that",
|
||||
"/// matters: past it the extension has already let Firefox take the download.",
|
||||
"std::int32_t deadline_ms(Method m) noexcept;",
|
||||
""]
|
||||
|
||||
o += ["/// Server-to-client notifications.", "enum class Event {"]
|
||||
for e in c.events:
|
||||
o.append(f" {pascal(e.name[len('event.'):])}, // {e.name}")
|
||||
o += ["};", "",
|
||||
"std::string_view to_string(Event e) noexcept;",
|
||||
"std::optional<Event> event_from_string(std::string_view s) noexcept;",
|
||||
"",
|
||||
"// --- dispatch --------------------------------------------------------------",
|
||||
"",
|
||||
"/// Build a JSON-RPC error response. `id` may be null for a request that could",
|
||||
"/// not be parsed far enough to have one.",
|
||||
"nlohmann::json make_error(const nlohmann::json& id, ErrorCode code, std::string_view message,",
|
||||
" nlohmann::json data = nullptr);",
|
||||
"nlohmann::json make_result(const nlohmann::json& id, nlohmann::json result);",
|
||||
"nlohmann::json make_notification(Event e, nlohmann::json params);",
|
||||
"",
|
||||
"/// One virtual per method. The daemon implements this; `dispatch` below does the",
|
||||
"/// envelope handling, the transport check and the parameter parsing, so a handler",
|
||||
"/// only ever sees a validated, typed params struct.",
|
||||
"class Dispatcher {",
|
||||
"public:",
|
||||
" virtual ~Dispatcher() = default;",
|
||||
""]
|
||||
for m in c.methods:
|
||||
o += doc_comment(m.doc, " ")
|
||||
o.append(f" virtual Result<{cpp_type(m.result)}> {handler_name(m.name)}(const {cpp_type(m.params)}& params) = 0;")
|
||||
o.append("")
|
||||
o += ["};", "",
|
||||
"/// Parse one JSON-RPC request, route it, and return the response to write back.",
|
||||
"/// Never throws. Returns a null json for a notification that needs no reply.",
|
||||
"nlohmann::json dispatch(Dispatcher& handler, Transport transport, const nlohmann::json& request);",
|
||||
"",
|
||||
"} // namespace velox::proto",
|
||||
""]
|
||||
return "\n".join(o)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ source
|
||||
|
||||
|
||||
def emit_source(c: Contract) -> str:
|
||||
o: list[str] = [BANNER.format(version=c.version),
|
||||
'#include "velox_proto.hpp"', "", "#include <algorithm>", "#include <regex>", "",
|
||||
"namespace velox::proto {", "", "namespace {", "",
|
||||
'std::string join(std::string_view path, std::string_view key) {',
|
||||
' std::string out(path);',
|
||||
' out += "/";',
|
||||
' out += key;',
|
||||
' return out;',
|
||||
'}', "", "} // namespace", ""]
|
||||
|
||||
# enum conversions
|
||||
for t in topo_sorted(c.types):
|
||||
if t.kind == "string_enum":
|
||||
o.append(f"std::string_view to_string({t.name} v) noexcept {{")
|
||||
o.append(" switch (v) {")
|
||||
for v in t.values:
|
||||
o.append(f' case {t.name}::{v.name}: return "{v.wire}";')
|
||||
o.append(" }")
|
||||
o.append(' return "";')
|
||||
o.append("}")
|
||||
o.append("")
|
||||
o.append(f"Result<{t.name}> parse_{t.name}(std::string_view s) {{")
|
||||
for v in t.values:
|
||||
o.append(f' if (s == "{v.wire}") return {t.name}::{v.name};')
|
||||
o.append(f' return std::unexpected(ParseError{{"", "not a valid {t.name}: \'" + std::string(s) + "\'"}});')
|
||||
o.append("}")
|
||||
o.append("")
|
||||
o.append(f"void to_json(nlohmann::json& j, const {t.name}& v) {{ j = to_string(v); }}")
|
||||
o.append("")
|
||||
o.append(f"template <> Result<{t.name}> parse<{t.name}>(const nlohmann::json& j, std::string_view path) {{")
|
||||
o.append(' if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"});')
|
||||
o.append(f" auto r = parse_{t.name}(j.get_ref<const std::string&>());")
|
||||
o.append(" if (!r) return std::unexpected(ParseError{std::string(path), r.error().message});")
|
||||
o.append(" return *r;")
|
||||
o.append("}")
|
||||
o.append("")
|
||||
elif t.kind == "int_enum":
|
||||
o.append(f"std::string_view to_string({t.name} v) noexcept {{")
|
||||
o.append(" switch (v) {")
|
||||
for v in t.values:
|
||||
o.append(f' case {t.name}::{v.name}: return "{v.name}";')
|
||||
o.append(" }")
|
||||
o.append(' return "";')
|
||||
o.append("}")
|
||||
o.append("")
|
||||
o.append(f"std::optional<{t.name}> {t.name.lower()}_from_int(std::int32_t v) noexcept {{")
|
||||
o.append(" switch (v) {")
|
||||
for v in t.values:
|
||||
o.append(f" case {v.wire}: return {t.name}::{v.name};")
|
||||
o.append(" default: return std::nullopt;")
|
||||
o.append(" }")
|
||||
o.append("}")
|
||||
o.append("")
|
||||
o.append(f"void to_json(nlohmann::json& j, const {t.name}& v) {{ j = static_cast<std::int32_t>(v); }}")
|
||||
o.append("")
|
||||
o.append(f"template <> Result<{t.name}> parse<{t.name}>(const nlohmann::json& j, std::string_view path) {{")
|
||||
o.append(' if (!j.is_number_integer()) return std::unexpected(ParseError{std::string(path), "expected an integer"});')
|
||||
o.append(f" auto v = {t.name.lower()}_from_int(j.get<std::int32_t>());")
|
||||
o.append(' if (!v) return std::unexpected(ParseError{std::string(path), "not a contract error code"});')
|
||||
o.append(" return *v;")
|
||||
o.append("}")
|
||||
o.append("")
|
||||
|
||||
# map aliases: a parse specialisation only. The alias is a std::map, which nlohmann
|
||||
# already serialises, so an emitted to_json here would be an ambiguous overload.
|
||||
for t in topo_sorted(c.types):
|
||||
if t.kind != "map_alias":
|
||||
continue
|
||||
o.append(f"template <> Result<{t.name}> parse<{t.name}>(const nlohmann::json& j, std::string_view path) {{")
|
||||
o += emit_value_parse(t.alias, "j", "out", "path", " ")
|
||||
o.append(" return out;")
|
||||
o.append("}")
|
||||
o.append("")
|
||||
|
||||
# struct to_json / parse
|
||||
for t in topo_sorted(c.types):
|
||||
if t.kind != "struct":
|
||||
continue
|
||||
# A no-parameter method's struct has nothing to read, so the parameter goes
|
||||
# unnamed: the project builds with -Wall -Wextra -Werror.
|
||||
vname = "v" if t.fields else "/*v*/"
|
||||
o.append(f"void to_json(nlohmann::json& j, const {t.name}& {vname}) {{")
|
||||
o.append(" j = nlohmann::json::object();")
|
||||
for f in t.fields:
|
||||
m = ident(f.name)
|
||||
if f.optional:
|
||||
o.append(f' if (v.{m}.has_value()) j["{f.name}"] = *v.{m};')
|
||||
if f.required:
|
||||
o.append(f' else j["{f.name}"] = nullptr;')
|
||||
else:
|
||||
o.append(f' j["{f.name}"] = v.{m};')
|
||||
o.append("}")
|
||||
o.append("")
|
||||
|
||||
pname = "path" if t.fields else "/*path*/"
|
||||
o.append(f"template <> Result<{t.name}> parse<{t.name}>(const nlohmann::json& j, std::string_view {pname}) {{")
|
||||
if t.fields:
|
||||
o.append(' if (!j.is_object()) return std::unexpected(ParseError{std::string(path), "expected an object"});')
|
||||
else:
|
||||
o.append(' if (!j.is_object()) return std::unexpected(ParseError{"", "expected an object"});')
|
||||
o.append(f" {t.name} out;")
|
||||
for f in t.fields:
|
||||
o += emit_field_parse(f, indent=" ")
|
||||
o.append(" return out;")
|
||||
o.append("}")
|
||||
o.append("")
|
||||
|
||||
o += emit_tables(c)
|
||||
o += emit_dispatch(c)
|
||||
o += ["} // namespace velox::proto", ""]
|
||||
return "\n".join(o)
|
||||
|
||||
|
||||
def emit_value_parse(ref: TypeRef, src: str, dst: str, path: str, indent: str) -> list[str]:
|
||||
"""Statements that parse json expression `src` into a fresh variable named `dst`."""
|
||||
i = indent
|
||||
o: list[str] = []
|
||||
if ref.kind == "named":
|
||||
o.append(f"{i}auto {dst}_r = parse<{ref.name}>({src}, {path});")
|
||||
o.append(f"{i}if (!{dst}_r) return std::unexpected({dst}_r.error());")
|
||||
o.append(f"{i}auto {dst} = std::move(*{dst}_r);")
|
||||
elif ref.kind == "string":
|
||||
o.append(f"{i}if (!{src}.is_string()) return std::unexpected(ParseError{{std::string({path}), \"expected a string\"}});")
|
||||
o.append(f"{i}auto {dst} = {src}.get<std::string>();")
|
||||
elif ref.kind == "integer":
|
||||
o.append(f"{i}if (!{src}.is_number_integer()) return std::unexpected(ParseError{{std::string({path}), \"expected an integer\"}});")
|
||||
o.append(f"{i}auto {dst} = {src}.get<std::int64_t>();")
|
||||
elif ref.kind == "number":
|
||||
o.append(f"{i}if (!{src}.is_number()) return std::unexpected(ParseError{{std::string({path}), \"expected a number\"}});")
|
||||
o.append(f"{i}auto {dst} = {src}.get<double>();")
|
||||
elif ref.kind == "boolean":
|
||||
o.append(f"{i}if (!{src}.is_boolean()) return std::unexpected(ParseError{{std::string({path}), \"expected a boolean\"}});")
|
||||
o.append(f"{i}auto {dst} = {src}.get<bool>();")
|
||||
elif ref.kind == "json":
|
||||
o.append(f"{i}auto {dst} = {src};")
|
||||
elif ref.kind == "array":
|
||||
et = cpp_type(ref.inner)
|
||||
o.append(f"{i}if (!{src}.is_array()) return std::unexpected(ParseError{{std::string({path}), \"expected an array\"}});")
|
||||
o.append(f"{i}std::vector<{et}> {dst};")
|
||||
o.append(f"{i}{dst}.reserve({src}.size());")
|
||||
o.append(f"{i}for (std::size_t idx = 0; idx < {src}.size(); ++idx) {{")
|
||||
o.append(f'{i} const std::string ip = join({path}, std::to_string(idx));')
|
||||
o += emit_value_parse(ref.inner, f"{src}[idx]", f"{dst}_e", "ip", i + " ")
|
||||
o.append(f"{i} {dst}.push_back(std::move({dst}_e));")
|
||||
o.append(f"{i}}}")
|
||||
elif ref.kind == "map":
|
||||
et = cpp_type(ref.inner)
|
||||
o.append(f"{i}if (!{src}.is_object()) return std::unexpected(ParseError{{std::string({path}), \"expected an object\"}});")
|
||||
o.append(f"{i}std::map<std::string, {et}> {dst};")
|
||||
o.append(f"{i}for (const auto& [mk, mv] : {src}.items()) {{")
|
||||
o.append(f'{i} const std::string mp = join({path}, mk);')
|
||||
o += emit_value_parse(ref.inner, "mv", f"{dst}_e", "mp", i + " ")
|
||||
o.append(f"{i} {dst}.emplace(mk, std::move({dst}_e));")
|
||||
o.append(f"{i}}}")
|
||||
else:
|
||||
raise AssertionError(ref.kind)
|
||||
o += cpp_constraints(ref, dst, path, indent)
|
||||
return o
|
||||
|
||||
|
||||
def emit_field_parse(f: Field, indent: str) -> list[str]:
|
||||
i = indent
|
||||
m = ident(f.name)
|
||||
o = [f'{i}{{',
|
||||
f'{i} const std::string fp = join(path, "{f.name}");',
|
||||
f'{i} const auto it = j.find("{f.name}");']
|
||||
if f.optional:
|
||||
# Absent and null mean the same thing: the field is not set. A client that omits
|
||||
# a nullable field and one that sends null are treated identically on purpose.
|
||||
o.append(f"{i} if (it != j.end() && !it->is_null()) {{")
|
||||
o += emit_value_parse(f.type, "(*it)", "val", "fp", i + " ")
|
||||
o.append(f"{i} out.{m} = std::move(val);")
|
||||
o.append(f"{i} }}")
|
||||
else:
|
||||
o.append(f"{i} if (it == j.end() || it->is_null())")
|
||||
o.append(f'{i} return std::unexpected(ParseError{{fp, "required field is missing"}});')
|
||||
o += emit_value_parse(f.type, "(*it)", "val", "fp", i + " ")
|
||||
o.append(f"{i} out.{m} = std::move(val);")
|
||||
o.append(f"{i}}}")
|
||||
return o
|
||||
|
||||
|
||||
def emit_tables(c: Contract) -> list[str]:
|
||||
o = ["std::string_view to_string(Method m) noexcept {", " switch (m) {"]
|
||||
for m in c.methods:
|
||||
o.append(f' case Method::{method_ident(m.name)}: return "{m.name}";')
|
||||
o += [" }", ' return "";', "}", ""]
|
||||
|
||||
o += ["std::optional<Method> method_from_string(std::string_view s) noexcept {"]
|
||||
for m in c.methods:
|
||||
o.append(f' if (s == "{m.name}") return Method::{method_ident(m.name)};')
|
||||
o += [" return std::nullopt;", "}", ""]
|
||||
|
||||
o += ["bool is_privileged(Method m) noexcept {", " switch (m) {"]
|
||||
for m in c.methods:
|
||||
o.append(f" case Method::{method_ident(m.name)}: return {str(m.privileged).lower()};")
|
||||
o += [" }", " return true; // unknown means refuse", "}", ""]
|
||||
|
||||
o += ["bool is_allowed_on(Method m, Transport t) noexcept {", " switch (m) {"]
|
||||
for m in c.methods:
|
||||
uds = "true" if "uds" in m.transports else "false"
|
||||
ws = "true" if "ws" in m.transports else "false"
|
||||
o.append(f" case Method::{method_ident(m.name)}: return t == Transport::Uds ? {uds} : {ws};")
|
||||
o += [" }", " return false;", "}", ""]
|
||||
|
||||
o += ["std::int32_t deadline_ms(Method m) noexcept {", " switch (m) {"]
|
||||
for m in c.methods:
|
||||
o.append(f" case Method::{method_ident(m.name)}: return {m.deadline_ms};")
|
||||
o += [" }", " return 5000;", "}", ""]
|
||||
|
||||
o += ["std::string_view to_string(Event e) noexcept {", " switch (e) {"]
|
||||
for e in c.events:
|
||||
o.append(f' case Event::{pascal(e.name[len("event."):])}: return "{e.name}";')
|
||||
o += [" }", ' return "";', "}", ""]
|
||||
|
||||
o += ["std::optional<Event> event_from_string(std::string_view s) noexcept {"]
|
||||
for e in c.events:
|
||||
o.append(f' if (s == "{e.name}") return Event::{pascal(e.name[len("event."):])};')
|
||||
o += [" return std::nullopt;", "}", ""]
|
||||
return o
|
||||
|
||||
|
||||
def emit_dispatch(c: Contract) -> list[str]:
|
||||
o = [
|
||||
"nlohmann::json make_error(const nlohmann::json& id, ErrorCode code, std::string_view message,",
|
||||
" nlohmann::json data) {",
|
||||
" nlohmann::json err = {{\"code\", static_cast<std::int32_t>(code)}, {\"message\", std::string(message)}};",
|
||||
" if (!data.is_null()) err[\"data\"] = std::move(data);",
|
||||
" return {{\"jsonrpc\", \"2.0\"}, {\"id\", id}, {\"error\", std::move(err)}};",
|
||||
"}",
|
||||
"",
|
||||
"nlohmann::json make_result(const nlohmann::json& id, nlohmann::json result) {",
|
||||
" return {{\"jsonrpc\", \"2.0\"}, {\"id\", id}, {\"result\", std::move(result)}};",
|
||||
"}",
|
||||
"",
|
||||
"nlohmann::json make_notification(Event e, nlohmann::json params) {",
|
||||
" return {{\"jsonrpc\", \"2.0\"}, {\"method\", std::string(to_string(e))}, {\"params\", std::move(params)}};",
|
||||
"}",
|
||||
"",
|
||||
"nlohmann::json dispatch(Dispatcher& handler, Transport transport, const nlohmann::json& request) {",
|
||||
" const nlohmann::json id = request.contains(\"id\") ? request.at(\"id\") : nlohmann::json(nullptr);",
|
||||
"",
|
||||
" if (!request.is_object() || request.value(\"jsonrpc\", \"\") != \"2.0\" || !request.contains(\"method\"))",
|
||||
" return make_error(id, ErrorCode::InvalidRequest, \"not a JSON-RPC 2.0 request\");",
|
||||
" if (!request.at(\"method\").is_string())",
|
||||
" return make_error(id, ErrorCode::InvalidRequest, \"method must be a string\");",
|
||||
"",
|
||||
" const auto method = method_from_string(request.at(\"method\").get_ref<const std::string&>());",
|
||||
" if (!method)",
|
||||
" return make_error(id, ErrorCode::MethodNotFound, \"no such method\");",
|
||||
" if (!is_allowed_on(*method, transport))",
|
||||
" return make_error(id, ErrorCode::TransportForbidden,",
|
||||
" \"method is not permitted on this transport\");",
|
||||
"",
|
||||
" const nlohmann::json params =",
|
||||
" request.contains(\"params\") ? request.at(\"params\") : nlohmann::json::object();",
|
||||
"",
|
||||
" switch (*method) {",
|
||||
]
|
||||
for m in c.methods:
|
||||
pt, rt = cpp_type(m.params), cpp_type(m.result)
|
||||
o += [
|
||||
f" case Method::{method_ident(m.name)}: {{",
|
||||
f' auto p = parse<{pt}>(params, "params");',
|
||||
" if (!p)",
|
||||
" return make_error(id, ErrorCode::InvalidParams, p.error().message,",
|
||||
' nlohmann::json{{"path", p.error().path}});',
|
||||
f" auto r = handler.{handler_name(m.name)}(*p);",
|
||||
" if (!r)",
|
||||
" return make_error(id, ErrorCode::InternalError, r.error().message,",
|
||||
' nlohmann::json{{"path", r.error().path}});',
|
||||
" nlohmann::json out = *r;",
|
||||
" return make_result(id, std::move(out));",
|
||||
" }",
|
||||
]
|
||||
o += [" }", "",
|
||||
" return make_error(id, ErrorCode::MethodNotFound, \"no such method\");",
|
||||
"}", ""]
|
||||
return o
|
||||
|
||||
|
||||
def main() -> int:
|
||||
c = load()
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(OUT_DIR / "velox_proto.hpp").write_text(emit_header(c))
|
||||
(OUT_DIR / "velox_proto.cpp").write_text(emit_source(c))
|
||||
print(f"gen_cpp: {len(c.types)} types, {len(c.methods)} methods, {len(c.events)} events -> {OUT_DIR}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Emit tests/conformance/cpp/fixture_dispatcher.hpp.
|
||||
|
||||
velox::proto::Dispatcher has one pure virtual per method, on purpose: a daemon that
|
||||
forgets to implement a method does not compile. That is exactly what a conformance runner
|
||||
needs, and also what makes one tedious to hand-write — so it is generated.
|
||||
|
||||
The dispatcher answers each method from that method's golden fixture, which lets the C++
|
||||
side exercise the real dispatch path: envelope handling, the transport check, parameter
|
||||
parsing, and result serialisation.
|
||||
|
||||
Run: python3 contracts/codegen/gen_cpp_conformance.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from gen_cpp import BANNER, cpp_type, handler_name # noqa: E402
|
||||
from schema_ir import load # noqa: E402
|
||||
|
||||
OUT = Path(__file__).resolve().parent.parent.parent / "tests" / "conformance" / "cpp" / "fixture_dispatcher.hpp"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
c = load()
|
||||
o = [BANNER.format(version=c.version), "#pragma once", "",
|
||||
'#include "velox_proto.hpp"', "",
|
||||
"#include <functional>", "#include <string>", "", "namespace velox::conformance {", "",
|
||||
"/// Answers every method from its golden fixture, so the generated dispatch path",
|
||||
"/// itself is under test: envelope, transport check, param parse, result serialise.",
|
||||
"class FixtureDispatcher final : public proto::Dispatcher {",
|
||||
"public:",
|
||||
" /// `results` maps a method name to that method's golden result JSON.",
|
||||
" explicit FixtureDispatcher(std::function<const nlohmann::json*(const std::string&)> results)",
|
||||
" : results_(std::move(results)) {}",
|
||||
""]
|
||||
|
||||
for m in c.methods:
|
||||
# Every method's params and result is a named struct, and this class lives in
|
||||
# velox::conformance, so the names need qualifying.
|
||||
pt, rt = "proto::" + cpp_type(m.params), "proto::" + cpp_type(m.result)
|
||||
o += [
|
||||
f" proto::Result<{rt}> {handler_name(m.name)}(const {pt}& params) override {{",
|
||||
" (void)params;",
|
||||
f' return golden<{rt}>("{m.name}");',
|
||||
" }",
|
||||
"",
|
||||
]
|
||||
|
||||
o += [
|
||||
"private:",
|
||||
" template <class T>",
|
||||
" proto::Result<T> golden(const std::string& method) {",
|
||||
" const nlohmann::json* value = results_(method);",
|
||||
" if (value == nullptr)",
|
||||
' return std::unexpected(proto::ParseError{method, "no fixture for this method"});',
|
||||
" return proto::parse<T>(*value, method);",
|
||||
" }",
|
||||
"",
|
||||
" std::function<const nlohmann::json*(const std::string&)> results_;",
|
||||
"};",
|
||||
"",
|
||||
"} // namespace velox::conformance",
|
||||
"",
|
||||
]
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text("\n".join(o))
|
||||
print(f"gen_cpp_conformance: {len(c.methods)} handlers -> {OUT}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,619 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Emit extension/src/shared/protocol/ from contracts/schema/.
|
||||
|
||||
What the EXT and GUI-adjacent lanes get:
|
||||
|
||||
* `types.ts` — every contract type as a TS interface or string-literal union.
|
||||
* `methods.ts` — the `MethodMap`, a typed `call<M>()` signature, and per-method
|
||||
metadata (privileged, transports, deadlineMs). capture.offer's
|
||||
750 ms budget is a generated constant, not a number typed twice.
|
||||
* `events.ts` — event payload types and a discriminated union of notifications.
|
||||
* `validate.ts` — runtime validators for everything crossing the wire.
|
||||
* `index.ts` — the public surface.
|
||||
|
||||
The validators exist because **the extension is not allowed to trust the daemon and the
|
||||
daemon is not allowed to trust the extension.** A `ws://127.0.0.1` socket is reachable by
|
||||
any local process, so a TypeScript type alone proves nothing at runtime.
|
||||
|
||||
Run: python3 contracts/codegen/gen_ts.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 Contract, Field, TypeDef, TypeRef, load, pascal, topo_sorted # noqa: E402
|
||||
|
||||
OUT_DIR = Path(__file__).resolve().parent.parent.parent / "extension" / "src" / "shared" / "protocol"
|
||||
|
||||
BANNER = """// ---------------------------------------------------------------------------
|
||||
// GENERATED FILE — DO NOT EDIT.
|
||||
//
|
||||
// Source: contracts/schema/**
|
||||
// Generator: contracts/codegen/gen_ts.py
|
||||
// Contract: v{version}
|
||||
//
|
||||
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
||||
// python3 contracts/codegen/gen_ts.py
|
||||
// Only lane PROTO commits to contracts/.
|
||||
// ---------------------------------------------------------------------------
|
||||
"""
|
||||
|
||||
IDENT_OK = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$")
|
||||
|
||||
|
||||
def prop_key(name: str) -> str:
|
||||
"""Property names such as `general.launchOnLogin` must be quoted."""
|
||||
if name and name[0].isalpha() and all(ch in IDENT_OK for ch in name):
|
||||
return name
|
||||
return json.dumps(name)
|
||||
|
||||
|
||||
def ts_type(ref: TypeRef) -> str:
|
||||
if ref.kind == "named":
|
||||
return ref.name or "never"
|
||||
if ref.kind == "string":
|
||||
return "string"
|
||||
if ref.kind in ("integer", "number"):
|
||||
return "number"
|
||||
if ref.kind == "boolean":
|
||||
return "boolean"
|
||||
if ref.kind == "json":
|
||||
return "unknown"
|
||||
if ref.kind == "array":
|
||||
inner = ts_type(ref.inner)
|
||||
return f"Array<{inner}>" if not inner.isidentifier() else f"{inner}[]"
|
||||
if ref.kind == "map":
|
||||
return f"Record<string, {ts_type(ref.inner)}>"
|
||||
raise AssertionError(ref.kind)
|
||||
|
||||
|
||||
def doc_block(text: str, indent: str = "") -> list[str]:
|
||||
if not text:
|
||||
return []
|
||||
words, lines, cur = text.split(), [], ""
|
||||
for w in words:
|
||||
if len(cur) + len(w) + 1 > 88:
|
||||
lines.append(cur)
|
||||
cur = w
|
||||
else:
|
||||
cur = f"{cur} {w}".strip()
|
||||
if cur:
|
||||
lines.append(cur)
|
||||
if len(lines) == 1:
|
||||
return [f"{indent}/** {lines[0]} */"]
|
||||
return [f"{indent}/**"] + [f"{indent} * {ln}" for ln in lines] + [f"{indent} */"]
|
||||
|
||||
|
||||
def event_ident(name: str) -> str:
|
||||
return pascal(name[len("event."):]) + "Event"
|
||||
|
||||
|
||||
def method_key(name: str) -> str:
|
||||
return json.dumps(name)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- types.ts
|
||||
|
||||
|
||||
def emit_types(c: Contract) -> str:
|
||||
o = [BANNER.format(version=c.version), ""]
|
||||
o.append(f'export const PROTOCOL_VERSION = {json.dumps(c.version)};')
|
||||
o.append("")
|
||||
for t in topo_sorted(c.types):
|
||||
if t.kind == "string_enum":
|
||||
o += doc_block(t.doc)
|
||||
union = " | ".join(json.dumps(v.wire) for v in t.values)
|
||||
o.append(f"export type {t.name} = {union};")
|
||||
o.append(f"export const {upper_snake(t.name)}_VALUES = [")
|
||||
for v in t.values:
|
||||
o.append(f" {json.dumps(v.wire)},")
|
||||
o.append(f"] as const satisfies readonly {t.name}[];")
|
||||
o.append("")
|
||||
elif t.kind == "int_enum":
|
||||
o += doc_block(t.doc)
|
||||
o.append(f"export const {t.name} = {{")
|
||||
for v in t.values:
|
||||
if v.doc:
|
||||
o += doc_block(v.doc, " ")
|
||||
o.append(f" {v.name}: {v.wire},")
|
||||
o.append("} as const;")
|
||||
o.append(f"export type {t.name} = (typeof {t.name})[keyof typeof {t.name}];")
|
||||
o.append("")
|
||||
elif t.kind == "map_alias":
|
||||
o += doc_block(t.doc)
|
||||
o.append(f"export type {t.name} = {ts_type(t.alias)};")
|
||||
o.append("")
|
||||
elif t.kind == "struct":
|
||||
o += doc_block(t.doc)
|
||||
if not t.fields:
|
||||
o.append(f"/** No parameters. */")
|
||||
o.append(f"export type {t.name} = Record<string, never>;")
|
||||
o.append("")
|
||||
continue
|
||||
o.append(f"export interface {t.name} {{")
|
||||
for f in t.fields:
|
||||
o += doc_block(f.doc, " ")
|
||||
opt = "?" if not f.required else ""
|
||||
null = " | null" if f.nullable else ""
|
||||
o.append(f" {prop_key(f.name)}{opt}: {ts_type(f.type)}{null};")
|
||||
o.append("}")
|
||||
o.append("")
|
||||
|
||||
o += [
|
||||
"/** JSON-RPC error as it appears on the wire. */",
|
||||
"export interface RpcError {",
|
||||
" code: ErrorCode;",
|
||||
" message: string;",
|
||||
" data?: Record<string, unknown> | null;",
|
||||
"}",
|
||||
"",
|
||||
"/** A response is one or the other, never both — narrow on `error`. */",
|
||||
"export type RpcResponse<T> =",
|
||||
" | { jsonrpc: '2.0'; id: number | string; result: T; error?: undefined }",
|
||||
" | { jsonrpc: '2.0'; id: number | string; result?: undefined; error: RpcError };",
|
||||
"",
|
||||
]
|
||||
return "\n".join(o)
|
||||
|
||||
|
||||
def upper_snake(name: str) -> str:
|
||||
out = []
|
||||
for i, ch in enumerate(name):
|
||||
if ch.isupper() and i and not name[i - 1].isupper():
|
||||
out.append("_")
|
||||
out.append(ch.upper())
|
||||
return "".join(out)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- methods.ts
|
||||
|
||||
|
||||
def emit_methods(c: Contract) -> str:
|
||||
imports = sorted({r.name for m in c.methods for r in (m.params, m.result) if r.kind == "named"})
|
||||
o = [BANNER.format(version=c.version), ""]
|
||||
o.append("import type {")
|
||||
for name in imports:
|
||||
o.append(f" {name},")
|
||||
o.append("} from './types.js';")
|
||||
o.append("")
|
||||
o += [
|
||||
"/** Params and result for every method, keyed by its wire name. */",
|
||||
"export interface MethodMap {",
|
||||
]
|
||||
for m in c.methods:
|
||||
o += doc_block(m.doc, " ")
|
||||
o.append(f" {method_key(m.name)}: {{ params: {ts_type(m.params)}; result: {ts_type(m.result)} }};")
|
||||
o += ["}", "",
|
||||
"export type MethodName = keyof MethodMap;",
|
||||
"export type Params<M extends MethodName> = MethodMap[M]['params'];",
|
||||
"export type Result<M extends MethodName> = MethodMap[M]['result'];",
|
||||
"",
|
||||
"export type Transport = 'uds' | 'ws';",
|
||||
"",
|
||||
"export interface MethodMeta {",
|
||||
" /** Refused over the WebSocket transport with -32003. */",
|
||||
" readonly privileged: boolean;",
|
||||
" readonly transports: readonly Transport[];",
|
||||
" /** How long a client waits before giving up on this call. */",
|
||||
" readonly deadlineMs: number;",
|
||||
" /** Error codes this method is documented to return. */",
|
||||
" readonly errors: readonly number[];",
|
||||
"}",
|
||||
"",
|
||||
"export const METHODS: { readonly [M in MethodName]: MethodMeta } = {"]
|
||||
for m in c.methods:
|
||||
transports = ", ".join(f"'{t}'" for t in m.transports)
|
||||
errors = ", ".join(str(e) for e in m.errors)
|
||||
o.append(f" {method_key(m.name)}: {{ privileged: {str(m.privileged).lower()}, "
|
||||
f"transports: [{transports}], deadlineMs: {m.deadline_ms}, errors: [{errors}] }},")
|
||||
o += ["} as const;", "",
|
||||
"export const METHOD_NAMES = Object.keys(METHODS) as MethodName[];",
|
||||
"",
|
||||
"export function isMethodName(v: unknown): v is MethodName {",
|
||||
" return typeof v === 'string' && Object.prototype.hasOwnProperty.call(METHODS, v);",
|
||||
"}",
|
||||
"",
|
||||
"/** Methods this transport may call. The extension checks before sending so a",
|
||||
" * privileged call fails in one place rather than as a puzzling -32003. */",
|
||||
"export function isAllowedOn(method: MethodName, transport: Transport): boolean {",
|
||||
" return (METHODS[method].transports as readonly string[]).includes(transport);",
|
||||
"}",
|
||||
"",
|
||||
"/**",
|
||||
" * The typed client surface. Every transport implements this; the generated",
|
||||
" * signature is what stops a caller passing download.add's params to download.get.",
|
||||
" */",
|
||||
"export interface VeloxClient {",
|
||||
" call<M extends MethodName>(method: M, params: Params<M>): Promise<Result<M>>;",
|
||||
"}",
|
||||
""]
|
||||
return "\n".join(o)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ events.ts
|
||||
|
||||
|
||||
def emit_events(c: Contract) -> str:
|
||||
imports = sorted({e.params.name for e in c.events if e.params.kind == "named"})
|
||||
o = [BANNER.format(version=c.version), ""]
|
||||
o.append("import type {")
|
||||
for name in imports:
|
||||
o.append(f" {name},")
|
||||
o.append("} from './types.js';")
|
||||
o.append("")
|
||||
o.append("/** Payload for each server-to-client notification, keyed by its wire name. */")
|
||||
o.append("export interface EventMap {")
|
||||
for e in c.events:
|
||||
o += doc_block(e.doc, " ")
|
||||
o.append(f" {method_key(e.name)}: {ts_type(e.params)};")
|
||||
o += ["}", "",
|
||||
"export type EventName = keyof EventMap;",
|
||||
"export type EventPayload<E extends EventName> = EventMap[E];",
|
||||
"",
|
||||
"/**",
|
||||
" * Discriminated on `method`: narrowing an incoming notification gives the",
|
||||
" * correctly typed params with no cast at the call site.",
|
||||
" */",
|
||||
"export type ServerNotification = {",
|
||||
" [E in EventName]: { jsonrpc: '2.0'; method: E; params: EventMap[E] };",
|
||||
"}[EventName];",
|
||||
"",
|
||||
"export interface EventMeta {",
|
||||
" /** Upper bound on emission rate, where the contract sets one. */",
|
||||
" readonly maxRateHz: number | null;",
|
||||
"}",
|
||||
"",
|
||||
"export const EVENTS: { readonly [E in EventName]: EventMeta } = {"]
|
||||
for e in c.events:
|
||||
rate = "null" if e.max_rate_hz is None else str(e.max_rate_hz)
|
||||
o.append(f" {method_key(e.name)}: {{ maxRateHz: {rate} }},")
|
||||
o += ["} as const;", "",
|
||||
"export const EVENT_NAMES = Object.keys(EVENTS) as EventName[];",
|
||||
"",
|
||||
"export function isEventName(v: unknown): v is EventName {",
|
||||
" return typeof v === 'string' && Object.prototype.hasOwnProperty.call(EVENTS, v);",
|
||||
"}",
|
||||
""]
|
||||
return "\n".join(o)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- validate.ts
|
||||
|
||||
PRELUDE = """
|
||||
/**
|
||||
* Runtime validation for everything that crosses the wire.
|
||||
*
|
||||
* The daemon does not trust the extension and the extension does not trust the daemon:
|
||||
* `ws://127.0.0.1` is reachable by any local process, so a TypeScript type proves nothing
|
||||
* at runtime. Every inbound payload goes through one of these before it is used.
|
||||
*
|
||||
* Validators mirror the C++ side exactly, including the rule that an absent field and an
|
||||
* explicit null mean the same thing.
|
||||
*/
|
||||
|
||||
export type Validated<T> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; path: string; message: string };
|
||||
|
||||
export type Validator<T> = (v: unknown, path: string) => Validated<T>;
|
||||
|
||||
function fail(path: string, message: string): Validated<never> {
|
||||
return { ok: false, path, message };
|
||||
}
|
||||
|
||||
function join(path: string, key: string): string {
|
||||
return path ? `${path}/${key}` : `/${key}`;
|
||||
}
|
||||
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
export const vString: Validator<string> = (v, p) =>
|
||||
typeof v === 'string' ? { ok: true, value: v } : fail(p, 'expected a string');
|
||||
|
||||
export const vNumber: Validator<number> = (v, p) =>
|
||||
typeof v === 'number' && Number.isFinite(v) ? { ok: true, value: v } : fail(p, 'expected a number');
|
||||
|
||||
export const vInteger: Validator<number> = (v, p) =>
|
||||
typeof v === 'number' && Number.isInteger(v) ? { ok: true, value: v } : fail(p, 'expected an integer');
|
||||
|
||||
export const vBoolean: Validator<boolean> = (v, p) =>
|
||||
typeof v === 'boolean' ? { ok: true, value: v } : fail(p, 'expected a boolean');
|
||||
|
||||
export const vUnknown: Validator<unknown> = (v) => ({ ok: true, value: v });
|
||||
|
||||
function vArray<T>(inner: Validator<T>): Validator<T[]> {
|
||||
return (v, p) => {
|
||||
if (!Array.isArray(v)) return fail(p, 'expected an array');
|
||||
const out: T[] = [];
|
||||
for (let i = 0; i < v.length; i += 1) {
|
||||
const r = inner(v[i], join(p, String(i)));
|
||||
if (!r.ok) return r;
|
||||
out.push(r.value);
|
||||
}
|
||||
return { ok: true, value: out };
|
||||
};
|
||||
}
|
||||
|
||||
function vRecord<T>(inner: Validator<T>): Validator<Record<string, T>> {
|
||||
return (v, p) => {
|
||||
if (!isPlainObject(v)) return fail(p, 'expected an object');
|
||||
const out: Record<string, T> = {};
|
||||
for (const [k, raw] of Object.entries(v)) {
|
||||
const r = inner(raw, join(p, k));
|
||||
if (!r.ok) return r;
|
||||
out[k] = r.value;
|
||||
}
|
||||
return { ok: true, value: out };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Range, length and pattern checks. The wire is untrusted, so a `maximum` in the schema
|
||||
* has to be a check at runtime — a TypeScript type cannot enforce one.
|
||||
*/
|
||||
interface Limits {
|
||||
readonly minimum?: number;
|
||||
readonly maximum?: number;
|
||||
readonly minLength?: number;
|
||||
readonly maxLength?: number;
|
||||
readonly pattern?: RegExp;
|
||||
readonly minItems?: number;
|
||||
readonly maxItems?: number;
|
||||
}
|
||||
|
||||
function vLimited<T>(inner: Validator<T>, limits: Limits): Validator<T> {
|
||||
return (v, p) => {
|
||||
const r = inner(v, p);
|
||||
if (!r.ok) return r;
|
||||
const value = r.value;
|
||||
if (typeof value === 'number') {
|
||||
if (limits.minimum !== undefined && value < limits.minimum)
|
||||
return fail(p, `value is below the minimum of ${limits.minimum}`);
|
||||
if (limits.maximum !== undefined && value > limits.maximum)
|
||||
return fail(p, `value is above the maximum of ${limits.maximum}`);
|
||||
} else if (typeof value === 'string') {
|
||||
if (limits.minLength !== undefined && value.length < limits.minLength)
|
||||
return fail(p, `value is shorter than ${limits.minLength} characters`);
|
||||
if (limits.maxLength !== undefined && value.length > limits.maxLength)
|
||||
return fail(p, `value is longer than ${limits.maxLength} characters`);
|
||||
if (limits.pattern !== undefined && !limits.pattern.test(value))
|
||||
return fail(p, 'value does not match the required pattern');
|
||||
} else if (Array.isArray(value)) {
|
||||
if (limits.minItems !== undefined && value.length < limits.minItems)
|
||||
return fail(p, `fewer than ${limits.minItems} items`);
|
||||
if (limits.maxItems !== undefined && value.length > limits.maxItems)
|
||||
return fail(p, `more than ${limits.maxItems} items`);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
}
|
||||
|
||||
function vEnum<T extends string>(values: readonly T[], name: string): Validator<T> {
|
||||
return (v, p) =>
|
||||
typeof v === 'string' && (values as readonly string[]).includes(v)
|
||||
? { ok: true, value: v as T }
|
||||
: fail(p, `not a valid ${name}`);
|
||||
}
|
||||
|
||||
function vIntEnum<T extends number>(values: readonly T[], name: string): Validator<T> {
|
||||
return (v, p) =>
|
||||
typeof v === 'number' && (values as readonly number[]).includes(v)
|
||||
? { ok: true, value: v as T }
|
||||
: fail(p, `not a valid ${name}`);
|
||||
}
|
||||
|
||||
/** Required: must be present and non-null. */
|
||||
function req<T>(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
path: string,
|
||||
inner: Validator<T>,
|
||||
out: Record<string, unknown>,
|
||||
): Validated<null> {
|
||||
const raw = obj[key];
|
||||
if (raw === undefined || raw === null) return fail(join(path, key), 'required field is missing');
|
||||
const r = inner(raw, join(path, key));
|
||||
if (!r.ok) return r;
|
||||
out[key] = r.value;
|
||||
return { ok: true, value: null };
|
||||
}
|
||||
|
||||
/** Optional: absent and null are the same thing, exactly as on the C++ side. */
|
||||
function opt<T>(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
path: string,
|
||||
inner: Validator<T>,
|
||||
out: Record<string, unknown>,
|
||||
): Validated<null> {
|
||||
const raw = obj[key];
|
||||
if (raw === undefined || raw === null) return { ok: true, value: null };
|
||||
const r = inner(raw, join(path, key));
|
||||
if (!r.ok) return r;
|
||||
out[key] = r.value;
|
||||
return { ok: true, value: null };
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def ts_limits(ref: TypeRef) -> str:
|
||||
"""The Limits object literal for a TypeRef, or "" when it is unconstrained."""
|
||||
lim = ref.limits
|
||||
parts = []
|
||||
for key in ("minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems"):
|
||||
if key in lim:
|
||||
parts.append(f"{key}: {lim[key]}")
|
||||
if "pattern" in lim:
|
||||
parts.append("pattern: " + js_regex(str(lim["pattern"])))
|
||||
return "{ " + ", ".join(parts) + " }" if parts else ""
|
||||
|
||||
|
||||
def js_regex(pattern: str) -> str:
|
||||
return "/" + pattern.replace("/", "\\/") + "/"
|
||||
|
||||
|
||||
def validator_expr(ref: TypeRef) -> str:
|
||||
base = _validator_base(ref)
|
||||
limits = ts_limits(ref)
|
||||
return f"vLimited({base}, {limits})" if limits else base
|
||||
|
||||
|
||||
def _validator_base(ref: TypeRef) -> str:
|
||||
if ref.kind == "named":
|
||||
return f"validate{ref.name}"
|
||||
if ref.kind == "string":
|
||||
return "vString"
|
||||
if ref.kind == "integer":
|
||||
return "vInteger"
|
||||
if ref.kind == "number":
|
||||
return "vNumber"
|
||||
if ref.kind == "boolean":
|
||||
return "vBoolean"
|
||||
if ref.kind == "json":
|
||||
return "vUnknown"
|
||||
if ref.kind == "array":
|
||||
return f"vArray({validator_expr(ref.inner)})"
|
||||
if ref.kind == "map":
|
||||
return f"vRecord({validator_expr(ref.inner)})"
|
||||
raise AssertionError(ref.kind)
|
||||
|
||||
|
||||
def emit_validate(c: Contract) -> str:
|
||||
# An int enum is exported from types.ts as a const *and* a type under one name, so a
|
||||
# value import already brings the type with it. Importing it twice is a TS2300.
|
||||
value_imported = {t.name for t in c.types if t.kind == "int_enum"}
|
||||
type_names = [t.name for t in c.types if t.name not in value_imported]
|
||||
o = [BANNER.format(version=c.version), ""]
|
||||
o.append("import type {")
|
||||
for name in sorted(type_names):
|
||||
o.append(f" {name},")
|
||||
o.append("} from './types.js';")
|
||||
o.append("import {")
|
||||
for t in sorted(c.types, key=lambda t: t.name):
|
||||
if t.kind == "string_enum":
|
||||
o.append(f" {upper_snake(t.name)}_VALUES,")
|
||||
elif t.kind == "int_enum":
|
||||
o.append(f" {t.name},")
|
||||
o.append("} from './types.js';")
|
||||
o.append("import { isEventName, type EventMap, type EventName } from './events.js';")
|
||||
o.append("import { isMethodName, type MethodMap, type MethodName } from './methods.js';")
|
||||
o.append(PRELUDE)
|
||||
|
||||
for t in c.types:
|
||||
if t.kind == "string_enum":
|
||||
o.append(f"export const validate{t.name}: Validator<{t.name}> = "
|
||||
f"vEnum({upper_snake(t.name)}_VALUES, '{t.name}');")
|
||||
o.append("")
|
||||
elif t.kind == "int_enum":
|
||||
o.append(f"const {upper_snake(t.name)}_VALUES = Object.values({t.name}) as {t.name}[];")
|
||||
o.append(f"export const validate{t.name}: Validator<{t.name}> = "
|
||||
f"vIntEnum({upper_snake(t.name)}_VALUES, '{t.name}');")
|
||||
o.append("")
|
||||
elif t.kind == "map_alias":
|
||||
o.append(f"export const validate{t.name}: Validator<{t.name}> = "
|
||||
f"{validator_expr(t.alias)};")
|
||||
o.append("")
|
||||
elif t.kind == "struct":
|
||||
o += doc_block(f"Validate an untrusted value as {t.name}.")
|
||||
o.append(f"export function validate{t.name}(v: unknown, path = ''): Validated<{t.name}> {{")
|
||||
o.append(" if (!isPlainObject(v)) return fail(path, 'expected an object');")
|
||||
if not t.fields:
|
||||
o.append(f" return {{ ok: true, value: {{}} as {t.name} }};")
|
||||
o.append("}")
|
||||
o.append("")
|
||||
continue
|
||||
o.append(" const out: Record<string, unknown> = {};")
|
||||
o.append(" let r: Validated<null>;")
|
||||
for f in t.fields:
|
||||
fn = "opt" if f.optional else "req"
|
||||
o.append(f" r = {fn}(v, {json.dumps(f.name)}, path, {validator_expr(f.type)}, out);")
|
||||
o.append(" if (!r.ok) return r;")
|
||||
o.append(f" return {{ ok: true, value: out as unknown as {t.name} }};")
|
||||
o.append("}")
|
||||
o.append("")
|
||||
|
||||
# dispatch tables
|
||||
o += ["// --- by-name entry points --------------------------------------------------",
|
||||
"",
|
||||
"const PARAMS_VALIDATORS: { [M in MethodName]: Validator<MethodMap[M]['params']> } = {"]
|
||||
for m in c.methods:
|
||||
o.append(f" {method_key(m.name)}: {validator_expr(m.params)},")
|
||||
o += ["};", "",
|
||||
"const RESULT_VALIDATORS: { [M in MethodName]: Validator<MethodMap[M]['result']> } = {"]
|
||||
for m in c.methods:
|
||||
o.append(f" {method_key(m.name)}: {validator_expr(m.result)},")
|
||||
o += ["};", "",
|
||||
"const EVENT_VALIDATORS: { [E in EventName]: Validator<EventMap[E]> } = {"]
|
||||
for e in c.events:
|
||||
o.append(f" {method_key(e.name)}: {validator_expr(e.params)},")
|
||||
o += ["};", "",
|
||||
"/** Validate params the daemon is about to receive for `method`. */",
|
||||
"export function validateParams<M extends MethodName>(method: M, v: unknown): Validated<MethodMap[M]['params']> {",
|
||||
" return PARAMS_VALIDATORS[method](v, 'params');",
|
||||
"}",
|
||||
"",
|
||||
"/** Validate a result the client just received for `method`. */",
|
||||
"export function validateResult<M extends MethodName>(method: M, v: unknown): Validated<MethodMap[M]['result']> {",
|
||||
" return RESULT_VALIDATORS[method](v, 'result');",
|
||||
"}",
|
||||
"",
|
||||
"/** Validate a notification payload. */",
|
||||
"export function validateEventParams<E extends EventName>(event: E, v: unknown): Validated<EventMap[E]> {",
|
||||
" return EVENT_VALIDATORS[event](v, 'params');",
|
||||
"}",
|
||||
"",
|
||||
"/**",
|
||||
" * Validate a whole inbound notification frame, including its method name.",
|
||||
" * Anything unrecognised is rejected rather than passed on: an unknown method on a",
|
||||
" * loopback socket is either a version skew or another local process probing us.",
|
||||
" */",
|
||||
"export function validateNotification(",
|
||||
" frame: unknown,",
|
||||
"): Validated<{ method: EventName; params: EventMap[EventName] }> {",
|
||||
" if (!isPlainObject(frame)) return fail('', 'expected an object');",
|
||||
" if (frame['jsonrpc'] !== '2.0') return fail('/jsonrpc', \"expected '2.0'\");",
|
||||
" const method = frame['method'];",
|
||||
" if (!isEventName(method)) return fail('/method', 'unknown event');",
|
||||
" const params = validateEventParams(method, frame['params']);",
|
||||
" if (!params.ok) return params;",
|
||||
" return { ok: true, value: { method, params: params.value } };",
|
||||
"}",
|
||||
"",
|
||||
"export { isEventName, isMethodName };",
|
||||
""]
|
||||
return "\n".join(o)
|
||||
|
||||
|
||||
def emit_index(c: Contract) -> str:
|
||||
return "\n".join([
|
||||
BANNER.format(version=c.version),
|
||||
"",
|
||||
"export * from './types.js';",
|
||||
"export * from './methods.js';",
|
||||
"export * from './events.js';",
|
||||
"export * from './validate.js';",
|
||||
"",
|
||||
])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
c = load()
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(OUT_DIR / "types.ts").write_text(emit_types(c))
|
||||
(OUT_DIR / "methods.ts").write_text(emit_methods(c))
|
||||
(OUT_DIR / "events.ts").write_text(emit_events(c))
|
||||
(OUT_DIR / "validate.ts").write_text(emit_validate(c))
|
||||
(OUT_DIR / "index.ts").write_text(emit_index(c))
|
||||
print(f"gen_ts: {len(c.types)} types, {len(c.methods)} methods, {len(c.events)} events -> {OUT_DIR}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,454 @@
|
||||
"""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}")
|
||||
Reference in New Issue
Block a user