From 97e7ade7457588feb2a55f8dc78e96f59579e86d Mon Sep 17 00:00:00 2001 From: KeviM Date: Sat, 26 Sep 2026 18:43:10 -0700 Subject: [PATCH] chore(codegen): stop on spec features it can't map The script silently dropped or mistranslated spec features it had no mapping for: PATCH operations, header parameters, non-JSON or inline bodies and responses, unions, multi-member allOf, 3.1 type lists, and more. A future spec refresh could lose an endpoint or its data while the tests, which come from the same model, kept passing. Each case now stops with a message naming the operation or schema. Kalshi's current specs use none of them, so the committed output is byte-identical. test_codegen.py covers every check, and make lint runs it. --- CONTRIBUTING.md | 5 + Makefile | 1 + docs/research.md | 6 +- tools/codegen/common.py | 41 ++- tools/codegen/generate.py | 200 +++++++++++--- tools/codegen/test_codegen.py | 482 ++++++++++++++++++++++++++++++++++ tools/codegen/ws.py | 77 +++++- 7 files changed, 755 insertions(+), 57 deletions(-) create mode 100644 tools/codegen/test_codegen.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57ce557..57becdd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,6 +73,11 @@ in `tools/codegen/`, or a spec, run `make codegen`, and commit the result. `make lint` fails on stale output. [docs/research.md](docs/research.md) explains how to refresh the specs. +The script stops with an error on spec features it can't map yet, such as +`oneOf`, PATCH, header parameters, and inline response schemas, rather than +dropping them. `tools/codegen/test_codegen.py` covers each of these checks, and +`make lint` runs it. + ## Code style - Public functions return `kalshi::Result` (`std::expected`) and diff --git a/Makefile b/Makefile index 8da2f26..aec4c36 100644 --- a/Makefile +++ b/Makefile @@ -67,6 +67,7 @@ lint: $(CPP_SOURCES) | xargs -0 $(CLANG_FORMAT) --dry-run --Werror $(PYTHON) tools/cpp_auto_audit.py CLANG_FORMAT="$(CLANG_FORMAT)" $(PYTHON) tools/codegen/generate.py --check + $(PYTHON) -m unittest discover -q -s tools/codegen lint-docs: markdownlint-cli2 diff --git a/docs/research.md b/docs/research.md index bdecb28..708611e 100644 --- a/docs/research.md +++ b/docs/research.md @@ -29,6 +29,8 @@ shasum -a 256 spec/*.yaml make codegen test ``` -Review the diff of the generated files, update the table above, and note -user-visible changes in `CHANGELOG.md`. Kalshi's changelog at +If `make codegen` stops on a spec feature it can't map, extend +`tools/codegen/` to support it before committing the new spec. Review the diff +of the generated files, update the table above, and note user-visible changes +in `CHANGELOG.md`. Kalshi's changelog at explains most changes. diff --git a/tools/codegen/common.py b/tools/codegen/common.py index c4bfea1..498c6d4 100644 --- a/tools/codegen/common.py +++ b/tools/codegen/common.py @@ -16,15 +16,23 @@ ACRONYMS = {"RFQs": "Rfqs", "RFQ": "Rfq", "FCM": "Fcm", "ID": "Id", "API": "Api", "MVE": "Mve"} CPP_RESERVED = { - "and", "auto", "bool", "break", "case", "catch", "char", "class", "const", "continue", - "default", "delete", "do", "double", "else", "enum", "explicit", "export", "extern", - "false", "float", "for", "friend", "goto", "if", "inline", "int", "long", "mutable", - "namespace", "new", "not", "operator", "or", "private", "protected", "public", "return", - "short", "signed", "sizeof", "static", "struct", "switch", "template", "this", "throw", - "true", "try", "typedef", "typename", "union", "unsigned", "using", "virtual", "void", - "volatile", "while", "xor", + "alignas", "alignof", "and", "and_eq", "asm", "auto", "bitand", "bitor", "bool", "break", + "case", "catch", "char", "char8_t", "char16_t", "char32_t", "class", "compl", "concept", + "const", "const_cast", "consteval", "constexpr", "constinit", "continue", "co_await", + "co_return", "co_yield", "decltype", "default", "delete", "do", "double", "dynamic_cast", + "else", "enum", "explicit", "export", "extern", "false", "float", "for", "friend", "goto", + "if", "inline", "int", "long", "mutable", "namespace", "new", "noexcept", "not", "not_eq", + "nullptr", "operator", "or", "or_eq", "private", "protected", "public", "register", + "reinterpret_cast", "requires", "return", "short", "signed", "sizeof", "static", + "static_assert", "static_cast", "struct", "switch", "template", "this", "thread_local", + "throw", "true", "try", "typedef", "typeid", "typename", "union", "unsigned", "using", + "virtual", "void", "volatile", "wchar_t", "while", "xor", "xor_eq", } +# Schema keywords with no C++ mapping yet. Without this check they would +# become empty structs or RawJson, and no test would notice. +UNSUPPORTED_KEYWORDS = ("oneOf", "anyOf", "not", "discriminator") + # A server value spelled "unknown" shares the Unknown enumerator every enum has. UNKNOWN_VALUE = "unknown" @@ -99,6 +107,7 @@ class Member: required: bool = False base_kind: str = "" # "string", "enum", "struct", "vector", ... element: str = "" # struct or enum name for validation + read_only: bool = False @dataclass @@ -108,6 +117,24 @@ class StructType: doc: str = "" +def check_schema(schema: dict, where: str, max_all_of: int) -> None: + """Stops on schema features that would otherwise map to a wrong or empty type.""" + for keyword in UNSUPPORTED_KEYWORDS: + if keyword in schema: + raise SystemExit(f"{where}: `{keyword}` is not supported; extend tools/codegen first") + if len(schema.get("allOf", [])) > max_all_of: + raise SystemExit(f"{where}: `allOf` with {len(schema['allOf'])} members is not supported") + ref = schema.get("$ref") + if ref is not None and not re.fullmatch(r"#/components/schemas/[^/]+", ref): + raise SystemExit(f"{where}: {ref} is not a component schema") + + +def check_identifier(owner: str, json_name: str) -> None: + """Members keep their JSON names so Glaze can reflect them; each must be a C++ name.""" + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", json_name) or json_name in CPP_RESERVED: + raise SystemExit(f"{owner}.{json_name} needs a rename; add glz::meta support first") + + def check_enums(enums: dict[str, EnumType]) -> None: for enum in enums.values(): names = [enumerator(v) for v in enum.known] diff --git a/tools/codegen/generate.py b/tools/codegen/generate.py index 6f0dddd..ccefec3 100644 --- a/tools/codegen/generate.py +++ b/tools/codegen/generate.py @@ -37,8 +37,8 @@ except ImportError: # pragma: no cover - guidance for developers sys.exit("PyYAML is required: install python3-yaml, or pip install pyyaml in a virtual environment") -from common import (CPP_RESERVED, ROOT, EnumType, Member, StructType, check_enums, clang_format, - doc_lines, emit_enum, emit_enum_adapter, emit_enum_parser, emit_struct, +from common import (ROOT, EnumType, Member, StructType, check_enums, check_identifier, check_schema, + clang_format, doc_lines, emit_enum, emit_enum_adapter, emit_enum_parser, emit_struct, first_sentence, ordered_structs, pascal, ref_name, snake) from ws import WS_SPEC, WsGenerator @@ -46,6 +46,15 @@ BANNER = "// Generated by tools/codegen/generate.py from spec/openapi.yaml. Do not edit.\n" HTTP_METHODS = ("get", "post", "put", "delete") +# Methods HttpMethod lacks. An operation using one stops the run instead of vanishing. +UNSUPPORTED_METHODS = ("patch", "head", "options", "trace") + +# Keywords that may sit beside a one-member allOf without changing its C++ type: +# notes, and validation the generator does not emit anywhere. +ALL_OF_SIBLINGS = {"allOf", "description", "nullable", "default", "example", "examples", "deprecated", "title", + "readOnly", "writeOnly", "externalDocs", "format", "pattern", "minLength", "maxLength", + "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf", "minItems", + "maxItems", "uniqueItems", "minProperties", "maxProperties"} # Methods whose operationId carries a version suffix the SDK does not need. METHOD_RENAMES = { @@ -101,6 +110,15 @@ def __init__(self, spec: dict) -> None: # ----- types ----------------------------------------------------------- def run(self) -> None: + for name, schema in self.schemas.items(): + self.check(schema, name) + # struct_for reads only `properties`, so a component that is only a + # reference would become an empty struct. + if "allOf" in schema or "$ref" in schema: + raise SystemExit(f"{name}: a component that is an `allOf` or `$ref` is not supported; " + "it would become an empty struct") + if schema.get("additionalProperties", False) is not False and not schema.get("properties"): + raise SystemExit(f"{name}: top-level map schemas are not supported; it would become an empty struct") for name, schema in self.schemas.items(): if "enum" in schema: self.enum_for(schema["enum"], name, schema.get("description", ""), fixed=name) @@ -108,22 +126,65 @@ def run(self) -> None: self.aliases[name] = self.primitive(schema) for name, schema in self.schemas.items(): if schema.get("type") == "array": - inner, _, _, _ = self.type_of(schema.get("items", {}), name + "Item") + inner, _, _, _ = self.type_of(self.element(schema.get("items", {}), name + "Item"), name + "Item") size = schema.get("minItems") fixed = size is not None and size == schema.get("maxItems") self.aliases[name] = f"std::array<{inner}, {size}>" if fixed else f"std::vector<{inner}>" for name, schema in self.schemas.items(): - empty = schema.get("type") == "object" and not schema.get("properties") and not schema.get("additionalProperties") - if name not in self.enums and name not in self.aliases and not empty: + if name not in self.enums and name not in self.aliases and not self.empty_object(schema): self.struct_for(name, schema) for path, item in self.spec["paths"].items(): + if "$ref" in item: + raise SystemExit(f"{path}: path item $ref is not supported") + for method in UNSUPPORTED_METHODS: + if method in item: + raise SystemExit(f"{method.upper()} {path}: HttpMethod has no {method.upper()}; " + "add it to the runtime and the generator first") for method in HTTP_METHODS: if method in item: self.operation(path, method, item[method], item.get("parameters", [])) + self.check_request_fields() check_enums(self.enums) + @staticmethod + def check(schema: dict, where: str) -> None: + check_schema(schema, where, max_all_of=1) + if isinstance(schema.get("type"), list): + raise SystemExit(f"{where}: `type` lists (OpenAPI 3.1) are not supported here; ws.py has them") + # unwrap() keeps only `nullable` from beside a one-member allOf, and member() + # reads `readOnly`. Anything else there, such as `properties`, would vanish. + # Vendor extensions (x-...) are hints for other tools and change nothing. + if "allOf" in schema and (extra := sorted(k for k in set(schema) - ALL_OF_SIBLINGS if not k.startswith("x-"))): + raise SystemExit(f"{where}: {extra} beside `allOf` is not supported; it would be dropped") + + def element(self, schema: dict, where: str) -> dict: + """Array items and map values have no std::optional form, so a null one would fail decode().""" + if self.unwrap(schema).get("nullable") or schema.get("nullable"): + raise SystemExit(f"{where}: nullable array items and map values are not supported") + return schema + + @staticmethod + def empty_object(schema: dict) -> bool: + return schema.get("type") == "object" and not schema.get("properties") and not schema.get("additionalProperties") + + def check_request_fields(self) -> None: + """A required read-only field in a request body would fail validation whenever it is empty.""" + pending, seen = sorted(self.request_bodies), set() + while pending: + name = pending.pop() + if name in seen or name not in self.structs: + continue + seen.add(name) + for m in self.structs[name].members: + if m.read_only and m.required: + raise SystemExit(f"{name}.{m.json_name}: required readOnly fields in request bodies " + "are not supported") + pending.append(m.element) + def enum_for(self, values: list, context: str, doc: str, fixed: str | None = None, prop: str = "") -> str: + if not all(isinstance(v, str) for v in values): + raise SystemExit(f"{fixed or context}: only string enums are supported; found {values}") key = frozenset(values) if fixed: self.enums[fixed] = EnumType(fixed, list(values), first_sentence(doc)) @@ -167,7 +228,9 @@ def unwrap(self, schema: dict) -> dict: def type_of(self, schema: dict, context: str, prop: str = "") -> tuple[str, str, str, bool]: """Returns (cpp type, kind, element name, fixed_point).""" + self.check(schema, context) schema = self.unwrap(schema) + self.check(schema, context) if "$ref" in schema: name = ref_name(schema["$ref"]) if name in self.enums: @@ -183,15 +246,19 @@ def type_of(self, schema: dict, context: str, prop: str = "") -> tuple[str, str, return name, "enum", name, False kind = schema.get("type") if kind == "array": - inner, inner_kind, element, _ = self.type_of(schema.get("items", {}), context + "Item", prop) + items = self.element(schema.get("items", {}), context + "Item") + inner, inner_kind, element, _ = self.type_of(items, context + "Item", prop) return f"std::vector<{inner}>", "vector", element if inner_kind in ("struct", "enum") else "", False if kind == "object" or "properties" in schema or "additionalProperties" in schema or not kind: if schema.get("properties"): + # Each inline object is reached once, so an existing name is a clash. + if context in self.schemas or context in self.structs: + raise SystemExit(f"{context}: an inline object has the same name as another schema") self.struct_for(context, schema) return context, "struct", context, False extra = schema.get("additionalProperties") if isinstance(extra, dict) and extra: - inner, _, element, _ = self.type_of(extra, context + "Value") + inner, _, element, _ = self.type_of(self.element(extra, context + "Value"), context + "Value") return f"std::map", "map", element, False return "RawJson", "raw", "", False return self.primitive(schema), "string" if kind == "string" else "scalar", "", False @@ -200,9 +267,10 @@ def member(self, owner: str, json_name: str, schema: dict, required: bool) -> Me schema_u = self.unwrap(schema) cpp, kind, element, fixed = self.type_of(schema, owner + pascal(json_name), json_name) nullable = bool(schema_u.get("nullable")) or bool(schema.get("nullable")) + if "$ref" in schema_u: # a nullable component is nullable wherever it is used + nullable = nullable or bool(self.schemas.get(ref_name(schema_u["$ref"]), {}).get("nullable")) plain = required and not nullable - if json_name in CPP_RESERVED: - raise SystemExit(f"{owner}.{json_name} needs a rename; add glz::meta support first") + check_identifier(owner, json_name) return Member( json_name=json_name, cpp_type=cpp if plain else f"std::optional<{cpp}>", @@ -211,11 +279,16 @@ def member(self, owner: str, json_name: str, schema: dict, required: bool) -> Me required=plain, base_kind=kind, element=element, + read_only=bool(schema.get("readOnly") or schema_u.get("readOnly")), ) def struct_for(self, name: str, schema: dict) -> StructType: if name in self.structs: return self.structs[name] + self.check(schema, name) + if schema.get("additionalProperties", False) is not False and schema.get("properties"): + raise SystemExit(f"{name}: properties with additionalProperties are not supported; " + "the extra keys would be dropped") struct = StructType(name, doc=first_sentence(schema.get("description"))) self.structs[name] = struct required = set(schema.get("required", [])) @@ -226,12 +299,58 @@ def struct_for(self, name: str, schema: dict) -> StructType: # ----- operations ------------------------------------------------------ def parameter(self, param: dict) -> dict: - return self.parameters[ref_name(param["$ref"])] if "$ref" in param else param + if "$ref" not in param: + return param + if not re.fullmatch(r"#/components/parameters/[^/]+", param["$ref"]): + raise SystemExit(f"{param['$ref']} is not a component parameter") + return self.parameters[ref_name(param["$ref"])] + + @staticmethod + def check_parameter(op_id: str, param: dict) -> None: + where = f"{op_id} parameter {param.get('name')}" + if param.get("in") not in ("path", "query"): + raise SystemExit(f"{where}: `in: {param.get('in')}` is not supported; " + "HttpTransport::request takes only a path and a body") + if "content" in param: + raise SystemExit(f"{where}: `content` parameters are not supported; use `schema`") + + def response_result(self, op_id: str, code: str, response: dict) -> tuple[str | None, str | None]: + """Returns (response type, unwrapped member); a None type means Result.""" + where = f"{op_id} response {code}" + if "$ref" in response: + raise SystemExit(f"{where}: $ref responses are not supported") + content = response.get("content", {}) + if code == "204" or not content: + return None, None + if "application/json" not in content: + raise SystemExit(f"{where}: only application/json is supported; found {sorted(content)}") + schema = content["application/json"].get("schema") + if schema is None or "$ref" not in schema: + raise SystemExit(f"{where}: must be a $ref to a component schema") + check_schema(schema, where, max_all_of=0) + name = ref_name(schema["$ref"]) + target = self.schemas[name] + props = target.get("properties", {}) + if target.get("type") == "object" and not props: + return None, None + if len(props) == 1: + member = self.structs[name].members[0] + return member.cpp_type, member.json_name + return name, None def operation(self, path: str, method: str, op: dict, shared: list) -> None: - op_id = op["operationId"] + op_id = op.get("operationId") + if not op_id: + raise SystemExit(f"{method.upper()} {path}: needs an operationId") method_name = METHOD_RENAMES.get(op_id, snake(op_id)) + if any(existing.method_name == method_name for existing in self.operations): + raise SystemExit(f"{op_id}: another operation already maps to {method_name}") params = [self.parameter(p) for p in shared + op.get("parameters", [])] + for p in params: + self.check_parameter(op_id, p) + if len({(p["name"], p["in"]) for p in params}) != len(params): + raise SystemExit(f"{op_id}: a parameter is declared twice; operation-level overrides " + "of path-level parameters are not supported") path_params = [p["name"] for p in params if p["in"] == "path"] in_path = re.findall(r"{([^}]+)}", path) if sorted(path_params) != sorted(in_path): @@ -241,11 +360,21 @@ def operation(self, path: str, method: str, op: dict, shared: list) -> None: query: list[tuple[str, Member]] = [] if query_params: params_struct = StructType(pascal(method_name) + "Params", doc=f"Query parameters for `{method_name}`.") + if params_struct.name in self.structs or params_struct.name in self.schemas: + raise SystemExit(f"{op_id}: {params_struct.name} is already a schema name") for p in query_params: schema = dict(p.get("schema", {})) if not schema.get("description") and p.get("description"): schema["description"] = p["description"] m = self.member(pascal(method_name), p["name"], schema, bool(p.get("required"))) + if m.base_kind in ("struct", "map", "raw") or (m.base_kind == "vector" and m.element in self.structs): + raise SystemExit(f"{op_id} parameter {p['name']}: detail::Query cannot send {m.cpp_type}") + # detail::Query repeats the key for each array item, which is form with + # explode. Scalars read the same whatever the style and explode say. + if m.base_kind in ("vector", "container") and ( + p.get("style", "form") != "form" or p.get("explode", True) is not True): + raise SystemExit(f"{op_id} parameter {p['name']}: only style: form with explode: true " + "is supported for arrays") if m.base_kind == "vector": m.cpp_type = m.cpp_type.removeprefix("std::optional<").removesuffix(">") if not m.required else m.cpp_type params_struct.members.append(m) @@ -255,31 +384,34 @@ def operation(self, path: str, method: str, op: dict, shared: list) -> None: body_type = None empty_body = False if "requestBody" in op: - content = op["requestBody"].get("content", {}).get("application/json", {}) - schema = content.get("schema", {}) - if "$ref" in schema and ref_name(schema["$ref"]) in self.structs: - body_type = ref_name(schema["$ref"]) + where = f"{op_id} request body" + if "$ref" in op["requestBody"]: + raise SystemExit(f"{where}: $ref request bodies are not supported") + content = op["requestBody"].get("content", {}) + if "application/json" not in content: + raise SystemExit(f"{where}: only application/json is supported; found {sorted(content)}") + schema = content["application/json"].get("schema", {}) + check_schema(schema, where, max_all_of=0) + name = ref_name(schema["$ref"]) if "$ref" in schema else None + if name in self.structs: + body_type = name self.request_bodies.add(body_type) - else: + elif name is not None and self.empty_object(self.schemas.get(name, {})): empty_body = True - - response_type, unwrap_member = None, None - responses = op.get("responses", {}) - ok = next((code for code in ("200", "201", "204") if code in responses), None) - if ok and ok != "204": - schema = responses[ok].get("content", {}).get("application/json", {}).get("schema", {}) - if "$ref" in schema: - name = ref_name(schema["$ref"]) - target = self.schemas[name] - props = target.get("properties", {}) - if target.get("type") == "object" and not props: - response_type = None - elif len(props) == 1: - struct = self.structs[name] - unwrap_member = struct.members[0].json_name - response_type = struct.members[0].cpp_type - else: - response_type = name + else: + raise SystemExit(f"{where}: must be a $ref to a component object") + + responses = {str(code): response for code, response in op.get("responses", {}).items()} + success = [code for code in responses if code.startswith("2")] + if not success: + raise SystemExit(f"{op_id}: has no 2xx response") + if unsupported := [code for code in success if code not in ("200", "201", "204")]: + raise SystemExit(f"{op_id}: responses {unsupported} are not supported; use 200, 201, or 204") + # Any 2xx counts as success, so each must decode as the method's result type. + results = {code: self.response_result(op_id, code, responses[code]) for code in success} + if len(set(results.values())) > 1: + raise SystemExit(f"{op_id}: 2xx responses {sorted(results)} have different bodies") + response_type, unwrap_member = results[success[0]] self.operations.append(Operation( operation_id=op_id, diff --git a/tools/codegen/test_codegen.py b/tools/codegen/test_codegen.py new file mode 100644 index 0000000..e3630c8 --- /dev/null +++ b/tools/codegen/test_codegen.py @@ -0,0 +1,482 @@ +"""Tests that tools/codegen stops on spec features it cannot map instead of dropping them. + + python3 -m unittest discover -s tools/codegen # `make lint` runs this + +Each test edits a small spec that the generator accepts, then expects SystemExit. +""" + +from __future__ import annotations + +import copy +import unittest + +from generate import Generator +from ws import WsGenerator + + +def ref(name: str) -> dict: + return {"$ref": f"#/components/schemas/{name}"} + + +def json_content(schema: dict) -> dict: + return {"content": {"application/json": {"schema": schema}}} + + +BASE = { + "openapi": "3.0.0", + "paths": { + "/things": { + "post": { + "operationId": "CreateThing", + "tags": ["things"], + "requestBody": json_content(ref("Thing")), + "responses": {"201": json_content(ref("Thing"))}, + }, + }, + "/things/{id}": { + "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "string"}}], + "get": { + "operationId": "GetThing", + "tags": ["things"], + "parameters": [ + {"name": "limit", "in": "query", "schema": {"type": "integer"}}, + {"name": "ids", "in": "query", "style": "form", "explode": True, + "schema": {"type": "array", "items": {"type": "string"}}}, + ], + "responses": {"200": json_content(ref("GetThingResponse")), "404": {"description": "missing"}}, + }, + "put": { + "operationId": "ResetThing", + "tags": ["things"], + "requestBody": json_content(ref("Empty")), + "responses": {"204": {"description": "reset"}}, + }, + }, + }, + "components": { + "schemas": { + "Thing": { + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "size": {"type": "string", "enum": ["small", "large"]}, + "detail": {"type": "object", "properties": {"note": {"type": "string"}}}, + }, + }, + "GetThingResponse": {"type": "object", "required": ["thing"], "properties": {"thing": ref("Thing")}}, + "Empty": {"type": "object"}, + }, + }, +} + + +def generate(spec: dict) -> Generator: + gen = Generator(spec) + gen.run() + return gen + + +class SupportedSpec(unittest.TestCase): + def test_base_spec_generates(self) -> None: + ops = {op.method_name: op for op in generate(copy.deepcopy(BASE)).operations} + self.assertEqual(ops["create_thing"].body_type, "Thing") + self.assertEqual(ops["get_thing"].response_type, "Thing") # unwrapped from GetThingResponse + self.assertTrue(ops["reset_thing"].empty_body) + self.assertIsNone(ops["reset_thing"].response_type) + + def test_read_only_outside_request_bodies_is_allowed(self) -> None: + spec = copy.deepcopy(BASE) + spec["components"]["schemas"]["GetThingResponse"]["properties"]["etag"] = {"type": "string", "readOnly": True} + generate(spec) + + def test_optional_read_only_in_request_body_is_allowed(self) -> None: + spec = copy.deepcopy(BASE) + spec["components"]["schemas"]["Thing"]["properties"]["id"] = {"type": "string", "readOnly": True} + members = {m.json_name: m for m in generate(spec).structs["Thing"].members} + self.assertEqual(members["id"].cpp_type, "std::optional") + + def test_scalar_query_without_explode_is_allowed(self) -> None: + spec = copy.deepcopy(BASE) + spec["paths"]["/things/{id}"]["get"]["parameters"][0]["explode"] = False # limit=5 either way + generate(spec) + + def test_all_of_with_notes_and_vendor_extensions_is_allowed(self) -> None: + spec = copy.deepcopy(BASE) + spec["components"]["schemas"]["GetThingResponse"]["properties"]["other"] = { + "allOf": [ref("Thing")], "nullable": True, "description": "Another thing.", "x-go-type": "Thing"} + members = {m.json_name: m for m in generate(spec).structs["GetThingResponse"].members} + self.assertEqual(members["other"].cpp_type, "std::optional") + + def test_all_of_with_validation_keywords_is_allowed(self) -> None: + spec = copy.deepcopy(BASE) # Kalshi's Perps spec puts maxLength and pattern here + spec["components"]["schemas"]["Code"] = {"type": "string"} + spec["components"]["schemas"]["Thing"]["properties"]["code"] = { + "allOf": [ref("Code")], "maxLength": 8, "pattern": "^[A-Z]+$"} + members = {m.json_name: m for m in generate(spec).structs["Thing"].members} + self.assertEqual(members["code"].cpp_type, "std::optional") + + def test_nullable_component_makes_members_optional(self) -> None: + spec = copy.deepcopy(BASE) + spec["components"]["schemas"]["MaybeName"] = {"type": "string", "nullable": True} + thing = spec["components"]["schemas"]["Thing"] + thing["properties"]["alias"] = ref("MaybeName") + thing["required"].append("alias") + members = {m.json_name: m for m in generate(spec).structs["Thing"].members} + self.assertEqual(members["alias"].cpp_type, "std::optional") + + def test_same_body_under_two_success_codes_is_allowed(self) -> None: + spec = copy.deepcopy(BASE) + spec["paths"]["/things"]["post"]["responses"]["200"] = json_content(ref("Thing")) + ops = {op.method_name: op for op in generate(spec).operations} + self.assertEqual(ops["create_thing"].response_type, "Thing") + + +class Rejected(unittest.TestCase): + def setUp(self) -> None: + self.spec = copy.deepcopy(BASE) + self.schemas = self.spec["components"]["schemas"] + self.get = self.spec["paths"]["/things/{id}"]["get"] + self.post = self.spec["paths"]["/things"]["post"] + + def stops(self, message: str) -> None: + with self.assertRaises(SystemExit) as caught: + generate(self.spec) + self.assertIn(message, str(caught.exception.code)) + + # ----- operations ------------------------------------------------------ + + def test_patch_operation(self) -> None: + self.spec["paths"]["/things/{id}"]["patch"] = dict(self.get, operationId="PatchThing") + self.stops("PATCH /things/{id}: HttpMethod has no PATCH") + + def test_path_item_ref(self) -> None: + self.spec["paths"]["/other"] = {"$ref": "other.yaml#/paths/other"} + self.stops("/other: path item $ref") + + def test_missing_operation_id(self) -> None: + del self.get["operationId"] + self.stops("GET /things/{id}: needs an operationId") + + def test_header_parameter(self) -> None: + self.get["parameters"].append({"name": "X-Trace", "in": "header", "schema": {"type": "string"}}) + self.stops("`in: header` is not supported") + + def test_cookie_parameter(self) -> None: + self.get["parameters"].append({"name": "session", "in": "cookie", "schema": {"type": "string"}}) + self.stops("`in: cookie` is not supported") + + def test_content_parameter(self) -> None: + self.get["parameters"].append({"name": "filter", "in": "query", "content": {"application/json": {}}}) + self.stops("`content` parameters are not supported") + + def test_unexploded_array_query(self) -> None: + self.get["parameters"][1]["explode"] = False + self.stops("only style: form with explode: true") + + def test_object_query(self) -> None: + self.get["parameters"].append({"name": "where", "in": "query", "schema": ref("Thing")}) + self.stops("detail::Query cannot send") + + def test_pipe_delimited_array_query(self) -> None: + self.get["parameters"][1]["style"] = "pipeDelimited" + self.stops("only style: form with explode: true is supported for arrays") + + def test_parameter_ref_outside_component_parameters(self) -> None: + self.get["parameters"].append({"$ref": "#/components/schemas/Limit"}) + self.stops("#/components/schemas/Limit is not a component parameter") + + def test_duplicate_parameter(self) -> None: + self.get["parameters"].append({"name": "id", "in": "path", "required": True, "schema": {"type": "string"}}) + self.stops("GetThing: a parameter is declared twice") + + def test_duplicate_method_name(self) -> None: + self.spec["paths"]["/v2/things/{id}"] = copy.deepcopy(self.spec["paths"]["/things/{id}"]) + self.stops("GetThing: another operation already maps to get_thing") + + def test_params_struct_named_like_a_schema(self) -> None: + self.schemas["GetThingParams"] = {"type": "object", "properties": {"x": {"type": "string"}}} + self.stops("GetThingParams is already a schema name") + + # ----- request bodies -------------------------------------------------- + + def test_non_json_body(self) -> None: + self.post["requestBody"] = {"content": {"multipart/form-data": {"schema": ref("Thing")}}} + self.stops("CreateThing request body: only application/json") + + def test_inline_body(self) -> None: + self.post["requestBody"] = json_content({"type": "array", "items": ref("Thing")}) + self.stops("CreateThing request body: must be a $ref to a component object") + + def test_body_ref(self) -> None: + self.post["requestBody"] = {"$ref": "#/components/requestBodies/Thing"} + self.stops("CreateThing request body: $ref request bodies are not supported") + + def test_body_schema_ref_outside_component_schemas(self) -> None: + self.post["requestBody"] = json_content({"$ref": "#/components/requestBodies/Thing"}) + self.stops("#/components/requestBodies/Thing is not a component schema") + + def test_body_ref_to_array_schema(self) -> None: + self.schemas["Things"] = {"type": "array", "items": ref("Thing")} + self.post["requestBody"] = json_content(ref("Things")) + self.stops("CreateThing request body: must be a $ref to a component object") + + def test_read_only_field_in_request_body(self) -> None: + self.schemas["Thing"]["properties"]["name"]["readOnly"] = True + self.stops("Thing.name: required readOnly fields in request bodies") + + # ----- responses ------------------------------------------------------- + + def test_inline_response(self) -> None: + self.get["responses"]["200"] = json_content({"type": "array", "items": ref("Thing")}) + self.stops("GetThing response 200: must be a $ref to a component schema") + + def test_response_ref(self) -> None: + self.get["responses"]["200"] = {"$ref": "#/components/responses/ThingResponse"} + self.stops("GetThing response 200: $ref responses are not supported") + + def test_response_schema_ref_outside_component_schemas(self) -> None: + self.get["responses"]["200"] = json_content({"$ref": "#/components/responses/Thing"}) + self.stops("#/components/responses/Thing is not a component schema") + + def test_success_codes_with_different_bodies(self) -> None: + self.get["responses"]["204"] = {"description": "nothing to return"} + self.stops("GetThing: 2xx responses ['200', '204'] have different bodies") + + def test_non_json_response(self) -> None: + self.get["responses"]["200"] = {"content": {"text/csv": {"schema": {"type": "string"}}}} + self.stops("GetThing response 200: only application/json") + + def test_accepted_response(self) -> None: + self.get["responses"] = {"202": json_content(ref("Thing"))} + self.stops("GetThing: responses ['202'] are not supported") + + def test_no_success_response(self) -> None: + self.get["responses"] = {"404": {"description": "missing"}} + self.stops("GetThing: has no 2xx response") + + # ----- schemas --------------------------------------------------------- + + def test_one_of(self) -> None: + self.schemas["Thing"]["properties"]["shape"] = {"oneOf": [{"type": "string"}, {"type": "integer"}]} + self.stops("ThingShape: `oneOf` is not supported") + + def test_any_of_component(self) -> None: + self.schemas["Either"] = {"anyOf": [ref("Thing"), ref("Empty")]} + self.stops("Either: `anyOf` is not supported") + + def test_discriminator(self) -> None: + self.schemas["Thing"]["discriminator"] = {"propertyName": "name"} + self.stops("Thing: `discriminator` is not supported") + + def test_not(self) -> None: + self.schemas["Thing"]["properties"]["shape"] = {"not": {"type": "integer"}} + self.stops("ThingShape: `not` is not supported") + + def test_component_that_is_an_all_of(self) -> None: + self.schemas["Wrapper"] = {"allOf": [ref("Thing")]} + self.stops("Wrapper: a component that is an `allOf` or `$ref`") + + def test_component_that_is_a_ref(self) -> None: + self.schemas["Alias"] = ref("Thing") + self.stops("Alias: a component that is an `allOf` or `$ref`") + + def test_all_of_with_properties_beside_it(self) -> None: + self.schemas["Thing"]["properties"]["more"] = {"allOf": [ref("GetThingResponse")], + "properties": {"extra": {"type": "string"}}} + self.stops("ThingMore: ['properties'] beside `allOf`") + + def test_nullable_array_items(self) -> None: + self.schemas["Thing"]["properties"]["notes"] = {"type": "array", "items": {"type": "string", "nullable": True}} + self.stops("ThingNotesItem: nullable array items and map values") + + def test_nullable_map_values(self) -> None: + self.schemas["Thing"]["properties"]["counts"] = { + "type": "object", "additionalProperties": {"type": "integer", "nullable": True}} + self.stops("ThingCountsValue: nullable array items and map values") + + def test_two_inline_objects_with_one_name(self) -> None: + props = self.schemas["Thing"]["properties"] + props["detail"]["properties"]["info"] = {"type": "object", "properties": {"a": {"type": "integer"}}} + props["detail_info"] = {"type": "object", "properties": {"b": {"type": "string"}}} + self.stops("ThingDetailInfo: an inline object has the same name as another schema") + + def test_all_of_with_two_members(self) -> None: + self.schemas["BigThing"] = {"allOf": [ref("Thing"), {"type": "object", "properties": {"x": {"type": "string"}}}]} + self.stops("BigThing: `allOf` with 2 members") + + def test_type_list(self) -> None: + self.schemas["Thing"]["properties"]["count"] = {"type": ["integer", "null"]} + self.stops("ThingCount: `type` lists (OpenAPI 3.1)") + + def test_properties_with_additional_properties(self) -> None: + self.schemas["Thing"]["additionalProperties"] = True + self.stops("Thing: properties with additionalProperties") + + def test_top_level_map(self) -> None: + self.schemas["Totals"] = {"type": "object", "additionalProperties": {"type": "integer"}} + self.stops("Totals: top-level map schemas") + + def test_ref_outside_component_schemas(self) -> None: + self.schemas["Thing"]["properties"]["owner"] = {"$ref": "people.yaml#/Person"} + self.stops("people.yaml#/Person is not a component schema") + + def test_integer_enum(self) -> None: + self.schemas["Level"] = {"type": "integer", "enum": [1, 2, 3]} + self.stops("Level: only string enums are supported") + + def test_inline_object_named_like_a_component(self) -> None: + self.schemas["ThingDetail"] = {"type": "object", "properties": {"other": {"type": "integer"}}} + self.stops("ThingDetail: an inline object has the same name as another schema") + + def test_non_identifier_key(self) -> None: + self.schemas["Thing"]["properties"]["x-rate"] = {"type": "string"} + self.stops("Thing.x-rate needs a rename") + + def test_reserved_key(self) -> None: + self.schemas["Thing"]["properties"]["requires"] = {"type": "string"} + self.stops("Thing.requires needs a rename") + + +class RejectedWebSocket(unittest.TestCase): + def setUp(self) -> None: + schemas = {"Tick": {"type": "object", "properties": {"price": {"type": "integer"}}}} + self.ws = WsGenerator({"components": {"schemas": schemas, "messages": {}}}, {}) + + def stops(self, schema: dict, message: str) -> None: + with self.assertRaises(SystemExit) as caught: + self.ws.type_of(schema, "TickerMsg", "field") + self.assertIn(message, str(caught.exception.code)) + + def test_supported_types(self) -> None: + self.assertEqual(self.ws.type_of({"type": ["string", "null"]}, "TickerMsg", "field")[0], "std::string") + self.assertEqual(self.ws.type_of(ref("Tick"), "TickerMsg", "tick")[0], "Tick") + + def test_one_of(self) -> None: + self.stops({"oneOf": [{"type": "string"}, {"type": "integer"}]}, "`oneOf` is not supported") + + def test_all_of(self) -> None: + self.stops({"allOf": [ref("Tick")]}, "`allOf` with 1 members is not supported") + + def test_untyped_schema(self) -> None: + self.stops({"description": "anything"}, "a schema with no type is not supported") + + def test_ref_outside_component_schemas(self) -> None: + self.stops({"$ref": "#/components/messages/Tick"}, "is not a component schema") + + def test_ref_to_a_union(self) -> None: + self.ws.schemas["Either"] = {"oneOf": [{"type": "string"}, {"type": "integer"}]} + self.stops(ref("Either"), "`oneOf` is not supported") + + def test_array_without_items(self) -> None: + self.stops({"type": "array"}, "an array without `items` is not supported") + + def test_null_type(self) -> None: + self.stops({"type": "null"}, "type 'null' is not supported") + + def test_integer_enum(self) -> None: + self.stops({"type": "integer", "enum": [1, 2]}, "only string enums are supported") + + def test_properties_with_additional_properties(self) -> None: + self.stops({"type": "object", "properties": {"a": {"type": "string"}}, "additionalProperties": True}, + "properties with additionalProperties") + + def test_struct_from_an_all_of(self) -> None: + with self.assertRaises(SystemExit) as caught: + self.ws.struct_for("TickerMsg", {"allOf": [ref("Tick")]}) + self.assertIn("`allOf` with 1 members is not supported", str(caught.exception.code)) + + +def command(cmd: str, params: dict, required: list[str]) -> dict: + return {"type": "object", "properties": { + "id": {"type": "integer"}, "cmd": {"type": "string", "const": cmd}, + "params": {"type": "object", "properties": params, "required": required}}} + + +def update_command(actions: list[str], extra: dict | None = None, required: list[str] | None = None) -> dict: + params = {"sid": {"type": "integer"}, "action": {"type": "string", "enum": actions}, **(extra or {})} + return command("update_subscription", params, required or ["action"]) + + +WS_BASE = { + "channels": {"ticker": {"messages": {"ticker": {"$ref": "#/components/messages/ticker"}}}}, + "components": { + "messages": {"ticker": {"summary": "Ticker update", "payload": ref("tickerPayload")}}, + "schemas": { + "subscribeCommandPayload": command("subscribe", { + "channels": {"type": "array", "items": {"type": "string", "enum": ["ticker"]}}, + "market_ticker": {"type": "string"}, + }, ["channels"]), + "updateSubscriptionCommandPayload": update_command( + ["add_markets"], {"market_tickers": {"type": "array", "items": {"type": "string"}}}), + "cfbenchmarksUpdateSubscriptionCommandPayload": update_command(["subscribe_indices"]), + "pythUpdateSubscriptionCommandPayload": update_command(["subscribe_feeds"]), + "tickerPayload": {"type": "object", "properties": { + "type": {"type": "string", "const": "ticker"}, + "sid": {"type": "integer"}, + "msg": {"type": "object", "properties": {"price": {"type": "integer"}}}, + }}, + }, + }, +} + + +def generate_ws(spec: dict) -> WsGenerator: + gen = WsGenerator(spec, {}) + gen.run() + return gen + + +class WebSocketSpec(unittest.TestCase): + def setUp(self) -> None: + self.spec = copy.deepcopy(WS_BASE) + self.schemas = self.spec["components"]["schemas"] + + def stops(self, message: str) -> None: + with self.assertRaises(SystemExit) as caught: + generate_ws(self.spec) + self.assertIn(message, str(caught.exception.code)) + + def members(self, struct: str) -> dict: + return {m.json_name: m for m in generate_ws(self.spec).structs[struct].members} + + def test_base_spec_generates(self) -> None: + self.assertIn("price", self.members("Ticker")) + + def test_msg_given_as_a_ref(self) -> None: + self.schemas["tickerMsg"] = self.schemas["tickerPayload"]["properties"]["msg"] + self.schemas["tickerPayload"]["properties"]["msg"] = ref("tickerMsg") + self.assertIn("price", self.members("Ticker")) + + def test_param_required_by_one_command_is_optional(self) -> None: + self.schemas["pythUpdateSubscriptionCommandPayload"] = update_command( + ["subscribe_feeds"], {"feed": {"type": "string"}}, ["action", "feed"]) + members = self.members("UpdateSubscriptionParams") + self.assertEqual(members["feed"].cpp_type, "std::optional") + self.assertTrue(members["action"].required) + + def test_msg_that_is_not_an_object(self) -> None: + self.schemas["tickerPayload"]["properties"]["msg"] = {"type": "array", "items": {"type": "integer"}} + self.stops("ticker.msg must be an object with properties") + + def test_extra_envelope_field(self) -> None: + self.schemas["tickerPayload"]["properties"]["ts_ms"] = {"type": "integer"} + self.stops("ticker: envelope fields ['ts_ms'] are not supported") + + def test_new_update_command(self) -> None: + self.schemas["fooUpdateSubscriptionCommandPayload"] = update_command(["subscribe_foo"]) + self.stops("update_subscription command payloads are") + + def test_shared_param_with_different_schemas(self) -> None: + self.schemas["pythUpdateSubscriptionCommandPayload"] = update_command( + ["subscribe_feeds"], {"market_tickers": {"type": "array", "items": {"type": "integer"}}}) + self.stops("UpdateSubscriptionParams.market_tickers has different schemas across commands") + + def test_param_that_is_not_an_identifier(self) -> None: + params = self.schemas["subscribeCommandPayload"]["properties"]["params"]["properties"] + params["market-ticker"] = {"type": "string"} + self.stops("SubscribeParams.market-ticker needs a rename") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/codegen/ws.py b/tools/codegen/ws.py index 4905bd7..dce14fe 100644 --- a/tools/codegen/ws.py +++ b/tools/codegen/ws.py @@ -19,8 +19,8 @@ import re from dataclasses import dataclass, field -from common import (CPP_RESERVED, ROOT, UNKNOWN_VALUE, EnumType, Member, StructType, check_enums, - emit_enum, emit_enum_adapter, emit_enum_parser, emit_struct, first_sentence, +from common import (ROOT, UNKNOWN_VALUE, EnumType, Member, StructType, check_enums, check_identifier, + check_schema, emit_enum, emit_enum_adapter, emit_enum_parser, emit_struct, first_sentence, ordered_structs, pascal, ref_name, snake) WS_SPEC = ROOT / "spec" / "asyncapi.yaml" @@ -41,6 +41,12 @@ UPDATE_PAYLOADS = ("updateSubscriptionCommandPayload", "cfbenchmarksUpdateSubscriptionCommandPayload", "pythUpdateSubscriptionCommandPayload") +# The envelope fields Update carries. Any other field on a data message would be dropped. +ENVELOPE_FIELDS = {"type", "sid", "seq", "id", "msg"} + +# Keywords that describe a parameter without changing how it is sent. +PARAM_NOTES = {"description", "example", "examples"} + @dataclass class DataMessage: @@ -71,6 +77,14 @@ def __init__(self, spec: dict, rest_enums: dict[str, EnumType]) -> None: # ----- types ----------------------------------------------------------- def run(self) -> None: + # Only these payloads feed SubscribeParams and UpdateSubscriptionParams, so a + # new command variant would otherwise be ignored. + for cmd, expected in (("subscribe", {"subscribeCommandPayload"}), ("update_subscription", set(UPDATE_PAYLOADS))): + found = {name for name, schema in self.schemas.items() + if schema.get("properties", {}).get("cmd", {}).get("const") == cmd} + if found != expected: + raise SystemExit(f"{cmd} command payloads are {sorted(found)}; expected {sorted(expected)}. " + "Update UPDATE_PAYLOADS in tools/codegen/ws.py") subscribe = self.schemas["subscribeCommandPayload"]["properties"]["params"] self.channels = list(subscribe["properties"]["channels"]["items"]["enum"]) self.enums["Channel"] = EnumType("Channel", self.channels, "A WebSocket channel.") @@ -99,9 +113,11 @@ def data_message(self, key: str, channel: str) -> None: return spec = self.message_specs[key] payload = self.schemas[ref_name(spec["payload"]["$ref"])] + if extra := sorted(set(payload["properties"]) - ENVELOPE_FIELDS): + raise SystemExit(f"{key}: envelope fields {extra} are not supported; Update would drop them") type_value = payload["properties"]["type"]["const"] name = pascal(key) - self.struct_for(name, payload["properties"]["msg"], doc=spec.get("summary") or spec.get("title")) + self.struct_for(name, self.message_body(key, payload), doc=spec.get("summary") or spec.get("title")) self.messages.append(DataMessage(key, type_value, name, first_sentence(spec.get("summary")), [channel], examples=[e["payload"] for e in spec.get("examples", [])])) @@ -115,7 +131,7 @@ def discriminate(self) -> None: defaults = [] for message in group: payload = self.schemas[ref_name(self.message_specs[message.key]["payload"]["$ref"])] - props = payload["properties"]["msg"].get("properties", {}) + props = self.message_body(message.key, payload)["properties"] const = next(((k, v["const"]) for k, v in props.items() if "const" in v), None) message.discriminator = const if const is None: @@ -129,7 +145,17 @@ def discriminate(self) -> None: group.sort(key=lambda m: m.discriminator is None) self.messages = [m for m in self.messages if m not in group] + group - def resolve(self, schema: dict) -> tuple[str | None, dict]: + def message_body(self, key: str, payload: dict) -> dict: + """The `msg` schema of a data message, which must be an object with properties.""" + _, msg = self.resolve(payload["properties"]["msg"], f"{key}.msg") + check_schema(msg, f"{key}.msg", max_all_of=0) + if msg.get("type") != "object" or not msg.get("properties"): + raise SystemExit(f"{key}.msg must be an object with properties; it would become an empty struct") + return msg + + def resolve(self, schema: dict, where: str = "schema") -> tuple[str | None, dict]: + # resolve() does not unwrap allOf, so any allOf would be dropped. + check_schema(schema, where, max_all_of=0) if "$ref" not in schema: return None, schema name = ref_name(schema["$ref"]) @@ -139,7 +165,8 @@ def resolve(self, schema: dict) -> tuple[str | None, dict]: def type_of(self, schema: dict, context: str, prop: str) -> tuple[str, str, str, bool]: """Returns (cpp type, kind, element name, nullable).""" - name, schema = self.resolve(schema) + name, schema = self.resolve(schema, context) + check_schema(schema, context, max_all_of=0) kind = schema.get("type") nullable = False if isinstance(kind, list): @@ -151,7 +178,9 @@ def type_of(self, schema: dict, context: str, prop: str) -> tuple[str, str, str, enum = self.enum_for(values, name, context, prop, schema.get("description", "")) return enum, "enum", enum.rsplit("::", 1)[-1], nullable if kind == "array": - inner, inner_kind, element, _ = self.type_of(schema.get("items", {}), context + "Item", prop) + if "items" not in schema: + raise SystemExit(f"{context}: an array without `items` is not supported") + inner, inner_kind, element, _ = self.type_of(schema["items"], context + "Item", prop) size = schema.get("minItems") if size is not None and size > 1 and size == schema.get("maxItems"): return f"std::array<{inner}, {size}>", "container", "", nullable @@ -168,9 +197,15 @@ def type_of(self, schema: dict, context: str, prop: str) -> tuple[str, str, str, return "double", "scalar", "", nullable if kind == "boolean": return "bool", "scalar", "", nullable + if kind is None: + raise SystemExit(f"{context}: a schema with no type is not supported; it would become std::string") + if kind != "string": + raise SystemExit(f"{context}: type {kind!r} is not supported") return "std::string", "string", "", nullable def enum_for(self, values: list[str], schema_name: str | None, context: str, prop: str, doc: str) -> str: + if not all(isinstance(v, str) for v in values): + raise SystemExit(f"{context}: only string enums are supported; found {values}") known = frozenset(v for v in values if v != UNKNOWN_VALUE) # `context` is the owner plus the property (TradeTakerSide), so a generic # property such as `status` still gets a specific name. @@ -201,8 +236,7 @@ def enum_for(self, values: list[str], schema_name: str | None, context: str, pro def member(self, owner: str, json_name: str, schema: dict, required: bool) -> Member: cpp, kind, element, nullable = self.type_of(schema, owner + pascal(json_name), json_name) - if json_name in CPP_RESERVED: - raise SystemExit(f"{owner}.{json_name} needs a rename") + check_identifier(owner, json_name) plain = required and not nullable _, resolved = self.resolve(schema) return Member(json_name=json_name, cpp_type=cpp if plain else f"std::optional<{cpp}>", @@ -212,6 +246,11 @@ def member(self, owner: str, json_name: str, schema: dict, required: bool) -> Me def struct_for(self, name: str, schema: dict, doc: str | None = None) -> StructType: if name in self.structs: return self.structs[name] + _, schema = self.resolve(schema, name) + check_schema(schema, name, max_all_of=0) + if schema.get("additionalProperties", False) is not False and schema.get("properties"): + raise SystemExit(f"{name}: properties with additionalProperties are not supported; " + "the extra keys would be dropped") struct = StructType(name, doc=first_sentence(doc or schema.get("description"))) self.structs[name] = struct required = set(schema.get("required", [])) @@ -224,20 +263,30 @@ def struct_for(self, name: str, schema: dict, doc: str | None = None) -> StructT def params_struct(self, name: str, schemas: list[dict], skip: set[str], doc: str) -> StructType: """Merges command parameter schemas into one struct of options.""" props: dict[str, dict] = {} - required: set[str] = set() - for schema in schemas: - required |= set(schema.get("required", [])) - for key, prop in schema.get("properties", {}).items(): + resolved = [self.resolve(schema, name)[1] for schema in schemas] + # The merged struct is a bag of options, so a key is required only when + # every command variant requires it. + required: set[str] = set.intersection(*(set(schema.get("required", [])) for schema in resolved)) + for schema in resolved: + check_schema(schema, name, max_all_of=0) + if not schema.get("properties"): + raise SystemExit(f"{name}: a command params schema needs `properties`") + for key, prop in schema["properties"].items(): if key in skip: continue if key in props and "enum" in prop: # e.g. every update_subscription action merged = props[key].get("enum", []) + [v for v in prop["enum"] if v not in props[key].get("enum", [])] props[key] = dict(props[key], enum=merged) + elif key in props: + shape = {k: v for k, v in prop.items() if k not in PARAM_NOTES} + if shape != {k: v for k, v in props[key].items() if k not in PARAM_NOTES}: + raise SystemExit(f"{name}.{key} has different schemas across commands") else: - props.setdefault(key, prop) + props[key] = prop struct = StructType(name, doc=doc) self.structs[name] = struct for key, prop in props.items(): + check_identifier(name, key) context = "UpdateAction" if name == "UpdateSubscriptionParams" and key == "action" else name + pascal(key) cpp, kind, element, _ = self.type_of(prop, context, "" if key == "action" else key) plain = key in required