From 47eb4c2f53f757ed68995eed00a6c4aab2c4762b Mon Sep 17 00:00:00 2001 From: Goopher Maijenburg Date: Mon, 22 Jun 2026 20:02:03 +0200 Subject: [PATCH 1/2] 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/2] 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