diff --git a/endpoint_json2/README.rst b/endpoint_json2/README.rst new file mode 100644 index 00000000..f5f63eb3 --- /dev/null +++ b/endpoint_json2/README.rst @@ -0,0 +1,201 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============== +Endpoint JSON2 +============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:c7e6ba5b070db8b93a1ae44bd87344a39b885ff2b4667a1a26e9e7b1cc677402 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/license-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/19.0/endpoint_json2 + :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-19-0/web-api-19-0-endpoint_json2 + :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=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Adds ``exec_mode="json2"`` to the endpoint framework, enabling +declarative JSON-2 API endpoint configuration. Select a model, method, +and parameters — the module handles dispatch, parameter validation, +access control, and result filtering. A code snippet can be used as an +alternative to a model method for quick, ad-hoc logic. + +Also provides auto-generated API documentation endpoints at +``/json2/doc``. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Go to *Settings > Technical > Endpoints* and create a new endpoint with +**Exec Mode** set to **JSON-2 API**. + +Basic Setup +----------- + +- **Route Group** and **Name**: Together these determine the endpoint + URL, which is automatically computed as + ``/json2/{route_group}/{name}``. For example, a route group + ``contacts`` with name ``get_partners`` produces + ``/json2/contacts/get_partners``. The route group also organizes + endpoints in the API documentation at ``/json2/doc/{route_group}``. + +- **Model**: The Odoo model to operate on (e.g. ``res.partner``). + +- **Method**: A public model method (e.g. ``search_read``). + Alternatively, provide a **Code Snippet** for custom logic — these + two fields are mutually exclusive. + +- **Response Fields**: One field per line. Optionally follow with an + alias to rename the key in the response. Use dotted notation (one + level) for relational fields (Many2one, Many2many, One2many). Leave + empty to return all fields. Example: + + :: + + name + email + country_id.name country + write_date last_modified + +- **Default Domain**: A JSON-formatted domain filter applied to every + request (e.g. ``[["active", "=", true]]``). + +- **Parameters**: Define named parameters with types, defaults, and + required flags. These are validated before the method is called. + +Access Control +-------------- + +All endpoint execution is wrapped in ``sudo()``, allowing API users to +operate with minimal Odoo privileges. Access is controlled at two +levels: + +- **Auth Type**: Select the authentication method for the endpoint + (e.g. **Bearer** for API key authentication). +- **Allowed Groups**: Restrict endpoint access to specific user groups. + Create integration-specific groups (e.g. "Hospital System", "WMS") + and assign them to the corresponding API users. Each endpoint + declares which groups may call it. Leave empty to allow any + authenticated user. + +Code Snippets +------------- + +As an alternative to a model method, a code snippet can be used for +quick, ad-hoc logic. Available variables: + +- ``Model``: The target model (with ``sudo()``). +- ``params``: Validated parameters from the request. +- ``env``: The Odoo environment. +- ``Command``: Odoo's ``Command`` helper for relational field writes. +- ``json``: Safe JSON module for serialization. +- ``exceptions``: Werkzeug exceptions (``BadRequest``, ``NotFound``, + etc.). +- ``log``: Log messages to the ``ir.logging`` table. + +The snippet must set a ``result`` variable with the response data. + +Usage +===== + +Calling an Endpoint +------------------- + +Send a POST request with a JSON body to the endpoint's route. The +example below uses Bearer authentication with an API key: + +.. code:: bash + + curl -X POST https://your-odoo.com/json2/contacts/get_partners \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -d '{"domain": [["is_company", "=", true]], "limit": 10}' + +API Documentation +----------------- + +Auto-generated documentation for all JSON-2 endpoints is available at +``/json2/doc``, grouped by route group. Each endpoint's visibility +respects the **Allowed Groups** setting — users only see endpoints they +have access to. Filter by route group with ``/json2/doc/{route_group}``. + +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 +------- + +* Quartile + +Contributors +------------ + +- Quartile + + - Yoshi Tashiro + +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-yostashiro| image:: https://github.com/yostashiro.png?size=40px + :target: https://github.com/yostashiro + :alt: yostashiro +.. |maintainer-aungkokolin1997| image:: https://github.com/aungkokolin1997.png?size=40px + :target: https://github.com/aungkokolin1997 + :alt: aungkokolin1997 + +Current `maintainers `__: + +|maintainer-yostashiro| |maintainer-aungkokolin1997| + +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_json2/__init__.py b/endpoint_json2/__init__.py new file mode 100644 index 00000000..91c5580f --- /dev/null +++ b/endpoint_json2/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/endpoint_json2/__manifest__.py b/endpoint_json2/__manifest__.py new file mode 100644 index 00000000..45a9808a --- /dev/null +++ b/endpoint_json2/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +{ + "name": "Endpoint JSON2", + "summary": "Declarative JSON-2 API endpoints on the endpoint stack", + "version": "19.0.1.0.0", + "license": "LGPL-3", + "development_status": "Alpha", + "author": "Quartile, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/web-api", + "category": "Technical", + "depends": ["endpoint"], + "data": [ + "security/ir.model.access.csv", + "views/endpoint_views.xml", + ], + "demo": ["demo/endpoint_json2_demo.xml"], + "installable": True, + "maintainers": ["yostashiro", "aungkokolin1997"], +} diff --git a/endpoint_json2/controllers/__init__.py b/endpoint_json2/controllers/__init__.py new file mode 100644 index 00000000..12a7e529 --- /dev/null +++ b/endpoint_json2/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/endpoint_json2/controllers/main.py b/endpoint_json2/controllers/main.py new file mode 100644 index 00000000..d7d0765c --- /dev/null +++ b/endpoint_json2/controllers/main.py @@ -0,0 +1,67 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from werkzeug.exceptions import NotFound + +from odoo import http +from odoo.http import request + + +class EndpointJson2DocController(http.Controller): + def _get_accessible_endpoints(self, extra_domain=None): + domain = [("exec_mode", "=", "json2")] + (extra_domain or []) + all_endpoints = request.env["endpoint.endpoint"].sudo().search(domain) + user = request.env.user + return all_endpoints.filtered( + lambda ep: ep.json2_group_ids & user.all_group_ids + ) + + def _endpoint_to_doc(self, endpoint): + return { + "name": endpoint.name, + "description": endpoint.json2_description or "", + "method": endpoint.json2_method, + "model": endpoint.json2_model_name, + "url": endpoint.route, + "parameters": [ + { + "name": p.name, + "type": p.param_type, + "required": p.required, + "description": p.description or "", + "default": p.default_value, + } + for p in endpoint.json2_param_ids + ], + } + + @http.route( + "/json2/doc", + methods=["GET"], + auth="user", + type="http", + readonly=True, + save_session=False, + ) + def doc_index(self): + endpoints = self._get_accessible_endpoints() + result = {} + for ep in endpoints: + result.setdefault(ep.route_group, []).append(self._endpoint_to_doc(ep)) + return request.make_json_response(result) + + @http.route( + "/json2/doc/", + methods=["GET"], + auth="user", + type="http", + readonly=True, + save_session=False, + ) + def doc_domain(self, route_group): + endpoints = self._get_accessible_endpoints([("route_group", "=", route_group)]) + if not endpoints: + raise NotFound(f"No endpoints found for domain {route_group!r}") + return request.make_json_response( + [self._endpoint_to_doc(ep) for ep in endpoints] + ) diff --git a/endpoint_json2/demo/endpoint_json2_demo.xml b/endpoint_json2/demo/endpoint_json2_demo.xml new file mode 100644 index 00000000..4b8d5187 --- /dev/null +++ b/endpoint_json2/demo/endpoint_json2_demo.xml @@ -0,0 +1,146 @@ + + + + + get_partners + /json2/contacts/get_partners + contacts + json2 + POST + application/json + bearer + Return partner records matching the given domain. + + search_read + name +email +phone +city +country_id.name country +write_date + [["active", "=", true]] + + + + + domain + list + + [] + 10 + + + + limit + integer + + 80 + 20 + + + + fields + list + + 30 + + + + + update_partner_name + /json2/contacts/update_partner_name + contacts + json2 + POST + application/json + bearer + Update a partner's name by ref (code snippet example). + + ref +name + +partner = Model.search([("ref", "=", params["ref"])], limit=1) +if not partner: + raise exceptions.NotFound("Partner not found: " + params["ref"]) +partner.write({"name": params["new_name"]}) +result = {"ref": partner.ref, "name": partner.name} + + + + + + ref + string + + 10 + + + + new_name + string + + 20 + + + + + create_portal_user + /json2/contacts/create_portal_user + contacts + json2 + POST + application/json + bearer + Create a portal user with the given name and email (code snippet example). + + login +name + +existing = Model.search([("login", "=", params["email"])], limit=1) +if existing: + raise exceptions.BadRequest("User already exists: " + params["email"]) +partner_vals = {"name": params["name"], "email": params["email"]} +if params.get("company_name"): + partner_vals["company_name"] = params["company_name"] +partner = env["res.partner"].create(partner_vals) +group_portal = env.ref("base.group_portal") +user = Model.create({ + "partner_id": partner.id, + "login": params["email"], + "group_ids": [Command.set([group_portal.id])], +}) +result = {"id": user.id, "login": user.login, "partner_id": partner.id} + + + + + + name + string + + 10 + + + + email + string + + 20 + + + + company_name + string + + 30 + + diff --git a/endpoint_json2/models/__init__.py b/endpoint_json2/models/__init__.py new file mode 100644 index 00000000..ab0f79d0 --- /dev/null +++ b/endpoint_json2/models/__init__.py @@ -0,0 +1,2 @@ +from . import endpoint_json2_param +from . import endpoint_endpoint diff --git a/endpoint_json2/models/endpoint_endpoint.py b/endpoint_json2/models/endpoint_endpoint.py new file mode 100644 index 00000000..311495a3 --- /dev/null +++ b/endpoint_json2/models/endpoint_endpoint.py @@ -0,0 +1,409 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +import json +from datetime import date, datetime + +import werkzeug + +from odoo import Command, api, fields, models +from odoo.exceptions import AccessError, ValidationError +from odoo.service.model import get_public_method +from odoo.tools.safe_eval import json as safe_json +from odoo.tools.safe_eval import safe_eval, wrap_module + + +class EndpointEndpoint(models.Model): + _inherit = "endpoint.endpoint" + + json2_model_id = fields.Many2one( + "ir.model", + string="Target Model", + ondelete="cascade", + domain=[("transient", "=", False)], + ) + json2_model_name = fields.Char( + related="json2_model_id.model", + store=True, + ) + json2_method = fields.Char( + string="Method", + help="Public method name on the target model.", + ) + json2_description = fields.Text( + string="Description", + help="Displayed in the API documentation endpoint.", + ) + json2_doc_url = fields.Char( + string="API Doc", + compute="_compute_json2_doc_url", + ) + json2_response_fields = fields.Text( + string="Response Fields", + help="One field per line. Optionally add an alias to rename in output.\n" + "Use dotted notation (one level) for relational fields.\n" + "Leave empty to return all fields.\n\n" + "Examples:\n" + " name\n" + " country_id.name country\n" + " write_date last_modified", + ) + json2_default_domain = fields.Char( + string="Default Domain", + default="[]", + help="Default domain filter applied before calling the method (JSON format).", + ) + json2_group_ids = fields.Many2many( + "res.groups", + string="Allowed Groups", + help="Groups allowed to call this endpoint.", + ) + json2_param_ids = fields.One2many( + "endpoint.json2.param", + "endpoint_id", + string="Parameters", + ) + json2_code_snippet = fields.Text( + string="JSON-2 Code Snippet", + help="Optional Python code executed instead of the model method. " + "Available variables: Model, params, env, Command, json, exceptions, log. " + "Use record.write({...}) for updates. " + "Set the result in the 'result' variable.", + ) + + def _selection_exec_mode(self): + return super()._selection_exec_mode() + [("json2", "JSON-2 API")] + + @api.depends("exec_mode", "route_group", "name") + def _compute_route(self): + super()._compute_route() + for rec in self: + if rec.exec_mode == "json2" and rec.route_group and rec.name: + rec.route = f"/json2/{rec.route_group}/{rec.name}" + return + + @api.depends("exec_mode", "route_group") + def _compute_json2_doc_url(self): + base = self.env["ir.config_parameter"].sudo().get_param("web.base.url", "") + for rec in self: + if rec.exec_mode == "json2" and rec.route_group: + rec.json2_doc_url = f"{base}/json2/doc/{rec.route_group}" + else: + rec.json2_doc_url = False + + @api.onchange("exec_mode") + def _onchange_exec_mode_json2_defaults(self): + if self.exec_mode == "json2": + self.request_method = "POST" + self.request_content_type = "application/json" + + def _validate_exec__json2(self): + if not self.json2_model_id: + raise ValidationError( + self.env._("Exec mode is set to 'JSON-2 API': you must select a model.") + ) + if not self.json2_method and not self.json2_code_snippet: + raise ValidationError( + self.env._( + "Exec mode is set to 'JSON-2 API': you must specify a method or " + "provide a code snippet." + ) + ) + + @api.constrains("request_method", "request_content_type", "exec_mode") + def _check_json2_request_settings(self): + for rec in self: + if rec.exec_mode != "json2": + continue + if ( + rec.request_method != "POST" + or rec.request_content_type != "application/json" + ): + raise ValidationError( + self.env._( + "JSON-2 API endpoints must use POST with 'application/json' " + "content type." + ) + ) + + @api.constrains("json2_method") + def _check_json2_method(self): + for rec in self: + if rec.json2_method and rec.json2_method.startswith("_"): + raise ValidationError( + self.env._("Private methods (starting with '_') cannot be exposed.") + ) + + @api.constrains("json2_default_domain") + def _check_json2_default_domain(self): + for rec in self: + if not rec.json2_default_domain: + continue + try: + domain = json.loads(rec.json2_default_domain) + if not isinstance(domain, list): + raise ValueError + except (json.JSONDecodeError, ValueError): + raise ValidationError( + self.env._("Default domain must be a valid JSON list.") + ) from None + + def _json2_is_valid_response_field(self, Model, field_spec): + if "." not in field_spec: + return field_spec in Model._fields + base, sub = field_spec.split(".", 1) + fd = Model._fields.get(base) + return ( + fd + and fd.type in ("many2one", "many2many", "one2many") + and sub in self.env[fd.comodel_name]._fields + ) + + @api.constrains("json2_response_fields", "json2_model_id") + def _check_json2_response_fields(self): + for rec in self: + if not rec.json2_response_fields or not rec.json2_model_name: + continue + if rec.json2_model_name not in self.env: + continue + Model = self.env[rec.json2_model_name] + field_names, _aliases = rec._json2_parse_response_fields() + invalid = [ + f + for f in field_names + if not rec._json2_is_valid_response_field(Model, f) + ] + if invalid: + raise ValidationError( + self.env._( + "Invalid field(s) for %(model)s: %(fields)s", + model=rec.json2_model_name, + fields=", ".join(invalid), + ) + ) + + def _json2_check_group_access(self, request): + if not (self.json2_group_ids & request.env.user.all_group_ids): + raise werkzeug.exceptions.Forbidden( + "User does not belong to any allowed group" + ) + + def _json2_validate_params(self, kwargs): + params = {} + for param_def in self.json2_param_ids: + params[param_def.name] = param_def._extract_value( + kwargs.pop(param_def.name, None) + ) + return params + + def _json2_get_code_snippet_eval_context(self, Model, params): + return { + "Model": Model, + "params": params, + "env": Model.env, + "Command": Command, + "json": safe_json, + "exceptions": wrap_module( + werkzeug.exceptions, + [ + "BadRequest", + "Forbidden", + "NotFound", + "UnprocessableEntity", + "InternalServerError", + ], + ), + "log": self._code_snippet_log_func, + } + + def _json2_exec_code_snippet(self, Model, params): + eval_ctx = self._json2_get_code_snippet_eval_context(Model, params) + safe_eval(self.json2_code_snippet, eval_ctx, mode="exec") + if "result" not in eval_ctx: + raise werkzeug.exceptions.InternalServerError( + "Code snippet must set a 'result' variable." + ) + return eval_ctx["result"] + + def _json2_parse_response_fields(self): + """Parse response fields text into a field list and alias map. + + Returns (fields, aliases) where aliases maps field_name -> alias. + """ + self.ensure_one() + if not self.json2_response_fields: + return [], {} + field_list = [] + aliases = {} + for line in self.json2_response_fields.splitlines(): + line = line.strip() + if not line: + continue + parts = line.split() + field_list.append(parts[0]) + if len(parts) > 1: + aliases[parts[0]] = parts[1] + return field_list, aliases + + def _json2_parse_dotted_fields(self, response_fields): + dotted = {} + for f in response_fields: + if "." in f: + base, sub = f.split(".", 1) + dotted.setdefault(base, []).append(sub) + return dotted + + def _json2_extract_rel_ids(self, val): + """Extract record IDs from search_read relational field values.""" + if not val: + return [] + if isinstance(val, int): + return [val] + if isinstance(val, (list, tuple)): + if val and isinstance(val[0], int): + if len(val) == 2 and isinstance(val[1], str): + return [val[0]] + return list(val) + if isinstance(val, dict): + rec_id = val.get("id") + return [rec_id] if rec_id else [] + return [] + + def _json2_normalize_rows(self, result): + if isinstance(result, list): + return result + if isinstance(result, dict): + return [result] + return [] + + def _json2_collect_rel_ids(self, rows, base_field): + all_ids = set() + rel_ids_map = [] + for row in rows: + if isinstance(row, dict): + rel_ids = self._json2_extract_rel_ids(row.get(base_field)) + else: + rel_ids = [] + rel_ids_map.append(rel_ids) + all_ids.update(rel_ids) + return all_ids, rel_ids_map + + def _json2_fetch_related(self, Model, field_def, ids, sub_fields): + if not ids: + return {} + comodel = Model.env[field_def.comodel_name] + return { + r["id"]: r + for r in comodel.search_read([("id", "in", list(ids))], fields=sub_fields) + } + + def _json2_inject_dotted_values( + self, rows, base_field, sub_fields, related, is_x2many, rel_ids_map + ): + for row, rel_ids in zip(rows, rel_ids_map, strict=False): + if not isinstance(row, dict): + continue + if is_x2many: + recs = [related[i] for i in rel_ids if i in related] + for sub in sub_fields: + row[f"{base_field}.{sub}"] = [r.get(sub, False) for r in recs] + else: + rec = related.get(rel_ids[0], {}) if rel_ids else {} + for sub in sub_fields: + row[f"{base_field}.{sub}"] = rec.get(sub, False) + + def _json2_resolve_dotted_fields(self, Model, result, dotted_map): + # rows shares references with result; mutations propagate back. + rows = self._json2_normalize_rows(result) + if not rows: + return result + for base_field, sub_fields in dotted_map.items(): + field_def = Model._fields.get(base_field) + if not field_def or field_def.type not in ( + "many2one", + "many2many", + "one2many", + ): + continue + ids, rel_ids_map = self._json2_collect_rel_ids(rows, base_field) + related = self._json2_fetch_related(Model, field_def, ids, sub_fields) + self._json2_inject_dotted_values( + rows, + base_field, + sub_fields, + related, + is_x2many=field_def.type != "many2one", + rel_ids_map=rel_ids_map, + ) + return result + + def _json2_filter_result(self, result, response_fields): + if not response_fields: + return result + field_set = set(response_fields) + if isinstance(result, list): + return [ + {k: v for k, v in row.items() if k in field_set} + if isinstance(row, dict) + else row + for row in result + ] + if isinstance(result, dict): + return {k: v for k, v in result.items() if k in field_set} + return result + + def _json2_apply_aliases(self, result, aliases): + def _rename(row): + if not isinstance(row, dict): + return row + return {aliases.get(k, k): v for k, v in row.items()} + + if isinstance(result, list): + return [_rename(row) for row in result] + if isinstance(result, dict): + return _rename(result) + return result + + def _json2_serialize_value(self, val): + if isinstance(val, datetime): + return val.strftime("%Y-%m-%d %H:%M:%S") + if isinstance(val, date): + return val.isoformat() + if isinstance(val, bytes): + return val.decode("utf-8", errors="replace") + return val + + def _json2_serialize_values(self, result): + if isinstance(result, list): + return [self._json2_serialize_values(item) for item in result] + if isinstance(result, dict): + return {k: self._json2_serialize_values(v) for k, v in result.items()} + return self._json2_serialize_value(result) + + def _handle_exec__json2(self, request): + self._json2_check_group_access(request) + kwargs = request.get_json_data() or {} + params = self._json2_validate_params(kwargs) + Model = request.env[self.json2_model_name].sudo() + default_domain = json.loads(self.json2_default_domain or "[]") + if default_domain: + params["domain"] = default_domain + (params.get("domain") or []) + response_fields, aliases = self._json2_parse_response_fields() + dotted_map = self._json2_parse_dotted_fields(response_fields) + if dotted_map and params.get("fields"): + params["fields"] = list(set(params["fields"]) | dotted_map.keys()) + if self.json2_code_snippet: + result = self._json2_exec_code_snippet(Model, params) + else: + try: + method = get_public_method(Model, self.json2_method) + except (AttributeError, AccessError) as exc: + raise werkzeug.exceptions.NotFound(str(exc)) from exc + result = method(Model, **params) + if dotted_map: + result = self._json2_resolve_dotted_fields(Model, result, dotted_map) + result = self._json2_filter_result(result, response_fields) + if aliases: + result = self._json2_apply_aliases(result, aliases) + result = self._json2_serialize_values(result) + return {"payload": result} diff --git a/endpoint_json2/models/endpoint_json2_param.py b/endpoint_json2/models/endpoint_json2_param.py new file mode 100644 index 00000000..a52b3e4d --- /dev/null +++ b/endpoint_json2/models/endpoint_json2_param.py @@ -0,0 +1,98 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +import json + +import werkzeug + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + +PARAM_TYPE_MAP = { + "string": str, + "integer": int, + "float": float, + "boolean": bool, + "list": list, + "dict": dict, +} + + +class EndpointJson2Param(models.Model): + _name = "endpoint.json2.param" + _description = "JSON2 Endpoint Parameter" + _order = "sequence, id" + + endpoint_id = fields.Many2one( + "endpoint.endpoint", + required=True, + ondelete="cascade", + ) + name = fields.Char(required=True, help="Parameter name as sent in the JSON body.") + description = fields.Char(help="Displayed in the API documentation.") + param_type = fields.Selection( + [ + ("string", "String"), + ("integer", "Integer"), + ("float", "Float"), + ("boolean", "Boolean"), + ("list", "List"), + ("dict", "Dict"), + ], + string="Type", + required=True, + default="string", + ) + required = fields.Boolean() + default_value = fields.Char( + help="Default value (JSON-encoded) when the parameter is not provided.", + ) + sequence = fields.Integer(default=10) + + def _check_param_type(self, value): + self.ensure_one() + expected_type = PARAM_TYPE_MAP.get(self.param_type) + if not expected_type: + return True + if isinstance(value, bool) and expected_type is not bool: + return False + if expected_type is float: + return isinstance(value, (int, float)) + return isinstance(value, expected_type) + + def _extract_value(self, raw_value): + self.ensure_one() + value = raw_value + if value is None and self.default_value: + value = json.loads(self.default_value) + if value is None and self.required: + raise werkzeug.exceptions.UnprocessableEntity( + f"Missing required parameter: {self.name}" + ) + if value is not None and not self._check_param_type(value): + raise werkzeug.exceptions.UnprocessableEntity( + f"Parameter {self.name!r} must be of type {self.param_type}" + ) + return value + + @api.constrains("default_value", "param_type") + def _check_default_value(self): + for rec in self: + if not rec.default_value: + continue + try: + parsed = json.loads(rec.default_value) + except json.JSONDecodeError: + raise ValidationError( + self.env._( + "Default value must be valid JSON: %(value)s", + value=rec.default_value, + ) + ) from None + if not rec._check_param_type(parsed): + raise ValidationError( + self.env._( + "Default value type mismatch: expected %(type)s", + type=rec.param_type, + ) + ) from None diff --git a/endpoint_json2/pyproject.toml b/endpoint_json2/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/endpoint_json2/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/endpoint_json2/readme/CONFIGURE.md b/endpoint_json2/readme/CONFIGURE.md new file mode 100644 index 00000000..a7ad5382 --- /dev/null +++ b/endpoint_json2/readme/CONFIGURE.md @@ -0,0 +1,54 @@ +Go to *Settings > Technical > Endpoints* and create a new endpoint with **Exec Mode** +set to **JSON-2 API**. + +## Basic Setup + +- **Route Group** and **Name**: Together these determine the endpoint URL, which is + automatically computed as `/json2/{route_group}/{name}`. For example, a route group + `contacts` with name `get_partners` produces `/json2/contacts/get_partners`. The + route group also organizes endpoints in the API documentation at + `/json2/doc/{route_group}`. +- **Model**: The Odoo model to operate on (e.g. `res.partner`). +- **Method**: A public model method (e.g. `search_read`). Alternatively, provide a + **Code Snippet** for custom logic — these two fields are mutually exclusive. +- **Response Fields**: One field per line. Optionally follow with an alias to rename + the key in the response. Use dotted notation (one level) for relational fields + (Many2one, Many2many, One2many). Leave empty to return all fields. Example: + + ``` + name + email + country_id.name country + write_date last_modified + ``` +- **Default Domain**: A JSON-formatted domain filter applied to every request + (e.g. `[["active", "=", true]]`). +- **Parameters**: Define named parameters with types, defaults, and required flags. + These are validated before the method is called. + +## Access Control + +All endpoint execution is wrapped in `sudo()`, allowing API users to operate with +minimal Odoo privileges. Access is controlled at two levels: + +- **Auth Type**: Select the authentication method for the endpoint (e.g. **Bearer** + for API key authentication). +- **Allowed Groups**: Restrict endpoint access to specific user groups. Create + integration-specific groups (e.g. "Hospital System", "WMS") and assign them to + the corresponding API users. Each endpoint declares which groups may call it. + Leave empty to allow any authenticated user. + +## Code Snippets + +As an alternative to a model method, a code snippet can be used for quick, ad-hoc +logic. Available variables: + +- `Model`: The target model (with `sudo()`). +- `params`: Validated parameters from the request. +- `env`: The Odoo environment. +- `Command`: Odoo's `Command` helper for relational field writes. +- `json`: Safe JSON module for serialization. +- `exceptions`: Werkzeug exceptions (`BadRequest`, `NotFound`, etc.). +- `log`: Log messages to the `ir.logging` table. + +The snippet must set a `result` variable with the response data. diff --git a/endpoint_json2/readme/CONTRIBUTORS.md b/endpoint_json2/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..5ce4a820 --- /dev/null +++ b/endpoint_json2/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Quartile \<\> + - Yoshi Tashiro diff --git a/endpoint_json2/readme/DESCRIPTION.md b/endpoint_json2/readme/DESCRIPTION.md new file mode 100644 index 00000000..556356d0 --- /dev/null +++ b/endpoint_json2/readme/DESCRIPTION.md @@ -0,0 +1,6 @@ +Adds `exec_mode="json2"` to the endpoint framework, enabling declarative JSON-2 API +endpoint configuration. Select a model, method, and parameters — the module handles +dispatch, parameter validation, access control, and result filtering. A code snippet +can be used as an alternative to a model method for quick, ad-hoc logic. + +Also provides auto-generated API documentation endpoints at `/json2/doc`. diff --git a/endpoint_json2/readme/USAGE.md b/endpoint_json2/readme/USAGE.md new file mode 100644 index 00000000..67bebda0 --- /dev/null +++ b/endpoint_json2/readme/USAGE.md @@ -0,0 +1,18 @@ +## Calling an Endpoint + +Send a POST request with a JSON body to the endpoint's route. The example below +uses Bearer authentication with an API key: + +```bash +curl -X POST https://your-odoo.com/json2/contacts/get_partners \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -d '{"domain": [["is_company", "=", true]], "limit": 10}' +``` + +## API Documentation + +Auto-generated documentation for all JSON-2 endpoints is available at +`/json2/doc`, grouped by route group. Each endpoint's visibility respects the +**Allowed Groups** setting — users only see endpoints they have access to. +Filter by route group with `/json2/doc/{route_group}`. diff --git a/endpoint_json2/security/ir.model.access.csv b/endpoint_json2/security/ir.model.access.csv new file mode 100644 index 00000000..6303efb3 --- /dev/null +++ b/endpoint_json2/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_json2_param,endpoint.json2.param,model_endpoint_json2_param,base.group_system,1,1,1,1 diff --git a/endpoint_json2/static/description/index.html b/endpoint_json2/static/description/index.html new file mode 100644 index 00000000..42c204bc --- /dev/null +++ b/endpoint_json2/static/description/index.html @@ -0,0 +1,549 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Endpoint JSON2

