From dae0019541ec963c02fad3c02b692260d7ce2b10 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 22:58:05 +0900 Subject: [PATCH 1/7] fix(spec): keep the stricter bound when folding exclusive bounds for 3.0 Preserve inclusive bounds that are tighter and make equal numeric bounds exclusive. Cover inline and component output and the Pydantic producer path. Refs #868 Co-authored-by: sisyphus-dev-ai --- .../_schema_version_conversion.py | 9 ++- .../test_spec_conversion_characterization.py | 62 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/azure_functions_openapi/_schema_version_conversion.py b/src/azure_functions_openapi/_schema_version_conversion.py index 63fdc007..1b870107 100644 --- a/src/azure_functions_openapi/_schema_version_conversion.py +++ b/src/azure_functions_openapi/_schema_version_conversion.py @@ -461,8 +461,13 @@ def _convert_schema_to_3_0( ): exclusive_bound = result.get(exclusive_key) if isinstance(exclusive_bound, (int, float)) and not isinstance(exclusive_bound, bool): - result[bound_key] = exclusive_bound - result[exclusive_key] = True + bound = result.get(bound_key, exclusive_bound) + tighter = _tighter_bound(bound_key, bound, exclusive_bound) + if tighter == exclusive_bound: + result[bound_key] = exclusive_bound + result[exclusive_key] = True + else: + del result[exclusive_key] # ``type: [T, "null"]`` -> ``type: T`` + ``nullable: true``. OpenAPI 3.0 # requires ``type`` to be a single string, so a multi-type union is expressed diff --git a/tests/test_spec_conversion_characterization.py b/tests/test_spec_conversion_characterization.py index ec1113ad..ce052e8d 100644 --- a/tests/test_spec_conversion_characterization.py +++ b/tests/test_spec_conversion_characterization.py @@ -10,6 +10,7 @@ import pytest from azure_functions_openapi._schema_version_conversion import ( + _convert_schema_to_3_0, _find_3_1_only_construct, _operation_schema_slots, ) @@ -32,6 +33,23 @@ @pytest.mark.parametrize( ("original", "expected_30", "expected_31", "valid", "invalid"), [ + *[ + ( + {"type": "number", bound: inclusive, exclusive: numeric}, + {"type": "number", bound: kept, **({exclusive: True} if strict else {})}, + {"type": "number", bound: inclusive, exclusive: numeric}, + valid, + invalid, + ) + for bound, exclusive, inclusive, numeric, kept, strict, valid, invalid in [ + ("minimum", "exclusiveMinimum", 5, 1, 5, False, [5, 6], [1, 2, 4.9]), + ("minimum", "exclusiveMinimum", 1, 5, 5, True, [5.1, 6], [1, 2, 5]), + ("minimum", "exclusiveMinimum", 5, 5, 5, True, [5.1, 6], [4, 5]), + ("maximum", "exclusiveMaximum", 1, 5, 1, False, [0, 1], [1.1, 3, 5]), + ("maximum", "exclusiveMaximum", 5, 1, 1, True, [0, 0.9], [1, 3, 5]), + ("maximum", "exclusiveMaximum", 1, 1, 1, True, [0, 0.9], [1, 2]), + ] + ], ( {"type": "string", "nullable": True, "example": "yes"}, {"type": "string", "nullable": True, "example": "yes"}, @@ -319,6 +337,12 @@ ), ], ids=[ + "inclusive-lower-wins", + "exclusive-lower-wins", + "equal-lower-is-exclusive", + "inclusive-upper-wins", + "exclusive-upper-wins", + "equal-upper-is-exclusive", "legacy-nullable", "nullable-union", "nested-keywords", @@ -729,6 +753,44 @@ class Payload(BaseModel): assert collect_spec_warnings(spec, registry) == () +def test_pydantic_overlapping_bounds_keep_the_stricter_constraint() -> None: + # Given: Pydantic emits both bounds, even when the inclusive bound is stricter. + class Payload(BaseModel): + value: int = Field(ge=5, gt=1) + + registry = OpenAPIRegistry() + register_openapi_metadata("/payload", "POST", request_model=Payload, registry=registry) + + # When + spec = generate_openapi_spec(registry=registry, openapi_version="3.0.0", strict=True) + + # Then: the generated component has the same acceptance boundary as the producer. + validate(spec) + original = OAS31Validator(Payload.model_json_schema()) + converted = OAS30Validator( + {"$ref": "#/components/schemas/Payload", "components": spec["components"]} + ) + probes = [{"value": n} for n in (1, 2, 3, 4, 5, 6)] + assert [converted.is_valid(p) for p in probes] == [original.is_valid(p) for p in probes] + assert collect_spec_warnings(spec, registry) == () + + +@pytest.mark.parametrize("exclusive", [False, True]) +def test_boolean_exclusive_bounds_are_already_openapi_3_0(exclusive: bool) -> None: + # Given: boolean exclusive bounds are modifiers, not numeric bounds. + schema = { + "type": "number", + "minimum": 1, + "maximum": 5, + "exclusiveMinimum": exclusive, + "exclusiveMaximum": exclusive, + } + # When + converted = _convert_schema_to_3_0(schema) + # Then + assert converted == schema + + @pytest.mark.parametrize( ("version", "header", "expected"), [ From eaa0b267116d8c7fcd506629d7098326b2917359 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 22:59:44 +0900 Subject: [PATCH 2/7] fix(spec): intersect enum and const when converting to 3.0 Reuse singleton intersection for null-only schemas and compare JSON values recursively without equating booleans and numbers. Preserve conflicting predicates in allOf. Refs #868 Co-authored-by: sisyphus-dev-ai --- .../_schema_version_conversion.py | 28 +++++++-- .../test_spec_conversion_characterization.py | 59 +++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/src/azure_functions_openapi/_schema_version_conversion.py b/src/azure_functions_openapi/_schema_version_conversion.py index 1b870107..00f6baad 100644 --- a/src/azure_functions_openapi/_schema_version_conversion.py +++ b/src/azure_functions_openapi/_schema_version_conversion.py @@ -393,12 +393,32 @@ def _keep_null_only(schema: dict[str, Any]) -> None: excludes it can be met by no instance, so the null-only predicate joins ``allOf`` and the two together still accept nothing. """ + _intersect_enum(schema, None) + + +def _json_schema_equal(first: Any, second: Any) -> bool: + """Compare JSON values without Python's boolean/number equivalence, including nested values.""" + if isinstance(first, bool) or isinstance(second, bool): + return type(first) is type(second) and first == second + if isinstance(first, list) and isinstance(second, list): + return len(first) == len(second) and all( + _json_schema_equal(a, b) for a, b in zip(first, second) + ) + if isinstance(first, dict) and isinstance(second, dict): + return first.keys() == second.keys() and all( + _json_schema_equal(value, second[key]) for key, value in first.items() + ) + return bool(first == second) + + +def _intersect_enum(schema: dict[str, Any], value: Any) -> None: + """Intersect a singleton with an existing enum, keeping contradictions as conjuncts.""" enum = schema.get("enum") - if isinstance(enum, list) and None not in enum: + if isinstance(enum, list) and not any(_json_schema_equal(value, member) for member in enum): all_of = schema.get("allOf") - schema["allOf"] = [*(all_of if isinstance(all_of, list) else []), {"enum": [None]}] + schema["allOf"] = [*(all_of if isinstance(all_of, list) else []), {"enum": [value]}] else: - schema["enum"] = [None] + schema["enum"] = [value] def _convert_schema_to_3_0( @@ -437,7 +457,7 @@ def _convert_schema_to_3_0( result = dict(schema) if "const" in result: - result["enum"] = [result.pop("const")] + _intersect_enum(result, result.pop("const")) examples = result.pop("examples", None) if isinstance(examples, list) and examples: diff --git a/tests/test_spec_conversion_characterization.py b/tests/test_spec_conversion_characterization.py index ce052e8d..80d6aa21 100644 --- a/tests/test_spec_conversion_characterization.py +++ b/tests/test_spec_conversion_characterization.py @@ -33,6 +33,53 @@ @pytest.mark.parametrize( ("original", "expected_30", "expected_31", "valid", "invalid"), [ + *[ + ( + {"enum": enum, "const": const}, + expected, + {"enum": enum, "const": const}, + valid, + invalid, + ) + for enum, const, expected, valid, invalid in [ + (["x", "y"], "x", {"enum": ["x"]}, ["x"], ["y", None]), + (["y"], "x", {"enum": ["y"], "allOf": [{"enum": ["x"]}]}, [], ["x", "y", None]), + ([1], True, {"enum": [1], "allOf": [{"enum": [True]}]}, [], [True, 1, None]), + ([True], 1, {"enum": [True], "allOf": [{"enum": [1]}]}, [], [True, 1, None]), + ([1], 1.0, {"enum": [1.0]}, [1, 1.0], [True, 2]), + ([None, "x"], None, {"enum": [None], "nullable": True}, [None], ["x", 0]), + ( + ["x"], + None, + {"enum": ["x"], "allOf": [{"enum": [None], "nullable": True}]}, + [], + ["x", None], + ), + ([[1]], [True], {"enum": [[1]], "allOf": [{"enum": [[True]]}]}, [], [[1], [True]]), + ( + [{"a": 1}], + {"a": True}, + {"enum": [{"a": 1}], "allOf": [{"enum": [{"a": True}]}]}, + [], + [{"a": 1}, {"a": True}], + ), + ([[1]], [1.0], {"enum": [[1.0]]}, [[1], [1.0]], [[], [True], [1, 2]]), + ( + [{"a": 1}], + {"a": 1.0}, + {"enum": [{"a": 1.0}]}, + [{"a": 1}], + [{}, {"b": 1}, {"a": True}], + ), + ] + ], + ( + {"enum": ["y"], "const": "x", "allOf": [{"type": "string"}]}, + {"enum": ["y"], "allOf": [{"type": "string"}, {"enum": ["x"]}]}, + {"enum": ["y"], "const": "x", "allOf": [{"type": "string"}]}, + [], + ["x", "y", None], + ), *[ ( {"type": "number", bound: inclusive, exclusive: numeric}, @@ -337,6 +384,18 @@ ), ], ids=[ + "enum-const-intersection", + "enum-const-contradiction", + "enum-number-const-bool", + "enum-bool-const-number", + "enum-const-numeric-equality", + "enum-const-null", + "enum-const-null-contradiction", + "enum-const-nested-list-bool", + "enum-const-nested-object-bool", + "enum-const-list-equality", + "enum-const-object-equality", + "enum-const-keeps-all-of", "inclusive-lower-wins", "exclusive-lower-wins", "equal-lower-is-exclusive", From efb5adec4fd503aaee9872c1d9c7b2be50e4a66b Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 23:01:35 +0900 Subject: [PATCH 3/7] fix(spec): preserve overlapping prefix and tail item constraints for 3.0 Cap closed prefixes at their position count and retain a tighter maxItems. Include schema tails when detecting homogeneous items or reporting the existing positional-loss approximation. Refs #868 Co-authored-by: sisyphus-dev-ai --- .../_schema_version_conversion.py | 14 +- .../test_spec_conversion_characterization.py | 130 ++++++++++++++++++ 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/src/azure_functions_openapi/_schema_version_conversion.py b/src/azure_functions_openapi/_schema_version_conversion.py index 00f6baad..0d978f21 100644 --- a/src/azure_functions_openapi/_schema_version_conversion.py +++ b/src/azure_functions_openapi/_schema_version_conversion.py @@ -547,16 +547,24 @@ def _convert_schema_to_3_0( if "items" in result: result["items"] = _convert_schema_to_3_0(result["items"], schemas, resolving, lossy) prefix_items = result.pop("prefixItems", None) - if isinstance(prefix_items, list) and prefix_items: + if isinstance(prefix_items, list): + additional_items = result.get("items") + if additional_items is False: + result["maxItems"] = min(result.get("maxItems", len(prefix_items)), len(prefix_items)) + result["items"] = {} converted_items = [ _convert_schema_to_3_0(item, schemas, resolving, lossy) for item in prefix_items ] - if all(item == converted_items[0] for item in converted_items[1:]): + if isinstance(additional_items, dict): + converted_items.append(additional_items) + if converted_items and all( + _json_schema_equal(item, converted_items[0]) for item in converted_items[1:] + ): # Every position shares the identical schema: a plain ``items`` # array faithfully represents it, with nothing position-specific # to lose. result["items"] = converted_items[0] - else: + elif converted_items: # A heterogeneous tuple has no OpenAPI-3.0-native representation: # ``oneOf`` looks closest to the original intent, but JSON # Schema's "integer" is a subset of "number", so a value valid at diff --git a/tests/test_spec_conversion_characterization.py b/tests/test_spec_conversion_characterization.py index 80d6aa21..960529a9 100644 --- a/tests/test_spec_conversion_characterization.py +++ b/tests/test_spec_conversion_characterization.py @@ -33,6 +33,64 @@ @pytest.mark.parametrize( ("original", "expected_30", "expected_31", "valid", "invalid"), [ + *[ + ( + {"type": "array", "prefixItems": prefix, "items": items, **extra}, + {"type": "array", "items": expected_items, **expected_extra}, + {"type": "array", "prefixItems": prefix, "items": items, **extra}, + valid, + invalid, + ) + for prefix, items, extra, expected_items, expected_extra, valid, invalid in [ + ( + [{"type": "string"}], + False, + {}, + {"type": "string"}, + {"maxItems": 1}, + [[], ["a"]], + [["a", "b"], [1]], + ), + ( + [{"type": "string"}] * 2, + False, + {}, + {"type": "string"}, + {"maxItems": 2}, + [[], ["a"], ["a", "b"]], + [["a"] * 3, [1]], + ), + ( + [{"type": "string"}] * 2, + False, + {"maxItems": 1}, + {"type": "string"}, + {"maxItems": 1}, + [[], ["a"]], + [["a", "b"], [1]], + ), + ( + [{"type": "string"}], + False, + {"maxItems": 5}, + {"type": "string"}, + {"maxItems": 1}, + [[], ["a"]], + [["a", "b"], [1]], + ), + ( + [{"type": "string"}], + {"type": "string"}, + {}, + {"type": "string"}, + {}, + [[], ["a"], ["a", "b"]], + [[1], ["a", 1]], + ), + ([], False, {}, {}, {"maxItems": 0}, [[]], [["a"], [1]]), + ([], {"type": "string"}, {}, {"type": "string"}, {}, [[], ["a"]], [[1]]), + ] + ], *[ ( {"enum": enum, "const": const}, @@ -384,6 +442,13 @@ ), ], ids=[ + "closed-single-prefix", + "closed-homogeneous-prefix", + "closed-prefix-tighter-max", + "closed-prefix-looser-max", + "prefix-matches-additional-items", + "closed-empty-prefix", + "empty-prefix-additional-items", "enum-const-intersection", "enum-const-contradiction", "enum-number-const-bool", @@ -549,6 +614,59 @@ def test_heterogeneous_tuple_strict_exception_is_repeatable() -> None: ) +@pytest.mark.parametrize("version", VERSIONS) +@pytest.mark.parametrize("hoisted", [False, True], ids=["inline", "component"]) +@pytest.mark.parametrize("strict", [False, True]) +@pytest.mark.parametrize("closed", [False, True], ids=["schema-tail", "closed"]) +def test_prefix_items_overlap_reports_positional_loss( + version: str, hoisted: bool, strict: bool, closed: bool +) -> None: + # Given: the tail differs from the prefix, or the closed prefix is heterogeneous. + members = [{"type": "integer"}, {"type": "number"}] + schema = { + "type": "array", + "prefixItems": members if closed else members[:1], + "items": False if closed else members[1], + } + body = {"$ref": "#/$defs/Pair", "$defs": {"Pair": schema}} if hoisted else schema + registry = OpenAPIRegistry() + registry.set("pair", {"route": "pair", "method": "post", "request_body": body}) + message = ("Schema 'Pair': " if hoisted else "") + TUPLE_LOSS + + # When / Then: strict conversion refuses the approximation through the existing channel. + if strict and version == "3.0.0": + with pytest.raises(OpenAPISpecConfigError) as caught: + generate_openapi_spec(registry=registry, openapi_version=version, strict=True) + assert message in str(caught.value) + return + + # When + spec = generate_openapi_spec(registry=registry, openapi_version=version, strict=strict) + + # Then: the tail is retained in the approximation and closed tuples keep their cap. + validate(spec) + actual = spec["paths"]["/api/pair"]["post"]["requestBody"]["content"]["application/json"][ + "schema" + ] + if hoisted: + actual = spec["components"]["schemas"]["Pair"] + if version != "3.0.0": + assert actual == schema + assert collect_spec_warnings(spec, registry) == () + return + assert actual == { + "type": "array", + "items": {"anyOf": members}, + **({"maxItems": 2} if closed else {}), + } + (warning,) = collect_spec_warnings(spec, registry) + assert warning.code is WarningCode.VERSION_DOWNGRADE_DROP + assert warning.message == message + assert OAS30Validator(actual).is_valid([2.5]) + assert not OAS31Validator(schema).is_valid([2.5]) + assert OAS30Validator(actual).is_valid([1, 2, 3]) is not closed + + @pytest.mark.parametrize("version", VERSIONS) @pytest.mark.parametrize("strict", [False, True]) def test_mutual_nullable_reference_cycle(version: str, strict: bool) -> None: @@ -850,6 +968,18 @@ def test_boolean_exclusive_bounds_are_already_openapi_3_0(exclusive: bool) -> No assert converted == schema +@pytest.mark.parametrize("items", [{}, {"items": True}]) +def test_empty_prefix_without_tail_constraints_stays_unconstrained( + items: dict[str, JsonValue], +) -> None: + # Given: an empty prefix adds no positional constraints. + schema = {"type": "array", "prefixItems": [], **items} + # When + converted = _convert_schema_to_3_0(schema) + # Then + assert converted == {"type": "array", **items} + + @pytest.mark.parametrize( ("version", "header", "expected"), [ From f4855639c0581e0b3c62123140734f744638d108 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 23:04:32 +0900 Subject: [PATCH 4/7] docs(spec): describe overlapping keyword conversion in 3.0 mode Document stricter numeric bounds, enum/const intersection, closed-prefix caps and positional-loss reporting for differing schema tails. Refs #868 Co-authored-by: sisyphus-dev-ai --- docs/configuration.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index fa66e013..27ba13e4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -253,6 +253,12 @@ reject every value. An `enum` or `const` beside it is intersected with that: a list that includes `null` narrows to it, and one that excludes `null` leaves a schema that accepts nothing, as the original does. +Numeric exclusive bounds become boolean modifiers of `minimum` or `maximum`. +When both forms are present, conversion keeps the stricter bound; equal bounds +stay exclusive. An `enum` beside `const` is intersected using JSON Schema equality +(booleans are distinct from numbers). A conflicting pair remains unsatisfiable +by retaining both predicates with `allOf`. + A heterogeneous `tuple[...]` (differing item types per position) has no positional representation in OpenAPI 3.0 at all: it is approximated as `items.anyOf`, which accepts the right set of values but can no longer @@ -264,6 +270,13 @@ the CLI's `--fail-on-warnings`). A tuple whose positions all share the identical type (e.g. `tuple[int, int]`) is unaffected — a plain `items` schema already describes it faithfully. +For `prefixItems` with `items: false`, conversion caps `maxItems` at the prefix +length, retaining a tighter existing cap. When `items` is a schema, it also +constrains additional positions: plain `items` is exact when every prefix schema +matches that tail schema. Otherwise, the tail joins `items.anyOf` and the same +positional-loss warning (or strict error) applies. Absent or `true` tail handling +is unchanged. + A nullable field that refers back to its own model, directly or through another model (e.g. a tree/linked-list node whose "next" is `Optional[SameModel]`), has one level of the self-reference inlined so the From 0bb54f7650af46384e10cd78eb3b91cfe984eeb5 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 23:47:24 +0900 Subject: [PATCH 5/7] fix(spec): emit a valid schema for an enum that accepts nothing in 3.0 OpenAPI 3.0 requires a non-empty enum, so an empty enum next to a const (or a null-only type) is replaced by an allOf member of not: {}, which rejects every instance and is valid in 3.0. The empty-prefix test no longer pins an items: true output, which OpenAPI 3.0 does not allow; that conversion is unchanged and belongs to a separate fix. Refs #868 Co-authored-by: sisyphus-dev-ai --- docs/configuration.md | 3 +- .../_schema_version_conversion.py | 19 ++++++-- .../test_spec_conversion_characterization.py | 47 ++++++++++++++++--- 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 27ba13e4..f1fa781e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -257,7 +257,8 @@ Numeric exclusive bounds become boolean modifiers of `minimum` or `maximum`. When both forms are present, conversion keeps the stricter bound; equal bounds stay exclusive. An `enum` beside `const` is intersected using JSON Schema equality (booleans are distinct from numbers). A conflicting pair remains unsatisfiable -by retaining both predicates with `allOf`. +by retaining both predicates with `allOf`; an empty `enum`, which OpenAPI 3.0 does +not allow, becomes `not: {}` instead. A heterogeneous `tuple[...]` (differing item types per position) has no positional representation in OpenAPI 3.0 at all: it is approximated as diff --git a/src/azure_functions_openapi/_schema_version_conversion.py b/src/azure_functions_openapi/_schema_version_conversion.py index 0d978f21..f00ae58c 100644 --- a/src/azure_functions_openapi/_schema_version_conversion.py +++ b/src/azure_functions_openapi/_schema_version_conversion.py @@ -412,13 +412,22 @@ def _json_schema_equal(first: Any, second: Any) -> bool: def _intersect_enum(schema: dict[str, Any], value: Any) -> None: - """Intersect a singleton with an existing enum, keeping contradictions as conjuncts.""" + """Intersect a singleton with an existing enum, keeping contradictions as conjuncts. + + OpenAPI 3.0 requires a non-empty ``enum``, so an empty one, which accepts nothing, is + replaced by ``not: {}``, the 3.0 schema that rejects every instance. + """ enum = schema.get("enum") - if isinstance(enum, list) and not any(_json_schema_equal(value, member) for member in enum): - all_of = schema.get("allOf") - schema["allOf"] = [*(all_of if isinstance(all_of, list) else []), {"enum": [value]}] - else: + if not isinstance(enum, list) or any(_json_schema_equal(value, member) for member in enum): schema["enum"] = [value] + return + if enum: + conjunct: dict[str, Any] = {"enum": [value]} + else: + del schema["enum"] + conjunct = {"not": {}} + all_of = schema.get("allOf") + schema["allOf"] = [*(all_of if isinstance(all_of, list) else []), conjunct] def _convert_schema_to_3_0( diff --git a/tests/test_spec_conversion_characterization.py b/tests/test_spec_conversion_characterization.py index 960529a9..9da71a81 100644 --- a/tests/test_spec_conversion_characterization.py +++ b/tests/test_spec_conversion_characterization.py @@ -106,6 +106,7 @@ ([True], 1, {"enum": [True], "allOf": [{"enum": [1]}]}, [], [True, 1, None]), ([1], 1.0, {"enum": [1.0]}, [1, 1.0], [True, 2]), ([None, "x"], None, {"enum": [None], "nullable": True}, [None], ["x", 0]), + ([], 1, {"allOf": [{"not": {}}]}, [], [1, None, "x", {}]), ( ["x"], None, @@ -455,6 +456,7 @@ "enum-bool-const-number", "enum-const-numeric-equality", "enum-const-null", + "empty-enum-const", "enum-const-null-contradiction", "enum-const-nested-list-bool", "enum-const-nested-object-bool", @@ -930,6 +932,42 @@ class Payload(BaseModel): assert collect_spec_warnings(spec, registry) == () +@pytest.mark.parametrize("hoisted", [False, True], ids=["inline", "component"]) +@pytest.mark.parametrize("strict", [False, True]) +@pytest.mark.parametrize( + "unsatisfiable", + [{"enum": [], "const": 1}, {"type": "null", "enum": []}, {"type": ["null"], "enum": []}], + ids=["empty-enum-const", "null-type-empty-enum", "null-type-array-empty-enum"], +) +def test_unsatisfiable_enum_stays_a_valid_openapi_3_0_document( + unsatisfiable: dict[str, JsonValue], hoisted: bool, strict: bool +) -> None: + # Given: an empty ``enum``, which accepts nothing but is not allowed by OpenAPI 3.0. + body = ( + {"$ref": "#/$defs/Never", "$defs": {"Never": unsatisfiable}} if hoisted else unsatisfiable + ) + registry = OpenAPIRegistry() + registry.set("never", {"route": "never", "method": "post", "request_body": body}) + + # When + spec = generate_openapi_spec( + registry=registry, openapi_version="3.0.0", hoist_flat_schemas=hoisted, strict=strict + ) + + # Then: the document validates, nothing is reported, and no instance is accepted. + validate(spec) + assert collect_spec_warnings(spec, registry) == () + if hoisted: + root = {"$ref": "#/components/schemas/Never", "components": spec["components"]} + else: + root = spec["paths"]["/api/never"]["post"]["requestBody"]["content"]["application/json"][ + "schema" + ] + validator = OAS30Validator(root) + probes: list[JsonValue] = [None, 1, "x", True, [], {}] + assert not any(validator.is_valid(probe) for probe in probes) + + def test_pydantic_overlapping_bounds_keep_the_stricter_constraint() -> None: # Given: Pydantic emits both bounds, even when the inclusive bound is stricter. class Payload(BaseModel): @@ -968,16 +1006,13 @@ def test_boolean_exclusive_bounds_are_already_openapi_3_0(exclusive: bool) -> No assert converted == schema -@pytest.mark.parametrize("items", [{}, {"items": True}]) -def test_empty_prefix_without_tail_constraints_stays_unconstrained( - items: dict[str, JsonValue], -) -> None: +def test_empty_prefix_without_a_tail_stays_unconstrained() -> None: # Given: an empty prefix adds no positional constraints. - schema = {"type": "array", "prefixItems": [], **items} + schema = {"type": "array", "prefixItems": []} # When converted = _convert_schema_to_3_0(schema) # Then - assert converted == {"type": "array", **items} + assert converted == {"type": "array"} @pytest.mark.parametrize( From 2f074c275b00df519397c8105b154d22cd5c8e04 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Sat, 31 Oct 2026 00:16:50 +0900 Subject: [PATCH 6/7] fix(spec): ignore an unreachable prefixItems tail when converting for 3.0 A tail schema beside prefixItems only constrains arrays that can hold more items than the prefix. With a maxItems at or below the prefix length it is unreachable, so appending it widened the positions the prefix does constrain (a string prefix with a number tail accepted [1]) and, being heterogeneous, was reported as a positional loss. The tail now joins the conversion only when maxItems is absent or exceeds the prefix length. Refs #868 Co-authored-by: sisyphus-dev-ai --- docs/configuration.md | 5 +- .../_schema_version_conversion.py | 17 ++++- .../test_spec_conversion_characterization.py | 73 +++++++++++++++++++ 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index f1fa781e..3d7de5ed 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -275,8 +275,9 @@ For `prefixItems` with `items: false`, conversion caps `maxItems` at the prefix length, retaining a tighter existing cap. When `items` is a schema, it also constrains additional positions: plain `items` is exact when every prefix schema matches that tail schema. Otherwise, the tail joins `items.anyOf` and the same -positional-loss warning (or strict error) applies. Absent or `true` tail handling -is unchanged. +positional-loss warning (or strict error) applies. A tail that a `maxItems` at or +below the prefix length makes unreachable is ignored. Absent or `true` tail +handling is unchanged. A nullable field that refers back to its own model, directly or through another model (e.g. a tree/linked-list node whose "next" is diff --git a/src/azure_functions_openapi/_schema_version_conversion.py b/src/azure_functions_openapi/_schema_version_conversion.py index f00ae58c..d27a0bc5 100644 --- a/src/azure_functions_openapi/_schema_version_conversion.py +++ b/src/azure_functions_openapi/_schema_version_conversion.py @@ -558,13 +558,26 @@ def _convert_schema_to_3_0( prefix_items = result.pop("prefixItems", None) if isinstance(prefix_items, list): additional_items = result.get("items") + declared_cap = result.get("maxItems") + max_items = ( + declared_cap + if isinstance(declared_cap, (int, float)) and not isinstance(declared_cap, bool) + else None + ) if additional_items is False: - result["maxItems"] = min(result.get("maxItems", len(prefix_items)), len(prefix_items)) + result["maxItems"] = ( + len(prefix_items) if max_items is None else min(max_items, len(prefix_items)) + ) result["items"] = {} converted_items = [ _convert_schema_to_3_0(item, schemas, resolving, lossy) for item in prefix_items ] - if isinstance(additional_items, dict): + # A tail schema only matters when an array can hold more items than the prefix + # covers; beyond a ``maxItems`` at or below the prefix length it is unreachable + # and must not widen the positions the prefix does constrain. + if isinstance(additional_items, dict) and ( + max_items is None or max_items > len(prefix_items) + ): converted_items.append(additional_items) if converted_items and all( _json_schema_equal(item, converted_items[0]) for item in converted_items[1:] diff --git a/tests/test_spec_conversion_characterization.py b/tests/test_spec_conversion_characterization.py index 9da71a81..f7385bc9 100644 --- a/tests/test_spec_conversion_characterization.py +++ b/tests/test_spec_conversion_characterization.py @@ -87,6 +87,33 @@ [[], ["a"], ["a", "b"]], [[1], ["a", 1]], ), + ( + [{"type": "string"}], + {"type": "number"}, + {"maxItems": 1}, + {"type": "string"}, + {"maxItems": 1}, + [[], ["a"]], + [[1], ["a", 1], ["a", "b"]], + ), + ( + [{"type": "string"}], + {"type": "number"}, + {"maxItems": 0}, + {"type": "string"}, + {"maxItems": 0}, + [[]], + [["a"], [1]], + ), + ( + [], + {"type": "string"}, + {"maxItems": 0}, + {"type": "string"}, + {"maxItems": 0}, + [[]], + [["a"], [1]], + ), ([], False, {}, {}, {"maxItems": 0}, [[]], [["a"], [1]]), ([], {"type": "string"}, {}, {"type": "string"}, {}, [[], ["a"]], [[1]]), ] @@ -448,6 +475,9 @@ "closed-prefix-tighter-max", "closed-prefix-looser-max", "prefix-matches-additional-items", + "unreachable-tail-at-the-cap", + "unreachable-tail-below-the-cap", + "unreachable-tail-of-an-empty-prefix", "closed-empty-prefix", "empty-prefix-additional-items", "enum-const-intersection", @@ -616,6 +646,49 @@ def test_heterogeneous_tuple_strict_exception_is_repeatable() -> None: ) +@pytest.mark.parametrize("strict", [False, True]) +@pytest.mark.parametrize( + ("max_items", "reachable"), + [(1, False), (2, True), (None, True)], + ids=["cap-at-the-prefix-length", "cap-beyond-the-prefix", "no-cap"], +) +def test_tail_schema_counts_only_when_an_array_can_reach_it( + max_items: int | None, reachable: bool, strict: bool +) -> None: + # Given: a string prefix whose tail is a different schema, with and without a cap. + schema = { + "type": "array", + "prefixItems": [{"type": "string"}], + "items": {"type": "number"}, + **({} if max_items is None else {"maxItems": max_items}), + } + registry = OpenAPIRegistry() + registry.set("pair", {"route": "pair", "method": "post", "request_body": schema}) + + # When / Then: a reachable tail is a positional loss; an unreachable one changes nothing. + if reachable and strict: + with pytest.raises(OpenAPISpecConfigError, match="positional constraints"): + generate_openapi_spec(registry=registry, openapi_version="3.0.0", strict=True) + return + spec = generate_openapi_spec(registry=registry, openapi_version="3.0.0", strict=strict) + actual = spec["paths"]["/api/pair"]["post"]["requestBody"]["content"]["application/json"][ + "schema" + ] + validate(spec) + validator = OAS30Validator(actual) + if reachable: + assert actual["items"] == {"anyOf": [{"type": "string"}, {"type": "number"}]} + (warning,) = collect_spec_warnings(spec, registry) + assert warning.code is WarningCode.VERSION_DOWNGRADE_DROP + assert validator.is_valid([1]) + return + assert actual == {"type": "array", "items": {"type": "string"}, "maxItems": 1} + assert collect_spec_warnings(spec, registry) == () + assert validator.is_valid(["a"]) + assert not validator.is_valid([1]) + assert not OAS31Validator(schema).is_valid([1]) + + @pytest.mark.parametrize("version", VERSIONS) @pytest.mark.parametrize("hoisted", [False, True], ids=["inline", "component"]) @pytest.mark.parametrize("strict", [False, True]) From fd8b6ef7e8a4bfa5da3d1268376cb858772cbfa8 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Sat, 31 Oct 2026 00:40:31 +0900 Subject: [PATCH 7/7] docs(spec): limit the empty-enum note to enum and const intersections Only an enum met by a const or a null-only type is rewritten to not: {}; a standalone empty enum is not normalized. Refs #868 Co-authored-by: sisyphus-dev-ai --- docs/configuration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 3d7de5ed..209ad53f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -257,8 +257,8 @@ Numeric exclusive bounds become boolean modifiers of `minimum` or `maximum`. When both forms are present, conversion keeps the stricter bound; equal bounds stay exclusive. An `enum` beside `const` is intersected using JSON Schema equality (booleans are distinct from numbers). A conflicting pair remains unsatisfiable -by retaining both predicates with `allOf`; an empty `enum`, which OpenAPI 3.0 does -not allow, becomes `not: {}` instead. +by retaining both predicates with `allOf`. When the `enum` it meets is empty, which +OpenAPI 3.0 does not allow, the contradiction is written as `not: {}` instead. A heterogeneous `tuple[...]` (differing item types per position) has no positional representation in OpenAPI 3.0 at all: it is approximated as