Skip to content

Commit 817888d

Browse files
author
Joakim Nordling
authored
Add support for strict validation (#52)
This adds support for optional stricter validation in the generated routes. If the OpenAPI Spec (`.json`) files on the root level contain the key `x-strict-validation` and it's set to `true` the library will use a stricter mode of validation for the models generated from the file. If the key is not present or set to `false`, the library will default to old, laxer mode. In short this stricter mode consists of: - Disallowing extra fields in the models - Using strict validation for `str,bytes,int,float,bool` - Strict validation of datetimes to follow RFC 3339 and dates to follow the 'full-date' format from RFC 3339 It's worth noting that the underlying [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator) in version [0.33.0](https://github.com/koxudaxi/datamodel-code-generator/releases/tag/0.33.0) reverted the behaviour of how datetimes are handled. Based on koxudaxi/datamodel-code-generator#2441 it seems like datamodel-code-generator versions 0.26.2 - 0.32.0 accidentally defaulted to using `datetime` for datetime fields, whereas versions before that and after that default to `AwareDatetime`. In essence this means that the versions 0.26.2 - 0.32.0 had a laxer validation of datetimes than versions up until 0.26.1 and again starting from 0.33.0. This means also openapi-to-fastapi has had this laxer validation in versions starting from 0.18.0 ->. This PR updates datamode-code-generator to latest 0.33.0 version, but contains such changes that by default the validation will be as lax as in the previous version by defaulting to using the `datetime` class for those fields. Thus by default the datetime validation will work the same way after this update to openapi-to-fastapi as before it despite datamodel-code-generator being updated to the latest version. The strict mode provided by this PR however takes things one step further. The `AwareDatetime` is somewhat lax; it allows you to send dates in multiple formats that are valid ISO 8601 formats, but aren't following the strict set of RFC 3339, as well as unix timestamps (seconds since 1970). It thus defines it's own `StrictAwareDatetime`, which checks the type and format. In essence it will check the separator is a capital `T` and that there is either a capital `Z` or `+` or `-` with a `HH:MM`. The strict mode also ensures that date fields can neither be unix timestamps, rather must be of the YYYY-MM-DD format.
1 parent 41733a3 commit 817888d

49 files changed

Lines changed: 2874 additions & 119 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

openapi_to_fastapi/model_generator.py

Lines changed: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,44 @@
44
from contextlib import contextmanager, suppress
55
from pathlib import Path
66

7-
from datamodel_code_generator import PythonVersion
7+
from datamodel_code_generator import DatetimeClassType, PythonVersion
88
from datamodel_code_generator.model import pydantic_v2 as pydantic_model
99
from datamodel_code_generator.parser.openapi import OpenAPIParser
10+
from datamodel_code_generator.types import StrictTypes
1011

1112
from openapi_to_fastapi.logger import logger
1213

1314

14-
def generate_model_from_schema(schema: str, format_code: bool = False) -> str:
15+
def generate_model_from_schema(
16+
schema: str,
17+
format_code: bool = False,
18+
strict_validation: bool = False,
19+
) -> str:
1520
"""
1621
Given an OpenAPI schema, generate pydantic models from everything defined
1722
in the "components/schemas" section
1823
1924
:param schema: Content of an OpenAPI spec, plain text
2025
:param format_code: Whether to format generated code
26+
:param strict_validation: Whether to use strict validation
2127
:return: Importable python code with generated models
2228
"""
29+
if strict_validation:
30+
strict_types = (
31+
StrictTypes.str,
32+
StrictTypes.bytes,
33+
StrictTypes.int,
34+
StrictTypes.float,
35+
StrictTypes.bool,
36+
)
37+
else:
38+
strict_types = None
39+
40+
if strict_validation:
41+
target_datetime_class = DatetimeClassType.Awaredatetime
42+
else:
43+
target_datetime_class = DatetimeClassType.Datetime
44+
2345
parser = OpenAPIParser(
2446
source=schema,
2547
data_model_type=pydantic_model.BaseModel,
@@ -31,14 +53,21 @@ def generate_model_from_schema(schema: str, format_code: bool = False) -> str:
3153
extra_template_data=None,
3254
target_python_version=PythonVersion.PY_39,
3355
dump_resolve_reference_action=None,
56+
extra_fields="forbid" if strict_validation else None,
57+
strict_types=strict_types,
3458
field_constraints=False,
3559
snake_case_field=False,
3660
strip_default_none=False,
3761
aliases=None,
62+
target_datetime_class=target_datetime_class,
3863
)
3964

40-
result = parser.parse(format_=format_code)
41-
return str(result)
65+
result = str(parser.parse(format_=format_code))
66+
67+
if strict_validation:
68+
result = override_with_stricter_dates(result)
69+
70+
return result
4271

4372

4473
@contextmanager
@@ -53,17 +82,23 @@ def _clean_tempfile(tmp_file, delete=True):
5382

5483

5584
def load_models(
56-
schema: str, name: str = "", cleanup: bool = True, format_code: bool = False
85+
schema: str,
86+
name: str = "",
87+
cleanup: bool = True,
88+
format_code: bool = False,
89+
strict_validation: bool = False,
5790
):
5891
"""
5992
Generate pydantic models from OpenAPI spec and return a python module,
6093
which contains all the models from the "components/schemas" section.
6194
This function will create a dedicated python file in OS's temporary dir
62-
and imports it
95+
and imports it.
96+
6397
:param schema: OpenAPI spec, plain text
6498
:param name: Prefix for a module name, optional
6599
:param cleanup: Whether to remove a file with models afterwards
66100
:param format_code: Whether to format generated code
101+
:param strict_validation: Whether to use strict validation
67102
:return: Module with pydantic models
68103
"""
69104
prefix = name.replace("/", "").replace(" ", "").replace("\\", "") + "_"
@@ -73,7 +108,7 @@ def load_models(
73108
),
74109
delete=cleanup,
75110
) as tmp_file:
76-
model_py = generate_model_from_schema(schema, format_code)
111+
model_py = generate_model_from_schema(schema, format_code, strict_validation)
77112
tmp_file.write(model_py)
78113
if not cleanup:
79114
logger.info("Generated module %s: %s", name, tmp_file.name)
@@ -84,3 +119,67 @@ def load_models(
84119
return spec.loader.load_module(module_name)
85120
else:
86121
raise ValueError(f"Failed to load module {module_name}")
122+
123+
124+
def override_with_stricter_dates(file_content: str) -> str:
125+
"""
126+
Overrides the AwareDatetime and date in the python file by identifying the first
127+
class definition (after the imports at the top) and injecting a comment and then
128+
importing the StrictAwareDatetime as AwareDatetime and StrictDate as date which will
129+
thus override the earlier imports.
130+
131+
Example of the file before applying changes:
132+
> from __future__ import annotations
133+
> from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, StrictInt, ...
134+
> from typing import List, Optional, Union
135+
> from datetime import date
136+
>
137+
>
138+
> class BadGateway(BaseModel):
139+
> pass
140+
> ...
141+
142+
Example of file after changes:
143+
> from __future__ import annotations
144+
> from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, StrictInt, ...
145+
> from typing import List, Optional, Union
146+
> from datetime import date
147+
>
148+
> # Overriding the AwareDatetime and date with ones that do stricter validation
149+
> from openapi_to_fastapi.pydantic_validators import StrictAwareDatetime as Aware...
150+
> from openapi_to_fastapi.pydantic_validators import StrictDate as date
151+
>
152+
>
153+
> class BadGateway(BaseModel):
154+
> pass
155+
> ...
156+
157+
:param file_content: The file content as a string.
158+
:return: The modified file content as a string.
159+
"""
160+
comment = (
161+
"# Overriding the AwareDatetime and date with ones that do stricter validation"
162+
)
163+
import_strict_date_time = (
164+
"from openapi_to_fastapi.pydantic_validators import "
165+
"StrictAwareDatetime as AwareDatetime"
166+
)
167+
import_strict_date = (
168+
"from openapi_to_fastapi.pydantic_validators import StrictDate as date"
169+
)
170+
171+
if "AwareDatetime" in file_content or "date" in file_content:
172+
nl = "\n"
173+
if "\r\n" in file_content:
174+
nl = "\r\n"
175+
176+
parts = file_content.partition(f"{nl}{nl}class ")
177+
file_content = (
178+
f"{parts[0]}{nl}"
179+
f"{comment}{nl}"
180+
f"{import_strict_date_time}{nl}"
181+
f"{import_strict_date}{nl}"
182+
f"{parts[1]}{parts[2]}"
183+
)
184+
185+
return file_content
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import re
2+
from datetime import date
3+
from typing import Annotated, Any
4+
5+
from pydantic import AwareDatetime, BeforeValidator
6+
from pydantic_core import PydanticCustomError
7+
8+
rfc_3339_pattern = re.compile(
9+
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(Z|[\+-]\d{2}:\d{2})$"
10+
)
11+
12+
year_month_day_pattern = re.compile(r"^\d{4}-\d{2}-\d{2}$")
13+
14+
15+
def strict_datetime_validator(value: Any) -> str:
16+
"""
17+
A function to be used as an extra before validator for a stricter version of the
18+
AwareDatetime provided by pydantic.
19+
20+
It aims to (together with AwareDatetime) only allow valid RFC 3339 date times.
21+
22+
:param value: The value provided for the field.
23+
:return: The string unchanged after validation.
24+
"""
25+
# When the before validators run, pydantic has not made any validation of the field
26+
# just yet. The content can really be of any kind.
27+
if not isinstance(value, str):
28+
# This will (also) catch integers that would else be parsed as unix timestamps.
29+
30+
raise PydanticCustomError(
31+
"datetime_type",
32+
"Input should be a valid datetime in RFC 3339 format, input is not a "
33+
"string",
34+
{"error": "input not string"},
35+
)
36+
37+
if not re.match(rfc_3339_pattern, value):
38+
# Validates the format of the string strictly, but leaves things like how many
39+
# days there is in a month, or hours in a day, etc. to AwareDatetime to check.
40+
raise PydanticCustomError(
41+
"datetime_from_date_parsing",
42+
"Input should be a valid datetime, in RFC 3339 format",
43+
{"error": "input does not follow RFC 3339"},
44+
)
45+
46+
return value
47+
48+
49+
def strict_date_validator(value: Any) -> str:
50+
"""
51+
A function to be used as an extra before validator for a stricter validation of
52+
dates.
53+
54+
It aims to only allow dates of the form YYYY-MM-DD.
55+
56+
:param value: The value provided for the field.
57+
:return: The string unchanged after validation.
58+
"""
59+
# When the before validators run, pydantic has not made any validation of the field
60+
# just yet. The content can really be of any kind.
61+
if not isinstance(value, str):
62+
# This will (also) catch integers that would else be parsed as unix timestamps.
63+
64+
raise PydanticCustomError(
65+
"date_type",
66+
"Input should be a valid date in RFC 3339 'full-date' format, input is not "
67+
"a string",
68+
{"error": "input not string"},
69+
)
70+
71+
if not re.match(year_month_day_pattern, value):
72+
# Validates the format of the string strictly, but leaves things like how many
73+
# days there is in a month to the normal date class.
74+
raise PydanticCustomError(
75+
"date_from_datetime_parsing",
76+
"Input should be a valid date, in RFC 3339 'full-date' format",
77+
{"error": "input is not of form YYYY-MM-DD"},
78+
)
79+
80+
return value
81+
82+
83+
StrictAwareDatetime = Annotated[
84+
AwareDatetime, BeforeValidator(strict_datetime_validator)
85+
]
86+
87+
88+
StrictDate = Annotated[date, BeforeValidator(strict_date_validator)]

openapi_to_fastapi/routes.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,14 @@ def _validate_and_parse_specs(self, cleanup=True):
118118

119119
raw_spec = spec_path.read_text(encoding="utf8")
120120
json_spec = json.loads(raw_spec)
121+
strict_validation = bool(json_spec.get("x-strict-validation"))
121122
for path, path_item in parse_openapi_spec(json_spec).items():
122123
models = load_models(
123-
raw_spec, path, cleanup=cleanup, format_code=self._format_code
124+
raw_spec,
125+
path,
126+
cleanup=cleanup,
127+
format_code=self._format_code,
128+
strict_validation=strict_validation,
124129
)
125130
post = path_item.post
126131
if post:
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"detail": [
3+
{
4+
"input": 1,
5+
"loc": [
6+
"body",
7+
"bool1"
8+
],
9+
"msg": "Input should be a valid boolean",
10+
"type": "bool_type"
11+
}
12+
]
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"detail": [
3+
{
4+
"input": null,
5+
"loc": [
6+
"body",
7+
"bool1"
8+
],
9+
"msg": "Input should be a valid boolean",
10+
"type": "bool_type"
11+
}
12+
]
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"detail": [
3+
{
4+
"input": null,
5+
"loc": [
6+
"body",
7+
"bool1"
8+
],
9+
"msg": "Input should be a valid boolean",
10+
"type": "bool_type"
11+
}
12+
]
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"detail": [
3+
{
4+
"input": "abc",
5+
"loc": [
6+
"body",
7+
"bool1"
8+
],
9+
"msg": "Input should be a valid boolean",
10+
"type": "bool_type"
11+
}
12+
]
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"detail": [
3+
{
4+
"input": "abc",
5+
"loc": [
6+
"body",
7+
"bool1"
8+
],
9+
"msg": "Input should be a valid boolean, unable to interpret input",
10+
"type": "bool_parsing"
11+
}
12+
]
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"detail": [
3+
{
4+
"input": "true",
5+
"loc": [
6+
"body",
7+
"bool1"
8+
],
9+
"msg": "Input should be a valid boolean",
10+
"type": "bool_type"
11+
}
12+
]
13+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"detail": [
3+
{
4+
"ctx": {
5+
"error": "day value is outside expected range"
6+
},
7+
"input": "2025-01-00",
8+
"loc": [
9+
"body",
10+
"date1"
11+
],
12+
"msg": "Input should be a valid date or datetime, day value is outside expected range",
13+
"type": "date_from_datetime_parsing"
14+
}
15+
]
16+
}

0 commit comments

Comments
 (0)