From 93ca45db1f511e37947e8e22f5c9b5a5c7cf8450 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 28 Apr 2026 03:11:16 +0000 Subject: [PATCH 1/2] Fix discriminated union simple types --- docs/llms-full.txt | 4 +- docs/supported_formats.md | 4 +- fastapi_code_generator/parser.py | 36 +++++ fastapi_code_generator/prompt_data.py | 4 +- .../discriminated_union_simple_type/main.py | 12 ++ .../discriminated_union_simple_type/models.py | 35 +++++ .../discriminated_union_simple_type.yaml | 62 +++++++++ tests/main/test_main.py | 30 ++++ tests/test_parser.py | 128 ++++++++++++++++++ 9 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 tests/data/expected/openapi/default_template/discriminated_union_simple_type/main.py create mode 100644 tests/data/expected/openapi/default_template/discriminated_union_simple_type/models.py create mode 100644 tests/data/openapi/default_template/discriminated_union_simple_type.yaml diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 90faced6..456b60d1 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -527,7 +527,7 @@ Run `tox run -e schema-docs` after changing supported inputs or model backends. | Format | Status | Evidence | Notes | |--------|--------|----------|-------| -| OpenAPI YAML | tested | `tests/data/openapi/**/*.yaml` (24 fixtures) | Primary fixture format exercised under `tests/data/openapi/**/*.yaml`. | +| OpenAPI YAML | tested | `tests/data/openapi/**/*.yaml` (25 fixtures) | Primary fixture format exercised under `tests/data/openapi/**/*.yaml`. | | OpenAPI JSON | tested | `tests/main/test_main.py::test_generate_from_json_input` | Covered by the JSON conversion CLI test in `tests/main/test_main.py`. | | Remote HTTP `$ref` targets | tested | `tests/main/test_main.py::test_generate_remote_ref` | Covered by the remote `$ref` generation test against a live HTTP server. | @@ -535,7 +535,7 @@ Run `tox run -e schema-docs` after changing supported inputs or model backends. | Suite | Fixtures | Example files | Notes | |-------|----------|---------------|-------| -| Default template | 17 | `body_and_parameters.yaml`, `content_in_parameters.yaml`, `content_in_parameters_inline.yaml` | Core single-file generation scenarios exercised by the main CLI tests. | +| Default template | 18 | `body_and_parameters.yaml`, `content_in_parameters.yaml`, `content_in_parameters_inline.yaml` | Core single-file generation scenarios exercised by the main CLI tests. | | Coverage fixtures | 3 | `callbacks.yaml`, `callbacks_with_operation_id.yaml`, `non_200_responses.yaml` | Focused fixtures for callbacks, non-200 responses, and other regression edges. | | Custom template overrides | 1 | `custom_security.yaml` | Template override coverage for `--template-dir`. | | Timestamp suppression | 1 | `simple.yaml` | Fixtures that exercise `--disable-timestamp`. | diff --git a/docs/supported_formats.md b/docs/supported_formats.md index 5282a946..f6ccbaa9 100644 --- a/docs/supported_formats.md +++ b/docs/supported_formats.md @@ -7,7 +7,7 @@ Run `tox run -e schema-docs` after changing supported inputs or model backends. | Format | Status | Evidence | Notes | |--------|--------|----------|-------| -| OpenAPI YAML | tested | `tests/data/openapi/**/*.yaml` (24 fixtures) | Primary fixture format exercised under `tests/data/openapi/**/*.yaml`. | +| OpenAPI YAML | tested | `tests/data/openapi/**/*.yaml` (25 fixtures) | Primary fixture format exercised under `tests/data/openapi/**/*.yaml`. | | OpenAPI JSON | tested | `tests/main/test_main.py::test_generate_from_json_input` | Covered by the JSON conversion CLI test in `tests/main/test_main.py`. | | Remote HTTP `$ref` targets | tested | `tests/main/test_main.py::test_generate_remote_ref` | Covered by the remote `$ref` generation test against a live HTTP server. | @@ -15,7 +15,7 @@ Run `tox run -e schema-docs` after changing supported inputs or model backends. | Suite | Fixtures | Example files | Notes | |-------|----------|---------------|-------| -| Default template | 17 | `body_and_parameters.yaml`, `content_in_parameters.yaml`, `content_in_parameters_inline.yaml` | Core single-file generation scenarios exercised by the main CLI tests. | +| Default template | 18 | `body_and_parameters.yaml`, `content_in_parameters.yaml`, `content_in_parameters_inline.yaml` | Core single-file generation scenarios exercised by the main CLI tests. | | Coverage fixtures | 3 | `callbacks.yaml`, `callbacks_with_operation_id.yaml`, `non_200_responses.yaml` | Focused fixtures for callbacks, non-200 responses, and other regression edges. | | Custom template overrides | 1 | `custom_security.yaml` | Template override coverage for `--template-dir`. | | Timestamp suppression | 1 | `simple.yaml` | Fixtures that exercise `--disable-timestamp`. | diff --git a/fastapi_code_generator/parser.py b/fastapi_code_generator/parser.py index 633d2894..4e4b390d 100644 --- a/fastapi_code_generator/parser.py +++ b/fastapi_code_generator/parser.py @@ -626,6 +626,42 @@ def parse_request_body( self._temporary_operation['_request'] = arguments[0] if arguments else None return request_body_fields + def get_field_extras(self, obj: JsonSchemaObject) -> dict[str, Any]: + extras = super().get_field_extras(obj) + if self._has_non_object_discriminator_variant(obj): + extras.pop('discriminator', None) + return extras + + def _has_non_object_discriminator_variant(self, obj: JsonSchemaObject) -> bool: + if not obj.discriminator: + return False + combined_schemas = obj.oneOf or obj.anyOf + if not combined_schemas: + return False + return any( + not self._is_object_discriminator_variant(schema) + for schema in combined_schemas + ) + + def _is_object_discriminator_variant( + self, schema: JsonSchemaObject, seen_refs: set[str] | None = None + ) -> bool: + if seen_refs is None: + seen_refs = set() + if schema.ref: + if schema.ref in seen_refs: + return False + seen_refs.add(schema.ref) + schema = JsonSchemaObject.model_validate(self.get_ref_model(schema.ref)) + if schema.is_object or schema.properties: + return True + if not schema.allOf: + return False + return any( + self._is_object_discriminator_variant(member, seen_refs.copy()) + for member in schema.allOf + ) + def _get_upload_file_type( self, schema: Union[JsonSchemaObject, ReferenceObject] ) -> tuple[str, str]: diff --git a/fastapi_code_generator/prompt_data.py b/fastapi_code_generator/prompt_data.py index 3d58b1be..971294d3 100644 --- a/fastapi_code_generator/prompt_data.py +++ b/fastapi_code_generator/prompt_data.py @@ -485,7 +485,7 @@ '\n' '| Format | Status | Evidence | Notes |\n' '|--------|--------|----------|-------|\n' - '| OpenAPI YAML | tested | `tests/data/openapi/**/*.yaml` (24 ' + '| OpenAPI YAML | tested | `tests/data/openapi/**/*.yaml` (25 ' 'fixtures) | Primary fixture format exercised under ' '`tests/data/openapi/**/*.yaml`. |\n' '| OpenAPI JSON | tested | ' @@ -501,7 +501,7 @@ '\n' '| Suite | Fixtures | Example files | Notes |\n' '|-------|----------|---------------|-------|\n' - '| Default template | 17 | `body_and_parameters.yaml`, ' + '| Default template | 18 | `body_and_parameters.yaml`, ' '`content_in_parameters.yaml`, ' '`content_in_parameters_inline.yaml` | Core single-file ' 'generation scenarios exercised by the main CLI tests. |\n' diff --git a/tests/data/expected/openapi/default_template/discriminated_union_simple_type/main.py b/tests/data/expected/openapi/default_template/discriminated_union_simple_type/main.py new file mode 100644 index 00000000..783dff26 --- /dev/null +++ b/tests/data/expected/openapi/default_template/discriminated_union_simple_type/main.py @@ -0,0 +1,12 @@ +# generated by fastapi-codegen: +# filename: discriminated_union_simple_type.yaml +# timestamp: 2020-06-19T00:00:00+00:00 + +from __future__ import annotations + +from fastapi import FastAPI + +app = FastAPI( + title='discriminated_union_simple_type', + version='1.0.0', +) diff --git a/tests/data/expected/openapi/default_template/discriminated_union_simple_type/models.py b/tests/data/expected/openapi/default_template/discriminated_union_simple_type/models.py new file mode 100644 index 00000000..f9c50e11 --- /dev/null +++ b/tests/data/expected/openapi/default_template/discriminated_union_simple_type/models.py @@ -0,0 +1,35 @@ +# generated by fastapi-codegen: +# filename: discriminated_union_simple_type.yaml +# timestamp: 2020-06-19T00:00:00+00:00 + +from __future__ import annotations + +from typing import Union + +from pydantic import BaseModel, Field, RootModel + + +class ImageUrl(BaseModel): + kind: str + image_url: str + + +class AudioUrl(BaseModel): + kind: str + audio_url: str + + +class DocumentUrl(BaseModel): + kind: str + document_url: str + + +class BinaryContent(BaseModel): + kind: str + binary_content: str + + +class Content(RootModel[Union[str, ImageUrl, AudioUrl, DocumentUrl, BinaryContent]]): + root: Union[str, ImageUrl, AudioUrl, DocumentUrl, BinaryContent] = Field( + ..., description='Union type representing different content formats' + ) diff --git a/tests/data/openapi/default_template/discriminated_union_simple_type.yaml b/tests/data/openapi/default_template/discriminated_union_simple_type.yaml new file mode 100644 index 00000000..8f20445d --- /dev/null +++ b/tests/data/openapi/default_template/discriminated_union_simple_type.yaml @@ -0,0 +1,62 @@ +info: + title: discriminated_union_simple_type + version: 1.0.0 +openapi: 3.0.0 +paths: {} +components: + schemas: + Content: + oneOf: + - type: string + - $ref: '#/components/schemas/ImageUrl' + - $ref: '#/components/schemas/AudioUrl' + - $ref: '#/components/schemas/DocumentUrl' + - $ref: '#/components/schemas/BinaryContent' + discriminator: + propertyName: kind + mapping: + image_url: '#/components/schemas/ImageUrl' + audio_url: '#/components/schemas/AudioUrl' + document_url: '#/components/schemas/DocumentUrl' + binary_content: '#/components/schemas/BinaryContent' + description: Union type representing different content formats + ImageUrl: + type: object + properties: + kind: + type: string + image_url: + type: string + required: + - kind + - image_url + AudioUrl: + type: object + properties: + kind: + type: string + audio_url: + type: string + required: + - kind + - audio_url + DocumentUrl: + type: object + properties: + kind: + type: string + document_url: + type: string + required: + - kind + - document_url + BinaryContent: + type: object + properties: + kind: + type: string + binary_content: + type: string + required: + - kind + - binary_content diff --git a/tests/main/test_main.py b/tests/main/test_main.py index c524dcc7..4ef78e4b 100644 --- a/tests/main/test_main.py +++ b/tests/main/test_main.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib.util import json from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -45,6 +46,17 @@ def assert_specific_tag_routers_generated(output_dir: Path) -> None: validate_generated_code(output_dir) +def assert_generated_module_has_attribute( + module_path: Path, module_name: str, attribute_name: str +) -> None: + spec = importlib.util.spec_from_file_location(module_name, module_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + assert hasattr(module, attribute_name) + + @pytest.mark.cli_doc( options=["--help"], option_description="Show the CLI help message.", @@ -239,6 +251,24 @@ def test_generate_from_json_input(tmp_path: Path, output_dir: Path) -> None: validate_generated_code(output_dir) +@freeze_time("2020-06-19") +def test_generate_discriminated_union_with_simple_type(output_dir: Path) -> None: + run_cli_and_assert( + input_path=DATA_PATH + / OPEN_API_DEFAULT_TEMPLATE_DIR_NAME + / "discriminated_union_simple_type.yaml", + output_path=output_dir, + expected_path=EXPECTED_OPENAPI_PATH + / "default_template" + / "discriminated_union_simple_type", + ) + assert_generated_module_has_attribute( + output_dir / "models.py", + "generated_discriminated_union_simple_type", + "Content", + ) + + @freeze_time("2020-06-19") def test_generate_escapes_aliases_in_parameter_defaults(output_dir: Path) -> None: spec = """openapi: 3.0.0 diff --git a/tests/test_parser.py b/tests/test_parser.py index 9baeae17..7ad098d1 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -44,6 +44,13 @@ def test_useful_str_case_helpers_match_legacy_stringcase_behavior( assert useful_value.pascalcase == expected_pascal +def assert_field_extras( + parser: OpenAPIParser, schema_data: dict[str, object], expected: dict[str, object] +) -> None: + schema = JsonSchemaObject.model_validate(schema_data) + assert parser.get_field_extras(schema) == expected + + def test_get_upload_file_type_resolves_reference(tmp_path: Path) -> None: schema_path = tmp_path / "schema.yaml" schema_path.write_text( @@ -156,3 +163,124 @@ def test_parse_request_body_filters_multipart_from_mixed_content() -> None: ) assert set(request_body_fields) == {"application/json"} + + +def test_get_field_extras_preserves_non_union_discriminator() -> None: + parser = OpenAPIParser( + "openapi: 3.0.0\ninfo: {title: Test, version: '1.0'}\npaths: {}\n" + ) + assert_field_extras( + parser, + { + "type": "object", + "discriminator": { + "propertyName": "kind", + }, + }, + {"discriminator": {"propertyName": "kind"}}, + ) + + +def test_get_field_extras_removes_discriminator_for_all_of_simple_variant() -> None: + parser = OpenAPIParser( + "openapi: 3.0.0\ninfo: {title: Test, version: '1.0'}\npaths: {}\n" + ) + assert_field_extras( + parser, + { + "oneOf": [ + { + "allOf": [ + { + "type": "string", + } + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + }, + }, + }, + ], + "discriminator": { + "propertyName": "kind", + }, + }, + {}, + ) + + +def test_get_field_extras_preserves_discriminator_for_all_of_object_variant() -> None: + parser = OpenAPIParser( + "openapi: 3.0.0\ninfo: {title: Test, version: '1.0'}\npaths: {}\n" + ) + assert_field_extras( + parser, + { + "oneOf": [ + { + "allOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + }, + }, + } + ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + }, + }, + }, + ], + "discriminator": { + "propertyName": "kind", + }, + }, + {"discriminator": {"propertyName": "kind"}}, + ) + + +def test_get_field_extras_removes_discriminator_for_cyclic_all_of_ref( + monkeypatch: pytest.MonkeyPatch, +) -> None: + parser = OpenAPIParser( + "openapi: 3.0.0\ninfo: {title: Test, version: '1.0'}\npaths: {}\n" + ) + schema_ref = "#/components/schemas/Loop" + + def get_ref_model(ref: str) -> dict[str, object]: + return {"allOf": [{"$ref": ref}]} + + monkeypatch.setattr(parser, "get_ref_model", get_ref_model) + assert_field_extras( + parser, + { + "oneOf": [ + { + "$ref": schema_ref, + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + }, + }, + }, + ], + "discriminator": { + "propertyName": "kind", + }, + }, + {}, + ) From 69080890816f8afca296052397b80871f474e5ba Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Thu, 30 Apr 2026 04:22:28 +0000 Subject: [PATCH 2/2] Address discriminator union review feedback --- fastapi_code_generator/parser.py | 2 +- tests/test_parser.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/fastapi_code_generator/parser.py b/fastapi_code_generator/parser.py index 4e4b390d..a9624089 100644 --- a/fastapi_code_generator/parser.py +++ b/fastapi_code_generator/parser.py @@ -635,7 +635,7 @@ def get_field_extras(self, obj: JsonSchemaObject) -> dict[str, Any]: def _has_non_object_discriminator_variant(self, obj: JsonSchemaObject) -> bool: if not obj.discriminator: return False - combined_schemas = obj.oneOf or obj.anyOf + combined_schemas = [*(obj.oneOf or []), *(obj.anyOf or [])] if not combined_schemas: return False return any( diff --git a/tests/test_parser.py b/tests/test_parser.py index 7ad098d1..72589343 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -284,3 +284,33 @@ def get_ref_model(ref: str) -> dict[str, object]: }, {}, ) + + +def test_get_field_extras_checks_one_of_and_any_of_variants() -> None: + parser = OpenAPIParser( + "openapi: 3.0.0\ninfo: {title: Test, version: '1.0'}\npaths: {}\n" + ) + assert_field_extras( + parser, + { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + }, + }, + }, + ], + "anyOf": [ + { + "type": "string", + }, + ], + "discriminator": { + "propertyName": "kind", + }, + }, + {}, + )