Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion codegen/breaking_vectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
- ``<where>``: ``response`` (any 2xx body), ``request body``, ``query``, ``path`` or
``header``.
- ``<field>``: 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 ``<field>``
(``GET /things/ response: can now be null``).
- ``<kind>``: ``removed``, ``type <old> -> <new>``, ``can now be null``, ``no longer
Expand Down Expand Up @@ -92,6 +93,7 @@

STRING: Json = {"type": "string"}
INTEGER: Json = {"type": "integer"}
NULL: Json = {"type": "null"}


def document(paths: Json, schemas: Json = None) -> Json:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
33 changes: 29 additions & 4 deletions codegen/spec_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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])
)
Expand Down
21 changes: 21 additions & 0 deletions codegen/tests/test_spec_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading