Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 106 additions & 7 deletions openapi_to_fastapi/model_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,44 @@
from contextlib import contextmanager, suppress
from pathlib import Path

from datamodel_code_generator import PythonVersion
from datamodel_code_generator import DatetimeClassType, PythonVersion
from datamodel_code_generator.model import pydantic_v2 as pydantic_model
from datamodel_code_generator.parser.openapi import OpenAPIParser
from datamodel_code_generator.types import StrictTypes

from openapi_to_fastapi.logger import logger


def generate_model_from_schema(schema: str, format_code: bool = False) -> str:
def generate_model_from_schema(
schema: str,
format_code: bool = False,
strict_validation: bool = False,
) -> str:
"""
Given an OpenAPI schema, generate pydantic models from everything defined
in the "components/schemas" section

:param schema: Content of an OpenAPI spec, plain text
:param format_code: Whether to format generated code
:param strict_validation: Whether to use strict validation
:return: Importable python code with generated models
"""
if strict_validation:
strict_types = (
StrictTypes.str,
StrictTypes.bytes,
StrictTypes.int,
StrictTypes.float,
StrictTypes.bool,
)
else:
strict_types = None

if strict_validation:
target_datetime_class = DatetimeClassType.Awaredatetime
else:
target_datetime_class = DatetimeClassType.Datetime

parser = OpenAPIParser(
source=schema,
data_model_type=pydantic_model.BaseModel,
Expand All @@ -31,14 +53,21 @@ def generate_model_from_schema(schema: str, format_code: bool = False) -> str:
extra_template_data=None,
target_python_version=PythonVersion.PY_39,
dump_resolve_reference_action=None,
extra_fields="forbid" if strict_validation else None,
strict_types=strict_types,
field_constraints=False,
snake_case_field=False,
strip_default_none=False,
aliases=None,
target_datetime_class=target_datetime_class,
)

result = parser.parse(format_=format_code)
return str(result)
result = str(parser.parse(format_=format_code))

if strict_validation:
result = override_with_stricter_dates(result)

return result


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


