Skip to content

Commit 47eb4c2

Browse files
committed
fix(python-fastapi): relax strict typing for path/query/header params (OpenAPITools#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
1 parent 97cb5b3 commit 47eb4c2

10 files changed

Lines changed: 115 additions & 44 deletions

File tree

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1301,6 +1301,24 @@ public void updateImportsFromCodegenModel(String modelName, CodegenModel cm, Set
13011301
}
13021302
}
13031303

1304+
/**
1305+
* Whether the given request parameter should be typed with coercible types
1306+
* ({@code int}/{@code str}/{@code float}) instead of Pydantic strict types
1307+
* ({@code StrictInt}/{@code StrictStr}/{@code StrictFloat}, {@code strict=True}).
1308+
*
1309+
* <p>The default is {@code false}, preserving strict typing for all generators
1310+
* (notably the Python client, which builds JSON request bodies where strict
1311+
* validation is desirable). Server generators that parse path/query/header values
1312+
* from the wire — where everything arrives as a string and relies on Pydantic
1313+
* coercion — should override this for non-body parameters. See issue #21905.
1314+
*
1315+
* @param parameter the request parameter being typed
1316+
* @return {@code true} to relax strict typing for this parameter
1317+
*/
1318+
protected boolean shouldRelaxStrictParameterTyping(CodegenParameter parameter) {
1319+
return false;
1320+
}
1321+
13041322
@Override
13051323
public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<ModelMap> allModels) {
13061324
hasModelsToImport = false;
@@ -1324,7 +1342,8 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
13241342
postponedModelImports,
13251343
postponedExampleImports,
13261344
moduleImports,
1327-
null
1345+
null,
1346+
shouldRelaxStrictParameterTyping(cp)
13281347
);
13291348
String typing = pydantic.generatePythonType(cp);
13301349
cp.vendorExtensions.put(X_PY_TYPING, typing);
@@ -1843,6 +1862,11 @@ class PydanticType {
18431862
private Set<String> postponedExampleImports;
18441863
private PythonImports moduleImports;
18451864
private String classname;
1865+
// When true, emit coercible types (int/str/float) instead of Pydantic strict
1866+
// types (StrictInt/StrictStr/StrictFloat) and omit the strict=True constraint.
1867+
// Used for non-body request parameters, whose values always arrive as strings
1868+
// on the wire and rely on Pydantic's automatic coercion. See issue #21905.
1869+
private boolean relaxStrict;
18461870

18471871
public PydanticType(
18481872
Set<String> modelImports,
@@ -1851,13 +1875,26 @@ public PydanticType(
18511875
Set<String> postponedExampleImports,
18521876
PythonImports moduleImports,
18531877
String classname
1878+
) {
1879+
this(modelImports, exampleImports, postponedModelImports, postponedExampleImports, moduleImports, classname, false);
1880+
}
1881+
1882+
public PydanticType(
1883+
Set<String> modelImports,
1884+
Set<String> exampleImports,
1885+
Set<String> postponedModelImports,
1886+
Set<String> postponedExampleImports,
1887+
PythonImports moduleImports,
1888+
String classname,
1889+
boolean relaxStrict
18541890
) {
18551891
this.modelImports = modelImports;
18561892
this.exampleImports = exampleImports;
18571893
this.postponedModelImports = postponedModelImports;
18581894
this.postponedExampleImports = postponedExampleImports;
18591895
this.moduleImports = moduleImports;
18601896
this.classname = classname;
1897+
this.relaxStrict = relaxStrict;
18611898
}
18621899

18631900
private PythonType arrayType(IJsonSchemaValidationProperties cp) {
@@ -1904,7 +1941,9 @@ private PythonType stringType(IJsonSchemaValidationProperties cp) {
19041941
PythonType pt = new PythonType("str");
19051942

19061943
// e.g. constr(regex=r'/[a-z]/i', strict=True)
1907-
pt.constrain("strict", true);
1944+
if (!relaxStrict) {
1945+
pt.constrain("strict", true);
1946+
}
19081947
if (cp.getMaxLength() != null) {
19091948
pt.constrain("max_length", cp.getMaxLength());
19101949
}
@@ -1922,6 +1961,8 @@ private PythonType stringType(IJsonSchemaValidationProperties cp) {
19221961
if ("password".equals(cp.getFormat())) { // TODO avoid using format, use `is` boolean flag instead
19231962
moduleImports.add(PYDANTIC, "SecretStr");
19241963
return new PythonType("SecretStr");
1964+
} else if (relaxStrict) {
1965+
return new PythonType("str");
19251966
} else {
19261967
moduleImports.add(PYDANTIC, "StrictStr");
19271968
return new PythonType("StrictStr");
@@ -1966,30 +2007,43 @@ private PythonType numberType(IJsonSchemaValidationProperties cp) {
19662007
}
19672008

19682009
if ("Union[StrictFloat, StrictInt]".equals(mapNumberTo)) {
1969-
floatt.constrain("strict", true);
1970-
intt.constrain("strict", true);
2010+
if (!relaxStrict) {
2011+
floatt.constrain("strict", true);
2012+
intt.constrain("strict", true);
2013+
}
19712014

19722015
moduleImports.add(TYPING, "Union");
19732016
PythonType pt = new PythonType("Union");
19742017
pt.addTypeParam(floatt);
19752018
pt.addTypeParam(intt);
19762019
return pt;
19772020
} else if ("StrictFloat".equals(mapNumberTo)) {
1978-
floatt.constrain("strict", true);
2021+
if (!relaxStrict) {
2022+
floatt.constrain("strict", true);
2023+
}
19792024
return floatt;
19802025
} else { // float
19812026
return floatt;
19822027
}
19832028
} else {
19842029
if ("Union[StrictFloat, StrictInt]".equals(mapNumberTo)) {
19852030
moduleImports.add(TYPING, "Union");
2031+
if (relaxStrict) {
2032+
PythonType pt = new PythonType("Union");
2033+
pt.addTypeParam(new PythonType("float"));
2034+
pt.addTypeParam(new PythonType("int"));
2035+
return pt;
2036+
}
19862037
moduleImports.add(PYDANTIC, "StrictFloat");
19872038
moduleImports.add(PYDANTIC, "StrictInt");
19882039
PythonType pt = new PythonType("Union");
19892040
pt.addTypeParam(new PythonType("StrictFloat"));
19902041
pt.addTypeParam(new PythonType("StrictInt"));
19912042
return pt;
19922043
} else if ("StrictFloat".equals(mapNumberTo)) {
2044+
if (relaxStrict) {
2045+
return new PythonType("float");
2046+
}
19932047
moduleImports.add(PYDANTIC, "StrictFloat");
19942048
return new PythonType("StrictFloat");
19952049
} else {
@@ -2002,10 +2056,15 @@ private PythonType intType(IJsonSchemaValidationProperties cp) {
20022056
if (cp.getHasValidation()) {
20032057
PythonType pt = new PythonType("int");
20042058
// e.g. conint(ge=10, le=100, strict=True)
2005-
pt.constrain("strict", true);
2059+
if (!relaxStrict) {
2060+
pt.constrain("strict", true);
2061+
}
20062062
applyConstraints(pt, cp);
20072063
return pt;
20082064
} else {
2065+
if (relaxStrict) {
2066+
return new PythonType("int");
2067+
}
20092068
moduleImports.add(PYDANTIC, "StrictInt");
20102069
return new PythonType("StrictInt");
20112070
}

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,18 @@ public String getTypeDeclaration(Schema p) {
237237
return super.getTypeDeclaration(p);
238238
}
239239

240+
/**
241+
* Path/query/header parameters arrive on the wire as strings and rely on Pydantic's automatic
242+
* coercion (e.g. {@code "3" -> 3}). Pydantic strict typing disables that coercion, making FastAPI
243+
* reject otherwise-valid requests with a 422 ({@code int_type: Input should be a valid integer}).
244+
* So relax strict typing for those parameters. Body parameters keep strict typing, since JSON
245+
* request bodies carry real types and strict validation is desirable there. See issue #21905.
246+
*/
247+
@Override
248+
protected boolean shouldRelaxStrictParameterTyping(CodegenParameter parameter) {
249+
return parameter.isQueryParam || parameter.isPathParam || parameter.isHeaderParam;
250+
}
251+
240252
@Override
241253
public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<ModelMap> allModels) {
242254
super.postProcessOperationsWithModels(objs, allModels);

samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
)
2424

2525
from openapi_server.models.extra_models import TokenModel # noqa: F401
26-
from pydantic import Field, StrictStr
26+
from pydantic import Field
2727
from typing import Any, Optional
2828
from typing_extensions import Annotated
2929

@@ -46,8 +46,8 @@
4646
response_model_by_alias=True,
4747
)
4848
async def fake_query_param_default(
49-
has_default: Annotated[Optional[StrictStr], Field(description="has default value")] = Query('Hello World', description="has default value", alias="hasDefault"),
50-
no_default: Annotated[Optional[StrictStr], Field(description="no default value")] = Query(None, description="no default value", alias="noDefault"),
49+
has_default: Annotated[Optional[str], Field(description="has default value")] = Query('Hello World', description="has default value", alias="hasDefault"),
50+
no_default: Annotated[Optional[str], Field(description="no default value")] = Query(None, description="no default value", alias="noDefault"),
5151
) -> None:
5252
""""""
5353
if not BaseFakeApi.subclasses:

samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api_base.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from typing import ClassVar, Dict, List, Tuple # noqa: F401
44

5-
from pydantic import Field, StrictStr
5+
from pydantic import Field
66
from typing import Any, Optional
77
from typing_extensions import Annotated
88

@@ -15,8 +15,8 @@ def __init_subclass__(cls, **kwargs):
1515
BaseFakeApi.subclasses = BaseFakeApi.subclasses + (cls,)
1616
async def fake_query_param_default(
1717
self,
18-
has_default: Annotated[Optional[StrictStr], Field(description="has default value")],
19-
no_default: Annotated[Optional[StrictStr], Field(description="no default value")],
18+
has_default: Annotated[Optional[str], Field(description="has default value")],
19+
no_default: Annotated[Optional[str], Field(description="no default value")],
2020
) -> None:
2121
""""""
2222
...

samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
)
2424

2525
from openapi_server.models.extra_models import TokenModel # noqa: F401
26-
from pydantic import Field, StrictBytes, StrictInt, StrictStr, field_validator
26+
from pydantic import Field, StrictBytes, StrictStr, field_validator
2727
from typing import Any, List, Optional, Tuple, Union
2828
from typing_extensions import Annotated
2929
from openapi_server.models.api_response import ApiResponse
@@ -95,7 +95,7 @@ async def add_pet(
9595
response_model_by_alias=True,
9696
)
9797
async def find_pets_by_status(
98-
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"),
98+
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"),
9999
token_petstore_auth: TokenModel = Security(
100100
get_token_petstore_auth, scopes=["read:pets"]
101101
),
@@ -117,7 +117,7 @@ async def find_pets_by_status(
117117
response_model_by_alias=True,
118118
)
119119
async def find_pets_by_tags(
120-
tags: Annotated[List[StrictStr], Field(description="Tags to filter by")] = Query(None, description="Tags to filter by", alias="tags"),
120+
tags: Annotated[List[str], Field(description="Tags to filter by")] = Query(None, description="Tags to filter by", alias="tags"),
121121
token_petstore_auth: TokenModel = Security(
122122
get_token_petstore_auth, scopes=["read:pets"]
123123
),
@@ -140,7 +140,7 @@ async def find_pets_by_tags(
140140
response_model_by_alias=True,
141141
)
142142
async def get_pet_by_id(
143-
petId: Annotated[StrictInt, Field(description="ID of pet to return")] = Path(..., description="ID of pet to return"),
143+
petId: Annotated[int, Field(description="ID of pet to return")] = Path(..., description="ID of pet to return"),
144144
token_api_key: TokenModel = Security(
145145
get_token_api_key
146146
),
@@ -161,7 +161,7 @@ async def get_pet_by_id(
161161
response_model_by_alias=True,
162162
)
163163
async def update_pet_with_form(
164-
petId: Annotated[StrictInt, Field(description="ID of pet that needs to be updated")] = Path(..., description="ID of pet that needs to be updated"),
164+
petId: Annotated[int, Field(description="ID of pet that needs to be updated")] = Path(..., description="ID of pet that needs to be updated"),
165165
name: Annotated[Optional[StrictStr], Field(description="Updated name of the pet")] = Form(None, description="Updated name of the pet"),
166166
status: Annotated[Optional[StrictStr], Field(description="Updated status of the pet")] = Form(None, description="Updated status of the pet"),
167167
token_petstore_auth: TokenModel = Security(
@@ -184,8 +184,8 @@ async def update_pet_with_form(
184184
response_model_by_alias=True,
185185
)
186186
async def delete_pet(
187-
petId: Annotated[StrictInt, Field(description="Pet id to delete")] = Path(..., description="Pet id to delete"),
188-
api_key: Optional[StrictStr] = Header(None, description=""),
187+
petId: Annotated[int, Field(description="Pet id to delete")] = Path(..., description="Pet id to delete"),
188+
api_key: Optional[str] = Header(None, description=""),
189189
token_petstore_auth: TokenModel = Security(
190190
get_token_petstore_auth, scopes=["write:pets", "read:pets"]
191191
),
@@ -206,7 +206,7 @@ async def delete_pet(
206206
response_model_by_alias=True,
207207
)
208208
async def upload_file(
209-
petId: Annotated[StrictInt, Field(description="ID of pet to update")] = Path(..., description="ID of pet to update"),
209+
petId: Annotated[int, Field(description="ID of pet to update")] = Path(..., description="ID of pet to update"),
210210
additional_metadata: Annotated[Optional[StrictStr], Field(description="Additional data to pass to server")] = Form(None, description="Additional data to pass to server"),
211211
file: Optional[UploadFile] = File(None, description="file to upload"),
212212
token_petstore_auth: TokenModel = Security(

samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api_base.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from typing import ClassVar, Dict, List, Tuple # noqa: F401
44

5-
from pydantic import Field, StrictBytes, StrictInt, StrictStr, field_validator
5+
from pydantic import Field, StrictBytes, StrictStr, field_validator
66
from typing import Any, List, Optional, Tuple, Union
77
from typing_extensions import Annotated
88
from openapi_server.models.api_response import ApiResponse
@@ -34,31 +34,31 @@ async def add_pet(
3434

3535
async def find_pets_by_status(
3636
self,
37-
status: Annotated[List[StrictStr], Field(description="Status values that need to be considered for filter")],
37+
status: Annotated[List[str], Field(description="Status values that need to be considered for filter")],
3838
) -> List[Pet]:
3939
"""Multiple status values can be provided with comma separated strings"""
4040
...
4141

4242

4343
async def find_pets_by_tags(
4444
self,
45-
tags: Annotated[List[StrictStr], Field(description="Tags to filter by")],
45+
tags: Annotated[List[str], Field(description="Tags to filter by")],
4646
) -> List[Pet]:
4747
"""Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing."""
4848
...
4949

5050

5151
async def get_pet_by_id(
5252
self,
53-
petId: Annotated[StrictInt, Field(description="ID of pet to return")],
53+
petId: Annotated[int, Field(description="ID of pet to return")],
5454
) -> Pet:
5555
"""Returns a single pet"""
5656
...
5757

5858

5959
async def update_pet_with_form(
6060
self,
61-
petId: Annotated[StrictInt, Field(description="ID of pet that needs to be updated")],
61+
petId: Annotated[int, Field(description="ID of pet that needs to be updated")],
6262
name: Annotated[Optional[StrictStr], Field(description="Updated name of the pet")],
6363
status: Annotated[Optional[StrictStr], Field(description="Updated status of the pet")],
6464
) -> None:
@@ -68,16 +68,16 @@ async def update_pet_with_form(
6868

6969
async def delete_pet(
7070
self,
71-
petId: Annotated[StrictInt, Field(description="Pet id to delete")],
72-
api_key: Optional[StrictStr],
71+
petId: Annotated[int, Field(description="Pet id to delete")],
72+
api_key: Optional[str],
7373
) -> None:
7474
""""""
7575
...
7676

7777

7878
async def upload_file(
7979
self,
80-
petId: Annotated[StrictInt, Field(description="ID of pet to update")],
80+
petId: Annotated[int, Field(description="ID of pet to update")],
8181
additional_metadata: Annotated[Optional[StrictStr], Field(description="Additional data to pass to server")],
8282
file: Optional[UploadFile],
8383
) -> ApiResponse:

samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
)
2424

2525
from openapi_server.models.extra_models import TokenModel # noqa: F401
26-
from pydantic import Field, StrictInt, StrictStr
26+
from pydantic import Field, StrictInt
2727
from typing import Any, Dict
2828
from typing_extensions import Annotated
2929
from openapi_server.models.order import Order
@@ -87,7 +87,7 @@ async def place_order(
8787
response_model_by_alias=True,
8888
)
8989
async def get_order_by_id(
90-
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),
90+
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),
9191
) -> Order:
9292
"""For valid response try integer IDs with value &lt;&#x3D; 5 or &gt; 10. Other values will generate exceptions"""
9393
if not BaseStoreApi.subclasses:
@@ -106,7 +106,7 @@ async def get_order_by_id(
106106
response_model_by_alias=True,
107107
)
108108
async def delete_order(
109-
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"),
109+
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"),
110110
) -> None:
111111
"""For valid response try integer IDs with value &lt; 1000. Anything above 1000 or nonintegers will generate API errors"""
112112
if not BaseStoreApi.subclasses:

0 commit comments

Comments
 (0)