diff --git a/base_rest/apispec/rest_method_param_plugin.py b/base_rest/apispec/rest_method_param_plugin.py index ed8e01acf..84464644c 100644 --- a/base_rest/apispec/rest_method_param_plugin.py +++ b/base_rest/apispec/rest_method_param_plugin.py @@ -48,12 +48,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"]) @@ -68,5 +70,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 18970c839..05f3c78cc 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") def _do_validate(self, service, data, direction): diff --git a/base_rest/tests/test_cerberus_list_validator.py b/base_rest/tests/test_cerberus_list_validator.py index 4357366be..da39dcb24 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 6596ab604..aeed44926 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 dc764c872..c5ba13639 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": { 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 e570d0efd..eab0c229b 100644 --- a/base_rest_demo/__manifest__.py +++ b/base_rest_demo/__manifest__.py @@ -11,9 +11,17 @@ "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", + "extendable", + ], "data": [], "demo": [], - "external_dependencies": {"python": ["jsondiff"]}, + "external_dependencies": { + "python": ["jsondiff", "extendable-pydantic", "pydantic"] + }, "installable": True, } 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..0c9adccb7 --- /dev/null +++ b/base_rest_demo/pydantic_models/naive_orm_model.py @@ -0,0 +1,14 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from extendable_pydantic import ExtendableModelMeta + +from odoo.addons.pydantic import utils + +from pydantic import BaseModel + + +class NaiveOrmModel(BaseModel, metaclass=ExtendableModelMeta): + 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..a5770d11c --- /dev/null +++ b/base_rest_demo/pydantic_models/partner_search_param.py @@ -0,0 +1,12 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from extendable_pydantic import ExtendableModelMeta + +from pydantic import BaseModel + + +class PartnerSearchParam(BaseModel, metaclass=ExtendableModelMeta): + + 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..52c17833d 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.extendable.tests.common import ExtendableMixin DATA_DIR = os.path.join(os.path.realpath(os.path.dirname(__file__)), "data") -class CommonCase(BaseRestCase): +class CommonCase(BaseRestCase, ExtendableMixin): @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.setUpExtendable() + + # pylint: disable=W8106 + def setUp(self): + # resolve an inheritance issue (common.TransactionCase does not call + # super) + BaseRestCase.setUp(self) + ExtendableMixin.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..b061642ac --- /dev/null +++ b/base_rest_pydantic/__manifest__.py @@ -0,0 +1,21 @@ +# 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"], + "data": [], + "demo": [], + "installable": True, + "external_dependencies": { + "python": [ + "pydantic", + ] + }, +} 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..7e49f004d --- /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 pydantic import BaseModel, 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 + 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(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 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..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..052ea3d33 --- /dev/null +++ b/base_rest_pydantic/tests/test_from_params.py @@ -0,0 +1,46 @@ +# 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.tests.common import SavepointCase + +from pydantic import BaseModel + +from .. import restapi + + +class TestPydantic(SavepointCase): + def setUp(self): + super(TestPydantic, self).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 = 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..83a1297a4 --- /dev/null +++ b/base_rest_pydantic/tests/test_response.py @@ -0,0 +1,42 @@ +# Copyright 2021 Wakari SRL +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +from typing import List + +import mock + +from odoo.tests.common import SavepointCase + +from pydantic import BaseModel + +from .. import restapi + + +class TestPydantic(SavepointCase): + 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_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/README.rst b/pydantic/README.rst new file mode 100644 index 000000000..0d6a5a9df --- /dev/null +++ b/pydantic/README.rst @@ -0,0 +1,128 @@ +======== +Pydantic +======== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! 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/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-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 provides a utility method that can be used to map odoo record +to a `Pydantic model `_. + +If you need to make your Pydantic models extendable at runtime, takes a look +at the python package `extendable-pydantic `_ +and the odoo addon `extendable `_ + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +To support pydantic models that map to Odoo models, Pydantic model instances can +be created from arbitrary odoo model instances by mapping fields from odoo +models to fields defined by the pydantic model. To ease the mapping, the addon +provide a utility class `odoo.addons.pydantic.utils.GenericOdooGetter`. + +.. code-block:: python + + import pydantic + from odoo.addons.pydantic import utils + + class Group(pydantic.BaseModel): + name: str + + class Config: + orm_mode = True + getter_dict = utils.GenericOdooGetter + + class UserInfo(pydantic.BaseModel): + name: str + groups: List[Group] = pydantic.Field(alias="groups_id") + + class Config: + orm_mode = True + getter_dict = utils.GenericOdooGetter + + user = self.env.user + user_info = UserInfo.from_orm(user) + +See the official `Pydantic documentation`_ to discover all the available functionalities. + +.. _`Pydantic documentation`: https://pydantic-docs.helpmanual.io/ + +Known issues / Roadmap +====================== + +The `roadmap `_ +and `known issues `_ can +be found on GitHub. + +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. + +.. |maintainer-lmignon| image:: https://github.com/lmignon.png?size=40px + :target: https://github.com/lmignon + :alt: lmignon + +Current `maintainer `__: + +|maintainer-lmignon| + +This module is part of the `OCA/rest-framework `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/pydantic/__init__.py b/pydantic/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pydantic/__manifest__.py b/pydantic/__manifest__.py new file mode 100644 index 000000000..8c4a773dc --- /dev/null +++ b/pydantic/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +{ + "name": "Pydantic", + "summary": """ + Utility addon to ease mapping between Pydantic and Odoo models""", + "version": "14.0.1.0.0", + "development_status": "Beta", + "license": "LGPL-3", + "maintainers": ["lmignon"], + "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/rest-framework", + "depends": [], + "data": [], + "demo": [], + "external_dependencies": { + "python": ["pydantic", "contextvars", "typing-extensions"] + }, + "installable": True, +} diff --git a/pydantic/readme/CONTRIBUTORS.rst b/pydantic/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..172b2d223 --- /dev/null +++ b/pydantic/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Laurent Mignon diff --git a/pydantic/readme/DESCRIPTION.rst b/pydantic/readme/DESCRIPTION.rst new file mode 100644 index 000000000..f401c8037 --- /dev/null +++ b/pydantic/readme/DESCRIPTION.rst @@ -0,0 +1,6 @@ +This addon provides a utility method that can be used to map odoo record +to a `Pydantic model `_. + +If you need to make your Pydantic models extendable at runtime, takes a look +at the python package `extendable-pydantic `_ +and the odoo addon `extendable `_ diff --git a/pydantic/readme/ROADMAP.rst b/pydantic/readme/ROADMAP.rst new file mode 100644 index 000000000..0778bc3aa --- /dev/null +++ b/pydantic/readme/ROADMAP.rst @@ -0,0 +1,3 @@ +The `roadmap `_ +and `known issues `_ can +be found on GitHub. diff --git a/pydantic/readme/USAGE.rst b/pydantic/readme/USAGE.rst new file mode 100644 index 000000000..f52f1c594 --- /dev/null +++ b/pydantic/readme/USAGE.rst @@ -0,0 +1,31 @@ +To support pydantic models that map to Odoo models, Pydantic model instances can +be created from arbitrary odoo model instances by mapping fields from odoo +models to fields defined by the pydantic model. To ease the mapping, the addon +provide a utility class `odoo.addons.pydantic.utils.GenericOdooGetter`. + +.. code-block:: python + + import pydantic + from odoo.addons.pydantic import utils + + class Group(pydantic.BaseModel): + name: str + + class Config: + orm_mode = True + getter_dict = utils.GenericOdooGetter + + class UserInfo(pydantic.BaseModel): + name: str + groups: List[Group] = pydantic.Field(alias="groups_id") + + class Config: + orm_mode = True + getter_dict = utils.GenericOdooGetter + + user = self.env.user + user_info = UserInfo.from_orm(user) + +See the official `Pydantic documentation`_ to discover all the available functionalities. + +.. _`Pydantic documentation`: https://pydantic-docs.helpmanual.io/ diff --git a/pydantic/static/description/icon.png b/pydantic/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/pydantic/static/description/icon.png differ diff --git a/pydantic/static/description/index.html b/pydantic/static/description/index.html new file mode 100644 index 000000000..05c239190 --- /dev/null +++ b/pydantic/static/description/index.html @@ -0,0 +1,463 @@ + + + + + + +Pydantic + + + +
+

Pydantic

+ + +

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

+

This addon provides a utility method that can be used to map odoo record +to a Pydantic model.

+

If you need to make your Pydantic models extendable at runtime, takes a look +at the python package extendable-pydantic +and the odoo addon extendable

+

Table of contents

+ +
+

Usage

+

To support pydantic models that map to Odoo models, Pydantic model instances can +be created from arbitrary odoo model instances by mapping fields from odoo +models to fields defined by the pydantic model. To ease the mapping, the addon +provide a utility class odoo.addons.pydantic.utils.GenericOdooGetter.

+
+import pydantic
+from odoo.addons.pydantic import utils
+
+class Group(pydantic.BaseModel):
+    name: str
+
+    class Config:
+        orm_mode = True
+        getter_dict = utils.GenericOdooGetter
+
+class UserInfo(pydantic.BaseModel):
+    name: str
+    groups: List[Group] = pydantic.Field(alias="groups_id")
+
+    class Config:
+        orm_mode = True
+        getter_dict = utils.GenericOdooGetter
+
+user = self.env.user
+user_info = UserInfo.from_orm(user)
+
+

See the official Pydantic documentation to discover all the available functionalities.

+
+
+

Known issues / Roadmap

+

The roadmap +and known issues can +be found on GitHub.

+
+
+

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.

+

Current maintainer:

+

lmignon

+

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

+

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

+
+
+
+ + diff --git a/pydantic/utils.py b/pydantic/utils.py new file mode 100644 index 000000000..1be3434d3 --- /dev/null +++ b/pydantic/utils.py @@ -0,0 +1,68 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +from typing import Any + +from odoo import fields, models + +from pydantic.utils import GetterDict + + +class GenericOdooGetter(GetterDict): + """A generic GetterDict for Odoo models + + The getter take care of casting one2many and many2many + field values to python list to allow the from_orm method from + pydantic class to work on odoo models. This getter is to specify + into the pydantic config. + + Usage: + + .. code-block:: python + + import pydantic + from odoo.addons.pydantic import models, utils + + class Group(models.BaseModel): + name: str + + class Config: + orm_mode = True + getter_dict = utils.GenericOdooGetter + + class UserInfo(models.BaseModel): + name: str + groups: List[Group] = pydantic.Field(alias="groups_id") + + class Config: + orm_mode = True + getter_dict = utils.GenericOdooGetter + + user = self.env.user + user_info = UserInfo.from_orm(user) + + To avoid having to repeat the specific configuration required for the + `from_orm` method into each pydantic model, "odoo_orm_mode" can be used + as parent via the `_inherit` attribute + + """ + + def get(self, key: Any, default: Any = None) -> Any: + res = getattr(self._obj, key, default) + if isinstance(self._obj, models.BaseModel) and key in self._obj._fields: + field = self._obj._fields[key] + if res is False and field.type != "boolean": + return None + if field.type == "date" and not res: + return None + if field.type == "datetime": + if not res: + return None + # Get the timestamp converted to the client's timezone. + # This call also add the tzinfo into the datetime object + return fields.Datetime.context_timestamp(self._obj, res) + if field.type == "many2one" and not res: + return None + if field.type in ["one2many", "many2many"]: + return list(res) + return res diff --git a/requirements.txt b/requirements.txt index c24156a6e..d338d1a40 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,11 +2,15 @@ apispec apispec>=4.0.0 cerberus +contextvars;python_version<"3.7" extendable +extendable-pydantic graphene graphql_server jsondiff marshmallow marshmallow-objects>=2.0.0 parse-accept-language +pydantic pyquerystring +typing-extensions 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, +) diff --git a/setup/pydantic/odoo/addons/pydantic b/setup/pydantic/odoo/addons/pydantic new file mode 120000 index 000000000..775eac291 --- /dev/null +++ b/setup/pydantic/odoo/addons/pydantic @@ -0,0 +1 @@ +../../../../pydantic \ No newline at end of file diff --git a/setup/pydantic/setup.py b/setup/pydantic/setup.py new file mode 100644 index 000000000..3665ea16f --- /dev/null +++ b/setup/pydantic/setup.py @@ -0,0 +1,10 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon={ + "external_dependencies_override": { + "python": {"contextvars": 'contextvars;python_version<"3.7"'} + } + }, +)