diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8d5b36e76..d6fc3be66 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ exclude: | (?x) # NOT INSTALLABLE ADDONS - ^extendable/| + ^extendable_fastapi/| # END NOT INSTALLABLE ADDONS # Files and folders generated by bots, to avoid loops ^setup/|/static/description/index\.html$| diff --git a/extendable/tests/common.py b/extendable/tests/common.py index 004db33a3..21e34f9d0 100644 --- a/extendable/tests/common.py +++ b/extendable/tests/common.py @@ -3,7 +3,6 @@ from contextlib import contextmanager -import odoo from odoo import SUPERUSER_ID, api from odoo.modules.registry import Registry from odoo.tests import common diff --git a/extendable_fastapi/README.rst b/extendable_fastapi/README.rst new file mode 100644 index 000000000..3bffb0f17 --- /dev/null +++ b/extendable_fastapi/README.rst @@ -0,0 +1,119 @@ +================== +Extendable Fastapi +================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:4e4f5d96294f860ce7f0c4e023431f8ed9ca011c318b5ba4a3cfcd15c31eac1a + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Frest--framework-lightgray.png?logo=github + :target: https://github.com/OCA/rest-framework/tree/18.0/extendable_fastapi + :alt: OCA/rest-framework +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/rest-framework-18-0/rest-framework-18-0-extendable_fastapi + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/rest-framework&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This addon is a technical addon used to allows the use of +`extendable `__ classes in the +implementation of your fastapi endpoint handlers. It also allows you to +use +`extendable_pydantic `__ +models when defining your endpoint handlers request and response models. + +**Table of contents** + +.. contents:: + :local: + +Changelog +========= + +16.0.2.1.1 (2023-11-07) +----------------------- + +**Bugfixes** + +- Fix registry corruption when running tests difined in a class + inheriting of the *FastAPITransactionCase* class if an error occurs + in the *setUpClass* after the call to super(). + (`#392 `__) + +16.0.2.1.0 (2023-10-13) +----------------------- + +**Features** + +- + + - New base schemas: *PagedCollection*. This schema is used to define + the the structure of a paged collection of resources. This schema + is similar to the ones defined in the Odoo's **fastapi** addon but + works as/with extendable models. + - The *StrictExtendableBaseModel* has been moved to the + *extendable_pydantic* python lib. You should consider to import it + from there. + (`#380 `__) + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ACSONE SA/NV + +Contributors +------------ + +- Laurent Mignon (https://acsone.eu) +- Marie Lejeune (https://acsone.eu) + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-lmignon| image:: https://github.com/lmignon.png?size=40px + :target: https://github.com/lmignon + :alt: lmignon + +Current `maintainer `__: + +|maintainer-lmignon| + +This module is part of the `OCA/rest-framework `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/extendable_fastapi/__init__.py b/extendable_fastapi/__init__.py new file mode 100644 index 000000000..28a255de7 --- /dev/null +++ b/extendable_fastapi/__init__.py @@ -0,0 +1,2 @@ +from . import fastapi_dispatcher +from .schemas import StrictExtendableBaseModel diff --git a/extendable_fastapi/__manifest__.py b/extendable_fastapi/__manifest__.py new file mode 100644 index 000000000..36803ca58 --- /dev/null +++ b/extendable_fastapi/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2023 ACSONE SA/NV +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +{ + "name": "Extendable Fastapi", + "summary": """ + Allows the use of extendable into fastapi apps""", + "version": "18.0.1.0.0", + "license": "LGPL-3", + "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "maintainers": ["lmignon"], + "website": "https://github.com/OCA/rest-framework", + "depends": ["fastapi", "extendable"], + "data": [], + "demo": [], + "external_dependencies": { + "python": [ + "pydantic>=2.0.0", + "extendable-pydantic>=1.2.0", + ], + }, + "installable": True, +} diff --git a/extendable_fastapi/fastapi_dispatcher.py b/extendable_fastapi/fastapi_dispatcher.py new file mode 100644 index 000000000..b940df9ce --- /dev/null +++ b/extendable_fastapi/fastapi_dispatcher.py @@ -0,0 +1,30 @@ +# Copyright 2023 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). + +from contextlib import contextmanager + +from odoo.addons.extendable.registry import _extendable_registries_database +from odoo.addons.fastapi.fastapi_dispatcher import ( + FastApiDispatcher as BaseFastApiDispatcher, +) + +from extendable import context + + +class FastApiDispatcher(BaseFastApiDispatcher): + routing_type = "fastapi" + + def dispatch(self, endpoint, args): + with self._manage_extendable_context(): + return super().dispatch(endpoint, args) + + @contextmanager + def _manage_extendable_context(self): + env = self.request.env + registry = _extendable_registries_database.get(env.cr.dbname, {}) + token = context.extendable_registry.set(registry) + try: + response = yield + finally: + context.extendable_registry.reset(token) + return response diff --git a/extendable_fastapi/i18n/extendable_fastapi.pot b/extendable_fastapi/i18n/extendable_fastapi.pot new file mode 100644 index 000000000..78d58d53f --- /dev/null +++ b/extendable_fastapi/i18n/extendable_fastapi.pot @@ -0,0 +1,13 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" diff --git a/extendable_fastapi/models/__init__.py b/extendable_fastapi/models/__init__.py new file mode 100644 index 000000000..a7e9655cc --- /dev/null +++ b/extendable_fastapi/models/__init__.py @@ -0,0 +1 @@ +from . import fastapi_endpoint_demo diff --git a/extendable_fastapi/models/fastapi_endpoint_demo.py b/extendable_fastapi/models/fastapi_endpoint_demo.py new file mode 100644 index 000000000..a8d195ffc --- /dev/null +++ b/extendable_fastapi/models/fastapi_endpoint_demo.py @@ -0,0 +1,19 @@ +# Copyright 2023 ACSONE SA/NV +# License LGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + +from fastapi import APIRouter + +from ..tests.routers import demo_pydantic_router + + +class FastapiEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + def _get_fastapi_routers(self) -> list[APIRouter]: + # Add router defined for tests to the demo app + routers = super()._get_fastapi_routers() + if self.app == "demo": + routers.append(demo_pydantic_router) + return routers diff --git a/extendable_fastapi/pyproject.toml b/extendable_fastapi/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/extendable_fastapi/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/extendable_fastapi/readme/CONTRIBUTORS.md b/extendable_fastapi/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..b6c834c48 --- /dev/null +++ b/extendable_fastapi/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Laurent Mignon \<\> () +- Marie Lejeune \<\> () diff --git a/extendable_fastapi/readme/DESCRIPTION.md b/extendable_fastapi/readme/DESCRIPTION.md new file mode 100644 index 000000000..da1b0868d --- /dev/null +++ b/extendable_fastapi/readme/DESCRIPTION.md @@ -0,0 +1,5 @@ +This addon is a technical addon used to allows the use of +[extendable](https://pypi.org/project/extendable/) classes in the +implementation of your fastapi endpoint handlers. It also allows you to +use [extendable_pydantic](https://pypi.org/project/extendable_pydantic/) +models when defining your endpoint handlers request and response models. diff --git a/extendable_fastapi/readme/HISTORY.md b/extendable_fastapi/readme/HISTORY.md new file mode 100644 index 000000000..93d4361cf --- /dev/null +++ b/extendable_fastapi/readme/HISTORY.md @@ -0,0 +1,21 @@ +## 16.0.2.1.1 (2023-11-07) + +**Bugfixes** + +- Fix registry corruption when running tests difined in a class + inheriting of the *FastAPITransactionCase* class if an error occurs in + the *setUpClass* after the call to super(). + ([\#392](https://github.com/OCA/rest-framework/issues/392)) + +## 16.0.2.1.0 (2023-10-13) + +**Features** + +- - New base schemas: *PagedCollection*. This schema is used to define + the the structure of a paged collection of resources. This schema is + similar to the ones defined in the Odoo's **fastapi** addon but + works as/with extendable models. + - The *StrictExtendableBaseModel* has been moved to the + *extendable_pydantic* python lib. You should consider to import it + from there. + ([\#380](https://github.com/OCA/rest-framework/issues/380)) diff --git a/extendable_fastapi/readme/newsfragments/.gitignore b/extendable_fastapi/readme/newsfragments/.gitignore new file mode 100644 index 000000000..e69de29bb diff --git a/extendable_fastapi/schemas.py b/extendable_fastapi/schemas.py new file mode 100644 index 000000000..c6a71e7d0 --- /dev/null +++ b/extendable_fastapi/schemas.py @@ -0,0 +1,19 @@ +from typing import Annotated, Generic, TypeVar + +from extendable_pydantic import StrictExtendableBaseModel + +from pydantic import Field + +T = TypeVar("T") + + +class PagedCollection(StrictExtendableBaseModel, Generic[T]): + """A paged collection of items""" + + # This is a generic model. The type of the items is defined by the generic type T. + # It provides you a common way to return a paged collection of items of + # extendable models. It's based on the StrictExtendableBaseModel to ensure + # a strict validation when used within the odoo fastapi framework. + + count: Annotated[int, Field(..., description="The count of items into the system")] + items: list[T] diff --git a/extendable_fastapi/static/description/icon.png b/extendable_fastapi/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/extendable_fastapi/static/description/icon.png differ diff --git a/extendable_fastapi/static/description/index.html b/extendable_fastapi/static/description/index.html new file mode 100644 index 000000000..f19713911 --- /dev/null +++ b/extendable_fastapi/static/description/index.html @@ -0,0 +1,466 @@ + + + + + +Extendable Fastapi + + + +
+

Extendable Fastapi

+ + +

Beta License: LGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runboat

+

This addon is a technical addon used to allows the use of +extendable classes in the +implementation of your fastapi endpoint handlers. It also allows you to +use +extendable_pydantic +models when defining your endpoint handlers request and response models.

+

Table of contents

+ +
+

Changelog

+
+

16.0.2.1.1 (2023-11-07)

+

Bugfixes

+
    +
  • Fix registry corruption when running tests difined in a class +inheriting of the FastAPITransactionCase class if an error occurs +in the setUpClass after the call to super(). +(#392)
  • +
+
+
+

16.0.2.1.0 (2023-10-13)

+

Features

+
    +
    • +
    • New base schemas: PagedCollection. This schema is used to define +the the structure of a paged collection of resources. This schema +is similar to the ones defined in the Odoo’s fastapi addon but +works as/with extendable models.
    • +
    • The StrictExtendableBaseModel has been moved to the +extendable_pydantic python lib. You should consider to import it +from there. +(#380)
    • +
    +
  • +
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

lmignon

+

This module is part of the OCA/rest-framework project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/extendable_fastapi/tests/__init__.py b/extendable_fastapi/tests/__init__.py new file mode 100644 index 000000000..3717f2ef6 --- /dev/null +++ b/extendable_fastapi/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_generic_extendable +from . import test_strict_extendable_base_model diff --git a/extendable_fastapi/tests/common.py b/extendable_fastapi/tests/common.py new file mode 100644 index 000000000..3c5f496dc --- /dev/null +++ b/extendable_fastapi/tests/common.py @@ -0,0 +1,17 @@ +# Copyright 2023 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). + +from odoo.addons.extendable.tests.common import ExtendableMixin +from odoo.addons.fastapi.tests.common import ( + FastAPITransactionCase as BaseFastAPITransactionCase, +) + + +class FastAPITransactionCase(BaseFastAPITransactionCase, ExtendableMixin): + """Base class for FastAPI tests using extendable classes.""" + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.init_extendable_registry() + cls.addClassCleanup(cls.reset_extendable_registry) diff --git a/extendable_fastapi/tests/routers.py b/extendable_fastapi/tests/routers.py new file mode 100644 index 000000000..0c68bd548 --- /dev/null +++ b/extendable_fastapi/tests/routers.py @@ -0,0 +1,100 @@ +# Copyright 2023 ACSONE SA/NV +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +from typing import Annotated + +from odoo import api + +from odoo.addons.fastapi.dependencies import odoo_env + +from fastapi import APIRouter, Depends + +from .schemas import Customer, PrivateCustomer, PrivateUser, User, UserSearchResponse + +demo_pydantic_router = APIRouter(tags=["demo_pydantic"]) + + +@demo_pydantic_router.get("/{user_id}") +def get(env: Annotated[api.Environment, Depends(odoo_env)], user_id: int) -> User: + """ + Get a specific user using its Odoo id. + """ + user = env["res.users"].sudo().search([("id", "=", user_id)]) + if not user: + raise ValueError("No user found") + return User.from_user(user) + + +@demo_pydantic_router.get("/private/{user_id}") +def get_private( + env: Annotated[api.Environment, Depends(odoo_env)], user_id: int +) -> User: + """ + Get a specific user using its Odoo id. + """ + user = env["res.users"].sudo().search([("id", "=", user_id)]) + if not user: + raise ValueError("No user found") + return PrivateUser.from_user(user) + + +@demo_pydantic_router.post("/post_user") +def post_user(user: User) -> UserSearchResponse: + """A demo endpoint to test the extendable pydantic model integration + with fastapi and odoo. + + Type of the request body is User. This model is the base model + of ExtendedUser. At runtime, the documentation and the processing + of the request body should be done as if the type of the request body + was ExtendedUser. + """ + return UserSearchResponse(total=1, items=[user]) + + +@demo_pydantic_router.post("/post_private_user") +def post_private_user(user: PrivateUser) -> User: + """A demo endpoint to test the extendable pydantic model integration + with fastapi and odoo. + + Type of the request body is PrivateUser. This model inherits base model + User but does not extend it. + + This method will return attributes from the declared response type. + It will never return attribute of a derived type from the declared response + type, even if in the route implementation we return an instance of the + derived type. + """ + return user + + +@demo_pydantic_router.post("/post_private_user_generic") +def post_private_user_generic(user: PrivateUser) -> UserSearchResponse: + """A demo endpoint to test the extendable pydantic model integration + with fastapi and odoo. + + Type of the request body is PrivateUser. This model inherits base model + User but does not extend it. + + This method will return attributes from the declared response type. + It will never return attribute of a derived type from the declared response + type, even if in the route implementation we return an instance of the + derived type. This assertion is also true with generics. + """ + return UserSearchResponse(total=1, items=[user]) + + +@demo_pydantic_router.post("/post_private_customer") +def post_private_customer(customer: PrivateCustomer) -> Customer: + """A demo endpoint to test the extendable pydantic model integration + with fastapi and odoo, and more particularly the extra="forbid" config parameter. + + Type of the request body is PrivateCustomer. This model inherits base model + Customer but does not extend it. + + This method will return attributes from the declared response type. + It will never return attribute of a derived type from the declared response + type, even if in the route implementation we return an instance of the + derived type. + + Since Customer has extra fields forbidden, this route is not supposed to work. + """ + return customer diff --git a/extendable_fastapi/tests/schemas.py b/extendable_fastapi/tests/schemas.py new file mode 100644 index 000000000..43e54675f --- /dev/null +++ b/extendable_fastapi/tests/schemas.py @@ -0,0 +1,94 @@ +# Copyright 2023 ACSONE SA/NV +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +from typing import Generic, TypeVar + +from extendable_pydantic import ExtendableBaseModel + +# User models + + +class User(ExtendableBaseModel, revalidate_instances="always"): + """ + We MUST set revalidate_instances="always" to be sure that FastAPI validates + responses of this model type. + """ + + name: str + + @classmethod + def from_user(cls, user): + return cls.model_construct(name=user.name) + + +class ExtendedUser(User, extends=True): + address: str + + @classmethod + def from_user(cls, user): + res = super().from_user(user) + if user.street or user.city: + # Dummy address construction + res.address = (user.street or "") + (user.city or "") + return res + + +class PrivateUser(User): + password: str + + @classmethod + def from_user(cls, user): + res = super().from_user(user) + res.password = user.password + return res + + +_T = TypeVar("_T") + + +class SearchResponse(ExtendableBaseModel, Generic[_T]): + total: int + items: list[_T] + + +class UserSearchResponse(SearchResponse[User]): + """We declare the generic type of the items of the list as User + which is the base model of the extended. When used, it should be resolved + to ExtendedUser, but items of PrivateUser class must stay private and not be returned""" + + +# Customer models: same as above but with extra="forbid" + + +class Customer(ExtendableBaseModel, revalidate_instances="always", extra="forbid"): + """ + Same hierarchy as User models, but with an extra config parameter: + forbid extra fields. + """ + + name: str + + @classmethod + def from_customer(cls, customer): + return cls.model_construct(name=customer.name) + + +class ExtendedCustomer(Customer, extends=True): + address: str + + @classmethod + def from_customer(cls, customer): + res = super().from_customer(customer) + if customer.street or customer.city: + # Dummy address construction + res.address = (customer.street or "") + (customer.city or "") + return res + + +class PrivateCustomer(Customer): + password: str + + @classmethod + def from_customer(cls, customer): + res = super().from_customer(customer) + res.password = customer.password + return res diff --git a/extendable_fastapi/tests/test_generic_extendable.py b/extendable_fastapi/tests/test_generic_extendable.py new file mode 100644 index 000000000..bf0b3fad6 --- /dev/null +++ b/extendable_fastapi/tests/test_generic_extendable.py @@ -0,0 +1,210 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from requests import Response + +from odoo.tests.common import tagged + +from fastapi.exceptions import ResponseValidationError + +from .common import FastAPITransactionCase +from .routers import demo_pydantic_router +from .schemas import PrivateCustomer, PrivateUser, User + + +@tagged("post_install", "-at_install") +class TestUser(FastAPITransactionCase): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + + def test_app_components(self): + with self._create_test_client(router=demo_pydantic_router) as test_client: + to_openapi = test_client.app.openapi() + # Check post input and output types + self.assertEqual( + to_openapi["paths"]["/post_user"]["post"]["requestBody"]["content"][ + "application/json" + ]["schema"]["$ref"], + "#/components/schemas/User", + ) + self.assertEqual( + to_openapi["paths"]["/post_user"]["post"]["responses"]["200"][ + "content" + ]["application/json"]["schema"]["$ref"], + "#/components/schemas/UserSearchResponse", + ) + self.assertEqual( + to_openapi["paths"]["/post_private_user"]["post"]["requestBody"][ + "content" + ]["application/json"]["schema"]["$ref"], + "#/components/schemas/PrivateUser", + ) + self.assertEqual( + to_openapi["paths"]["/post_private_user"]["post"]["responses"]["200"][ + "content" + ]["application/json"]["schema"]["$ref"], + "#/components/schemas/User", + ) + self.assertEqual( + to_openapi["paths"]["/post_private_user_generic"]["post"][ + "requestBody" + ]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/PrivateUser", + ) + self.assertEqual( + to_openapi["paths"]["/post_private_user_generic"]["post"]["responses"][ + "200" + ]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/UserSearchResponse", + ) + + # Check Pydantic model extension + self.assertEqual( + set(to_openapi["components"]["schemas"]["User"]["properties"].keys()), + {"name", "address"}, + ) + self.assertEqual( + set( + to_openapi["components"]["schemas"]["PrivateUser"][ + "properties" + ].keys() + ), + {"name", "address", "password"}, + ) + self.assertEqual( + to_openapi["components"]["schemas"]["UserSearchResponse"]["properties"][ + "items" + ]["items"]["$ref"], + "#/components/schemas/User", + ) + + def test_post_user(self): + name = "Jean Dupont" + address = "Rue du Puits 12, 4000 Liège" + pydantic_data = User(name=name, address=address) + # Assert that class was correctly extended + self.assertTrue(pydantic_data.address) + + with self._create_test_client(router=demo_pydantic_router) as test_client: + response: Response = test_client.post( + "/post_user", content=pydantic_data.model_dump_json() + ) + self.assertEqual(response.status_code, 200) + res = response.json() + self.assertEqual(res["total"], 1) + user = res["items"][0] + self.assertEqual(user["name"], name) + self.assertEqual(user["address"], address) + self.assertFalse("password" in user.keys()) + + def test_post_private_user(self): + """ + /post_private_user return attributes from User, but not PrivateUser + + Security check: this method should never return attributes from + derived type PrivateUser, even thought a PrivateUser object + is given as input. + """ + name = "Jean Dupont" + address = "Rue du Puits 12, 4000 Liège" + password = "dummy123" + pydantic_data = PrivateUser(name=name, address=address, password=password) + # Assert that class was correctly extended + self.assertTrue(pydantic_data.address) + self.assertTrue(pydantic_data.password) + + with self._create_test_client(router=demo_pydantic_router) as test_client: + response: Response = test_client.post( + "/post_private_user", content=pydantic_data.model_dump_json() + ) + self.assertEqual(response.status_code, 200) + user = response.json() + self.assertEqual(user["name"], name) + self.assertEqual(user["address"], address) + # Private attrs were not returned + self.assertFalse("password" in user.keys()) + + def test_post_private_user_generic(self): + """ + /post_private_user_generic return attributes from User, but not PrivateUser + + Security check: this method should never return attributes from + derived type PrivateUser, even thought a PrivateUser object + is given as input. + This test is specifically made to test this assertion with generics. + """ + name = "Jean Dupont" + address = "Rue du Puits 12, 4000 Liège" + password = "dummy123" + pydantic_data = PrivateUser(name=name, address=address, password=password) + # Assert that class was correctly extended + self.assertTrue(pydantic_data.address) + self.assertTrue(pydantic_data.password) + + with self._create_test_client(router=demo_pydantic_router) as test_client: + response: Response = test_client.post( + "/post_private_user_generic", content=pydantic_data.model_dump_json() + ) + self.assertEqual(response.status_code, 200) + res = response.json() + self.assertEqual(res["total"], 1) + user = res["items"][0] + self.assertEqual(user["name"], name) + self.assertEqual(user["address"], address) + # Private attrs were not returned + self.assertFalse("password" in user.keys()) + + def test_get_user_failed_no_address(self): + """ + Try to get a specific user but having no address + -> Error because address is a required field on User (extended) class + :return: + """ + user = self.env["res.users"].create( + { + "name": "Michel Dupont", + "login": "michel", + } + ) + with ( + self._create_test_client(router=demo_pydantic_router) as test_client, + self.assertRaises(ResponseValidationError), + ): + test_client.get(f"/{user.id}") + + def test_get_user_failed_no_pwd(self): + """ + Try to get a specific user having an address but no password. + -> No error because return type is User, not PrivateUser + :return: + """ + user = self.env["res.users"].create( + { + "name": "Michel Dupont", + "login": "michel", + "street": "Rue du Moulin", + } + ) + self.assertFalse(user.password) + with self._create_test_client(router=demo_pydantic_router) as test_client: + response: Response = test_client.get(f"/private/{user.id}") + self.assertEqual(response.status_code, 200) + + def test_extra_forbid_response_fails(self): + """ + If adding extra="forbid" to the User model, we cannot write + a router with a response type = User and returning PrivateUser + in the code + """ + name = "Jean Dupont" + address = "Rue du Puits 12, 4000 Liège" + password = "dummy123" + pydantic_data = PrivateCustomer(name=name, address=address, password=password) + + with ( + self.assertRaises(ResponseValidationError), + self._create_test_client(router=demo_pydantic_router) as test_client, + ): + test_client.post( + "/post_private_customer", content=pydantic_data.model_dump_json() + ) 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..f05dfd79c --- /dev/null +++ b/extendable_fastapi/tests/test_strict_extendable_base_model.py @@ -0,0 +1,63 @@ +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_StrictModel_strict_false(self): + # Coerce str->date is allowed to enable coercion from JSON + # by FastAPI + m = self.StrictModel(x=1, d=None) + m.d = "2023-01-01" + self.assertTrue(m.model_validate(m)) diff --git a/requirements.txt b/requirements.txt index 69f99aad8..adef356c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ apispec cerberus contextvars extendable>=0.0.4 +extendable-pydantic>=1.2.0 fastapi>=0.110.0 parse-accept-language pydantic>=2.0.0