diff --git a/base_rest/restapi.py b/base_rest/restapi.py index 12c9e2d10..c9cf2c0e7 100644 --- a/base_rest/restapi.py +++ b/base_rest/restapi.py @@ -7,7 +7,7 @@ from cerberus import Validator -from odoo import _, http +from odoo import http from odoo.exceptions import UserError, ValidationError from .tools import ROUTING_DECORATOR_ATTR, cerberus_to_json @@ -218,13 +218,13 @@ def from_params(self, service, params): validator = self.get_cerberus_validator(service, "input") if validator.validate(params): return validator.document - raise UserError(_("BadRequest %s") % validator.errors) + raise UserError(service.env._("BadRequest %s") % validator.errors) def to_response(self, service, result): validator = self.get_cerberus_validator(service, "output") if validator.validate(result): return validator.document - raise SystemError(_("Invalid Response %s") % validator.errors) + raise SystemError(service.env._("Invalid Response %s") % validator.errors) def to_openapi_query_parameters(self, service, spec): json_schema = self.to_json_schema(service, spec, "input") @@ -275,7 +275,9 @@ def get_cerberus_validator(self, service, direction): return schema if isinstance(schema, dict): return Validator(schema, purge_unknown=True) - raise Exception(_("Unable to get cerberus schema from %s") % self._schema) + raise Exception( + service.env._("Unable to get cerberus schema from %s") % self._schema + ) def to_json_schema(self, service, spec, direction): schema = self.get_cerberus_validator(service, direction).schema @@ -321,7 +323,7 @@ def _do_validate(self, service, data, direction): for idx, p in enumerate(data): if not validator.validate(p): raise ExceptionClass( - _( + service.env._( "BadRequest item %(idx)s :%(errors)s", idx=idx, errors=validator.errors, @@ -330,7 +332,7 @@ def _do_validate(self, service, data, direction): values.append(validator.document) if self._min_items is not None and len(values) < self._min_items: raise ExceptionClass( - _( + service.env._( "BadRequest: Not enough items in the list (%(current)s " "< %(expected)s)", current=len(values), @@ -339,7 +341,7 @@ def _do_validate(self, service, data, direction): ) if self._max_items is not None and len(values) > self._max_items: raise ExceptionClass( - _( + service.env._( "BadRequest: Too many items in the list (%(current)s " "> %(expected)s)", current=len(values), @@ -367,7 +369,7 @@ def __init__(self, parts): :param parts: list of RestMethodParam """ if not isinstance(parts, dict): - raise ValidationError(_("You must provide a dict of RestMethodParam")) + raise RuntimeError("You must provide a dict of RestMethodParam") self._parts = parts def to_openapi_properties(self, service, spec, direction): @@ -410,7 +412,7 @@ def from_params(self, service, params): ) # multipart ony sends its parts as string except json.JSONDecodeError as error: raise ValidationError( - _(f"{key}'s JSON content is malformed: {error}") + service.env._(f"{key}'s JSON content is malformed: {error}") ) from error param = part.from_params(service, json_param) params[key] = param diff --git a/base_rest/tests/test_cerberus_list_validator.py b/base_rest/tests/test_cerberus_list_validator.py index 02e2c6b0c..e20065e24 100644 --- a/base_rest/tests/test_cerberus_list_validator.py +++ b/base_rest/tests/test_cerberus_list_validator.py @@ -1,19 +1,18 @@ # Copyright 2020 ACSONE SA/NV # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). -import logging import unittest from cerberus import Validator from odoo.exceptions import UserError -from odoo.tests.common import BaseCase, MetaCase +from odoo.tests.common import TransactionCase from ..components.cerberus_validator import BaseRestCerberusValidator from ..restapi import CerberusListValidator -class TestCerberusListValidator(BaseCase, MetaCase("DummyCase", (object,), {})): +class TestCerberusListValidator(TransactionCase): """Test all the methods that must be implemented by CerberusListValidator to be a valid RestMethodParam""" @@ -47,18 +46,8 @@ def setUpClass(cls): schema=cls.nested_schema ) cls.maxDiff = None - - def setUp(self): - super().setUp() - # mute logger - loggers = ["odoo.tools.translate"] - for logger in loggers: - logging.getLogger(logger).addFilter(self) - - @self.addCleanup - def un_mute_logger(): - for logger_ in loggers: - logging.getLogger(logger_).removeFilter(self) + cls.mock_service = unittest.mock.Mock() + cls.mock_service.env = cls.env def filter(self, record): # required to mute logger @@ -203,15 +192,18 @@ def test_from_params_validation(self): # minItems / maxItems with self.assertRaises(UserError): # minItems = 1 - self.simple_schema_list_validator.from_params(None, params=[]) + self.simple_schema_list_validator.from_params(self.mock_service, params=[]) with self.assertRaises(UserError): # maxItems = 2 self.simple_schema_list_validator.from_params( - None, params=[{"name": "test"}, {"name": "test"}, {"name": "test"}] + self.mock_service, + params=[{"name": "test"}, {"name": "test"}, {"name": "test"}], ) with self.assertRaises(UserError): # name required - self.simple_schema_list_validator.from_params(None, params=[{}]) + self.simple_schema_list_validator.from_params( + self.mock_service, params=[{}] + ) def test_to_response_ignore_unknown(self): result = [{"name": "test", "unknown": True}] @@ -223,15 +215,18 @@ def test_to_response_validation(self): # as a programmatic error not a user error with self.assertRaises(SystemError): # minItems = 1 - self.simple_schema_list_validator.to_response(None, result=[]) + self.simple_schema_list_validator.to_response(self.mock_service, result=[]) with self.assertRaises(SystemError): # maxItems = 2 self.simple_schema_list_validator.to_response( - None, result=[{"name": "test"}, {"name": "test"}, {"name": "test"}] + self.mock_service, + result=[{"name": "test"}, {"name": "test"}, {"name": "test"}], ) with self.assertRaises(SystemError): # name required - self.simple_schema_list_validator.to_response(None, result=[{}]) + self.simple_schema_list_validator.to_response( + self.mock_service, result=[{}] + ) def test_schema_lookup_from_string(self): class MyService: diff --git a/base_rest/tests/test_cerberus_validator.py b/base_rest/tests/test_cerberus_validator.py index 5aa222da4..437437df6 100644 --- a/base_rest/tests/test_cerberus_validator.py +++ b/base_rest/tests/test_cerberus_validator.py @@ -1,20 +1,19 @@ # Copyright 2020 ACSONE SA/NV # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). -import logging import unittest from cerberus import Validator from odoo.exceptions import UserError -from odoo.tests.common import BaseCase, MetaCase +from odoo.tests.common import TransactionCase from ..components.cerberus_validator import BaseRestCerberusValidator from ..restapi import CerberusValidator from ..tools import cerberus_to_json -class TestCerberusValidator(BaseCase, MetaCase("DummyCase", (object,), {})): +class TestCerberusValidator(TransactionCase): """Test all the methods that must be implemented by CerberusValidator to be a valid RestMethodParam""" @@ -50,22 +49,8 @@ def setUpClass(cls): cls.nested_schema_cerberus_validator = CerberusValidator( schema=cls.nested_schema ) - - def setUp(self): - super().setUp() - # mute logger - loggers = ["odoo.tools.translate"] - for logger in loggers: - logging.getLogger(logger).addFilter(self) - - @self.addCleanup - def un_mute_logger(): - for logger_ in loggers: - logging.getLogger(logger_).removeFilter(self) - - def filter(self, record): - # required to mute logger - return 0 + cls.mock_service = unittest.mock.Mock() + cls.mock_service.env = cls.env def test_to_openapi_responses(self): res = self.simple_schema_cerberus_validator.to_openapi_responses(None, None) @@ -271,7 +256,9 @@ def test_from_params_ignore_unknown(self): def test_from_params_validation(self): # name is required with self.assertRaises(UserError): - self.simple_schema_cerberus_validator.from_params(None, params={}) + self.simple_schema_cerberus_validator.from_params( + self.mock_service, params={} + ) def test_to_response_add_default(self): result = {"name": "test"} @@ -288,7 +275,9 @@ def test_to_response_validation(self): # If a response is not conform to the expected schema it's considered # as a programmatic error not a user error with self.assertRaises(SystemError): - self.simple_schema_cerberus_validator.to_response(None, result={}) + self.simple_schema_cerberus_validator.to_response( + self.mock_service, result={} + ) def test_schema_lookup_from_string(self): class MyService: diff --git a/base_rest_pydantic/README.rst b/base_rest_pydantic/README.rst new file mode 100644 index 000000000..7555d2aee --- /dev/null +++ b/base_rest_pydantic/README.rst @@ -0,0 +1,113 @@ +=================== +Base Rest Datamodel +=================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:ed7868cc1a1d1a63a8b53b8e25e19ed162638e6f7eb3246bcc6cad2c5c0ca1a6 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/base_rest_pydantic + :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-base_rest_pydantic + :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 allows you to use Pydantic objects as params and/or response +with your REST API methods. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +To use Pydantic instances as request and/or response of a REST service +endpoint you must: + +- Define your Pydantic classes; +- Provides the information required to the + ``odoo.addons.base_rest.restapi.method`` decorator; + +.. code:: python + + from odoo.addons.base_rest import restapi + from odoo.addons.component.core import Component + from odoo.addons.pydantic.models import BaseModel + + class PingMessage(BaseModel): + message: str + + + class PingService(Component): + _inherit = 'base.rest.service' + _name = 'ping.service' + _usage = 'ping' + _collection = 'my_module.services' + + + @restapi.method( + [(["/pong"], "GET")], + input_param=restapi.PydanticModel(PingMessage), + output_param=restapi.PydanticModel(PingMessage), + auth="public", + ) + def pong(self, ping_message): + return PingMessage(message = "Received: " + ping_message.message) + +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 + +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. + +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/base_rest_pydantic/__init__.py b/base_rest_pydantic/__init__.py new file mode 100644 index 000000000..0d7891f8a --- /dev/null +++ b/base_rest_pydantic/__init__.py @@ -0,0 +1 @@ +from . import restapi diff --git a/base_rest_pydantic/__manifest__.py b/base_rest_pydantic/__manifest__.py new file mode 100644 index 000000000..0b94bbd77 --- /dev/null +++ b/base_rest_pydantic/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +{ + "name": "Base Rest Datamodel", + "summary": """ + Pydantic binding for base_rest""", + "version": "18.0.1.0.0", + "license": "LGPL-3", + "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/rest-framework", + "depends": ["base_rest"], + "external_dependencies": { + "python": [ + "pydantic>=2.0.0", + ] + }, +} diff --git a/base_rest_pydantic/i18n/base_rest_datamodel.pot b/base_rest_pydantic/i18n/base_rest_datamodel.pot new file mode 100644 index 000000000..7dc461d5e --- /dev/null +++ b/base_rest_pydantic/i18n/base_rest_datamodel.pot @@ -0,0 +1,26 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_rest_datamodel +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.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" + +#. module: base_rest_datamodel +#: code:addons/base_rest_datamodel/restapi.py:0 +#, python-format +msgid "BadRequest %s" +msgstr "" + +#. module: base_rest_datamodel +#: code:addons/base_rest_datamodel/restapi.py:0 +#, python-format +msgid "Invalid Response %s" +msgstr "" diff --git a/base_rest_pydantic/i18n/base_rest_pydantic.pot b/base_rest_pydantic/i18n/base_rest_pydantic.pot new file mode 100644 index 000000000..92a2904bc --- /dev/null +++ b/base_rest_pydantic/i18n/base_rest_pydantic.pot @@ -0,0 +1,42 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_rest_pydantic +# +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" + +#. module: base_rest_pydantic +#. odoo-python +#: code:addons/base_rest_pydantic/restapi.py:0 +#, python-format +msgid "BadRequest %s" +msgstr "" + +#. module: base_rest_pydantic +#. odoo-python +#: code:addons/base_rest_pydantic/restapi.py:0 +#, python-format +msgid "BadRequest: Not enough items in the list (%(current)s < %(expected)s)" +msgstr "" + +#. module: base_rest_pydantic +#. odoo-python +#: code:addons/base_rest_pydantic/restapi.py:0 +#, python-format +msgid "BadRequest: Too many items in the list (%(current)s > %(expected)s)" +msgstr "" + +#. module: base_rest_pydantic +#. odoo-python +#: code:addons/base_rest_pydantic/restapi.py:0 +#, python-format +msgid "Invalid Response" +msgstr "" diff --git a/base_rest_pydantic/pyproject.toml b/base_rest_pydantic/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/base_rest_pydantic/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/base_rest_pydantic/readme/CONTRIBUTORS.md b/base_rest_pydantic/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..dc42b35f3 --- /dev/null +++ b/base_rest_pydantic/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Laurent Mignon \ diff --git a/base_rest_pydantic/readme/DESCRIPTION.md b/base_rest_pydantic/readme/DESCRIPTION.md new file mode 100644 index 000000000..c5f8ebaee --- /dev/null +++ b/base_rest_pydantic/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +This addon allows you to use Pydantic objects as params and/or response +with your REST API methods. diff --git a/base_rest_pydantic/readme/USAGE.md b/base_rest_pydantic/readme/USAGE.md new file mode 100644 index 000000000..36f78b1e4 --- /dev/null +++ b/base_rest_pydantic/readme/USAGE.md @@ -0,0 +1,32 @@ +To use Pydantic instances as request and/or response of a REST service +endpoint you must: + +- Define your Pydantic classes; +- Provides the information required to the + `odoo.addons.base_rest.restapi.method` decorator; + +``` python +from odoo.addons.base_rest import restapi +from odoo.addons.component.core import Component +from odoo.addons.pydantic.models import BaseModel + +class PingMessage(BaseModel): + message: str + + +class PingService(Component): + _inherit = 'base.rest.service' + _name = 'ping.service' + _usage = 'ping' + _collection = 'my_module.services' + + + @restapi.method( + [(["/pong"], "GET")], + input_param=restapi.PydanticModel(PingMessage), + output_param=restapi.PydanticModel(PingMessage), + auth="public", + ) + def pong(self, ping_message): + return PingMessage(message = "Received: " + ping_message.message) +``` diff --git a/base_rest_pydantic/restapi.py b/base_rest_pydantic/restapi.py new file mode 100644 index 000000000..4764f0232 --- /dev/null +++ b/base_rest_pydantic/restapi.py @@ -0,0 +1,233 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +import json + +from odoo.exceptions import UserError + +from odoo.addons.base_rest import restapi + +from pydantic import BaseModel, ValidationError + + +def replace_ref_in_schema(item, original_schema): + if isinstance(item, list): + return [replace_ref_in_schema(i, original_schema) for i in item] + elif isinstance(item, dict): + if list(item.keys()) == ["$ref"]: + schema = item["$ref"].split("/")[-1] + return {"$ref": f"#/components/schemas/{schema}"} + else: + return { + key: replace_ref_in_schema(i, original_schema) + for key, i in item.items() + } + else: + return item + + +class PydanticModel(restapi.RestMethodParam): + def __init__(self, cls: BaseModel): + """ + :param name: The pydantic model name + """ + if not issubclass(cls, BaseModel): + raise TypeError( + f"{cls} is not a subclass of odoo.addons.pydantic.models.BaseModel" + ) + self._model_cls = cls + + def from_params(self, service, params): + try: + return self._model_cls(**params) + except ValidationError as ve: + raise UserError(service.env._("BadRequest %s") % ve.json(indent=0)) from ve + + def to_response(self, service, result): + # do we really need to validate the instance???? + json_dict = result.model_dump() + orm_mode = result.model_config.get("from_attributes", None) + to_validate = json_dict if orm_mode else result.model_dump(by_alias=True) + # Ensure that to_validate is under json format + try: + json.loads(to_validate) + to_validate_jsonified = to_validate + except TypeError: + to_validate_jsonified = json.dumps(to_validate) + + try: + self._model_cls.model_validate_json(to_validate_jsonified) + except ValidationError as validation_error: + raise SystemError(service.env._("Invalid Response")) from validation_error + return json_dict + + def to_openapi_query_parameters(self, servic, spec): + json_schema = self._model_cls.model_json_schema() + parameters = [] + for prop, spec in list(json_schema["properties"].items()): + params = { + "name": prop, + "in": "query", + "required": prop in json_schema.get("required", []), + "allowEmptyValue": spec.get("nullable", False), + "default": spec.get("default"), + } + if spec.get("schema"): + params["schema"] = spec.get("schema") + else: + params["schema"] = {"type": spec["type"]} + if spec.get("items"): + params["schema"]["items"] = spec.get("items") + if "enum" in spec: + params["schema"]["enum"] = spec["enum"] + + parameters.append(params) + + if spec["type"] == "array": + # To correctly handle array into the url query string, + # the name must ends with [] + params["name"] = params["name"] + "[]" + + return parameters + + # TODO, we should probably get the spec as parameters. That should + # allows to add the definition of a schema only once into the specs + # and use a reference to the schema into the parameters + def to_openapi_requestbody(self, service, spec): + return { + "content": { + "application/json": { + "schema": self.to_json_schema(service, spec, "input") + } + } + } + + def to_openapi_responses(self, service, spec): + return { + "200": { + "content": { + "application/json": { + "schema": self.to_json_schema(service, spec, "output") + } + } + } + } + + def to_json_schema(self, service, spec, direction): + schema = self._model_cls.model_json_schema(by_alias=False) + schema_name = schema["title"] + if schema_name not in spec.components.schemas: + definitions = schema.pop("$defs", {}) + for name, sch in definitions.items(): + if name in spec.components.schemas: + continue + sch = replace_ref_in_schema(sch, sch) + spec.components.schema(name, sch) + schema = replace_ref_in_schema(schema, schema) + spec.components.schema(schema_name, schema) + return {"$ref": f"#/components/schemas/{schema_name}"} + + +class PydanticModelList(PydanticModel): + def __init__( + self, + cls: BaseModel, + min_items: int = None, + max_items: int = None, + unique_items: bool = None, + ): + """ + :param name: The pydantic model name + :param min_items: A list instance is valid against "min_items" if its + size is greater than, or equal to, min_items. + The value MUST be a non-negative integer. + :param max_items: A list instance is valid against "max_items" if its + size is less than, or equal to, max_items. + The value MUST be a non-negative integer. + :param unique_items: Used to document that the list should only + contain unique items. + (Not enforced at validation time) + """ + super().__init__(cls=cls) + self._min_items = min_items + self._max_items = max_items + self._unique_items = unique_items + + def from_params(self, service, params): + self._do_validate(service, params, "input") + return [ + super(PydanticModelList, self).from_params(service, param) + for param in params + ] + + def to_response(self, service, result): + self._do_validate(service, result, "output") + return [ + super(PydanticModelList, self).to_response(service=service, result=r) + for r in result + ] + + def to_openapi_query_parameters(self, service, spec): + raise NotImplementedError("List are not (?yet?) supported as query paramters") + + def _do_validate(self, service, values, direction): + ExceptionClass = UserError if direction == "input" else SystemError + if self._min_items is not None and len(values) < self._min_items: + raise ExceptionClass( + service.env._( + ( + "BadRequest: Not enough items in the list (%(current)s < " + "%(expected)s)" + ), + current=len(values), + expected=self._min_items, + ) + ) + if self._max_items is not None and len(values) > self._max_items: + raise ExceptionClass( + service.env._( + ( + "BadRequest: Too many items in the list (%(current)s > " + "%(expected)s)" + ), + current=len(values), + expected=self._max_items, + ) + ) + + # TODO, we should probably get the spec as parameters. That should + # allows to add the definition of a schema only once into the specs + # and use a reference to the schema into the parameters + def to_openapi_requestbody(self, service, spec): + return { + "content": { + "application/json": { + "schema": self.to_json_schema(service, spec, "input") + } + } + } + + def to_openapi_responses(self, service, spec): + return { + "200": { + "content": { + "application/json": { + "schema": self.to_json_schema(service, spec, "output") + } + } + } + } + + def to_json_schema(self, service, spec, direction): + json_schema = super().to_json_schema(service, spec, direction) + json_schema = {"type": "array", "items": json_schema} + if self._min_items is not None: + json_schema["minItems"] = self._min_items + if self._max_items is not None: + json_schema["maxItems"] = self._max_items + if self._unique_items is not None: + json_schema["uniqueItems"] = self._unique_items + return json_schema + + +restapi.PydanticModel = PydanticModel +restapi.PydanticModelList = PydanticModelList diff --git a/base_rest_pydantic/static/description/icon.png b/base_rest_pydantic/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/base_rest_pydantic/static/description/icon.png differ diff --git a/base_rest_pydantic/static/description/index.html b/base_rest_pydantic/static/description/index.html new file mode 100644 index 000000000..07533129f --- /dev/null +++ b/base_rest_pydantic/static/description/index.html @@ -0,0 +1,460 @@ + + + + + +Base Rest Datamodel + + + +
+

Base Rest Datamodel

+ + +

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

+

This addon allows you to use Pydantic objects as params and/or response +with your REST API methods.

+

Table of contents

+ +
+

Usage

+

To use Pydantic instances as request and/or response of a REST service +endpoint you must:

+
    +
  • Define your Pydantic classes;
  • +
  • Provides the information required to the +odoo.addons.base_rest.restapi.method decorator;
  • +
+
+from odoo.addons.base_rest import restapi
+from odoo.addons.component.core import Component
+from odoo.addons.pydantic.models import BaseModel
+
+class PingMessage(BaseModel):
+    message: str
+
+
+class PingService(Component):
+    _inherit = 'base.rest.service'
+    _name = 'ping.service'
+    _usage = 'ping'
+    _collection = 'my_module.services'
+
+
+    @restapi.method(
+        [(["/pong"], "GET")],
+        input_param=restapi.PydanticModel(PingMessage),
+        output_param=restapi.PydanticModel(PingMessage),
+        auth="public",
+    )
+    def pong(self, ping_message):
+        return PingMessage(message = "Received: " + ping_message.message)
+
+
+
+

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

+ +
+
+

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.

+

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/base_rest_pydantic/tests/__init__.py b/base_rest_pydantic/tests/__init__.py new file mode 100644 index 000000000..07ef555a4 --- /dev/null +++ b/base_rest_pydantic/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_response +from . import test_from_params diff --git a/base_rest_pydantic/tests/test_from_params.py b/base_rest_pydantic/tests/test_from_params.py new file mode 100644 index 000000000..b0e39b872 --- /dev/null +++ b/base_rest_pydantic/tests/test_from_params.py @@ -0,0 +1,68 @@ +# Copyright 2021 Wakari SRL +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +from unittest import mock + +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase + +from pydantic import BaseModel + +from .. import restapi + + +class TestPydantic(TransactionCase): + def setUp(self): + super().setUp() + + class Model1(BaseModel): + name: str + description: str = None + + self.Model1: BaseModel = Model1 + + def _from_params(self, pydantic_cls: type[BaseModel], params: dict, **kwargs): + restapi_pydantic_cls = ( + restapi.PydanticModelList + if isinstance(params, list) + else restapi.PydanticModel + ) + restapi_pydantic = restapi_pydantic_cls(pydantic_cls, **kwargs) + mock_service = mock.Mock() + mock_service.env = self.env + return restapi_pydantic.from_params(mock_service, params) + + def test_from_params(self): + params = { + "name": "Instance Name", + "description": "Instance Description", + } + instance = self._from_params(self.Model1, params) + self.assertEqual(instance.name, params["name"]) + self.assertEqual(instance.description, params["description"]) + + def test_from_params_missing_optional_field(self): + params = {"name": "Instance Name"} + instance = self._from_params(self.Model1, params) + self.assertEqual(instance.name, params["name"]) + self.assertIsNone(instance.description) + + def test_from_params_missing_required_field(self): + msg = r"Field required" + with self.assertRaisesRegex(UserError, msg): + self._from_params(self.Model1, {"description": "Instance Description"}) + + def test_from_params_pydantic_model_list(self): + params = [ + { + "name": "Instance Name", + "description": "Instance Description", + }, + { + "name": "Instance Name 2", + "description": "Instance Description 2", + }, + ] + instances = self._from_params(self.Model1, params) + self.assertEqual(len(instances), 2) + self.assertEqual(instances[0].name, params[0]["name"]) + self.assertEqual(instances[0].description, params[0]["description"]) diff --git a/base_rest_pydantic/tests/test_response.py b/base_rest_pydantic/tests/test_response.py new file mode 100644 index 000000000..9bafff3ea --- /dev/null +++ b/base_rest_pydantic/tests/test_response.py @@ -0,0 +1,49 @@ +# Copyright 2021 Wakari SRL +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +from unittest import mock + +from odoo.tests.common import TransactionCase + +from pydantic import BaseModel + +from .. import restapi + + +class TestPydantic(TransactionCase): + def _to_response(self, instance: BaseModel): + restapi_pydantic = restapi.PydanticModel(instance.__class__) + mock_service = mock.Mock() + mock_service.env = self.env + return restapi_pydantic.to_response(mock_service, instance) + + def _to_response_list(self, instance: list[BaseModel]): + restapi_pydantic = restapi.PydanticModelList(instance[0].__class__) + mock_service = mock.Mock() + mock_service.env = self.env + return restapi_pydantic.to_response(mock_service, instance) + + def test_to_response(self): + class Model1(BaseModel): + name: str + + instance = Model1(name="Instance 1") + res = self._to_response(instance) + self.assertEqual(res["name"], instance.name) + + def test_to_response_validation_failed(self): + class Model1(BaseModel): + name: str = None + + instance = Model1(named="Instance 1") + msg = r"Invalid Response" + with self.assertRaisesRegex(SystemError, msg): + self._to_response(instance) + + def test_to_response_list(self): + class Model1(BaseModel): + name: str + + instances = [Model1(name="Instance 1"), Model1(name="Instance 2")] + res = self._to_response_list(instances) + self.assertEqual(len(res), 2) + self.assertSetEqual({r["name"] for r in res}, {"Instance 1", "Instance 2"}) diff --git a/pydantic/__manifest__.py b/pydantic/__manifest__.py index e8692fdc5..59810a021 100644 --- a/pydantic/__manifest__.py +++ b/pydantic/__manifest__.py @@ -15,7 +15,7 @@ "data": [], "demo": [], "external_dependencies": { - "python": ["pydantic", "contextvars", "typing-extensions"] + "python": ["pydantic>=2.0.0", "contextvars", "typing-extensions"] }, "installable": True, } diff --git a/requirements.txt b/requirements.txt index 1a2af5049..69f99aad8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ contextvars extendable>=0.0.4 fastapi>=0.110.0 parse-accept-language -pydantic +pydantic>=2.0.0 pyquerystring python-multipart typing-extensions