From c5c2b78da66e243c60d0d52259b4f82fba736f10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Todorovich?= Date: Thu, 30 Apr 2026 15:48:11 -0300 Subject: [PATCH 1/2] [IMP] endpoint: implement optional request content schema validation --- endpoint/__manifest__.py | 3 + endpoint/exceptions.py | 26 +++ endpoint/models/endpoint_mixin.py | 134 ++++++++++++ endpoint/tests/__init__.py | 1 + endpoint/tests/common.py | 36 ++++ ...test_endpoint_content_schema_validation.py | 195 ++++++++++++++++++ endpoint/views/endpoint_view.xml | 11 + requirements.txt | 2 + 8 files changed, 408 insertions(+) create mode 100644 endpoint/exceptions.py create mode 100644 endpoint/tests/test_endpoint_content_schema_validation.py diff --git a/endpoint/__manifest__.py b/endpoint/__manifest__.py index f5e65f59..bd98ce15 100644 --- a/endpoint/__manifest__.py +++ b/endpoint/__manifest__.py @@ -11,6 +11,9 @@ "maintainers": ["simahawk"], "website": "https://github.com/OCA/web-api", "depends": ["endpoint_route_handler", "rpc_helper"], + "external_dependencies": { + "python": ["jsonschema", "PyYAML"], + }, "data": [ "data/server_action.xml", "security/ir.model.access.csv", diff --git a/endpoint/exceptions.py b/endpoint/exceptions.py new file mode 100644 index 00000000..f95139d7 --- /dev/null +++ b/endpoint/exceptions.py @@ -0,0 +1,26 @@ +# Copyright 2026 Camptocamp SA (https://www.camptocamp.com). +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import json + +import werkzeug.exceptions +import werkzeug.wrappers + + +class RequestValidationError(werkzeug.exceptions.BadRequest): + """Bad request raised when the body fails JSON Schema validation. + + Emits ``{"detail": [{"loc", "msg", "type"}, ...]}`` (FastAPI-style) + instead of the generic werkzeug HTML body. + """ + + def __init__(self, detail): + super().__init__() + self.detail = detail + + def get_response(self, environ=None, scope=None): + return werkzeug.wrappers.Response( + json.dumps({"detail": self.detail}), + status=self.code, + mimetype="application/json", + ) diff --git a/endpoint/models/endpoint_mixin.py b/endpoint/models/endpoint_mixin.py index 7e008cf9..b9c4a619 100644 --- a/endpoint/models/endpoint_mixin.py +++ b/endpoint/models/endpoint_mixin.py @@ -2,9 +2,13 @@ # @author: Simone Orsi # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +import json import textwrap +import jsonschema import werkzeug +import yaml +from lxml import etree from odoo import api, exceptions, fields, http, models from odoo.exceptions import UserError @@ -12,6 +16,8 @@ from odoo.addons.rpc_helper.decorator import disable_rpc +from ..exceptions import RequestValidationError + hashlib = safe_eval.wrap_module( __import__("hashlib"), [ @@ -48,6 +54,18 @@ class EndpointMixin(models.AbstractModel): selection="_selection_exec_mode", required=True, ) + request_content_schema = fields.Text( + help=( + "Optional schema validated against the parsed request body. " + "The accepted format depends on 'Request content type':\n" + " - application/json: JSON Schema (Draft 2020-12), as YAML or JSON\n" + " - application/xml, text/xml: XML Schema (XSD)\n" + "If empty, no validation runs." + ), + ) + request_content_schema_applicable = fields.Boolean( + compute="_compute_request_content_schema_applicable", + ) code_snippet = fields.Text() code_snippet_docs = fields.Text( compute="_compute_code_snippet_docs", @@ -80,6 +98,69 @@ def _validate_exec__code(self): ) ) + def _get_request_content_schema_applicable_for_types(self): + """Content types for which ``request_content_schema`` applies.""" + return ["application/json", "application/xml"] + + @api.depends("request_method", "request_content_type") + def _compute_request_content_schema_applicable(self): + applicable_types = self._get_request_content_schema_applicable_for_types() + for rec in self: + rec.request_content_schema_applicable = ( + rec.request_method in ("POST", "PUT") + and rec.request_content_type in applicable_types + ) + + @api.onchange("request_content_type") + def _onchange_request_content_type_clear_schema(self): + # The schema format depends on the content type (e.g. JSON Schema vs + # XSD), so it cannot survive a content type change. + self.request_content_schema = False + + @api.constrains("request_content_schema", "request_content_type") + def _check_request_content_schema(self): + for rec in self: + if not rec.request_content_schema: + continue + elif rec.request_content_type == "application/json": + rec._check_request_content_schema_json() + elif rec.request_content_type == "application/xml": + rec._check_request_content_schema_xml() + + def _check_request_content_schema_json(self): + try: + schema = yaml.safe_load(self.request_content_schema) + except yaml.YAMLError as exception: + raise UserError( + self.env._("Invalid YAML/JSON in request content schema: %s", exception) + ) from exception + try: + jsonschema.Draft202012Validator.check_schema(schema) + except jsonschema.SchemaError as exception: + raise UserError( + self.env._( + "Invalid JSON Schema in request content schema: %s", + exception.message, + ) + ) from exception + + def _check_request_content_schema_xml(self): + try: + schema_doc = etree.fromstring(self.request_content_schema.encode()) + except etree.XMLSyntaxError as exception: + raise UserError( + self.env._("Invalid XML in request content schema: %s", exception) + ) from exception + try: + etree.XMLSchema(schema_doc) + except etree.XMLSchemaParseError as exception: + raise UserError( + self.env._( + "Invalid XML Schema (XSD) in request content schema: %s", + exception, + ) + ) from exception + @api.constrains("auth_type") def _check_auth(self): for rec in self: @@ -233,6 +314,59 @@ def _validate_request(self, request): ): self._logger.error("_validate_request: UnsupportedMediaType") raise werkzeug.exceptions.UnsupportedMediaType() + self._validate_request_content(request) + + def _validate_request_content(self, request): + if not (self.request_content_schema and self.request_content_schema_applicable): + return + if self.request_content_type == "application/json": + self._validate_request_content_json(request) + elif self.request_content_type == "application/xml": + self._validate_request_content_xml(request) + + def _validate_request_content_json(self, request): + try: + body = request.get_json_data() + except json.JSONDecodeError as exception: + self._logger.error("Invalid JSON body: %s", exception) + raise RequestValidationError( + [{"loc": ["body"], "msg": str(exception), "type": "json_invalid"}] + ) from exception + schema = yaml.safe_load(self.request_content_schema) + errors = list(jsonschema.Draft202012Validator(schema).iter_errors(body)) + if errors: + self._logger.error("Schema validation failed (%d errors)", len(errors)) + raise RequestValidationError( + [ + { + "loc": ["body", *err.absolute_path], + "msg": err.message, + "type": err.validator, + } + for err in errors + ] + ) + + def _validate_request_content_xml(self, request): + body = request.httprequest.get_data() + try: + doc = etree.fromstring(body) + except etree.XMLSyntaxError as exception: + self._logger.error("Invalid XML body: %s", exception) + raise RequestValidationError( + [{"loc": ["body"], "msg": str(exception), "type": "xml_invalid"}] + ) from exception + schema = etree.XMLSchema(etree.fromstring(self.request_content_schema.encode())) + if not schema.validate(doc): + self._logger.error( + "Schema validation failed (%d errors)", len(schema.error_log) + ) + raise RequestValidationError( + [ + {"loc": ["body"], "msg": err.message, "type": "xml_schema"} + for err in schema.error_log + ] + ) def _get_handler(self): try: diff --git a/endpoint/tests/__init__.py b/endpoint/tests/__init__.py index 6885a0f9..d01290f9 100644 --- a/endpoint/tests/__init__.py +++ b/endpoint/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_endpoint +from . import test_endpoint_content_schema_validation from . import test_endpoint_controller diff --git a/endpoint/tests/common.py b/endpoint/tests/common.py index e28794e6..7ab0eddd 100644 --- a/endpoint/tests/common.py +++ b/endpoint/tests/common.py @@ -101,6 +101,42 @@ def _setup_demo_records(env): ), } ) + endpoints += env["endpoint.endpoint"].create( + { + "name": "Demo Endpoint 8", + "route": "/demo/schema", + "request_method": "POST", + "request_content_type": "application/json", + "auth_type": "public", + "exec_as_user_id": demo_user.id, + "exec_mode": "code", + "code_snippet": 'result = {"payload": {"ok": True}}', + "request_content_schema": ( + "type: object\n" + "required: [data]\n" + "properties:\n" + " data:\n" + " type: array\n" + ), + } + ) + endpoints += env["endpoint.endpoint"].create( + { + "name": "Demo Endpoint 9", + "route": "/demo/schema-xml", + "request_method": "POST", + "request_content_type": "application/xml", + "auth_type": "public", + "exec_as_user_id": demo_user.id, + "exec_mode": "code", + "code_snippet": 'result = {"payload": {"ok": True}}', + "request_content_schema": ( + '' + '' + "" + ), + } + ) return endpoints diff --git a/endpoint/tests/test_endpoint_content_schema_validation.py b/endpoint/tests/test_endpoint_content_schema_validation.py new file mode 100644 index 00000000..711ba87b --- /dev/null +++ b/endpoint/tests/test_endpoint_content_schema_validation.py @@ -0,0 +1,195 @@ +# Copyright 2026 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import json +import os +from unittest import mock, skipIf + +from odoo import exceptions +from odoo.tests import Form, HttpCase +from odoo.tools.misc import mute_logger + +from .common import CommonEndpoint, _setup_demo_records + + +class TestEndpointContentSchemaValidation(CommonEndpoint): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.endpoint_json = cls.env["endpoint.endpoint"].search( + [("route", "=", "/demo/schema")] + ) + cls.endpoint_xml = cls.env["endpoint.endpoint"].search( + [("route", "=", "/demo/schema-xml")] + ) + + def test_request_content_schema_applicable_post_json(self): + self.assertTrue(self.endpoint_json.request_content_schema_applicable) + + def test_request_content_schema_applicable_put_json(self): + self.endpoint_json.request_method = "PUT" + self.assertTrue(self.endpoint_json.request_content_schema_applicable) + + def test_request_content_schema_applicable_post_xml(self): + self.assertTrue(self.endpoint_xml.request_content_schema_applicable) + + def test_request_content_schema_not_applicable_for_non_body_method(self): + self.endpoint_json.write( + {"request_method": "GET", "request_content_type": False} + ) + self.assertFalse(self.endpoint_json.request_content_schema_applicable) + + def test_request_content_schema_not_applicable_for_unsupported_type(self): + # Clear the schema first; otherwise the constraint would re-validate + # the JSON Schema content as if it were the new (non-applicable) type. + self.endpoint_json.request_content_schema = False + self.endpoint_json.request_content_type = "text/plain" + self.assertFalse(self.endpoint_json.request_content_schema_applicable) + + def test_request_content_schema_cleared_on_content_type_change(self): + self.assertTrue(self.endpoint_json.request_content_schema) + # Minimal view to bypass form inheritance from sibling modules that + # may not be loaded when the endpoint module is tested in isolation. + with Form(self.endpoint_json) as form: + form.request_content_type = "application/xml" + self.assertFalse(form.request_content_schema) + + def test_request_content_schema_invalid_yaml_raises(self): + with self.assertRaisesRegex( + exceptions.UserError, r"Invalid YAML/JSON in request content schema" + ): + self.endpoint_json.request_content_schema = "this: is: not: valid: yaml: [" + + def test_request_content_schema_invalid_jsonschema_raises(self): + with self.assertRaisesRegex( + exceptions.UserError, r"Invalid JSON Schema in request content schema" + ): + self.endpoint_json.request_content_schema = "type: not_a_real_type" + + def test_request_content_schema_valid_json_ok(self): + self.endpoint_json.request_content_schema = json.dumps( + {"type": "object", "required": ["data"]} + ) + + def test_request_content_schema_valid_xsd_ok(self): + self.endpoint_xml.request_content_schema = ( + '' + '' + "" + ) + + def test_request_content_schema_invalid_xml_raises(self): + with self.assertRaisesRegex( + exceptions.UserError, r"Invalid XML in request content schema" + ): + self.endpoint_xml.request_content_schema = "' + '' + "" + ) + + def test_request_content_schema_unknown_content_type_skipped(self): + # text/plain is not in the applicable types; the constraint dispatch + # is a no-op and any text passes. + self.endpoint_json.request_content_type = "text/plain" + self.endpoint_json.request_content_schema = "this is not JSON or XML" + self.assertTrue(self.endpoint_json.request_content_schema) + + def test_validate_request_content_skipped_without_schema(self): + self.endpoint_json.request_content_schema = False + get_json_data = mock.Mock() + with self._get_mocked_request(httprequest={"method": "POST"}) as req: + req.get_json_data = get_json_data + self.endpoint_json._validate_request_content(req) + get_json_data.assert_not_called() + + def test_validate_request_content_skipped_for_non_applicable_type(self): + self.endpoint_json.request_content_type = "text/plain" + get_json_data = mock.Mock() + with self._get_mocked_request(httprequest={"method": "POST"}) as req: + req.get_json_data = get_json_data + self.endpoint_json._validate_request_content(req) + get_json_data.assert_not_called() + + +@skipIf(os.getenv("SKIP_HTTP_CASE"), "HttpCase skipped") +class TestEndpointContentSchemaValidationHttp(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + _setup_demo_records(cls.env) + 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_json_valid_body(self): + response = self.url_open( + "/demo/schema", + data=json.dumps({"data": [1, 2, 3]}), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(json.loads(response.content.decode()), {"ok": True}) + + @mute_logger("endpoint.endpoint") + def test_json_invalid_body(self): + response = self.url_open( + "/demo/schema", + data=json.dumps({"data": "not-an-array"}), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 400) + payload = json.loads(response.content.decode()) + self.assertEqual(payload["detail"][0]["loc"], ["body", "data"]) + self.assertEqual(payload["detail"][0]["type"], "type") + + @mute_logger("endpoint.endpoint") + def test_json_malformed_body(self): + response = self.url_open( + "/demo/schema", + data="not-json", + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 400) + payload = json.loads(response.content.decode()) + self.assertEqual(payload["detail"][0]["type"], "json_invalid") + + def test_xml_valid_body(self): + response = self.url_open( + "/demo/schema-xml", + data=b"hello", + headers={"Content-Type": "application/xml"}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(json.loads(response.content.decode()), {"ok": True}) + + @mute_logger("endpoint.endpoint") + def test_xml_invalid_body(self): + response = self.url_open( + "/demo/schema-xml", + data=b"oops", + headers={"Content-Type": "application/xml"}, + ) + self.assertEqual(response.status_code, 400) + payload = json.loads(response.content.decode()) + self.assertEqual(payload["detail"][0]["type"], "xml_schema") + + @mute_logger("endpoint.endpoint") + def test_xml_malformed_body(self): + response = self.url_open( + "/demo/schema-xml", + data=b"not-xml", + headers={"Content-Type": "application/xml"}, + ) + self.assertEqual(response.status_code, 400) + payload = json.loads(response.content.decode()) + self.assertEqual(payload["detail"][0]["type"], "xml_invalid") diff --git a/endpoint/views/endpoint_view.xml b/endpoint/views/endpoint_view.xml index a267153d..d51ddfba 100644 --- a/endpoint/views/endpoint_view.xml +++ b/endpoint/views/endpoint_view.xml @@ -70,6 +70,17 @@ + + + diff --git a/requirements.txt b/requirements.txt index 45e2e80f..70c765a0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ # generated from manifests external_dependencies +PyYAML +jsonschema oauthlib requests-oauthlib responses From e2b04c2b09862d8df36e7f8fb346a28925947946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Todorovich?= Date: Wed, 6 May 2026 10:57:18 -0300 Subject: [PATCH 2/2] [IMP] endpoint: write configure section in readme --- endpoint/README.rst | 139 +++++++++++++++++++- endpoint/readme/CONFIGURE.md | 120 ++++++++++++++++- endpoint/readme/CONTRIBUTORS.md | 1 + endpoint/readme/ROADMAP.md | 1 - endpoint/static/description/index.html | 171 ++++++++++++++++++++++--- 5 files changed, 413 insertions(+), 19 deletions(-) diff --git a/endpoint/README.rst b/endpoint/README.rst index 9acac54f..b80f3660 100644 --- a/endpoint/README.rst +++ b/endpoint/README.rst @@ -51,12 +51,146 @@ records. Configuration ============= -Go to "Technical -> Endpoints" and create a new endpoint. +Endpoints are managed under **Technical → Endpoints**. + +Each record represents a single HTTP route exposed by Odoo and executed +according to its configuration. + +Identification +-------------- + +- **Name**: human-readable label used in lists and logs. +- **Route**: URL path served by the endpoint (e.g. ``/my/custom/path``). + Routes must be unique across **all** endpoint-consumer models, not + only within ``endpoint.endpoint``. +- **Route group**: free-text tag to classify related routes together. + Useful for filtering and for downstream modules that group endpoints. + +Authentication +-------------- + +- **Auth type**: + + - ``User``: the request must be authenticated as an Odoo user + (default). + - ``Public``: the route is accessible without authentication. + +- **Exec as user**: the user under which the code snippet runs. + + - **Mandatory** when *Auth type* is ``Public`` (the public user has no + real privileges, so the endpoint must impersonate a real user to + perform any meaningful work). + - Optional otherwise; if set, the snippet is evaluated as that user + instead of the caller. + +Request +------- + +- **Request method**: ``GET``, ``POST``, ``PUT`` or ``DELETE``. Requests + using any other method are rejected with ``405 Method Not Allowed``. + +- **Request content type**: expected ``Content-Type`` of the incoming + request body. Mandatory for ``POST``/``PUT``. Available values: + + - ``text/plain``, ``text/csv`` + - ``application/json`` + - ``application/xml`` + - ``application/x-www-form-urlencoded`` + + When set, requests with a different ``Content-Type`` header are + rejected with ``415 Unsupported Media Type``. + +Request content schema (optional) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For ``POST``/``PUT`` endpoints whose content type is +``application/json`` or ``application/xml``, an optional **Request +Schema** tab is shown. When a schema is provided, the request body is +validated against it before the code snippet runs; on failure, the +request is rejected with a structured validation error +(``RequestValidationError``). + +The accepted schema format depends on the content type: + +- **JSON** (``application/json``): a JSON Schema (Draft 2020-12). The + field accepts both JSON and YAML syntax — YAML is convenient when + authoring schemas inline. +- **XML** (``application/xml``): an XML Schema (XSD). + +Example JSON Schema (YAML form): + +.. code:: yaml + + type: object + required: [name, qty] + properties: + name: { type: string } + qty: { type: integer, minimum: 1 } + +Example XSD: + +.. code:: xml + + + + + +.. + + **Tip:** when shipping endpoints from a module, the schema can live + in a separate file and be loaded into the field at install time using + the ``file`` attribute on the data record: + + .. code:: xml + + + +Execution +--------- + +- **Exec mode**: how the request is handled. The base module ships + ``Execute code``; downstream modules can register additional modes. + +- **Code snippet** *(when exec mode is ``code``)*: Python code evaluated + via ``safe_eval`` for every request. The snippet must assign a + ``dict`` to the ``result`` variable; it can either contain a + ready-made ``Response`` (under the ``response`` key) or ``payload``, + ``headers`` and ``status_code`` to be assembled by the framework. + + Variables exposed to the snippet (see the **Code Help** tab on the + form for the live, up-to-date list): + + - ``env``, ``user``, ``endpoint``, ``request`` + - ``datetime``, ``dateutil``, ``time``, ``json`` + - ``Response`` (Odoo's ``http.Response``) + - ``werkzeug`` (limited to ``NotFound``, ``BadRequest``, + ``Unauthorized``) + - ``exceptions`` (limited to ``UserError``, ``ValidationError``) + - ``hashlib``, ``hmac`` (subset of the standard library) + - ``log(message, level="info")`` — writes to ``ir.logging`` + + Raising ``UserError`` or ``ValidationError`` from the snippet is + converted to ``400 Bad Request``. + +Minimal snippet: + +.. code:: python + + result = {"response": Response("Hello, World!")} + +Registry synchronization +------------------------ + +Endpoints are registered in a routing registry that lives outside the +ORM. After creating, modifying or archiving a record, the warning +*"Registry out of sync"* appears on the form, and the record is shown in +the **To sync** filter on the list view. Run the **Sync registry** +action to commit the changes to the live routing table — until then, the +new configuration is **not** served. Known issues / Roadmap ====================== -- add validation of request data - add api docs generation - handle multiple routes per endpoint @@ -82,6 +216,7 @@ Contributors ------------ - Simone Orsi +- Iván Todorovich - Alex Garcia Maintainers diff --git a/endpoint/readme/CONFIGURE.md b/endpoint/readme/CONFIGURE.md index 1a1b547f..5059ab8e 100644 --- a/endpoint/readme/CONFIGURE.md +++ b/endpoint/readme/CONFIGURE.md @@ -1 +1,119 @@ -Go to "Technical -\> Endpoints" and create a new endpoint. +Endpoints are managed under **Technical → Endpoints**. + +Each record represents a single HTTP route exposed by Odoo and executed +according to its configuration. + +## Identification + +- **Name**: human-readable label used in lists and logs. +- **Route**: URL path served by the endpoint (e.g. `/my/custom/path`). + Routes must be unique across **all** endpoint-consumer models, not + only within `endpoint.endpoint`. +- **Route group**: free-text tag to classify related routes together. + Useful for filtering and for downstream modules that group endpoints. + +## Authentication + +- **Auth type**: + - `User`: the request must be authenticated as an Odoo user (default). + - `Public`: the route is accessible without authentication. +- **Exec as user**: the user under which the code snippet runs. + - **Mandatory** when *Auth type* is `Public` (the public user has no + real privileges, so the endpoint must impersonate a real user to + perform any meaningful work). + - Optional otherwise; if set, the snippet is evaluated as that user + instead of the caller. + +## Request + +- **Request method**: `GET`, `POST`, `PUT` or `DELETE`. Requests using + any other method are rejected with `405 Method Not Allowed`. +- **Request content type**: expected `Content-Type` of the incoming + request body. Mandatory for `POST`/`PUT`. Available values: + - `text/plain`, `text/csv` + - `application/json` + - `application/xml` + - `application/x-www-form-urlencoded` + + When set, requests with a different `Content-Type` header are rejected + with `415 Unsupported Media Type`. + +### Request content schema (optional) + +For `POST`/`PUT` endpoints whose content type is `application/json` or +`application/xml`, an optional **Request Schema** tab is shown. When a +schema is provided, the request body is validated against it before the +code snippet runs; on failure, the request is rejected with a structured +validation error (`RequestValidationError`). + +The accepted schema format depends on the content type: + +- **JSON** (`application/json`): a JSON Schema (Draft 2020-12). The field + accepts both JSON and YAML syntax — YAML is convenient when authoring + schemas inline. +- **XML** (`application/xml`): an XML Schema (XSD). + +Example JSON Schema (YAML form): + +```yaml +type: object +required: [name, qty] +properties: + name: { type: string } + qty: { type: integer, minimum: 1 } +``` + +Example XSD: + +```xml + + + +``` + +> **Tip:** when shipping endpoints from a module, the schema can live in +> a separate file and be loaded into the field at install time using the +> `file` attribute on the data record: +> +> ```xml +> +> ``` + +## Execution + +- **Exec mode**: how the request is handled. The base module ships + `Execute code`; downstream modules can register additional modes. +- **Code snippet** *(when exec mode is `code`)*: Python code evaluated + via `safe_eval` for every request. The snippet must assign a `dict` + to the `result` variable; it can either contain a ready-made + `Response` (under the `response` key) or `payload`, `headers` and + `status_code` to be assembled by the framework. + + Variables exposed to the snippet (see the **Code Help** tab on the + form for the live, up-to-date list): + + - `env`, `user`, `endpoint`, `request` + - `datetime`, `dateutil`, `time`, `json` + - `Response` (Odoo's `http.Response`) + - `werkzeug` (limited to `NotFound`, `BadRequest`, `Unauthorized`) + - `exceptions` (limited to `UserError`, `ValidationError`) + - `hashlib`, `hmac` (subset of the standard library) + - `log(message, level="info")` — writes to `ir.logging` + + Raising `UserError` or `ValidationError` from the snippet is converted + to `400 Bad Request`. + +Minimal snippet: + +```python +result = {"response": Response("Hello, World!")} +``` + +## Registry synchronization + +Endpoints are registered in a routing registry that lives outside the +ORM. After creating, modifying or archiving a record, the warning +*"Registry out of sync"* appears on the form, and the record is shown in +the **To sync** filter on the list view. Run the **Sync registry** +action to commit the changes to the live routing table — until then, the +new configuration is **not** served. diff --git a/endpoint/readme/CONTRIBUTORS.md b/endpoint/readme/CONTRIBUTORS.md index 023a1a2b..163a9802 100644 --- a/endpoint/readme/CONTRIBUTORS.md +++ b/endpoint/readme/CONTRIBUTORS.md @@ -1,2 +1,3 @@ - Simone Orsi \<\> +- Iván Todorovich \<\> - Alex Garcia \<\> diff --git a/endpoint/readme/ROADMAP.md b/endpoint/readme/ROADMAP.md index 170278d4..85e8621c 100644 --- a/endpoint/readme/ROADMAP.md +++ b/endpoint/readme/ROADMAP.md @@ -1,3 +1,2 @@ -- add validation of request data - add api docs generation - handle multiple routes per endpoint diff --git a/endpoint/static/description/index.html b/endpoint/static/description/index.html index 4c0d770d..37f34de1 100644 --- a/endpoint/static/description/index.html +++ b/endpoint/static/description/index.html @@ -385,31 +385,171 @@

Endpoint

Table of contents

Configuration

-

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

+

Endpoints are managed under Technical → Endpoints.

+

Each record represents a single HTTP route exposed by Odoo and executed +according to its configuration.

+
+

Identification

+
    +
  • Name: human-readable label used in lists and logs.
  • +
  • Route: URL path served by the endpoint (e.g. /my/custom/path). +Routes must be unique across all endpoint-consumer models, not +only within endpoint.endpoint.
  • +
  • Route group: free-text tag to classify related routes together. +Useful for filtering and for downstream modules that group endpoints.
  • +
+
+
+

Authentication

+
    +
  • Auth type:
      +
    • User: the request must be authenticated as an Odoo user +(default).
    • +
    • Public: the route is accessible without authentication.
    • +
    +
  • +
  • Exec as user: the user under which the code snippet runs.
      +
    • Mandatory when Auth type is Public (the public user has no +real privileges, so the endpoint must impersonate a real user to +perform any meaningful work).
    • +
    • Optional otherwise; if set, the snippet is evaluated as that user +instead of the caller.
    • +
    +
  • +
+
+
+

Request

+
    +
  • Request method: GET, POST, PUT or DELETE. Requests +using any other method are rejected with 405 Method Not Allowed.

    +
  • +
  • Request content type: expected Content-Type of the incoming +request body. Mandatory for POST/PUT. Available values:

    +
      +
    • text/plain, text/csv
    • +
    • application/json
    • +
    • application/xml
    • +
    • application/x-www-form-urlencoded
    • +
    +

    When set, requests with a different Content-Type header are +rejected with 415 Unsupported Media Type.

    +
  • +
+
+

Request content schema (optional)

+

For POST/PUT endpoints whose content type is +application/json or application/xml, an optional Request +Schema tab is shown. When a schema is provided, the request body is +validated against it before the code snippet runs; on failure, the +request is rejected with a structured validation error +(RequestValidationError).

+

The accepted schema format depends on the content type:

+
    +
  • JSON (application/json): a JSON Schema (Draft 2020-12). The +field accepts both JSON and YAML syntax — YAML is convenient when +authoring schemas inline.
  • +
  • XML (application/xml): an XML Schema (XSD).
  • +
+

Example JSON Schema (YAML form):

+
+type: object
+required: [name, qty]
+properties:
+  name: { type: string }
+  qty:  { type: integer, minimum: 1 }
+
+

Example XSD:

+
+<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
+  <xs:element name="greeting" type="xs:string"/>
+</xs:schema>
+
+ +
+

Tip: when shipping endpoints from a module, the schema can live +in a separate file and be loaded into the field at install time using +the file attribute on the data record:

+
+<field name="request_content_schema" type="char" file="endpoint/demo/content_schema.xsd" />
+
+
+
+
+
+

Execution

+
    +
  • Exec mode: how the request is handled. The base module ships +Execute code; downstream modules can register additional modes.

    +
  • +
  • Code snippet (when exec mode is ``code``): Python code evaluated +via safe_eval for every request. The snippet must assign a +dict to the result variable; it can either contain a +ready-made Response (under the response key) or payload, +headers and status_code to be assembled by the framework.

    +

    Variables exposed to the snippet (see the Code Help tab on the +form for the live, up-to-date list):

    +
      +
    • env, user, endpoint, request
    • +
    • datetime, dateutil, time, json
    • +
    • Response (Odoo’s http.Response)
    • +
    • werkzeug (limited to NotFound, BadRequest, +Unauthorized)
    • +
    • exceptions (limited to UserError, ValidationError)
    • +
    • hashlib, hmac (subset of the standard library)
    • +
    • log(message, level="info") — writes to ir.logging
    • +
    +

    Raising UserError or ValidationError from the snippet is +converted to 400 Bad Request.

    +
  • +
+

Minimal snippet:

+
+result = {"response": Response("Hello, World!")}
+
+
+
+

Registry synchronization

+

Endpoints are registered in a routing registry that lives outside the +ORM. After creating, modifying or archiving a record, the warning +“Registry out of sync” appears on the form, and the record is shown in +the To sync filter on the list view. Run the Sync registry +action to commit the changes to the live routing table — until then, the +new configuration is not served.

+
-

Known issues / Roadmap

+

Known issues / Roadmap

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

Bug Tracker

+

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 @@ -417,22 +557,23 @@

Bug Tracker

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

-

Credits

+

Credits

-

Authors

+

Authors

  • Camptocamp
-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association