Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 137 additions & 2 deletions endpoint/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

<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:

.. code:: xml

<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:

.. 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

Expand All @@ -82,6 +216,7 @@ Contributors
------------

- Simone Orsi <simone.orsi@camptocamp.com>
- Iván Todorovich <ivan.todorovich@camptocamp.com>
- Alex Garcia <alex@studio73.es>

Maintainers
Expand Down
3 changes: 3 additions & 0 deletions endpoint/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions endpoint/exceptions.py
Original file line number Diff line number Diff line change
@@ -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",
)
134 changes: 134 additions & 0 deletions endpoint/models/endpoint_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,22 @@
# @author: Simone Orsi <simone.orsi@camptocamp.com>
# 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
from odoo.tools import safe_eval

from odoo.addons.rpc_helper.decorator import disable_rpc

from ..exceptions import RequestValidationError

hashlib = safe_eval.wrap_module(
__import__("hashlib"),
[
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self.request_content_schema = False
self.request_content_schema = ""

Nit: I personally find it more self-explanatory


@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):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have plenty of XSD files in modules: we should be able to provide a path too.
If you don't want to add a specific field for this, we could use a prefix path:.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What use case do you have in mind?

IMO if you want to specify the schema in a different file, it can be covered with standard Odoo xml import, like this.

    <record id="endpoint_demo" model="endpoint.endpoint">
        <field name="name">Demo Endpoint</field>
        <field name="route">/demo</field>
        <field name="request_method">POST</field>
        <field name="exec_mode">code</field>
        <field name="code_snippet">result = {"response": Response("Hello, World!")}</field>
        <field name="request_content_type">application/xml</field>
        <field name="request_content_schema" type="char" file="endpoint/demo/content_schema.xsd" />
    </record>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that won't work from the UI.
The use case is: I have a URL to XSD or an attachment: how would I use it here w/o copy/pasting its content?
I would at list allow to select an existing attachment instead w/ domain filter on *.xsd files.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a pure UI created record, I think it's not that problematic if the user copy&paste the content directly in the field. There's no benefit in having a separate attachment for this, in fact in practice it will make it slower due to binary reads than just having its content in the DB.

I rather not add a magic syntax like "path:" to the field content. IMO, the schema field content should be a pure schema definition, either XSD or JSON schema (or something else in the future for other content types), not a magic thing built on top.

So, if we were to do this cleanly, I'd go for a request_content_schema_source and separate fields for each source type. But, honestly, it's seems overly complicated to avoid a copy&paste...

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:
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading