diff --git a/extendable_fastapi/__init__.py b/extendable_fastapi/__init__.py index 08121b91b..28a255de7 100644 --- a/extendable_fastapi/__init__.py +++ b/extendable_fastapi/__init__.py @@ -1 +1,2 @@ from . import fastapi_dispatcher +from .schemas import StrictExtendableBaseModel diff --git a/extendable_fastapi/__manifest__.py b/extendable_fastapi/__manifest__.py index 04421f9d7..374394ada 100644 --- a/extendable_fastapi/__manifest__.py +++ b/extendable_fastapi/__manifest__.py @@ -15,7 +15,7 @@ "demo": [], "external_dependencies": { "python": [ - "extendable-pydantic>=1.0.0", + "extendable-pydantic>=1.1.0", ], }, "installable": True, diff --git a/extendable_fastapi/schemas.py b/extendable_fastapi/schemas.py new file mode 100644 index 000000000..797f065a0 --- /dev/null +++ b/extendable_fastapi/schemas.py @@ -0,0 +1,29 @@ +from extendable_pydantic import ExtendableBaseModel + + +class StrictExtendableBaseModel( + ExtendableBaseModel, + revalidate_instances="always", + validate_assignment=True, + extra="forbid", + strict=True, +): + """ + An ExtendableBaseModel with strict validation. + + By default, Pydantic does not revalidate instances during validation, nor + when the data is changed. Validation only occurs when the model is created. + This is not suitable for a REST API, where the data is changed after the + model is created or the model is created from a partial set of data and + then updated with more data. This class enforce strict validation by + forcing the revalidation of instances when the method `model_validate` is + called and by ensuring that the values assignment is validated. + + The following parameters are added: + * revalidate_instances="always": model instances are revalidated during validation + (default is "never") + * validate_assignment=True: revalidate the model when the data is changed (default is False) + * extra="forbid": Forbid any extra attributes (default is "ignore") + * strict=True: raise an error if a value's type does not match the field's type + annotation (default is False; Pydantic attempts to coerce values to the correct type) + """ diff --git a/extendable_fastapi/tests/__init__.py b/extendable_fastapi/tests/__init__.py index e69de29bb..11499bebb 100644 --- a/extendable_fastapi/tests/__init__.py +++ b/extendable_fastapi/tests/__init__.py @@ -0,0 +1 @@ +from . import test_strict_extendable_base_model diff --git a/extendable_fastapi/tests/test_strict_extendable_base_model.py b/extendable_fastapi/tests/test_strict_extendable_base_model.py new file mode 100644 index 000000000..650982f43 --- /dev/null +++ b/extendable_fastapi/tests/test_strict_extendable_base_model.py @@ -0,0 +1,68 @@ +import warnings +from datetime import date + +from extendable_pydantic import ExtendableBaseModel + +from pydantic import ValidationError + +from ..schemas import StrictExtendableBaseModel +from .common import FastAPITransactionCase + + +class TestStrictExtendableBaseModel(FastAPITransactionCase): + class Model(ExtendableBaseModel): + x: int + d: date | None + + class StrictModel(StrictExtendableBaseModel): + x: int + d: date | None + + def test_Model_revalidate_instance_never(self): + # Missing required fields but no re-validation + m = self.Model.model_construct() + self.assertEqual(m.model_validate(m).model_dump(), {}) + + def test_StrictModel_revalidate_instance_always(self): + # Missing required fields and always revalidate + m = self.StrictModel.model_construct() + with self.assertRaises(ValidationError): + m.model_validate(m) + + def test_Model_validate_assignment_false(self): + # Wrong assignment but no re-validation at assignment + m = self.Model(x=1, d=None) + m.x = "TEST" + with warnings.catch_warnings(): + # Disable 'Expected `int` but got `str`' warning + warnings.simplefilter("ignore") + self.assertEqual(m.model_dump(), {"x": "TEST", "d": None}) + + def test_StrictModel_validate_assignment_true(self): + # Wrong assignment and validation at assignment + m = self.StrictModel.model_construct() + m.x = 1 # Validate only this field -> OK even if m.d is not set + with self.assertRaises(ValidationError): + m.x = "TEST" + + def test_Model_extra_ignored(self): + # Ignore extra fields + m = self.Model(x=1, z=3, d=None) + self.assertEqual(m.model_dump(), {"x": 1, "d": None}) + + def test_StrictModel_extra_forbidden(self): + # Forbid extra fields + with self.assertRaises(ValidationError): + self.StrictModel(x=1, z=3, d=None) + + def test_Model_strict_false(self): + # Coerce str->date is allowed + m = self.Model(x=1, d=None) + m.d = "2023-01-01" + self.assertTrue(m.model_validate(m)) + + def test_StrictModel_strict_true(self): + # Coerce str->date is forbidden + m = self.StrictModel(x=1, d=None) + with self.assertRaises(ValidationError): + m.d = "2023-01-01" diff --git a/fastapi/README.rst b/fastapi/README.rst index 1d7f693d3..e9c1c4122 100644 --- a/fastapi/README.rst +++ b/fastapi/README.rst @@ -23,7 +23,7 @@ Odoo FastAPI :target: https://runbot.odoo-community.org/runbot/271/16.0 :alt: Try me on Runbot -|badge1| |badge2| |badge3| |badge4| |badge5| +|badge1| |badge2| |badge3| |badge4| |badge5| This addon provides the basis to smoothly integrate the `FastAPI`_ framework into Odoo. @@ -862,14 +862,14 @@ complete transparent. This addon is called `odoo-addon-extendable-fastapi `_. When you want to allow other addons to extend a pydantic model, you must -first define the model as an extendable model by using a dedicated metaclass +first define the model as an extendable model by using the dedicated +class from `extendable_pydantic`. .. code-block:: python - from pydantic import BaseModel - from extendable_pydantic import ExtendableModelMeta + from extendable_pydantic import ExtendableBaseModel - class Partner(BaseModel, metaclass=ExtendableModelMeta): + class Partner(ExtendableBaseModel): name = 0.1 model_config = ConfigDict(from_attributes=True) @@ -890,6 +890,11 @@ pydantic. """Return the location""" return Partner.model_validate(partner) +Some of the Pydantic config parameters are not set by default but are very useful when +using `odoo-addon-fastapi`, hence the `ExtendableBaseModel` model was extended +to `StrictExtendableBaseModel` in **'odoo-addon-extendable-fastapi'** +to force revalidation of the instances at each change, and forbidding the +extra values. If you need to add a new field into the model **'Partner'**, you can extend it in your new addon by defining a new model that inherits from the model **'Partner'**. @@ -899,7 +904,7 @@ in your new addon by defining a new model that inherits from the model **'Partne from typing import Optional from odoo.addons.fastapi.models.fastapi_endpoint_demo import Partner - class PartnerExtended(Partner, extends=Partner): + class PartnerExtended(Partner, extends=True): email: Optional[str] If your new addon is installed in a database, a call to the route handler @@ -1503,7 +1508,7 @@ promote its widespread use. Current `maintainer `__: -|maintainer-lmignon| +|maintainer-lmignon| This module is part of the `OCA/rest-framework `_ project on GitHub. diff --git a/requirements.txt b/requirements.txt index dbae38c38..87fd73dbe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ cerberus contextvars extendable-pydantic extendable-pydantic>=1.0.0 +extendable-pydantic>=1.1.0 extendable>=0.0.4 fastapi graphene