From a6a9d1bb5c28061adde1a7dfa12da08ed362d799 Mon Sep 17 00:00:00 2001 From: Aungkokolin1997 Date: Tue, 27 Oct 2026 07:33:13 +0000 Subject: [PATCH] [ADD] endpoint_log: log endpoint requests in database Endpoints have no record of the calls they receive: the server log holds only what the code chooses to write, and nothing ties it to the request and response. This makes webhook and integration issues hard to debug in production. This module plugs endpoint.endpoint into api_log, as fastapi_log does for fastapi.endpoint: a "Log Requests" switch per endpoint, and the request, response, timing and stack trace of each call stored in api.log. Logging hooks into ir.http._dispatch rather than the endpoint controller, because endpoint routes call their controller class by its dotted path, so a controller override in another module is never reached. Logs are written through a separate cursor so the logs of failed calls survive the rollback of their transaction. api.log is also adapted to plain Odoo routes, where an HTTP error reaches the logger as a werkzeug exception rather than a response. Assisted-by: Claude Opus 5.5 --- endpoint_log/README.rst | 126 +++++ endpoint_log/__init__.py | 1 + endpoint_log/__manifest__.py | 19 + endpoint_log/models/__init__.py | 3 + endpoint_log/models/api_log.py | 36 ++ endpoint_log/models/endpoint_endpoint.py | 12 + endpoint_log/models/ir_http.py | 89 ++++ endpoint_log/pyproject.toml | 3 + endpoint_log/readme/CONTRIBUTORS.md | 2 + endpoint_log/readme/DESCRIPTION.md | 7 + endpoint_log/readme/ROADMAP.md | 5 + endpoint_log/readme/USAGE.md | 9 + endpoint_log/static/description/icon.png | Bin 0 -> 9455 bytes endpoint_log/static/description/index.html | 470 ++++++++++++++++++ endpoint_log/tests/__init__.py | 1 + endpoint_log/tests/common.py | 110 ++++ endpoint_log/tests/test_endpoint_log.py | 94 ++++ .../views/endpoint_endpoint_views.xml | 29 ++ endpoint_log/views/endpoint_log_views.xml | 20 + test-requirements.txt | 1 + 20 files changed, 1037 insertions(+) create mode 100644 endpoint_log/README.rst create mode 100644 endpoint_log/__init__.py create mode 100644 endpoint_log/__manifest__.py create mode 100644 endpoint_log/models/__init__.py create mode 100644 endpoint_log/models/api_log.py create mode 100644 endpoint_log/models/endpoint_endpoint.py create mode 100644 endpoint_log/models/ir_http.py create mode 100644 endpoint_log/pyproject.toml create mode 100644 endpoint_log/readme/CONTRIBUTORS.md create mode 100644 endpoint_log/readme/DESCRIPTION.md create mode 100644 endpoint_log/readme/ROADMAP.md create mode 100644 endpoint_log/readme/USAGE.md create mode 100644 endpoint_log/static/description/icon.png create mode 100644 endpoint_log/static/description/index.html create mode 100644 endpoint_log/tests/__init__.py create mode 100644 endpoint_log/tests/common.py create mode 100644 endpoint_log/tests/test_endpoint_log.py create mode 100644 endpoint_log/views/endpoint_endpoint_views.xml create mode 100644 endpoint_log/views/endpoint_log_views.xml create mode 100644 test-requirements.txt 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 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 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