+ +

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

+

Adds exec_mode="json2" to the endpoint framework, enabling +declarative JSON-2 API endpoint configuration. Select a model, method, +and parameters — the module handles dispatch, parameter validation, +access control, and result filtering. A code snippet can be used as an +alternative to a model method for quick, ad-hoc logic.

+

Also provides auto-generated API documentation endpoints at +/json2/doc.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Configuration

+

Go to Settings > Technical > Endpoints and create a new endpoint with +Exec Mode set to JSON-2 API.

+
+

Basic Setup

+
    +
  • Route Group and Name: Together these determine the endpoint +URL, which is automatically computed as +/json2/{route_group}/{name}. For example, a route group +contacts with name get_partners produces +/json2/contacts/get_partners. The route group also organizes +endpoints in the API documentation at /json2/doc/{route_group}.

    +
  • +
  • Model: The Odoo model to operate on (e.g. res.partner).

    +
  • +
  • Method: A public model method (e.g. search_read). +Alternatively, provide a Code Snippet for custom logic — these +two fields are mutually exclusive.

    +
  • +
  • Response Fields: One field per line. Optionally follow with an +alias to rename the key in the response. Use dotted notation (one +level) for relational fields (Many2one, Many2many, One2many). Leave +empty to return all fields. Example:

    +
    +name
    +email
    +country_id.name country
    +write_date last_modified
    +
    +
  • +
  • Default Domain: A JSON-formatted domain filter applied to every +request (e.g. [["active", "=", true]]).

    +
  • +
  • Parameters: Define named parameters with types, defaults, and +required flags. These are validated before the method is called.

    +
  • +