def load_models(
schema: str, name: str = "", cleanup: bool = True, format_code: bool = False
schema: str,
name: str = "",
cleanup: bool = True,
format_code: bool = False,
strict_validation: bool = False,
):
"""
Generate pydantic models from OpenAPI spec and return a python module,
which contains all the models from the "components/schemas" section.
This function will create a dedicated python file in OS's temporary dir
and imports it
and imports it.

:param schema: OpenAPI spec, plain text
:param name: Prefix for a module name, optional
:param cleanup: Whether to remove a file with models afterwards
:param format_code: Whether to format generated code
:param strict_validation: Whether to use strict validation
:return: Module with pydantic models
"""
prefix = name.replace("/", "").replace(" ", "").replace("\\", "") + "_"
Expand All @@ -73,7 +108,7 @@ def load_models(
),
delete=cleanup,
) as tmp_file:
model_py = generate_model_from_schema(schema, format_code)
model_py = generate_model_from_schema(schema, format_code, strict_validation)
tmp_file.write(model_py)
if not cleanup:
logger.info("Generated module %s: %s", name, tmp_file.name)
Expand All @@ -84,3 +119,67 @@ def load_models(
return spec.loader.load_module(module_name)
else:
raise ValueError(f"Failed to load module {module_name}")


def override_with_stricter_dates(file_content: str) -> str:
"""
Overrides the AwareDatetime and date in the python file by identifying the first
class definition (after the imports at the top) and injecting a comment and then
importing the StrictAwareDatetime as AwareDatetime and StrictDate as date which will
thus override the earlier imports.

Example of the file before applying changes:
> from __future__ import annotations
> from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, StrictInt, ...
> from typing import List, Optional, Union
> from datetime import date
>
>
> class BadGateway(BaseModel):
> pass
> ...

Example of file after changes:
> from __future__ import annotations
> from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, StrictInt, ...
> from typing import List, Optional, Union
> from datetime import date
>
> # Overriding the AwareDatetime and date with ones that do stricter validation
> from openapi_to_fastapi.pydantic_validators import StrictAwareDatetime as Aware...
> from openapi_to_fastapi.pydantic_validators import StrictDate as date
>
>
> class BadGateway(BaseModel):
> pass
> ...

:param file_content: The file content as a string.
:return: The modified file content as a string.
"""
comment = (
"# Overriding the AwareDatetime and date with ones that do stricter validation"
)
import_strict_date_time = (
"from openapi_to_fastapi.pydantic_validators import "
"StrictAwareDatetime as AwareDatetime"
)
import_strict_date = (
"from openapi_to_fastapi.pydantic_validators import StrictDate as date"
)

if "AwareDatetime" in file_content or "date" in file_content:
nl = "\n"
if "\r\n" in file_content:
nl = "\r\n"

parts = file_content.partition(f"{nl}{nl}class ")
file_content = (
f"{parts[0]}{nl}"
f"{comment}{nl}"
f"{import_strict_date_time}{nl}"
f"{import_strict_date}{nl}"
f"{parts[1]}{parts[2]}"
)

return file_content
88 changes: 88 additions & 0 deletions openapi_to_fastapi/pydantic_validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import re
from datetime import date
from typing import Annotated, Any

from pydantic import AwareDatetime, BeforeValidator
from pydantic_core import PydanticCustomError

rfc_3339_pattern = re.compile(
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(Z|[\+-]\d{2}:\d{2})$"
)

year_month_day_pattern = re.compile(r"^\d{4}-\d{2}-\d{2}$")


def strict_datetime_validator(value: Any) -> str:
"""
A function to be used as an extra before validator for a stricter version of the
AwareDatetime provided by pydantic.

It aims to (together with AwareDatetime) only allow valid RFC 3339 date times.

:param value: The value provided for the field.
:return: The string unchanged after validation.
"""
# When the before validators run, pydantic has not made any validation of the field
# just yet. The content can really be of any kind.
if not isinstance(value, str):
# This will (also) catch integers that would else be parsed as unix timestamps.

raise PydanticCustomError(
"datetime_type",
"Input should be a valid datetime in RFC 3339 format, input is not a "
"string",
{"error": "input not string"},
)

if not re.match(rfc_3339_pattern, value):
# Validates the format of the string strictly, but leaves things like how many
# days there is in a month, or hours in a day, etc. to AwareDatetime to check.
raise PydanticCustomError(
"datetime_from_date_parsing",
"Input should be a valid datetime, in RFC 3339 format",
{"error": "input does not follow RFC 3339"},
)

return value


def strict_date_validator(value: Any) -> str:
"""
A function to be used as an extra before validator for a stricter validation of
dates.

It aims to only allow dates of the form YYYY-MM-DD.

:param value: The value provided for the field.
:return: The string unchanged after validation.
"""
# When the before validators run, pydantic has not made any validation of the field
# just yet. The content can really be of any kind.
if not isinstance(value, str):
# This will (also) catch integers that would else be parsed as unix timestamps.

raise PydanticCustomError(
"date_type",
"Input should be a valid date in RFC 3339 'full-date' format, input is not "
"a string",
{"error": "input not string"},
)

if not re.match(year_month_day_pattern, value):
# Validates the format of the string strictly, but leaves things like how many
# days there is in a month to the normal date class.
raise PydanticCustomError(
"date_from_datetime_parsing",
"Input should be a valid date, in RFC 3339 'full-date' format",
{"error": "input is not of form YYYY-MM-DD"},
)

return value


StrictAwareDatetime = Annotated[
AwareDatetime, BeforeValidator(strict_datetime_validator)
]


StrictDate = Annotated[date, BeforeValidator(strict_date_validator)]
7 changes: 6 additions & 1 deletion openapi_to_fastapi/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,14 @@ def _validate_and_parse_specs(self, cleanup=True):

raw_spec = spec_path.read_text(encoding="utf8")
json_spec = json.loads(raw_spec)
strict_validation = bool(json_spec.get("x-strict-validation"))
for path, path_item in parse_openapi_spec(json_spec).items():
models = load_models(
raw_spec, path, cleanup=cleanup, format_code=self._format_code
raw_spec,
path,
cleanup=cleanup,
format_code=self._format_code,
strict_validation=strict_validation,
)
post = path_item.post
if post:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"detail": [
{
"input": 1,
"loc": [
"body",
"bool1"
],
"msg": "Input should be a valid boolean",
"type": "bool_type"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"detail": [
{
"input": null,
"loc": [
"body",
"bool1"
],
"msg": "Input should be a valid boolean",
"type": "bool_type"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"detail": [
{
"input": null,
"loc": [
"body",
"bool1"
],
"msg": "Input should be a valid boolean",
"type": "bool_type"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"detail": [
{
"input": "abc",
"loc": [
"body",
"bool1"
],
"msg": "Input should be a valid boolean",
"type": "bool_type"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"detail": [
{
"input": "abc",
"loc": [
"body",
"bool1"
],
"msg": "Input should be a valid boolean, unable to interpret input",
"type": "bool_parsing"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"detail": [
{
"input": "true",
"loc": [
"body",
"bool1"
],
"msg": "Input should be a valid boolean",
"type": "bool_type"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"detail": [
{
"ctx": {
"error": "day value is outside expected range"
},
"input": "2025-01-00",
"loc": [
"body",
"date1"
],
"msg": "Input should be a valid date or datetime, day value is outside expected range",
"type": "date_from_datetime_parsing"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"detail": [
{
"ctx": {
"error": "day value is outside expected range"
},
"input": "2025-01-00",
"loc": [
"body",
"date1"
],
"msg": "Input should be a valid date or datetime, day value is outside expected range",
"type": "date_from_datetime_parsing"
}
]
}
Loading