diff --git a/endpoint/README.rst b/endpoint/README.rst new file mode 100644 index 00000000..28296998 --- /dev/null +++ b/endpoint/README.rst @@ -0,0 +1,105 @@ +======== +Endpoint +======== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:5b9d2b71d09ff066cd8d17ae88885c36a1eca061387e2a18d62d526b4844edfc + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fweb--api-lightgray.png?logo=github + :target: https://github.com/OCA/web-api/tree/18.0/endpoint + :alt: OCA/web-api +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-api-18-0/web-api-18-0-endpoint + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/web-api&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Provide an endpoint framework allowing users to define their own custom +endpoint. + +Thanks to endpoint mixin the endpoint records are automatically +registered as real Odoo routes. + +You can easily code what you want in the code snippet. + +NOTE: for security reasons any kind of RPC call is blocked on endpoint +records. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Go to "Technical -> Endpoints" and create a new endpoint. + +Known issues / Roadmap +====================== + +- add validation of request data +- add api docs generation +- handle multiple routes per endpoint + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Camptocamp + +Contributors +------------ + +- Simone Orsi + +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-simahawk| image:: https://github.com/simahawk.png?size=40px + :target: https://github.com/simahawk + :alt: simahawk + +Current `maintainer `__: + +|maintainer-simahawk| + +This module is part of the `OCA/web-api `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/endpoint/__init__.py b/endpoint/__init__.py new file mode 100644 index 00000000..91c5580f --- /dev/null +++ b/endpoint/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/endpoint/__manifest__.py b/endpoint/__manifest__.py new file mode 100644 index 00000000..03f132e1 --- /dev/null +++ b/endpoint/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2021 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "Endpoint", + "summary": """Provide custom endpoint machinery.""", + "version": "18.0.1.0.0", + "license": "LGPL-3", + "development_status": "Beta", + "author": "Camptocamp,Odoo Community Association (OCA)", + "maintainers": ["simahawk"], + "website": "https://github.com/OCA/web-api", + "depends": ["endpoint_route_handler", "rpc_helper"], + "data": [ + "data/server_action.xml", + "security/ir.model.access.csv", + "security/ir_rule.xml", + "views/endpoint_view.xml", + ], + "demo": [ + "demo/endpoint_demo.xml", + ], +} diff --git a/endpoint/controllers/__init__.py b/endpoint/controllers/__init__.py new file mode 100644 index 00000000..12a7e529 --- /dev/null +++ b/endpoint/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/endpoint/controllers/main.py b/endpoint/controllers/main.py new file mode 100644 index 00000000..afae8a10 --- /dev/null +++ b/endpoint/controllers/main.py @@ -0,0 +1,54 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +import json + +from werkzeug.exceptions import NotFound + +from odoo import http +from odoo.http import Response, request + + +class EndpointControllerMixin: + def _handle_endpoint(self, env, model, endpoint_route, **params): + endpoint = self._find_endpoint(env, model, endpoint_route) + if not endpoint: + raise NotFound() + endpoint._validate_request(request) + result = endpoint._handle_request(request) + return self._handle_result(result) + + def _handle_result(self, result): + response = result.get("response") + if isinstance(response, Response): + # Full response already provided + return response + payload = result.get("payload", "") + status = result.get("status_code", 200) + headers = result.get("headers", {}) + return self._make_json_response(payload, headers=headers, status=status) + + # TODO: probably not needed anymore as controllers are automatically registered + def _make_json_response(self, payload, headers=None, status=200, **kw): + # TODO: guess out type? + data = json.dumps(payload) + if headers is None: + headers = {} + headers["Content-Type"] = "application/json" + resp = request.make_response(data, headers=headers) + resp.status = str(status) + return resp + + def _find_endpoint(self, env, model, endpoint_route): + return env[model]._find_endpoint(endpoint_route) + + def auto_endpoint(self, model, endpoint_route, **params): + """Default method to handle auto-generated endpoints""" + env = request.env + return self._handle_endpoint(env, model, endpoint_route, **params) + + +class EndpointController(http.Controller, EndpointControllerMixin): + pass diff --git a/endpoint/data/server_action.xml b/endpoint/data/server_action.xml new file mode 100644 index 00000000..838d9d73 --- /dev/null +++ b/endpoint/data/server_action.xml @@ -0,0 +1,13 @@ + + + + Sync registry + ir.actions.server + + + action + code + records.write({"registry_sync": True}) + + + diff --git a/endpoint/demo/endpoint_demo.xml b/endpoint/demo/endpoint_demo.xml new file mode 100644 index 00000000..290cfe30 --- /dev/null +++ b/endpoint/demo/endpoint_demo.xml @@ -0,0 +1,84 @@ + + + + Demo Endpoint 1 + /demo/one + GET + code + +result = {"response": Response("ok")} + + + + + Demo Endpoint 2 + /demo/as_demo_user + GET + public + + code + +result = {"response": Response("My name is: " + user.name)} + + + + + Demo Endpoint 3 + /demo/json_data + GET + public + + code + +result = {"payload": {"a": 1, "b": 2}} + + + + + Demo Endpoint 4 + /demo/raise_not_found + GET + public + + code + +raise werkzeug.exceptions.NotFound() + + + + + Demo Endpoint 5 + /demo/raise_validation_error + GET + public + + code + +raise exceptions.ValidationError("Sorry, you cannot do this!") + + + + + Demo Endpoint 6 + /demo/value_from_request + GET + public + + code + +result = {"response": Response(request.params.get("your_name", ""))} + + + + + Demo Endpoint 7 + /demo/bad_method + GET + code + public + + +result = {"payload": "Method used:" + request.httprequest.method} + + + diff --git a/endpoint/i18n/endpoint.pot b/endpoint/i18n/endpoint.pot new file mode 100644 index 00000000..4843f0e7 --- /dev/null +++ b/endpoint/i18n/endpoint.pot @@ -0,0 +1,266 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * endpoint +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.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: endpoint +#. odoo-python +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "'Exec as user' is mandatory for public endpoints." +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__active +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__active +msgid "Active" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_endpoint_search_view +msgid "All" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_endpoint_search_view +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Archived" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Auth" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__auth_type +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__auth_type +msgid "Auth Type" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Code" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Code Help" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__code_snippet +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__code_snippet +msgid "Code Snippet" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__code_snippet_docs +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__code_snippet_docs +msgid "Code Snippet Docs" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__company_id +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__company_id +msgid "Company" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__create_uid +msgid "Created by" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__create_date +msgid "Created on" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__csrf +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__csrf +msgid "Csrf" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__display_name +msgid "Display Name" +msgstr "" + +#. module: endpoint +#: model:ir.model,name:endpoint.model_endpoint_endpoint +msgid "Endpoint" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__endpoint_hash +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__endpoint_hash +msgid "Endpoint Hash" +msgstr "" + +#. module: endpoint +#: model:ir.model,name:endpoint.model_endpoint_mixin +msgid "Endpoint mixin" +msgstr "" + +#. module: endpoint +#: model:ir.actions.act_window,name:endpoint.endpoint_endpoint_act_window +#: model:ir.ui.menu,name:endpoint.endpoint_endpoint_menu +msgid "Endpoints" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__exec_as_user_id +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__exec_as_user_id +msgid "Exec As User" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__exec_mode +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__exec_mode +msgid "Exec Mode" +msgstr "" + +#. module: endpoint +#. odoo-python +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "Exec mode is set to `Code`: you must provide a piece of code" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__id +msgid "ID" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__endpoint_hash +#: model:ir.model.fields,help:endpoint.field_endpoint_mixin__endpoint_hash +msgid "Identify the route with its main params" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__write_date +msgid "Last Updated on" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Main" +msgstr "" + +#. module: endpoint +#. odoo-python +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "Missing handler for exec mode %s" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__name +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__name +msgid "Name" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__registry_sync +#: model:ir.model.fields,help:endpoint.field_endpoint_mixin__registry_sync +msgid "" +"ON: the record has been modified and registry was not notified.\n" +"No change will be active until this flag is set to false via proper action.\n" +"\n" +"OFF: record in line with the registry, nothing to do." +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__registry_sync +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__registry_sync +msgid "Registry Sync" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "" +"Registry out of sync. Likely the record has been modified but not sync'ed " +"with the routing registry." +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Request" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__request_content_type +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__request_content_type +msgid "Request Content Type" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__request_method +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__request_method +msgid "Request Method" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route +msgid "Route" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route_group +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route_group +msgid "Route Group" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route_type +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route_type +msgid "Route Type" +msgstr "" + +#. module: endpoint +#: model:ir.actions.server,name:endpoint.server_action_registry_sync +msgid "Sync registry" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_endpoint_search_view +msgid "To sync" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "" +"Use the action \"Sync registry\" to make changes effective once you are done" +" with edits and creates." +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__route_group +#: model:ir.model.fields,help:endpoint.field_endpoint_mixin__route_group +msgid "Use this to classify routes together" +msgstr "" + +#. module: endpoint +#. odoo-python +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "code_snippet should return a dict into `result` variable." +msgstr "" diff --git a/endpoint/i18n/fr.po b/endpoint/i18n/fr.po new file mode 100644 index 00000000..fdffb787 --- /dev/null +++ b/endpoint/i18n/fr.po @@ -0,0 +1,272 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * endpoint +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" + +#. module: endpoint +#. odoo-python +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "'Exec as user' is mandatory for public endpoints." +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__active +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__active +msgid "Active" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_endpoint_search_view +msgid "All" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_endpoint_search_view +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Archived" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Auth" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__auth_type +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__auth_type +msgid "Auth Type" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Code" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Code Help" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__code_snippet +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__code_snippet +msgid "Code Snippet" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__code_snippet_docs +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__code_snippet_docs +msgid "Code Snippet Docs" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__company_id +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__company_id +msgid "Company" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__create_uid +msgid "Created by" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__create_date +msgid "Created on" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__csrf +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__csrf +msgid "Csrf" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__display_name +msgid "Display Name" +msgstr "" + +#. module: endpoint +#: model:ir.model,name:endpoint.model_endpoint_endpoint +msgid "Endpoint" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__endpoint_hash +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__endpoint_hash +msgid "Endpoint Hash" +msgstr "" + +#. module: endpoint +#: model:ir.model,name:endpoint.model_endpoint_mixin +msgid "Endpoint mixin" +msgstr "" + +#. module: endpoint +#: model:ir.actions.act_window,name:endpoint.endpoint_endpoint_act_window +#: model:ir.ui.menu,name:endpoint.endpoint_endpoint_menu +msgid "Endpoints" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__exec_as_user_id +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__exec_as_user_id +msgid "Exec As User" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__exec_mode +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__exec_mode +msgid "Exec Mode" +msgstr "" + +#. module: endpoint +#. odoo-python +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "Exec mode is set to `Code`: you must provide a piece of code" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__id +msgid "ID" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__endpoint_hash +#: model:ir.model.fields,help:endpoint.field_endpoint_mixin__endpoint_hash +msgid "Identify the route with its main params" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint____last_update +msgid "Last Modified on" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__write_date +msgid "Last Updated on" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Main" +msgstr "" + +#. module: endpoint +#. odoo-python +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "Missing handler for exec mode %s" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__name +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__name +msgid "Name" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__registry_sync +#: model:ir.model.fields,help:endpoint.field_endpoint_mixin__registry_sync +msgid "" +"ON: the record has been modified and registry was not notified.\n" +"No change will be active until this flag is set to false via proper action.\n" +"\n" +"OFF: record in line with the registry, nothing to do." +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__registry_sync +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__registry_sync +msgid "Registry Sync" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "" +"Registry out of sync. Likely the record has been modified but not sync'ed " +"with the routing registry." +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Request" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__request_content_type +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__request_content_type +msgid "Request Content Type" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__request_method +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__request_method +msgid "Request Method" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route +msgid "Route" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route_group +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route_group +msgid "Route Group" +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route_type +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route_type +msgid "Route Type" +msgstr "" + +#. module: endpoint +#: model:ir.actions.server,name:endpoint.server_action_registry_sync +msgid "Sync registry" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_endpoint_search_view +msgid "To sync" +msgstr "" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "" +"Use the action \"Sync registry\" to make changes effective once you are done " +"with edits and creates." +msgstr "" + +#. module: endpoint +#: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__route_group +#: model:ir.model.fields,help:endpoint.field_endpoint_mixin__route_group +msgid "Use this to classify routes together" +msgstr "" + +#. module: endpoint +#. odoo-python +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "code_snippet should return a dict into `result` variable." +msgstr "" diff --git a/endpoint/i18n/it.po b/endpoint/i18n/it.po new file mode 100644 index 00000000..3d545274 --- /dev/null +++ b/endpoint/i18n/it.po @@ -0,0 +1,284 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * endpoint +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-07-08 08:59+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.6.2\n" + +#. module: endpoint +#. odoo-python +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "'Exec as user' is mandatory for public endpoints." +msgstr "'Esegui come utente' è obbligatorio per gli endpoint pubblici." + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__active +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__active +msgid "Active" +msgstr "Attivo" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_endpoint_search_view +msgid "All" +msgstr "Tutti" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_endpoint_search_view +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Archived" +msgstr "In archivio" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Auth" +msgstr "Autrizzazione" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__auth_type +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__auth_type +msgid "Auth Type" +msgstr "Tipo autorizzazione" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Code" +msgstr "Codice" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Code Help" +msgstr "Aiuto codice" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__code_snippet +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__code_snippet +msgid "Code Snippet" +msgstr "Esempio codice" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__code_snippet_docs +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__code_snippet_docs +msgid "Code Snippet Docs" +msgstr "Documenti esempio codice" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__company_id +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__company_id +msgid "Company" +msgstr "Azienda" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__create_uid +msgid "Created by" +msgstr "Creato da" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__create_date +msgid "Created on" +msgstr "Creato il" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__csrf +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__csrf +msgid "Csrf" +msgstr "CSRF" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. module: endpoint +#: model:ir.model,name:endpoint.model_endpoint_endpoint +msgid "Endpoint" +msgstr "Endpoint" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__endpoint_hash +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__endpoint_hash +msgid "Endpoint Hash" +msgstr "Hash endpoint" + +#. module: endpoint +#: model:ir.model,name:endpoint.model_endpoint_mixin +msgid "Endpoint mixin" +msgstr "Mixin endpoint" + +#. module: endpoint +#: model:ir.actions.act_window,name:endpoint.endpoint_endpoint_act_window +#: model:ir.ui.menu,name:endpoint.endpoint_endpoint_menu +msgid "Endpoints" +msgstr "Endpoint" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__exec_as_user_id +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__exec_as_user_id +msgid "Exec As User" +msgstr "Esegui come utente" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__exec_mode +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__exec_mode +msgid "Exec Mode" +msgstr "Modo esecuzione" + +#. module: endpoint +#. odoo-python +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "Exec mode is set to `Code`: you must provide a piece of code" +msgstr "" +"Im modo di esecuzione è impostato a `Codice`: bisogna fornire del codice" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__id +msgid "ID" +msgstr "ID" + +#. module: endpoint +#: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__endpoint_hash +#: model:ir.model.fields,help:endpoint.field_endpoint_mixin__endpoint_hash +msgid "Identify the route with its main params" +msgstr "Identifica la rotta con questi parametri principali" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint____last_update +msgid "Last Modified on" +msgstr "Ultima modifica il" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__write_uid +msgid "Last Updated by" +msgstr "Ultimo aggiornamento di" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__write_date +msgid "Last Updated on" +msgstr "Ultimo aggiornamento il" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Main" +msgstr "Principale" + +#. module: endpoint +#. odoo-python +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "Missing handler for exec mode %s" +msgstr "Gestore non disponibile per il modo esecuzione %s" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__name +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__name +msgid "Name" +msgstr "Nome" + +#. module: endpoint +#: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__registry_sync +#: model:ir.model.fields,help:endpoint.field_endpoint_mixin__registry_sync +msgid "" +"ON: the record has been modified and registry was not notified.\n" +"No change will be active until this flag is set to false via proper action.\n" +"\n" +"OFF: record in line with the registry, nothing to do." +msgstr "" +"Acceso: il record è stato modificato e il registro non è stato notificato.\n" +"Nessuna modifica sarà attiva finchè questa opzione è impostata a falso " +"attraverso un'azione opportuna.\n" +"\n" +"Spento: record allineato con il registro, non c'è niente da fare." + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__registry_sync +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__registry_sync +msgid "Registry Sync" +msgstr "Sincro registro" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "" +"Registry out of sync. Likely the record has been modified but not sync'ed " +"with the routing registry." +msgstr "" +"Registro fuori sinc. Probabilmente il record è stato modificto ma non " +"sincronizzato con il registro di instradamento." + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "Request" +msgstr "Richiesta" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__request_content_type +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__request_content_type +msgid "Request Content Type" +msgstr "Tipo contenuto richiesta" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__request_method +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__request_method +msgid "Request Method" +msgstr "Metodo richiesta" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route +msgid "Route" +msgstr "Percorso" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route_group +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route_group +msgid "Route Group" +msgstr "Gruppo percorso" + +#. module: endpoint +#: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route_type +#: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route_type +msgid "Route Type" +msgstr "Tipo percorso" + +#. module: endpoint +#: model:ir.actions.server,name:endpoint.server_action_registry_sync +msgid "Sync registry" +msgstr "Sincronizza registro" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_endpoint_search_view +msgid "To sync" +msgstr "Sa sincronizzare" + +#. module: endpoint +#: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view +msgid "" +"Use the action \"Sync registry\" to make changes effective once you are done " +"with edits and creates." +msgstr "" +"Utilizzare l'azione \"Sincronizza registro\" per rendere effettive le " +"modifiche una volta completate le modifiche e le creazioni." + +#. module: endpoint +#: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__route_group +#: model:ir.model.fields,help:endpoint.field_endpoint_mixin__route_group +msgid "Use this to classify routes together" +msgstr "Utilizzarlo per classificare i percorsi insieme" + +#. module: endpoint +#. odoo-python +#: code:addons/endpoint/models/endpoint_mixin.py:0 +#, python-format +msgid "code_snippet should return a dict into `result` variable." +msgstr "code_snippet deve restituire un dizIonaro nella variabile `result`." diff --git a/endpoint/models/__init__.py b/endpoint/models/__init__.py new file mode 100644 index 00000000..e5ecde3f --- /dev/null +++ b/endpoint/models/__init__.py @@ -0,0 +1,2 @@ +from . import endpoint_mixin +from . import endpoint_endpoint diff --git a/endpoint/models/endpoint_endpoint.py b/endpoint/models/endpoint_endpoint.py new file mode 100644 index 00000000..a7efb6ed --- /dev/null +++ b/endpoint/models/endpoint_endpoint.py @@ -0,0 +1,9 @@ +from odoo import models + + +class EndpointEndpoint(models.Model): + """Define a custom endpoint.""" + + _name = "endpoint.endpoint" + _inherit = "endpoint.mixin" + _description = "Endpoint" diff --git a/endpoint/models/endpoint_mixin.py b/endpoint/models/endpoint_mixin.py new file mode 100644 index 00000000..ebbd321d --- /dev/null +++ b/endpoint/models/endpoint_mixin.py @@ -0,0 +1,237 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import textwrap + +import werkzeug + +from odoo import _, api, exceptions, fields, http, models +from odoo.exceptions import UserError +from odoo.tools import safe_eval + +from odoo.addons.rpc_helper.decorator import disable_rpc + + +@disable_rpc() # Block ALL RPC calls +class EndpointMixin(models.AbstractModel): + _name = "endpoint.mixin" + _inherit = "endpoint.route.handler" + _description = "Endpoint mixin" + + exec_mode = fields.Selection( + selection="_selection_exec_mode", + required=True, + ) + code_snippet = fields.Text() + code_snippet_docs = fields.Text( + compute="_compute_code_snippet_docs", + default=lambda self: self._default_code_snippet_docs(), + ) + exec_as_user_id = fields.Many2one(comodel_name="res.users") + company_id = fields.Many2one("res.company", string="Company") + + def _selection_exec_mode(self): + return [("code", "Execute code")] + + def _compute_code_snippet_docs(self): + for rec in self: + rec.code_snippet_docs = textwrap.dedent(rec._default_code_snippet_docs()) + + @api.constrains("exec_mode") + def _check_exec_mode(self): + for rec in self: + rec._validate_exec_mode() + + def _validate_exec_mode(self): + validator = getattr(self, "_validate_exec__" + self.exec_mode, lambda x: True) + validator() + + def _validate_exec__code(self): + if not self._code_snippet_valued(): + raise UserError( + _("Exec mode is set to `Code`: you must provide a piece of code") + ) + + @api.constrains("auth_type") + def _check_auth(self): + for rec in self: + if rec.auth_type == "public" and not rec.exec_as_user_id: + raise UserError(_("'Exec as user' is mandatory for public endpoints.")) + + def _default_code_snippet_docs(self): + return """ + Available vars: + + * env + * endpoint + * request + * datetime + * dateutil + * time + * user + * json + * Response + * werkzeug + * exceptions + + Must generate either an instance of ``Response`` into ``response`` var or: + + * payload + * headers + * status_code + + which are all optional. + + Use ``log`` function to log messages into ir.logging table. + """ + + def _get_code_snippet_eval_context(self, request): + """Prepare the context used when evaluating python code + + :returns: dict -- evaluation context given to safe_eval + """ + return { + "env": self.env, + "user": self.env.user, + "endpoint": self, + "request": request, + "datetime": safe_eval.datetime, + "dateutil": safe_eval.dateutil, + "time": safe_eval.time, + "json": safe_eval.json, + "Response": http.Response, + "werkzeug": safe_eval.wrap_module( + werkzeug, {"exceptions": ["NotFound", "BadRequest", "Unauthorized"]} + ), + "exceptions": safe_eval.wrap_module( + exceptions, ["UserError", "ValidationError"] + ), + "log": self._code_snippet_log_func, + } + + def _code_snippet_log_func(self, message, level="info"): + # Almost barely copied from ir.actions.server + with self.pool.cursor() as cr: + cr.execute( + """ + INSERT INTO ir_logging + ( + create_date, + create_uid, + type, + dbname, + name, + level, + message, + path, + line, + func + ) + VALUES ( + NOW() at time zone 'UTC', + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s + ) + """, + ( + self.env.uid, + "server", + self._cr.dbname, + __name__, + level, + message, + "endpoint", + self.id, + self.name, + ), + ) + + def _handle_exec__code(self, request): + if not self._code_snippet_valued(): + return {} + eval_ctx = self._get_code_snippet_eval_context(request) + snippet = self.code_snippet + safe_eval.safe_eval(snippet, eval_ctx, mode="exec", nocopy=True) + result = eval_ctx.get("result") + if not isinstance(result, dict): + raise exceptions.UserError( + _("code_snippet should return a dict into `result` variable.") + ) + return result + + def _code_snippet_valued(self): + snippet = self.code_snippet or "" + return bool( + [ + not line.startswith("#") + for line in (snippet.splitlines()) + if line.strip("") + ] + ) + + def _default_endpoint_options_handler(self): + kdp = "odoo.addons.endpoint.controllers.main.EndpointController" + return { + "klass_dotted_path": kdp, + "method_name": "auto_endpoint", + "default_pargs": (self._name, self.route), + } + + def _validate_request(self, request): + http_req = request.httprequest + if self.request_method and self.request_method != http_req.method: + self._logger.error("_validate_request: MethodNotAllowed") + raise werkzeug.exceptions.MethodNotAllowed() + if ( + self.request_content_type + and self.request_content_type != http_req.content_type + ): + self._logger.error("_validate_request: UnsupportedMediaType") + raise werkzeug.exceptions.UnsupportedMediaType() + + def _get_handler(self): + try: + return getattr(self, "_handle_exec__" + self.exec_mode) + except AttributeError as e: + raise UserError( + _("Missing handler for exec mode %s") % self.exec_mode + ) from e + + def _handle_request(self, request): + # Switch user for the whole process + self_with_user = self + if self.exec_as_user_id: + self_with_user = self.with_user(user=self.exec_as_user_id) + handler = self_with_user._get_handler() + try: + res = handler(request) + except self._bad_request_exceptions() as orig_exec: + self._logger.error("_validate_request: BadRequest") + raise werkzeug.exceptions.BadRequest() from orig_exec + return res + + def _bad_request_exceptions(self): + return (exceptions.UserError, exceptions.ValidationError) + + @api.model + def _find_endpoint(self, endpoint_route): + return self.sudo().search(self._find_endpoint_domain(endpoint_route), limit=1) + + def _find_endpoint_domain(self, endpoint_route): + return [("route", "=", endpoint_route)] + + def copy_data(self, default=None): + # OVERRIDE: ``route`` cannot be copied as it must me unique. + # Yet, we want to be able to duplicate a record from the UI. + self.ensure_one() + default = dict(default or {}) + default.setdefault("route", f"{self.route}/COPY_FIXME") + return super().copy_data(default=default) diff --git a/endpoint/pyproject.toml b/endpoint/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/endpoint/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/endpoint/readme/CONFIGURE.md b/endpoint/readme/CONFIGURE.md new file mode 100644 index 00000000..1a1b547f --- /dev/null +++ b/endpoint/readme/CONFIGURE.md @@ -0,0 +1 @@ +Go to "Technical -\> Endpoints" and create a new endpoint. diff --git a/endpoint/readme/CONTRIBUTORS.md b/endpoint/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..2b66303a --- /dev/null +++ b/endpoint/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Simone Orsi \<\> diff --git a/endpoint/readme/DESCRIPTION.md b/endpoint/readme/DESCRIPTION.md new file mode 100644 index 00000000..b7e4aed4 --- /dev/null +++ b/endpoint/readme/DESCRIPTION.md @@ -0,0 +1,10 @@ +Provide an endpoint framework allowing users to define their own custom +endpoint. + +Thanks to endpoint mixin the endpoint records are automatically +registered as real Odoo routes. + +You can easily code what you want in the code snippet. + +NOTE: for security reasons any kind of RPC call is blocked on endpoint +records. diff --git a/endpoint/readme/ROADMAP.md b/endpoint/readme/ROADMAP.md new file mode 100644 index 00000000..170278d4 --- /dev/null +++ b/endpoint/readme/ROADMAP.md @@ -0,0 +1,3 @@ +- add validation of request data +- add api docs generation +- handle multiple routes per endpoint diff --git a/endpoint/security/ir.model.access.csv b/endpoint/security/ir.model.access.csv new file mode 100644 index 00000000..2bfbff5d --- /dev/null +++ b/endpoint/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_endpoint_endpoint_edit,endpoint_endpoint edit,model_endpoint_endpoint,base.group_system,1,1,1,1 diff --git a/endpoint/security/ir_rule.xml b/endpoint/security/ir_rule.xml new file mode 100644 index 00000000..18a9b41b --- /dev/null +++ b/endpoint/security/ir_rule.xml @@ -0,0 +1,11 @@ + + + + Endpoint Multi-company + + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + diff --git a/endpoint/static/description/icon.png b/endpoint/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/endpoint/static/description/icon.png differ diff --git a/endpoint/static/description/index.html b/endpoint/static/description/index.html new file mode 100644 index 00000000..dd1c0c38 --- /dev/null +++ b/endpoint/static/description/index.html @@ -0,0 +1,445 @@ + + + + + +Endpoint + + + +
+