+
+
+

Access Control

+

All endpoint execution is wrapped in sudo(), allowing API users to +operate with minimal Odoo privileges. Access is controlled at two +levels:

+
    +
  • Auth Type: Select the authentication method for the endpoint +(e.g. Bearer for API key authentication).
  • +
  • Allowed Groups: Restrict endpoint access to specific user groups. +Create integration-specific groups (e.g. “Hospital System”, “WMS”) +and assign them to the corresponding API users. Each endpoint +declares which groups may call it. Leave empty to allow any +authenticated user.
  • +
+
+
+

Code Snippets

+

As an alternative to a model method, a code snippet can be used for +quick, ad-hoc logic. Available variables:

+
    +
  • Model: The target model (with sudo()).
  • +
  • params: Validated parameters from the request.
  • +
  • env: The Odoo environment.
  • +
  • Command: Odoo’s Command helper for relational field writes.
  • +
  • json: Safe JSON module for serialization.
  • +
  • exceptions: Werkzeug exceptions (BadRequest, NotFound, +etc.).
  • +
  • log: Log messages to the ir.logging table.
  • +
+

The snippet must set a result variable with the response data.

+
+
+
+

Usage

+
+

Calling an Endpoint

+

Send a POST request with a JSON body to the endpoint’s route. The +example below uses Bearer authentication with an API key:

