Skip to content

Commit 97682fc

Browse files
[IMP] endpoint: implement optional request content schema validation
1 parent 58de455 commit 97682fc

8 files changed

Lines changed: 408 additions & 0 deletions

File tree

endpoint/__manifest__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
"maintainers": ["simahawk"],
1212
"website": "https://github.com/OCA/web-api",
1313
"depends": ["endpoint_route_handler", "rpc_helper"],
14+
"external_dependencies": {
15+
"python": ["jsonschema", "PyYAML"],
16+
},
1417
"data": [
1518
"data/server_action.xml",
1619
"security/ir.model.access.csv",

endpoint/exceptions.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Copyright 2026 Camptocamp SA (https://www.camptocamp.com).
2+
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
3+
4+
import json
5+
6+
import werkzeug.exceptions
7+
import werkzeug.wrappers
8+
9+
10+
class RequestValidationError(werkzeug.exceptions.BadRequest):
11+
"""Bad request raised when the body fails JSON Schema validation.
12+
13+
Emits ``{"detail": [{"loc", "msg", "type"}, ...]}`` (FastAPI-style)
14+
instead of the generic werkzeug HTML body.
15+
"""
16+
17+
def __init__(self, detail):
18+
super().__init__()
19+
self.detail = detail
20+
21+
def get_response(self, environ=None, scope=None):
22+
return werkzeug.wrappers.Response(
23+
json.dumps({"detail": self.detail}),
24+
status=self.code,
25+
mimetype="application/json",
26+
)

endpoint/models/endpoint_mixin.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,22 @@
22
# @author: Simone Orsi <simone.orsi@camptocamp.com>
33
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
44

5+
import json
56
import textwrap
67

8+
import jsonschema
79
import werkzeug
10+
import yaml
11+
from lxml import etree
812

913
from odoo import api, exceptions, fields, http, models
1014
from odoo.exceptions import UserError
1115
from odoo.tools import safe_eval
1216

1317
from odoo.addons.rpc_helper.decorator import disable_rpc
1418

19+
from ..exceptions import RequestValidationError
20+
1521
hashlib = safe_eval.wrap_module(
1622
__import__("hashlib"),
1723
[
@@ -48,6 +54,18 @@ class EndpointMixin(models.AbstractModel):
4854
selection="_selection_exec_mode",
4955
required=True,
5056
)
57+
request_content_schema = fields.Text(
58+
help=(
59+
"Optional schema validated against the parsed request body. "
60+
"The accepted format depends on 'Request content type':\n"
61+
" - application/json: JSON Schema (Draft 2020-12), as YAML or JSON\n"
62+
" - application/xml, text/xml: XML Schema (XSD)\n"
63+
"If empty, no validation runs."
64+
),
65+
)
66+
request_content_schema_applicable = fields.Boolean(
67+
compute="_compute_request_content_schema_applicable",
68+
)
5169
code_snippet = fields.Text()
5270
code_snippet_docs = fields.Text(
5371
compute="_compute_code_snippet_docs",
@@ -80,6 +98,69 @@ def _validate_exec__code(self):
8098
)
8199
)
82100

101+
def _get_request_content_schema_applicable_for_types(self):
102+
"""Content types for which ``request_content_schema`` applies."""
103+
return ["application/json", "application/xml"]
104+
105+
@api.depends("request_method", "request_content_type")
106+
def _compute_request_content_schema_applicable(self):
107+
applicable_types = self._get_request_content_schema_applicable_for_types()
108+
for rec in self:
109+
rec.request_content_schema_applicable = (
110+
rec.request_method in ("POST", "PUT")
111+
and rec.request_content_type in applicable_types
112+
)
113+
114+
@api.onchange("request_content_type")
115+
def _onchange_request_content_type_clear_schema(self):
116+
# The schema format depends on the content type (e.g. JSON Schema vs
117+
# XSD), so it cannot survive a content type change.
118+
self.request_content_schema = False
119+
120+
@api.constrains("request_content_schema", "request_content_type")
121+
def _check_request_content_schema(self):
122+
for rec in self:
123+
if not rec.request_content_schema:
124+
continue
125+
elif rec.request_content_type == "application/json":
126+
rec._check_request_content_schema_json()
127+
elif rec.request_content_type == "application/xml":
128+
rec._check_request_content_schema_xml()
129+
130+
def _check_request_content_schema_json(self):
131+
try:
132+
schema = yaml.safe_load(self.request_content_schema)
133+
except yaml.YAMLError as exception:
134+
raise UserError(
135+
self.env._("Invalid YAML/JSON in request content schema: %s", exception)
136+
) from exception
137+
try:
138+
jsonschema.Draft202012Validator.check_schema(schema)
139+
except jsonschema.SchemaError as exception:
140+
raise UserError(
141+
self.env._(
142+
"Invalid JSON Schema in request content schema: %s",
143+
exception.message,
144+
)
145+
) from exception
146+
147+
def _check_request_content_schema_xml(self):
148+
try:
149+
schema_doc = etree.fromstring(self.request_content_schema.encode())
150+
except etree.XMLSyntaxError as exception:
151+
raise UserError(
152+
self.env._("Invalid XML in request content schema: %s", exception)
153+
) from exception
154+
try:
155+
etree.XMLSchema(schema_doc)
156+
except etree.XMLSchemaParseError as exception:
157+
raise UserError(
158+
self.env._(
159+
"Invalid XML Schema (XSD) in request content schema: %s",
160+
exception,
161+
)
162+
) from exception
163+
83164
@api.constrains("auth_type")
84165
def _check_auth(self):
85166
for rec in self:
@@ -233,6 +314,59 @@ def _validate_request(self, request):
233314
):
234315
self._logger.error("_validate_request: UnsupportedMediaType")
235316
raise werkzeug.exceptions.UnsupportedMediaType()
317+
self._validate_request_content(request)
318+
319+
def _validate_request_content(self, request):
320+
if not (self.request_content_schema and self.request_content_schema_applicable):
321+
return
322+
if self.request_content_type == "application/json":
323+
self._validate_request_content_json(request)
324+
elif self.request_content_type == "application/xml":
325+
self._validate_request_content_xml(request)
326+
327+
def _validate_request_content_json(self, request):
328+
try:
329+
body = request.get_json_data()
330+
except json.JSONDecodeError as exception:
331+
self._logger.error("Invalid JSON body: %s", exception)
332+
raise RequestValidationError(
333+
[{"loc": ["body"], "msg": str(exception), "type": "json_invalid"}]
334+
) from exception
335+
schema = yaml.safe_load(self.request_content_schema)
336+
errors = list(jsonschema.Draft202012Validator(schema).iter_errors(body))
337+
if errors:
338+
self._logger.error("Schema validation failed (%d errors)", len(errors))
339+
raise RequestValidationError(
340+
[
341+
{
342+
"loc": ["body", *err.absolute_path],
343+
"msg": err.message,
344+
"type": err.validator,
345+
}
346+
for err in errors
347+
]
348+
)
349+
350+
def _validate_request_content_xml(self, request):
351+
body = request.httprequest.get_data()
352+
try:
353+
doc = etree.fromstring(body)
354+
except etree.XMLSyntaxError as exception:
355+
self._logger.error("Invalid XML body: %s", exception)
356+
raise RequestValidationError(
357+
[{"loc": ["body"], "msg": str(exception), "type": "xml_invalid"}]
358+
) from exception
359+
schema = etree.XMLSchema(etree.fromstring(self.request_content_schema.encode()))
360+
if not schema.validate(doc):
361+
self._logger.error(
362+
"Schema validation failed (%d errors)", len(schema.error_log)
363+
)
364+
raise RequestValidationError(
365+
[
366+
{"loc": ["body"], "msg": err.message, "type": "xml_schema"}
367+
for err in schema.error_log
368+
]
369+
)
236370

237371
def _get_handler(self):
238372
try:

endpoint/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
from . import test_endpoint
2+
from . import test_endpoint_content_schema_validation
23
from . import test_endpoint_controller

endpoint/tests/common.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,42 @@ def _setup_demo_records(env):
101101
),
102102
}
103103
)
104+
endpoints += env["endpoint.endpoint"].create(
105+
{
106+
"name": "Demo Endpoint 8",
107+
"route": "/demo/schema",
108+
"request_method": "POST",
109+
"request_content_type": "application/json",
110+
"auth_type": "public",
111+
"exec_as_user_id": demo_user.id,
112+
"exec_mode": "code",
113+
"code_snippet": 'result = {"payload": {"ok": True}}',
114+
"request_content_schema": (
115+
"type: object\n"
116+
"required: [data]\n"
117+
"properties:\n"
118+
" data:\n"
119+
" type: array\n"
120+
),
121+
}
122+
)
123+
endpoints += env["endpoint.endpoint"].create(
124+
{
125+
"name": "Demo Endpoint 9",
126+
"route": "/demo/schema-xml",
127+
"request_method": "POST",
128+
"request_content_type": "application/xml",
129+
"auth_type": "public",
130+
"exec_as_user_id": demo_user.id,
131+
"exec_mode": "code",
132+
"code_snippet": 'result = {"payload": {"ok": True}}',
133+
"request_content_schema": (
134+
'<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">'
135+
'<xs:element name="greeting" type="xs:string"/>'
136+
"</xs:schema>"
137+
),
138+
}
139+
)
104140
return endpoints
105141

106142

0 commit comments

Comments
 (0)