From 3af88b87414202cffbffcbb4f1bbbc45511cba27 Mon Sep 17 00:00:00 2001 From: Alejandro Rosas Date: Sat, 7 Nov 2026 07:34:01 +0000 Subject: [PATCH] test(spec): two union cases in the shared breaking-change vectors The last commit of the vectors pull request (#26) was pushed after it merged, so main kept 73 cases while sdk-node, sdk-go, sdk-java, sdk-php and sdk-dotnet copied the 75-case file from its branch. This lands that commit: the cases union-required-field-no-longer-required and union-variant-numbered-with-its-null-variant, the generator entries and the detector change they need. openapi/breaking-vectors.json now has the same bytes as the other repositories (SHA-256 5c9a66000f9743f9...). Co-Authored-By: Claude Opus 5.5 --- codegen/breaking_vectors.py | 23 ++- codegen/spec_sync.py | 33 +++- codegen/tests/test_spec_sync.py | 21 ++ openapi/breaking-vectors.json | 338 ++++++++++++++++++++++++++++++++ 4 files changed, 410 insertions(+), 5 deletions(-) diff --git a/codegen/breaking_vectors.py b/codegen/breaking_vectors.py index e5bb8b4..6fe9f2e 100644 --- a/codegen/breaking_vectors.py +++ b/codegen/breaking_vectors.py @@ -31,7 +31,8 @@ - ````: ``response`` (any 2xx body), ``request body``, ``query``, ``path`` or ``header``. - ````: a dotted path; ``[]`` is an array's items, ``{}`` a map's values, and - ``(oneOf N)`` or ``(anyOf N)`` the Nth variant (from 1) of the union after the change. + ``(oneOf N)`` or ``(anyOf N)`` the Nth variant (from 1, a ``null`` variant counted) of + the union after the change. A field the union itself requires is required in each variant. A parameter's field starts with its name (``ids[]``). A whole body has no ```` (``GET /things/ response: can now be null``). - ````: ``removed``, ``type -> ``, ``can now be null``, ``no longer @@ -92,6 +93,7 @@ STRING: Json = {"type": "string"} INTEGER: Json = {"type": "integer"} +NULL: Json = {"type": "null"} def document(paths: Json, schemas: Json = None) -> Json: @@ -699,6 +701,8 @@ def _unions() -> list[Json]: payment = "GET /payments/{id}/ response" bank = edit(BANK, iban=INTEGER) titled = {**CARD, "title": "Card payment"} + # Each variant leaves ``kind`` to the union, which requires it. + loose = {"oneOf": [requiring(CARD, "last4"), requiring(BANK, "iban")]} by_email = obj({"email": STRING}, required=("email",)) by_phone = obj({"phone": STRING, "country_code": STRING}, required=("phone",)) contact = document( @@ -767,6 +771,23 @@ def _unions() -> list[Json]: contact_after, ["POST /contacts/ request body (anyOf 2).country_code: removed"], ), + case( + "union-required-field-no-longer-required", + rule, + get_payment({**loose, "required": ["kind"]}), + get_payment(loose), + [ + f"{payment} (oneOf 1).kind: no longer required", + f"{payment} (oneOf 2).kind: no longer required", + ], + ), + case( + "union-variant-numbered-with-its-null-variant", + rule, + get_payment({"anyOf": [NULL, CARD, BANK]}), + get_payment({"anyOf": [NULL, edit(CARD, last4=INTEGER), BANK]}), + [f"{payment} (anyOf 2).last4: type string -> integer"], + ), case( "union-variant-added-is-listed", rule, diff --git a/codegen/spec_sync.py b/codegen/spec_sync.py index b4758da..a32de0b 100644 --- a/codegen/spec_sync.py +++ b/codegen/spec_sync.py @@ -300,6 +300,27 @@ def _variants(schema: Json, keyword: str | None = None) -> list[Json]: return [v for k in keywords for v in schema.get(k) or [] if not _null_only(v)] +def _positions(schema: Json) -> list[str]: + """Each of ``_variants(schema)`` as the vectors number it: its keyword and its position + from 1 in that keyword's list, a null variant included (``anyOf 2`` in ``[null, X, Y]``).""" + return [ + f"{k} {n}" + for k in ("oneOf", "anyOf") + for n, v in enumerate(schema.get(k) or [], 1) + if not _null_only(v) + ] + + +def _with_required(variant: Json, union: Json) -> Json: + """A union's variant with the fields the union itself requires, which JSON Schema + applies to every variant: dropping one from the union drops it from each variant.""" + inherited = {name for name in union.get("required") or [] if isinstance(name, str)} + if not inherited or not isinstance(variant, dict): + return variant + own = {name for name in variant.get("required") or [] if isinstance(name, str)} + return {**variant, "required": sorted(own | inherited)} + + def _nullable(schema: Json, depth: int = 0) -> bool: """Whether ``schema`` accepts null: a null type, ``nullable``, null in its enum, or a null or nullable oneOf/anyOf variant.""" @@ -339,8 +360,8 @@ def _single(schema: Json, depth: int = 0) -> Json: variants = _variants(schema) if len(variants) != 1 or not isinstance(variants[0], dict): return schema - rest = {k: v for k, v in schema.items() if k not in ("oneOf", "anyOf")} - return _single({**variants[0], **rest}, depth + 1) + rest = {k: v for k, v in schema.items() if k not in ("oneOf", "anyOf", "required")} + return _single({**_with_required(variants[0], schema), **rest}, depth + 1) def _json_type(value: Json) -> str: @@ -608,8 +629,12 @@ def _diff_variants( keyword = _union_keyword(new) or _union_keyword(old) pairs, removed, added = _pair_variants(old_vars, new_vars) for i, j in pairs: - at = f"{path}({keyword} {j + 1})" if len(new_vars) > 1 else path - diff_schema(old_vars[i], new_vars[j], where, at, request=request, out=out, depth=depth + 1) + at = f"{path}({_positions(new)[j]})" if len(new_vars) > 1 else path + # The fields the union requires are required in each variant too. + diff_schema( + _with_required(old_vars[i], old), _with_required(new_vars[j], new), where, at, + request=request, out=out, depth=depth + 1, + ) # fmt: skip before, after = ( _variant_type_name(v) or "a numbered name" for v in (old_vars[i], new_vars[j]) ) diff --git a/codegen/tests/test_spec_sync.py b/codegen/tests/test_spec_sync.py index 09579f9..3fcf28f 100644 --- a/codegen/tests/test_spec_sync.py +++ b/codegen/tests/test_spec_sync.py @@ -334,6 +334,27 @@ def test_union_variants_are_compared_with_the_variant_they_became() -> None: ] # Variants in another order are the same union. assert _diff({"oneOf": [a, b]}, {"oneOf": [b, a]}) == [("", "oneOf variants reordered", False)] + # A variant keeps its position when a null variant comes before it. + assert _diff({"anyOf": [{"type": "null"}, a, b]}, {"anyOf": [{"type": "null"}, a, b2]}) == [ + ("(anyOf 3).code", retyped, True) + ] + + +def test_a_field_the_union_requires_is_required_in_each_variant() -> None: + a: dict[str, Any] = {"type": "object", "properties": {"kind": STATUS, "code": STATUS}} + b: dict[str, Any] = {"type": "object", "properties": {"kind": STATUS, "url": STATUS}} + gone = "no longer always present" + assert _diff({"oneOf": [a, b], "required": ["kind"]}, {"oneOf": [a, b]}) == [ + ("(oneOf 1).kind", gone, True), + ("(oneOf 2).kind", gone, True), + ] + assert _diff({"oneOf": [a, b]}, {"oneOf": [a, b], "required": ["kind"]}, request=True) == [ + ("(oneOf 1).kind", "now required", True), + ("(oneOf 2).kind", "now required", True), + ] + # One variant and null: the union's list adds to the variant's own, never replaces it. + optional = {"anyOf": [{**a, "required": ["code"]}, {"type": "null"}], "required": ["kind"]} + assert _diff(optional, {**optional, "anyOf": [a, {"type": "null"}]}) == [("code", gone, True)] def test_a_variant_title_names_its_generated_type() -> None: diff --git a/openapi/breaking-vectors.json b/openapi/breaking-vectors.json index e5c4d9c..02d0c8b 100644 --- a/openapi/breaking-vectors.json +++ b/openapi/breaking-vectors.json @@ -8161,6 +8161,344 @@ ] } }, + { + "name": "union-required-field-no-longer-required", + "rule": "Unions", + "before": { + "openapi": "3.1.0", + "info": { + "title": "Breaking-change vectors", + "version": "1.0.0" + }, + "paths": { + "/payments/{id}/": { + "get": { + "x-didit-sdk": { + "resource": "payments", + "method": "retrieve", + "auth": "api_key", + "server": true + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "card" + ] + }, + "last4": { + "type": "string" + } + }, + "required": [ + "last4" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "bank" + ] + }, + "iban": { + "type": "string" + } + }, + "required": [ + "iban" + ] + } + ], + "required": [ + "kind" + ] + } + } + } + } + } + } + } + } + }, + "after": { + "openapi": "3.1.0", + "info": { + "title": "Breaking-change vectors", + "version": "1.0.0" + }, + "paths": { + "/payments/{id}/": { + "get": { + "x-didit-sdk": { + "resource": "payments", + "method": "retrieve", + "auth": "api_key", + "server": true + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "card" + ] + }, + "last4": { + "type": "string" + } + }, + "required": [ + "last4" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "bank" + ] + }, + "iban": { + "type": "string" + } + }, + "required": [ + "iban" + ] + } + ] + } + } + } + } + } + } + } + } + }, + "expect": { + "type": "feat(spec)", + "breaking": [ + "GET /payments/{id}/ response (oneOf 1).kind: no longer required", + "GET /payments/{id}/ response (oneOf 2).kind: no longer required" + ] + } + }, + { + "name": "union-variant-numbered-with-its-null-variant", + "rule": "Unions", + "before": { + "openapi": "3.1.0", + "info": { + "title": "Breaking-change vectors", + "version": "1.0.0" + }, + "paths": { + "/payments/{id}/": { + "get": { + "x-didit-sdk": { + "resource": "payments", + "method": "retrieve", + "auth": "api_key", + "server": true + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "card" + ] + }, + "last4": { + "type": "string" + } + }, + "required": [ + "kind", + "last4" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "bank" + ] + }, + "iban": { + "type": "string" + } + }, + "required": [ + "kind", + "iban" + ] + } + ] + } + } + } + } + } + } + } + } + }, + "after": { + "openapi": "3.1.0", + "info": { + "title": "Breaking-change vectors", + "version": "1.0.0" + }, + "paths": { + "/payments/{id}/": { + "get": { + "x-didit-sdk": { + "resource": "payments", + "method": "retrieve", + "auth": "api_key", + "server": true + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "card" + ] + }, + "last4": { + "type": "integer" + } + }, + "required": [ + "kind", + "last4" + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "bank" + ] + }, + "iban": { + "type": "string" + } + }, + "required": [ + "kind", + "iban" + ] + } + ] + } + } + } + } + } + } + } + } + }, + "expect": { + "type": "feat(spec)", + "breaking": [ + "GET /payments/{id}/ response (anyOf 2).last4: type string -> integer" + ] + } + }, { "name": "union-variant-added-is-listed", "rule": "Unions",