+
+curl -X POST https://your-odoo.com/json2/contacts/get_partners \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -d '{"domain": [["is_company", "=", true]], "limit": 10}'
+
+
+
+

API Documentation

+

Auto-generated documentation for all JSON-2 endpoints is available at +/json2/doc, grouped by route group. Each endpoint’s visibility +respects the Allowed Groups setting — users only see endpoints they +have access to. Filter by route group with /json2/doc/{route_group}.

+
+
+
+

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

+
    +
  • Quartile
  • +
+
+
+

Contributors

+ +
+
+

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

+

yostashiro aungkokolin1997

+

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_json2/tests/__init__.py b/endpoint_json2/tests/__init__.py new file mode 100644 index 00000000..ce073f55 --- /dev/null +++ b/endpoint_json2/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_endpoint_json2 +from . import test_endpoint_json2_controller diff --git a/endpoint_json2/tests/common.py b/endpoint_json2/tests/common.py new file mode 100644 index 00000000..968e1219 --- /dev/null +++ b/endpoint_json2/tests/common.py @@ -0,0 +1,44 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from odoo import Command +from odoo.tests.common import TransactionCase, tagged + + +@tagged("-at_install", "post_install") +class CommonEndpointJson2(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.model_partner = cls.env["ir.model"]._get("res.partner") + cls.group_user = cls.env.ref("base.group_user") + cls.endpoint = cls._create_endpoint( + { + "name": "get_partners", + "route_group": "contacts", + "json2_description": "Return partner records", + "json2_method": "search_read", + "json2_response_fields": "name\nemail", + "json2_default_domain": "[]", + "json2_group_ids": [Command.link(cls.group_user.id)], + } + ) + + @classmethod + def _create_endpoint(cls, vals): + # route is required but not precomputed in endpoint_route_handler; + # auto-derive it until that is fixed upstream. + defaults = { + "route_group": "test", + "exec_mode": "json2", + "request_method": "POST", + "request_content_type": "application/json", + "auth_type": "bearer", + "json2_model_id": cls.model_partner.id, + "json2_group_ids": [Command.link(cls.group_user.id)], + } + defaults.update(vals) + defaults.setdefault( + "route", f"/json2/{defaults['route_group']}/{defaults['name']}" + ) + return cls.env["endpoint.endpoint"].create(defaults) diff --git a/endpoint_json2/tests/test_endpoint_json2.py b/endpoint_json2/tests/test_endpoint_json2.py new file mode 100644 index 00000000..46a3990e --- /dev/null +++ b/endpoint_json2/tests/test_endpoint_json2.py @@ -0,0 +1,194 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from datetime import date, datetime + +from odoo.exceptions import ValidationError + +from .common import CommonEndpointJson2 + + +class TestEndpointJson2(CommonEndpointJson2): + def test_route_auto_computed(self): + self.assertEqual(self.endpoint.route, "/json2/contacts/get_partners") + + def test_private_method_rejected(self): + with self.assertRaises(ValidationError): + self._create_endpoint( + {"name": "bad", "json2_method": "_compute_display_name"} + ) + + def test_invalid_domain(self): + with self.assertRaises(ValidationError): + self.endpoint.json2_default_domain = "not valid json" + with self.assertRaises(ValidationError): + self.endpoint.json2_default_domain = '{"key": "value"}' + + def test_invalid_response_fields(self): + with self.assertRaises(ValidationError): + self.endpoint.json2_response_fields = "name\nnonexistent_field" + with self.assertRaises(ValidationError): + self.endpoint.json2_response_fields = "name\nnonexistent_id.name" + with self.assertRaises(ValidationError): + self.endpoint.json2_response_fields = "name\nemail.something" + with self.assertRaises(ValidationError): + self.endpoint.json2_response_fields = "name\ncountry_id.nonexistent" + + def test_empty_response_fields(self): + self.endpoint.json2_response_fields = False + fields, aliases = self.endpoint._json2_parse_response_fields() + self.assertEqual(fields, []) + self.assertEqual(aliases, {}) + + def test_param_invalid_default_value(self): + with self.assertRaises(ValidationError): + self.env["endpoint.json2.param"].create( + { + "endpoint_id": self.endpoint.id, + "name": "bad_param", + "param_type": "string", + "default_value": "not valid json", + } + ) + + def test_param_bool_rejected_for_integer(self): + param = self.env["endpoint.json2.param"].create( + { + "endpoint_id": self.endpoint.id, + "name": "count", + "param_type": "integer", + } + ) + self.assertFalse(param._check_param_type(True)) + self.assertFalse(param._check_param_type(False)) + + def test_param_default_value_type_mismatch(self): + with self.assertRaises(ValidationError): + self.env["endpoint.json2.param"].create( + { + "endpoint_id": self.endpoint.id, + "name": "bad_default", + "param_type": "integer", + "default_value": '"hello"', + } + ) + + def test_filter_result_dict(self): + result = {"name": "Test", "email": "a@b.c", "phone": "123"} + filtered = self.endpoint._json2_filter_result(result, ["name", "email"]) + self.assertEqual(filtered, {"name": "Test", "email": "a@b.c"}) + aliased = self.endpoint._json2_apply_aliases(filtered, {"email": "mail"}) + self.assertEqual(aliased, {"name": "Test", "mail": "a@b.c"}) + + def test_filter_result_list(self): + result = [ + {"name": "A", "phone": "1"}, + {"name": "B", "phone": "2"}, + ] + filtered = self.endpoint._json2_filter_result(result, ["name"]) + self.assertEqual(filtered, [{"name": "A"}, {"name": "B"}]) + aliased = self.endpoint._json2_apply_aliases(filtered, {"name": "label"}) + self.assertEqual(aliased, [{"label": "A"}, {"label": "B"}]) + + def test_request_settings_constrained(self): + with self.assertRaises(ValidationError): + self._create_endpoint( + { + "name": "get_test", + "request_method": "GET", + "json2_method": "search_read", + } + ) + with self.assertRaises(ValidationError): + self._create_endpoint( + { + "name": "form_test", + "request_content_type": "text/html", + "json2_method": "search_read", + } + ) + + def test_validate_method_or_snippet_required(self): + with self.assertRaises(ValidationError): + self._create_endpoint({"name": "no_method_no_snippet"}) + + def test_validate_snippet_without_method_ok(self): + ep = self._create_endpoint( + {"name": "snippet_only", "json2_code_snippet": "result = []"} + ) + self.assertTrue(ep.json2_code_snippet) + + def test_dotted_response_fields_valid(self): + self.endpoint.json2_response_fields = "name\ncountry_id.name country" + fields, aliases = self.endpoint._json2_parse_response_fields() + self.assertEqual(fields, ["name", "country_id.name"]) + self.assertEqual(aliases, {"country_id.name": "country"}) + + def test_resolve_dotted_fields(self): + country = self.env["res.country"].search([("code", "=", "JP")], limit=1) + self.assertTrue(country) + result = [ + {"id": 1, "name": "Test", "country_id": (country.id, country.display_name)}, + {"id": 2, "name": "Test2", "country_id": False}, + ] + dotted_map = {"country_id": ["name", "code"]} + Model = self.env["res.partner"] + self.endpoint._json2_resolve_dotted_fields(Model, result, dotted_map) + self.assertEqual(result[0]["country_id.name"], country.name) + self.assertEqual(result[0]["country_id.code"], "JP") + self.assertFalse(result[1]["country_id.name"]) + self.assertFalse(result[1]["country_id.code"]) + + def test_resolve_dotted_fields_x2many(self): + tags = self.env["res.partner.category"].search([], limit=2) + if len(tags) < 2: + tags = self.env["res.partner.category"].create( + [{"name": "TagA"}, {"name": "TagB"}] + ) + result = [ + {"id": 1, "name": "Test", "category_id": tags.ids}, + {"id": 2, "name": "Test2", "category_id": []}, + ] + dotted_map = {"category_id": ["name"]} + Model = self.env["res.partner"] + self.endpoint._json2_resolve_dotted_fields(Model, result, dotted_map) + self.assertEqual(result[0]["category_id.name"], tags.mapped("name")) + self.assertEqual(result[1]["category_id.name"], []) + + def test_filter_excludes_base_when_only_dotted(self): + result = { + "name": "Test", + "country_id": (1, "Japan"), + "country_id.name": "Japan", + } + filtered = self.endpoint._json2_filter_result( + result, ["name", "country_id.name"] + ) + self.assertEqual(filtered, {"name": "Test", "country_id.name": "Japan"}) + self.assertNotIn("country_id", filtered) + aliased = self.endpoint._json2_apply_aliases( + filtered, {"country_id.name": "country"} + ) + self.assertEqual(aliased, {"name": "Test", "country": "Japan"}) + + def test_serialize_values(self): + result = { + "name": "Test", + "write_date": datetime(2026, 1, 15, 10, 30, 0), + "date": date(2026, 1, 15), + "avatar": b"\x89PNG", + } + serialized = self.endpoint._json2_serialize_values(result) + self.assertEqual(serialized["write_date"], "2026-01-15 10:30:00") + self.assertEqual(serialized["date"], "2026-01-15") + self.assertIsInstance(serialized["avatar"], str) + + def test_serialize_values_nested_list(self): + result = { + "name": "Test", + "tag_dates": [datetime(2026, 1, 1), datetime(2026, 2, 1)], + } + serialized = self.endpoint._json2_serialize_values(result) + self.assertEqual( + serialized["tag_dates"], ["2026-01-01 00:00:00", "2026-02-01 00:00:00"] + ) diff --git a/endpoint_json2/tests/test_endpoint_json2_controller.py b/endpoint_json2/tests/test_endpoint_json2_controller.py new file mode 100644 index 00000000..bc6fac59 --- /dev/null +++ b/endpoint_json2/tests/test_endpoint_json2_controller.py @@ -0,0 +1,356 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +import json +import os +from datetime import datetime, timedelta +from unittest import skipIf + +from odoo import Command +from odoo.tests import new_test_user +from odoo.tests.common import HttpCase + +CT_JSON = {"Content-Type": "application/json"} + + +@skipIf(os.getenv("SKIP_HTTP_CASE"), "HttpCase skipped") +class TestEndpointJson2Controller(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.api_user = new_test_user( + cls.env, + "json2_api_user", + groups="base.group_user", + ) + key = ( + cls.api_user.with_user(cls.api_user) + .env["res.users.apikeys"] + ._generate( + scope="rpc", + name="test", + expiration_date=datetime.now() + timedelta(days=1), + ) + ) + cls.bearer = {"Authorization": f"Bearer {key}"} + cls.model_partner = cls.env["ir.model"]._get("res.partner") + cls.group_user = cls.env.ref("base.group_user") + cls.endpoint = cls._create_endpoint( + { + "name": "get_partners", + "route_group": "contacts", + "json2_description": "Return partner records", + "json2_method": "search_read", + "json2_response_fields": "name\nemail", + "json2_default_domain": '[["is_company", "=", true]]', + } + ) + cls.env["endpoint.json2.param"].create( + [ + { + "endpoint_id": cls.endpoint.id, + "name": "domain", + "param_type": "list", + "required": False, + "default_value": "[]", + "sequence": 10, + }, + { + "endpoint_id": cls.endpoint.id, + "name": "limit", + "param_type": "integer", + "required": False, + "default_value": "10", + "sequence": 20, + }, + { + "endpoint_id": cls.endpoint.id, + "name": "fields", + "param_type": "list", + "required": False, + "sequence": 30, + }, + ] + ) + cls.env["endpoint.endpoint"].search([])._handle_registry_sync() + + @classmethod + def _create_endpoint(cls, vals): + # route is required but not precomputed in endpoint_route_handler; + # auto-derive it until that is fixed upstream. + defaults = { + "route_group": "test", + "exec_mode": "json2", + "request_method": "POST", + "request_content_type": "application/json", + "auth_type": "bearer", + "json2_model_id": cls.model_partner.id, + "json2_group_ids": [Command.link(cls.group_user.id)], + } + defaults.update(vals) + defaults.setdefault( + "route", f"/json2/{defaults['route_group']}/{defaults['name']}" + ) + return cls.env["endpoint.endpoint"].create(defaults) + + def tearDown(self): + self.env.registry.clear_cache("routing") + super().tearDown() + + def _call(self, route_group, endpoint_name, payload=None): + url = f"/json2/{route_group}/{endpoint_name}" + return self.url_open( + url, + data=json.dumps(payload or {}), + headers=CT_JSON | self.bearer, + ) + + def _call_doc(self, path=""): + url = f"/json2/doc{path}" + self.authenticate("json2_api_user", "json2_api_user") + return self.url_open( + url, + allow_redirects=False, + ) + + def test_dispatch_happy_path(self): + res = self._call("contacts", "get_partners") + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertIsInstance(data, list) + for row in data: + self.assertIn("name", row) + self.assertIn("email", row) + self.assertNotIn("phone", row) + + def test_dispatch_with_limit(self): + res = self._call("contacts", "get_partners", {"limit": 2}) + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertLessEqual(len(data), 2) + + def test_dispatch_not_found(self): + res = self._call("contacts", "nonexistent") + self.assertEqual(res.status_code, 404) + + def test_dispatch_inactive_endpoint(self): + endpoint = self._create_endpoint( + {"name": "inactive_test", "json2_method": "search_read", "active": False} + ) + endpoint._handle_registry_sync() + res = self._call("test", endpoint.name) + self.assertEqual(res.status_code, 404) + + def test_dispatch_required_param_missing(self): + endpoint = self._create_endpoint( + {"name": "get_required", "json2_method": "search_read"} + ) + self.env["endpoint.json2.param"].create( + { + "endpoint_id": endpoint.id, + "name": "domain", + "param_type": "list", + "required": True, + } + ) + endpoint._handle_registry_sync() + res = self._call("test", "get_required") + self.assertEqual(res.status_code, 422) + + def test_dispatch_wrong_param_type(self): + res = self._call("contacts", "get_partners", {"limit": "not_an_int"}) + self.assertEqual(res.status_code, 422) + + def test_dispatch_int_accepted_for_float(self): + endpoint = self._create_endpoint( + { + "name": "float_test", + "json2_method": "search_read", + "json2_response_fields": "name", + } + ) + self.env["endpoint.json2.param"].create( + { + "endpoint_id": endpoint.id, + "name": "limit", + "param_type": "float", + } + ) + endpoint._handle_registry_sync() + res = self._call("test", "float_test", {"limit": 5}) + self.assertEqual(res.status_code, 200) + + def test_dispatch_default_domain_applied(self): + self.env["res.partner"].create({"name": "Test Individual", "is_company": False}) + res = self._call("contacts", "get_partners") + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertTrue(data) + names = [row["name"] for row in data] + self.assertNotIn("Test Individual", names) + + def test_dispatch_domain_merge(self): + company = self.env["res.partner"].create( + {"name": "MergeCo", "ref": "MERGE_TEST", "is_company": True} + ) + res = self._call( + "contacts", + "get_partners", + {"domain": [["ref", "=", "MERGE_TEST"]]}, + ) + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["name"], company.name) + + def test_dispatch_dotted_fields(self): + country_jp = self.env["res.country"].search([("code", "=", "JP")], limit=1) + self.assertTrue(country_jp) + self.env["res.partner"].create( + { + "name": "DottedCo", + "ref": "DOTTED_TEST", + "is_company": True, + "country_id": country_jp.id, + } + ) + endpoint = self._create_endpoint( + { + "name": "dotted_partners", + "json2_method": "search_read", + "json2_response_fields": "name\ncountry_id.name country", + } + ) + self.env["endpoint.json2.param"].create( + { + "endpoint_id": endpoint.id, + "name": "domain", + "param_type": "list", + } + ) + endpoint._handle_registry_sync() + res = self._call( + "test", "dotted_partners", {"domain": [["ref", "=", "DOTTED_TEST"]]} + ) + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["name"], "DottedCo") + self.assertEqual(data[0]["country"], country_jp.name) + self.assertNotIn("country_id", data[0]) + self.assertNotIn("country_id.name", data[0]) + + def test_dispatch_group_access_denied(self): + group = self.env["res.groups"].create({"name": "Secret API Group"}) + endpoint = self._create_endpoint( + { + "name": "restricted", + "json2_method": "search_read", + "json2_group_ids": [Command.link(group.id)], + } + ) + endpoint._handle_registry_sync() + res = self._call("test", "restricted") + self.assertEqual(res.status_code, 403) + + def test_dispatch_group_access_granted(self): + group = self.env["res.groups"].create({"name": "Allowed API Group"}) + self.api_user.group_ids = [Command.link(group.id)] + endpoint = self._create_endpoint( + { + "name": "allowed", + "json2_method": "search_read", + "json2_group_ids": [Command.link(group.id)], + } + ) + endpoint._handle_registry_sync() + res = self._call("test", "allowed") + self.assertEqual(res.status_code, 200) + + def test_doc(self): + res = self._call_doc() + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertIn("contacts", data) + names = [ep["name"] for ep in data["contacts"]] + self.assertIn("get_partners", names) + + def test_dispatch_code_snippet(self): + partner = self.env["res.partner"].create( + {"name": "Original Name", "ref": "SNIPPET_TEST"} + ) + endpoint = self._create_endpoint( + { + "name": "update_name", + "json2_code_snippet": ( + 'p = Model.search([("ref", "=", params["ref"])], limit=1)\n' + "if not p:\n" + ' raise exceptions.NotFound("Not found")\n' + 'p.write({"name": params["new_name"]})\n' + 'result = {"ref": p.ref, "name": p.name}\n' + ), + } + ) + self.env["endpoint.json2.param"].create( + [ + { + "endpoint_id": endpoint.id, + "name": "ref", + "param_type": "string", + "required": True, + "sequence": 10, + }, + { + "endpoint_id": endpoint.id, + "name": "new_name", + "param_type": "string", + "required": True, + "sequence": 20, + }, + ] + ) + endpoint._handle_registry_sync() + res = self._call( + "test", + "update_name", + {"ref": "SNIPPET_TEST", "new_name": "Updated Name"}, + ) + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertEqual(data["name"], "Updated Name") + partner.invalidate_recordset() + self.assertEqual(partner.name, "Updated Name") + + def test_dispatch_code_snippet_missing_result(self): + endpoint = self._create_endpoint( + {"name": "bad_snippet", "json2_code_snippet": "x = 1"} + ) + endpoint._handle_registry_sync() + res = self._call("test", "bad_snippet") + self.assertEqual(res.status_code, 500) + + def test_dispatch_with_alias(self): + endpoint = self._create_endpoint( + { + "name": "aliased_partners", + "json2_method": "search_read", + "json2_response_fields": "name label\nemail", + } + ) + self.env["endpoint.json2.param"].create( + { + "endpoint_id": endpoint.id, + "name": "limit", + "param_type": "integer", + "default_value": "5", + } + ) + endpoint._handle_registry_sync() + res = self._call("test", "aliased_partners") + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertTrue(data) + for row in data: + self.assertIn("label", row) + self.assertNotIn("name", row) + self.assertIn("email", row) diff --git a/endpoint_json2/views/endpoint_views.xml b/endpoint_json2/views/endpoint_views.xml new file mode 100644 index 00000000..e0176454 --- /dev/null +++ b/endpoint_json2/views/endpoint_views.xml @@ -0,0 +1,75 @@ + + + + endpoint.endpoint.json2.form + endpoint.endpoint + + + + exec_mode == 'json2' + + + exec_mode == 'json2' + + + exec_mode == 'json2' + + + + exec_mode == 'json2' or not request_content_schema_applicable + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +