From 3997cae0999941c8ba15fc78671d12e30c26a2e8 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Mon, 22 Nov 2021 15:46:55 +0100 Subject: [PATCH 1/4] [IMP] base_rest: provides the apispec object to method called to generate the json schemas infos --- base_rest/apispec/rest_method_param_plugin.py | 10 +++++++--- base_rest/restapi.py | 18 +++++++++--------- .../tests/test_cerberus_list_validator.py | 10 +++++----- base_rest/tests/test_cerberus_validator.py | 16 ++++++++++------ base_rest_datamodel/restapi.py | 6 +++--- 5 files changed, 34 insertions(+), 26 deletions(-) diff --git a/base_rest/apispec/rest_method_param_plugin.py b/base_rest/apispec/rest_method_param_plugin.py index dd96a24ca..64ad65039 100644 --- a/base_rest/apispec/rest_method_param_plugin.py +++ b/base_rest/apispec/rest_method_param_plugin.py @@ -49,12 +49,14 @@ def _generate_pamareters(self, routing, method, params): if method == "get": # get quey params from RequestMethodParam object parameters.extend( - input_param.to_openapi_query_parameters(self._service) + input_param.to_openapi_query_parameters(self._service, self.spec) ) else: # get requestBody from RequestMethodParam object request_body = params.get("requestBody", {}) - request_body.update(input_param.to_openapi_requestbody(self._service)) + request_body.update( + input_param.to_openapi_requestbody(self._service, self.spec) + ) params["requestBody"] = request_body # sort paramters to ease comparison into unittests parameters.sort(key=lambda a: a["name"]) @@ -69,5 +71,7 @@ def _generate_responses(self, routing, method, params): responses = params.get("responses", {}) # get response from RequestMethodParam object responses.update(self._default_responses.copy()) - responses.update(output_param.to_openapi_responses(self._service)) + responses.update( + output_param.to_openapi_responses(self._service, self.spec) + ) return responses diff --git a/base_rest/restapi.py b/base_rest/restapi.py index 37a078d9c..c49bfbc90 100644 --- a/base_rest/restapi.py +++ b/base_rest/restapi.py @@ -131,13 +131,13 @@ def to_response(self, service, result): :return: http.Response or JSON dict """ - def to_openapi_query_parameters(self, service): + def to_openapi_query_parameters(self, service, spec): return {} - def to_openapi_requestbody(self, service): + def to_openapi_requestbody(self, service, spec): return {} - def to_openapi_responses(self, service): + def to_openapi_responses(self, service, spec): return {} @@ -161,10 +161,10 @@ def _binary_content_schema(self): for mediatype in self._mediatypes } - def to_openapi_requestbody(self, service): + def to_openapi_requestbody(self, services, spec): return {"content": self._binary_content_schema} - def to_openapi_responses(self, service): + def to_openapi_responses(self, service, spec): return {"200": {"content": self._binary_content_schema}} def to_response(self, service, result): @@ -209,7 +209,7 @@ def to_response(self, service, result): return validator.document raise SystemError(_("Invalid Response %s") % validator.errors) - def to_openapi_query_parameters(self, service): + def to_openapi_query_parameters(self, service, spec): json_schema = self.to_json_schema(service, "input") parameters = [] for prop, spec in list(json_schema["properties"].items()): @@ -238,11 +238,11 @@ def to_openapi_query_parameters(self, service): return parameters - def to_openapi_requestbody(self, service): + def to_openapi_requestbody(self, service, spec): json_schema = self.to_json_schema(service, "input") return {"content": {"application/json": {"schema": json_schema}}} - def to_openapi_responses(self, service): + def to_openapi_responses(self, service, spec): json_schema = self.to_json_schema(service, "output") return {"200": {"content": {"application/json": {"schema": json_schema}}}} @@ -293,7 +293,7 @@ def from_params(self, service, params): def to_response(self, service, result): return self._do_validate(service, data=result, direction="output") - def to_openapi_query_parameters(self, service): + def to_openapi_query_parameters(self, service, spec): raise NotImplementedError("List are not (?yet?) supported as query paramters") # pylint: disable=W8120,W8115 diff --git a/base_rest/tests/test_cerberus_list_validator.py b/base_rest/tests/test_cerberus_list_validator.py index 91ba28e3b..1d5fb03e3 100644 --- a/base_rest/tests/test_cerberus_list_validator.py +++ b/base_rest/tests/test_cerberus_list_validator.py @@ -48,7 +48,7 @@ def setUpClass(cls): cls.maxDiff = None def test_to_openapi_responses(self): - res = self.simple_schema_list_validator.to_openapi_responses(None) + res = self.simple_schema_list_validator.to_openapi_responses(None, None) self.assertDictEqual( res, { @@ -78,7 +78,7 @@ def test_to_openapi_responses(self): } }, ) - res = self.nested_schema_list_validator.to_openapi_responses(None) + res = self.nested_schema_list_validator.to_openapi_responses(None, None) self.assertDictEqual( res, { @@ -113,7 +113,7 @@ def test_to_openapi_responses(self): ) def test_to_openapi_requestbody(self): - res = self.simple_schema_list_validator.to_openapi_requestbody(None) + res = self.simple_schema_list_validator.to_openapi_requestbody(None, None) self.assertEqual( res, { @@ -141,7 +141,7 @@ def test_to_openapi_requestbody(self): } }, ) - res = self.nested_schema_list_validator.to_openapi_requestbody(None) + res = self.nested_schema_list_validator.to_openapi_requestbody(None, None) self.assertDictEqual( res, { @@ -175,7 +175,7 @@ def test_to_openapi_requestbody(self): def test_to_openapi_query_parameters(self): with self.assertRaises(NotImplementedError): - self.simple_schema_list_validator.to_openapi_query_parameters(None) + self.simple_schema_list_validator.to_openapi_query_parameters(None, None) def test_from_params_ignore_unknown(self): params = [{"name": "test", "unknown": True}] diff --git a/base_rest/tests/test_cerberus_validator.py b/base_rest/tests/test_cerberus_validator.py index e36b599d9..e6c8360ef 100644 --- a/base_rest/tests/test_cerberus_validator.py +++ b/base_rest/tests/test_cerberus_validator.py @@ -51,7 +51,7 @@ def setUpClass(cls): ) def test_to_openapi_responses(self): - res = self.simple_schema_cerberus_validator.to_openapi_responses(None) + res = self.simple_schema_cerberus_validator.to_openapi_responses(None, None) self.assertDictEqual( res, { @@ -80,7 +80,7 @@ def test_to_openapi_responses(self): } }, ) - res = self.nested_schema_cerberus_validator.to_openapi_responses(None) + res = self.nested_schema_cerberus_validator.to_openapi_responses(None, None) self.assertDictEqual( res, { @@ -113,7 +113,7 @@ def test_to_openapi_responses(self): ) def test_to_openapi_requestbody(self): - res = self.simple_schema_cerberus_validator.to_openapi_requestbody(None) + res = self.simple_schema_cerberus_validator.to_openapi_requestbody(None, None) self.assertEqual( res, { @@ -140,7 +140,7 @@ def test_to_openapi_requestbody(self): } }, ) - res = self.nested_schema_cerberus_validator.to_openapi_requestbody(None) + res = self.nested_schema_cerberus_validator.to_openapi_requestbody(None, None) self.assertDictEqual( res, { @@ -168,7 +168,9 @@ def test_to_openapi_requestbody(self): ) def test_to_openapi_query_parameters(self): - res = self.simple_schema_cerberus_validator.to_openapi_query_parameters(None) + res = self.simple_schema_cerberus_validator.to_openapi_query_parameters( + None, None + ) self.assertListEqual( res, [ @@ -206,7 +208,9 @@ def test_to_openapi_query_parameters(self): }, ], ) - res = self.nested_schema_cerberus_validator.to_openapi_query_parameters(None) + res = self.nested_schema_cerberus_validator.to_openapi_query_parameters( + None, None + ) self.assertListEqual( res, [ diff --git a/base_rest_datamodel/restapi.py b/base_rest_datamodel/restapi.py index c334ec1e3..5fdfffe40 100644 --- a/base_rest_datamodel/restapi.py +++ b/base_rest_datamodel/restapi.py @@ -52,7 +52,7 @@ def to_response(self, service, result): raise SystemError(_("Invalid Response %s") % errors) return json - def to_openapi_query_parameters(self, service): + def to_openapi_query_parameters(self, service, spec): converter = self._get_converter() schema = self._get_schema(service) return converter.schema2parameters(schema, location="query") @@ -60,14 +60,14 @@ def to_openapi_query_parameters(self, service): # 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): + def to_openapi_requestbody(self, service, spec): return { "content": { "application/json": {"schema": self.to_json_schema(service, "input")} } } - def to_openapi_responses(self, service): + def to_openapi_responses(self, service, spec): return { "200": { "content": { From 94761d94863d3f253668dbc2ae6dbebfb4130ea1 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Mon, 22 Nov 2021 17:23:16 +0100 Subject: [PATCH 2/4] [ADD] base_rest_dynamic: Allows the use of pydantic models into base_rest --- base_rest_demo/__init__.py | 1 + base_rest_demo/__manifest__.py | 2 +- base_rest_demo/partner.json | 560 ------------------ base_rest_demo/partner_image_api.json | 59 -- base_rest_demo/ping_api.json | 433 -------------- base_rest_demo/pydantic_models/__init__.py | 6 + .../pydantic_models/country_info.py | 10 + .../pydantic_models/naive_orm_model.py | 10 + .../pydantic_models/partner_info.py | 20 + .../pydantic_models/partner_search_param.py | 10 + .../pydantic_models/partner_short_info.py | 10 + base_rest_demo/pydantic_models/state_info.py | 10 + base_rest_demo/services/__init__.py | 1 + .../services/partner_pydantic_services.py | 60 ++ base_rest_demo/tests/common.py | 15 +- .../tests/data/partner_pydantic_api.json | 332 +++++++++++ base_rest_demo/tests/test_openapi.py | 31 +- base_rest_pydantic/README.rst | 111 ++++ base_rest_pydantic/__init__.py | 1 + base_rest_pydantic/__manifest__.py | 16 + .../i18n/base_rest_datamodel.pot | 26 + base_rest_pydantic/readme/CONTRIBUTORS.rst | 1 + base_rest_pydantic/readme/DESCRIPTION.rst | 2 + base_rest_pydantic/readme/USAGE.rst | 33 ++ base_rest_pydantic/restapi.py | 195 ++++++ .../static/description/icon.png | Bin 0 -> 9455 bytes .../static/description/index.html | 455 ++++++++++++++ base_rest_pydantic/tests/__init__.py | 2 + base_rest_pydantic/tests/test_from_params.py | 49 ++ base_rest_pydantic/tests/test_response.py | 43 ++ .../odoo/addons/base_rest_pydantic | 1 + setup/base_rest_pydantic/setup.py | 6 + 32 files changed, 1441 insertions(+), 1070 deletions(-) delete mode 100644 base_rest_demo/partner.json delete mode 100644 base_rest_demo/partner_image_api.json delete mode 100644 base_rest_demo/ping_api.json create mode 100644 base_rest_demo/pydantic_models/__init__.py create mode 100644 base_rest_demo/pydantic_models/country_info.py create mode 100644 base_rest_demo/pydantic_models/naive_orm_model.py create mode 100644 base_rest_demo/pydantic_models/partner_info.py create mode 100644 base_rest_demo/pydantic_models/partner_search_param.py create mode 100644 base_rest_demo/pydantic_models/partner_short_info.py create mode 100644 base_rest_demo/pydantic_models/state_info.py create mode 100644 base_rest_demo/services/partner_pydantic_services.py create mode 100644 base_rest_demo/tests/data/partner_pydantic_api.json create mode 100644 base_rest_pydantic/README.rst create mode 100644 base_rest_pydantic/__init__.py create mode 100644 base_rest_pydantic/__manifest__.py create mode 100644 base_rest_pydantic/i18n/base_rest_datamodel.pot create mode 100644 base_rest_pydantic/readme/CONTRIBUTORS.rst create mode 100644 base_rest_pydantic/readme/DESCRIPTION.rst create mode 100644 base_rest_pydantic/readme/USAGE.rst create mode 100644 base_rest_pydantic/restapi.py create mode 100644 base_rest_pydantic/static/description/icon.png create mode 100644 base_rest_pydantic/static/description/index.html create mode 100644 base_rest_pydantic/tests/__init__.py create mode 100644 base_rest_pydantic/tests/test_from_params.py create mode 100644 base_rest_pydantic/tests/test_response.py create mode 120000 setup/base_rest_pydantic/odoo/addons/base_rest_pydantic create mode 100644 setup/base_rest_pydantic/setup.py diff --git a/base_rest_demo/__init__.py b/base_rest_demo/__init__.py index 5b9cd7bd0..0fb902089 100644 --- a/base_rest_demo/__init__.py +++ b/base_rest_demo/__init__.py @@ -1,3 +1,4 @@ from . import controllers from . import datamodels +from . import pydantic_models from . import services diff --git a/base_rest_demo/__manifest__.py b/base_rest_demo/__manifest__.py index 7ba77a9dc..d9e3c9168 100644 --- a/base_rest_demo/__manifest__.py +++ b/base_rest_demo/__manifest__.py @@ -11,7 +11,7 @@ "author": "ACSONE SA/NV, " "Odoo Community Association (OCA)", "maintainers": ["lmignon"], "website": "https://github.com/OCA/rest-framework", - "depends": ["base_rest", "base_rest_datamodel", "component"], + "depends": ["base_rest", "base_rest_datamodel", "base_rest_pydantic", "component"], "data": [], "demo": [], "external_dependencies": {"python": ["jsondiff"]}, diff --git a/base_rest_demo/partner.json b/base_rest_demo/partner.json deleted file mode 100644 index c90d042cc..000000000 --- a/base_rest_demo/partner.json +++ /dev/null @@ -1,560 +0,0 @@ -{ - "info": { - "description": "\nPartner Services\nAccess to the partner services is only allowed to authenticated users.\nIf you are not authenticated go to Login\n", - "title": "partner REST services", - "version": "" - }, - "servers": [{"url": "http://localhost:8069/base_rest_demo_api/private/partner"}], - "paths": { - "/{id}/archive": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": {"type": "object", "required": [], "properties": {}} - } - } - }, - "parameters": [] - }, - "description": "\nArchive the given partner. This method is an empty method, IOW it\ndon't update the partner. This method is part of the demo data to\nillustrate that historically it's not mandatory to defined a schema\ndescribing the content of the response returned by a method.\nThis kind of definition is DEPRECATED and will no more supported in\nthe future.\n:param _id:\n:param params:\n:return:\n", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": {"type": "integer", "format": "int32"} - } - ] - }, - "/create": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["city", "name", "street", "zip"], - "properties": { - "name": {"type": "string"}, - "street": {"type": "string"}, - "street2": {"nullable": true, "type": "string"}, - "zip": {"type": "string"}, - "city": {"type": "string"}, - "phone": {"nullable": true, "type": "string"}, - "state": { - "type": "object", - "required": [], - "properties": { - "id": {"nullable": true, "type": "integer"}, - "name": {"type": "string"} - } - }, - "country": { - "type": "object", - "required": ["id"], - "properties": { - "id": {"nullable": false, "type": "integer"}, - "name": {"type": "string"} - } - }, - "is_company": {"type": "boolean"} - } - } - } - } - }, - "parameters": [], - "responses": { - "400": {"description": "One of the given parameter is not valid"}, - "401": { - "description": "The user is not authorized. Authentication is required" - }, - "404": {"description": "Requested resource not found"}, - "403": { - "description": "You don't have the permission to access the requested resource." - }, - "200": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["city", "id", "name", "street", "zip"], - "properties": { - "name": {"type": "string"}, - "street": {"type": "string"}, - "street2": {"nullable": true, "type": "string"}, - "zip": {"type": "string"}, - "city": {"type": "string"}, - "phone": {"nullable": true, "type": "string"}, - "state": { - "type": "object", - "required": [], - "properties": { - "id": {"nullable": true, "type": "integer"}, - "name": {"type": "string"} - } - }, - "country": { - "type": "object", - "required": ["id"], - "properties": { - "id": {"nullable": false, "type": "integer"}, - "name": {"type": "string"} - } - }, - "is_company": {"type": "boolean"}, - "id": {"type": "integer"} - } - } - } - } - } - } - }, - "description": "\nCreate a new partner\n" - }, - "/{id}/get": { - "get": { - "parameters": [], - "responses": { - "400": {"description": "One of the given parameter is not valid"}, - "401": { - "description": "The user is not authorized. Authentication is required" - }, - "404": {"description": "Requested resource not found"}, - "403": { - "description": "You don't have the permission to access the requested resource." - }, - "200": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["city", "id", "name", "street", "zip"], - "properties": { - "name": {"type": "string"}, - "street": {"type": "string"}, - "street2": {"nullable": true, "type": "string"}, - "zip": {"type": "string"}, - "city": {"type": "string"}, - "phone": {"nullable": true, "type": "string"}, - "state": { - "type": "object", - "required": [], - "properties": { - "id": {"nullable": true, "type": "integer"}, - "name": {"type": "string"} - } - }, - "country": { - "type": "object", - "required": ["id"], - "properties": { - "id": {"nullable": false, "type": "integer"}, - "name": {"type": "string"} - } - }, - "is_company": {"type": "boolean"}, - "id": {"type": "integer"} - } - } - } - } - } - } - }, - "description": "\nGet partner's informations\n", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": {"type": "integer", "format": "int32"} - } - ] - }, - "/{id}": { - "get": { - "parameters": [], - "responses": { - "400": {"description": "One of the given parameter is not valid"}, - "401": { - "description": "The user is not authorized. Authentication is required" - }, - "404": {"description": "Requested resource not found"}, - "403": { - "description": "You don't have the permission to access the requested resource." - }, - "200": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["city", "id", "name", "street", "zip"], - "properties": { - "name": {"type": "string"}, - "street": {"type": "string"}, - "street2": {"nullable": true, "type": "string"}, - "zip": {"type": "string"}, - "city": {"type": "string"}, - "phone": {"nullable": true, "type": "string"}, - "state": { - "type": "object", - "required": [], - "properties": { - "id": {"nullable": true, "type": "integer"}, - "name": {"type": "string"} - } - }, - "country": { - "type": "object", - "required": ["id"], - "properties": { - "id": {"nullable": false, "type": "integer"}, - "name": {"type": "string"} - } - }, - "is_company": {"type": "boolean"}, - "id": {"type": "integer"} - } - } - } - } - } - } - }, - "description": "\nUpdate partner informations\n", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": {"type": "integer", "format": "int32"} - } - ], - "put": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [], - "properties": { - "name": {"type": "string"}, - "street": {"type": "string"}, - "street2": {"nullable": true, "type": "string"}, - "zip": {"type": "string"}, - "city": {"type": "string"}, - "phone": {"nullable": true, "type": "string"}, - "state": { - "type": "object", - "required": [], - "properties": { - "id": {"nullable": true, "type": "integer"}, - "name": {"type": "string"} - } - }, - "country": { - "type": "object", - "required": ["id"], - "properties": { - "id": {"nullable": false, "type": "integer"}, - "name": {"type": "string"} - } - }, - "is_company": {"type": "boolean"} - } - } - } - } - }, - "parameters": [], - "responses": { - "400": {"description": "One of the given parameter is not valid"}, - "401": { - "description": "The user is not authorized. Authentication is required" - }, - "404": {"description": "Requested resource not found"}, - "403": { - "description": "You don't have the permission to access the requested resource." - }, - "200": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["city", "id", "name", "street", "zip"], - "properties": { - "name": {"type": "string"}, - "street": {"type": "string"}, - "street2": {"nullable": true, "type": "string"}, - "zip": {"type": "string"}, - "city": {"type": "string"}, - "phone": {"nullable": true, "type": "string"}, - "state": { - "type": "object", - "required": [], - "properties": { - "id": {"nullable": true, "type": "integer"}, - "name": {"type": "string"} - } - }, - "country": { - "type": "object", - "required": ["id"], - "properties": { - "id": {"nullable": false, "type": "integer"}, - "name": {"type": "string"} - } - }, - "is_company": {"type": "boolean"}, - "id": {"type": "integer"} - } - } - } - } - } - } - } - }, - "/search": { - "get": { - "parameters": [ - { - "name": "name", - "in": "query", - "required": true, - "allowEmptyValue": false, - "default": null, - "schema": {"type": "string"} - } - ], - "responses": { - "400": {"description": "One of the given parameter is not valid"}, - "401": { - "description": "The user is not authorized. Authentication is required" - }, - "404": {"description": "Requested resource not found"}, - "403": { - "description": "You don't have the permission to access the requested resource." - }, - "200": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["count", "rows"], - "properties": { - "count": {"type": "integer"}, - "rows": { - "type": "array", - "items": { - "type": "object", - "required": ["city", "id", "name", "street", "zip"], - "properties": { - "name": {"type": "string"}, - "street": {"type": "string"}, - "street2": {"nullable": true, "type": "string"}, - "zip": {"type": "string"}, - "city": {"type": "string"}, - "phone": {"nullable": true, "type": "string"}, - "state": { - "type": "object", - "required": [], - "properties": { - "id": {"nullable": true, "type": "integer"}, - "name": {"type": "string"} - } - }, - "country": { - "type": "object", - "required": ["id"], - "properties": { - "id": {"nullable": false, "type": "integer"}, - "name": {"type": "string"} - } - }, - "is_company": {"type": "boolean"}, - "id": {"type": "integer"} - } - } - } - } - } - } - } - } - } - }, - "description": "\nSearh partner by name\n" - }, - "/": { - "get": { - "parameters": [ - { - "name": "name", - "in": "query", - "required": true, - "allowEmptyValue": false, - "default": null, - "schema": {"type": "string"} - } - ], - "responses": { - "400": {"description": "One of the given parameter is not valid"}, - "401": { - "description": "The user is not authorized. Authentication is required" - }, - "404": {"description": "Requested resource not found"}, - "403": { - "description": "You don't have the permission to access the requested resource." - }, - "200": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["count", "rows"], - "properties": { - "count": {"type": "integer"}, - "rows": { - "type": "array", - "items": { - "type": "object", - "required": ["city", "id", "name", "street", "zip"], - "properties": { - "name": {"type": "string"}, - "street": {"type": "string"}, - "street2": {"nullable": true, "type": "string"}, - "zip": {"type": "string"}, - "city": {"type": "string"}, - "phone": {"nullable": true, "type": "string"}, - "state": { - "type": "object", - "required": [], - "properties": { - "id": {"nullable": true, "type": "integer"}, - "name": {"type": "string"} - } - }, - "country": { - "type": "object", - "required": ["id"], - "properties": { - "id": {"nullable": false, "type": "integer"}, - "name": {"type": "string"} - } - }, - "is_company": {"type": "boolean"}, - "id": {"type": "integer"} - } - } - } - } - } - } - } - } - } - }, - "description": "\nSearh partner by name\n" - }, - "/{id}/update": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [], - "properties": { - "name": {"type": "string"}, - "street": {"type": "string"}, - "street2": {"nullable": true, "type": "string"}, - "zip": {"type": "string"}, - "city": {"type": "string"}, - "phone": {"nullable": true, "type": "string"}, - "state": { - "type": "object", - "required": [], - "properties": { - "id": {"nullable": true, "type": "integer"}, - "name": {"type": "string"} - } - }, - "country": { - "type": "object", - "required": ["id"], - "properties": { - "id": {"nullable": false, "type": "integer"}, - "name": {"type": "string"} - } - }, - "is_company": {"type": "boolean"} - } - } - } - } - }, - "parameters": [], - "responses": { - "400": {"description": "One of the given parameter is not valid"}, - "401": { - "description": "The user is not authorized. Authentication is required" - }, - "404": {"description": "Requested resource not found"}, - "403": { - "description": "You don't have the permission to access the requested resource." - }, - "200": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["city", "id", "name", "street", "zip"], - "properties": { - "name": {"type": "string"}, - "street": {"type": "string"}, - "street2": {"nullable": true, "type": "string"}, - "zip": {"type": "string"}, - "city": {"type": "string"}, - "phone": {"nullable": true, "type": "string"}, - "state": { - "type": "object", - "required": [], - "properties": { - "id": {"nullable": true, "type": "integer"}, - "name": {"type": "string"} - } - }, - "country": { - "type": "object", - "required": ["id"], - "properties": { - "id": {"nullable": false, "type": "integer"}, - "name": {"type": "string"} - } - }, - "is_company": {"type": "boolean"}, - "id": {"type": "integer"} - } - } - } - } - } - } - }, - "description": "\nUpdate partner informations\n", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": {"type": "integer", "format": "int32"} - } - ] - } - }, - "openapi": "3.0.2" -} diff --git a/base_rest_demo/partner_image_api.json b/base_rest_demo/partner_image_api.json deleted file mode 100644 index 70ef55db7..000000000 --- a/base_rest_demo/partner_image_api.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "info": { - "description": "\nPartner Image Services\n\nService used to retrieve the partner's image\nAccess to the partner image service is only allowed to authenticated\nusers.\nIf you are not authenticated go to Login\n", - "title": "partner_image REST services", - "version": "" - }, - "servers": [ - {"url": "http://localhost:8069/base_rest_demo_api/private/partner_image"} - ], - "paths": { - "/{id}/get": { - "get": { - "parameters": [ - { - "name": "size", - "in": "query", - "required": false, - "allowEmptyValue": false, - "default": "small", - "schema": {"type": "string", "enum": ["small", "medium", "large"]} - } - ] - }, - "description": "\nGet partner's image\n", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": {"type": "integer", "format": "int32"} - } - ] - }, - "/{id}": { - "get": { - "parameters": [ - { - "name": "size", - "in": "query", - "required": false, - "allowEmptyValue": false, - "default": "small", - "schema": {"type": "string", "enum": ["small", "medium", "large"]} - } - ] - }, - "description": "\nGet partner's image\n", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": {"type": "integer", "format": "int32"} - } - ] - } - }, - "openapi": "3.0.0" -} diff --git a/base_rest_demo/ping_api.json b/base_rest_demo/ping_api.json deleted file mode 100644 index 01e233ab5..000000000 --- a/base_rest_demo/ping_api.json +++ /dev/null @@ -1,433 +0,0 @@ -{ - "info": { - "description": "\nPing Services\nAccess to the ping services is allowed to everyone\n", - "title": "ping REST services", - "version": "" - }, - "servers": [{"url": "http://localhost:8069/base_rest_demo_api/public/ping"}], - "paths": { - "/create": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [], - "properties": {"message": {"type": "string"}} - } - } - } - }, - "parameters": [], - "responses": { - "400": {"description": "One of the given parameter is not valid"}, - "401": { - "description": "The user is not authorized. Authentication is required" - }, - "404": {"description": "Requested resource not found"}, - "403": { - "description": "You don't have the permission to access the requested resource." - }, - "200": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [], - "properties": {"response": {"type": "string"}} - } - } - } - } - } - }, - "description": "\nCreate method description ...\n" - }, - "/{id}/delete": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": {"type": "object", "required": [], "properties": {}} - } - } - }, - "parameters": [], - "responses": { - "400": {"description": "One of the given parameter is not valid"}, - "401": { - "description": "The user is not authorized. Authentication is required" - }, - "404": {"description": "Requested resource not found"}, - "403": { - "description": "You don't have the permission to access the requested resource." - }, - "200": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [], - "properties": {"response": {"type": "string"}} - } - } - } - } - } - }, - "description": "\nDelete method description ...\n", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": {"type": "integer", "format": "int32"} - } - ] - }, - "/{id}": { - "delete": { - "requestBody": { - "content": { - "application/json": { - "schema": {"type": "object", "required": [], "properties": {}} - } - } - }, - "parameters": [], - "responses": { - "400": {"description": "One of the given parameter is not valid"}, - "401": { - "description": "The user is not authorized. Authentication is required" - }, - "404": {"description": "Requested resource not found"}, - "403": { - "description": "You don't have the permission to access the requested resource." - }, - "200": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [], - "properties": {"response": {"type": "string"}} - } - } - } - } - } - }, - "description": "\nUpdate method description ...\n", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": {"type": "integer", "format": "int32"} - } - ], - "get": { - "parameters": [ - { - "name": "message", - "in": "query", - "required": false, - "allowEmptyValue": false, - "default": null, - "schema": {"type": "string"} - } - ], - "responses": { - "400": {"description": "One of the given parameter is not valid"}, - "401": { - "description": "The user is not authorized. Authentication is required" - }, - "404": {"description": "Requested resource not found"}, - "403": { - "description": "You don't have the permission to access the requested resource." - }, - "200": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [], - "properties": { - "message": {"type": "string"}, - "id": {"type": "integer"} - } - } - } - } - } - } - }, - "put": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [], - "properties": {"message": {"type": "string"}} - } - } - } - }, - "parameters": [], - "responses": { - "400": {"description": "One of the given parameter is not valid"}, - "401": { - "description": "The user is not authorized. Authentication is required" - }, - "404": {"description": "Requested resource not found"}, - "403": { - "description": "You don't have the permission to access the requested resource." - }, - "200": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [], - "properties": {"response": {"type": "string"}} - } - } - } - } - } - } - }, - "/{id}/get": { - "get": { - "parameters": [ - { - "name": "message", - "in": "query", - "required": false, - "allowEmptyValue": false, - "default": null, - "schema": {"type": "string"} - } - ], - "responses": { - "400": {"description": "One of the given parameter is not valid"}, - "401": { - "description": "The user is not authorized. Authentication is required" - }, - "404": {"description": "Requested resource not found"}, - "403": { - "description": "You don't have the permission to access the requested resource." - }, - "200": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [], - "properties": { - "message": {"type": "string"}, - "id": {"type": "integer"} - } - } - } - } - } - } - }, - "description": "\nThis method is used to get the information of the object specified\nby Id.\n", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": {"type": "integer", "format": "int32"} - } - ] - }, - "/search": { - "get": { - "parameters": [ - { - "name": "limit", - "in": "query", - "required": false, - "allowEmptyValue": false, - "default": 50, - "schema": {"type": "integer"} - }, - { - "name": "offset", - "in": "query", - "required": false, - "allowEmptyValue": false, - "default": 0, - "schema": {"type": "integer"} - }, - { - "name": "param_required", - "in": "query", - "required": true, - "allowEmptyValue": false, - "default": null, - "schema": {"type": "string"} - }, - { - "name": "param_string", - "in": "query", - "required": false, - "allowEmptyValue": false, - "default": null, - "schema": {"type": "string"} - }, - { - "name": "params[]", - "in": "query", - "required": false, - "allowEmptyValue": false, - "default": null, - "schema": {"type": "array", "items": {"type": "string"}} - } - ], - "responses": { - "400": {"description": "One of the given parameter is not valid"}, - "401": { - "description": "The user is not authorized. Authentication is required" - }, - "404": {"description": "Requested resource not found"}, - "403": { - "description": "You don't have the permission to access the requested resource." - }, - "200": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [], - "properties": {"response": {"type": "string"}} - } - } - } - } - } - }, - "description": "\nA search method to illustrate how you can define a complex request.\nIn the case of the methods 'get' and 'search' the parameters are\npassed to the server as the query part of the service URL.\n" - }, - "/": { - "get": { - "parameters": [ - { - "name": "limit", - "in": "query", - "required": false, - "allowEmptyValue": false, - "default": 50, - "schema": {"type": "integer"} - }, - { - "name": "offset", - "in": "query", - "required": false, - "allowEmptyValue": false, - "default": 0, - "schema": {"type": "integer"} - }, - { - "name": "param_required", - "in": "query", - "required": true, - "allowEmptyValue": false, - "default": null, - "schema": {"type": "string"} - }, - { - "name": "param_string", - "in": "query", - "required": false, - "allowEmptyValue": false, - "default": null, - "schema": {"type": "string"} - }, - { - "name": "params[]", - "in": "query", - "required": false, - "allowEmptyValue": false, - "default": null, - "schema": {"type": "array", "items": {"type": "string"}} - } - ], - "responses": { - "400": {"description": "One of the given parameter is not valid"}, - "401": { - "description": "The user is not authorized. Authentication is required" - }, - "404": {"description": "Requested resource not found"}, - "403": { - "description": "You don't have the permission to access the requested resource." - }, - "200": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [], - "properties": {"response": {"type": "string"}} - } - } - } - } - } - }, - "description": "\nA search method to illustrate how you can define a complex request.\nIn the case of the methods 'get' and 'search' the parameters are\npassed to the server as the query part of the service URL.\n" - }, - "/{id}/update": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [], - "properties": {"message": {"type": "string"}} - } - } - } - }, - "parameters": [], - "responses": { - "400": {"description": "One of the given parameter is not valid"}, - "401": { - "description": "The user is not authorized. Authentication is required" - }, - "404": {"description": "Requested resource not found"}, - "403": { - "description": "You don't have the permission to access the requested resource." - }, - "200": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [], - "properties": {"response": {"type": "string"}} - } - } - } - } - } - }, - "description": "\nUpdate method description ...\n", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": {"type": "integer", "format": "int32"} - } - ] - } - }, - "openapi": "3.0.0" -} diff --git a/base_rest_demo/pydantic_models/__init__.py b/base_rest_demo/pydantic_models/__init__.py new file mode 100644 index 000000000..20f435f4a --- /dev/null +++ b/base_rest_demo/pydantic_models/__init__.py @@ -0,0 +1,6 @@ +from . import naive_orm_model +from . import country_info +from . import state_info +from . import partner_short_info +from . import partner_info +from . import partner_search_param diff --git a/base_rest_demo/pydantic_models/country_info.py b/base_rest_demo/pydantic_models/country_info.py new file mode 100644 index 000000000..57eedd844 --- /dev/null +++ b/base_rest_demo/pydantic_models/country_info.py @@ -0,0 +1,10 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from .naive_orm_model import NaiveOrmModel + + +class CountryInfo(NaiveOrmModel): + + id: int + name: str diff --git a/base_rest_demo/pydantic_models/naive_orm_model.py b/base_rest_demo/pydantic_models/naive_orm_model.py new file mode 100644 index 000000000..3efe64f74 --- /dev/null +++ b/base_rest_demo/pydantic_models/naive_orm_model.py @@ -0,0 +1,10 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.addons.pydantic import models, utils + + +class NaiveOrmModel(models.BaseModel): + class Config: + orm_mode = True + getter_dict = utils.GenericOdooGetter diff --git a/base_rest_demo/pydantic_models/partner_info.py b/base_rest_demo/pydantic_models/partner_info.py new file mode 100644 index 000000000..f57b67303 --- /dev/null +++ b/base_rest_demo/pydantic_models/partner_info.py @@ -0,0 +1,20 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import pydantic + +from .country_info import CountryInfo +from .partner_short_info import PartnerShortInfo +from .state_info import StateInfo + + +class PartnerInfo(PartnerShortInfo): + + street: str + street2: str = None + zip_code: str = pydantic.Field(..., alias="zip") + city: str + phone: str = None + state: StateInfo = pydantic.Field(..., alias="state_id") + country: CountryInfo = pydantic.Field(..., alias="country_id") + is_company: bool = None diff --git a/base_rest_demo/pydantic_models/partner_search_param.py b/base_rest_demo/pydantic_models/partner_search_param.py new file mode 100644 index 000000000..668ec9967 --- /dev/null +++ b/base_rest_demo/pydantic_models/partner_search_param.py @@ -0,0 +1,10 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.addons.pydantic import models + + +class PartnerSearchParam(models.BaseModel): + + id: int = None + name: str = None diff --git a/base_rest_demo/pydantic_models/partner_short_info.py b/base_rest_demo/pydantic_models/partner_short_info.py new file mode 100644 index 000000000..57e7554df --- /dev/null +++ b/base_rest_demo/pydantic_models/partner_short_info.py @@ -0,0 +1,10 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from .naive_orm_model import NaiveOrmModel + + +class PartnerShortInfo(NaiveOrmModel): + + id: int + name: str diff --git a/base_rest_demo/pydantic_models/state_info.py b/base_rest_demo/pydantic_models/state_info.py new file mode 100644 index 000000000..ce1f4fd1b --- /dev/null +++ b/base_rest_demo/pydantic_models/state_info.py @@ -0,0 +1,10 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from .naive_orm_model import NaiveOrmModel + + +class StateInfo(NaiveOrmModel): + + id: int + name: str diff --git a/base_rest_demo/services/__init__.py b/base_rest_demo/services/__init__.py index b1529568f..d77f3a079 100644 --- a/base_rest_demo/services/__init__.py +++ b/base_rest_demo/services/__init__.py @@ -4,3 +4,4 @@ from . import partner_jwt_services from . import exception_services from . import partner_new_api_services +from . import partner_pydantic_services diff --git a/base_rest_demo/services/partner_pydantic_services.py b/base_rest_demo/services/partner_pydantic_services.py new file mode 100644 index 000000000..fe72a931e --- /dev/null +++ b/base_rest_demo/services/partner_pydantic_services.py @@ -0,0 +1,60 @@ +# Copyright 2018 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +from odoo.addons.base_rest import restapi +from odoo.addons.base_rest_pydantic.restapi import PydanticModel, PydanticModelList +from odoo.addons.component.core import Component + +from ..pydantic_models.partner_info import PartnerInfo +from ..pydantic_models.partner_search_param import PartnerSearchParam +from ..pydantic_models.partner_short_info import PartnerShortInfo + + +class PartnerNewApiService(Component): + _inherit = "base.rest.service" + _name = "partner.pydantic.service" + _usage = "partner_pydantic" + _collection = "base.rest.demo.new_api.services" + _description = """ + Partner New API Services + Services developed with the new api provided by base_rest and pydantic + """ + + @restapi.method( + [(["//get", "/"], "GET")], + output_param=PydanticModel(PartnerInfo), + auth="public", + ) + def get(self, _id): + """ + Get partner's information + """ + partner = self._get(_id) + return PartnerInfo.from_orm(partner) + + @restapi.method( + [(["/", "/search"], "GET")], + input_param=PydanticModel(PartnerSearchParam), + output_param=PydanticModelList(PartnerShortInfo), + auth="public", + ) + def search(self, partner_search_param): + """ + Search for partners + :param partner_search_param: An instance of partner.search.param + :return: List of partner.short.info + """ + domain = [] + if partner_search_param.name: + domain.append(("name", "like", partner_search_param.name)) + if partner_search_param.id: + domain.append(("id", "=", partner_search_param.id)) + res = [] + for p in self.env["res.partner"].sudo().search(domain): + res.append(PartnerShortInfo.from_orm(p)) + return res + + # The following method are 'private' and should be never never NEVER call + # from the controller. + + def _get(self, _id): + return self.env["res.partner"].sudo().browse(_id) diff --git a/base_rest_demo/tests/common.py b/base_rest_demo/tests/common.py index 4f53cf4f7..f05a02cff 100644 --- a/base_rest_demo/tests/common.py +++ b/base_rest_demo/tests/common.py @@ -7,11 +7,12 @@ from odoo.addons.base_rest.controllers.main import _PseudoCollection from odoo.addons.base_rest.tests.common import BaseRestCase from odoo.addons.component.core import WorkContext +from odoo.addons.pydantic.tests.common import PydanticMixin DATA_DIR = os.path.join(os.path.realpath(os.path.dirname(__file__)), "data") -class CommonCase(BaseRestCase): +class CommonCase(BaseRestCase, PydanticMixin): @classmethod def setUpClass(cls): super(CommonCase, cls).setUpClass() @@ -24,6 +25,18 @@ def setUpClass(cls): cls.public_services_env = WorkContext( model_name="rest.service.registration", collection=collection ) + collection = _PseudoCollection("base.rest.demo.new_api.services", cls.env) + cls.new_api_services_env = WorkContext( + model_name="rest.service.registration", collection=collection + ) + cls.setUpPydantic() + + # pylint: disable=W8106 + def setUp(self): + # resolve an inheritance issue (common.TransactionCase does not call + # super) + BaseRestCase.setUp(self) + PydanticMixin.setUp(self) def get_canonical_json(file_name): diff --git a/base_rest_demo/tests/data/partner_pydantic_api.json b/base_rest_demo/tests/data/partner_pydantic_api.json new file mode 100644 index 000000000..5129a9a74 --- /dev/null +++ b/base_rest_demo/tests/data/partner_pydantic_api.json @@ -0,0 +1,332 @@ +{ + "info": { + "description": "\nPartner New API Services\nServices developed with the new api provided by base_rest and pydantic\n", + "title": "partner_pydantic REST services", + "version": "" + }, + "servers": [ + { + "url": "http://localhost:8069/base_rest_demo_api/new_api/partner_pydantic" + } + ], + "paths": { + "/{id}/get": { + "get": { + "summary": "\nGet partner's information\n", + "responses": { + "400": { + "description": "One of the given parameter is not valid" + }, + "401": { + "description": "The user is not authorized. Authentication is required" + }, + "404": { + "description": "Requested resource not found" + }, + "403": { + "description": "You don't have the permission to access the requested resource." + }, + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PartnerInfo" + } + } + } + } + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ] + }, + "/{id}": { + "get": { + "summary": "\nGet partner's information\n", + "responses": { + "400": { + "description": "One of the given parameter is not valid" + }, + "401": { + "description": "The user is not authorized. Authentication is required" + }, + "404": { + "description": "Requested resource not found" + }, + "403": { + "description": "You don't have the permission to access the requested resource." + }, + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PartnerInfo" + } + } + } + } + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ] + }, + "/": { + "get": { + "summary": "\nSearch for partners\n:param partner_search_param: An instance of partner.search.param\n:return: List of partner.short.info\n", + "parameters": [ + { + "name": "id", + "in": "query", + "required": false, + "allowEmptyValue": false, + "default": null, + "schema": { + "type": "integer" + } + }, + { + "name": "name", + "in": "query", + "required": false, + "allowEmptyValue": false, + "default": null, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "One of the given parameter is not valid" + }, + "401": { + "description": "The user is not authorized. Authentication is required" + }, + "404": { + "description": "Requested resource not found" + }, + "403": { + "description": "You don't have the permission to access the requested resource." + }, + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PartnerShortInfo" + } + } + } + } + } + } + } + }, + "/search": { + "get": { + "summary": "\nSearch for partners\n:param partner_search_param: An instance of partner.search.param\n:return: List of partner.short.info\n", + "parameters": [ + { + "name": "id", + "in": "query", + "required": false, + "allowEmptyValue": false, + "default": null, + "schema": { + "type": "integer" + } + }, + { + "name": "name", + "in": "query", + "required": false, + "allowEmptyValue": false, + "default": null, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "One of the given parameter is not valid" + }, + "401": { + "description": "The user is not authorized. Authentication is required" + }, + "404": { + "description": "Requested resource not found" + }, + "403": { + "description": "You don't have the permission to access the requested resource." + }, + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PartnerShortInfo" + } + } + } + } + } + } + } + } + }, + "openapi": "3.0.0", + "components": { + "schemas": { + "StateInfo": { + "title": "StateInfo", + "type": "object", + "properties": { + "id": { + "title": "Id", + "type": "integer" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": ["id", "name"] + }, + "CountryInfo": { + "title": "CountryInfo", + "type": "object", + "properties": { + "id": { + "title": "Id", + "type": "integer" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": ["id", "name"] + }, + "PartnerInfo": { + "title": "PartnerInfo", + "type": "object", + "properties": { + "id": { + "title": "Id", + "type": "integer" + }, + "name": { + "title": "Name", + "type": "string" + }, + "street": { + "title": "Street", + "type": "string" + }, + "street2": { + "title": "Street2", + "type": "string" + }, + "zip": { + "title": "Zip", + "type": "string" + }, + "city": { + "title": "City", + "type": "string" + }, + "phone": { + "title": "Phone", + "type": "string" + }, + "state_id": { + "$ref": "#/components/schemas/StateInfo" + }, + "country_id": { + "$ref": "#/components/schemas/CountryInfo" + }, + "is_company": { + "title": "Is Company", + "type": "boolean" + } + }, + "required": ["id", "name", "street", "zip", "city", "state_id", "country_id"], + "definitions": { + "StateInfo": { + "title": "StateInfo", + "type": "object", + "properties": { + "id": { + "title": "Id", + "type": "integer" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": ["id", "name"] + }, + "CountryInfo": { + "title": "CountryInfo", + "type": "object", + "properties": { + "id": { + "title": "Id", + "type": "integer" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": ["id", "name"] + } + } + }, + "PartnerShortInfo": { + "title": "PartnerShortInfo", + "type": "object", + "properties": { + "id": { + "title": "Id", + "type": "integer" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": ["id", "name"] + } + }, + "securitySchemes": { + "user": { + "type": "apiKey", + "in": "cookie", + "name": "session_id" + } + } + } +} diff --git a/base_rest_demo/tests/test_openapi.py b/base_rest_demo/tests/test_openapi.py index 1b80d8bc2..08ecd5013 100644 --- a/base_rest_demo/tests/test_openapi.py +++ b/base_rest_demo/tests/test_openapi.py @@ -26,26 +26,25 @@ def _fix_openapi_components(self, openapi_def): for key in unknow_keys: del security_components[key] - def test_partner_api(self): - partner_service = self.private_services_env.component(usage="partner") - openapi_def = partner_service.to_openapi(default_auth="user") + def assertOpenApiDef(self, service, canocincal_json_file, default_auth): + openapi_def = service.to_openapi(default_auth=default_auth) self._fix_openapi_components(openapi_def) - canonical_def = get_canonical_json("partner_api.json") + canonical_def = get_canonical_json(canocincal_json_file) self._fix_server_url(canonical_def) self.assertFalse(jsondiff.diff(openapi_def, canonical_def)) + def test_partner_api(self): + service = self.private_services_env.component(usage="partner") + self.assertOpenApiDef(service, "partner_api.json", "user") + def test_ping_api(self): - ping_service = self.public_services_env.component(usage="ping") - openapi_def = ping_service.to_openapi(default_auth="public") - self._fix_openapi_components(openapi_def) - canonical_def = get_canonical_json("ping_api.json") - self._fix_server_url(canonical_def) - self.assertFalse(jsondiff.diff(openapi_def, canonical_def)) + service = self.public_services_env.component(usage="ping") + self.assertOpenApiDef(service, "ping_api.json", "public") def test_partner_image_api(self): - partner_service = self.private_services_env.component(usage="partner_image") - openapi_def = partner_service.to_openapi(default_auth="user") - self._fix_openapi_components(openapi_def) - canonical_def = get_canonical_json("partner_image_api.json") - self._fix_server_url(canonical_def) - self.assertFalse(jsondiff.diff(openapi_def, canonical_def)) + service = self.private_services_env.component(usage="partner_image") + self.assertOpenApiDef(service, "partner_image_api.json", "user") + + def test_partner_pydantic_api(self): + service = self.new_api_services_env.component(usage="partner_pydantic") + self.assertOpenApiDef(service, "partner_pydantic_api.json", "public") diff --git a/base_rest_pydantic/README.rst b/base_rest_pydantic/README.rst new file mode 100644 index 000000000..cb7b63867 --- /dev/null +++ b/base_rest_pydantic/README.rst @@ -0,0 +1,111 @@ +=================== +Base Rest Datamodel +=================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/14.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-14-0/rest-framework-14-0-base_rest_pydantic + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/271/14.0 + :alt: Try me on Runbot + +|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-block:: 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 smashing 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..0badab3e7 --- /dev/null +++ b/base_rest_pydantic/__manifest__.py @@ -0,0 +1,16 @@ +# 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": "14.0.4.1.0", + "license": "LGPL-3", + "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/rest-framework", + "depends": ["base_rest", "pydantic"], + "data": [], + "demo": [], + "installable": True, +} 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/readme/CONTRIBUTORS.rst b/base_rest_pydantic/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..172b2d223 --- /dev/null +++ b/base_rest_pydantic/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Laurent Mignon diff --git a/base_rest_pydantic/readme/DESCRIPTION.rst b/base_rest_pydantic/readme/DESCRIPTION.rst new file mode 100644 index 000000000..a5311dcef --- /dev/null +++ b/base_rest_pydantic/readme/DESCRIPTION.rst @@ -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.rst b/base_rest_pydantic/readme/USAGE.rst new file mode 100644 index 000000000..47d6506a0 --- /dev/null +++ b/base_rest_pydantic/readme/USAGE.rst @@ -0,0 +1,33 @@ +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-block:: 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..89e5d43b0 --- /dev/null +++ b/base_rest_pydantic/restapi.py @@ -0,0 +1,195 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import _ +from odoo.exceptions import UserError + +from odoo.addons.base_rest import restapi +from odoo.addons.pydantic.models import BaseModel + +from pydantic import ValidationError, validate_model + + +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(_("BadRequest %s") % ve.json(indent=0)) + + def to_response(self, service, result): + # do we really need to validate the instance???? + json_dict = result.dict() + to_validate = ( + json_dict if not result.__config__.orm_mode else result.dict(by_alias=True) + ) + *_, validation_error = validate_model(self._model_cls, to_validate) + if validation_error: + raise SystemError(_("Invalid Response %s") % validation_error) + return json_dict + + def to_openapi_query_parameters(self, servic, spec): + json_schema = self._model_cls.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(spec)}}} + + def to_openapi_responses(self, service, spec): + return { + "200": { + "content": {"application/json": {"schema": self.to_json_schema(spec)}} + } + } + + def to_json_schema(self, spec): + schema = self._model_cls.schema() + schema_name = schema["title"] + if schema_name not in spec.components.schemas: + definitions = schema.get("definitions", {}) + for name, sch in definitions.items(): + if name in spec.components.schemas: + continue + 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(params, "input") + return [super(PydanticModelList, self).from_params(param) for param in params] + + def to_response(self, service, result): + self._do_validate(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, values, direction): + ExceptionClass = UserError if direction == "input" else SystemError + if self._min_items is not None and len(values) < self._min_items: + raise ExceptionClass( + _( + "BadRequest: Not enough items in the list (%s < %s)" + % (len(values), self._min_items) + ) + ) + if self._max_items is not None and len(values) > self._max_items: + raise ExceptionClass( + _( + "BadRequest: Too many items in the list (%s > %s)" + % (len(values), 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(spec)}}} + + def to_openapi_responses(self, service, spec): + return { + "200": { + "content": {"application/json": {"schema": self.to_json_schema(spec)}} + } + } + + def to_json_schema(self, spec): + json_schema = super().to_json_schema(spec) + 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 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/base_rest_pydantic/static/description/index.html b/base_rest_pydantic/static/description/index.html new file mode 100644 index 000000000..b42fa6eb4 --- /dev/null +++ b/base_rest_pydantic/static/description/index.html @@ -0,0 +1,455 @@ + + + + + + +Base Rest Datamodel + + + +
+

