|
2 | 2 | # @author: Simone Orsi <simone.orsi@camptocamp.com> |
3 | 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). |
4 | 4 |
|
| 5 | +import json |
5 | 6 | import textwrap |
6 | 7 |
|
| 8 | +import jsonschema |
7 | 9 | import werkzeug |
| 10 | +import yaml |
| 11 | +from lxml import etree |
8 | 12 |
|
9 | 13 | from odoo import api, exceptions, fields, http, models |
10 | 14 | from odoo.exceptions import UserError |
11 | 15 | from odoo.tools import safe_eval |
12 | 16 |
|
13 | 17 | from odoo.addons.rpc_helper.decorator import disable_rpc |
14 | 18 |
|
| 19 | +from ..exceptions import RequestValidationError |
| 20 | + |
15 | 21 | hashlib = safe_eval.wrap_module( |
16 | 22 | __import__("hashlib"), |
17 | 23 | [ |
@@ -48,6 +54,18 @@ class EndpointMixin(models.AbstractModel): |
48 | 54 | selection="_selection_exec_mode", |
49 | 55 | required=True, |
50 | 56 | ) |
| 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 | + ) |
51 | 69 | code_snippet = fields.Text() |
52 | 70 | code_snippet_docs = fields.Text( |
53 | 71 | compute="_compute_code_snippet_docs", |
@@ -80,6 +98,69 @@ def _validate_exec__code(self): |
80 | 98 | ) |
81 | 99 | ) |
82 | 100 |
|
| 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 | + |
83 | 164 | @api.constrains("auth_type") |
84 | 165 | def _check_auth(self): |
85 | 166 | for rec in self: |
@@ -233,6 +314,59 @@ def _validate_request(self, request): |
233 | 314 | ): |
234 | 315 | self._logger.error("_validate_request: UnsupportedMediaType") |
235 | 316 | 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 | + ) |
236 | 370 |
|
237 | 371 | def _get_handler(self): |
238 | 372 | try: |
|
0 commit comments