diff --git a/endpoint_log/README.rst b/endpoint_log/README.rst new file mode 100644 index 00000000..43653bb6 --- /dev/null +++ b/endpoint_log/README.rst @@ -0,0 +1,126 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============ +Endpoint Log +============ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:b802c5eba07d74e17c9a0dd597cadeafc97886d35636eff3a7813a8d5903a558 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-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_log + :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_log + :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| + +This module logs the requests and responses of endpoints +(``endpoint.endpoint``) in the database, using the generic ``api.log`` +model of ``api_log``. + +Each log stores the request URL, method, headers, body and date, the +response status, headers and body, the elapsed time and, when the call +failed, the stack trace. Logs are written with a separate database +connection, so the logs of failed calls are kept even though the call's +own transaction is rolled back. + +.. 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: + +Usage +===== + +To log the calls of an endpoint, go to *Settings > Technical > +Endpoints*, open the endpoint and check *Log Requests* in the *Request* +group. The option is visible to members of the *API Log Access* group. + +The *Logs* smart button on the endpoint shows its logs. The logs of all +endpoints are listed in *Settings > Technical > Endpoint Logs*. + +Each logged response carries an ``API-Log-Entry-ID`` header with the ID +of its log, which helps to find the log of a given call. + +Known issues / Roadmap +====================== + +- Requests rejected before the endpoint is reached, such as an + authentication failure or a request method the route does not accept, + are not logged. +- The body of a form-encoded request is not logged, because Odoo parses + it into the request parameters before the endpoint is reached. +- Logs are never deleted automatically. + +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 `__: + + - Aung Ko Ko Lin + +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-AungKoKoLin1997| image:: https://github.com/AungKoKoLin1997.png?size=40px + :target: https://github.com/AungKoKoLin1997 + :alt: AungKoKoLin1997 + +Current `maintainer `__: + +|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_log/__init__.py b/endpoint_log/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/endpoint_log/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/endpoint_log/__manifest__.py b/endpoint_log/__manifest__.py new file mode 100644 index 00000000..bcc1232b --- /dev/null +++ b/endpoint_log/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Endpoint Log", + "summary": "Log endpoint requests in database", + "version": "19.0.1.0.0", + "license": "AGPL-3", + "development_status": "Alpha", + "author": "Quartile, Odoo Community Association (OCA)", + "maintainers": ["AungKoKoLin1997"], + "website": "https://github.com/OCA/web-api", + "category": "Tools", + "depends": ["api_log", "endpoint"], + "data": [ + "views/endpoint_endpoint_views.xml", + "views/endpoint_log_views.xml", + ], +} diff --git a/endpoint_log/models/__init__.py b/endpoint_log/models/__init__.py new file mode 100644 index 00000000..0d14a915 --- /dev/null +++ b/endpoint_log/models/__init__.py @@ -0,0 +1,3 @@ +from . import api_log +from . import endpoint_endpoint +from . import ir_http diff --git a/endpoint_log/models/api_log.py b/endpoint_log/models/api_log.py new file mode 100644 index 00000000..b1022076 --- /dev/null +++ b/endpoint_log/models/api_log.py @@ -0,0 +1,36 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from traceback import format_exception + +from werkzeug.exceptions import HTTPException + +from odoo import api, models + + +class APILog(models.Model): + _inherit = "api.log" + + @api.model + def _selection_collection_ref(self): + collections = super()._selection_collection_ref() + endpoint_model = self.env["endpoint.endpoint"] + collections.append((endpoint_model._name, endpoint_model._description)) + return collections + + def _prepare_log_response(self, response): + # For a plain Odoo route, ``ir.http._handle_error`` (called by + # ``log_exception``) returns the HTTP exception itself, not a response. + if isinstance(response, HTTPException): + response = response.get_response() + return super()._prepare_log_response(response) + + def _prepare_log_exception(self, exception): + if not isinstance(exception, HTTPException): + return super()._prepare_log_exception(exception) + # Log the response the exception renders: an exception may override + # ``get_response()`` (e.g. endpoint's ``RequestValidationError``), which + # ``code``, ``get_headers()`` and ``get_body()`` do not reflect. + values = self._prepare_log_response(exception.get_response()) + values["stack_trace"] = "".join(format_exception(exception)) + return values diff --git a/endpoint_log/models/endpoint_endpoint.py b/endpoint_log/models/endpoint_endpoint.py new file mode 100644 index 00000000..bf9211d7 --- /dev/null +++ b/endpoint_log/models/endpoint_endpoint.py @@ -0,0 +1,12 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class EndpointEndpoint(models.Model): + _name = "endpoint.endpoint" + _inherit = [ + "api.log_collection.mixin", + "endpoint.endpoint", + ] diff --git a/endpoint_log/models/ir_http.py b/endpoint_log/models/ir_http.py new file mode 100644 index 00000000..db3a4b4f --- /dev/null +++ b/endpoint_log/models/ir_http.py @@ -0,0 +1,89 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import functools +import logging +from contextlib import contextmanager + +from odoo import models +from odoo.http import request +from odoo.modules.registry import Registry + +from odoo.addons.endpoint.controllers.main import EndpointControllerMixin + +_logger = logging.getLogger(__name__) + + +class IrHttp(models.AbstractModel): + _inherit = "ir.http" + + @classmethod + def _get_endpoint_to_log(cls, endpoint): + """Return the endpoint record served by ``endpoint`` if it logs requests. + + Routes of ``endpoint.mixin`` records are registered by + ``endpoint_route_handler`` as a ``functools.partial`` of the controller + method, bound to the model name and the route. + """ + if not isinstance(endpoint, functools.partial): + return None + controller = getattr(endpoint.func, "__self__", None) + if not isinstance(controller, EndpointControllerMixin): + return None + if len(endpoint.args) < 2: + return None + model_name, route = endpoint.args[:2] + model = request.env.get(model_name) + if model is None or "log_requests" not in model._fields: + return None + record = model._find_endpoint(route) + return record if record.log_requests else None + + @classmethod + @contextmanager + def _create_endpoint_log_env(cls, request_env): + request_registry = request_env.registry + # `test_log_cr` is set on the registry by the tests (see tests/common.py) + # to capture the logs with a dedicated cursor. + test_log_cr = getattr(request_registry, "test_log_cr", None) + if test_log_cr is not None: + cr = test_log_cr + else: + # Use an independent cursor so that the logs are committed even when + # the request's transaction is rolled back because of an exception. + cr = Registry(request_registry.db_name).cursor() + try: + yield request_env(cr=cr, su=True) + finally: + if test_log_cr is None: + try: + cr.commit() # pylint: disable=invalid-commit + finally: + cr.close() + + @classmethod + def _dispatch(cls, endpoint): + record = cls._get_endpoint_to_log(endpoint) + if not record: + return super()._dispatch(endpoint) + with cls._create_endpoint_log_env(request.env) as log_env: + try: + log = log_env["api.log"].log_request( + request, {"collection_ref": f"{record._name},{record.id}"} + ) + except Exception as e: + _logger.warning("Failed to log request", exc_info=e) + log = None + try: + response = super()._dispatch(endpoint) + except Exception as response_exc: + try: + log and log.log_exception(response_exc) + except Exception as e: + _logger.warning("Failed to log exception", exc_info=e) + raise + try: + log and log.log_response(response) + except Exception as e: + _logger.warning("Failed to log response", exc_info=e) + return response diff --git a/endpoint_log/pyproject.toml b/endpoint_log/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/endpoint_log/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/endpoint_log/readme/CONTRIBUTORS.md b/endpoint_log/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..faae3280 --- /dev/null +++ b/endpoint_log/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Quartile](https://www.quartile.co): + - Aung Ko Ko Lin diff --git a/endpoint_log/readme/DESCRIPTION.md b/endpoint_log/readme/DESCRIPTION.md new file mode 100644 index 00000000..99a48e61 --- /dev/null +++ b/endpoint_log/readme/DESCRIPTION.md @@ -0,0 +1,7 @@ +This module logs the requests and responses of endpoints (`endpoint.endpoint`) in +the database, using the generic `api.log` model of `api_log`. + +Each log stores the request URL, method, headers, body and date, the response status, +headers and body, the elapsed time and, when the call failed, the stack trace. Logs are +written with a separate database connection, so the logs of failed calls are kept even +though the call's own transaction is rolled back. diff --git a/endpoint_log/readme/ROADMAP.md b/endpoint_log/readme/ROADMAP.md new file mode 100644 index 00000000..af32ed25 --- /dev/null +++ b/endpoint_log/readme/ROADMAP.md @@ -0,0 +1,5 @@ +- Requests rejected before the endpoint is reached, such as an authentication failure + or a request method the route does not accept, are not logged. +- The body of a form-encoded request is not logged, because Odoo parses it into the + request parameters before the endpoint is reached. +- Logs are never deleted automatically. diff --git a/endpoint_log/readme/USAGE.md b/endpoint_log/readme/USAGE.md new file mode 100644 index 00000000..209a7549 --- /dev/null +++ b/endpoint_log/readme/USAGE.md @@ -0,0 +1,9 @@ +To log the calls of an endpoint, go to *Settings > Technical > Endpoints*, open the +endpoint and check *Log Requests* in the *Request* group. The option is visible to +members of the *API Log Access* group. + +The *Logs* smart button on the endpoint shows its logs. The logs of all endpoints are +listed in *Settings > Technical > Endpoint Logs*. + +Each logged response carries an `API-Log-Entry-ID` header with the ID of its log, which +helps to find the log of a given call. diff --git a/endpoint_log/static/description/icon.png b/endpoint_log/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/endpoint_log/static/description/icon.png differ diff --git a/endpoint_log/static/description/index.html b/endpoint_log/static/description/index.html new file mode 100644 index 00000000..2ae2974b --- /dev/null +++ b/endpoint_log/static/description/index.html @@ -0,0 +1,470 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Endpoint Log

