From 47eb4c2f53f757ed68995eed00a6c4aab2c4762b Mon Sep 17 00:00:00 2001 From: Goopher Maijenburg Date: Mon, 22 Jun 2026 20:02:03 +0200 Subject: [PATCH 1/6] fix(python-fastapi): relax strict typing for path/query/header params (#21905) Pydantic strict types (StrictInt/StrictStr/StrictFloat) and Field(strict=True) disable automatic string coercion. Since path/query/header values always arrive on the wire as strings, FastAPI rejected otherwise-valid requests with a 422 (int_type: Input should be a valid integer). Add a relaxStrict flag to PydanticType that emits coercible types (int/str/float) and omits strict=True, gated behind a shouldRelaxStrictParameterTyping() hook that defaults to false. PythonFastAPIServerCodegen overrides it for path/query/header params. Body params and the Python client generator are unchanged (strict typing is correct for JSON bodies). Regenerated the python-fastapi petstore sample. Claude-Session: https://claude.ai/code/session_019g7iwAyg7ErX1WrhqHyTn6 --- .../languages/AbstractPythonCodegen.java | 71 +++++++++++++++++-- .../languages/PythonFastAPIServerCodegen.java | 12 ++++ .../src/openapi_server/apis/fake_api.py | 6 +- .../src/openapi_server/apis/fake_api_base.py | 6 +- .../src/openapi_server/apis/pet_api.py | 16 ++--- .../src/openapi_server/apis/pet_api_base.py | 16 ++--- .../src/openapi_server/apis/store_api.py | 6 +- .../src/openapi_server/apis/store_api_base.py | 6 +- .../src/openapi_server/apis/user_api.py | 10 +-- .../src/openapi_server/apis/user_api_base.py | 10 +-- 10 files changed, 115 insertions(+), 44 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java index 510cf09b44c7..d114e6916165 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java @@ -1301,6 +1301,24 @@ public void updateImportsFromCodegenModel(String modelName, CodegenModel cm, Set } } + /** + * Whether the given request parameter should be typed with coercible types + * ({@code int}/{@code str}/{@code float}) instead of Pydantic strict types + * ({@code StrictInt}/{@code StrictStr}/{@code StrictFloat}, {@code strict=True}). + * + *

The default is {@code false}, preserving strict typing for all generators + * (notably the Python client, which builds JSON request bodies where strict + * validation is desirable). Server generators that parse path/query/header values + * from the wire — where everything arrives as a string and relies on Pydantic + * coercion — should override this for non-body parameters. See issue #21905. + * + * @param parameter the request parameter being typed + * @return {@code true} to relax strict typing for this parameter + */ + protected boolean shouldRelaxStrictParameterTyping(CodegenParameter parameter) { + return false; + } + @Override public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List allModels) { hasModelsToImport = false; @@ -1324,7 +1342,8 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List postponedExampleImports; private PythonImports moduleImports; private String classname; + // When true, emit coercible types (int/str/float) instead of Pydantic strict + // types (StrictInt/StrictStr/StrictFloat) and omit the strict=True constraint. + // Used for non-body request parameters, whose values always arrive as strings + // on the wire and rely on Pydantic's automatic coercion. See issue #21905. + private boolean relaxStrict; public PydanticType( Set modelImports, @@ -1851,6 +1875,18 @@ public PydanticType( Set postponedExampleImports, PythonImports moduleImports, String classname + ) { + this(modelImports, exampleImports, postponedModelImports, postponedExampleImports, moduleImports, classname, false); + } + + public PydanticType( + Set modelImports, + Set exampleImports, + Set postponedModelImports, + Set postponedExampleImports, + PythonImports moduleImports, + String classname, + boolean relaxStrict ) { this.modelImports = modelImports; this.exampleImports = exampleImports; @@ -1858,6 +1894,7 @@ public PydanticType( this.postponedExampleImports = postponedExampleImports; this.moduleImports = moduleImports; this.classname = classname; + this.relaxStrict = relaxStrict; } private PythonType arrayType(IJsonSchemaValidationProperties cp) { @@ -1904,7 +1941,9 @@ private PythonType stringType(IJsonSchemaValidationProperties cp) { PythonType pt = new PythonType("str"); // e.g. constr(regex=r'/[a-z]/i', strict=True) - pt.constrain("strict", true); + if (!relaxStrict) { + pt.constrain("strict", true); + } if (cp.getMaxLength() != null) { pt.constrain("max_length", cp.getMaxLength()); } @@ -1922,6 +1961,8 @@ private PythonType stringType(IJsonSchemaValidationProperties cp) { if ("password".equals(cp.getFormat())) { // TODO avoid using format, use `is` boolean flag instead moduleImports.add(PYDANTIC, "SecretStr"); return new PythonType("SecretStr"); + } else if (relaxStrict) { + return new PythonType("str"); } else { moduleImports.add(PYDANTIC, "StrictStr"); return new PythonType("StrictStr"); @@ -1966,8 +2007,10 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { } if ("Union[StrictFloat, StrictInt]".equals(mapNumberTo)) { - floatt.constrain("strict", true); - intt.constrain("strict", true); + if (!relaxStrict) { + floatt.constrain("strict", true); + intt.constrain("strict", true); + } moduleImports.add(TYPING, "Union"); PythonType pt = new PythonType("Union"); @@ -1975,7 +2018,9 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { pt.addTypeParam(intt); return pt; } else if ("StrictFloat".equals(mapNumberTo)) { - floatt.constrain("strict", true); + if (!relaxStrict) { + floatt.constrain("strict", true); + } return floatt; } else { // float return floatt; @@ -1983,6 +2028,12 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { } else { if ("Union[StrictFloat, StrictInt]".equals(mapNumberTo)) { moduleImports.add(TYPING, "Union"); + if (relaxStrict) { + PythonType pt = new PythonType("Union"); + pt.addTypeParam(new PythonType("float")); + pt.addTypeParam(new PythonType("int")); + return pt; + } moduleImports.add(PYDANTIC, "StrictFloat"); moduleImports.add(PYDANTIC, "StrictInt"); PythonType pt = new PythonType("Union"); @@ -1990,6 +2041,9 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { pt.addTypeParam(new PythonType("StrictInt")); return pt; } else if ("StrictFloat".equals(mapNumberTo)) { + if (relaxStrict) { + return new PythonType("float"); + } moduleImports.add(PYDANTIC, "StrictFloat"); return new PythonType("StrictFloat"); } else { @@ -2002,10 +2056,15 @@ private PythonType intType(IJsonSchemaValidationProperties cp) { if (cp.getHasValidation()) { PythonType pt = new PythonType("int"); // e.g. conint(ge=10, le=100, strict=True) - pt.constrain("strict", true); + if (!relaxStrict) { + pt.constrain("strict", true); + } applyConstraints(pt, cp); return pt; } else { + if (relaxStrict) { + return new PythonType("int"); + } moduleImports.add(PYDANTIC, "StrictInt"); return new PythonType("StrictInt"); } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java index ab6a95fcc3a1..db0fb5be7efe 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java @@ -237,6 +237,18 @@ public String getTypeDeclaration(Schema p) { return super.getTypeDeclaration(p); } + /** + * Path/query/header parameters arrive on the wire as strings and rely on Pydantic's automatic + * coercion (e.g. {@code "3" -> 3}). Pydantic strict typing disables that coercion, making FastAPI + * reject otherwise-valid requests with a 422 ({@code int_type: Input should be a valid integer}). + * So relax strict typing for those parameters. Body parameters keep strict typing, since JSON + * request bodies carry real types and strict validation is desirable there. See issue #21905. + */ + @Override + protected boolean shouldRelaxStrictParameterTyping(CodegenParameter parameter) { + return parameter.isQueryParam || parameter.isPathParam || parameter.isHeaderParam; + } + @Override public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List allModels) { super.postProcessOperationsWithModels(objs, allModels); diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api.py index 0e3f8d51b319..56b3e6e64381 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api.py @@ -23,7 +23,7 @@ ) from openapi_server.models.extra_models import TokenModel # noqa: F401 -from pydantic import Field, StrictStr +from pydantic import Field from typing import Any, Optional from typing_extensions import Annotated @@ -46,8 +46,8 @@ response_model_by_alias=True, ) async def fake_query_param_default( - has_default: Annotated[Optional[StrictStr], Field(description="has default value")] = Query('Hello World', description="has default value", alias="hasDefault"), - no_default: Annotated[Optional[StrictStr], Field(description="no default value")] = Query(None, description="no default value", alias="noDefault"), + has_default: Annotated[Optional[str], Field(description="has default value")] = Query('Hello World', description="has default value", alias="hasDefault"), + no_default: Annotated[Optional[str], Field(description="no default value")] = Query(None, description="no default value", alias="noDefault"), ) -> None: """""" if not BaseFakeApi.subclasses: diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api_base.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api_base.py index 1c71537aa944..e8343c5d1330 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api_base.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api_base.py @@ -2,7 +2,7 @@ from typing import ClassVar, Dict, List, Tuple # noqa: F401 -from pydantic import Field, StrictStr +from pydantic import Field from typing import Any, Optional from typing_extensions import Annotated @@ -15,8 +15,8 @@ def __init_subclass__(cls, **kwargs): BaseFakeApi.subclasses = BaseFakeApi.subclasses + (cls,) async def fake_query_param_default( self, - has_default: Annotated[Optional[StrictStr], Field(description="has default value")], - no_default: Annotated[Optional[StrictStr], Field(description="no default value")], + has_default: Annotated[Optional[str], Field(description="has default value")], + no_default: Annotated[Optional[str], Field(description="no default value")], ) -> None: """""" ... diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py index 660a8d99d437..870db9dc5a56 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py @@ -23,7 +23,7 @@ ) from openapi_server.models.extra_models import TokenModel # noqa: F401 -from pydantic import Field, StrictBytes, StrictInt, StrictStr, field_validator +from pydantic import Field, StrictBytes, StrictStr, field_validator from typing import Any, List, Optional, Tuple, Union from typing_extensions import Annotated from openapi_server.models.api_response import ApiResponse @@ -95,7 +95,7 @@ async def add_pet( response_model_by_alias=True, ) async def find_pets_by_status( - status: Annotated[List[StrictStr], Field(description="Status values that need to be considered for filter")] = Query(None, description="Status values that need to be considered for filter", alias="status"), + status: Annotated[List[str], Field(description="Status values that need to be considered for filter")] = Query(None, description="Status values that need to be considered for filter", alias="status"), token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["read:pets"] ), @@ -117,7 +117,7 @@ async def find_pets_by_status( response_model_by_alias=True, ) async def find_pets_by_tags( - tags: Annotated[List[StrictStr], Field(description="Tags to filter by")] = Query(None, description="Tags to filter by", alias="tags"), + tags: Annotated[List[str], Field(description="Tags to filter by")] = Query(None, description="Tags to filter by", alias="tags"), token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["read:pets"] ), @@ -140,7 +140,7 @@ async def find_pets_by_tags( response_model_by_alias=True, ) async def get_pet_by_id( - petId: Annotated[StrictInt, Field(description="ID of pet to return")] = Path(..., description="ID of pet to return"), + petId: Annotated[int, Field(description="ID of pet to return")] = Path(..., description="ID of pet to return"), token_api_key: TokenModel = Security( get_token_api_key ), @@ -161,7 +161,7 @@ async def get_pet_by_id( response_model_by_alias=True, ) async def update_pet_with_form( - petId: Annotated[StrictInt, Field(description="ID of pet that needs to be updated")] = Path(..., description="ID of pet that needs to be updated"), + petId: Annotated[int, Field(description="ID of pet that needs to be updated")] = Path(..., description="ID of pet that needs to be updated"), name: Annotated[Optional[StrictStr], Field(description="Updated name of the pet")] = Form(None, description="Updated name of the pet"), status: Annotated[Optional[StrictStr], Field(description="Updated status of the pet")] = Form(None, description="Updated status of the pet"), token_petstore_auth: TokenModel = Security( @@ -184,8 +184,8 @@ async def update_pet_with_form( response_model_by_alias=True, ) async def delete_pet( - petId: Annotated[StrictInt, Field(description="Pet id to delete")] = Path(..., description="Pet id to delete"), - api_key: Optional[StrictStr] = Header(None, description=""), + petId: Annotated[int, Field(description="Pet id to delete")] = Path(..., description="Pet id to delete"), + api_key: Optional[str] = Header(None, description=""), token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["write:pets", "read:pets"] ), @@ -206,7 +206,7 @@ async def delete_pet( response_model_by_alias=True, ) async def upload_file( - petId: Annotated[StrictInt, Field(description="ID of pet to update")] = Path(..., description="ID of pet to update"), + petId: Annotated[int, Field(description="ID of pet to update")] = Path(..., description="ID of pet to update"), additional_metadata: Annotated[Optional[StrictStr], Field(description="Additional data to pass to server")] = Form(None, description="Additional data to pass to server"), file: Optional[UploadFile] = File(None, description="file to upload"), token_petstore_auth: TokenModel = Security( diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api_base.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api_base.py index 4f8b292e3eec..dd8780dc2416 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api_base.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api_base.py @@ -2,7 +2,7 @@ from typing import ClassVar, Dict, List, Tuple # noqa: F401 -from pydantic import Field, StrictBytes, StrictInt, StrictStr, field_validator +from pydantic import Field, StrictBytes, StrictStr, field_validator from typing import Any, List, Optional, Tuple, Union from typing_extensions import Annotated from openapi_server.models.api_response import ApiResponse @@ -34,7 +34,7 @@ async def add_pet( async def find_pets_by_status( self, - status: Annotated[List[StrictStr], Field(description="Status values that need to be considered for filter")], + status: Annotated[List[str], Field(description="Status values that need to be considered for filter")], ) -> List[Pet]: """Multiple status values can be provided with comma separated strings""" ... @@ -42,7 +42,7 @@ async def find_pets_by_status( async def find_pets_by_tags( self, - tags: Annotated[List[StrictStr], Field(description="Tags to filter by")], + tags: Annotated[List[str], Field(description="Tags to filter by")], ) -> List[Pet]: """Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.""" ... @@ -50,7 +50,7 @@ async def find_pets_by_tags( async def get_pet_by_id( self, - petId: Annotated[StrictInt, Field(description="ID of pet to return")], + petId: Annotated[int, Field(description="ID of pet to return")], ) -> Pet: """Returns a single pet""" ... @@ -58,7 +58,7 @@ async def get_pet_by_id( async def update_pet_with_form( self, - petId: Annotated[StrictInt, Field(description="ID of pet that needs to be updated")], + petId: Annotated[int, Field(description="ID of pet that needs to be updated")], name: Annotated[Optional[StrictStr], Field(description="Updated name of the pet")], status: Annotated[Optional[StrictStr], Field(description="Updated status of the pet")], ) -> None: @@ -68,8 +68,8 @@ async def update_pet_with_form( async def delete_pet( self, - petId: Annotated[StrictInt, Field(description="Pet id to delete")], - api_key: Optional[StrictStr], + petId: Annotated[int, Field(description="Pet id to delete")], + api_key: Optional[str], ) -> None: """""" ... @@ -77,7 +77,7 @@ async def delete_pet( async def upload_file( self, - petId: Annotated[StrictInt, Field(description="ID of pet to update")], + petId: Annotated[int, Field(description="ID of pet to update")], additional_metadata: Annotated[Optional[StrictStr], Field(description="Additional data to pass to server")], file: Optional[UploadFile], ) -> ApiResponse: diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api.py index 3d2744c2e028..fbcf205b9866 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api.py @@ -23,7 +23,7 @@ ) from openapi_server.models.extra_models import TokenModel # noqa: F401 -from pydantic import Field, StrictInt, StrictStr +from pydantic import Field, StrictInt from typing import Any, Dict from typing_extensions import Annotated from openapi_server.models.order import Order @@ -87,7 +87,7 @@ async def place_order( response_model_by_alias=True, ) async def get_order_by_id( - orderId: Annotated[int, Field(le=5, strict=True, ge=1, description="ID of pet that needs to be fetched")] = Path(..., description="ID of pet that needs to be fetched", ge=1, le=5), + orderId: Annotated[int, Field(le=5, ge=1, description="ID of pet that needs to be fetched")] = Path(..., description="ID of pet that needs to be fetched", ge=1, le=5), ) -> Order: """For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions""" if not BaseStoreApi.subclasses: @@ -106,7 +106,7 @@ async def get_order_by_id( response_model_by_alias=True, ) async def delete_order( - orderId: Annotated[StrictStr, Field(description="ID of the order that needs to be deleted")] = Path(..., description="ID of the order that needs to be deleted"), + orderId: Annotated[str, Field(description="ID of the order that needs to be deleted")] = Path(..., description="ID of the order that needs to be deleted"), ) -> None: """For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors""" if not BaseStoreApi.subclasses: diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api_base.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api_base.py index 84d9b639c1d3..76d310a4997c 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api_base.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api_base.py @@ -2,7 +2,7 @@ from typing import ClassVar, Dict, List, Tuple # noqa: F401 -from pydantic import Field, StrictInt, StrictStr +from pydantic import Field, StrictInt from typing import Any, Dict from typing_extensions import Annotated from openapi_server.models.order import Order @@ -31,7 +31,7 @@ async def place_order( async def get_order_by_id( self, - orderId: Annotated[int, Field(le=5, strict=True, ge=1, description="ID of pet that needs to be fetched")], + orderId: Annotated[int, Field(le=5, ge=1, description="ID of pet that needs to be fetched")], ) -> Order: """For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions""" ... @@ -39,7 +39,7 @@ async def get_order_by_id( async def delete_order( self, - orderId: Annotated[StrictStr, Field(description="ID of the order that needs to be deleted")], + orderId: Annotated[str, Field(description="ID of the order that needs to be deleted")], ) -> None: """For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors""" ... diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api.py index e7d5ea8011b5..942ad817a3c8 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api.py @@ -110,8 +110,8 @@ async def create_users_with_list_input( response_model_by_alias=True, ) async def login_user( - username: Annotated[str, Field(strict=True, description="The user name for login")] = Query(None, description="The user name for login", alias="username", regex=r"^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$"), - password: Annotated[StrictStr, Field(description="The password for login in clear text")] = Query(None, description="The password for login in clear text", alias="password"), + username: Annotated[str, Field(description="The user name for login")] = Query(None, description="The user name for login", alias="username", regex=r"^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$"), + password: Annotated[str, Field(description="The password for login in clear text")] = Query(None, description="The password for login in clear text", alias="password"), ) -> str: """""" if not BaseUserApi.subclasses: @@ -151,7 +151,7 @@ async def logout_user( response_model_by_alias=True, ) async def get_user_by_name( - username: Annotated[StrictStr, Field(description="The name that needs to be fetched. Use user1 for testing.")] = Path(..., description="The name that needs to be fetched. Use user1 for testing."), + username: Annotated[str, Field(description="The name that needs to be fetched. Use user1 for testing.")] = Path(..., description="The name that needs to be fetched. Use user1 for testing."), ) -> User: """""" if not BaseUserApi.subclasses: @@ -170,7 +170,7 @@ async def get_user_by_name( response_model_by_alias=True, ) async def update_user( - username: Annotated[StrictStr, Field(description="name that need to be deleted")] = Path(..., description="name that need to be deleted"), + username: Annotated[str, Field(description="name that need to be deleted")] = Path(..., description="name that need to be deleted"), user: Annotated[User, Field(description="Updated user object")] = Body(None, description="Updated user object"), token_api_key: TokenModel = Security( get_token_api_key @@ -193,7 +193,7 @@ async def update_user( response_model_by_alias=True, ) async def delete_user( - username: Annotated[StrictStr, Field(description="The name that needs to be deleted")] = Path(..., description="The name that needs to be deleted"), + username: Annotated[str, Field(description="The name that needs to be deleted")] = Path(..., description="The name that needs to be deleted"), token_api_key: TokenModel = Security( get_token_api_key ), diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api_base.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api_base.py index 752960411104..9668b337d089 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api_base.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api_base.py @@ -40,8 +40,8 @@ async def create_users_with_list_input( async def login_user( self, - username: Annotated[str, Field(strict=True, description="The user name for login")], - password: Annotated[StrictStr, Field(description="The password for login in clear text")], + username: Annotated[str, Field(description="The user name for login")], + password: Annotated[str, Field(description="The password for login in clear text")], ) -> str: """""" ... @@ -56,7 +56,7 @@ async def logout_user( async def get_user_by_name( self, - username: Annotated[StrictStr, Field(description="The name that needs to be fetched. Use user1 for testing.")], + username: Annotated[str, Field(description="The name that needs to be fetched. Use user1 for testing.")], ) -> User: """""" ... @@ -64,7 +64,7 @@ async def get_user_by_name( async def update_user( self, - username: Annotated[StrictStr, Field(description="name that need to be deleted")], + username: Annotated[str, Field(description="name that need to be deleted")], user: Annotated[User, Field(description="Updated user object")], ) -> None: """This can only be done by the logged in user.""" @@ -73,7 +73,7 @@ async def update_user( async def delete_user( self, - username: Annotated[StrictStr, Field(description="The name that needs to be deleted")], + username: Annotated[str, Field(description="The name that needs to be deleted")], ) -> None: """This can only be done by the logged in user.""" ... From 4decf3c8dfd96e8eee57115e216bac04d5291562 Mon Sep 17 00:00:00 2001 From: Goopher Maijenburg Date: Mon, 22 Jun 2026 21:50:07 +0200 Subject: [PATCH 2/6] refactor(python): express param strictness as a type, not a flag Replace the relaxStrict boolean + shouldRelaxStrictParameterTyping hook with an explicit PydanticCoercibleType subclass of PydanticType that never emits Pydantic strict types for the scalar kinds where strictness blocks coercion. Selection moves into a getPydanticParameterType() factory: PythonFastAPIServer returns PydanticCoercibleType for path/query/header params and the strict base type for everything else. The rule ("wire-string params are never strict") is now a code structure rather than a flag callers must remember to set, and the explanatory comment lives in one place on the class it describes. Behaviour-preserving: the regenerated python-fastapi sample is byte-identical to the previous commit. Python codegen tests (fastapi, client) pass. Claude-Session: https://claude.ai/code/session_019g7iwAyg7ErX1WrhqHyTn6 --- .../languages/AbstractPythonCodegen.java | 272 +++++++++++------- .../languages/PythonFastAPIServerCodegen.java | 38 ++- 2 files changed, 204 insertions(+), 106 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java index d114e6916165..53f82d2f7ba9 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java @@ -1301,24 +1301,6 @@ public void updateImportsFromCodegenModel(String modelName, CodegenModel cm, Set } } - /** - * Whether the given request parameter should be typed with coercible types - * ({@code int}/{@code str}/{@code float}) instead of Pydantic strict types - * ({@code StrictInt}/{@code StrictStr}/{@code StrictFloat}, {@code strict=True}). - * - *

The default is {@code false}, preserving strict typing for all generators - * (notably the Python client, which builds JSON request bodies where strict - * validation is desirable). Server generators that parse path/query/header values - * from the wire — where everything arrives as a string and relies on Pydantic - * coercion — should override this for non-body parameters. See issue #21905. - * - * @param parameter the request parameter being typed - * @return {@code true} to relax strict typing for this parameter - */ - protected boolean shouldRelaxStrictParameterTyping(CodegenParameter parameter) { - return false; - } - @Override public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List allModels) { hasModelsToImport = false; @@ -1336,14 +1318,14 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List params = operation.allParams; for (CodegenParameter cp : params) { - PydanticType pydantic = new PydanticType( + PydanticType pydantic = getPydanticParameterType( + cp, modelImports, exampleImports, postponedModelImports, postponedExampleImports, moduleImports, - null, - shouldRelaxStrictParameterTyping(cp) + null ); String typing = pydantic.generatePythonType(cp); cp.vendorExtensions.put(X_PY_TYPING, typing); @@ -1433,6 +1415,23 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List modelImports, + Set exampleImports, + Set postponedModelImports, + Set postponedExampleImports, + PythonImports moduleImports, + String classname) { + return new PydanticType( + modelImports, + exampleImports, + postponedModelImports, + postponedExampleImports, + moduleImports, + classname + ); + } + @Override public void postProcessParameter(CodegenParameter parameter) { @@ -1800,7 +1799,7 @@ public String asTypeValue(PythonImports imports) { * entries will be automatically removed. * * */ - class PythonImports { + protected class PythonImports { private Map> imports; public PythonImports() { @@ -1846,27 +1845,22 @@ public boolean isEmpty() { } } - class PydanticType { + protected class PydanticType { - private static final String LESS_THAN = "lt"; - private static final String GREATER_THAN = "gt"; - private static final String GREATER_OR_EQUAL_TO = "ge"; - private static final String LESS_OR_EQUAL_TO = "le"; - private static final String TYPING = "typing"; + protected static final String LESS_THAN = "lt"; + protected static final String GREATER_THAN = "gt"; + protected static final String GREATER_OR_EQUAL_TO = "ge"; + protected static final String LESS_OR_EQUAL_TO = "le"; + protected static final String TYPING = "typing"; - private static final String DECIMAL = "Decimal"; + protected static final String DECIMAL = "Decimal"; - private Set modelImports; - private Set exampleImports; - private Set postponedModelImports; - private Set postponedExampleImports; - private PythonImports moduleImports; - private String classname; - // When true, emit coercible types (int/str/float) instead of Pydantic strict - // types (StrictInt/StrictStr/StrictFloat) and omit the strict=True constraint. - // Used for non-body request parameters, whose values always arrive as strings - // on the wire and rely on Pydantic's automatic coercion. See issue #21905. - private boolean relaxStrict; + protected Set modelImports; + protected Set exampleImports; + protected Set postponedModelImports; + protected Set postponedExampleImports; + protected PythonImports moduleImports; + protected String classname; public PydanticType( Set modelImports, @@ -1875,18 +1869,6 @@ public PydanticType( Set postponedExampleImports, PythonImports moduleImports, String classname - ) { - this(modelImports, exampleImports, postponedModelImports, postponedExampleImports, moduleImports, classname, false); - } - - public PydanticType( - Set modelImports, - Set exampleImports, - Set postponedModelImports, - Set postponedExampleImports, - PythonImports moduleImports, - String classname, - boolean relaxStrict ) { this.modelImports = modelImports; this.exampleImports = exampleImports; @@ -1894,10 +1876,9 @@ public PydanticType( this.postponedExampleImports = postponedExampleImports; this.moduleImports = moduleImports; this.classname = classname; - this.relaxStrict = relaxStrict; } - private PythonType arrayType(IJsonSchemaValidationProperties cp) { + protected PythonType arrayType(IJsonSchemaValidationProperties cp) { PythonType pt = new PythonType(); if (cp.getMaxItems() != null) { pt.constrain("max_length", cp.getMaxItems()); @@ -1924,7 +1905,7 @@ private PythonType arrayType(IJsonSchemaValidationProperties cp) { return pt; } - private PythonType collectionItemType(CodegenProperty itemCp) { + protected PythonType collectionItemType(CodegenProperty itemCp) { PythonType itemPt = getType(itemCp); if (itemCp != null && !itemPt.type.equals("Any") && itemCp.isNullable) { moduleImports.add(TYPING, "Optional"); @@ -1935,15 +1916,13 @@ private PythonType collectionItemType(CodegenProperty itemCp) { return itemPt; } - private PythonType stringType(IJsonSchemaValidationProperties cp) { + protected PythonType stringType(IJsonSchemaValidationProperties cp) { if (cp.getHasValidation()) { PythonType pt = new PythonType("str"); // e.g. constr(regex=r'/[a-z]/i', strict=True) - if (!relaxStrict) { - pt.constrain("strict", true); - } + pt.constrain("strict", true); if (cp.getMaxLength() != null) { pt.constrain("max_length", cp.getMaxLength()); } @@ -1961,8 +1940,6 @@ private PythonType stringType(IJsonSchemaValidationProperties cp) { if ("password".equals(cp.getFormat())) { // TODO avoid using format, use `is` boolean flag instead moduleImports.add(PYDANTIC, "SecretStr"); return new PythonType("SecretStr"); - } else if (relaxStrict) { - return new PythonType("str"); } else { moduleImports.add(PYDANTIC, "StrictStr"); return new PythonType("StrictStr"); @@ -1970,7 +1947,7 @@ private PythonType stringType(IJsonSchemaValidationProperties cp) { } } - private PythonType mapType(IJsonSchemaValidationProperties cp) { + protected PythonType mapType(IJsonSchemaValidationProperties cp) { moduleImports.add(TYPING, "Dict"); PythonType pt = new PythonType("Dict"); pt.addTypeParam(new PythonType("str")); @@ -1978,7 +1955,7 @@ private PythonType mapType(IJsonSchemaValidationProperties cp) { return pt; } - private PythonType numberType(IJsonSchemaValidationProperties cp) { + protected PythonType numberType(IJsonSchemaValidationProperties cp) { if (cp.getHasValidation()) { PythonType floatt = new PythonType("float"); PythonType intt = new PythonType("int"); @@ -2007,10 +1984,8 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { } if ("Union[StrictFloat, StrictInt]".equals(mapNumberTo)) { - if (!relaxStrict) { - floatt.constrain("strict", true); - intt.constrain("strict", true); - } + floatt.constrain("strict", true); + intt.constrain("strict", true); moduleImports.add(TYPING, "Union"); PythonType pt = new PythonType("Union"); @@ -2018,9 +1993,7 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { pt.addTypeParam(intt); return pt; } else if ("StrictFloat".equals(mapNumberTo)) { - if (!relaxStrict) { - floatt.constrain("strict", true); - } + floatt.constrain("strict", true); return floatt; } else { // float return floatt; @@ -2028,12 +2001,6 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { } else { if ("Union[StrictFloat, StrictInt]".equals(mapNumberTo)) { moduleImports.add(TYPING, "Union"); - if (relaxStrict) { - PythonType pt = new PythonType("Union"); - pt.addTypeParam(new PythonType("float")); - pt.addTypeParam(new PythonType("int")); - return pt; - } moduleImports.add(PYDANTIC, "StrictFloat"); moduleImports.add(PYDANTIC, "StrictInt"); PythonType pt = new PythonType("Union"); @@ -2041,9 +2008,6 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { pt.addTypeParam(new PythonType("StrictInt")); return pt; } else if ("StrictFloat".equals(mapNumberTo)) { - if (relaxStrict) { - return new PythonType("float"); - } moduleImports.add(PYDANTIC, "StrictFloat"); return new PythonType("StrictFloat"); } else { @@ -2052,25 +2016,20 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { } } - private PythonType intType(IJsonSchemaValidationProperties cp) { + protected PythonType intType(IJsonSchemaValidationProperties cp) { if (cp.getHasValidation()) { PythonType pt = new PythonType("int"); // e.g. conint(ge=10, le=100, strict=True) - if (!relaxStrict) { - pt.constrain("strict", true); - } + pt.constrain("strict", true); applyConstraints(pt, cp); return pt; } else { - if (relaxStrict) { - return new PythonType("int"); - } moduleImports.add(PYDANTIC, "StrictInt"); return new PythonType("StrictInt"); } } - private PythonType binaryType(IJsonSchemaValidationProperties cp) { + protected PythonType binaryType(IJsonSchemaValidationProperties cp) { if (cp.getHasValidation()) { PythonType bytest = new PythonType("bytes"); PythonType strt = new PythonType("str"); @@ -2134,12 +2093,12 @@ private PythonType binaryType(IJsonSchemaValidationProperties cp) { } } - private PythonType boolType(IJsonSchemaValidationProperties cp) { + protected PythonType boolType(IJsonSchemaValidationProperties cp) { moduleImports.add(PYDANTIC, "StrictBool"); return new PythonType("StrictBool"); } - private PythonType decimalType(IJsonSchemaValidationProperties cp) { + protected PythonType decimalType(IJsonSchemaValidationProperties cp) { PythonType pt = new PythonType(DECIMAL); moduleImports.add("decimal", DECIMAL); @@ -2152,12 +2111,12 @@ private PythonType decimalType(IJsonSchemaValidationProperties cp) { return pt; } - private PythonType anyType(IJsonSchemaValidationProperties cp) { + protected PythonType anyType(IJsonSchemaValidationProperties cp) { moduleImports.add(TYPING, "Any"); return new PythonType("Any"); } - private PythonType dateType(IJsonSchemaValidationProperties cp) { + protected PythonType dateType(IJsonSchemaValidationProperties cp) { if (cp.getIsDate()) { moduleImports.add("datetime", "date"); } @@ -2168,12 +2127,12 @@ private PythonType dateType(IJsonSchemaValidationProperties cp) { return new PythonType(cp.getDataType()); } - private PythonType uuidType(IJsonSchemaValidationProperties cp) { + protected PythonType uuidType(IJsonSchemaValidationProperties cp) { moduleImports.add("uuid", "UUID"); return new PythonType("UUID"); } - private PythonType modelType(IJsonSchemaValidationProperties cp) { + protected PythonType modelType(IJsonSchemaValidationProperties cp) { // add model prefix hasModelsToImport = true; modelImports.add(cp.getDataType()); @@ -2181,7 +2140,7 @@ private PythonType modelType(IJsonSchemaValidationProperties cp) { return new PythonType(cp.getDataType()); } - private PythonType fromCommon(IJsonSchemaValidationProperties cp) { + protected PythonType fromCommon(IJsonSchemaValidationProperties cp) { if (cp == null) { // if codegen property (e.g. map/dict of undefined type) is null, default to string LOGGER.warn("Codegen property is null (e.g. map/dict of undefined type). Default to typing.Any."); @@ -2229,7 +2188,7 @@ public String generatePythonType(CodegenProperty cp) { return this.finalizeType(cp, pt); } - private PythonType getType(CodegenProperty cp) { + protected PythonType getType(CodegenProperty cp) { PythonType result = fromCommon(cp); /* comment out the following since Literal requires python 3.8 @@ -2335,7 +2294,7 @@ public String generatePythonType(CodegenParameter cp) { return this.finalizeType(cp, pt); } - private PythonType getType(CodegenParameter cp) { + protected PythonType getType(CodegenParameter cp) { // TODO: cleanup PythonType result = fromCommon(cp); @@ -2364,7 +2323,7 @@ private PythonType getType(CodegenParameter cp) { return result; } - private void applyConstraints(PythonType pythonType, IJsonSchemaValidationProperties cp) { + protected void applyConstraints(PythonType pythonType, IJsonSchemaValidationProperties cp) { if (cp.getMaximum() != null) { if (cp.getExclusiveMaximum()) { pythonType.constrain(LESS_THAN, cp.getMaximum(), false); @@ -2400,4 +2359,123 @@ private String finalizeType(CodegenParameter cp, PythonType pt) { return pt.asTypeConstraintWithAnnotations(moduleImports); } } + + /** + * Pydantic type generator for values that arrive over the wire as strings — server-bound request + * parameters in path, query, and header position. These rely on Pydantic's automatic coercion + * (e.g. {@code "3" -> 3}); the strict types emitted by the base {@link PydanticType} + * ({@code StrictInt}/{@code StrictStr}/{@code StrictFloat}, {@code strict=True}) disable that + * coercion and make FastAPI reject otherwise-valid requests with a 422. See issue #21905. + * + *

Request bodies and models are not wire-string values — they carry real JSON types — + * so they keep the strict base behaviour. + */ + protected class PydanticCoercibleType extends PydanticType { + public PydanticCoercibleType( + Set modelImports, + Set exampleImports, + Set postponedModelImports, + Set postponedExampleImports, + PythonImports moduleImports, + String classname + ) { + super(modelImports, exampleImports, postponedModelImports, postponedExampleImports, moduleImports, classname); + } + + @Override + protected PythonType stringType(IJsonSchemaValidationProperties cp) { + if (cp.getHasValidation()) { + PythonType pt = new PythonType("str"); + if (cp.getMaxLength() != null) { + pt.constrain("max_length", cp.getMaxLength()); + } + if (cp.getMinLength() != null) { + pt.constrain("min_length", cp.getMinLength()); + } + if (cp.getPattern() != null) { + moduleImports.add(PYDANTIC, "field_validator"); + } + return pt; + } else if ("password".equals(cp.getFormat())) { // TODO avoid using format, use `is` boolean flag instead + moduleImports.add(PYDANTIC, "SecretStr"); + return new PythonType("SecretStr"); + } + + return new PythonType("str"); + } + + @Override + protected PythonType numberType(IJsonSchemaValidationProperties cp) { + if (cp.getHasValidation()) { + PythonType floatt = new PythonType("float"); + PythonType intt = new PythonType("int"); + + if (cp.getMaximum() != null) { + if (cp.getExclusiveMaximum()) { + floatt.constrain(LESS_THAN, cp.getMaximum(), false); + intt.constrain(LESS_THAN, (int) Math.ceil(Double.valueOf(cp.getMaximum()))); + } else { + floatt.constrain(LESS_OR_EQUAL_TO, cp.getMaximum(), false); + intt.constrain(LESS_OR_EQUAL_TO, (int) Math.floor(Double.valueOf(cp.getMaximum()))); + } + } + if (cp.getMinimum() != null) { + if (cp.getExclusiveMinimum()) { + floatt.constrain(GREATER_THAN, cp.getMinimum(), false); + intt.constrain(GREATER_THAN, (int) Math.floor(Double.valueOf(cp.getMinimum()))); + } else { + floatt.constrain(GREATER_OR_EQUAL_TO, cp.getMinimum(), false); + intt.constrain(GREATER_OR_EQUAL_TO, (int) Math.ceil(Double.valueOf(cp.getMinimum()))); + } + } + if (cp.getMultipleOf() != null) { + floatt.constrain("multiple_of", cp.getMultipleOf()); + } + + if ("Union[StrictFloat, StrictInt]".equals(mapNumberTo)) { + moduleImports.add(TYPING, "Union"); + PythonType pt = new PythonType("Union"); + pt.addTypeParam(floatt); + pt.addTypeParam(intt); + return pt; + } + + return floatt; + } else if ("Union[StrictFloat, StrictInt]".equals(mapNumberTo)) { + moduleImports.add(TYPING, "Union"); + PythonType pt = new PythonType("Union"); + pt.addTypeParam(new PythonType("float")); + pt.addTypeParam(new PythonType("int")); + return pt; + } + + return new PythonType("float"); + } + + @Override + protected PythonType intType(IJsonSchemaValidationProperties cp) { + PythonType pt = new PythonType("int"); + if (cp.getHasValidation()) { + applyConstraints(pt, cp); + } + return pt; + } + + @Override + protected PythonType boolType(IJsonSchemaValidationProperties cp) { + return new PythonType("bool"); + } + + @Override + protected PythonType decimalType(IJsonSchemaValidationProperties cp) { + PythonType pt = new PythonType(DECIMAL); + moduleImports.add("decimal", DECIMAL); + + if (cp.getHasValidation()) { + applyConstraints(pt, cp); + } + + return pt; + } + } } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java index db0fb5be7efe..8454c4f1dd9f 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java @@ -237,16 +237,36 @@ public String getTypeDeclaration(Schema p) { return super.getTypeDeclaration(p); } - /** - * Path/query/header parameters arrive on the wire as strings and rely on Pydantic's automatic - * coercion (e.g. {@code "3" -> 3}). Pydantic strict typing disables that coercion, making FastAPI - * reject otherwise-valid requests with a 422 ({@code int_type: Input should be a valid integer}). - * So relax strict typing for those parameters. Body parameters keep strict typing, since JSON - * request bodies carry real types and strict validation is desirable there. See issue #21905. - */ @Override - protected boolean shouldRelaxStrictParameterTyping(CodegenParameter parameter) { - return parameter.isQueryParam || parameter.isPathParam || parameter.isHeaderParam; + protected PydanticType getPydanticParameterType(CodegenParameter parameter, + Set modelImports, + Set exampleImports, + Set postponedModelImports, + Set postponedExampleImports, + PythonImports moduleImports, + String classname) { + // Path/query/header values always arrive as strings on the wire and rely on Pydantic + // coercion, so they must not use strict types. Body params keep the strict default. + if (parameter.isQueryParam || parameter.isPathParam || parameter.isHeaderParam) { + return new PydanticCoercibleType( + modelImports, + exampleImports, + postponedModelImports, + postponedExampleImports, + moduleImports, + classname + ); + } + + return super.getPydanticParameterType( + parameter, + modelImports, + exampleImports, + postponedModelImports, + postponedExampleImports, + moduleImports, + classname + ); } @Override From 925f8713badfea3a245a0815cbb6c0d5a5f6b61d Mon Sep 17 00:00:00 2001 From: Goopher Maijenburg Date: Mon, 22 Jun 2026 20:02:03 +0200 Subject: [PATCH 3/6] fix(python-fastapi): relax strict typing for path/query/header params (#21905) Pydantic strict types (StrictInt/StrictStr/StrictFloat) and Field(strict=True) disable automatic string coercion. Since path/query/header values always arrive on the wire as strings, FastAPI rejected otherwise-valid requests with a 422 (int_type: Input should be a valid integer). Add a relaxStrict flag to PydanticType that emits coercible types (int/str/float) and omits strict=True, gated behind a shouldRelaxStrictParameterTyping() hook that defaults to false. PythonFastAPIServerCodegen overrides it for path/query/header params. Body params and the Python client generator are unchanged (strict typing is correct for JSON bodies). Regenerated the python-fastapi petstore sample. Claude-Session: https://claude.ai/code/session_019g7iwAyg7ErX1WrhqHyTn6 Conflicts: modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api.py samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api.py samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api.py --- .../languages/AbstractPythonCodegen.java | 71 +++++++++++++++++-- .../languages/PythonFastAPIServerCodegen.java | 12 ++++ .../src/openapi_server/apis/fake_api.py | 6 +- .../src/openapi_server/apis/fake_api_base.py | 6 +- .../src/openapi_server/apis/pet_api.py | 16 ++--- .../src/openapi_server/apis/pet_api_base.py | 16 ++--- .../src/openapi_server/apis/store_api.py | 6 +- .../src/openapi_server/apis/store_api_base.py | 6 +- .../src/openapi_server/apis/user_api.py | 10 +-- .../src/openapi_server/apis/user_api_base.py | 10 +-- 10 files changed, 115 insertions(+), 44 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java index f2ab264952cd..90939fc4b9a7 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java @@ -1378,6 +1378,24 @@ public void updateImportsFromCodegenModel(String modelName, CodegenModel cm, Set } } + /** + * Whether the given request parameter should be typed with coercible types + * ({@code int}/{@code str}/{@code float}) instead of Pydantic strict types + * ({@code StrictInt}/{@code StrictStr}/{@code StrictFloat}, {@code strict=True}). + * + *

The default is {@code false}, preserving strict typing for all generators + * (notably the Python client, which builds JSON request bodies where strict + * validation is desirable). Server generators that parse path/query/header values + * from the wire — where everything arrives as a string and relies on Pydantic + * coercion — should override this for non-body parameters. See issue #21905. + * + * @param parameter the request parameter being typed + * @return {@code true} to relax strict typing for this parameter + */ + protected boolean shouldRelaxStrictParameterTyping(CodegenParameter parameter) { + return false; + } + @Override public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List allModels) { hasModelsToImport = false; @@ -1401,7 +1419,8 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List postponedExampleImports; private PythonImports moduleImports; private String classname; + // When true, emit coercible types (int/str/float) instead of Pydantic strict + // types (StrictInt/StrictStr/StrictFloat) and omit the strict=True constraint. + // Used for non-body request parameters, whose values always arrive as strings + // on the wire and rely on Pydantic's automatic coercion. See issue #21905. + private boolean relaxStrict; public PydanticType( Set modelImports, @@ -1927,6 +1951,18 @@ public PydanticType( Set postponedExampleImports, PythonImports moduleImports, String classname + ) { + this(modelImports, exampleImports, postponedModelImports, postponedExampleImports, moduleImports, classname, false); + } + + public PydanticType( + Set modelImports, + Set exampleImports, + Set postponedModelImports, + Set postponedExampleImports, + PythonImports moduleImports, + String classname, + boolean relaxStrict ) { this.modelImports = modelImports; this.exampleImports = exampleImports; @@ -1934,6 +1970,7 @@ public PydanticType( this.postponedExampleImports = postponedExampleImports; this.moduleImports = moduleImports; this.classname = classname; + this.relaxStrict = relaxStrict; } private PythonType arrayType(IJsonSchemaValidationProperties cp) { @@ -1975,7 +2012,9 @@ private PythonType stringType(IJsonSchemaValidationProperties cp) { PythonType pt = new PythonType("str"); // e.g. constr(regex=r'/[a-z]/i', strict=True) - pt.constrain("strict", true); + if (!relaxStrict) { + pt.constrain("strict", true); + } ConstraintApplier.applyConstraints(cp, pt, ConstraintType.STRING); if (cp.getPattern() != null) { @@ -1988,6 +2027,8 @@ private PythonType stringType(IJsonSchemaValidationProperties cp) { if ("password".equals(cp.getFormat())) { // TODO avoid using format, use `is` boolean flag instead moduleImports.add(PYDANTIC, "SecretStr"); return new PythonType("SecretStr"); + } else if (relaxStrict) { + return new PythonType("str"); } else { moduleImports.add(PYDANTIC, "StrictStr"); return new PythonType("StrictStr"); @@ -2012,8 +2053,10 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { ConstraintApplier.applyConstraints(cp, intt, ConstraintType.ROUNDED_NUMBER); if ("Union[StrictFloat, StrictInt]".equals(mapNumberTo)) { - floatt.constrain("strict", true); - intt.constrain("strict", true); + if (!relaxStrict) { + floatt.constrain("strict", true); + intt.constrain("strict", true); + } moduleImports.add(TYPING, "Union"); PythonType pt = new PythonType("Union"); @@ -2021,7 +2064,9 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { pt.addTypeParam(intt); return pt; } else if ("StrictFloat".equals(mapNumberTo)) { - floatt.constrain("strict", true); + if (!relaxStrict) { + floatt.constrain("strict", true); + } return floatt; } else if (DECIMAL.equals(mapNumberTo)) { return decimalType(cp); @@ -2031,6 +2076,12 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { } else { if ("Union[StrictFloat, StrictInt]".equals(mapNumberTo)) { moduleImports.add(TYPING, "Union"); + if (relaxStrict) { + PythonType pt = new PythonType("Union"); + pt.addTypeParam(new PythonType("float")); + pt.addTypeParam(new PythonType("int")); + return pt; + } moduleImports.add(PYDANTIC, "StrictFloat"); moduleImports.add(PYDANTIC, "StrictInt"); PythonType pt = new PythonType("Union"); @@ -2038,6 +2089,9 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { pt.addTypeParam(new PythonType("StrictInt")); return pt; } else if ("StrictFloat".equals(mapNumberTo)) { + if (relaxStrict) { + return new PythonType("float"); + } moduleImports.add(PYDANTIC, "StrictFloat"); return new PythonType("StrictFloat"); } else if (DECIMAL.equals(mapNumberTo)) { @@ -2053,10 +2107,15 @@ private PythonType intType(IJsonSchemaValidationProperties cp) { if (cp.getHasValidation()) { PythonType pt = new PythonType("int"); // e.g. conint(ge=10, le=100, strict=True) - pt.constrain("strict", true); + if (!relaxStrict) { + pt.constrain("strict", true); + } ConstraintApplier.applyConstraints(cp, pt, ConstraintType.NUMBER); return pt; } else { + if (relaxStrict) { + return new PythonType("int"); + } moduleImports.add(PYDANTIC, "StrictInt"); return new PythonType("StrictInt"); } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java index 5969ca789ecb..5e92485fd844 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java @@ -242,6 +242,18 @@ public String getTypeDeclaration(Schema p) { return super.getTypeDeclaration(p); } + /** + * Path/query/header parameters arrive on the wire as strings and rely on Pydantic's automatic + * coercion (e.g. {@code "3" -> 3}). Pydantic strict typing disables that coercion, making FastAPI + * reject otherwise-valid requests with a 422 ({@code int_type: Input should be a valid integer}). + * So relax strict typing for those parameters. Body parameters keep strict typing, since JSON + * request bodies carry real types and strict validation is desirable there. See issue #21905. + */ + @Override + protected boolean shouldRelaxStrictParameterTyping(CodegenParameter parameter) { + return parameter.isQueryParam || parameter.isPathParam || parameter.isHeaderParam; + } + @Override public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List allModels) { super.postProcessOperationsWithModels(objs, allModels); diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api.py index 966ef8084147..0c38ab658584 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api.py @@ -23,7 +23,7 @@ ) from openapi_server.models.extra_models import TokenModel # noqa: F401 -from pydantic import Field, StrictStr +from pydantic import Field from typing import Any, Optional from typing_extensions import Annotated @@ -46,9 +46,9 @@ response_model_by_alias=True, ) async def fake_query_param_default( - has_default: Annotated[Optional[StrictStr], Field(description="has default value")] = Query('Hello World', description="has default value", alias="hasDefault") + has_default: Annotated[Optional[str], Field(description="has default value")] = Query('Hello World', description="has default value", alias="hasDefault") , - no_default: Annotated[Optional[StrictStr], Field(description="no default value")] = Query(None, description="no default value", alias="noDefault") + no_default: Annotated[Optional[str], Field(description="no default value")] = Query(None, description="no default value", alias="noDefault") , ) -> None: """""" diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api_base.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api_base.py index 1c71537aa944..e8343c5d1330 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api_base.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api_base.py @@ -2,7 +2,7 @@ from typing import ClassVar, Dict, List, Tuple # noqa: F401 -from pydantic import Field, StrictStr +from pydantic import Field from typing import Any, Optional from typing_extensions import Annotated @@ -15,8 +15,8 @@ def __init_subclass__(cls, **kwargs): BaseFakeApi.subclasses = BaseFakeApi.subclasses + (cls,) async def fake_query_param_default( self, - has_default: Annotated[Optional[StrictStr], Field(description="has default value")], - no_default: Annotated[Optional[StrictStr], Field(description="no default value")], + has_default: Annotated[Optional[str], Field(description="has default value")], + no_default: Annotated[Optional[str], Field(description="no default value")], ) -> None: """""" ... diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py index 7a6d8bdc4ea7..0e94e547c48e 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py @@ -23,7 +23,7 @@ ) from openapi_server.models.extra_models import TokenModel # noqa: F401 -from pydantic import Field, StrictBytes, StrictInt, StrictStr, field_validator +from pydantic import Field, StrictBytes, StrictStr, field_validator from typing import Any, List, Optional, Tuple, Union from typing_extensions import Annotated from openapi_server.models.api_response import ApiResponse @@ -97,7 +97,7 @@ async def add_pet( response_model_by_alias=True, ) async def find_pets_by_status( - status: Annotated[List[StrictStr], Field(description="Status values that need to be considered for filter")] = Query(..., description="Status values that need to be considered for filter", alias="status") + status: Annotated[List[str], Field(description="Status values that need to be considered for filter")] = Query(..., description="Status values that need to be considered for filter", alias="status") , token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["read:pets"] @@ -120,7 +120,7 @@ async def find_pets_by_status( response_model_by_alias=True, ) async def find_pets_by_tags( - tags: Annotated[List[StrictStr], Field(description="Tags to filter by")] = Query(..., description="Tags to filter by", alias="tags") + tags: Annotated[List[str], Field(description="Tags to filter by")] = Query(..., description="Tags to filter by", alias="tags") , token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["read:pets"] @@ -144,7 +144,7 @@ async def find_pets_by_tags( response_model_by_alias=True, ) async def get_pet_by_id( - petId: Annotated[StrictInt, Field(description="ID of pet to return")] = Path(..., description="ID of pet to return") + petId: Annotated[int, Field(description="ID of pet to return")] = Path(..., description="ID of pet to return") , token_api_key: TokenModel = Security( get_token_api_key @@ -166,7 +166,7 @@ async def get_pet_by_id( response_model_by_alias=True, ) async def update_pet_with_form( - petId: Annotated[StrictInt, Field(description="ID of pet that needs to be updated")] = Path(..., description="ID of pet that needs to be updated") + petId: Annotated[int, Field(description="ID of pet that needs to be updated")] = Path(..., description="ID of pet that needs to be updated") , name: Annotated[Optional[StrictStr], Field(description="Updated name of the pet")] = Form(None, description="Updated name of the pet", alias="name") , @@ -192,9 +192,9 @@ async def update_pet_with_form( response_model_by_alias=True, ) async def delete_pet( - petId: Annotated[StrictInt, Field(description="Pet id to delete")] = Path(..., description="Pet id to delete") + petId: Annotated[int, Field(description="Pet id to delete")] = Path(..., description="Pet id to delete") , - api_key: Optional[StrictStr] = Header(None, description="") + api_key: Optional[str] = Header(None, description="") , token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["write:pets", "read:pets"] @@ -216,7 +216,7 @@ async def delete_pet( response_model_by_alias=True, ) async def upload_file( - petId: Annotated[StrictInt, Field(description="ID of pet to update")] = Path(..., description="ID of pet to update") + petId: Annotated[int, Field(description="ID of pet to update")] = Path(..., description="ID of pet to update") , additional_metadata: Annotated[Optional[StrictStr], Field(description="Additional data to pass to server")] = Form(None, description="Additional data to pass to server", alias="additionalMetadata") , diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api_base.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api_base.py index 4f8b292e3eec..dd8780dc2416 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api_base.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api_base.py @@ -2,7 +2,7 @@ from typing import ClassVar, Dict, List, Tuple # noqa: F401 -from pydantic import Field, StrictBytes, StrictInt, StrictStr, field_validator +from pydantic import Field, StrictBytes, StrictStr, field_validator from typing import Any, List, Optional, Tuple, Union from typing_extensions import Annotated from openapi_server.models.api_response import ApiResponse @@ -34,7 +34,7 @@ async def add_pet( async def find_pets_by_status( self, - status: Annotated[List[StrictStr], Field(description="Status values that need to be considered for filter")], + status: Annotated[List[str], Field(description="Status values that need to be considered for filter")], ) -> List[Pet]: """Multiple status values can be provided with comma separated strings""" ... @@ -42,7 +42,7 @@ async def find_pets_by_status( async def find_pets_by_tags( self, - tags: Annotated[List[StrictStr], Field(description="Tags to filter by")], + tags: Annotated[List[str], Field(description="Tags to filter by")], ) -> List[Pet]: """Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.""" ... @@ -50,7 +50,7 @@ async def find_pets_by_tags( async def get_pet_by_id( self, - petId: Annotated[StrictInt, Field(description="ID of pet to return")], + petId: Annotated[int, Field(description="ID of pet to return")], ) -> Pet: """Returns a single pet""" ... @@ -58,7 +58,7 @@ async def get_pet_by_id( async def update_pet_with_form( self, - petId: Annotated[StrictInt, Field(description="ID of pet that needs to be updated")], + petId: Annotated[int, Field(description="ID of pet that needs to be updated")], name: Annotated[Optional[StrictStr], Field(description="Updated name of the pet")], status: Annotated[Optional[StrictStr], Field(description="Updated status of the pet")], ) -> None: @@ -68,8 +68,8 @@ async def update_pet_with_form( async def delete_pet( self, - petId: Annotated[StrictInt, Field(description="Pet id to delete")], - api_key: Optional[StrictStr], + petId: Annotated[int, Field(description="Pet id to delete")], + api_key: Optional[str], ) -> None: """""" ... @@ -77,7 +77,7 @@ async def delete_pet( async def upload_file( self, - petId: Annotated[StrictInt, Field(description="ID of pet to update")], + petId: Annotated[int, Field(description="ID of pet to update")], additional_metadata: Annotated[Optional[StrictStr], Field(description="Additional data to pass to server")], file: Optional[UploadFile], ) -> ApiResponse: diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api.py index 6dbefdcaa1a9..3ebf433d9d1a 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api.py @@ -23,7 +23,7 @@ ) from openapi_server.models.extra_models import TokenModel # noqa: F401 -from pydantic import Field, StrictInt, StrictStr +from pydantic import Field, StrictInt from typing import Any, Dict from typing_extensions import Annotated from openapi_server.models.order import Order @@ -88,7 +88,7 @@ async def place_order( response_model_by_alias=True, ) async def get_order_by_id( - orderId: Annotated[int, Field(le=5, strict=True, ge=1, description="ID of pet that needs to be fetched")] = Path(..., description="ID of pet that needs to be fetched", ge=1, le=5) + orderId: Annotated[int, Field(le=5, ge=1, description="ID of pet that needs to be fetched")] = Path(..., description="ID of pet that needs to be fetched", ge=1, le=5) , ) -> Order: """For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions""" @@ -108,7 +108,7 @@ async def get_order_by_id( response_model_by_alias=True, ) async def delete_order( - orderId: Annotated[StrictStr, Field(description="ID of the order that needs to be deleted")] = Path(..., description="ID of the order that needs to be deleted") + orderId: Annotated[str, Field(description="ID of the order that needs to be deleted")] = Path(..., description="ID of the order that needs to be deleted") , ) -> None: """For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors""" diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api_base.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api_base.py index 84d9b639c1d3..76d310a4997c 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api_base.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api_base.py @@ -2,7 +2,7 @@ from typing import ClassVar, Dict, List, Tuple # noqa: F401 -from pydantic import Field, StrictInt, StrictStr +from pydantic import Field, StrictInt from typing import Any, Dict from typing_extensions import Annotated from openapi_server.models.order import Order @@ -31,7 +31,7 @@ async def place_order( async def get_order_by_id( self, - orderId: Annotated[int, Field(le=5, strict=True, ge=1, description="ID of pet that needs to be fetched")], + orderId: Annotated[int, Field(le=5, ge=1, description="ID of pet that needs to be fetched")], ) -> Order: """For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions""" ... @@ -39,7 +39,7 @@ async def get_order_by_id( async def delete_order( self, - orderId: Annotated[StrictStr, Field(description="ID of the order that needs to be deleted")], + orderId: Annotated[str, Field(description="ID of the order that needs to be deleted")], ) -> None: """For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors""" ... diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api.py index 06634461bfa4..176c0e775880 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api.py @@ -113,9 +113,9 @@ async def create_users_with_list_input( response_model_by_alias=True, ) async def login_user( - username: Annotated[str, Field(strict=True, description="The user name for login")] = Query(..., description="The user name for login", alias="username", regex=r"^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$") + username: Annotated[str, Field(description="The user name for login")] = Query(..., description="The user name for login", alias="username", regex=r"^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$") , - password: Annotated[StrictStr, Field(description="The password for login in clear text")] = Query(..., description="The password for login in clear text", alias="password") + password: Annotated[str, Field(description="The password for login in clear text")] = Query(..., description="The password for login in clear text", alias="password") , ) -> str: """""" @@ -156,7 +156,7 @@ async def logout_user( response_model_by_alias=True, ) async def get_user_by_name( - username: Annotated[StrictStr, Field(description="The name that needs to be fetched. Use user1 for testing.")] = Path(..., description="The name that needs to be fetched. Use user1 for testing.") + username: Annotated[str, Field(description="The name that needs to be fetched. Use user1 for testing.")] = Path(..., description="The name that needs to be fetched. Use user1 for testing.") , ) -> User: """""" @@ -176,7 +176,7 @@ async def get_user_by_name( response_model_by_alias=True, ) async def update_user( - username: Annotated[StrictStr, Field(description="name that need to be deleted")] = Path(..., description="name that need to be deleted") + username: Annotated[str, Field(description="name that need to be deleted")] = Path(..., description="name that need to be deleted") , user: Annotated[User, Field(description="Updated user object")] = Body(..., description="Updated user object") , @@ -201,7 +201,7 @@ async def update_user( response_model_by_alias=True, ) async def delete_user( - username: Annotated[StrictStr, Field(description="The name that needs to be deleted")] = Path(..., description="The name that needs to be deleted") + username: Annotated[str, Field(description="The name that needs to be deleted")] = Path(..., description="The name that needs to be deleted") , token_api_key: TokenModel = Security( get_token_api_key diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api_base.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api_base.py index 752960411104..9668b337d089 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api_base.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api_base.py @@ -40,8 +40,8 @@ async def create_users_with_list_input( async def login_user( self, - username: Annotated[str, Field(strict=True, description="The user name for login")], - password: Annotated[StrictStr, Field(description="The password for login in clear text")], + username: Annotated[str, Field(description="The user name for login")], + password: Annotated[str, Field(description="The password for login in clear text")], ) -> str: """""" ... @@ -56,7 +56,7 @@ async def logout_user( async def get_user_by_name( self, - username: Annotated[StrictStr, Field(description="The name that needs to be fetched. Use user1 for testing.")], + username: Annotated[str, Field(description="The name that needs to be fetched. Use user1 for testing.")], ) -> User: """""" ... @@ -64,7 +64,7 @@ async def get_user_by_name( async def update_user( self, - username: Annotated[StrictStr, Field(description="name that need to be deleted")], + username: Annotated[str, Field(description="name that need to be deleted")], user: Annotated[User, Field(description="Updated user object")], ) -> None: """This can only be done by the logged in user.""" @@ -73,7 +73,7 @@ async def update_user( async def delete_user( self, - username: Annotated[StrictStr, Field(description="The name that needs to be deleted")], + username: Annotated[str, Field(description="The name that needs to be deleted")], ) -> None: """This can only be done by the logged in user.""" ... From 3d2528ab0befb6e4fffc8e5700b3d992c0ef43c2 Mon Sep 17 00:00:00 2001 From: Goopher Maijenburg Date: Mon, 22 Jun 2026 21:50:07 +0200 Subject: [PATCH 4/6] refactor(python): express param strictness as a type, not a flag Replace the relaxStrict boolean + shouldRelaxStrictParameterTyping hook with an explicit PydanticCoercibleType subclass of PydanticType that never emits Pydantic strict types for the scalar kinds where strictness blocks coercion. Selection moves into a getPydanticParameterType() factory: PythonFastAPIServer returns PydanticCoercibleType for path/query/header params and the strict base type for everything else. The rule ("wire-string params are never strict") is now a code structure rather than a flag callers must remember to set, and the explanatory comment lives in one place on the class it describes. Behaviour-preserving: the regenerated python-fastapi sample is byte-identical to the previous commit. Python codegen tests (fastapi, client) pass. Claude-Session: https://claude.ai/code/session_019g7iwAyg7ErX1WrhqHyTn6 Conflicts: modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java --- .../languages/AbstractPythonCodegen.java | 247 +++++++++++------- .../languages/PythonFastAPIServerCodegen.java | 38 ++- 2 files changed, 184 insertions(+), 101 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java index 90939fc4b9a7..477663e172fa 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java @@ -1378,24 +1378,6 @@ public void updateImportsFromCodegenModel(String modelName, CodegenModel cm, Set } } - /** - * Whether the given request parameter should be typed with coercible types - * ({@code int}/{@code str}/{@code float}) instead of Pydantic strict types - * ({@code StrictInt}/{@code StrictStr}/{@code StrictFloat}, {@code strict=True}). - * - *

The default is {@code false}, preserving strict typing for all generators - * (notably the Python client, which builds JSON request bodies where strict - * validation is desirable). Server generators that parse path/query/header values - * from the wire — where everything arrives as a string and relies on Pydantic - * coercion — should override this for non-body parameters. See issue #21905. - * - * @param parameter the request parameter being typed - * @return {@code true} to relax strict typing for this parameter - */ - protected boolean shouldRelaxStrictParameterTyping(CodegenParameter parameter) { - return false; - } - @Override public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List allModels) { hasModelsToImport = false; @@ -1413,14 +1395,14 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List params = operation.allParams; for (CodegenParameter cp : params) { - PydanticType pydantic = new PydanticType( + PydanticType pydantic = getPydanticParameterType( + cp, modelImports, exampleImports, postponedModelImports, postponedExampleImports, moduleImports, - null, - shouldRelaxStrictParameterTyping(cp) + null ); String typing = pydantic.generatePythonType(cp); cp.vendorExtensions.put(X_PY_TYPING, typing); @@ -1510,6 +1492,23 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List modelImports, + Set exampleImports, + Set postponedModelImports, + Set postponedExampleImports, + PythonImports moduleImports, + String classname) { + return new PydanticType( + modelImports, + exampleImports, + postponedModelImports, + postponedExampleImports, + moduleImports, + classname + ); + } + @Override public void postProcessParameter(CodegenParameter parameter) { @@ -1880,7 +1879,7 @@ public String asTypeValue(PythonImports imports) { * entries will be automatically removed. * * */ - class PythonImports { + protected class PythonImports { private Map> imports; public PythonImports() { @@ -1926,23 +1925,18 @@ public boolean isEmpty() { } } - class PydanticType { + protected class PydanticType { - private static final String TYPING = "typing"; + protected static final String TYPING = "typing"; - private static final String DECIMAL = "Decimal"; + protected static final String DECIMAL = "Decimal"; - private Set modelImports; - private Set exampleImports; - private Set postponedModelImports; - private Set postponedExampleImports; - private PythonImports moduleImports; - private String classname; - // When true, emit coercible types (int/str/float) instead of Pydantic strict - // types (StrictInt/StrictStr/StrictFloat) and omit the strict=True constraint. - // Used for non-body request parameters, whose values always arrive as strings - // on the wire and rely on Pydantic's automatic coercion. See issue #21905. - private boolean relaxStrict; + protected Set modelImports; + protected Set exampleImports; + protected Set postponedModelImports; + protected Set postponedExampleImports; + protected PythonImports moduleImports; + protected String classname; public PydanticType( Set modelImports, @@ -1951,18 +1945,6 @@ public PydanticType( Set postponedExampleImports, PythonImports moduleImports, String classname - ) { - this(modelImports, exampleImports, postponedModelImports, postponedExampleImports, moduleImports, classname, false); - } - - public PydanticType( - Set modelImports, - Set exampleImports, - Set postponedModelImports, - Set postponedExampleImports, - PythonImports moduleImports, - String classname, - boolean relaxStrict ) { this.modelImports = modelImports; this.exampleImports = exampleImports; @@ -1970,10 +1952,9 @@ public PydanticType( this.postponedExampleImports = postponedExampleImports; this.moduleImports = moduleImports; this.classname = classname; - this.relaxStrict = relaxStrict; } - private PythonType arrayType(IJsonSchemaValidationProperties cp) { + protected PythonType arrayType(IJsonSchemaValidationProperties cp) { PythonType pt = new PythonType(); ConstraintApplier.applyConstraints(cp, pt, ConstraintType.ARRAY); if (cp.getUniqueItems()) { @@ -1995,7 +1976,7 @@ private PythonType arrayType(IJsonSchemaValidationProperties cp) { return pt; } - private PythonType collectionItemType(CodegenProperty itemCp) { + protected PythonType collectionItemType(CodegenProperty itemCp) { PythonType itemPt = getType(itemCp); if (itemCp != null && !itemPt.type.equals("Any") && itemCp.isNullable) { moduleImports.add(TYPING, "Optional"); @@ -2006,15 +1987,13 @@ private PythonType collectionItemType(CodegenProperty itemCp) { return itemPt; } - private PythonType stringType(IJsonSchemaValidationProperties cp) { + protected PythonType stringType(IJsonSchemaValidationProperties cp) { if (cp.getHasValidation()) { PythonType pt = new PythonType("str"); // e.g. constr(regex=r'/[a-z]/i', strict=True) - if (!relaxStrict) { - pt.constrain("strict", true); - } + pt.constrain("strict", true); ConstraintApplier.applyConstraints(cp, pt, ConstraintType.STRING); if (cp.getPattern() != null) { @@ -2027,8 +2006,6 @@ private PythonType stringType(IJsonSchemaValidationProperties cp) { if ("password".equals(cp.getFormat())) { // TODO avoid using format, use `is` boolean flag instead moduleImports.add(PYDANTIC, "SecretStr"); return new PythonType("SecretStr"); - } else if (relaxStrict) { - return new PythonType("str"); } else { moduleImports.add(PYDANTIC, "StrictStr"); return new PythonType("StrictStr"); @@ -2036,7 +2013,7 @@ private PythonType stringType(IJsonSchemaValidationProperties cp) { } } - private PythonType mapType(IJsonSchemaValidationProperties cp) { + protected PythonType mapType(IJsonSchemaValidationProperties cp) { moduleImports.add(TYPING, "Dict"); PythonType pt = new PythonType("Dict"); pt.addTypeParam(new PythonType("str")); @@ -2044,7 +2021,7 @@ private PythonType mapType(IJsonSchemaValidationProperties cp) { return pt; } - private PythonType numberType(IJsonSchemaValidationProperties cp) { + protected PythonType numberType(IJsonSchemaValidationProperties cp) { if (cp.getHasValidation()) { PythonType floatt = new PythonType("float"); PythonType intt = new PythonType("int"); @@ -2053,10 +2030,8 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { ConstraintApplier.applyConstraints(cp, intt, ConstraintType.ROUNDED_NUMBER); if ("Union[StrictFloat, StrictInt]".equals(mapNumberTo)) { - if (!relaxStrict) { - floatt.constrain("strict", true); - intt.constrain("strict", true); - } + floatt.constrain("strict", true); + intt.constrain("strict", true); moduleImports.add(TYPING, "Union"); PythonType pt = new PythonType("Union"); @@ -2064,9 +2039,7 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { pt.addTypeParam(intt); return pt; } else if ("StrictFloat".equals(mapNumberTo)) { - if (!relaxStrict) { - floatt.constrain("strict", true); - } + floatt.constrain("strict", true); return floatt; } else if (DECIMAL.equals(mapNumberTo)) { return decimalType(cp); @@ -2076,12 +2049,6 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { } else { if ("Union[StrictFloat, StrictInt]".equals(mapNumberTo)) { moduleImports.add(TYPING, "Union"); - if (relaxStrict) { - PythonType pt = new PythonType("Union"); - pt.addTypeParam(new PythonType("float")); - pt.addTypeParam(new PythonType("int")); - return pt; - } moduleImports.add(PYDANTIC, "StrictFloat"); moduleImports.add(PYDANTIC, "StrictInt"); PythonType pt = new PythonType("Union"); @@ -2089,9 +2056,6 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { pt.addTypeParam(new PythonType("StrictInt")); return pt; } else if ("StrictFloat".equals(mapNumberTo)) { - if (relaxStrict) { - return new PythonType("float"); - } moduleImports.add(PYDANTIC, "StrictFloat"); return new PythonType("StrictFloat"); } else if (DECIMAL.equals(mapNumberTo)) { @@ -2103,25 +2067,20 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) { } } - private PythonType intType(IJsonSchemaValidationProperties cp) { + protected PythonType intType(IJsonSchemaValidationProperties cp) { if (cp.getHasValidation()) { PythonType pt = new PythonType("int"); // e.g. conint(ge=10, le=100, strict=True) - if (!relaxStrict) { - pt.constrain("strict", true); - } + pt.constrain("strict", true); ConstraintApplier.applyConstraints(cp, pt, ConstraintType.NUMBER); return pt; } else { - if (relaxStrict) { - return new PythonType("int"); - } moduleImports.add(PYDANTIC, "StrictInt"); return new PythonType("StrictInt"); } } - private PythonType binaryType(IJsonSchemaValidationProperties cp) { + protected PythonType binaryType(IJsonSchemaValidationProperties cp) { if (cp.getHasValidation()) { PythonType bytest = new PythonType("bytes"); PythonType strt = new PythonType("str"); @@ -2179,12 +2138,12 @@ private PythonType binaryType(IJsonSchemaValidationProperties cp) { } } - private PythonType boolType(IJsonSchemaValidationProperties cp) { + protected PythonType boolType(IJsonSchemaValidationProperties cp) { moduleImports.add(PYDANTIC, "StrictBool"); return new PythonType("StrictBool"); } - private PythonType decimalType(IJsonSchemaValidationProperties cp) { + protected PythonType decimalType(IJsonSchemaValidationProperties cp) { PythonType pt = new PythonType(DECIMAL); moduleImports.add("decimal", DECIMAL); @@ -2197,12 +2156,12 @@ private PythonType decimalType(IJsonSchemaValidationProperties cp) { return pt; } - private PythonType anyType(IJsonSchemaValidationProperties cp) { + protected PythonType anyType(IJsonSchemaValidationProperties cp) { moduleImports.add(TYPING, "Any"); return new PythonType("Any"); } - private PythonType dateType(IJsonSchemaValidationProperties cp) { + protected PythonType dateType(IJsonSchemaValidationProperties cp) { if (cp.getIsDate()) { moduleImports.add("datetime", "date"); } @@ -2213,12 +2172,12 @@ private PythonType dateType(IJsonSchemaValidationProperties cp) { return new PythonType(cp.getDataType()); } - private PythonType uuidType(IJsonSchemaValidationProperties cp) { + protected PythonType uuidType(IJsonSchemaValidationProperties cp) { moduleImports.add("uuid", "UUID"); return new PythonType("UUID"); } - private PythonType modelType(IJsonSchemaValidationProperties cp) { + protected PythonType modelType(IJsonSchemaValidationProperties cp) { // add model prefix hasModelsToImport = true; modelImports.add(cp.getDataType()); @@ -2226,7 +2185,7 @@ private PythonType modelType(IJsonSchemaValidationProperties cp) { return new PythonType(cp.getDataType()); } - private PythonType fromCommon(IJsonSchemaValidationProperties cp) { + protected PythonType fromCommon(IJsonSchemaValidationProperties cp) { if (cp == null) { // if codegen property (e.g. map/dict of undefined type) is null, default to string LOGGER.warn("Codegen property is null (e.g. map/dict of undefined type). Default to typing.Any."); @@ -2284,7 +2243,7 @@ public String generatePythonType(CodegenProperty cp) { return this.finalizeType(cp, pt); } - private PythonType getType(CodegenProperty cp) { + protected PythonType getType(CodegenProperty cp) { PythonType result = fromCommon(cp); /* comment out the following since Literal requires python 3.8 @@ -2398,7 +2357,7 @@ public String generatePythonType(CodegenParameter cp) { return this.finalizeType(cp, pt); } - private PythonType getType(CodegenParameter cp) { + protected PythonType getType(CodegenParameter cp) { // TODO: cleanup PythonType result = fromCommon(cp); @@ -2533,4 +2492,108 @@ private static int floorValue(String value) { return (int) Math.floor(Double.parseDouble(value)); } } + + /** + * Pydantic type generator for values that arrive over the wire as strings — server-bound request + * parameters in path, query, and header position. These rely on Pydantic's automatic coercion + * (e.g. {@code "3" -> 3}); the strict types emitted by the base {@link PydanticType} + * ({@code StrictInt}/{@code StrictStr}/{@code StrictFloat}, {@code strict=True}) disable that + * coercion and make FastAPI reject otherwise-valid requests with a 422. See issue #21905. + * + *

Request bodies and models are not wire-string values — they carry real JSON types — + * so they keep the strict base behaviour. + */ + protected class PydanticCoercibleType extends PydanticType { + public PydanticCoercibleType( + Set modelImports, + Set exampleImports, + Set postponedModelImports, + Set postponedExampleImports, + PythonImports moduleImports, + String classname + ) { + super(modelImports, exampleImports, postponedModelImports, postponedExampleImports, moduleImports, classname); + } + + @Override + protected PythonType stringType(IJsonSchemaValidationProperties cp) { + if (cp.getHasValidation()) { + PythonType pt = new PythonType("str"); + ConstraintApplier.applyConstraints(cp, pt, ConstraintType.STRING); + if (cp.getPattern() != null) { + moduleImports.add(PYDANTIC, "field_validator"); + } + return pt; + } else if ("password".equals(cp.getFormat())) { // TODO avoid using format, use `is` boolean flag instead + moduleImports.add(PYDANTIC, "SecretStr"); + return new PythonType("SecretStr"); + } + + return new PythonType("str"); + } + + @Override + protected PythonType numberType(IJsonSchemaValidationProperties cp) { + if (cp.getHasValidation()) { + PythonType floatt = new PythonType("float"); + PythonType intt = new PythonType("int"); + + ConstraintApplier.applyConstraints(cp, floatt, ConstraintType.NUMBER); + ConstraintApplier.applyConstraints(cp, intt, ConstraintType.ROUNDED_NUMBER); + + if ("Union[StrictFloat, StrictInt]".equals(mapNumberTo)) { + moduleImports.add(TYPING, "Union"); + PythonType pt = new PythonType("Union"); + pt.addTypeParam(floatt); + pt.addTypeParam(intt); + return pt; + } else if ("StrictFloat".equals(mapNumberTo)) { + return floatt; + } else if (DECIMAL.equals(mapNumberTo)) { + return decimalType(cp); + } + + return floatt; + } else if ("Union[StrictFloat, StrictInt]".equals(mapNumberTo)) { + moduleImports.add(TYPING, "Union"); + PythonType pt = new PythonType("Union"); + pt.addTypeParam(new PythonType("float")); + pt.addTypeParam(new PythonType("int")); + return pt; + } else if ("StrictFloat".equals(mapNumberTo)) { + return new PythonType("float"); + } else if (DECIMAL.equals(mapNumberTo)) { + moduleImports.add("decimal", DECIMAL); + return new PythonType(DECIMAL); + } + + return new PythonType("float"); + } + + @Override + protected PythonType intType(IJsonSchemaValidationProperties cp) { + PythonType pt = new PythonType("int"); + if (cp.getHasValidation()) { + ConstraintApplier.applyConstraints(cp, pt, ConstraintType.NUMBER); + } + return pt; + } + + @Override + protected PythonType boolType(IJsonSchemaValidationProperties cp) { + return new PythonType("bool"); + } + + @Override + protected PythonType decimalType(IJsonSchemaValidationProperties cp) { + PythonType pt = new PythonType(DECIMAL); + moduleImports.add("decimal", DECIMAL); + + if (cp.getHasValidation()) { + ConstraintApplier.applyConstraints(cp, pt, ConstraintType.NUMBER); + } + + return pt; + } + } } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java index 5e92485fd844..b0bccb4f4f3a 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java @@ -242,16 +242,36 @@ public String getTypeDeclaration(Schema p) { return super.getTypeDeclaration(p); } - /** - * Path/query/header parameters arrive on the wire as strings and rely on Pydantic's automatic - * coercion (e.g. {@code "3" -> 3}). Pydantic strict typing disables that coercion, making FastAPI - * reject otherwise-valid requests with a 422 ({@code int_type: Input should be a valid integer}). - * So relax strict typing for those parameters. Body parameters keep strict typing, since JSON - * request bodies carry real types and strict validation is desirable there. See issue #21905. - */ @Override - protected boolean shouldRelaxStrictParameterTyping(CodegenParameter parameter) { - return parameter.isQueryParam || parameter.isPathParam || parameter.isHeaderParam; + protected PydanticType getPydanticParameterType(CodegenParameter parameter, + Set modelImports, + Set exampleImports, + Set postponedModelImports, + Set postponedExampleImports, + PythonImports moduleImports, + String classname) { + // Path/query/header values always arrive as strings on the wire and rely on Pydantic + // coercion, so they must not use strict types. Body params keep the strict default. + if (parameter.isQueryParam || parameter.isPathParam || parameter.isHeaderParam) { + return new PydanticCoercibleType( + modelImports, + exampleImports, + postponedModelImports, + postponedExampleImports, + moduleImports, + classname + ); + } + + return super.getPydanticParameterType( + parameter, + modelImports, + exampleImports, + postponedModelImports, + postponedExampleImports, + moduleImports, + classname + ); } @Override From 7c7ceb42fae1bbc58c21bf8db1c8c16a3a52d6f1 Mon Sep 17 00:00:00 2001 From: Jerry Date: Thu, 27 Aug 2026 19:47:25 +0800 Subject: [PATCH 5/6] fix(python-fastapi): extend coercible typing to cookie params (#21905) Cookie values arrive as strings in the Cookie header, just like path/query/header params, so strict types (StrictInt/StrictBool/ StrictStr, strict=True) disabled Pydantic's string coercion and made FastAPI reject valid requests with a 422. Route cookie params through PydanticCoercibleType as well, and add a regression test covering path/query/header/cookie params while verifying JSON body model properties keep strict typing. --- .../languages/AbstractPythonCodegen.java | 2 +- .../languages/PythonFastAPIServerCodegen.java | 4 +- .../PythonFastAPIServerCodegenTest.java | 45 ++++++++++++ .../src/test/resources/bugs/issue_21905.yaml | 69 +++++++++++++++++++ 4 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 modules/openapi-generator/src/test/resources/bugs/issue_21905.yaml diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java index 477663e172fa..b08a6f518719 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java @@ -2495,7 +2495,7 @@ private static int floorValue(String value) { /** * Pydantic type generator for values that arrive over the wire as strings — server-bound request - * parameters in path, query, and header position. These rely on Pydantic's automatic coercion + * parameters in path, query, header, and cookie position. These rely on Pydantic's automatic coercion * (e.g. {@code "3" -> 3}); the strict types emitted by the base {@link PydanticType} * ({@code StrictInt}/{@code StrictStr}/{@code StrictFloat}, {@code strict=True}) disable that * coercion and make FastAPI reject otherwise-valid requests with a 422. See issue #21905. diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java index b0bccb4f4f3a..144e22ac4b8e 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java @@ -250,9 +250,9 @@ protected PydanticType getPydanticParameterType(CodegenParameter parameter, Set postponedExampleImports, PythonImports moduleImports, String classname) { - // Path/query/header values always arrive as strings on the wire and rely on Pydantic + // Path/query/header/cookie values always arrive as strings on the wire and rely on Pydantic // coercion, so they must not use strict types. Body params keep the strict default. - if (parameter.isQueryParam || parameter.isPathParam || parameter.isHeaderParam) { + if (parameter.isQueryParam || parameter.isPathParam || parameter.isHeaderParam || parameter.isCookieParam) { return new PydanticCoercibleType( modelImports, exampleImports, diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonFastAPIServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonFastAPIServerCodegenTest.java index 26b9f73e20be..720b77bf09cd 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonFastAPIServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonFastAPIServerCodegenTest.java @@ -193,4 +193,49 @@ public void testBinaryResponseUsesBytesNotFile() throws IOException { assertFileContains(baseApi, "-> bytes"); assertFileNotContains(baseApi, "-> file"); } + + /** + * Verifies that parameters arriving on the wire as strings (path, query, header, cookie) + * are typed with coercible Pydantic types ({@code int}/{@code bool}/{@code str}) instead of + * strict ones ({@code StrictInt}/{@code StrictBool}/{@code StrictStr}, {@code strict=True}), + * which would disable Pydantic's string coercion and make FastAPI reject valid requests + * with a 422 (#21905). + * + *

Schema constraints (e.g. {@code ge}/{@code le}) must be preserved, while JSON body + * model properties must keep strict typing since bodies carry real JSON types. + */ + @Test(description = "path/query/header/cookie params use coercible types, not strict types (#21905)") + public void testWireStringParamsUseCoercibleTypes() throws IOException { + final DefaultCodegen codegen = new PythonFastAPIServerCodegen(); + final String outputPath = generateFiles(codegen, "src/test/resources/bugs/issue_21905.yaml"); + final Path api = Paths.get(outputPath + "src/openapi_server/apis/item_api.py"); + final Path baseApi = Paths.get(outputPath + "src/openapi_server/apis/item_api_base.py"); + final Path model = Paths.get(outputPath + "src/openapi_server/models/item.py"); + + assertFileExists(api); + assertFileExists(baseApi); + + // path param: coercible int + assertFileContains(api, "itemId: int = Path(..., description=\"\")"); + // query param: coercible int, constraints kept but no strict=True + assertFileContains(api, "limit: Optional[Annotated[int, Field(le=100, ge=1)]] = Query(None, description=\"\", alias=\"limit\", ge=1, le=100)"); + // header param: coercible bool + assertFileContains(api, "x_verbose: Optional[bool] = Header(None, description=\"\")"); + // cookie params: values also arrive as strings on the wire, so they must be coercible too + assertFileContains(api, "session_id: Optional[int] = Cookie(None, description=\"\")"); + assertFileContains(api, "dark_mode: Optional[bool] = Cookie(None, description=\"\")"); + + // no strict types anywhere in the endpoint signatures + assertFileNotContains(api, "StrictInt"); + assertFileNotContains(api, "StrictBool"); + assertFileNotContains(api, "StrictStr"); + assertFileNotContains(api, "strict=True"); + assertFileNotContains(baseApi, "StrictInt"); + assertFileNotContains(baseApi, "StrictBool"); + assertFileNotContains(baseApi, "StrictStr"); + assertFileNotContains(baseApi, "strict=True"); + + // JSON body model properties keep strict typing (real JSON types, no wire-string coercion) + assertFileContains(model, "count: Optional[StrictInt] = None"); + } } diff --git a/modules/openapi-generator/src/test/resources/bugs/issue_21905.yaml b/modules/openapi-generator/src/test/resources/bugs/issue_21905.yaml new file mode 100644 index 000000000000..55567d98388a --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/issue_21905.yaml @@ -0,0 +1,69 @@ +# Regression spec for #21905, consumed by +# PythonFastAPIServerCodegenTest#testWireStringParamsUseCoercibleTypes. +# +# Focus: parameters that arrive on the wire as strings (path, query, header, cookie) +# must be generated with coercible Pydantic types (int/bool/str). Strict types +# (StrictInt/StrictBool/StrictStr, strict=True) would disable Pydantic's string +# coercion and make FastAPI reject valid requests with a 422. +openapi: 3.0.3 +info: + title: Wire-string parameter coercion (#21905) + version: 1.0.0 +paths: + /items/{itemId}: + get: + operationId: getItem + tags: + - item + parameters: + # one parameter per wire-string location; all must come out non-strict + - name: itemId + in: path + required: true + schema: + type: integer + - name: limit + in: query + schema: + type: integer + # constraints must be preserved while strict=True is dropped + minimum: 1 + maximum: 100 + - name: x_verbose + in: header + schema: + type: boolean + - name: session_id + in: cookie + schema: + type: integer + - name: dark_mode + in: cookie + schema: + type: boolean + responses: + '200': + description: OK + /items: + post: + operationId: createItem + tags: + - item + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Item' + responses: + '200': + description: OK +components: + schemas: + # counter-assertion: JSON bodies carry real JSON types, so model properties + # must keep strict typing (count stays Optional[StrictInt]) + Item: + type: object + properties: + count: + type: integer From ae924801005684d3710a2a1b1ad77262d4f1bb31 Mon Sep 17 00:00:00 2001 From: Jerry Date: Thu, 27 Aug 2026 21:05:33 +0800 Subject: [PATCH 6/6] fix(python-fastapi): keep endpoint argument comma on the parameter line The trailing newline added to endpoint_argument_definition.mustache in #23398 leaks into the rendered output: api.mustache includes the partial inline, immediately followed by a comma, so the newline wraps the comma onto a line of its own: pet: Annotated[Pet, Field(description="...")] = Body(..., description="...") , Remove the trailing newline so parameters render as `...),` again. Add a regression test asserting the comma stays at end of line (raw content checks, since assertFileContains linearizes away newlines), and regenerate the python-fastapi petstore sample. --- .../endpoint_argument_definition.mustache | 2 +- .../PythonFastAPIServerCodegenTest.java | 30 ++++++++++++++ .../src/openapi_server/apis/fake_api.py | 6 +-- .../src/openapi_server/apis/pet_api.py | 39 +++++++------------ .../src/openapi_server/apis/store_api.py | 9 ++--- .../src/openapi_server/apis/user_api.py | 27 +++++-------- 6 files changed, 58 insertions(+), 55 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/python-fastapi/endpoint_argument_definition.mustache b/modules/openapi-generator/src/main/resources/python-fastapi/endpoint_argument_definition.mustache index e398eca12c82..e031fb676969 100644 --- a/modules/openapi-generator/src/main/resources/python-fastapi/endpoint_argument_definition.mustache +++ b/modules/openapi-generator/src/main/resources/python-fastapi/endpoint_argument_definition.mustache @@ -1 +1 @@ -{{#isPathParam}}{{baseName}}{{/isPathParam}}{{^isPathParam}}{{paramName}}{{/isPathParam}}: {{>param_type}} = {{#isPathParam}}Path{{/isPathParam}}{{#isHeaderParam}}Header{{/isHeaderParam}}{{#isFormParam}}{{#isFile}}File{{/isFile}}{{^isFile}}Form{{/isFile}}{{/isFormParam}}{{#isQueryParam}}Query{{/isQueryParam}}{{#isCookieParam}}Cookie{{/isCookieParam}}{{#isBodyParam}}Body{{/isBodyParam}}({{&defaultValue}}{{^defaultValue}}{{#required}}...{{/required}}{{^required}}None{{/required}}{{/defaultValue}}, description="{{description}}"{{#isQueryParam}}, alias="{{baseName}}"{{/isQueryParam}}{{#isFormParam}}, alias="{{baseName}}"{{/isFormParam}}{{#isLong}}{{#minimum}}, ge={{.}}{{/minimum}}{{#maximum}}, le={{.}}{{/maximum}}{{/isLong}}{{#isInteger}}{{#minimum}}, ge={{.}}{{/minimum}}{{#maximum}}, le={{.}}{{/maximum}}{{/isInteger}}{{#vendorExtensions.x-regex}}, regex=r"{{.}}"{{/vendorExtensions.x-regex}}{{#minLength}}, min_length={{.}}{{/minLength}}{{#maxLength}}, max_length={{.}}{{/maxLength}}{{^isBodyParam}}{{#vendorExtensions.x-py-example}}, examples=[{{{.}}}]{{/vendorExtensions.x-py-example}}{{/isBodyParam}}{{#isBodyParam}}{{#vendorExtensions.x-py-fastapi-example}}, examples=[{{{.}}}]{{/vendorExtensions.x-py-fastapi-example}}{{/isBodyParam}}) +{{#isPathParam}}{{baseName}}{{/isPathParam}}{{^isPathParam}}{{paramName}}{{/isPathParam}}: {{>param_type}} = {{#isPathParam}}Path{{/isPathParam}}{{#isHeaderParam}}Header{{/isHeaderParam}}{{#isFormParam}}{{#isFile}}File{{/isFile}}{{^isFile}}Form{{/isFile}}{{/isFormParam}}{{#isQueryParam}}Query{{/isQueryParam}}{{#isCookieParam}}Cookie{{/isCookieParam}}{{#isBodyParam}}Body{{/isBodyParam}}({{&defaultValue}}{{^defaultValue}}{{#required}}...{{/required}}{{^required}}None{{/required}}{{/defaultValue}}, description="{{description}}"{{#isQueryParam}}, alias="{{baseName}}"{{/isQueryParam}}{{#isFormParam}}, alias="{{baseName}}"{{/isFormParam}}{{#isLong}}{{#minimum}}, ge={{.}}{{/minimum}}{{#maximum}}, le={{.}}{{/maximum}}{{/isLong}}{{#isInteger}}{{#minimum}}, ge={{.}}{{/minimum}}{{#maximum}}, le={{.}}{{/maximum}}{{/isInteger}}{{#vendorExtensions.x-regex}}, regex=r"{{.}}"{{/vendorExtensions.x-regex}}{{#minLength}}, min_length={{.}}{{/minLength}}{{#maxLength}}, max_length={{.}}{{/maxLength}}{{^isBodyParam}}{{#vendorExtensions.x-py-example}}, examples=[{{{.}}}]{{/vendorExtensions.x-py-example}}{{/isBodyParam}}{{#isBodyParam}}{{#vendorExtensions.x-py-fastapi-example}}, examples=[{{{.}}}]{{/vendorExtensions.x-py-fastapi-example}}{{/isBodyParam}}) \ No newline at end of file diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonFastAPIServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonFastAPIServerCodegenTest.java index 720b77bf09cd..72a330b29b60 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonFastAPIServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonFastAPIServerCodegenTest.java @@ -238,4 +238,34 @@ public void testWireStringParamsUseCoercibleTypes() throws IOException { // JSON body model properties keep strict typing (real JSON types, no wire-string coercion) assertFileContains(model, "count: Optional[StrictInt] = None"); } + + /** + * Verifies that endpoint argument commas stay at the end of the parameter line instead of + * being wrapped onto a line of their own. The {@code endpoint_argument_definition} partial + * is included inline (followed by {@code ,}) in api.mustache, so a trailing newline in the + * partial leaks into the output and produces the broken ")\n," style (#22494). + */ + @Test(description = "endpoint argument commas stay at end of line, no newline before comma (#22494)") + public void testEndpointArgumentCommaStaysOnSameLine() throws IOException { + final DefaultCodegen codegen = new PythonFastAPIServerCodegen(); + final String outputPath = generateFiles(codegen, "src/test/resources/bugs/issue_21905.yaml"); + final Path api = Paths.get(outputPath + "src/openapi_server/apis/item_api.py"); + + assertFileExists(api); + + // NOTE: assertFileContains linearizes away newlines, so raw content checks are required here + final String content = Files.readString(api); + + // commas terminate the parameter line + Assert.assertTrue(content.contains("itemId: int = Path(..., description=\"\"),\n"), + "parameter line should end with a comma: " + api); + Assert.assertTrue(content.contains("session_id: Optional[int] = Cookie(None, description=\"\"),\n"), + "parameter line should end with a comma: " + api); + Assert.assertTrue(content.contains("dark_mode: Optional[bool] = Cookie(None, description=\"\"),\n"), + "parameter line should end with a comma: " + api); + + // the comma must never be wrapped onto its own line + Assert.assertFalse(content.contains("\n,\n"), + "comma wrapped onto its own line in: " + api); + } } diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api.py index 0c38ab658584..56b3e6e64381 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api.py @@ -46,10 +46,8 @@ response_model_by_alias=True, ) async def fake_query_param_default( - has_default: Annotated[Optional[str], Field(description="has default value")] = Query('Hello World', description="has default value", alias="hasDefault") -, - no_default: Annotated[Optional[str], Field(description="no default value")] = Query(None, description="no default value", alias="noDefault") -, + has_default: Annotated[Optional[str], Field(description="has default value")] = Query('Hello World', description="has default value", alias="hasDefault"), + no_default: Annotated[Optional[str], Field(description="no default value")] = Query(None, description="no default value", alias="noDefault"), ) -> None: """""" if not BaseFakeApi.subclasses: diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py index 0e94e547c48e..a550aa7a21d1 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py @@ -51,8 +51,7 @@ response_model_by_alias=True, ) async def update_pet( - pet: Annotated[Pet, Field(description="Pet object that needs to be added to the store")] = Body(..., description="Pet object that needs to be added to the store") -, + pet: Annotated[Pet, Field(description="Pet object that needs to be added to the store")] = Body(..., description="Pet object that needs to be added to the store"), token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["write:pets", "read:pets"] ), @@ -74,8 +73,7 @@ async def update_pet( response_model_by_alias=True, ) async def add_pet( - pet: Annotated[Pet, Field(description="Pet object that needs to be added to the store")] = Body(..., description="Pet object that needs to be added to the store") -, + pet: Annotated[Pet, Field(description="Pet object that needs to be added to the store")] = Body(..., description="Pet object that needs to be added to the store"), token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["write:pets", "read:pets"] ), @@ -97,8 +95,7 @@ async def add_pet( response_model_by_alias=True, ) async def find_pets_by_status( - status: Annotated[List[str], Field(description="Status values that need to be considered for filter")] = Query(..., description="Status values that need to be considered for filter", alias="status") -, + status: Annotated[List[str], Field(description="Status values that need to be considered for filter")] = Query(..., description="Status values that need to be considered for filter", alias="status"), token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["read:pets"] ), @@ -120,8 +117,7 @@ async def find_pets_by_status( response_model_by_alias=True, ) async def find_pets_by_tags( - tags: Annotated[List[str], Field(description="Tags to filter by")] = Query(..., description="Tags to filter by", alias="tags") -, + tags: Annotated[List[str], Field(description="Tags to filter by")] = Query(..., description="Tags to filter by", alias="tags"), token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["read:pets"] ), @@ -144,8 +140,7 @@ async def find_pets_by_tags( response_model_by_alias=True, ) async def get_pet_by_id( - petId: Annotated[int, Field(description="ID of pet to return")] = Path(..., description="ID of pet to return") -, + petId: Annotated[int, Field(description="ID of pet to return")] = Path(..., description="ID of pet to return"), token_api_key: TokenModel = Security( get_token_api_key ), @@ -166,12 +161,9 @@ async def get_pet_by_id( response_model_by_alias=True, ) async def update_pet_with_form( - petId: Annotated[int, Field(description="ID of pet that needs to be updated")] = Path(..., description="ID of pet that needs to be updated") -, - name: Annotated[Optional[StrictStr], Field(description="Updated name of the pet")] = Form(None, description="Updated name of the pet", alias="name") -, - status: Annotated[Optional[StrictStr], Field(description="Updated status of the pet")] = Form(None, description="Updated status of the pet", alias="status") -, + petId: Annotated[int, Field(description="ID of pet that needs to be updated")] = Path(..., description="ID of pet that needs to be updated"), + name: Annotated[Optional[StrictStr], Field(description="Updated name of the pet")] = Form(None, description="Updated name of the pet", alias="name"), + status: Annotated[Optional[StrictStr], Field(description="Updated status of the pet")] = Form(None, description="Updated status of the pet", alias="status"), token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["write:pets", "read:pets"] ), @@ -192,10 +184,8 @@ async def update_pet_with_form( response_model_by_alias=True, ) async def delete_pet( - petId: Annotated[int, Field(description="Pet id to delete")] = Path(..., description="Pet id to delete") -, - api_key: Optional[str] = Header(None, description="") -, + petId: Annotated[int, Field(description="Pet id to delete")] = Path(..., description="Pet id to delete"), + api_key: Optional[str] = Header(None, description=""), token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["write:pets", "read:pets"] ), @@ -216,12 +206,9 @@ async def delete_pet( response_model_by_alias=True, ) async def upload_file( - petId: Annotated[int, Field(description="ID of pet to update")] = Path(..., description="ID of pet to update") -, - additional_metadata: Annotated[Optional[StrictStr], Field(description="Additional data to pass to server")] = Form(None, description="Additional data to pass to server", alias="additionalMetadata") -, - file: Optional[UploadFile] = File(None, description="file to upload", alias="file") -, + petId: Annotated[int, Field(description="ID of pet to update")] = Path(..., description="ID of pet to update"), + additional_metadata: Annotated[Optional[StrictStr], Field(description="Additional data to pass to server")] = Form(None, description="Additional data to pass to server", alias="additionalMetadata"), + file: Optional[UploadFile] = File(None, description="file to upload", alias="file"), token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["write:pets", "read:pets"] ), diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api.py index 3ebf433d9d1a..d1882a9005a8 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api.py @@ -67,8 +67,7 @@ async def get_inventory( response_model_by_alias=True, ) async def place_order( - order: Annotated[Order, Field(description="order placed for purchasing the pet")] = Body(..., description="order placed for purchasing the pet") -, + order: Annotated[Order, Field(description="order placed for purchasing the pet")] = Body(..., description="order placed for purchasing the pet"), ) -> Order: """""" if not BaseStoreApi.subclasses: @@ -88,8 +87,7 @@ async def place_order( response_model_by_alias=True, ) async def get_order_by_id( - orderId: Annotated[int, Field(le=5, ge=1, description="ID of pet that needs to be fetched")] = Path(..., description="ID of pet that needs to be fetched", ge=1, le=5) -, + orderId: Annotated[int, Field(le=5, ge=1, description="ID of pet that needs to be fetched")] = Path(..., description="ID of pet that needs to be fetched", ge=1, le=5), ) -> Order: """For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions""" if not BaseStoreApi.subclasses: @@ -108,8 +106,7 @@ async def get_order_by_id( response_model_by_alias=True, ) async def delete_order( - orderId: Annotated[str, Field(description="ID of the order that needs to be deleted")] = Path(..., description="ID of the order that needs to be deleted") -, + orderId: Annotated[str, Field(description="ID of the order that needs to be deleted")] = Path(..., description="ID of the order that needs to be deleted"), ) -> None: """For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors""" if not BaseStoreApi.subclasses: diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api.py index 176c0e775880..c000022a85fe 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api.py @@ -46,8 +46,7 @@ response_model_by_alias=True, ) async def create_user( - user: Annotated[User, Field(description="Created user object")] = Body(..., description="Created user object") -, + user: Annotated[User, Field(description="Created user object")] = Body(..., description="Created user object"), token_api_key: TokenModel = Security( get_token_api_key ), @@ -68,8 +67,7 @@ async def create_user( response_model_by_alias=True, ) async def create_users_with_array_input( - user: Annotated[List[User], Field(description="List of user object")] = Body(..., description="List of user object") -, + user: Annotated[List[User], Field(description="List of user object")] = Body(..., description="List of user object"), token_api_key: TokenModel = Security( get_token_api_key ), @@ -90,8 +88,7 @@ async def create_users_with_array_input( response_model_by_alias=True, ) async def create_users_with_list_input( - user: Annotated[List[User], Field(description="List of user object")] = Body(..., description="List of user object") -, + user: Annotated[List[User], Field(description="List of user object")] = Body(..., description="List of user object"), token_api_key: TokenModel = Security( get_token_api_key ), @@ -113,10 +110,8 @@ async def create_users_with_list_input( response_model_by_alias=True, ) async def login_user( - username: Annotated[str, Field(description="The user name for login")] = Query(..., description="The user name for login", alias="username", regex=r"^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$") -, - password: Annotated[str, Field(description="The password for login in clear text")] = Query(..., description="The password for login in clear text", alias="password") -, + username: Annotated[str, Field(description="The user name for login")] = Query(..., description="The user name for login", alias="username", regex=r"^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$"), + password: Annotated[str, Field(description="The password for login in clear text")] = Query(..., description="The password for login in clear text", alias="password"), ) -> str: """""" if not BaseUserApi.subclasses: @@ -156,8 +151,7 @@ async def logout_user( response_model_by_alias=True, ) async def get_user_by_name( - username: Annotated[str, Field(description="The name that needs to be fetched. Use user1 for testing.")] = Path(..., description="The name that needs to be fetched. Use user1 for testing.") -, + username: Annotated[str, Field(description="The name that needs to be fetched. Use user1 for testing.")] = Path(..., description="The name that needs to be fetched. Use user1 for testing."), ) -> User: """""" if not BaseUserApi.subclasses: @@ -176,10 +170,8 @@ async def get_user_by_name( response_model_by_alias=True, ) async def update_user( - username: Annotated[str, Field(description="name that need to be deleted")] = Path(..., description="name that need to be deleted") -, - user: Annotated[User, Field(description="Updated user object")] = Body(..., description="Updated user object") -, + username: Annotated[str, Field(description="name that need to be deleted")] = Path(..., description="name that need to be deleted"), + user: Annotated[User, Field(description="Updated user object")] = Body(..., description="Updated user object"), token_api_key: TokenModel = Security( get_token_api_key ), @@ -201,8 +193,7 @@ async def update_user( response_model_by_alias=True, ) async def delete_user( - username: Annotated[str, Field(description="The name that needs to be deleted")] = Path(..., description="The name that needs to be deleted") -, + username: Annotated[str, Field(description="The name that needs to be deleted")] = Path(..., description="The name that needs to be deleted"), token_api_key: TokenModel = Security( get_token_api_key ),