From d8b41fe52ce770bb29a655ae63598d56fbff43c8 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 19:34:26 +0900 Subject: [PATCH 01/10] fix(spec): convert non-null type arrays and audit inline schemas for 3.0 A type array without null, such as the loc items of the 422 envelope a validation producer publishes, stayed in OpenAPI 3.0 output where it is invalid. The residual 3.0 audit only looked at components.schemas, so the invalid document was produced without any signal. Rewrite such an array to an anyOf of single-type schemas. Sibling keywords stay on the schema, and a node with a discriminator is left for the audit because a combinator would activate it. Also convert schemas under not, audit inline operation schemas and $defs values, name the construct and its location in each finding, and send non-strict findings through the structured downgrade channel so --fail-on-warnings can gate on them. Refs #858 Co-authored-by: sisyphus-dev-ai --- .../_schema_version_conversion.py | 229 ++++++++++++------ src/azure_functions_openapi/spec.py | 13 +- tests/test_bridge_endpoint.py | 47 ++++ .../test_spec_conversion_characterization.py | 216 ++++++++++++++++- 4 files changed, 420 insertions(+), 85 deletions(-) diff --git a/src/azure_functions_openapi/_schema_version_conversion.py b/src/azure_functions_openapi/_schema_version_conversion.py index 05da5bd6..efd1908c 100644 --- a/src/azure_functions_openapi/_schema_version_conversion.py +++ b/src/azure_functions_openapi/_schema_version_conversion.py @@ -10,7 +10,7 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Iterable, Iterator from typing import Any from azure_functions_openapi.exceptions import OpenAPISpecConfigError @@ -414,6 +414,27 @@ def _convert_schema_to_3_0( else: del result["type"] result["anyOf"] = [{"type": t} for t in non_null_types] + elif ( + isinstance(type_val, list) + and type_val + and all(isinstance(t, str) for t in type_val) + and "discriminator" not in result + ): + # A type array without ``null`` takes the same shape. Type-specific keywords + # (minLength, items, properties, ...) only constrain instances of their own + # type, so leaving them beside the ``anyOf`` accepts exactly the same values. + # A sibling ``discriminator`` would start constraining instances once a + # combinator appears, so that node is left for the audit to report instead. + if len(type_val) == 1: + result["type"] = type_val[0] + else: + del result["type"] + union = [{"type": t} for t in type_val] + if "anyOf" in result: + all_of = result.get("allOf") + result["allOf"] = [*(all_of if isinstance(all_of, list) else []), {"anyOf": union}] + else: + result["anyOf"] = union # ``enum: [None, ...]`` -> keep ``null`` IN the enum (``nullable: true`` # alone does not exempt a sibling ``enum`` from its own closed-list check, @@ -479,6 +500,8 @@ def _convert_schema_to_3_0( result["additionalProperties"] = _convert_schema_to_3_0( result["additionalProperties"], schemas, resolving, lossy ) + if "not" in result and isinstance(result["not"], dict): + result["not"] = _convert_schema_to_3_0(result["not"], schemas, resolving, lossy) if "$defs" in result and isinstance(result["$defs"], dict): result["$defs"] = { k: _convert_schema_to_3_0(v, schemas, resolving, lossy) @@ -505,43 +528,72 @@ def _convert_schemas_to_3_0(schemas: dict[str, Any]) -> tuple[dict[str, Any], li return converted, lossy_messages -def _has_3_1_only_constructs(schema: dict[str, Any]) -> bool: - """Check if a schema contains constructs incompatible with OpenAPI 3.0. +_NESTED_SCHEMA_KEYS = ( + "properties", + "items", + "allOf", + "anyOf", + "oneOf", + "not", + "additionalProperties", + "$defs", +) + + +def _find_3_1_only_construct(schema: dict[str, Any]) -> str | None: + """Name the first construct in *schema* that OpenAPI 3.0 cannot express, if any. Detects: - - ``anyOf`` containing ``{"type": "null"}`` (Pydantic v2 nullable pattern) - ``type`` as a list (JSON Schema 2020-12 / OpenAPI 3.1 syntax) + - ``anyOf`` containing ``{"type": "null"}`` (Pydantic v2 nullable pattern) """ if not isinstance(schema, dict): - return False + return None - # type as list is 3.1-only if isinstance(schema.get("type"), list): - return True + return 'a "type" array' - # anyOf containing {type: "null"} is the Pydantic v2 nullable pattern any_of = schema.get("anyOf") if isinstance(any_of, list): for item in any_of: if isinstance(item, dict) and item.get("type") == "null": - return True + return 'an "anyOf" with a null type' - # Recurse into nested structures - for key in ("properties", "items", "allOf", "anyOf", "oneOf", "additionalProperties", "$defs"): + for key in _NESTED_SCHEMA_KEYS: val = schema.get(key) + children: Iterable[Any] if isinstance(val, dict): - if key == "properties": - for prop_schema in val.values(): - if isinstance(prop_schema, dict) and _has_3_1_only_constructs(prop_schema): - return True - elif _has_3_1_only_constructs(val): - return True + children = val.values() if key in ("properties", "$defs") else [val] elif isinstance(val, list): - for item in val: - if isinstance(item, dict) and _has_3_1_only_constructs(item): - return True + children = val + else: + continue + for child in children: + if (found := _find_3_1_only_construct(child)) is not None: + return found - return False + return None + + +def _has_3_1_only_constructs(schema: dict[str, Any]) -> bool: + """Check if a schema contains constructs incompatible with OpenAPI 3.0.""" + return _find_3_1_only_construct(schema) is not None + + +def _incompatibility_message(subject: str, construct: str) -> str: + return ( + f"{subject} contains OpenAPI 3.1-only constructs ({construct}) incompatible with " + "OpenAPI 3.0. Use openapi_version='3.1.0' (default) or provide a manual " + "3.0-compatible schema." + ) + + +def _report_incompatibilities(findings: list[str], strict: bool) -> list[str]: + if strict and findings: + raise OpenAPISpecConfigError( + "OpenAPI 3.0 compatibility error:\n" + "\n".join(f" - {f}" for f in findings) + ) + return findings def _check_schemas_3_0_compatible(schemas: dict[str, Any], strict: bool) -> list[str]: @@ -550,81 +602,98 @@ def _check_schemas_3_0_compatible(schemas: dict[str, Any], strict: bool) -> list Returns a list of warning messages for incompatible schemas. In strict mode, raises OpenAPISpecConfigError if any are found. """ - warnings: list[str] = [] - for name, schema in schemas.items(): - if _has_3_1_only_constructs(schema): - warnings.append( - f"Schema '{name}' contains OpenAPI 3.1-only constructs " - f"(e.g., anyOf with null type) incompatible with OpenAPI 3.0. " - f"Use openapi_version='3.1.0' (default) or provide a manual " - f"3.0-compatible schema instead of a Pydantic model." - ) + findings = [ + _incompatibility_message(f"Schema '{name}'", construct) + for name, schema in schemas.items() + if (construct := _find_3_1_only_construct(schema)) is not None + ] + return _report_incompatibilities(findings, strict) - if strict and warnings: - raise OpenAPISpecConfigError( - "OpenAPI 3.0 compatibility error:\n" + "\n".join(f" - {w}" for w in warnings) - ) - return warnings +def _operation_schema_slots( + operation: dict[str, Any], +) -> Iterator[tuple[str, dict[str, Any], str]]: + """Yield ``(location, container, key)`` for every inline schema of one operation. + + Covers: + - requestBody.content.*.schema + - responses.*.content.*.schema and .itemSchema + - parameters[].schema (and querystring parameters' content.*.schema) + """ + request_body = operation.get("requestBody") + if isinstance(request_body, dict): + content = request_body.get("content") + if isinstance(content, dict): + for media, media_obj in content.items(): + if isinstance(media_obj, dict) and "schema" in media_obj: + yield f"requestBody.content.{media}.schema", media_obj, "schema" + + responses = operation.get("responses") + if isinstance(responses, dict): + for status, resp in responses.items(): + if not isinstance(resp, dict): + continue + content = resp.get("content") + if isinstance(content, dict): + for media, media_obj in content.items(): + if not isinstance(media_obj, dict): + continue + for schema_key in _MEDIA_SCHEMA_KEYS: + if schema_key in media_obj: + yield ( + f"responses.{status}.content.{media}.{schema_key}", + media_obj, + schema_key, + ) + + for index, param in enumerate(operation.get("parameters", [])): + if not isinstance(param, dict): + continue + if "schema" in param: + yield f"parameters[{index}].schema", param, "schema" + # querystring parameters (OpenAPI 3.2) carry a content map instead of a + # bare schema. + param_content = param.get("content") + if isinstance(param_content, dict): + for media, media_obj in param_content.items(): + if isinstance(media_obj, dict) and "schema" in media_obj: + yield f"parameters[{index}].content.{media}.schema", media_obj, "schema" def _convert_operation_schemas( paths: dict[str, Any], converter: Callable[[dict[str, Any]], dict[str, Any]], ) -> dict[str, Any]: - """Apply *converter* to every inline schema in operations, in place. - - Converts schemas in: - - requestBody.content.*.schema - - responses.*.content.*.schema and .itemSchema - - parameters[].schema (and querystring parameters' content.*.schema) - """ + """Apply *converter* to every inline schema in operations, in place.""" for _path, methods in paths.items(): for _method, operation in methods.items(): if not isinstance(operation, dict): continue - - # requestBody - rb = operation.get("requestBody") - if isinstance(rb, dict): - content = rb.get("content") - if isinstance(content, dict): - for _media, media_obj in content.items(): - if isinstance(media_obj, dict) and "schema" in media_obj: - media_obj["schema"] = converter(media_obj["schema"]) - - # responses - responses = operation.get("responses") - if isinstance(responses, dict): - for _status, resp in responses.items(): - if not isinstance(resp, dict): - continue - content = resp.get("content") - if isinstance(content, dict): - for _media, media_obj in content.items(): - if not isinstance(media_obj, dict): - continue - for _schema_key in _MEDIA_SCHEMA_KEYS: - if _schema_key in media_obj: - media_obj[_schema_key] = converter(media_obj[_schema_key]) - - # parameters - for param in operation.get("parameters", []): - if not isinstance(param, dict): - continue - if "schema" in param: - param["schema"] = converter(param["schema"]) - # querystring parameters (OpenAPI 3.2) carry a content map - # instead of a bare schema. - param_content = param.get("content") - if isinstance(param_content, dict): - for _media, media_obj in param_content.items(): - if isinstance(media_obj, dict) and "schema" in media_obj: - media_obj["schema"] = converter(media_obj["schema"]) + for _location, container, key in _operation_schema_slots(operation): + container[key] = converter(container[key]) return paths +def _check_operation_schemas_3_0_compatible(paths: dict[str, Any], strict: bool) -> list[str]: + """Check inline operation schemas for OpenAPI 3.1-only constructs when targeting 3.0. + + Counterpart of :func:`_check_schemas_3_0_compatible` for schemas that sit directly + in an operation rather than in ``components.schemas``. Each message names the + operation and the schema location. In strict mode, raises OpenAPISpecConfigError + if any are found. + """ + findings = [ + _incompatibility_message(f"{method.upper()} {path} {location}", construct) + for path, methods in paths.items() + for method, operation in methods.items() + if isinstance(operation, dict) + for location, container, key in _operation_schema_slots(operation) + if (construct := _find_3_1_only_construct(container[key])) is not None + ] + return _report_incompatibilities(findings, strict) + + def _convert_operation_schemas_to_3_1(paths: dict[str, Any]) -> dict[str, Any]: """Apply 3.1 schema conversion to inline schemas in operations.""" return _convert_operation_schemas(paths, _convert_schema_to_3_1) diff --git a/src/azure_functions_openapi/spec.py b/src/azure_functions_openapi/spec.py index 5f2eaec3..667e38f1 100644 --- a/src/azure_functions_openapi/spec.py +++ b/src/azure_functions_openapi/spec.py @@ -20,6 +20,9 @@ from azure_functions_openapi._schema_version_conversion import ( _STRUCTURAL_MERGE_KEYS as _STRUCTURAL_MERGE_KEYS, ) +from azure_functions_openapi._schema_version_conversion import ( + _check_operation_schemas_3_0_compatible, +) from azure_functions_openapi._schema_version_conversion import ( _check_schemas_3_0_compatible as _check_schemas_3_0_compatible, ) @@ -862,6 +865,7 @@ def _generate_openapi_result( lossy_conversion_warnings.extend( _convert_operation_schemas_to_3_0(paths, components.get("schemas")) ) + lossy_conversion_warnings.extend(_check_operation_schemas_3_0_compatible(paths, strict)) # Top-level and info metadata passthrough (#494). Each field is emitted # only when supplied; contact/license nest under ``info`` while @@ -922,9 +926,9 @@ def _generate_openapi_result( components["schemas"] ) lossy_conversion_warnings.extend(component_lossy_warnings) - compat_warnings = _check_schemas_3_0_compatible(components["schemas"], strict) - for w in compat_warnings: - logger.warning("OpenAPI 3.0 compatibility: %s", w) + lossy_conversion_warnings.extend( + _check_schemas_3_0_compatible(components["schemas"], strict) + ) if components.get("schemas") or components.get("securitySchemes"): spec["components"] = components @@ -950,7 +954,8 @@ def _generate_openapi_result( downgrade_drops = _restructure_additional_operations(spec, openapi_version) downgrade_drops.extend(_drop_unsupported_query(spec, openapi_version)) # Lossy OpenAPI 3.0 schema approximations (#782, e.g. a heterogeneous - # tuple down-converted to items.anyOf) share the same silent-loss + # tuple down-converted to items.anyOf) and residual 3.1-only constructs + # the conversion could not normalize (#858) share the same silent-loss # shape as a custom-method/query drop, so they are reported through # the identical downgrade-drop channel and strict/non-strict gate. downgrade_drops.extend(lossy_conversion_warnings) diff --git a/tests/test_bridge_endpoint.py b/tests/test_bridge_endpoint.py index 776729a9..86be3382 100644 --- a/tests/test_bridge_endpoint.py +++ b/tests/test_bridge_endpoint.py @@ -2,6 +2,8 @@ from typing import Any, cast +from openapi_schema_validator import OAS30Validator +from openapi_spec_validator import validate from pydantic import BaseModel import pytest @@ -969,3 +971,48 @@ def test_endpoint_v1_additive_422_response_is_preserved_in_spec() -> None: # The 422 response schema is carried through, not dropped or flattened away. schema = responses["422"]["content"]["application/json"]["schema"] assert schema["properties"]["detail"]["type"] == "string" + + +@pytest.mark.parametrize("hoist_flat_schemas", [False, True]) +def test_endpoint_v1_422_envelope_with_type_array_is_valid_openapi_3_0( + hoist_flat_schemas: bool, +) -> None: + """The ``422`` envelope a validation producer publishes lists ``loc`` items as + ``type: [string, integer]``, a JSON Schema 2020-12 construct OpenAPI 3.0 cannot + express. The 3.0 document must still validate and keep accepting both (#858).""" + envelope: dict[str, Any] = { + "type": "object", + "properties": { + "detail": { + "type": "array", + "items": { + "type": "object", + "properties": { + "loc": {"type": "array", "items": {"type": ["string", "integer"]}}, + "msg": {"type": "string"}, + }, + "required": ["loc", "msg"], + }, + } + }, + "required": ["detail"], + } + payload: dict[str, Any] = { + "version": 1, + "request_body": {"type": "object", "properties": {"name": {"type": "string"}}}, + "responses": {"422": {"schema": envelope}}, + } + scan_endpoint_metadata(_make_app({"endpoint": payload})) + + spec = generate_openapi_spec( + openapi_version="3.0.0", strict=True, hoist_flat_schemas=hoist_flat_schemas + ) + + validate(spec) + schema = spec["paths"]["/api/users"]["post"]["responses"]["422"]["content"]["application/json"][ + "schema" + ] + validator = OAS30Validator({**schema, "components": spec.get("components", {})}) + assert validator.is_valid({"detail": [{"loc": ["body", 0], "msg": "invalid"}]}) + assert not validator.is_valid({"detail": [{"loc": [None], "msg": "invalid"}]}) + assert collect_spec_warnings(spec) == () diff --git a/tests/test_spec_conversion_characterization.py b/tests/test_spec_conversion_characterization.py index 5afb6ce0..3ede9c4c 100644 --- a/tests/test_spec_conversion_characterization.py +++ b/tests/test_spec_conversion_characterization.py @@ -8,6 +8,11 @@ from pydantic import JsonValue import pytest +from azure_functions_openapi._schema_version_conversion import ( + _find_3_1_only_construct, + _operation_schema_slots, +) +from azure_functions_openapi._warnings import WarningCode from azure_functions_openapi.exceptions import OpenAPISpecConfigError from azure_functions_openapi.registry import OpenAPIRegistry from azure_functions_openapi.spec import collect_spec_warnings, generate_openapi_spec @@ -70,8 +75,96 @@ [{"choice": "fixed", "score": 2, "labels": [None, "x"]}], [{"choice": "other"}, {"score": 1}, {"labels": [42]}], ), + ( + {"type": ["string"]}, + {"type": "string"}, + {"type": ["string"]}, + ["yes"], + [42, None], + ), + ( + {"type": ["string", "integer"]}, + {"anyOf": [{"type": "string"}, {"type": "integer"}]}, + {"type": ["string", "integer"]}, + ["yes", 42], + [[1], 1.5, None], + ), + ( + {"type": ["string", "integer"], "minLength": 2, "minimum": 0}, + { + "anyOf": [{"type": "string"}, {"type": "integer"}], + "minLength": 2, + "minimum": 0, + }, + {"type": ["string", "integer"], "minLength": 2, "minimum": 0}, + ["ab", 0, 5], + ["a", -1, 1.5], + ), + ( + { + "type": ["string", "integer"], + "anyOf": [ + {"type": "string", "minLength": 2}, + {"type": "integer", "minimum": 5}, + ], + }, + { + "anyOf": [ + {"type": "string", "minLength": 2}, + {"type": "integer", "minimum": 5}, + ], + "allOf": [{"anyOf": [{"type": "string"}, {"type": "integer"}]}], + }, + { + "type": ["string", "integer"], + "anyOf": [ + {"type": "string", "minLength": 2}, + {"type": "integer", "minimum": 5}, + ], + }, + ["ab", 6], + ["a", 4, 1.5, None], + ), + ( + { + "type": "object", + "properties": {"loc": {"type": "array", "items": {"type": ["string", "integer"]}}}, + }, + { + "type": "object", + "properties": { + "loc": { + "type": "array", + "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, + } + }, + }, + { + "type": "object", + "properties": {"loc": {"type": "array", "items": {"type": ["string", "integer"]}}}, + }, + [{"loc": ["body", 0]}], + [{"loc": [None]}, {"loc": [1.5]}], + ), + ( + {"not": {"type": ["string", "integer"]}}, + {"not": {"anyOf": [{"type": "string"}, {"type": "integer"}]}}, + {"not": {"type": ["string", "integer"]}}, + [[1], None, 1.5], + ["x", 5], + ), + ], + ids=[ + "legacy-nullable", + "nullable-union", + "nested-keywords", + "single-type-array", + "type-array", + "type-array-with-sibling-keywords", + "type-array-beside-any-of", + "nested-type-array", + "negated-type-array", ], - ids=["legacy-nullable", "nullable-union", "nested-keywords"], ) def test_schema_conversion_at_every_operation_position( version: str, @@ -255,3 +348,124 @@ def test_mutual_nullable_reference_cycle(version: str, strict: bool) -> None: assert validator.is_valid({"next": None}) # Current 3.0 fallback loses nullability at a deeper recursive edge. assert validator.is_valid({"next": {"next": {"next": None}}}) == (version != "3.0.0") + + +@pytest.mark.parametrize( + ("schema", "construct"), + [ + ({"type": ["string", "integer"]}, 'a "type" array'), + ({"anyOf": [{"type": "string"}, {"type": "null"}]}, 'an "anyOf" with a null type'), + ( + {"type": "object", "properties": {"a": {"items": {"type": ["string"]}}}}, + 'a "type" array', + ), + ({"allOf": ["junk", {"type": ["string"]}]}, 'a "type" array'), + ({"$defs": {"X": {"type": ["string"]}}}, 'a "type" array'), + ({"not": {"type": ["string", "integer"]}}, 'a "type" array'), + ({"type": "string", "nullable": True}, None), + ], +) +def test_audit_names_the_first_construct_openapi_3_0_cannot_express( + schema: dict[str, JsonValue], construct: str | None +) -> None: + # Given / When / Then: the finding names what is wrong, not just that something is. + assert _find_3_1_only_construct(schema) == construct + + +@pytest.mark.parametrize( + "odd", + [ + {"type": []}, + {"type": ["string", "integer"], "discriminator": {"propertyName": "kind"}}, + ], + ids=["empty-type-list", "type-array-beside-discriminator"], +) +@pytest.mark.parametrize("hoisted", [False, True], ids=["inline", "component"]) +@pytest.mark.parametrize("strict", [False, True]) +def test_unconvertible_construct_is_reported_through_the_downgrade_channel( + odd: dict[str, JsonValue], hoisted: bool, strict: bool +) -> None: + # Given: a ``type`` array no OpenAPI 3.0 conversion can normalize faithfully: an + # empty list, or one beside a ``discriminator`` (a combinator would activate it). + body = {"$ref": "#/$defs/Odd", "$defs": {"Odd": odd}} if hoisted else odd + registry = OpenAPIRegistry() + registry.set("odd", {"route": "odd", "method": "post", "request_body": body}) + subject = ( + "Schema 'Odd'" if hoisted else "POST /api/odd requestBody.content.application/json.schema" + ) + + # When / Then: strict mode refuses the document and names the construct and location. + if strict: + with pytest.raises(OpenAPISpecConfigError, match="3.1-only constructs") as caught: + generate_openapi_spec(registry=registry, openapi_version="3.0.0", strict=True) + assert subject in str(caught.value) + assert '(a "type" array)' in str(caught.value) + return + + # When: non-strict generation still produces a document. + spec = generate_openapi_spec( + registry=registry, openapi_version="3.0.0", hoist_flat_schemas=hoisted + ) + + # Then: the finding is a structured warning that --fail-on-warnings can gate on. + (warning,) = collect_spec_warnings(spec, registry) + assert warning.code is WarningCode.VERSION_DOWNGRADE_DROP + assert warning.message.startswith(f"{subject} contains OpenAPI 3.1-only constructs") + assert '(a "type" array)' in warning.message + + +def test_has_3_1_only_constructs_stays_importable_from_spec() -> None: + # Given / When: the compatibility re-export that predates the construct-naming audit. + from azure_functions_openapi.spec import _has_3_1_only_constructs + + # Then: it still answers the yes/no question it always did. + assert _has_3_1_only_constructs({"type": ["string", "integer"]}) is True + assert _has_3_1_only_constructs({"type": "string"}) is False + + +def test_operation_schema_slots_label_each_schema_and_skip_malformed_entries() -> None: + # Given: an operation mixing well-formed schema positions with malformed raw entries. + operation = { + "requestBody": {"content": {"application/json": {"schema": {"type": "object"}}}}, + "responses": { + "200": "junk", + "201": {"content": {"application/json": "junk"}}, + "202": {"content": {"application/json": {"schema": {"type": "string"}}}}, + }, + "parameters": [ + "junk", + {"name": "q", "in": "query", "schema": {"type": "string"}}, + { + "name": "qs", + "in": "querystring", + "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object"}}}, + }, + ], + } + + # When + locations = [location for location, _container, _key in _operation_schema_slots(operation)] + + # Then: every location names where the schema sits, and malformed entries are skipped. + assert locations == [ + "requestBody.content.application/json.schema", + "responses.202.content.application/json.schema", + "parameters[1].schema", + "parameters[2].content.application/x-www-form-urlencoded.schema", + ] + + +@pytest.mark.parametrize( + "operation", + [ + {"requestBody": {"content": "junk"}, "responses": "junk"}, + {"requestBody": {"content": {"application/json": "junk"}}, "parameters": []}, + {"requestBody": "junk", "responses": {"200": {"content": "junk"}}}, + {"parameters": [{"name": "qs", "in": "querystring", "content": {"x": "junk"}}]}, + ], +) +def test_operation_schema_slots_yield_nothing_for_unusable_containers( + operation: dict[str, JsonValue], +) -> None: + # Given / When / Then + assert list(_operation_schema_slots(operation)) == [] From df54092138aecc27670e0eaf9d9c6500777ca88a Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 19:34:46 +0900 Subject: [PATCH 02/10] docs: describe the OpenAPI 3.0 type-array conversion and audit Update the schema version conversion design notes and the 3.0 mode section of the configuration guide for the type-array rewrite, the inline-schema audit and the structured downgrade warnings. Refs #858 Co-authored-by: sisyphus-dev-ai --- DESIGN.md | 12 +++++++++--- docs/configuration.md | 11 +++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 91218f91..7231af26 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -87,9 +87,15 @@ The final call-flow is: `_convert_schemas_to_3_0`: inline schemas and components are distinct locations, not duplicate passes. The 3.0 component conversion also resolves refs against the original map, replacing it only after all converted components are returned. -4. For 3.0, retain `_check_schemas_3_0_compatible` after conversion. It rejects or - warns about residual constructs such as non-null type arrays that conversion - does not normalize. Faithfully converted nullable schemas must not trigger it. +4. For 3.0, retain the residual compatibility audit after conversion: + `_check_operation_schemas_3_0_compatible` for inline operation schemas and + `_check_schemas_3_0_compatible` for components. Each rejects (strict) or reports + a `type` array or null-`anyOf` that conversion could not normalize, naming the + construct and where it was found. Conversion rewrites non-null `type` arrays to + `anyOf` except beside a `discriminator`, which a combinator would activate, and + faithfully converted schemas must not trigger the audit. Non-strict findings + join the schema-loss findings below rather than being logged on their own. + Keywords OpenAPI 3.0 does not define are not audited. 5. Normalize and validate the document before custom-method relocation and QUERY removal. Relocate custom methods for 3.2; record unsupported method/QUERY drops before 3.2. Append schema-loss findings, log findings, and enforce strict mode. diff --git a/docs/configuration.md b/docs/configuration.md index 0c3cb6ac..36adf9a4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -227,6 +227,17 @@ attaching `nullable` directly to one branch of a nullable multi-type union, so the result is not just structurally valid but actually accepts a `null` value under real OpenAPI 3.0 tooling. +A `type` array without `null` (for example `type: [string, integer]`, which +validation producers use for error locations) is rewritten to an `anyOf` of +single-type schemas, and sibling keywords stay on the schema. A `type` array +that conversion cannot rewrite faithfully, such as one beside a `discriminator`, +stays in the document. Like an `anyOf` with a `null` branch that conversion +could not normalize, it is reported as a structured `version-downgrade-drop` +warning (visible via `generate_openapi_report()` or the CLI's +`--fail-on-warnings`) and raises `OpenAPISpecConfigError` under `strict=True`. +Other JSON Schema 2020-12 keywords that OpenAPI 3.0 does not define, for +example `if`/`then`/`else` or `contains`, are not checked. + 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 From f44ddbb4050ce364974d7dec3f019b11e1b69e41 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 21:10:05 +0900 Subject: [PATCH 03/10] fix(spec): keep null, items and references valid in converted type arrays Expanding a multi-type array into an anyOf left three gaps under OpenAPI 3.0. A nullable union lost null because nullable sat beside no type, so the first branch now carries it. An array branch had no items, which 3.0 requires, so it gets an empty one. A $ref beside the array lost the type because 3.0 ignores the siblings of a reference, so the reference moves into allOf. A single-type array beside a discriminator adds no combinator, so it is converted too. Refs #858 Co-authored-by: sisyphus-dev-ai --- .../_schema_version_conversion.py | 71 +++++++++--- tests/test_openapi_3_0_nullable.py | 3 +- .../test_spec_conversion_characterization.py | 103 ++++++++++++++++++ 3 files changed, 159 insertions(+), 18 deletions(-) diff --git a/src/azure_functions_openapi/_schema_version_conversion.py b/src/azure_functions_openapi/_schema_version_conversion.py index efd1908c..296e7101 100644 --- a/src/azure_functions_openapi/_schema_version_conversion.py +++ b/src/azure_functions_openapi/_schema_version_conversion.py @@ -337,6 +337,48 @@ def _tighter_bound(key: str, first: Any, second: Any) -> Any: return first +def _expand_type_union(schema: dict[str, Any], types: list[str]) -> None: + """Replace the multi-type ``type`` array of *schema* with an ``anyOf`` of single-type schemas. + + Sibling keywords stay where they are: type-specific ones (``minLength``, ``items``, + ...) only constrain instances of their own type, so the result accepts the same + values. Three details keep each branch valid and faithful under OpenAPI 3.0: + + * ``nullable`` only takes effect beside the ``type`` it exempts, so a nullable + *schema* hands it to the first branch, the idiom the nullable-union collapse + already uses. + * Every ``array`` schema needs ``items``, so an ``array`` branch gets an empty one; + the sibling ``items`` of *schema* still applies as before. + * An ``anyOf`` that *schema* already carries is kept by adding the new union to its + ``allOf``. + """ + del schema["type"] + nullable = schema.get("nullable") is True + union: list[dict[str, Any]] = [] + for index, name in enumerate(types): + branch: dict[str, Any] = {"type": name} + if name == "array": + branch["items"] = {} + if nullable and index == 0: + branch["nullable"] = True + union.append(branch) + if "anyOf" in schema: + all_of = schema.get("allOf") + schema["allOf"] = [*(all_of if isinstance(all_of, list) else []), {"anyOf": union}] + else: + schema["anyOf"] = union + + +def _wrap_ref_siblings(schema: dict[str, Any]) -> None: + """Move the ``$ref`` of *schema* into ``allOf``. + + OpenAPI 3.0 ignores every sibling of a Reference Object, so keywords that conversion + just wrote beside a ``$ref`` would silently stop constraining anything. + """ + all_of = schema.get("allOf") + schema["allOf"] = [{"$ref": schema.pop("$ref")}, *(all_of if isinstance(all_of, list) else [])] + + def _convert_schema_to_3_0( schema: dict[str, Any], schemas: dict[str, Any] | None = None, @@ -404,6 +446,7 @@ def _convert_schema_to_3_0( # requires ``type`` to be a single string, so a multi-type union is expressed # as ``anyOf`` of single-type schemas rather than a (3.0-invalid) type array. type_val = result.get("type") + type_array_rewritten = False if isinstance(type_val, list) and "null" in type_val: non_null_types = [t for t in type_val if t != "null"] result["nullable"] = True @@ -412,29 +455,23 @@ def _convert_schema_to_3_0( elif not non_null_types: del result["type"] else: - del result["type"] - result["anyOf"] = [{"type": t} for t in non_null_types] - elif ( - isinstance(type_val, list) - and type_val - and all(isinstance(t, str) for t in type_val) - and "discriminator" not in result - ): + _expand_type_union(result, non_null_types) + type_array_rewritten = True + elif isinstance(type_val, list) and type_val and all(isinstance(t, str) for t in type_val): # A type array without ``null`` takes the same shape. Type-specific keywords # (minLength, items, properties, ...) only constrain instances of their own # type, so leaving them beside the ``anyOf`` accepts exactly the same values. # A sibling ``discriminator`` would start constraining instances once a - # combinator appears, so that node is left for the audit to report instead. + # combinator appears, so a multi-type array there is left for the audit to + # report; a single type adds no combinator and converts regardless. if len(type_val) == 1: result["type"] = type_val[0] - else: - del result["type"] - union = [{"type": t} for t in type_val] - if "anyOf" in result: - all_of = result.get("allOf") - result["allOf"] = [*(all_of if isinstance(all_of, list) else []), {"anyOf": union}] - else: - result["anyOf"] = union + type_array_rewritten = True + elif "discriminator" not in result: + _expand_type_union(result, type_val) + type_array_rewritten = True + if type_array_rewritten and "$ref" in result: + _wrap_ref_siblings(result) # ``enum: [None, ...]`` -> keep ``null`` IN the enum (``nullable: true`` # alone does not exempt a sibling ``enum`` from its own closed-list check, diff --git a/tests/test_openapi_3_0_nullable.py b/tests/test_openapi_3_0_nullable.py index 785853c7..861e8903 100644 --- a/tests/test_openapi_3_0_nullable.py +++ b/tests/test_openapi_3_0_nullable.py @@ -88,7 +88,8 @@ def test_type_array_multi_non_null(self) -> None: assert result["nullable"] is True assert "type" not in result - assert result["anyOf"] == [{"type": "string"}, {"type": "integer"}] + # ``nullable`` only takes effect beside a ``type``, so the first branch carries it. + assert result["anyOf"] == [{"type": "string", "nullable": True}, {"type": "integer"}] assert not _has_null_type(result) assert not _has_type_array(result) diff --git a/tests/test_spec_conversion_characterization.py b/tests/test_spec_conversion_characterization.py index 3ede9c4c..34d28f74 100644 --- a/tests/test_spec_conversion_characterization.py +++ b/tests/test_spec_conversion_characterization.py @@ -5,6 +5,7 @@ import copy from openapi_schema_validator import OAS30Validator, OAS31Validator +from openapi_spec_validator import validate from pydantic import JsonValue import pytest @@ -153,6 +154,64 @@ [[1], None, 1.5], ["x", 5], ), + ( + {"type": ["string", "integer", "null"]}, + { + "nullable": True, + "anyOf": [{"type": "string", "nullable": True}, {"type": "integer"}], + }, + {"type": ["string", "integer", "null"]}, + [None, "yes", 42], + [1.5, [1]], + ), + ( + {"anyOf": [{"type": ["string", "integer"]}, {"type": "object"}, {"type": "null"}]}, + { + "nullable": True, + "anyOf": [ + { + "nullable": True, + "anyOf": [{"type": "string", "nullable": True}, {"type": "integer"}], + }, + {"type": "object"}, + ], + }, + {"anyOf": [{"type": ["string", "integer"]}, {"type": "object"}, {"type": "null"}]}, + [None, "yes", 42, {}], + [1.5, [1]], + ), + ( + {"type": ["array", "string"], "items": {"type": "integer"}}, + { + "anyOf": [{"type": "array", "items": {}}, {"type": "string"}], + "items": {"type": "integer"}, + }, + {"type": ["array", "string"], "items": {"type": "integer"}}, + [[1], "yes"], + [["a"], 1, None], + ), + ( + { + "type": ["object"], + "discriminator": {"propertyName": "kind"}, + "properties": {"kind": {"type": "string"}}, + "required": ["kind"], + }, + { + "type": "object", + "discriminator": {"propertyName": "kind"}, + "properties": {"kind": {"type": "string"}}, + "required": ["kind"], + }, + { + "type": ["object"], + "discriminator": {"propertyName": "kind"}, + "properties": {"kind": {"type": "string"}}, + "required": ["kind"], + }, + [{"kind": "a"}], + [{}, "a"], + ), ], ids=[ "legacy-nullable", @@ -164,6 +223,10 @@ "type-array-beside-any-of", "nested-type-array", "negated-type-array", + "nullable-type-array", + "nullable-union-of-type-array", + "type-array-with-array-member", + "single-type-array-beside-discriminator", ], ) def test_schema_conversion_at_every_operation_position( @@ -414,6 +477,46 @@ def test_unconvertible_construct_is_reported_through_the_downgrade_channel( assert '(a "type" array)' in warning.message +@pytest.mark.parametrize( + ("original", "expected"), + [ + ( + {"$ref": "#/$defs/Num", "type": ["string", "integer"]}, + { + "allOf": [{"$ref": "#/components/schemas/Num"}], + "anyOf": [{"type": "string"}, {"type": "integer"}], + }, + ), + ( + {"$ref": "#/$defs/Num", "type": ["integer", "null"]}, + {"allOf": [{"$ref": "#/components/schemas/Num"}], "type": "integer", "nullable": True}, + ), + ], + ids=["multi-type", "nullable-single-type"], +) +def test_type_array_beside_a_reference_moves_the_reference_into_all_of( + original: dict[str, JsonValue], expected: dict[str, JsonValue] +) -> None: + # Given: OpenAPI 3.0 ignores every sibling of a ``$ref``, so a converted ``type`` + # must not be left beside one where it would silently stop constraining anything. + body = {"$ref": "#/$defs/Holder", "$defs": {"Holder": original, "Num": {"type": "number"}}} + registry = OpenAPIRegistry() + registry.set("holder", {"route": "holder", "method": "post", "request_body": body}) + + # When + spec = generate_openapi_spec(registry=registry, openapi_version="3.0.0", strict=True) + + # Then: the reference and the converted type constrain the value together. + assert spec["components"]["schemas"]["Holder"] == expected + validate(spec) + validator = OAS30Validator( + {"$ref": "#/components/schemas/Holder", "components": spec["components"]} + ) + assert validator.is_valid(1) + assert not any(validator.is_valid(instance) for instance in (1.5, "x", None)) + assert collect_spec_warnings(spec, registry) == () + + def test_has_3_1_only_constructs_stays_importable_from_spec() -> None: # Given / When: the compatibility re-export that predates the construct-naming audit. from azure_functions_openapi.spec import _has_3_1_only_constructs From 40217cb340314182e75c8cbf4009d787affafdcf Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 21:10:44 +0900 Subject: [PATCH 04/10] fix(spec): convert and audit response header schemas for 3.0 Response headers declare a schema directly or through content, but the shared inline-schema traversal skipped them, so a type array there reached an OpenAPI 3.0 document unconverted and strict mode reported nothing. Visit each header's schema and content schemas for conversion and for the audit. Refs #858 Co-authored-by: sisyphus-dev-ai --- .../_schema_version_conversion.py | 17 ++++ .../test_spec_conversion_characterization.py | 88 ++++++++++++++++++- 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/src/azure_functions_openapi/_schema_version_conversion.py b/src/azure_functions_openapi/_schema_version_conversion.py index 296e7101..e906a78e 100644 --- a/src/azure_functions_openapi/_schema_version_conversion.py +++ b/src/azure_functions_openapi/_schema_version_conversion.py @@ -655,6 +655,7 @@ def _operation_schema_slots( Covers: - requestBody.content.*.schema - responses.*.content.*.schema and .itemSchema + - responses.*.headers.*.schema and .content.*.schema - parameters[].schema (and querystring parameters' content.*.schema) """ request_body = operation.get("requestBody") @@ -682,6 +683,22 @@ def _operation_schema_slots( media_obj, schema_key, ) + headers = resp.get("headers") + if isinstance(headers, dict): + for name, header in headers.items(): + if not isinstance(header, dict): + continue + if "schema" in header: + yield f"responses.{status}.headers.{name}.schema", header, "schema" + header_content = header.get("content") + if isinstance(header_content, dict): + for media, media_obj in header_content.items(): + if isinstance(media_obj, dict) and "schema" in media_obj: + yield ( + f"responses.{status}.headers.{name}.content.{media}.schema", + media_obj, + "schema", + ) for index, param in enumerate(operation.get("parameters", [])): if not isinstance(param, dict): diff --git a/tests/test_spec_conversion_characterization.py b/tests/test_spec_conversion_characterization.py index 34d28f74..eb59a25e 100644 --- a/tests/test_spec_conversion_characterization.py +++ b/tests/test_spec_conversion_characterization.py @@ -517,6 +517,81 @@ def test_type_array_beside_a_reference_moves_the_reference_into_all_of( assert collect_spec_warnings(spec, registry) == () +@pytest.mark.parametrize( + ("version", "header", "expected"), + [ + ( + "3.0.0", + {"type": ["string", "integer"]}, + {"anyOf": [{"type": "string"}, {"type": "integer"}]}, + ), + ("3.1.0", {"type": "string", "nullable": True}, {"type": ["string", "null"]}), + ("3.2.0", {"type": "string", "nullable": True}, {"type": ["string", "null"]}), + ], +) +@pytest.mark.parametrize("through_content", [False, True], ids=["schema", "content"]) +def test_response_header_schemas_are_converted_for_every_version( + version: str, + header: dict[str, JsonValue], + expected: dict[str, JsonValue], + through_content: bool, +) -> None: + # Given: a raw response whose header declares its schema directly or through ``content``. + declaration = ( + {"content": {"application/json": {"schema": header}}} + if through_content + else {"schema": header} + ) + registry = OpenAPIRegistry() + registry.set( + "headers", + { + "route": "headers", + "method": "get", + "response": {200: {"description": "ok", "headers": {"X-Rate": declaration}}}, + }, + ) + + # When + spec = generate_openapi_spec(registry=registry, openapi_version=version, strict=True) + + # Then: the header schema is converted like every other inline schema. + converted = spec["paths"]["/api/headers"]["get"]["responses"]["200"]["headers"]["X-Rate"] + if through_content: + assert converted == {"content": {"application/json": {"schema": expected}}} + else: + assert converted == {"schema": expected} + validate(spec) + assert collect_spec_warnings(spec, registry) == () + + +@pytest.mark.parametrize("strict", [False, True]) +def test_unconvertible_response_header_schema_is_audited(strict: bool) -> None: + # Given: a header schema conversion cannot rewrite (a multi-type array beside a discriminator). + schema = {"type": ["string", "integer"], "discriminator": {"propertyName": "kind"}} + registry = OpenAPIRegistry() + registry.set( + "headers", + { + "route": "headers", + "method": "get", + "response": {200: {"description": "ok", "headers": {"X-Rate": {"schema": schema}}}}, + }, + ) + subject = "GET /api/headers responses.200.headers.X-Rate.schema" + + # When / Then: strict mode names the header; otherwise it is a structured warning. + if strict: + with pytest.raises(OpenAPISpecConfigError, match="3.1-only constructs") as caught: + generate_openapi_spec(registry=registry, openapi_version="3.0.0", strict=True) + assert subject in str(caught.value) + return + spec = generate_openapi_spec(registry=registry, openapi_version="3.0.0") + (warning,) = collect_spec_warnings(spec, registry) + assert warning.code is WarningCode.VERSION_DOWNGRADE_DROP + assert warning.message.startswith(f"{subject} contains OpenAPI 3.1-only constructs") + + def test_has_3_1_only_constructs_stays_importable_from_spec() -> None: # Given / When: the compatibility re-export that predates the construct-naming audit. from azure_functions_openapi.spec import _has_3_1_only_constructs @@ -533,7 +608,16 @@ def test_operation_schema_slots_label_each_schema_and_skip_malformed_entries() - "responses": { "200": "junk", "201": {"content": {"application/json": "junk"}}, - "202": {"content": {"application/json": {"schema": {"type": "string"}}}}, + "202": { + "content": {"application/json": {"schema": {"type": "string"}}}, + "headers": { + "X-Junk": "junk", + "X-Plain": {"schema": {"type": "integer"}}, + "X-Wrapped": {"content": {"text/plain": {"schema": {"type": "string"}}}}, + "X-Empty": {"content": {"text/plain": "junk"}}, + }, + }, + "203": {"headers": "junk"}, }, "parameters": [ "junk", @@ -553,6 +637,8 @@ def test_operation_schema_slots_label_each_schema_and_skip_malformed_entries() - assert locations == [ "requestBody.content.application/json.schema", "responses.202.content.application/json.schema", + "responses.202.headers.X-Plain.schema", + "responses.202.headers.X-Wrapped.content.text/plain.schema", "parameters[1].schema", "parameters[2].content.application/x-www-form-urlencoded.schema", ] From e111b4ab21f135cdc8e15bfbc9c15caa11736d1c Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 21:11:15 +0900 Subject: [PATCH 05/10] fix(spec): report a negated null-only schema instead of inverting it OpenAPI 3.0 can only approximate a null-only schema as a type-less nullable schema, which accepts every value, so under not the negation rejected every value without a word. Report it as a lossy conversion, which is a structured warning and an error in strict mode. Share the nested-schema iteration with the existing audit. Refs #858 Co-authored-by: sisyphus-dev-ai --- .../_schema_version_conversion.py | 70 +++++++++--- .../test_spec_conversion_characterization.py | 106 ++++++++++++++++++ 2 files changed, 162 insertions(+), 14 deletions(-) diff --git a/src/azure_functions_openapi/_schema_version_conversion.py b/src/azure_functions_openapi/_schema_version_conversion.py index e906a78e..4ff79656 100644 --- a/src/azure_functions_openapi/_schema_version_conversion.py +++ b/src/azure_functions_openapi/_schema_version_conversion.py @@ -10,7 +10,7 @@ from __future__ import annotations -from collections.abc import Callable, Iterable, Iterator +from collections.abc import Callable, Iterator from typing import Any from azure_functions_openapi.exceptions import OpenAPISpecConfigError @@ -538,7 +538,14 @@ def _convert_schema_to_3_0( result["additionalProperties"], schemas, resolving, lossy ) if "not" in result and isinstance(result["not"], dict): - result["not"] = _convert_schema_to_3_0(result["not"], schemas, resolving, lossy) + negated = _convert_schema_to_3_0(result["not"], schemas, resolving, lossy) + if lossy is not None and _contains_bare_nullable(negated): + lossy.append( + "a 'not' schema contains a null-only schema, which OpenAPI 3.0 can only " + "approximate as a type-less 'nullable' schema that accepts every value, so " + "the negation rejects every value" + ) + result["not"] = negated if "$defs" in result and isinstance(result["$defs"], dict): result["$defs"] = { k: _convert_schema_to_3_0(v, schemas, resolving, lossy) @@ -576,6 +583,50 @@ def _convert_schemas_to_3_0(schemas: dict[str, Any]) -> tuple[dict[str, Any], li "$defs", ) +# Keywords that describe a schema without constraining any instance. +_ANNOTATION_KEYS = frozenset( + { + "title", + "description", + "default", + "example", + "deprecated", + "readOnly", + "writeOnly", + "externalDocs", + "xml", + } +) + + +def _nested_schemas(schema: dict[str, Any], *, skip: frozenset[str] = frozenset()) -> Iterator[Any]: + """Yield what sits directly under the schema-bearing keywords of *schema*.""" + for key in _NESTED_SCHEMA_KEYS: + if key in skip: + continue + val = schema.get(key) + if isinstance(val, dict): + yield from val.values() if key in ("properties", "$defs") else [val] + elif isinstance(val, list): + yield from val + + +def _contains_bare_nullable(schema: Any) -> bool: + """Whether *schema*, or a schema nested in it, is a bare ``nullable: true``. + + Conversion approximates a null-only schema that way, and OpenAPI 3.0 reads it as + accepting every value. A nested ``not`` reports for itself, so it is not entered. + """ + if not isinstance(schema, dict): + return False + if schema.get("nullable") is True and all( + key == "nullable" or key in _ANNOTATION_KEYS for key in schema + ): + return True + return any( + _contains_bare_nullable(child) for child in _nested_schemas(schema, skip=frozenset({"not"})) + ) + def _find_3_1_only_construct(schema: dict[str, Any]) -> str | None: """Name the first construct in *schema* that OpenAPI 3.0 cannot express, if any. @@ -596,18 +647,9 @@ def _find_3_1_only_construct(schema: dict[str, Any]) -> str | None: if isinstance(item, dict) and item.get("type") == "null": return 'an "anyOf" with a null type' - for key in _NESTED_SCHEMA_KEYS: - val = schema.get(key) - children: Iterable[Any] - if isinstance(val, dict): - children = val.values() if key in ("properties", "$defs") else [val] - elif isinstance(val, list): - children = val - else: - continue - for child in children: - if (found := _find_3_1_only_construct(child)) is not None: - return found + for child in _nested_schemas(schema): + if (found := _find_3_1_only_construct(child)) is not None: + return found return None diff --git a/tests/test_spec_conversion_characterization.py b/tests/test_spec_conversion_characterization.py index eb59a25e..362ecbe3 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 ( + _contains_bare_nullable, _find_3_1_only_construct, _operation_schema_slots, ) @@ -517,6 +518,111 @@ def test_type_array_beside_a_reference_moves_the_reference_into_all_of( assert collect_spec_warnings(spec, registry) == () +@pytest.mark.parametrize( + "negated", + [ + {"type": "null"}, + {"type": "null", "description": "never null"}, + {"properties": {"a": {"type": "null"}}}, + {"allOf": [{"type": "string"}, {"type": "null"}]}, + ], + ids=["null-only", "annotated-null-only", "nested-property", "all-of-member"], +) +@pytest.mark.parametrize("hoisted", [False, True], ids=["inline", "component"]) +@pytest.mark.parametrize("strict", [False, True]) +def test_negated_null_only_schema_is_reported_instead_of_silently_inverted( + negated: dict[str, JsonValue], hoisted: bool, strict: bool +) -> None: + # Given: a null-only schema OpenAPI 3.0 can only approximate as a type-less + # ``nullable`` schema, which accepts every value; under ``not`` that would make the + # schema reject every value without a word. + odd = {"not": negated} + body = {"$ref": "#/$defs/Odd", "$defs": {"Odd": odd}} if hoisted else odd + registry = OpenAPIRegistry() + registry.set("odd", {"route": "odd", "method": "post", "request_body": body}) + message = "a 'not' schema contains a null-only schema" + + # When / Then: strict mode refuses it, naming the problem. + if strict: + with pytest.raises(OpenAPISpecConfigError, match=message): + generate_openapi_spec(registry=registry, openapi_version="3.0.0", strict=True) + return + + # When: non-strict generation still produces a document. + spec = generate_openapi_spec( + registry=registry, openapi_version="3.0.0", hoist_flat_schemas=hoisted + ) + + # Then: the loss is one structured warning that --fail-on-warnings can gate on. + (warning,) = collect_spec_warnings(spec, registry) + assert warning.code is WarningCode.VERSION_DOWNGRADE_DROP + assert message in warning.message + + +@pytest.mark.parametrize( + ("negated", "valid", "invalid"), + [ + ({"type": ["string", "null"]}, [1, {}], [None, "x"]), + ({"anyOf": [{"type": "string"}, {"type": "null"}]}, [1, {}], [None, "x"]), + ({"enum": [None, "a"]}, [1, "b"], [None, "a"]), + ({"type": ["string", "integer", "null"]}, [1.5, {}], [None, "x", 1]), + ], + ids=["nullable-type", "nullable-union", "enum-with-null", "nullable-type-array"], +) +def test_negated_schema_that_stays_exact_is_not_reported( + negated: dict[str, JsonValue], valid: list[JsonValue], invalid: list[JsonValue] +) -> None: + # Given: negated schemas whose 3.0 form accepts exactly the original values. + registry = OpenAPIRegistry() + registry.set("exact", {"route": "exact", "method": "post", "request_body": {"not": negated}}) + + # When + spec = generate_openapi_spec(registry=registry, openapi_version="3.0.0", strict=True) + + # Then: nothing is reported, and the negation behaves like the original. + schema = spec["paths"]["/api/exact"]["post"]["requestBody"]["content"]["application/json"][ + "schema" + ] + validator = OAS30Validator(schema) + assert all(validator.is_valid(instance) for instance in valid) + assert not any(validator.is_valid(instance) for instance in invalid) + assert collect_spec_warnings(spec, registry) == () + + +@pytest.mark.parametrize( + ("schema", "bare"), + [ + ({"nullable": True}, True), + ({"nullable": True, "description": "annotation only"}, True), + ({"nullable": True, "type": "string"}, False), + ({"nullable": True, "enum": [None]}, False), + ({"nullable": False}, False), + ({"properties": {"a": {"nullable": True}}}, True), + ({"allOf": ["junk", {"nullable": True}]}, True), + ({"allOf": ["junk"]}, False), + ({"not": {"nullable": True}}, False), + ("junk", False), + ], + ids=[ + "bare", + "annotated", + "typed", + "enum", + "not-nullable", + "nested", + "beside-junk", + "only-junk", + "nested-negation-reports-for-itself", + "not-a-schema", + ], +) +def test_bare_nullable_detection_finds_type_less_nullable_schemas( + schema: JsonValue, bare: bool +) -> None: + # Given / When / Then: only a schema that constrains nothing but ``null`` is bare. + assert _contains_bare_nullable(schema) is bare + + @pytest.mark.parametrize( ("version", "header", "expected"), [ From 9a2bd5da393c8b910fb286d7a749da76f889a18d Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 21:11:28 +0900 Subject: [PATCH 06/10] docs: describe the type-array, header and negation handling for 3.0 Document that a nullable union carries nullable on its first branch, that an array branch gains an empty items, that a reference beside a type array moves into allOf, that response header schemas are converted and audited, and that a null-only schema under not is reported. Keep the design notes in step. Refs #858 Co-authored-by: sisyphus-dev-ai --- DESIGN.md | 19 +++++++++++-------- docs/configuration.md | 24 ++++++++++++++++++------ 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 7231af26..c512f79d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -78,11 +78,12 @@ The final call-flow is: 1. Snapshot metadata and scan-lifetime warnings; assemble operations and hoist definitions into the complete component map. Compilation events accumulate locally rather than updating the registry during the walk. -2. Convert inline request, response (including `itemSchema`), and parameter schemas: - 3.1 and 3.2 use `_convert_operation_schemas_to_3_1` for legacy nullable/example - syntax; 3.0 uses `_convert_operation_schemas_to_3_0` for nullable unions, refs, - and other downgrade forms, recording losses in encounter order. Inline nullable - refs resolve against the **original complete component map**, before replacement. +2. Convert inline request, response (including `itemSchema` and headers), and + parameter schemas: 3.1 and 3.2 use `_convert_operation_schemas_to_3_1` for legacy + nullable/example syntax; 3.0 uses `_convert_operation_schemas_to_3_0` for nullable + unions, refs, and other downgrade forms, recording losses in encounter order. + Inline nullable refs resolve against the **original complete component map**, + before replacement. 3. Convert named components separately with `_convert_schemas_to_3_1` or `_convert_schemas_to_3_0`: inline schemas and components are distinct locations, not duplicate passes. The 3.0 component conversion also resolves refs against @@ -92,9 +93,11 @@ The final call-flow is: `_check_schemas_3_0_compatible` for components. Each rejects (strict) or reports a `type` array or null-`anyOf` that conversion could not normalize, naming the construct and where it was found. Conversion rewrites non-null `type` arrays to - `anyOf` except beside a `discriminator`, which a combinator would activate, and - faithfully converted schemas must not trigger the audit. Non-strict findings - join the schema-loss findings below rather than being logged on their own. + `anyOf` except a multi-type array beside a `discriminator`, which a combinator + would activate, and faithfully converted schemas must not trigger the audit. + Inline schemas include response header schemas. A null-only schema under `not` + cannot be expressed, so it joins the schema-loss findings below. Non-strict + findings join those schema-loss findings rather than being logged on their own. Keywords OpenAPI 3.0 does not define are not audited. 5. Normalize and validate the document before custom-method relocation and QUERY removal. Relocate custom methods for 3.2; record unsupported method/QUERY drops diff --git a/docs/configuration.md b/docs/configuration.md index 36adf9a4..86bf3ff5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -229,14 +229,26 @@ value under real OpenAPI 3.0 tooling. A `type` array without `null` (for example `type: [string, integer]`, which validation producers use for error locations) is rewritten to an `anyOf` of -single-type schemas, and sibling keywords stay on the schema. A `type` array -that conversion cannot rewrite faithfully, such as one beside a `discriminator`, -stays in the document. Like an `anyOf` with a `null` branch that conversion -could not normalize, it is reported as a structured `version-downgrade-drop` +single-type schemas, and sibling keywords stay on the schema. An `array` branch +gains an empty `items`, because OpenAPI 3.0 requires one; a nullable union +carries `nullable` on its first branch, so `null` is still accepted; and a +`$ref` beside the array moves into `allOf`, because OpenAPI 3.0 ignores the +siblings of a reference. A multi-type `type` array that conversion cannot +rewrite faithfully, such as one beside a `discriminator`, stays in the document. +Like an `anyOf` with a `null` branch that conversion could not normalize, it is +reported as a structured `version-downgrade-drop` warning (visible via `generate_openapi_report()` or the CLI's `--fail-on-warnings`) and raises `OpenAPISpecConfigError` under `strict=True`. -Other JSON Schema 2020-12 keywords that OpenAPI 3.0 does not define, for -example `if`/`then`/`else` or `contains`, are not checked. +These rewrites and the check cover every inline schema, including the schemas +of response headers. Other JSON Schema 2020-12 keywords that OpenAPI 3.0 does +not define, for example `if`/`then`/`else` or `contains`, are not checked. + +A null-only schema has no exact OpenAPI 3.0 form either: it is approximated as a +type-less `nullable` schema, which accepts every value. Beneath `not` that +approximation would turn the negation into a schema that rejects every value, so +it is reported like a heterogeneous tuple: `strict=True` raises +`OpenAPISpecConfigError` and non-strict generation emits a structured +`version-downgrade-drop` warning. A heterogeneous `tuple[...]` (differing item types per position) has no positional representation in OpenAPI 3.0 at all: it is approximated as From 33bfe656e69e7f853d178361c1f9f313d1b8430b Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 21:23:34 +0900 Subject: [PATCH 07/10] fix(spec): detect a negated null-only schema from the schema as written Looking for a bare nullable in the converted output reported a nullable schema already written for 3.0, which conversion leaves exact, and missed a null-only schema with a constraint or extension keyword beside it. Read the negated schema before conversion instead, follow references into the component map, ignore null members that collapse exactly into nullable, and say that the negation rejects values the original accepts. Refs #858 Co-authored-by: sisyphus-dev-ai --- docs/configuration.md | 12 +- .../_schema_version_conversion.py | 61 ++--- .../test_spec_conversion_characterization.py | 225 ++++++++++++++---- 3 files changed, 224 insertions(+), 74 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 86bf3ff5..bf66d19c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -243,12 +243,14 @@ These rewrites and the check cover every inline schema, including the schemas of response headers. Other JSON Schema 2020-12 keywords that OpenAPI 3.0 does not define, for example `if`/`then`/`else` or `contains`, are not checked. -A null-only schema has no exact OpenAPI 3.0 form either: it is approximated as a -type-less `nullable` schema, which accepts every value. Beneath `not` that -approximation would turn the negation into a schema that rejects every value, so -it is reported like a heterogeneous tuple: `strict=True` raises +A null-only schema (`type: "null"`, or a union of nothing but null members) has +no exact OpenAPI 3.0 form either: it is approximated as a type-less `nullable` +schema, which accepts every value. Beneath `not`, directly or through a `$ref`, +that approximation makes the negation reject values the original accepts, so it +is reported like a heterogeneous tuple: `strict=True` raises `OpenAPISpecConfigError` and non-strict generation emits a structured -`version-downgrade-drop` warning. +`version-downgrade-drop` warning. A `nullable` schema already written for 3.0 is +exact and is not reported. 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 4ff79656..ce6bcbd9 100644 --- a/src/azure_functions_openapi/_schema_version_conversion.py +++ b/src/azure_functions_openapi/_schema_version_conversion.py @@ -538,14 +538,14 @@ def _convert_schema_to_3_0( result["additionalProperties"], schemas, resolving, lossy ) if "not" in result and isinstance(result["not"], dict): - negated = _convert_schema_to_3_0(result["not"], schemas, resolving, lossy) - if lossy is not None and _contains_bare_nullable(negated): + negated = result["not"] + result["not"] = _convert_schema_to_3_0(negated, schemas, resolving, lossy) + if lossy is not None and _contains_null_only(negated, schemas): lossy.append( "a 'not' schema contains a null-only schema, which OpenAPI 3.0 can only " "approximate as a type-less 'nullable' schema that accepts every value, so " - "the negation rejects every value" + "the negation rejects values the original accepts" ) - result["not"] = negated if "$defs" in result and isinstance(result["$defs"], dict): result["$defs"] = { k: _convert_schema_to_3_0(v, schemas, resolving, lossy) @@ -583,21 +583,6 @@ def _convert_schemas_to_3_0(schemas: dict[str, Any]) -> tuple[dict[str, Any], li "$defs", ) -# Keywords that describe a schema without constraining any instance. -_ANNOTATION_KEYS = frozenset( - { - "title", - "description", - "default", - "example", - "deprecated", - "readOnly", - "writeOnly", - "externalDocs", - "xml", - } -) - def _nested_schemas(schema: dict[str, Any], *, skip: frozenset[str] = frozenset()) -> Iterator[Any]: """Yield what sits directly under the schema-bearing keywords of *schema*.""" @@ -611,20 +596,42 @@ def _nested_schemas(schema: dict[str, Any], *, skip: frozenset[str] = frozenset( yield from val -def _contains_bare_nullable(schema: Any) -> bool: - """Whether *schema*, or a schema nested in it, is a bare ``nullable: true``. +def _is_null_type(schema: Any) -> bool: + return isinstance(schema, dict) and schema.get("type") in ("null", ["null"]) - Conversion approximates a null-only schema that way, and OpenAPI 3.0 reads it as - accepting every value. A nested ``not`` reports for itself, so it is not entered. + +def _contains_null_only( + schema: Any, schemas: dict[str, Any] | None, seen: frozenset[str] = frozenset() +) -> bool: + """Whether *schema*, or a schema nested in it, accepts nothing but ``null`` by its own type. + + Conversion can only approximate that as a type-less ``nullable`` schema, which accepts + every value. The schema is read as written, not as converted: a ``nullable`` schema + already authored for 3.0 is exact, and keywords beside ``type: null`` change nothing + about the approximation. A null member beside other ``anyOf``/``oneOf`` members + collapses exactly into ``nullable``, so only a union made of null members alone counts. + A ``$ref`` into *schemas* is followed once per name. A nested ``not`` reports for + itself, so it is not entered. """ if not isinstance(schema, dict): return False - if schema.get("nullable") is True and all( - key == "nullable" or key in _ANNOTATION_KEYS for key in schema - ): + if _is_null_type(schema): return True + scanned = schema + for key in ("anyOf", "oneOf"): + members = schema.get(key) + if isinstance(members, list) and members: + if all(_is_null_type(m) for m in members): + return True + scanned = {**scanned, key: [m for m in members if not _is_null_type(m)]} + ref = schema.get("$ref") + if isinstance(ref, str) and schemas is not None and ref.startswith("#/components/schemas/"): + name = ref.rsplit("/", 1)[-1] + if name not in seen and _contains_null_only(schemas.get(name), schemas, seen | {name}): + return True return any( - _contains_bare_nullable(child) for child in _nested_schemas(schema, skip=frozenset({"not"})) + _contains_null_only(child, schemas, seen) + for child in _nested_schemas(scanned, skip=frozenset({"not"})) ) diff --git a/tests/test_spec_conversion_characterization.py b/tests/test_spec_conversion_characterization.py index 362ecbe3..b52c2b65 100644 --- a/tests/test_spec_conversion_characterization.py +++ b/tests/test_spec_conversion_characterization.py @@ -10,7 +10,7 @@ import pytest from azure_functions_openapi._schema_version_conversion import ( - _contains_bare_nullable, + _contains_null_only, _find_3_1_only_construct, _operation_schema_slots, ) @@ -519,23 +519,52 @@ def test_type_array_beside_a_reference_moves_the_reference_into_all_of( @pytest.mark.parametrize( - "negated", + ("negated", "emitted", "lost"), [ - {"type": "null"}, - {"type": "null", "description": "never null"}, - {"properties": {"a": {"type": "null"}}}, - {"allOf": [{"type": "string"}, {"type": "null"}]}, + ({"type": "null"}, {"nullable": True}, ["x", 1, {}]), + ( + {"type": "null", "description": "never null"}, + {"description": "never null", "nullable": True}, + ["x", 1, {}], + ), + ({"type": "null", "minLength": 1}, {"minLength": 1, "nullable": True}, ["x", 1, {}]), + ({"type": "null", "x-label": "a"}, {"x-label": "a", "nullable": True}, ["x", 1, {}]), + ({"type": ["null"]}, {"nullable": True}, ["x", 1, {}]), + ({"anyOf": [{"type": "null"}]}, {"nullable": True}, ["x", 1, {}]), + ( + {"properties": {"a": {"type": "null"}}}, + {"properties": {"a": {"nullable": True}}}, + [{"a": 1}], + ), + ( + {"allOf": [{"type": "string"}, {"type": "null"}]}, + {"allOf": [{"type": "string"}, {"nullable": True}]}, + ["x"], + ), + ], + ids=[ + "null-only", + "annotated-null-only", + "null-only-with-constraint", + "null-only-with-extension", + "null-only-type-list", + "only-null-member", + "nested-property", + "all-of-member", ], - ids=["null-only", "annotated-null-only", "nested-property", "all-of-member"], ) @pytest.mark.parametrize("hoisted", [False, True], ids=["inline", "component"]) @pytest.mark.parametrize("strict", [False, True]) def test_negated_null_only_schema_is_reported_instead_of_silently_inverted( - negated: dict[str, JsonValue], hoisted: bool, strict: bool + negated: dict[str, JsonValue], + emitted: dict[str, JsonValue], + lost: list[JsonValue], + hoisted: bool, + strict: bool, ) -> None: # Given: a null-only schema OpenAPI 3.0 can only approximate as a type-less - # ``nullable`` schema, which accepts every value; under ``not`` that would make the - # schema reject every value without a word. + # ``nullable`` schema, which accepts every value; under ``not`` that approximation + # rejects values the original accepts, whatever keywords sit beside ``type: null``. odd = {"not": negated} body = {"$ref": "#/$defs/Odd", "$defs": {"Odd": odd}} if hoisted else odd registry = OpenAPIRegistry() @@ -553,26 +582,76 @@ def test_negated_null_only_schema_is_reported_instead_of_silently_inverted( registry=registry, openapi_version="3.0.0", hoist_flat_schemas=hoisted ) - # Then: the loss is one structured warning that --fail-on-warnings can gate on. + # Then: the loss is one structured warning that --fail-on-warnings can gate on, and + # the document really is the reported approximation. (warning,) = collect_spec_warnings(spec, registry) assert warning.code is WarningCode.VERSION_DOWNGRADE_DROP assert message in warning.message + if hoisted: + converted = spec["components"]["schemas"]["Odd"] + root = {"$ref": "#/components/schemas/Odd", "components": spec["components"]} + else: + converted = spec["paths"]["/api/odd"]["post"]["requestBody"]["content"]["application/json"][ + "schema" + ] + root = converted + assert converted == {"not": emitted} + original = OAS31Validator(odd) + approximation = OAS30Validator(root) + assert all(original.is_valid(instance) for instance in lost) + assert not any(approximation.is_valid(instance) for instance in lost) @pytest.mark.parametrize( - ("negated", "valid", "invalid"), + ("negated", "emitted", "valid", "invalid"), [ - ({"type": ["string", "null"]}, [1, {}], [None, "x"]), - ({"anyOf": [{"type": "string"}, {"type": "null"}]}, [1, {}], [None, "x"]), - ({"enum": [None, "a"]}, [1, "b"], [None, "a"]), - ({"type": ["string", "integer", "null"]}, [1.5, {}], [None, "x", 1]), + ({"type": ["string", "null"]}, {"type": "string", "nullable": True}, [1, {}], [None, "x"]), + ( + {"anyOf": [{"type": "string"}, {"type": "null"}]}, + {"type": "string", "nullable": True}, + [1, {}], + [None, "x"], + ), + ( + {"enum": [None, "a"]}, + {"enum": [None, "a"], "nullable": True}, + [1, "b"], + [None, "a"], + ), + ( + {"type": ["string", "integer", "null"]}, + { + "nullable": True, + "anyOf": [{"type": "string", "nullable": True}, {"type": "integer"}], + }, + [1.5, {}], + [None, "x", 1], + ), + ({"nullable": True}, {"nullable": True}, [], ["x", 1, None]), + ( + {"nullable": True, "x-label": "a"}, + {"nullable": True, "x-label": "a"}, + [], + ["x", 1, None], + ), + ], + ids=[ + "nullable-type", + "nullable-union", + "enum-with-null", + "nullable-type-array", + "nullable-written-for-3-0", + "nullable-written-for-3-0-with-extension", ], - ids=["nullable-type", "nullable-union", "enum-with-null", "nullable-type-array"], ) def test_negated_schema_that_stays_exact_is_not_reported( - negated: dict[str, JsonValue], valid: list[JsonValue], invalid: list[JsonValue] + negated: dict[str, JsonValue], + emitted: dict[str, JsonValue], + valid: list[JsonValue], + invalid: list[JsonValue], ) -> None: - # Given: negated schemas whose 3.0 form accepts exactly the original values. + # Given: negated schemas whose 3.0 form accepts exactly the original values, among + # them a ``nullable`` schema already written for 3.0, which conversion leaves alone. registry = OpenAPIRegistry() registry.set("exact", {"route": "exact", "method": "post", "request_body": {"not": negated}}) @@ -583,44 +662,98 @@ def test_negated_schema_that_stays_exact_is_not_reported( schema = spec["paths"]["/api/exact"]["post"]["requestBody"]["content"]["application/json"][ "schema" ] + assert schema == {"not": emitted} + validate(spec) validator = OAS30Validator(schema) assert all(validator.is_valid(instance) for instance in valid) assert not any(validator.is_valid(instance) for instance in invalid) assert collect_spec_warnings(spec, registry) == () +@pytest.mark.parametrize("strict", [False, True]) +def test_negated_reference_to_a_null_only_component_is_reported(strict: bool) -> None: + # Given: the null-only schema sits behind a ``$ref``, so the negated schema never + # says ``type: null`` itself. + body = { + "$ref": "#/$defs/Odd", + "$defs": {"Odd": {"not": {"$ref": "#/$defs/Nothing"}}, "Nothing": {"type": "null"}}, + } + registry = OpenAPIRegistry() + registry.set("odd", {"route": "odd", "method": "post", "request_body": body}) + message = "Schema 'Odd': a 'not' schema contains a null-only schema" + + # When / Then: strict mode refuses it; non-strict generation reports it once. + if strict: + with pytest.raises(OpenAPISpecConfigError, match=message): + generate_openapi_spec(registry=registry, openapi_version="3.0.0", strict=True) + return + spec = generate_openapi_spec(registry=registry, openapi_version="3.0.0") + (warning,) = collect_spec_warnings(spec, registry) + assert warning.code is WarningCode.VERSION_DOWNGRADE_DROP + assert message in warning.message + + @pytest.mark.parametrize( - ("schema", "bare"), + ("schema", "components", "null_only"), [ - ({"nullable": True}, True), - ({"nullable": True, "description": "annotation only"}, True), - ({"nullable": True, "type": "string"}, False), - ({"nullable": True, "enum": [None]}, False), - ({"nullable": False}, False), - ({"properties": {"a": {"nullable": True}}}, True), - ({"allOf": ["junk", {"nullable": True}]}, True), - ({"allOf": ["junk"]}, False), - ({"not": {"nullable": True}}, False), - ("junk", False), + ({"type": "null"}, None, True), + ({"type": ["null"]}, None, True), + ({"type": "null", "minLength": 1}, None, True), + ({"type": "null", "x-label": "a"}, None, True), + ({"anyOf": [{"type": "null"}, {"type": ["null"]}]}, None, True), + ({"oneOf": [{"type": "null"}]}, None, True), + ({"properties": {"a": {"type": "null"}}}, None, True), + ({"allOf": ["junk", {"type": "null"}]}, None, True), + ({"anyOf": [{"type": "string"}, {"type": "null"}]}, None, False), + ({"type": ["string", "null"]}, None, False), + ({"nullable": True}, None, False), + ({"nullable": True, "x-label": "a"}, None, False), + ({"enum": [None]}, None, False), + ({"const": None}, None, False), + ({"allOf": ["junk"]}, None, False), + ({"not": {"type": "null"}}, None, False), + ("junk", None, False), + ({"$ref": "#/components/schemas/Nothing"}, {"Nothing": {"type": "null"}}, True), + ( + {"$ref": "#/components/schemas/Loop"}, + {"Loop": {"items": {"$ref": "#/components/schemas/Loop"}}}, + False, + ), + ({"$ref": "#/components/schemas/Missing"}, {}, False), + ({"$ref": "#/components/schemas/Nothing"}, None, False), + ({"$ref": "#/$defs/Nothing"}, {"Nothing": {"type": "null"}}, False), ], ids=[ - "bare", - "annotated", - "typed", - "enum", - "not-nullable", - "nested", + "null-type", + "null-type-list", + "with-constraint", + "with-extension", + "all-null-any-of", + "single-null-one-of", + "nested-property", "beside-junk", + "exact-union", + "exact-nullable-type", + "nullable-written-for-3-0", + "nullable-with-extension", + "enum-with-null", + "const-null", "only-junk", "nested-negation-reports-for-itself", "not-a-schema", + "reference-to-null-only", + "reference-cycle", + "unknown-reference", + "no-components", + "not-a-component-reference", ], ) -def test_bare_nullable_detection_finds_type_less_nullable_schemas( - schema: JsonValue, bare: bool +def test_null_only_detection_reads_the_schema_as_written( + schema: JsonValue, components: dict[str, JsonValue] | None, null_only: bool ) -> None: - # Given / When / Then: only a schema that constrains nothing but ``null`` is bare. - assert _contains_bare_nullable(schema) is bare + # Given / When / Then: only a schema that accepts nothing but ``null`` by its own + # ``type`` counts, however many other keywords sit beside it. + assert _contains_null_only(schema, components) is null_only @pytest.mark.parametrize( @@ -631,8 +764,16 @@ def test_bare_nullable_detection_finds_type_less_nullable_schemas( {"type": ["string", "integer"]}, {"anyOf": [{"type": "string"}, {"type": "integer"}]}, ), - ("3.1.0", {"type": "string", "nullable": True}, {"type": ["string", "null"]}), - ("3.2.0", {"type": "string", "nullable": True}, {"type": ["string", "null"]}), + ( + "3.1.0", + {"type": "string", "nullable": True, "example": "abc"}, + {"type": ["string", "null"], "examples": ["abc"]}, + ), + ( + "3.2.0", + {"type": "string", "nullable": True, "example": "abc"}, + {"type": ["string", "null"], "examples": ["abc"]}, + ), ], ) @pytest.mark.parametrize("through_content", [False, True], ids=["schema", "content"]) From c1222aa67b236aee69af78662ab96f3c4a05c0c8 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 21:34:37 +0900 Subject: [PATCH 08/10] fix(spec): keep a null-only schema null-only when converting for 3.0 A schema that accepts only null lost its type and was left as a type-less nullable schema, which accepts every value in OpenAPI 3.0, so a not over it rejected every value. Keep an enum of null on it instead, for a type of null, a type array of only null and a union of null members alone. This replaces the detector that reported a null-only schema under not: it warned for schemas that convert exactly and missed others. It also makes a null-typed property accept only null. Refs #858 Co-authored-by: sisyphus-dev-ai --- DESIGN.md | 6 +- docs/configuration.md | 12 +- .../_schema_version_conversion.py | 83 ++--- tests/test_openapi_3_0_nullable.py | 2 + .../test_spec_conversion_characterization.py | 300 ++++++------------ 5 files changed, 123 insertions(+), 280 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index c512f79d..62430f7d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -95,9 +95,9 @@ The final call-flow is: construct and where it was found. Conversion rewrites non-null `type` arrays to `anyOf` except a multi-type array beside a `discriminator`, which a combinator would activate, and faithfully converted schemas must not trigger the audit. - Inline schemas include response header schemas. A null-only schema under `not` - cannot be expressed, so it joins the schema-loss findings below. Non-strict - findings join those schema-loss findings rather than being logged on their own. + Inline schemas include response header schemas. A null-only schema converts to + `enum: [null]` with `nullable`, which keeps it exact beneath `not`. Non-strict + findings join the schema-loss findings below rather than being logged on their own. Keywords OpenAPI 3.0 does not define are not audited. 5. Normalize and validate the document before custom-method relocation and QUERY removal. Relocate custom methods for 3.2; record unsupported method/QUERY drops diff --git a/docs/configuration.md b/docs/configuration.md index bf66d19c..e3924e6c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -243,14 +243,10 @@ These rewrites and the check cover every inline schema, including the schemas of response headers. Other JSON Schema 2020-12 keywords that OpenAPI 3.0 does not define, for example `if`/`then`/`else` or `contains`, are not checked. -A null-only schema (`type: "null"`, or a union of nothing but null members) has -no exact OpenAPI 3.0 form either: it is approximated as a type-less `nullable` -schema, which accepts every value. Beneath `not`, directly or through a `$ref`, -that approximation makes the negation reject values the original accepts, so it -is reported like a heterogeneous tuple: `strict=True` raises -`OpenAPISpecConfigError` and non-strict generation emits a structured -`version-downgrade-drop` warning. A `nullable` schema already written for 3.0 is -exact and is not reported. +A schema that accepts only `null` (`type: "null"`, or a union of nothing but null +members) becomes `enum: [null]` with `nullable: true`. A type-less `nullable` +schema would accept every value in OpenAPI 3.0, and a `not` over it would then +reject every value. 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 ce6bcbd9..a803c2bf 100644 --- a/src/azure_functions_openapi/_schema_version_conversion.py +++ b/src/azure_functions_openapi/_schema_version_conversion.py @@ -10,7 +10,7 @@ from __future__ import annotations -from collections.abc import Callable, Iterator +from collections.abc import Callable, Iterable, Iterator from typing import Any from azure_functions_openapi.exceptions import OpenAPISpecConfigError @@ -292,6 +292,8 @@ def _collapse_nullable_combinator( result.setdefault(k, v) else: result[key] = non_null + elif not non_null: + result.setdefault("enum", [None]) elif len(non_null) > 1: patched_members: list[Any] = [] patched_one = False @@ -454,6 +456,7 @@ def _convert_schema_to_3_0( result["type"] = non_null_types[0] elif not non_null_types: del result["type"] + result.setdefault("enum", [None]) else: _expand_type_union(result, non_null_types) type_array_rewritten = True @@ -481,8 +484,11 @@ def _convert_schema_to_3_0( result["nullable"] = True if result.get("type") == "null": + # Without ``type``, ``nullable`` alone accepts every value, so a null-only schema + # keeps an ``enum`` of ``null``; a ``not`` over it would otherwise reject everything. del result["type"] result["nullable"] = True + result.setdefault("enum", [None]) result = _collapse_nullable_combinator(result, schemas, resolving, lossy) @@ -538,14 +544,7 @@ def _convert_schema_to_3_0( result["additionalProperties"], schemas, resolving, lossy ) if "not" in result and isinstance(result["not"], dict): - negated = result["not"] - result["not"] = _convert_schema_to_3_0(negated, schemas, resolving, lossy) - if lossy is not None and _contains_null_only(negated, schemas): - lossy.append( - "a 'not' schema contains a null-only schema, which OpenAPI 3.0 can only " - "approximate as a type-less 'nullable' schema that accepts every value, so " - "the negation rejects values the original accepts" - ) + result["not"] = _convert_schema_to_3_0(result["not"], schemas, resolving, lossy) if "$defs" in result and isinstance(result["$defs"], dict): result["$defs"] = { k: _convert_schema_to_3_0(v, schemas, resolving, lossy) @@ -584,57 +583,6 @@ def _convert_schemas_to_3_0(schemas: dict[str, Any]) -> tuple[dict[str, Any], li ) -def _nested_schemas(schema: dict[str, Any], *, skip: frozenset[str] = frozenset()) -> Iterator[Any]: - """Yield what sits directly under the schema-bearing keywords of *schema*.""" - for key in _NESTED_SCHEMA_KEYS: - if key in skip: - continue - val = schema.get(key) - if isinstance(val, dict): - yield from val.values() if key in ("properties", "$defs") else [val] - elif isinstance(val, list): - yield from val - - -def _is_null_type(schema: Any) -> bool: - return isinstance(schema, dict) and schema.get("type") in ("null", ["null"]) - - -def _contains_null_only( - schema: Any, schemas: dict[str, Any] | None, seen: frozenset[str] = frozenset() -) -> bool: - """Whether *schema*, or a schema nested in it, accepts nothing but ``null`` by its own type. - - Conversion can only approximate that as a type-less ``nullable`` schema, which accepts - every value. The schema is read as written, not as converted: a ``nullable`` schema - already authored for 3.0 is exact, and keywords beside ``type: null`` change nothing - about the approximation. A null member beside other ``anyOf``/``oneOf`` members - collapses exactly into ``nullable``, so only a union made of null members alone counts. - A ``$ref`` into *schemas* is followed once per name. A nested ``not`` reports for - itself, so it is not entered. - """ - if not isinstance(schema, dict): - return False - if _is_null_type(schema): - return True - scanned = schema - for key in ("anyOf", "oneOf"): - members = schema.get(key) - if isinstance(members, list) and members: - if all(_is_null_type(m) for m in members): - return True - scanned = {**scanned, key: [m for m in members if not _is_null_type(m)]} - ref = schema.get("$ref") - if isinstance(ref, str) and schemas is not None and ref.startswith("#/components/schemas/"): - name = ref.rsplit("/", 1)[-1] - if name not in seen and _contains_null_only(schemas.get(name), schemas, seen | {name}): - return True - return any( - _contains_null_only(child, schemas, seen) - for child in _nested_schemas(scanned, skip=frozenset({"not"})) - ) - - def _find_3_1_only_construct(schema: dict[str, Any]) -> str | None: """Name the first construct in *schema* that OpenAPI 3.0 cannot express, if any. @@ -654,9 +602,18 @@ def _find_3_1_only_construct(schema: dict[str, Any]) -> str | None: if isinstance(item, dict) and item.get("type") == "null": return 'an "anyOf" with a null type' - for child in _nested_schemas(schema): - if (found := _find_3_1_only_construct(child)) is not None: - return found + for key in _NESTED_SCHEMA_KEYS: + val = schema.get(key) + children: Iterable[Any] + if isinstance(val, dict): + children = val.values() if key in ("properties", "$defs") else [val] + elif isinstance(val, list): + children = val + else: + continue + for child in children: + if (found := _find_3_1_only_construct(child)) is not None: + return found return None diff --git a/tests/test_openapi_3_0_nullable.py b/tests/test_openapi_3_0_nullable.py index 861e8903..0ef42651 100644 --- a/tests/test_openapi_3_0_nullable.py +++ b/tests/test_openapi_3_0_nullable.py @@ -98,6 +98,8 @@ def test_type_array_only_null(self) -> None: assert result["nullable"] is True assert "type" not in result + # A type-less ``nullable`` schema accepts every value, so it stays null-only. + assert result["enum"] == [None] def test_enum_with_null(self) -> None: # Regression (#782): ``None`` must stay IN the enum -- a sibling diff --git a/tests/test_spec_conversion_characterization.py b/tests/test_spec_conversion_characterization.py index b52c2b65..6d16fcd7 100644 --- a/tests/test_spec_conversion_characterization.py +++ b/tests/test_spec_conversion_characterization.py @@ -10,7 +10,6 @@ import pytest from azure_functions_openapi._schema_version_conversion import ( - _contains_null_only, _find_3_1_only_construct, _operation_schema_slots, ) @@ -213,6 +212,55 @@ [{"kind": "a"}], [{}, "a"], ), + ( + {"type": "null"}, + {"enum": [None], "nullable": True}, + {"type": "null"}, + [None], + ["x", 1, {}], + ), + ( + {"type": "null", "minLength": 1, "x-label": "a"}, + {"enum": [None], "nullable": True, "minLength": 1, "x-label": "a"}, + {"type": "null", "minLength": 1, "x-label": "a"}, + [None], + ["x", 1, {}], + ), + ( + {"const": None, "type": "null"}, + {"enum": [None], "nullable": True}, + {"const": None, "type": "null"}, + [None], + ["x", 1, {}], + ), + ( + {"anyOf": [{"type": "null"}]}, + {"enum": [None], "nullable": True}, + {"anyOf": [{"type": "null"}]}, + [None], + ["x", 1, {}], + ), + ( + {"properties": {"a": {"type": "null"}}}, + {"properties": {"a": {"enum": [None], "nullable": True}}}, + {"properties": {"a": {"type": "null"}}}, + [{}, {"a": None}], + [{"a": 1}], + ), + ( + {"not": {"type": "null"}}, + {"not": {"enum": [None], "nullable": True}}, + {"not": {"type": "null"}}, + ["x", 1, {}], + [None], + ), + ( + {"not": {"anyOf": [{"type": ["null"]}, {"type": "string"}]}}, + {"not": {"anyOf": [{"enum": [None], "nullable": True}, {"type": "string"}]}}, + {"not": {"anyOf": [{"type": ["null"]}, {"type": "string"}]}}, + [1, {}, [1]], + [None, "x"], + ), ], ids=[ "legacy-nullable", @@ -228,6 +276,13 @@ "nullable-union-of-type-array", "type-array-with-array-member", "single-type-array-beside-discriminator", + "null-only", + "null-only-with-sibling-keywords", + "null-literal", + "only-null-member", + "null-only-in-a-property", + "negated-null-only", + "negated-null-list-member", ], ) def test_schema_conversion_at_every_operation_position( @@ -518,242 +573,75 @@ def test_type_array_beside_a_reference_moves_the_reference_into_all_of( assert collect_spec_warnings(spec, registry) == () +@pytest.mark.parametrize("hoisted", [False, True], ids=["inline", "component"]) @pytest.mark.parametrize( - ("negated", "emitted", "lost"), + "negated", [ - ({"type": "null"}, {"nullable": True}, ["x", 1, {}]), - ( - {"type": "null", "description": "never null"}, - {"description": "never null", "nullable": True}, - ["x", 1, {}], - ), - ({"type": "null", "minLength": 1}, {"minLength": 1, "nullable": True}, ["x", 1, {}]), - ({"type": "null", "x-label": "a"}, {"x-label": "a", "nullable": True}, ["x", 1, {}]), - ({"type": ["null"]}, {"nullable": True}, ["x", 1, {}]), - ({"anyOf": [{"type": "null"}]}, {"nullable": True}, ["x", 1, {}]), - ( - {"properties": {"a": {"type": "null"}}}, - {"properties": {"a": {"nullable": True}}}, - [{"a": 1}], - ), - ( - {"allOf": [{"type": "string"}, {"type": "null"}]}, - {"allOf": [{"type": "string"}, {"nullable": True}]}, - ["x"], - ), - ], - ids=[ - "null-only", - "annotated-null-only", - "null-only-with-constraint", - "null-only-with-extension", - "null-only-type-list", - "only-null-member", - "nested-property", - "all-of-member", + {"type": "null"}, + {"type": "null", "minLength": 1}, + {"const": None, "type": "null"}, + {"anyOf": [{"type": ["null"]}, {"type": "string"}]}, + {"type": "string", "properties": {"a": {"type": "null"}}}, + {"allOf": [{"type": "string"}, {"type": "null"}]}, ], + ids=["null-only", "constrained", "null-literal", "null-list-member", "inapplicable", "all-of"], ) -@pytest.mark.parametrize("hoisted", [False, True], ids=["inline", "component"]) -@pytest.mark.parametrize("strict", [False, True]) -def test_negated_null_only_schema_is_reported_instead_of_silently_inverted( - negated: dict[str, JsonValue], - emitted: dict[str, JsonValue], - lost: list[JsonValue], - hoisted: bool, - strict: bool, +def test_negation_of_a_null_only_schema_accepts_what_the_original_accepts( + negated: dict[str, JsonValue], hoisted: bool ) -> None: - # Given: a null-only schema OpenAPI 3.0 can only approximate as a type-less - # ``nullable`` schema, which accepts every value; under ``not`` that approximation - # rejects values the original accepts, whatever keywords sit beside ``type: null``. + # Given: schemas an OpenAPI 3.0 reader could only get right if a null-only schema stays + # null-only; as a type-less ``nullable`` schema it would accept every value and the + # negation would reject every value. odd = {"not": negated} body = {"$ref": "#/$defs/Odd", "$defs": {"Odd": odd}} if hoisted else odd registry = OpenAPIRegistry() registry.set("odd", {"route": "odd", "method": "post", "request_body": body}) - message = "a 'not' schema contains a null-only schema" - # When / Then: strict mode refuses it, naming the problem. - if strict: - with pytest.raises(OpenAPISpecConfigError, match=message): - generate_openapi_spec(registry=registry, openapi_version="3.0.0", strict=True) - return - - # When: non-strict generation still produces a document. + # When spec = generate_openapi_spec( - registry=registry, openapi_version="3.0.0", hoist_flat_schemas=hoisted + registry=registry, openapi_version="3.0.0", hoist_flat_schemas=hoisted, strict=True ) - # Then: the loss is one structured warning that --fail-on-warnings can gate on, and - # the document really is the reported approximation. - (warning,) = collect_spec_warnings(spec, registry) - assert warning.code is WarningCode.VERSION_DOWNGRADE_DROP - assert message in warning.message + # Then: nothing is reported, and both versions accept exactly the same instances. + assert collect_spec_warnings(spec, registry) == () + validate(spec) if hoisted: - converted = spec["components"]["schemas"]["Odd"] root = {"$ref": "#/components/schemas/Odd", "components": spec["components"]} else: - converted = spec["paths"]["/api/odd"]["post"]["requestBody"]["content"]["application/json"][ + root = spec["paths"]["/api/odd"]["post"]["requestBody"]["content"]["application/json"][ "schema" ] - root = converted - assert converted == {"not": emitted} original = OAS31Validator(odd) - approximation = OAS30Validator(root) - assert all(original.is_valid(instance) for instance in lost) - assert not any(approximation.is_valid(instance) for instance in lost) - - -@pytest.mark.parametrize( - ("negated", "emitted", "valid", "invalid"), - [ - ({"type": ["string", "null"]}, {"type": "string", "nullable": True}, [1, {}], [None, "x"]), - ( - {"anyOf": [{"type": "string"}, {"type": "null"}]}, - {"type": "string", "nullable": True}, - [1, {}], - [None, "x"], - ), - ( - {"enum": [None, "a"]}, - {"enum": [None, "a"], "nullable": True}, - [1, "b"], - [None, "a"], - ), - ( - {"type": ["string", "integer", "null"]}, - { - "nullable": True, - "anyOf": [{"type": "string", "nullable": True}, {"type": "integer"}], - }, - [1.5, {}], - [None, "x", 1], - ), - ({"nullable": True}, {"nullable": True}, [], ["x", 1, None]), - ( - {"nullable": True, "x-label": "a"}, - {"nullable": True, "x-label": "a"}, - [], - ["x", 1, None], - ), - ], - ids=[ - "nullable-type", - "nullable-union", - "enum-with-null", - "nullable-type-array", - "nullable-written-for-3-0", - "nullable-written-for-3-0-with-extension", - ], -) -def test_negated_schema_that_stays_exact_is_not_reported( - negated: dict[str, JsonValue], - emitted: dict[str, JsonValue], - valid: list[JsonValue], - invalid: list[JsonValue], -) -> None: - # Given: negated schemas whose 3.0 form accepts exactly the original values, among - # them a ``nullable`` schema already written for 3.0, which conversion leaves alone. - registry = OpenAPIRegistry() - registry.set("exact", {"route": "exact", "method": "post", "request_body": {"not": negated}}) - - # When - spec = generate_openapi_spec(registry=registry, openapi_version="3.0.0", strict=True) - - # Then: nothing is reported, and the negation behaves like the original. - schema = spec["paths"]["/api/exact"]["post"]["requestBody"]["content"]["application/json"][ - "schema" + converted = OAS30Validator(root) + probes: list[JsonValue] = [None, "", "x", 0, 1.5, [], [None], {}, {"a": None}, {"a": 1}] + assert [converted.is_valid(probe) for probe in probes] == [ + original.is_valid(probe) for probe in probes ] - assert schema == {"not": emitted} - validate(spec) - validator = OAS30Validator(schema) - assert all(validator.is_valid(instance) for instance in valid) - assert not any(validator.is_valid(instance) for instance in invalid) - assert collect_spec_warnings(spec, registry) == () @pytest.mark.parametrize("strict", [False, True]) -def test_negated_reference_to_a_null_only_component_is_reported(strict: bool) -> None: - # Given: the null-only schema sits behind a ``$ref``, so the negated schema never - # says ``type: null`` itself. +def test_reference_to_a_null_only_component_stays_null_only(strict: bool) -> None: + # Given: the null-only schema is a component, reached through a ``$ref`` under ``not``. body = { "$ref": "#/$defs/Odd", "$defs": {"Odd": {"not": {"$ref": "#/$defs/Nothing"}}, "Nothing": {"type": "null"}}, } registry = OpenAPIRegistry() registry.set("odd", {"route": "odd", "method": "post", "request_body": body}) - message = "Schema 'Odd': a 'not' schema contains a null-only schema" - - # When / Then: strict mode refuses it; non-strict generation reports it once. - if strict: - with pytest.raises(OpenAPISpecConfigError, match=message): - generate_openapi_spec(registry=registry, openapi_version="3.0.0", strict=True) - return - spec = generate_openapi_spec(registry=registry, openapi_version="3.0.0") - (warning,) = collect_spec_warnings(spec, registry) - assert warning.code is WarningCode.VERSION_DOWNGRADE_DROP - assert message in warning.message + # When + spec = generate_openapi_spec(registry=registry, openapi_version="3.0.0", strict=strict) -@pytest.mark.parametrize( - ("schema", "components", "null_only"), - [ - ({"type": "null"}, None, True), - ({"type": ["null"]}, None, True), - ({"type": "null", "minLength": 1}, None, True), - ({"type": "null", "x-label": "a"}, None, True), - ({"anyOf": [{"type": "null"}, {"type": ["null"]}]}, None, True), - ({"oneOf": [{"type": "null"}]}, None, True), - ({"properties": {"a": {"type": "null"}}}, None, True), - ({"allOf": ["junk", {"type": "null"}]}, None, True), - ({"anyOf": [{"type": "string"}, {"type": "null"}]}, None, False), - ({"type": ["string", "null"]}, None, False), - ({"nullable": True}, None, False), - ({"nullable": True, "x-label": "a"}, None, False), - ({"enum": [None]}, None, False), - ({"const": None}, None, False), - ({"allOf": ["junk"]}, None, False), - ({"not": {"type": "null"}}, None, False), - ("junk", None, False), - ({"$ref": "#/components/schemas/Nothing"}, {"Nothing": {"type": "null"}}, True), - ( - {"$ref": "#/components/schemas/Loop"}, - {"Loop": {"items": {"$ref": "#/components/schemas/Loop"}}}, - False, - ), - ({"$ref": "#/components/schemas/Missing"}, {}, False), - ({"$ref": "#/components/schemas/Nothing"}, None, False), - ({"$ref": "#/$defs/Nothing"}, {"Nothing": {"type": "null"}}, False), - ], - ids=[ - "null-type", - "null-type-list", - "with-constraint", - "with-extension", - "all-null-any-of", - "single-null-one-of", - "nested-property", - "beside-junk", - "exact-union", - "exact-nullable-type", - "nullable-written-for-3-0", - "nullable-with-extension", - "enum-with-null", - "const-null", - "only-junk", - "nested-negation-reports-for-itself", - "not-a-schema", - "reference-to-null-only", - "reference-cycle", - "unknown-reference", - "no-components", - "not-a-component-reference", - ], -) -def test_null_only_detection_reads_the_schema_as_written( - schema: JsonValue, components: dict[str, JsonValue] | None, null_only: bool -) -> None: - # Given / When / Then: only a schema that accepts nothing but ``null`` by its own - # ``type`` counts, however many other keywords sit beside it. - assert _contains_null_only(schema, components) is null_only + # Then: the component keeps accepting only ``null``, so the negation accepts the rest. + assert spec["components"]["schemas"]["Nothing"] == {"enum": [None], "nullable": True} + validate(spec) + validator = OAS30Validator( + {"$ref": "#/components/schemas/Odd", "components": spec["components"]} + ) + rest: list[JsonValue] = ["x", 1, {}] + assert all(validator.is_valid(instance) for instance in rest) + assert not validator.is_valid(None) + assert collect_spec_warnings(spec, registry) == () @pytest.mark.parametrize( From 516a49ee8b1de9acb5badb59a0e6d4be64f8d6df Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 21:36:24 +0900 Subject: [PATCH 09/10] docs(spec): list the null-only union among the nullable collapse forms The collapse docstring enumerates every form it produces; add the one that keeps a union of null members alone null-only. Refs #858 Co-authored-by: sisyphus-dev-ai --- src/azure_functions_openapi/_schema_version_conversion.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/azure_functions_openapi/_schema_version_conversion.py b/src/azure_functions_openapi/_schema_version_conversion.py index a803c2bf..25a29ef7 100644 --- a/src/azure_functions_openapi/_schema_version_conversion.py +++ b/src/azure_functions_openapi/_schema_version_conversion.py @@ -228,6 +228,9 @@ def _collapse_nullable_combinator( one branch in the union actually accepts null, plus the same sibling ``nullable: true`` for discoverability (ignored by validation once a branch already accepts null, but documents the field's shape). + * null members alone -> the combinator is dropped and the schema stays + null-only (an ``enum`` of ``null`` plus ``nullable: true``), because + ``nullable`` without a ``type`` accepts every value. Nodes without a null-bearing ``anyOf``/``oneOf`` are returned unchanged. """ From 52f8f5dcc5c94c534aecb95b309cab31e894714b Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 21:44:49 +0900 Subject: [PATCH 10/10] fix(spec): intersect a null-only schema with the enum beside it Keeping a null-only schema null-only added an enum of null only when none existed, so type null beside enum [null, "x"] accepted "x" once the type was gone, and type null beside const "x" accepted "x" although it accepts nothing. Intersect with the existing enum: narrow a list that includes null to null, and add the null-only predicate as an allOf conjunct when the list excludes null, so the schema still accepts nothing. Refs #858 Co-authored-by: sisyphus-dev-ai --- docs/configuration.md | 4 +- .../_schema_version_conversion.py | 25 +++++- .../test_spec_conversion_characterization.py | 87 ++++++++++++++++++- 3 files changed, 110 insertions(+), 6 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index e3924e6c..2a5acdff 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -246,7 +246,9 @@ not define, for example `if`/`then`/`else` or `contains`, are not checked. A schema that accepts only `null` (`type: "null"`, or a union of nothing but null members) becomes `enum: [null]` with `nullable: true`. A type-less `nullable` schema would accept every value in OpenAPI 3.0, and a `not` over it would then -reject every value. +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. 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 25a29ef7..63fdc007 100644 --- a/src/azure_functions_openapi/_schema_version_conversion.py +++ b/src/azure_functions_openapi/_schema_version_conversion.py @@ -296,7 +296,7 @@ def _collapse_nullable_combinator( else: result[key] = non_null elif not non_null: - result.setdefault("enum", [None]) + _keep_null_only(result) elif len(non_null) > 1: patched_members: list[Any] = [] patched_one = False @@ -384,6 +384,23 @@ def _wrap_ref_siblings(schema: dict[str, Any]) -> None: schema["allOf"] = [{"$ref": schema.pop("$ref")}, *(all_of if isinstance(all_of, list) else [])] +def _keep_null_only(schema: dict[str, Any]) -> None: + """Keep *schema*, which just lost a null-only ``type``, accepting nothing but ``null``. + + ``nullable`` without a ``type`` accepts every value, so the schema gets an ``enum`` of + ``null`` instead. An ``enum`` it already carries (a ``const`` was turned into one) is + intersected with that: a list that includes ``null`` narrows to it, and one that + excludes it can be met by no instance, so the null-only predicate joins ``allOf`` and + the two together still accept nothing. + """ + enum = schema.get("enum") + if isinstance(enum, list) and None not in enum: + all_of = schema.get("allOf") + schema["allOf"] = [*(all_of if isinstance(all_of, list) else []), {"enum": [None]}] + else: + schema["enum"] = [None] + + def _convert_schema_to_3_0( schema: dict[str, Any], schemas: dict[str, Any] | None = None, @@ -459,7 +476,7 @@ def _convert_schema_to_3_0( result["type"] = non_null_types[0] elif not non_null_types: del result["type"] - result.setdefault("enum", [None]) + _keep_null_only(result) else: _expand_type_union(result, non_null_types) type_array_rewritten = True @@ -488,10 +505,10 @@ def _convert_schema_to_3_0( if result.get("type") == "null": # Without ``type``, ``nullable`` alone accepts every value, so a null-only schema - # keeps an ``enum`` of ``null``; a ``not`` over it would otherwise reject everything. + # must stay null-only; a ``not`` over it would otherwise reject everything. del result["type"] result["nullable"] = True - result.setdefault("enum", [None]) + _keep_null_only(result) result = _collapse_nullable_combinator(result, schemas, resolving, lossy) diff --git a/tests/test_spec_conversion_characterization.py b/tests/test_spec_conversion_characterization.py index 6d16fcd7..ec1113ad 100644 --- a/tests/test_spec_conversion_characterization.py +++ b/tests/test_spec_conversion_characterization.py @@ -6,7 +6,7 @@ from openapi_schema_validator import OAS30Validator, OAS31Validator from openapi_spec_validator import validate -from pydantic import JsonValue +from pydantic import BaseModel, Field, JsonValue import pytest from azure_functions_openapi._schema_version_conversion import ( @@ -14,6 +14,7 @@ _operation_schema_slots, ) from azure_functions_openapi._warnings import WarningCode +from azure_functions_openapi.decorator import register_openapi_metadata from azure_functions_openapi.exceptions import OpenAPISpecConfigError from azure_functions_openapi.registry import OpenAPIRegistry from azure_functions_openapi.spec import collect_spec_warnings, generate_openapi_spec @@ -261,6 +262,61 @@ [1, {}, [1]], [None, "x"], ), + ( + {"type": "null", "enum": [None, "x"]}, + {"enum": [None], "nullable": True}, + {"type": "null", "enum": [None, "x"]}, + [None], + ["x", 1, {}], + ), + ( + {"type": ["null"], "enum": [None, "x"]}, + {"enum": [None], "nullable": True}, + {"type": ["null"], "enum": [None, "x"]}, + [None], + ["x", 1, {}], + ), + ( + {"anyOf": [{"type": "null"}], "enum": [None, "x"]}, + {"enum": [None], "nullable": True}, + {"anyOf": [{"type": "null"}], "enum": [None, "x"]}, + [None], + ["x", 1, {}], + ), + ( + {"properties": {"a": {"type": "null", "enum": [None, "x"]}}}, + {"properties": {"a": {"enum": [None], "nullable": True}}}, + {"properties": {"a": {"type": "null", "enum": [None, "x"]}}}, + [{}, {"a": None}], + [{"a": "x"}], + ), + ( + {"type": "null", "enum": ["x"]}, + {"enum": ["x"], "nullable": True, "allOf": [{"enum": [None], "nullable": True}]}, + {"type": "null", "enum": ["x"]}, + [], + [None, "x", 1, {}], + ), + ( + {"type": "null", "const": "x"}, + {"enum": ["x"], "nullable": True, "allOf": [{"enum": [None], "nullable": True}]}, + {"type": "null", "const": "x"}, + [], + [None, "x", 1], + ), + ( + {"not": {"type": "null", "const": "x"}}, + { + "not": { + "enum": ["x"], + "nullable": True, + "allOf": [{"enum": [None], "nullable": True}], + } + }, + {"not": {"type": "null", "const": "x"}}, + [None, "x", 1, {}], + [], + ), ], ids=[ "legacy-nullable", @@ -283,6 +339,13 @@ "null-only-in-a-property", "negated-null-only", "negated-null-list-member", + "null-only-beside-a-broader-enum", + "null-list-beside-a-broader-enum", + "only-null-member-beside-a-broader-enum", + "null-only-property-beside-a-broader-enum", + "null-only-beside-an-enum-without-null", + "null-only-beside-a-const", + "negated-null-only-beside-a-const", ], ) def test_schema_conversion_at_every_operation_position( @@ -644,6 +707,28 @@ def test_reference_to_a_null_only_component_stays_null_only(strict: bool) -> Non assert collect_spec_warnings(spec, registry) == () +def test_none_typed_field_with_a_broader_enum_accepts_only_null() -> None: + # Given: Pydantic keeps both ``type: "null"`` and the enum that an extra supplies. + class Payload(BaseModel): + value: None = Field(default=None, json_schema_extra={"enum": [None, "x"]}) + + 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 3.0 property accepts exactly what the original property does. + original = OAS31Validator(Payload.model_json_schema()) + converted = OAS30Validator( + {"$ref": "#/components/schemas/Payload", "components": spec["components"]} + ) + probes: list[JsonValue] = [{}, {"value": None}, {"value": "x"}] + assert [converted.is_valid(probe) for probe in probes] == [True, True, False] + assert [original.is_valid(probe) for probe in probes] == [True, True, False] + assert collect_spec_warnings(spec, registry) == () + + @pytest.mark.parametrize( ("version", "header", "expected"), [