+ +

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

+

This module logs the requests and responses of endpoints +(endpoint.endpoint) in the database, using the generic api.log +model of api_log.

+

Each log stores the request URL, method, headers, body and date, the +response status, headers and body, the elapsed time and, when the call +failed, the stack trace. Logs are written with a separate database +connection, so the logs of failed calls are kept even though the call’s +own transaction is rolled back.

+
+

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

+ +
+

Usage

+

To log the calls of an endpoint, go to Settings > Technical > +Endpoints, open the endpoint and check Log Requests in the Request +group. The option is visible to members of the API Log Access group.

+

The Logs smart button on the endpoint shows its logs. The logs of all +endpoints are listed in Settings > Technical > Endpoint Logs.

+

Each logged response carries an API-Log-Entry-ID header with the ID +of its log, which helps to find the log of a given call.

+
+
+

Known issues / Roadmap

+
    +
  • Requests rejected before the endpoint is reached, such as an +authentication failure or a request method the route does not accept, +are not logged.
  • +
  • The body of a form-encoded request is not logged, because Odoo parses +it into the request parameters before the endpoint is reached.
  • +
  • Logs are never deleted automatically.
  • +
+
+
+

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 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_log/tests/__init__.py b/endpoint_log/tests/__init__.py new file mode 100644 index 00000000..17ce4969 --- /dev/null +++ b/endpoint_log/tests/__init__.py @@ -0,0 +1 @@ +from . import test_endpoint_log diff --git a/endpoint_log/tests/common.py b/endpoint_log/tests/common.py new file mode 100644 index 00000000..c8d752b1 --- /dev/null +++ b/endpoint_log/tests/common.py @@ -0,0 +1,110 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import threading +from contextlib import contextmanager + +from odoo.tests.common import RecordCapturer +from odoo.tests.test_cursor import TestCursor + +from odoo.addons.api_log.tests.common import Common as CommonAPILog + + +class Common(CommonAPILog): + @classmethod + def setUpClass(cls): + super().setUpClass() + demo_user = cls.env["res.users"].create( + {"login": "endpoint_log_user", "name": "Endpoint Log User"} + ) + vals = { + "request_method": "GET", + "auth_type": "public", + "exec_as_user_id": demo_user.id, + "exec_mode": "code", + "log_requests": True, + } + Endpoint = cls.env["endpoint.endpoint"] + cls.endpoint_ok = Endpoint.create( + dict( + vals, + name="Log OK", + route="/endpoint_log/test/ok", + code_snippet='result = {"payload": {"name": user.name}}', + ) + ) + cls.endpoint_post = Endpoint.create( + dict( + vals, + name="Log POST", + route="/endpoint_log/test/post", + request_method="POST", + request_content_type="application/json", + code_snippet='result = {"payload": request.get_json_data()}', + ) + ) + cls.endpoint_schema = Endpoint.create( + dict( + vals, + name="Log schema", + route="/endpoint_log/test/schema", + request_method="POST", + request_content_type="application/json", + request_content_schema=( + "type: object\nproperties:\n items:\n type: array\n" + ), + code_snippet='result = {"payload": {"ok": True}}', + ) + ) + cls.endpoint_user_error = Endpoint.create( + dict( + vals, + name="Log user error", + route="/endpoint_log/test/user_error", + code_snippet='raise exceptions.ValidationError("Sorry, not allowed")', + ) + ) + cls.endpoint_bare_error = Endpoint.create( + dict( + vals, + name="Log bare error", + route="/endpoint_log/test/bare_error", + code_snippet="result = 1 / 0", + ) + ) + cls.endpoints = ( + cls.endpoint_ok + | cls.endpoint_post + | cls.endpoint_schema + | cls.endpoint_user_error + | cls.endpoint_bare_error + ) + cls.endpoints._handle_registry_sync() + + def setUp(self): + super().setUp() + # Use a side test cursor to be able to get exception logs + reg = self.env.registry + reg.test_log_lock = threading.RLock() + reg.test_log_cr = TestCursor(reg._db.cursor(), reg.test_log_lock, False) + + def tearDown(self): + reg = self.env.registry + reg.test_log_cr.rollback() + reg.test_log_cr.close() + # Also close the real cursor to avoid unclosed errors + reg.test_log_cr._cursor.close() + reg.test_log_cr = None + reg.test_log_lock = None + # Clear cache for method ``ir.http.routing_map()`` + reg.clear_cache("routing") + super().tearDown() + + @contextmanager + def log_capturer(self, endpoint): + log_env = self.env(cr=self.env.registry.test_log_cr) + with RecordCapturer( + log_env[self.log_model._name], + [("collection_ref", "=", f"{endpoint._name},{endpoint.id}")], + ) as capturer: + yield capturer diff --git a/endpoint_log/tests/test_endpoint_log.py b/endpoint_log/tests/test_endpoint_log.py new file mode 100644 index 00000000..3a9e496a --- /dev/null +++ b/endpoint_log/tests/test_endpoint_log.py @@ -0,0 +1,94 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +import os +import unittest + +from odoo.tools import mute_logger + +from .common import Common + + +@unittest.skipIf(os.getenv("SKIP_HTTP_CASE"), "TestEndpointLog skipped") +class TestEndpointLog(Common): + def test_no_log_if_disabled(self): + self.endpoint_ok.log_requests = False + with self.log_capturer(self.endpoint_ok) as capturer: + response = self.url_open("/endpoint_log/test/ok") + self.assertEqual(response.status_code, 200) + self.assertFalse(capturer.records) + self.assertNotIn("API-Log-Entry-ID", response.headers) + + def test_log_simple(self): + with self.log_capturer(self.endpoint_ok) as capturer: + response = self.url_open("/endpoint_log/test/ok") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(capturer.records), 1) + log = capturer.records + self.assertEqual(response.headers["API-Log-Entry-ID"], str(log.id)) + self.assertTrue(log.request_url.endswith("/endpoint_log/test/ok")) + self.assertEqual(log.request_method, "GET") + self.assertEqual(log.response_status_code, 200) + self.assertEqual(json.loads(log.response_body), {"name": "Endpoint Log User"}) + self.assertTrue(log.time > 0) + self.assertFalse(log.stack_trace) + # ``collection_ref`` is matched by ``log_capturer``. Its derived + # ``collection_model`` and ``collection_id`` cannot be checked here: the + # endpoint is not committed, so the log cursor cannot resolve it. + + def test_log_post_body(self): + payload = {"items": [1, 2, 3]} + with self.log_capturer(self.endpoint_post) as capturer: + response = self.url_open( + "/endpoint_log/test/post", + data=json.dumps(payload), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), payload) + log = capturer.records + self.assertEqual(log.request_method, "POST") + self.assertEqual(json.loads(log.request_body), payload) + self.assertEqual(json.loads(log.response_body), payload) + + @mute_logger("odoo.http", "endpoint.endpoint") + def test_log_request_validation_error(self): + # Rejected by ``_validate_request`` before the endpoint's code runs + with self.log_capturer(self.endpoint_schema) as capturer: + response = self.url_open( + "/endpoint_log/test/schema", + data=json.dumps({"items": "not a list"}), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 400) + log = capturer.records + self.assertEqual(len(log), 1) + self.assertEqual(log.response_status_code, 400) + self.assertEqual(json.loads(log.response_body), response.json()) + self.assertIn("RequestValidationError", log.stack_trace) + + @mute_logger("odoo.http", "endpoint.endpoint") + def test_log_user_error(self): + with self.log_capturer(self.endpoint_user_error) as capturer: + response = self.url_open("/endpoint_log/test/user_error") + self.assertEqual(response.status_code, 400) + log = capturer.records + self.assertEqual(len(log), 1) + self.assertEqual(log.response_status_code, 400) + self.assertTrue(log.time > 0) + # The original error is chained to the BadRequest raised by the endpoint + self.assertIn( + "odoo.exceptions.ValidationError: Sorry, not allowed", log.stack_trace + ) + self.assertIn("werkzeug.exceptions.BadRequest", log.stack_trace) + + @mute_logger("odoo.http") + def test_log_bare_error(self): + with self.log_capturer(self.endpoint_bare_error) as capturer: + response = self.url_open("/endpoint_log/test/bare_error") + self.assertEqual(response.status_code, 500) + log = capturer.records + self.assertEqual(len(log), 1) + self.assertEqual(log.response_status_code, 500) + self.assertIn("ZeroDivisionError: division by zero", log.stack_trace) diff --git a/endpoint_log/views/endpoint_endpoint_views.xml b/endpoint_log/views/endpoint_endpoint_views.xml new file mode 100644 index 00000000..b7a459b6 --- /dev/null +++ b/endpoint_log/views/endpoint_endpoint_views.xml @@ -0,0 +1,29 @@ + + + + + endpoint.endpoint + + +
+
+ +
+
+ + + + +
+
diff --git a/endpoint_log/views/endpoint_log_views.xml b/endpoint_log/views/endpoint_log_views.xml new file mode 100644 index 00000000..519ba84e --- /dev/null +++ b/endpoint_log/views/endpoint_log_views.xml @@ -0,0 +1,20 @@ + + + + + Endpoint Logs + api.log + [("collection_model", "=", "endpoint.endpoint")] + list,form + + + + diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 00000000..cb2dfb6b --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +odoo-addon-api_log @ git+https://github.com/OCA/rest-framework.git@refs/pull/616/head#subdirectory=api_log