Base Rest Datamodel

+ + +

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

+

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 smashing 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..db74f22f7 --- /dev/null +++ b/base_rest_pydantic/tests/test_from_params.py @@ -0,0 +1,49 @@ +# Copyright 2021 Wakari SRL +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +from typing import Type + +import mock + +from odoo.exceptions import UserError + +from odoo.addons.pydantic import models +from odoo.addons.pydantic.tests import common + +from .. import restapi + + +class TestPydantic(common.PydanticRegistryCase): + def setUp(self): + super(TestPydantic, self).setUp() + + class Model1(models.BaseModel): + name: str + description: str = None + + self._build_pydantic_classes(Model1) + self.Model1: models.BaseModel = Model1 + + def _from_params( + self, pydantic_cls: Type[models.BaseModel], params: dict, **kwargs + ): + restapi_pydantic = restapi.PydanticModel(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"value_error.missing" + with self.assertRaisesRegex(UserError, msg): + self._from_params(self.Model1, {"description": "Instance 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..75fbbbe03 --- /dev/null +++ b/base_rest_pydantic/tests/test_response.py @@ -0,0 +1,43 @@ +# Copyright 2021 Wakari SRL +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +from typing import List + +import mock + +from odoo.addons.pydantic import models +from odoo.addons.pydantic.tests import common + +from .. import restapi + + +class TestPydantic(common.PydanticRegistryCase): + def _to_response(self, instance: models.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[models.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(models.BaseModel): + name: str + + self._build_pydantic_classes(Model1) + instance = Model1(name="Instance 1") + res = self._to_response(instance) + self.assertEqual(res["name"], instance.name) + + def test_to_response_list(self): + class Model1(models.BaseModel): + name: str + + self._build_pydantic_classes(Model1) + 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/setup/base_rest_pydantic/odoo/addons/base_rest_pydantic b/setup/base_rest_pydantic/odoo/addons/base_rest_pydantic new file mode 120000 index 000000000..07264e9f8 --- /dev/null +++ b/setup/base_rest_pydantic/odoo/addons/base_rest_pydantic @@ -0,0 +1 @@ +../../../../base_rest_pydantic \ No newline at end of file diff --git a/setup/base_rest_pydantic/setup.py b/setup/base_rest_pydantic/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/base_rest_pydantic/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) From 77224447814489c841d3568de934f64717ef0d28 Mon Sep 17 00:00:00 2001 From: Maksym Yankin Date: Wed, 24 Nov 2021 16:47:52 +0200 Subject: [PATCH 3/4] [15.0][MIG] base_rest_pydantic: Migration to 15.0 --- base_rest_pydantic/README.rst | 12 ++++++------ base_rest_pydantic/__manifest__.py | 9 +++++++-- ...ase_rest_datamodel.pot => base_rest_pydantic.pot} | 12 ++++++------ base_rest_pydantic/restapi.py | 5 +++-- base_rest_pydantic/static/description/index.html | 12 ++++++------ requirements.txt | 1 + 6 files changed, 29 insertions(+), 22 deletions(-) rename base_rest_pydantic/i18n/{base_rest_datamodel.pot => base_rest_pydantic.pot} (64%) diff --git a/base_rest_pydantic/README.rst b/base_rest_pydantic/README.rst index cb7b63867..d717c98c0 100644 --- a/base_rest_pydantic/README.rst +++ b/base_rest_pydantic/README.rst @@ -1,5 +1,5 @@ =================== -Base Rest Datamodel +Base Rest Pydantic =================== .. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! @@ -14,13 +14,13 @@ Base Rest Datamodel :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/14.0/base_rest_pydantic + :target: https://github.com/OCA/rest-framework/tree/15.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-14-0/rest-framework-14-0-base_rest_pydantic + :target: https://translation.odoo-community.org/projects/rest-framework-15-0/rest-framework-15-0-base_rest_pydantic :alt: Translate me on Weblate .. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png - :target: https://runbot.odoo-community.org/runbot/271/14.0 + :target: https://runbot.odoo-community.org/runbot/271/15.0 :alt: Try me on Runbot |badge1| |badge2| |badge3| |badge4| |badge5| @@ -76,7 +76,7 @@ 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 smashing it by providing a detailed and welcomed -`feedback `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -106,6 +106,6 @@ 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. +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/__manifest__.py b/base_rest_pydantic/__manifest__.py index 0badab3e7..278d7461f 100644 --- a/base_rest_pydantic/__manifest__.py +++ b/base_rest_pydantic/__manifest__.py @@ -2,15 +2,20 @@ # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) { - "name": "Base Rest Datamodel", + "name": "Base Rest Pydantic", "summary": """ Pydantic binding for base_rest""", - "version": "14.0.4.1.0", + "version": "15.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", "pydantic"], "data": [], "demo": [], + "external_dependencies": { + "python": [ + "pydantic", + ] + }, "installable": True, } diff --git a/base_rest_pydantic/i18n/base_rest_datamodel.pot b/base_rest_pydantic/i18n/base_rest_pydantic.pot similarity index 64% rename from base_rest_pydantic/i18n/base_rest_datamodel.pot rename to base_rest_pydantic/i18n/base_rest_pydantic.pot index 7dc461d5e..eb668b469 100644 --- a/base_rest_pydantic/i18n/base_rest_datamodel.pot +++ b/base_rest_pydantic/i18n/base_rest_pydantic.pot @@ -1,10 +1,10 @@ # Translation of Odoo Server. # This file contains the translation of the following modules: -# * base_rest_datamodel +# * base_rest_pydantic # msgid "" msgstr "" -"Project-Id-Version: Odoo Server 14.0\n" +"Project-Id-Version: Odoo Server 15.0\n" "Report-Msgid-Bugs-To: \n" "Last-Translator: \n" "Language-Team: \n" @@ -13,14 +13,14 @@ msgstr "" "Content-Transfer-Encoding: \n" "Plural-Forms: \n" -#. module: base_rest_datamodel -#: code:addons/base_rest_datamodel/restapi.py:0 +#. module: base_rest_pydantic +#: code:addons/base_rest_pydantic/restapi.py:0 #, python-format msgid "BadRequest %s" msgstr "" -#. module: base_rest_datamodel -#: code:addons/base_rest_datamodel/restapi.py:0 +#. module: base_rest_pydantic +#: code:addons/base_rest_pydantic/restapi.py:0 #, python-format msgid "Invalid Response %s" msgstr "" diff --git a/base_rest_pydantic/restapi.py b/base_rest_pydantic/restapi.py index 89e5d43b0..bd20a3e94 100644 --- a/base_rest_pydantic/restapi.py +++ b/base_rest_pydantic/restapi.py @@ -1,5 +1,7 @@ # Copyright 2021 ACSONE SA/NV # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +# flake8: noqa +from pydantic import ValidationError, validate_model from odoo import _ from odoo.exceptions import UserError @@ -7,8 +9,6 @@ from odoo.addons.base_rest import restapi from odoo.addons.pydantic.models import BaseModel -from pydantic import ValidationError, validate_model - def replace_ref_in_schema(item, original_schema): if isinstance(item, list): @@ -149,6 +149,7 @@ def to_response(self, service, result): def to_openapi_query_parameters(self, service, spec): raise NotImplementedError("List are not (?yet?) supported as query paramters") + # pylint: disable=W8115,W8120 def _do_validate(self, values, direction): ExceptionClass = UserError if direction == "input" else SystemError if self._min_items is not None and len(values) < self._min_items: diff --git a/base_rest_pydantic/static/description/index.html b/base_rest_pydantic/static/description/index.html index b42fa6eb4..4311de44b 100644 --- a/base_rest_pydantic/static/description/index.html +++ b/base_rest_pydantic/static/description/index.html @@ -4,7 +4,7 @@ -Base Rest Datamodel +Base Rest Pydantic -
-

Base Rest Datamodel

+
+

Base Rest Pydantic

-

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

+

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

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

Table of contents

@@ -422,7 +422,7 @@

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 smashing it by providing a detailed and welcomed -feedback.

+feedback.

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

@@ -446,7 +446,7 @@

Maintainers

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.

+

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/requirements.txt b/requirements.txt index 690f7ff5f..6a526e918 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ jsondiff marshmallow marshmallow-objects>=2.0.0 parse-accept-language +pydantic pyquerystring From 128bafb793e0175ea4d0cc9769c0090c9d782ed6 Mon Sep 17 00:00:00 2001 From: Maksym Yankin Date: Wed, 1 Dec 2021 16:07:37 +0200 Subject: [PATCH 4/4] Add temporary dependency --- test-requirements.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 test-requirements.txt diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 000000000..9f404d79c --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +odoo-addon-pydantic @ git+https://github.com/OCA/rest-framework@refs/pull/223/head#subdirectory=setup/pydantic