From 4005764582ad0e17a24b7784ff1cfae8d0150e08 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:16:11 +0100 Subject: [PATCH 1/7] fix: resolve OpenAPI schema validation warnings for union/optional fields Flatten Litestar's nullable oneOf unions to idiomatic JSON Schema 2020-12 type arrays, eliminating 17 of 18 Scalar UI "Expected union value" warnings. The remaining 1 (MeetingRecord) is a confirmed Scalar bug (scalar/scalar#8369) with no client-side workaround. - Primitive nullable (T | None): flatten to type: [T, "null"] - Enum $ref nullable: inline enum values and flatten - Object $ref nullable: convert oneOf to anyOf - Redundant oneOf with empty schema: collapse to concrete branch - Move RFC 9457 docs from info.description to info.x-documentation Closes #268 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/synthorg/api/openapi.py | 192 +++++++++++++++-- .../api/test_openapi_integration.py | 44 +++- tests/unit/api/test_openapi.py | 193 ++++++++++++++++-- 3 files changed, 399 insertions(+), 30 deletions(-) diff --git a/src/synthorg/api/openapi.py b/src/synthorg/api/openapi.py index 7fd9e55d69..db8de81ce6 100644 --- a/src/synthorg/api/openapi.py +++ b/src/synthorg/api/openapi.py @@ -8,11 +8,13 @@ This module provides :func:`inject_rfc9457_responses` which transforms the Litestar-generated schema dict to: -1. Add the ``ProblemDetail`` schema (RFC 9457 bare response body) -2. Define reusable error responses with dual content types -3. Inject error response references into every operation -4. Replace Litestar's default 400 schema with the actual envelope -5. Append content negotiation docs to ``info.description`` +1. Flatten nullable ``oneOf`` unions to JSON Schema 2020-12 ``type`` + arrays (fixes Scalar UI *"Expected union value"* warnings) +2. Add the ``ProblemDetail`` schema (RFC 9457 bare response body) +3. Define reusable error responses with dual content types +4. Inject error response references into every operation +5. Replace Litestar's default 400 schema with the actual envelope +6. Store content negotiation docs in ``info.x-documentation`` Called by ``scripts/export_openapi.py`` after schema generation. @@ -174,6 +176,163 @@ class _ErrorResponseSpec(NamedTuple): """ +# ── Nullable union normalization ────────────────────────────── + +_SCHEMAS_PREFIX: Final[str] = "#/components/schemas/" + + +def _flatten_nullable_ref( + result: dict[str, Any], + keyword: str, + branch: dict[str, Any], + all_schemas: dict[str, Any], +) -> bool: + """Inline a nullable ``$ref`` to an enum schema. + + When the ``$ref`` target is a simple enum (has ``type`` and + ``enum``), inlines the enum values and flattens to + ``{type: [T, "null"], enum: [..., null]}``. + + Returns ``True`` if the union was handled, ``False`` otherwise. + """ + ref: str = branch.get("$ref", "") + if not ref.startswith(_SCHEMAS_PREFIX): + return False + + target_name = ref.removeprefix(_SCHEMAS_PREFIX) + target = all_schemas.get(target_name, {}) + + if "enum" not in target or "type" not in target: + return False + + prop_desc = result.get("description") + merged = {k: v for k, v in target.items() if k not in ("title", "description")} + merged["type"] = [target["type"], "null"] + merged["enum"] = [*target["enum"], None] + del result[keyword] + result.update(merged) + if prop_desc: + result["description"] = prop_desc + return True + + +def _flatten_nullable( + result: dict[str, Any], + keyword: str, + items: list[Any], + all_schemas: dict[str, Any] | None = None, +) -> None: + """Flatten a nullable union (``T | None``) in *result* in place. + + * Primitive branch (has ``type``): collapses to + ``{type: [T, "null"], ...extras}``. + * ``$ref`` to enum: delegates to :func:`_flatten_nullable_ref`. + * Other ``$ref``: swaps ``oneOf`` to ``anyOf``. + """ + null_entries = [i for i in items if isinstance(i, dict) and i.get("type") == "null"] + if len(null_entries) != 1: + return + + non_null = [i for i in items if i is not null_entries[0]] + if len(non_null) != 1: + return + + branch = non_null[0] + if isinstance(branch, dict) and "type" in branch: + merged = {k: v for k, v in branch.items() if k != "type"} + merged["type"] = [branch["type"], "null"] + del result[keyword] + result.update(merged) + return + + if ( + isinstance(branch, dict) + and "$ref" in branch + and all_schemas + and _flatten_nullable_ref(result, keyword, branch, all_schemas) + ): + return + + if keyword == "oneOf": + result["anyOf"] = result.pop("oneOf") + + +_EXPECTED_UNION_BRANCHES: Final[int] = 2 + + +def _collapse_redundant_union( + result: dict[str, Any], + keyword: str, + items: list[Any], +) -> None: + """Collapse a redundant ``oneOf``/``anyOf`` with an empty schema. + + Litestar emits ``oneOf: [{$ref: ...}, {}]`` for tuple item + schemas. The empty ``{}`` matches anything, making the union + redundant -- collapse to just the concrete branch. + """ + if len(items) != _EXPECTED_UNION_BRANCHES: + return + empty_entries = [i for i in items if isinstance(i, dict) and not i] + if len(empty_entries) != 1: + return + concrete = [i for i in items if i is not empty_entries[0]] + if concrete: + del result[keyword] + result.update(concrete[0]) + + +def _normalize_nullable_unions( + obj: Any, + all_schemas: dict[str, Any] | None = None, +) -> Any: + """Flatten nullable union schemas to idiomatic JSON Schema 2020-12. + + Litestar wraps ``T | None`` fields in ``oneOf``, producing + ``oneOf: [{type: "string"}, {type: "null"}]``. Scalar UI + expects the compact ``type: ["string", "null"]`` form for + primitives, and ``anyOf`` for ``$ref``-based nullables. + + Args: + obj: Any JSON-serialisable value (typically the full OpenAPI + schema dict). + all_schemas: ``components.schemas`` dict used to resolve + ``$ref`` targets for enum inlining. When ``None``, + ``$ref``-based nullable enums are left as ``anyOf``. + + Conversion rules (applied to both ``oneOf`` and ``anyOf``): + + * **Primitive nullable** -- non-null branch has a ``type`` key: + merge into ``{type: [T, "null"], ...extras}``. + * **Enum $ref nullable** -- non-null branch is a ``$ref`` to a + simple enum: inline the enum values and flatten. + * **Object $ref nullable** -- non-null branch is a ``$ref`` to + a complex schema: convert to ``anyOf`` (known Scalar bug + `#8369 `_). + * **Redundant union** -- one branch is an empty schema ``{}``: + collapse to just the non-empty branch (Litestar emits this + for ``tuple[T, ...]`` item schemas). + * **Discriminated unions** -- no ``{"type": "null"}`` entry and + no empty-schema branch: left unchanged. + """ + if isinstance(obj, dict): + result = {k: _normalize_nullable_unions(v, all_schemas) for k, v in obj.items()} + + for keyword in ("oneOf", "anyOf"): + if keyword not in result or not isinstance(result[keyword], list): + continue + items: list[Any] = result[keyword] + _flatten_nullable(result, keyword, items, all_schemas) + if keyword in result: + _collapse_redundant_union(result, keyword, items) + + return result + + if isinstance(obj, list): + return [_normalize_nullable_unions(item, all_schemas) for item in obj] + return obj + + # ── Helpers ─────────────────────────────────────────────────── @@ -437,11 +596,14 @@ def _build_all_responses( def _update_info_description(info: dict[str, Any]) -> None: - """Append RFC 9457 documentation to ``info.description`` idempotently.""" - existing = info.get("description", "") - if "## Error Handling (RFC 9457)" not in existing: - separator = "\n\n" if existing else "" - info["description"] = f"{existing}{separator}{_RFC9457_DESCRIPTION_SECTION}" + """Store RFC 9457 documentation in an extension field. + + Uses ``x-documentation`` so the content is preserved in the + spec but not rendered inline by Scalar UI (which displays + ``info.description`` prominently at the top of the page). + """ + if "x-documentation" not in info: + info["x-documentation"] = {"rfc9457": _RFC9457_DESCRIPTION_SECTION} # ── Main function ───────────────────────────────────────────── @@ -453,11 +615,13 @@ def inject_rfc9457_responses(schema: dict[str, Any]) -> dict[str, Any]: Takes the raw schema dict produced by Litestar's ``app.openapi_schema.to_schema()`` and returns a **new** dict with: + - Nullable ``oneOf`` unions flattened to JSON Schema 2020-12 + ``type`` arrays (fixes Scalar UI validation warnings) - ``ProblemDetail`` added to ``components.schemas`` - Reusable error responses (dual content types) in ``components.responses`` - Error response refs injected into every operation - - RFC 9457 docs appended to ``info.description`` + - RFC 9457 docs stored in ``info.x-documentation`` Args: schema: OpenAPI schema dict (not modified). @@ -465,7 +629,7 @@ def inject_rfc9457_responses(schema: dict[str, Any]) -> dict[str, Any]: Returns: Enhanced copy of the schema. """ - result = copy.deepcopy(schema) + result: dict[str, Any] = copy.deepcopy(schema) components = result.setdefault("components", {}) schemas = components.setdefault("schemas", {}) @@ -480,6 +644,10 @@ def inject_rfc9457_responses(schema: dict[str, Any]) -> dict[str, Any]: ) _update_info_description(result.setdefault("info", {})) + # Normalize after all schemas are in place (including ProblemDetail). + # Workaround for Scalar bug: https://github.com/scalar/scalar/issues/8369 + result = _normalize_nullable_unions(result, all_schemas=schemas) + path_count = len(result.get("paths", {})) logger.debug( API_OPENAPI_SCHEMA_ENHANCED, diff --git a/tests/integration/api/test_openapi_integration.py b/tests/integration/api/test_openapi_integration.py index f16dc49731..e1370aaa81 100644 --- a/tests/integration/api/test_openapi_integration.py +++ b/tests/integration/api/test_openapi_integration.py @@ -59,5 +59,45 @@ def test_full_app_schema_enhancement() -> None: health = result["paths"]["/api/v1/health"]["get"]["responses"] assert "401" not in health - # Info description updated. - assert "RFC 9457" in result["info"]["description"] + # RFC 9457 docs in x-documentation, not info.description. + assert "RFC 9457" not in result["info"].get("description", "") + assert "rfc9457" in result["info"]["x-documentation"] + + +def _find_oneof_with_null( + obj: Any, + path: str = "$", +) -> list[str]: + """Find all ``oneOf`` arrays containing a null type.""" + violations: list[str] = [] + if isinstance(obj, dict): + if "oneOf" in obj and isinstance(obj["oneOf"], list): + for item in obj["oneOf"]: + if isinstance(item, dict) and item.get("type") == "null": + violations.append(path) + break + for key, value in obj.items(): + violations.extend(_find_oneof_with_null(value, f"{path}.{key}")) + elif isinstance(obj, list): + for i, item in enumerate(obj): + violations.extend(_find_oneof_with_null(item, f"{path}[{i}]")) + return violations + + +@pytest.mark.integration +def test_no_oneof_with_null_after_processing() -> None: + """No ``oneOf``-with-null survives post-processing. + + Catches regressions when new models with optional fields are + added. + """ + from synthorg.api.app import create_app + + app = create_app() + schema: dict[str, Any] = app.openapi_schema.to_schema() + result = inject_rfc9457_responses(schema) + + violations = _find_oneof_with_null(result) + assert violations == [], ( + f"oneOf-with-null found after post-processing: {violations}" + ) diff --git a/tests/unit/api/test_openapi.py b/tests/unit/api/test_openapi.py index 6faf66d8c6..403127f1ae 100644 --- a/tests/unit/api/test_openapi.py +++ b/tests/unit/api/test_openapi.py @@ -10,7 +10,11 @@ import pytest -from synthorg.api.openapi import _should_inject, inject_rfc9457_responses +from synthorg.api.openapi import ( + _normalize_nullable_unions, + _should_inject, + inject_rfc9457_responses, +) # ── Fixtures ────────────────────────────────────────────────── @@ -585,32 +589,32 @@ def test_unknown_key_returns_false(self) -> None: @pytest.mark.unit class TestInfoDescription: - """info.description is updated with RFC 9457 documentation.""" + """RFC 9457 documentation is stored in x-documentation extension.""" - def test_mentions_rfc_9457(self, base_schema: dict[str, Any]) -> None: + def test_rfc9457_in_x_documentation(self, base_schema: dict[str, Any]) -> None: result = inject_rfc9457_responses(base_schema) - desc = result["info"]["description"] - assert "RFC 9457" in desc + xdoc = result["info"]["x-documentation"] + assert "rfc9457" in xdoc + assert "RFC 9457" in xdoc["rfc9457"] - def test_mentions_content_negotiation(self, base_schema: dict[str, Any]) -> None: + def test_not_in_description(self, base_schema: dict[str, Any]) -> None: + """RFC 9457 docs should not pollute info.description.""" result = inject_rfc9457_responses(base_schema) - desc = result["info"]["description"] - assert "application/problem+json" in desc - assert "application/json" in desc + desc = result["info"].get("description", "") + assert "RFC 9457" not in desc - def test_mentions_error_reference(self, base_schema: dict[str, Any]) -> None: + def test_mentions_content_negotiation(self, base_schema: dict[str, Any]) -> None: result = inject_rfc9457_responses(base_schema) - desc = result["info"]["description"] - assert "synthorg.io/docs/errors" in desc + rfc_doc = result["info"]["x-documentation"]["rfc9457"] + assert "application/problem+json" in rfc_doc + assert "application/json" in rfc_doc def test_preserves_existing_description(self) -> None: - """Existing info.description is preserved with RFC section appended.""" + """Existing info.description is not modified.""" schema = _minimal_schema() schema["info"]["description"] = "My custom API description." result = inject_rfc9457_responses(schema) - desc = result["info"]["description"] - assert desc.startswith("My custom API description.") - assert "RFC 9457" in desc + assert result["info"]["description"] == "My custom API description." # ── Idempotency and immutability ────────────────────────────── @@ -645,3 +649,160 @@ def test_missing_components(self) -> None: } result = inject_rfc9457_responses(schema) assert "ProblemDetail" in result["components"]["schemas"] + + +# ── Nullable union normalization ───────────────────────────── + + +@pytest.mark.unit +class TestNullableUnionNormalization: + """Nullable oneOf/anyOf unions are flattened to type arrays.""" + + def test_primitive_oneof_flattened(self) -> None: + """oneOf with primitive + null becomes type array.""" + schema: dict[str, Any] = { + "oneOf": [{"type": "string"}, {"type": "null"}], + } + result = _normalize_nullable_unions(schema) + assert result == {"type": ["string", "null"]} + + def test_primitive_anyof_flattened(self) -> None: + """anyOf with primitive + null becomes type array.""" + schema: dict[str, Any] = { + "anyOf": [{"type": "integer"}, {"type": "null"}], + } + result = _normalize_nullable_unions(schema) + assert result == {"type": ["integer", "null"]} + + def test_constraints_preserved(self) -> None: + """Extra properties (minLength, format) are kept.""" + schema: dict[str, Any] = { + "oneOf": [ + {"type": "string", "format": "date-time"}, + {"type": "null"}, + ], + } + result = _normalize_nullable_unions(schema) + assert result == {"type": ["string", "null"], "format": "date-time"} + + def test_enum_ref_inlined(self) -> None: + """$ref to enum + null inlines enum values and flattens.""" + all_schemas: dict[str, Any] = { + "Status": { + "type": "string", + "enum": ["active", "inactive"], + "title": "Status", + }, + } + schema: dict[str, Any] = { + "description": "Current status", + "oneOf": [ + {"$ref": "#/components/schemas/Status"}, + {"type": "null"}, + ], + } + result = _normalize_nullable_unions(schema, all_schemas=all_schemas) + assert result["type"] == ["string", "null"] + assert result["enum"] == ["active", "inactive", None] + assert result["description"] == "Current status" + + def test_object_ref_becomes_anyof(self) -> None: + """$ref to object + null uses anyOf (Scalar bug #8369).""" + schema: dict[str, Any] = { + "oneOf": [ + {"$ref": "#/components/schemas/Minutes"}, + {"type": "null"}, + ], + } + result = _normalize_nullable_unions(schema) + assert "anyOf" in result + assert "oneOf" not in result + + def test_discriminated_union_preserved(self) -> None: + """oneOf without null stays oneOf.""" + schema: dict[str, Any] = { + "oneOf": [ + {"$ref": "#/components/schemas/TypeA"}, + {"$ref": "#/components/schemas/TypeB"}, + ], + } + result = _normalize_nullable_unions(schema) + assert "oneOf" in result + assert "anyOf" not in result + + def test_nested_properties_normalized(self) -> None: + """Nullable unions inside properties are flattened.""" + schema: dict[str, Any] = { + "type": "object", + "properties": { + "deadline": { + "oneOf": [{"type": "string"}, {"type": "null"}], + }, + }, + } + result = _normalize_nullable_unions(schema) + assert result["properties"]["deadline"] == { + "type": ["string", "null"], + } + + def test_redundant_empty_schema_collapsed(self) -> None: + """oneOf with $ref + empty {} collapses to just the $ref.""" + schema: dict[str, Any] = { + "items": { + "oneOf": [ + {"$ref": "#/components/schemas/Phase"}, + {}, + ], + }, + "type": "array", + } + result = _normalize_nullable_unions(schema) + assert result["items"] == { + "$ref": "#/components/schemas/Phase", + } + + def test_idempotent(self) -> None: + """Running normalization twice produces the same result.""" + schema: dict[str, Any] = { + "oneOf": [{"type": "string"}, {"type": "null"}], + } + first = _normalize_nullable_unions(schema) + second = _normalize_nullable_unions(first) + assert first == second + + def test_full_pipeline(self) -> None: + """Full inject_rfc9457_responses pipeline normalizes unions.""" + schema = _minimal_schema( + extra_schemas={ + "TaskStatus": { + "type": "string", + "enum": ["pending", "done"], + "title": "TaskStatus", + }, + "Task": { + "type": "object", + "properties": { + "assigned_to": { + "oneOf": [ + {"type": "string"}, + {"type": "null"}, + ], + }, + "status": { + "oneOf": [ + {"$ref": "#/components/schemas/TaskStatus"}, + {"type": "null"}, + ], + }, + }, + }, + }, + ) + result = inject_rfc9457_responses(schema) + task = result["components"]["schemas"]["Task"] + assert task["properties"]["assigned_to"] == { + "type": ["string", "null"], + } + status = task["properties"]["status"] + assert status["type"] == ["string", "null"] + assert status["enum"] == ["pending", "done", None] From b6bf7b45b7eb56408d5352550af580a38894d999 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:12:54 +0100 Subject: [PATCH 2/7] refactor: split nullable union tests and address review findings - Extract TestNullableUnionNormalization to test_openapi_nullable.py (test_openapi.py was 808 lines, now 647 -- under 800 limit) - Re-fetch items list after _flatten_nullable to prevent stale ref - Add clarifying comment about mutation contract on helper functions - Improve all_schemas docstring wording - Add 4 new edge-case tests: non-enum $ref with registry, non- component $ref prefix, multi-branch union, enum without description Pre-reviewed by 7 agents, 9 findings addressed Co-Authored-By: Claude Opus 4.6 (1M context) --- src/synthorg/api/openapi.py | 14 +- tests/unit/api/test_openapi.py | 163 +-------------- tests/unit/api/test_openapi_nullable.py | 257 ++++++++++++++++++++++++ 3 files changed, 268 insertions(+), 166 deletions(-) create mode 100644 tests/unit/api/test_openapi_nullable.py diff --git a/src/synthorg/api/openapi.py b/src/synthorg/api/openapi.py index db8de81ce6..c1c48ef536 100644 --- a/src/synthorg/api/openapi.py +++ b/src/synthorg/api/openapi.py @@ -177,6 +177,11 @@ class _ErrorResponseSpec(NamedTuple): # ── Nullable union normalization ────────────────────────────── +# +# The helpers below mutate ``result`` (a freshly constructed dict from +# the enclosing comprehension in ``_normalize_nullable_unions``) in +# place. They must not be called on the original input schema -- +# ``inject_rfc9457_responses`` deep-copies it first. _SCHEMAS_PREFIX: Final[str] = "#/components/schemas/" @@ -298,7 +303,8 @@ def _normalize_nullable_unions( schema dict). all_schemas: ``components.schemas`` dict used to resolve ``$ref`` targets for enum inlining. When ``None``, - ``$ref``-based nullable enums are left as ``anyOf``. + ``$ref``-based nullable unions are converted to ``anyOf`` + (enums cannot be inlined without schema resolution). Conversion rules (applied to both ``oneOf`` and ``anyOf``): @@ -321,10 +327,10 @@ def _normalize_nullable_unions( for keyword in ("oneOf", "anyOf"): if keyword not in result or not isinstance(result[keyword], list): continue - items: list[Any] = result[keyword] - _flatten_nullable(result, keyword, items, all_schemas) + _flatten_nullable(result, keyword, result[keyword], all_schemas) if keyword in result: - _collapse_redundant_union(result, keyword, items) + # Re-fetch: _flatten_nullable may have replaced the list. + _collapse_redundant_union(result, keyword, result[keyword]) return result diff --git a/tests/unit/api/test_openapi.py b/tests/unit/api/test_openapi.py index 403127f1ae..489f9bc9cf 100644 --- a/tests/unit/api/test_openapi.py +++ b/tests/unit/api/test_openapi.py @@ -10,11 +10,7 @@ import pytest -from synthorg.api.openapi import ( - _normalize_nullable_unions, - _should_inject, - inject_rfc9457_responses, -) +from synthorg.api.openapi import _should_inject, inject_rfc9457_responses # ── Fixtures ────────────────────────────────────────────────── @@ -649,160 +645,3 @@ def test_missing_components(self) -> None: } result = inject_rfc9457_responses(schema) assert "ProblemDetail" in result["components"]["schemas"] - - -# ── Nullable union normalization ───────────────────────────── - - -@pytest.mark.unit -class TestNullableUnionNormalization: - """Nullable oneOf/anyOf unions are flattened to type arrays.""" - - def test_primitive_oneof_flattened(self) -> None: - """oneOf with primitive + null becomes type array.""" - schema: dict[str, Any] = { - "oneOf": [{"type": "string"}, {"type": "null"}], - } - result = _normalize_nullable_unions(schema) - assert result == {"type": ["string", "null"]} - - def test_primitive_anyof_flattened(self) -> None: - """anyOf with primitive + null becomes type array.""" - schema: dict[str, Any] = { - "anyOf": [{"type": "integer"}, {"type": "null"}], - } - result = _normalize_nullable_unions(schema) - assert result == {"type": ["integer", "null"]} - - def test_constraints_preserved(self) -> None: - """Extra properties (minLength, format) are kept.""" - schema: dict[str, Any] = { - "oneOf": [ - {"type": "string", "format": "date-time"}, - {"type": "null"}, - ], - } - result = _normalize_nullable_unions(schema) - assert result == {"type": ["string", "null"], "format": "date-time"} - - def test_enum_ref_inlined(self) -> None: - """$ref to enum + null inlines enum values and flattens.""" - all_schemas: dict[str, Any] = { - "Status": { - "type": "string", - "enum": ["active", "inactive"], - "title": "Status", - }, - } - schema: dict[str, Any] = { - "description": "Current status", - "oneOf": [ - {"$ref": "#/components/schemas/Status"}, - {"type": "null"}, - ], - } - result = _normalize_nullable_unions(schema, all_schemas=all_schemas) - assert result["type"] == ["string", "null"] - assert result["enum"] == ["active", "inactive", None] - assert result["description"] == "Current status" - - def test_object_ref_becomes_anyof(self) -> None: - """$ref to object + null uses anyOf (Scalar bug #8369).""" - schema: dict[str, Any] = { - "oneOf": [ - {"$ref": "#/components/schemas/Minutes"}, - {"type": "null"}, - ], - } - result = _normalize_nullable_unions(schema) - assert "anyOf" in result - assert "oneOf" not in result - - def test_discriminated_union_preserved(self) -> None: - """oneOf without null stays oneOf.""" - schema: dict[str, Any] = { - "oneOf": [ - {"$ref": "#/components/schemas/TypeA"}, - {"$ref": "#/components/schemas/TypeB"}, - ], - } - result = _normalize_nullable_unions(schema) - assert "oneOf" in result - assert "anyOf" not in result - - def test_nested_properties_normalized(self) -> None: - """Nullable unions inside properties are flattened.""" - schema: dict[str, Any] = { - "type": "object", - "properties": { - "deadline": { - "oneOf": [{"type": "string"}, {"type": "null"}], - }, - }, - } - result = _normalize_nullable_unions(schema) - assert result["properties"]["deadline"] == { - "type": ["string", "null"], - } - - def test_redundant_empty_schema_collapsed(self) -> None: - """oneOf with $ref + empty {} collapses to just the $ref.""" - schema: dict[str, Any] = { - "items": { - "oneOf": [ - {"$ref": "#/components/schemas/Phase"}, - {}, - ], - }, - "type": "array", - } - result = _normalize_nullable_unions(schema) - assert result["items"] == { - "$ref": "#/components/schemas/Phase", - } - - def test_idempotent(self) -> None: - """Running normalization twice produces the same result.""" - schema: dict[str, Any] = { - "oneOf": [{"type": "string"}, {"type": "null"}], - } - first = _normalize_nullable_unions(schema) - second = _normalize_nullable_unions(first) - assert first == second - - def test_full_pipeline(self) -> None: - """Full inject_rfc9457_responses pipeline normalizes unions.""" - schema = _minimal_schema( - extra_schemas={ - "TaskStatus": { - "type": "string", - "enum": ["pending", "done"], - "title": "TaskStatus", - }, - "Task": { - "type": "object", - "properties": { - "assigned_to": { - "oneOf": [ - {"type": "string"}, - {"type": "null"}, - ], - }, - "status": { - "oneOf": [ - {"$ref": "#/components/schemas/TaskStatus"}, - {"type": "null"}, - ], - }, - }, - }, - }, - ) - result = inject_rfc9457_responses(schema) - task = result["components"]["schemas"]["Task"] - assert task["properties"]["assigned_to"] == { - "type": ["string", "null"], - } - status = task["properties"]["status"] - assert status["type"] == ["string", "null"] - assert status["enum"] == ["pending", "done", None] diff --git a/tests/unit/api/test_openapi_nullable.py b/tests/unit/api/test_openapi_nullable.py new file mode 100644 index 0000000000..68a8c75cf9 --- /dev/null +++ b/tests/unit/api/test_openapi_nullable.py @@ -0,0 +1,257 @@ +"""Tests for nullable union normalization in OpenAPI schema post-processing. + +Verifies that :func:`_normalize_nullable_unions` correctly flattens +``oneOf``/``anyOf`` nullable unions to JSON Schema 2020-12 ``type`` +arrays, inlines enum ``$ref`` targets, and collapses redundant unions. +""" + +from typing import Any + +import pytest + +from synthorg.api.openapi import ( + _normalize_nullable_unions, + inject_rfc9457_responses, +) + + +def _minimal_schema( + *, + extra_schemas: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build a minimal OpenAPI schema dict for normalization tests.""" + schemas: dict[str, Any] = { + "ErrorCode": {"type": "integer", "enum": [1000, 3001]}, + "ErrorCategory": {"type": "string", "enum": ["auth", "not_found"]}, + "ErrorDetail": {"type": "object", "properties": {}}, + "ApiResponse_NoneType_": {"type": "object", "properties": {}}, + } + if extra_schemas: + schemas.update(extra_schemas) + return { + "openapi": "3.1.0", + "info": {"title": "Test API", "version": "0.1.0"}, + "paths": {}, + "components": {"schemas": schemas}, + } + + +@pytest.mark.unit +class TestNullableUnionNormalization: + """Nullable oneOf/anyOf unions are flattened to type arrays.""" + + def test_primitive_oneof_flattened(self) -> None: + """oneOf with primitive + null becomes type array.""" + schema: dict[str, Any] = { + "oneOf": [{"type": "string"}, {"type": "null"}], + } + result = _normalize_nullable_unions(schema) + assert result == {"type": ["string", "null"]} + + def test_primitive_anyof_flattened(self) -> None: + """anyOf with primitive + null becomes type array.""" + schema: dict[str, Any] = { + "anyOf": [{"type": "integer"}, {"type": "null"}], + } + result = _normalize_nullable_unions(schema) + assert result == {"type": ["integer", "null"]} + + def test_constraints_preserved(self) -> None: + """Extra properties (minLength, format) are kept.""" + schema: dict[str, Any] = { + "oneOf": [ + {"type": "string", "format": "date-time"}, + {"type": "null"}, + ], + } + result = _normalize_nullable_unions(schema) + assert result == {"type": ["string", "null"], "format": "date-time"} + + def test_enum_ref_inlined(self) -> None: + """$ref to enum + null inlines enum values and flattens.""" + all_schemas: dict[str, Any] = { + "Status": { + "type": "string", + "enum": ["active", "inactive"], + "title": "Status", + }, + } + schema: dict[str, Any] = { + "description": "Current status", + "oneOf": [ + {"$ref": "#/components/schemas/Status"}, + {"type": "null"}, + ], + } + result = _normalize_nullable_unions(schema, all_schemas=all_schemas) + assert result["type"] == ["string", "null"] + assert result["enum"] == ["active", "inactive", None] + assert result["description"] == "Current status" + + def test_enum_ref_without_description(self) -> None: + """$ref to enum + null without description omits description key.""" + all_schemas: dict[str, Any] = { + "Status": { + "type": "string", + "enum": ["on", "off"], + "title": "Status", + }, + } + schema: dict[str, Any] = { + "oneOf": [ + {"$ref": "#/components/schemas/Status"}, + {"type": "null"}, + ], + } + result = _normalize_nullable_unions(schema, all_schemas=all_schemas) + assert result["type"] == ["string", "null"] + assert "description" not in result + + def test_object_ref_becomes_anyof(self) -> None: + """$ref to object + null uses anyOf (Scalar bug #8369).""" + schema: dict[str, Any] = { + "oneOf": [ + {"$ref": "#/components/schemas/Minutes"}, + {"type": "null"}, + ], + } + result = _normalize_nullable_unions(schema) + assert "anyOf" in result + assert "oneOf" not in result + + def test_object_ref_anyof_with_registry_stays_anyof(self) -> None: + """anyOf with non-enum $ref + null stays anyOf when registry provided.""" + all_schemas: dict[str, Any] = { + "Minutes": { + "type": "object", + "properties": {"text": {"type": "string"}}, + }, + } + schema: dict[str, Any] = { + "anyOf": [ + {"$ref": "#/components/schemas/Minutes"}, + {"type": "null"}, + ], + } + result = _normalize_nullable_unions(schema, all_schemas=all_schemas) + assert "anyOf" in result + assert len(result["anyOf"]) == 2 + + def test_ref_with_non_component_prefix_not_inlined(self) -> None: + """$ref with non-#/components/schemas/ prefix falls through.""" + all_schemas: dict[str, Any] = { + "Foo": {"type": "string", "enum": ["a", "b"]}, + } + schema: dict[str, Any] = { + "oneOf": [ + {"$ref": "#/$defs/Foo"}, + {"type": "null"}, + ], + } + result = _normalize_nullable_unions(schema, all_schemas=all_schemas) + # Falls through to oneOf -> anyOf conversion. + assert "anyOf" in result + assert "oneOf" not in result + + def test_multi_branch_union_not_flattened(self) -> None: + """Union with 3+ branches (including null) is not flattened.""" + schema: dict[str, Any] = { + "oneOf": [ + {"type": "string"}, + {"type": "integer"}, + {"type": "null"}, + ], + } + result = _normalize_nullable_unions(schema) + # Multiple non-null branches: left unchanged. + assert "oneOf" in result + assert len(result["oneOf"]) == 3 + + def test_discriminated_union_preserved(self) -> None: + """oneOf without null stays oneOf.""" + schema: dict[str, Any] = { + "oneOf": [ + {"$ref": "#/components/schemas/TypeA"}, + {"$ref": "#/components/schemas/TypeB"}, + ], + } + result = _normalize_nullable_unions(schema) + assert "oneOf" in result + assert "anyOf" not in result + + def test_nested_properties_normalized(self) -> None: + """Nullable unions inside properties are flattened.""" + schema: dict[str, Any] = { + "type": "object", + "properties": { + "deadline": { + "oneOf": [{"type": "string"}, {"type": "null"}], + }, + }, + } + result = _normalize_nullable_unions(schema) + assert result["properties"]["deadline"] == { + "type": ["string", "null"], + } + + def test_redundant_empty_schema_collapsed(self) -> None: + """oneOf with $ref + empty {} collapses to just the $ref.""" + schema: dict[str, Any] = { + "items": { + "oneOf": [ + {"$ref": "#/components/schemas/Phase"}, + {}, + ], + }, + "type": "array", + } + result = _normalize_nullable_unions(schema) + assert result["items"] == { + "$ref": "#/components/schemas/Phase", + } + + def test_idempotent(self) -> None: + """Running normalization twice produces the same result.""" + schema: dict[str, Any] = { + "oneOf": [{"type": "string"}, {"type": "null"}], + } + first = _normalize_nullable_unions(schema) + second = _normalize_nullable_unions(first) + assert first == second + + def test_full_pipeline(self) -> None: + """Full inject_rfc9457_responses pipeline normalizes unions.""" + schema = _minimal_schema( + extra_schemas={ + "TaskStatus": { + "type": "string", + "enum": ["pending", "done"], + "title": "TaskStatus", + }, + "Task": { + "type": "object", + "properties": { + "assigned_to": { + "oneOf": [ + {"type": "string"}, + {"type": "null"}, + ], + }, + "status": { + "oneOf": [ + {"$ref": "#/components/schemas/TaskStatus"}, + {"type": "null"}, + ], + }, + }, + }, + }, + ) + result = inject_rfc9457_responses(schema) + task = result["components"]["schemas"]["Task"] + assert task["properties"]["assigned_to"] == { + "type": ["string", "null"], + } + status = task["properties"]["status"] + assert status["type"] == ["string", "null"] + assert status["enum"] == ["pending", "done", None] From 0c508225a89e4261db0c61a9a6b79b2fdf679481 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:26:27 +0100 Subject: [PATCH 3/7] fix: use setdefault for x-documentation to handle pre-existing keys Address Gemini Code Assist review feedback -- the previous check would silently skip adding rfc9457 if x-documentation already existed without the rfc9457 key. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/synthorg/api/openapi.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/synthorg/api/openapi.py b/src/synthorg/api/openapi.py index c1c48ef536..c4d75c4fca 100644 --- a/src/synthorg/api/openapi.py +++ b/src/synthorg/api/openapi.py @@ -608,8 +608,8 @@ def _update_info_description(info: dict[str, Any]) -> None: spec but not rendered inline by Scalar UI (which displays ``info.description`` prominently at the top of the page). """ - if "x-documentation" not in info: - info["x-documentation"] = {"rfc9457": _RFC9457_DESCRIPTION_SECTION} + x_doc: dict[str, Any] = info.setdefault("x-documentation", {}) + x_doc.setdefault("rfc9457", _RFC9457_DESCRIPTION_SECTION) # ── Main function ───────────────────────────────────────────── From 7464a720faed7b167a6517fbd807261fdcec79a5 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:32:47 +0100 Subject: [PATCH 4/7] refactor: remove vendor name references from docstrings and comments Replace "Scalar UI" and "Scalar bug #8369" with vendor-agnostic wording per project conventions. Tool-specific context is preserved in the PR and issue tracker. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/synthorg/api/openapi.py | 14 +++++++------- tests/unit/api/test_openapi_nullable.py | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/synthorg/api/openapi.py b/src/synthorg/api/openapi.py index c4d75c4fca..4ae1a6f2b8 100644 --- a/src/synthorg/api/openapi.py +++ b/src/synthorg/api/openapi.py @@ -9,7 +9,7 @@ the Litestar-generated schema dict to: 1. Flatten nullable ``oneOf`` unions to JSON Schema 2020-12 ``type`` - arrays (fixes Scalar UI *"Expected union value"* warnings) + arrays (fixes API doc renderers *"Expected union value"* warnings) 2. Add the ``ProblemDetail`` schema (RFC 9457 bare response body) 3. Define reusable error responses with dual content types 4. Inject error response references into every operation @@ -294,7 +294,7 @@ def _normalize_nullable_unions( """Flatten nullable union schemas to idiomatic JSON Schema 2020-12. Litestar wraps ``T | None`` fields in ``oneOf``, producing - ``oneOf: [{type: "string"}, {type: "null"}]``. Scalar UI + ``oneOf: [{type: "string"}, {type: "null"}]``. API doc renderers expects the compact ``type: ["string", "null"]`` form for primitives, and ``anyOf`` for ``$ref``-based nullables. @@ -313,8 +313,8 @@ def _normalize_nullable_unions( * **Enum $ref nullable** -- non-null branch is a ``$ref`` to a simple enum: inline the enum values and flatten. * **Object $ref nullable** -- non-null branch is a ``$ref`` to - a complex schema: convert to ``anyOf`` (known Scalar bug - `#8369 `_). + a complex schema: convert to ``anyOf`` (known renderer + bug -- see linked issue for details). * **Redundant union** -- one branch is an empty schema ``{}``: collapse to just the non-empty branch (Litestar emits this for ``tuple[T, ...]`` item schemas). @@ -605,7 +605,7 @@ def _update_info_description(info: dict[str, Any]) -> None: """Store RFC 9457 documentation in an extension field. Uses ``x-documentation`` so the content is preserved in the - spec but not rendered inline by Scalar UI (which displays + spec but not rendered inline by API doc renderers (which displays ``info.description`` prominently at the top of the page). """ x_doc: dict[str, Any] = info.setdefault("x-documentation", {}) @@ -622,7 +622,7 @@ def inject_rfc9457_responses(schema: dict[str, Any]) -> dict[str, Any]: ``app.openapi_schema.to_schema()`` and returns a **new** dict with: - Nullable ``oneOf`` unions flattened to JSON Schema 2020-12 - ``type`` arrays (fixes Scalar UI validation warnings) + ``type`` arrays (fixes API doc renderers validation warnings) - ``ProblemDetail`` added to ``components.schemas`` - Reusable error responses (dual content types) in ``components.responses`` @@ -651,7 +651,7 @@ def inject_rfc9457_responses(schema: dict[str, Any]) -> dict[str, Any]: _update_info_description(result.setdefault("info", {})) # Normalize after all schemas are in place (including ProblemDetail). - # Workaround for Scalar bug: https://github.com/scalar/scalar/issues/8369 + # Workaround for Renderer bug workaround -- see issue #268 for details result = _normalize_nullable_unions(result, all_schemas=schemas) path_count = len(result.get("paths", {})) diff --git a/tests/unit/api/test_openapi_nullable.py b/tests/unit/api/test_openapi_nullable.py index 68a8c75cf9..369abbb7b8 100644 --- a/tests/unit/api/test_openapi_nullable.py +++ b/tests/unit/api/test_openapi_nullable.py @@ -108,7 +108,7 @@ def test_enum_ref_without_description(self) -> None: assert "description" not in result def test_object_ref_becomes_anyof(self) -> None: - """$ref to object + null uses anyOf (Scalar bug #8369).""" + """$ref to object + null uses anyOf (known renderer limitation).""" schema: dict[str, Any] = { "oneOf": [ {"$ref": "#/components/schemas/Minutes"}, From d455b6d19b49ee0021457f28373bdfeb00997469 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Thu, 19 Mar 2026 07:49:52 +0100 Subject: [PATCH 5/7] fix: guard x-documentation against non-dict values Handle edge case where info["x-documentation"] exists but is not a dict -- re-initialize to empty dict before inserting rfc9457 key. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/synthorg/api/openapi.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/synthorg/api/openapi.py b/src/synthorg/api/openapi.py index 4ae1a6f2b8..76a11479a2 100644 --- a/src/synthorg/api/openapi.py +++ b/src/synthorg/api/openapi.py @@ -608,7 +608,10 @@ def _update_info_description(info: dict[str, Any]) -> None: spec but not rendered inline by API doc renderers (which displays ``info.description`` prominently at the top of the page). """ - x_doc: dict[str, Any] = info.setdefault("x-documentation", {}) + x_doc = info.get("x-documentation") + if not isinstance(x_doc, dict): + x_doc = {} + info["x-documentation"] = x_doc x_doc.setdefault("rfc9457", _RFC9457_DESCRIPTION_SECTION) From 2a0ded3fcee0a03735363cb467816b59e99488ef Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:03:04 +0100 Subject: [PATCH 6/7] fix: correct grammar and duplicated word in docstrings - "expects" -> "expect" (subject-verb agreement) - Remove duplicated "workaround" in comment Co-Authored-By: Claude Opus 4.6 (1M context) --- src/synthorg/api/openapi.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/synthorg/api/openapi.py b/src/synthorg/api/openapi.py index 76a11479a2..41f3aeb205 100644 --- a/src/synthorg/api/openapi.py +++ b/src/synthorg/api/openapi.py @@ -295,7 +295,7 @@ def _normalize_nullable_unions( Litestar wraps ``T | None`` fields in ``oneOf``, producing ``oneOf: [{type: "string"}, {type: "null"}]``. API doc renderers - expects the compact ``type: ["string", "null"]`` form for + expect the compact ``type: ["string", "null"]`` form for primitives, and ``anyOf`` for ``$ref``-based nullables. Args: @@ -654,7 +654,7 @@ def inject_rfc9457_responses(schema: dict[str, Any]) -> dict[str, Any]: _update_info_description(result.setdefault("info", {})) # Normalize after all schemas are in place (including ProblemDetail). - # Workaround for Renderer bug workaround -- see issue #268 for details + # Workaround for renderer bug -- see issue #268 for details result = _normalize_nullable_unions(result, all_schemas=schemas) path_count = len(result.get("paths", {})) From 79c680342032bd3ca5945b3b898245ee9ae8b892 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:28:30 +0100 Subject: [PATCH 7/7] fix: restrict redundant union collapse to oneOf only Collapsing anyOf with an empty schema {} would change semantics -- {} matches anything, so anyOf with {} means "accept anything". Litestar only emits this pattern as oneOf for tuple item schemas. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/synthorg/api/openapi.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/synthorg/api/openapi.py b/src/synthorg/api/openapi.py index 41f3aeb205..7496aa1f96 100644 --- a/src/synthorg/api/openapi.py +++ b/src/synthorg/api/openapi.py @@ -270,13 +270,14 @@ def _collapse_redundant_union( keyword: str, items: list[Any], ) -> None: - """Collapse a redundant ``oneOf``/``anyOf`` with an empty schema. + """Collapse a redundant ``oneOf`` with an empty schema. Litestar emits ``oneOf: [{$ref: ...}, {}]`` for tuple item - schemas. The empty ``{}`` matches anything, making the union - redundant -- collapse to just the concrete branch. + schemas. Only applies to ``oneOf`` -- collapsing ``anyOf`` + with ``{}`` would change semantics (``{}`` matches anything, + so ``anyOf`` with ``{}`` means "accept anything"). """ - if len(items) != _EXPECTED_UNION_BRANCHES: + if keyword != "oneOf" or len(items) != _EXPECTED_UNION_BRANCHES: return empty_entries = [i for i in items if isinstance(i, dict) and not i] if len(empty_entries) != 1: