diff --git a/README.md b/README.md index 83ed513a..68bba2db 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ Options: --specify-tags TEXT -c, --custom-visitor PATH --disable-timestamp + --strict-nullable Respect explicit OpenAPI nullable flags when + generating models. --include-request-argument Auto-inject a FastAPI Request parameter into operations when not present. -d, --output-model-type [pydantic_v2.BaseModel|pydantic_v2.dataclass|dataclasses.dataclass|typing.TypedDict|msgspec.Struct] diff --git a/docs/cli-reference.md b/docs/cli-reference.md index d5d27bc2..e21d191f 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -18,6 +18,8 @@ Options: --specify-tags TEXT -c, --custom-visitor PATH --disable-timestamp + --strict-nullable Respect explicit OpenAPI nullable flags when + generating models. --include-request-argument Auto-inject a FastAPI Request parameter into operations when not present. -d, --output-model-type [pydantic_v2.BaseModel|pydantic_v2.dataclass|dataclasses.dataclass|typing.TypedDict|msgspec.Struct] @@ -99,6 +101,14 @@ Omit the generated timestamp header from output files. Input schema: `openapi/disable_timestamp/simple.yaml` +### --strict-nullable + +Respect explicit OpenAPI nullable flags when generating models. + +`fastapi-codegen --input openapi/default_template/nullable_test.yaml --output app --strict-nullable` + +Input schema: `openapi/default_template/nullable_test.yaml` + ### --generate-routers Generate modular router files from tagged OpenAPI operations. diff --git a/docs/index.md b/docs/index.md index 660b8412..3f66c74b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -57,6 +57,8 @@ Options: --specify-tags TEXT -c, --custom-visitor PATH --disable-timestamp + --strict-nullable Respect explicit OpenAPI nullable flags when + generating models. --include-request-argument Auto-inject a FastAPI Request parameter into operations when not present. -d, --output-model-type [pydantic_v2.BaseModel|pydantic_v2.dataclass|dataclasses.dataclass|typing.TypedDict|msgspec.Struct] diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 5b48dc6e..4895eb1a 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -59,6 +59,8 @@ Options: --specify-tags TEXT -c, --custom-visitor PATH --disable-timestamp + --strict-nullable Respect explicit OpenAPI nullable flags when + generating models. --include-request-argument Auto-inject a FastAPI Request parameter into operations when not present. -d, --output-model-type [pydantic_v2.BaseModel|pydantic_v2.dataclass|dataclasses.dataclass|typing.TypedDict|msgspec.Struct] @@ -342,6 +344,8 @@ Options: --specify-tags TEXT -c, --custom-visitor PATH --disable-timestamp + --strict-nullable Respect explicit OpenAPI nullable flags when + generating models. --include-request-argument Auto-inject a FastAPI Request parameter into operations when not present. -d, --output-model-type [pydantic_v2.BaseModel|pydantic_v2.dataclass|dataclasses.dataclass|typing.TypedDict|msgspec.Struct] @@ -423,6 +427,14 @@ Omit the generated timestamp header from output files. Input schema: `openapi/disable_timestamp/simple.yaml` +### --strict-nullable + +Respect explicit OpenAPI nullable flags when generating models. + +`fastapi-codegen --input openapi/default_template/nullable_test.yaml --output app --strict-nullable` + +Input schema: `openapi/default_template/nullable_test.yaml` + ### --generate-routers Generate modular router files from tagged OpenAPI operations. @@ -515,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` (23 fixtures) | Primary fixture format exercised under `tests/data/openapi/**/*.yaml`. | +| OpenAPI YAML | tested | `tests/data/openapi/**/*.yaml` (24 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. | @@ -523,7 +535,7 @@ Run `tox run -e schema-docs` after changing supported inputs or model backends. | Suite | Fixtures | Example files | Notes | |-------|----------|---------------|-------| -| Default template | 16 | `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 | 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. | | 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 ee947ec0..5282a946 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` (23 fixtures) | Primary fixture format exercised under `tests/data/openapi/**/*.yaml`. | +| OpenAPI YAML | tested | `tests/data/openapi/**/*.yaml` (24 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 | 16 | `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 | 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. | | 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/_types/generate_config_dict.py b/fastapi_code_generator/_types/generate_config_dict.py index 4c355787..1524f4b4 100644 --- a/fastapi_code_generator/_types/generate_config_dict.py +++ b/fastapi_code_generator/_types/generate_config_dict.py @@ -30,5 +30,6 @@ class GenerateConfigDict(TypedDict): ] python_version: NotRequired[Literal['3.10', '3.11', '3.12', '3.13', '3.14']] specify_tags: NotRequired[str | None] + strict_nullable: NotRequired[bool] template_dir: NotRequired[str | None] use_annotated: NotRequired[bool] diff --git a/fastapi_code_generator/cli.py b/fastapi_code_generator/cli.py index 52c3ced0..7dd833d9 100644 --- a/fastapi_code_generator/cli.py +++ b/fastapi_code_generator/cli.py @@ -90,6 +90,11 @@ def main( None, "--custom-visitor", "-c" ), disable_timestamp: bool = typer.Option(False, "--disable-timestamp"), + strict_nullable: bool = typer.Option( + False, + "--strict-nullable", + help="Respect explicit OpenAPI nullable flags when generating models.", + ), include_request_argument: bool = typer.Option( False, "--include-request-argument", @@ -137,6 +142,7 @@ def main( enum_field_as_literal=enum_field_as_literal or None, custom_visitors=custom_visitors, disable_timestamp=disable_timestamp, + strict_nullable=strict_nullable, include_request_argument=include_request_argument, generate_routers=generate_routers, specify_tags=specify_tags, @@ -176,6 +182,7 @@ def generate_code( enum_field_as_literal: Optional[LiteralType] = None, custom_visitors: Optional[List[Path]] = None, disable_timestamp: bool = False, + strict_nullable: bool = False, include_request_argument: bool = False, generate_routers: Optional[bool] = None, specify_tags: Optional[str] = None, @@ -209,6 +216,7 @@ def generate_code( dump_resolve_reference_action=data_model_types.dump_resolve_reference_action, custom_template_dir=model_template_dir, target_python_version=python_version, + strict_nullable=strict_nullable, include_request_argument=include_request_argument, use_annotated=use_annotated, ) diff --git a/fastapi_code_generator/config.py b/fastapi_code_generator/config.py index d827b505..267fb0df 100644 --- a/fastapi_code_generator/config.py +++ b/fastapi_code_generator/config.py @@ -150,6 +150,11 @@ class GenerateConfig(BaseModel): description="Omit timestamp headers from generated files.", json_schema_extra=cast(Any, _cli_metadata("--disable-timestamp")), ) + strict_nullable: bool = Field( + default=False, + description="Respect explicit OpenAPI nullable flags when generating models.", + json_schema_extra=cast(Any, _cli_metadata("--strict-nullable")), + ) include_request_argument: bool = Field( default=False, description=( diff --git a/fastapi_code_generator/prompt_data.py b/fastapi_code_generator/prompt_data.py index e0ff260f..507fe8f1 100644 --- a/fastapi_code_generator/prompt_data.py +++ b/fastapi_code_generator/prompt_data.py @@ -121,6 +121,17 @@ 'type': 'boolean', 'choices': [], }, + { + 'name': 'strict_nullable', + 'cli_flags': ['--strict-nullable'], + 'description': 'Respect explicit OpenAPI nullable flags when ' + 'generating models.', + 'required': False, + 'default': False, + 'multiple': False, + 'type': 'boolean', + 'choices': [], + }, { 'name': 'include_request_argument', 'cli_flags': ['--include-request-argument'], @@ -261,6 +272,19 @@ ], 'input_schema': 'openapi/disable_timestamp/simple.yaml', }, + { + 'options': ['--strict-nullable'], + 'description': 'Respect explicit OpenAPI nullable flags when ' + 'generating models.', + 'cli_args': [ + '--input', + 'openapi/default_template/nullable_test.yaml', + '--output', + 'app', + '--strict-nullable', + ], + 'input_schema': 'openapi/default_template/nullable_test.yaml', + }, { 'options': ['--generate-routers'], 'description': 'Generate modular router files from tagged OpenAPI ' @@ -461,7 +485,7 @@ '\n' '| Format | Status | Evidence | Notes |\n' '|--------|--------|----------|-------|\n' - '| OpenAPI YAML | tested | `tests/data/openapi/**/*.yaml` (23 ' + '| OpenAPI YAML | tested | `tests/data/openapi/**/*.yaml` (24 ' 'fixtures) | Primary fixture format exercised under ' '`tests/data/openapi/**/*.yaml`. |\n' '| OpenAPI JSON | tested | ' @@ -477,7 +501,7 @@ '\n' '| Suite | Fixtures | Example files | Notes |\n' '|-------|----------|---------------|-------|\n' - '| Default template | 16 | `body_and_parameters.yaml`, ' + '| 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. |\n' diff --git a/tests/data/expected/openapi/default_template/nullable_test/main.py b/tests/data/expected/openapi/default_template/nullable_test/main.py new file mode 100644 index 00000000..6cb589a2 --- /dev/null +++ b/tests/data/expected/openapi/default_template/nullable_test/main.py @@ -0,0 +1,24 @@ +# generated by fastapi-codegen: +# filename: nullable_test.yaml +# timestamp: 2020-06-19T00:00:00+00:00 + +from __future__ import annotations + +from fastapi import FastAPI + +from .models import User + +app = FastAPI( + version='1.0.0', + title='Nullable Test API', + description='API for testing nullable field behavior', + servers=[{'url': 'http://api.example.com/v1'}], +) + + +@app.get('/users', response_model=User) +def get_user_details() -> User: + """ + Get user details + """ + pass diff --git a/tests/data/expected/openapi/default_template/nullable_test/models.py b/tests/data/expected/openapi/default_template/nullable_test/models.py new file mode 100644 index 00000000..007c92c3 --- /dev/null +++ b/tests/data/expected/openapi/default_template/nullable_test/models.py @@ -0,0 +1,21 @@ +# generated by fastapi-codegen: +# filename: nullable_test.yaml +# timestamp: 2020-06-19T00:00:00+00:00 + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, Field + + +class User(BaseModel): + id: int + username: str + email: Optional[str] = Field( + None, description="User's email address (explicitly nullable)" + ) + phone: str = Field(..., description="User's phone number (explicitly non-nullable)") + nickname: Optional[str] = Field( + None, description="User's nickname (optional, not nullable under strict mode)" + ) diff --git a/tests/data/expected/openapi/default_template/nullable_test_strict/main.py b/tests/data/expected/openapi/default_template/nullable_test_strict/main.py new file mode 100644 index 00000000..6cb589a2 --- /dev/null +++ b/tests/data/expected/openapi/default_template/nullable_test_strict/main.py @@ -0,0 +1,24 @@ +# generated by fastapi-codegen: +# filename: nullable_test.yaml +# timestamp: 2020-06-19T00:00:00+00:00 + +from __future__ import annotations + +from fastapi import FastAPI + +from .models import User + +app = FastAPI( + version='1.0.0', + title='Nullable Test API', + description='API for testing nullable field behavior', + servers=[{'url': 'http://api.example.com/v1'}], +) + + +@app.get('/users', response_model=User) +def get_user_details() -> User: + """ + Get user details + """ + pass diff --git a/tests/data/expected/openapi/default_template/nullable_test_strict/models.py b/tests/data/expected/openapi/default_template/nullable_test_strict/models.py new file mode 100644 index 00000000..007c92c3 --- /dev/null +++ b/tests/data/expected/openapi/default_template/nullable_test_strict/models.py @@ -0,0 +1,21 @@ +# generated by fastapi-codegen: +# filename: nullable_test.yaml +# timestamp: 2020-06-19T00:00:00+00:00 + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, Field + + +class User(BaseModel): + id: int + username: str + email: Optional[str] = Field( + None, description="User's email address (explicitly nullable)" + ) + phone: str = Field(..., description="User's phone number (explicitly non-nullable)") + nickname: Optional[str] = Field( + None, description="User's nickname (optional, not nullable under strict mode)" + ) diff --git a/tests/data/openapi/default_template/nullable_test.yaml b/tests/data/openapi/default_template/nullable_test.yaml new file mode 100644 index 00000000..cc928dfc --- /dev/null +++ b/tests/data/openapi/default_template/nullable_test.yaml @@ -0,0 +1,44 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: Nullable Test API + description: API for testing nullable field behavior +servers: + - url: http://api.example.com/v1 +paths: + /users: + get: + summary: Get user details + operationId: getUserDetails + responses: + "200": + description: User details + content: + application/json: + schema: + $ref: "#/components/schemas/User" +components: + schemas: + User: + type: object + required: + - id + - phone + - username + properties: + id: + type: integer + format: int64 + username: + type: string + email: + type: string + nullable: true + description: User's email address (explicitly nullable) + phone: + type: string + nullable: false + description: User's phone number (explicitly non-nullable) + nickname: + type: string + description: User's nickname (optional, not nullable under strict mode) diff --git a/tests/main/test_main.py b/tests/main/test_main.py index eabd57f9..ca93509a 100644 --- a/tests/main/test_main.py +++ b/tests/main/test_main.py @@ -431,6 +431,33 @@ def test_disable_timestamp(oas_file: Path, output_dir: Path) -> None: ) +@pytest.mark.cli_doc( + options=["--strict-nullable"], + option_description="Respect explicit OpenAPI nullable flags when generating models.", + cli_args=[ + "--input", + "openapi/default_template/nullable_test.yaml", + "--output", + "app", + "--strict-nullable", + ], + input_schema="openapi/default_template/nullable_test.yaml", + golden_output="openapi/default_template/nullable_test_strict/models.py", +) +@freeze_time("2020-06-19") +def test_generate_with_strict_nullable(output_dir: Path) -> None: + run_cli_and_assert( + input_path=DATA_PATH + / OPEN_API_DEFAULT_TEMPLATE_DIR_NAME + / "nullable_test.yaml", + output_path=output_dir, + expected_path=EXPECTED_OPENAPI_PATH + / "default_template" + / "nullable_test_strict", + extra_args=["--strict-nullable"], + ) + + @pytest.mark.cli_doc( options=["--generate-routers"], option_description="Generate modular router files from tagged OpenAPI operations.",