Endpoint

+ + +

Beta License: LGPL-3 OCA/web-api Translate me on Weblate Try me on Runboat

+

Provide an endpoint framework allowing users to define their own custom +endpoint.

+

Thanks to endpoint mixin the endpoint records are automatically +registered as real Odoo routes.

+

You can easily code what you want in the code snippet.

+

NOTE: for security reasons any kind of RPC call is blocked on endpoint +records.

+

Table of contents

+ +
+

Configuration

+

Go to “Technical -> Endpoints” and create a new endpoint.

+
+
+

Known issues / Roadmap

+
    +
  • add validation of request data
  • +
  • add api docs generation
  • +
  • handle multiple routes per endpoint
  • +
+
+
+

Bug Tracker

+

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

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+ +
+

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:

+

simahawk

+

This module is part of the OCA/web-api project on GitHub.

+

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

+
+
+
+ + diff --git a/endpoint/tests/__init__.py b/endpoint/tests/__init__.py new file mode 100644 index 00000000..6885a0f9 --- /dev/null +++ b/endpoint/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_endpoint +from . import test_endpoint_controller diff --git a/endpoint/tests/common.py b/endpoint/tests/common.py new file mode 100644 index 00000000..4948f8ec --- /dev/null +++ b/endpoint/tests/common.py @@ -0,0 +1,51 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import contextlib + +from odoo.tests.common import TransactionCase, tagged +from odoo.tools import DotDict + +from odoo.addons.website.tools import MockRequest + + +@tagged("-at_install", "post_install") +class CommonEndpoint(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_env() + cls._setup_records() + + @classmethod + def _setup_env(cls): + cls.env = cls.env(context=cls._setup_context()) + + @classmethod + def _setup_context(cls): + return dict( + cls.env.context, + tracking_disable=True, + ) + + @classmethod + def _setup_records(cls): + pass + + @contextlib.contextmanager + def _get_mocked_request( + self, httprequest=None, extra_headers=None, request_attrs=None + ): + with MockRequest(self.env) as mocked_request: + mocked_request.httprequest = ( + DotDict(httprequest) if httprequest else mocked_request.httprequest + ) + headers = {} + headers.update(extra_headers or {}) + mocked_request.httprequest.headers = headers + request_attrs = request_attrs or {} + for k, v in request_attrs.items(): + setattr(mocked_request, k, v) + mocked_request.make_response = lambda data, **kw: data + yield mocked_request diff --git a/endpoint/tests/test_endpoint.py b/endpoint/tests/test_endpoint.py new file mode 100644 index 00000000..c5501d81 --- /dev/null +++ b/endpoint/tests/test_endpoint.py @@ -0,0 +1,250 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import json +import textwrap +from unittest import mock + +import psycopg2 +import werkzeug + +from odoo import exceptions +from odoo.tools.misc import mute_logger + +from .common import CommonEndpoint + + +class TestEndpoint(CommonEndpoint): + @classmethod + def _setup_records(cls): + res = super()._setup_records() + cls.endpoint = cls.env.ref("endpoint.endpoint_demo_1") + return res + + @mute_logger("odoo.sql_db") + def test_endpoint_unique(self): + with self.assertRaises(psycopg2.IntegrityError): + self.env["endpoint.endpoint"].create( + { + "name": "Endpoint", + "route": "/demo/one", + "exec_mode": "code", + } + ) + + def test_endpoint_validation(self): + with self.assertRaisesRegex( + exceptions.UserError, r"you must provide a piece of code" + ): + self.env["endpoint.endpoint"].create( + { + "name": "Endpoint 2", + "route": "/demo/2", + "exec_mode": "code", + "request_method": "GET", + "auth_type": "user_endpoint", + } + ) + with self.assertRaisesRegex( + exceptions.UserError, r"Request content type is required for POST and PUT." + ): + self.env["endpoint.endpoint"].create( + { + "name": "Endpoint 3", + "route": "/demo/3", + "exec_mode": "code", + "code_snippet": "foo = 1", + "request_method": "POST", + "auth_type": "user_endpoint", + } + ) + with self.assertRaisesRegex( + exceptions.UserError, r"Request content type is required for POST and PUT." + ): + self.endpoint.request_method = "POST" + + def test_endpoint_find(self): + self.assertEqual( + self.env["endpoint.endpoint"]._find_endpoint("/demo/one"), self.endpoint + ) + + def test_endpoint_code_eval_full_response(self): + with self._get_mocked_request() as req: + result = self.endpoint._handle_request(req) + resp = result["response"] + self.assertEqual(resp.status, "200 OK") + self.assertEqual(resp.data, b"ok") + + def test_endpoint_code_eval_free_vals(self): + self.endpoint.write( + { + "code_snippet": textwrap.dedent( + """ + result = { + "payload": json.dumps({"a": 1, "b": 2}), + "headers": [("content-type", "application/json")] + } + """ + ) + } + ) + with self._get_mocked_request() as req: + result = self.endpoint._handle_request(req) + payload = result["payload"] + self.assertEqual(json.loads(payload), {"a": 1, "b": 2}) + + def test_endpoint_log(self): + self.endpoint.write( + { + "code_snippet": textwrap.dedent( + """ + log("ciao") + result = {"ok": True} + """ + ) + } + ) + with self._get_mocked_request() as req: + # just test that logging does not break + # as it creates a record directly via sql + # and we cannot easily check the result + self.endpoint._handle_request(req) + self.env.cr.execute("DELETE FROM ir_logging") + + @mute_logger("endpoint.endpoint", "odoo.modules.registry") + def test_endpoint_validate_request(self): + endpoint = self.endpoint.copy( + { + "route": "/wrong", + "request_method": "POST", + "request_content_type": "text/plain", + } + ) + with self.assertRaises(werkzeug.exceptions.UnsupportedMediaType): + with self._get_mocked_request(httprequest={"method": "POST"}) as req: + endpoint._validate_request(req) + with self.assertRaises(werkzeug.exceptions.MethodNotAllowed): + with self._get_mocked_request( + httprequest={"method": "GET"}, + extra_headers=[("Content-type", "text/plain")], + ) as req: + endpoint._validate_request(req) + + @mute_logger("odoo.modules.registry") + def test_routing(self): + route, info, __ = self.endpoint._get_routing_info() + self.assertEqual(route, "/demo/one") + self.assertEqual( + info, + { + "auth": "user_endpoint", + "methods": ["GET"], + "routes": ["/demo/one"], + "type": "http", + "csrf": False, + "readonly": False, + }, + ) + endpoint = self.endpoint.copy( + { + "route": "/new/one", + "request_method": "POST", + "request_content_type": "text/plain", + "auth_type": "public", + "exec_as_user_id": self.env.user.id, + } + ) + __, info, __ = endpoint._get_routing_info() + self.assertEqual( + info, + { + "auth": "public", + "methods": ["POST"], + "routes": ["/new/one"], + "type": "http", + "csrf": False, + "readonly": False, + }, + ) + # check prefix + type(endpoint)._endpoint_route_prefix = "/foo" + endpoint._compute_route() + __, info, __ = endpoint._get_routing_info() + self.assertEqual( + info, + { + "auth": "public", + "methods": ["POST"], + "routes": ["/foo/new/one"], + "type": "http", + "csrf": False, + "readonly": False, + }, + ) + type(endpoint)._endpoint_route_prefix = "" + + @mute_logger("odoo.modules.registry") + def test_unlink(self): + endpoint = self.endpoint.copy( + { + "route": "/delete/this", + "request_method": "POST", + "request_content_type": "text/plain", + "auth_type": "public", + "exec_as_user_id": self.env.user.id, + } + ) + endpoint._handle_registry_sync() + key = endpoint._endpoint_registry_unique_key() + reg = endpoint._endpoint_registry + self.assertEqual(reg._get_rule(key).route, "/delete/this") + endpoint.unlink() + self.assertEqual(reg._get_rule(key), None) + + @mute_logger("odoo.modules.registry") + def test_archive(self): + endpoint = self.endpoint.copy( + { + "route": "/enable-disable/this", + "request_method": "POST", + "request_content_type": "text/plain", + "auth_type": "public", + "exec_as_user_id": self.env.user.id, + } + ) + endpoint._handle_registry_sync() + self.assertTrue(endpoint.active) + key = endpoint._endpoint_registry_unique_key() + reg = endpoint._endpoint_registry + self.assertEqual(reg._get_rule(key).route, "/enable-disable/this") + endpoint.active = False + endpoint._handle_registry_sync() + self.assertEqual(reg._get_rule(key), None) + + def test_registry_sync(self): + endpoint = self.env["endpoint.endpoint"].create( + { + "name": "New", + "route": "/not/active/yet", + "exec_mode": "code", + "code_snippet": "foo = 1", + "request_method": "GET", + "auth_type": "user_endpoint", + } + ) + self.assertFalse(endpoint.registry_sync) + key = endpoint._endpoint_registry_unique_key() + reg = endpoint._endpoint_registry + self.assertEqual(reg._get_rule(key), None) + with mock.patch.object(type(self.env.cr.postcommit), "add") as mocked: + endpoint.registry_sync = True + partial_func = mocked.call_args[0][0] + self.assertEqual(partial_func.args, ([endpoint.id],)) + self.assertEqual( + partial_func.func.__name__, "_handle_registry_sync_post_commit" + ) + + def test_duplicate(self): + endpoint = self.endpoint.copy() + self.assertTrue(endpoint.route.endswith("/COPY_FIXME")) diff --git a/endpoint/tests/test_endpoint_controller.py b/endpoint/tests/test_endpoint_controller.py new file mode 100644 index 00000000..fb5e84fd --- /dev/null +++ b/endpoint/tests/test_endpoint_controller.py @@ -0,0 +1,79 @@ +# Copyright 2021 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import json +import os +from unittest import skipIf + +from odoo.tests.common import HttpCase +from odoo.tools.misc import mute_logger + + +@skipIf(os.getenv("SKIP_HTTP_CASE"), "HttpCase skipped") +class EndpointHttpCase(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # force sync for demo records + cls.env["endpoint.endpoint"].search([])._handle_registry_sync() + + def tearDown(self): + # Clear cache for method ``ir.http.routing_map()`` + self.env.registry.clear_cache("routing") + super().tearDown() + + def test_call1(self): + response = self.url_open("/demo/one") + self.assertEqual(response.status_code, 401) + # Let's login now + self.authenticate("admin", "admin") + response = self.url_open("/demo/one") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b"ok") + + def test_call_route_update(self): + # Ensure that a route that gets updated is not available anymore + self.authenticate("admin", "admin") + endpoint = self.env.ref("endpoint.endpoint_demo_1") + endpoint.route += "/new" + # force sync + endpoint._handle_registry_sync() + response = self.url_open("/demo/one") + self.assertEqual(response.status_code, 404) + response = self.url_open("/demo/one/new") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b"ok") + # Archive it + endpoint.active = False + response = self.url_open("/demo/one/new") + self.assertEqual(response.status_code, 404) + endpoint.active = True + response = self.url_open("/demo/one/new") + self.assertEqual(response.status_code, 200) + + def test_call2(self): + response = self.url_open("/demo/as_demo_user") + self.assertEqual(response.content, b"My name is: Marc Demo") + + def test_call3(self): + response = self.url_open("/demo/json_data") + data = json.loads(response.content.decode()) + self.assertEqual(data, {"a": 1, "b": 2}) + + @mute_logger("endpoint.endpoint") + def test_call4(self): + response = self.url_open("/demo/raise_validation_error") + self.assertEqual(response.status_code, 400) + + def test_call5(self): + response = self.url_open("/demo/none") + self.assertEqual(response.status_code, 404) + + def test_call6(self): + response = self.url_open("/demo/value_from_request?your_name=JonnyTest") + self.assertEqual(response.content, b"JonnyTest") + + def test_call7(self): + response = self.url_open("/demo/bad_method", data="ok") + self.assertEqual(response.status_code, 405) diff --git a/endpoint/views/endpoint_view.xml b/endpoint/views/endpoint_view.xml new file mode 100644 index 00000000..a267153d --- /dev/null +++ b/endpoint/views/endpoint_view.xml @@ -0,0 +1,189 @@ + + + + + + endpoint.mixin.form + endpoint.mixin + +
+
+ + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + endpoint.endpoint.form + endpoint.endpoint + + primary + +
+ + real +
+
+
+ + + endpoint.mixin.search + endpoint.mixin + + + + + + + + + + + + + + + + + endpoint.endpoint.search + endpoint.endpoint + + primary + + + + + + + + + + endpoint.mixin.list + endpoint.mixin + + + + + + + + + + + + + endpoint.endpoint.list + endpoint.endpoint + + primary + + + + + + + + + + Endpoints + endpoint.endpoint + list,form + [] + {'search_default_all': 1} + + + + Endpoints + + + + +