Skip to content

Commit 340b576

Browse files
committed
Merge PR #584 into 18.0
Signed-off-by lmignon
2 parents 5154573 + 5da32d5 commit 340b576

63 files changed

Lines changed: 3425 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api_log/README.rst

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
=======
2+
API Log
3+
=======
4+
5+
..
6+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
7+
!! This file is generated by oca-gen-addon-readme !!
8+
!! changes will be overwritten. !!
9+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
10+
!! source digest: sha256:ef0c0bceb8ae27bcfebaebc22e2fb4747475f2a2c60dd2d410bc40b6efee9b6a
11+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
12+
13+
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
14+
:target: https://odoo-community.org/page/development-status
15+
:alt: Beta
16+
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
17+
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
18+
:alt: License: AGPL-3
19+
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Frest--framework-lightgray.png?logo=github
20+
:target: https://github.com/OCA/rest-framework/tree/18.0/api_log
21+
:alt: OCA/rest-framework
22+
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
23+
:target: https://translation.odoo-community.org/projects/rest-framework-18-0/rest-framework-18-0-api_log
24+
:alt: Translate me on Weblate
25+
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
26+
:target: https://runboat.odoo-community.org/builds?repo=OCA/rest-framework&target_branch=18.0
27+
:alt: Try me on Runboat
28+
29+
|badge1| |badge2| |badge3| |badge4| |badge5|
30+
31+
This module allows to store request and response logs for any API.
32+
33+
When a response is logged, the header ``API_LOG_ENTRY_ID`` is injected
34+
in the response header. This header stores the identifier of the log
35+
record produced from the response.
36+
37+
**Table of contents**
38+
39+
.. contents::
40+
:local:
41+
42+
Bug Tracker
43+
===========
44+
45+
Bugs are tracked on `GitHub Issues <https://github.com/OCA/rest-framework/issues>`_.
46+
In case of trouble, please check there if your issue has already been reported.
47+
If you spotted it first, help us to smash it by providing a detailed and welcomed
48+
`feedback <https://github.com/OCA/rest-framework/issues/new?body=module:%20api_log%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
49+
50+
Do not contact contributors directly about support or help with technical issues.
51+
52+
Credits
53+
=======
54+
55+
Authors
56+
-------
57+
58+
* Akretion
59+
60+
Contributors
61+
------------
62+
63+
- Florian Mounier florian.mounier@akretion.com
64+
- Guewen Baconnier guewen.baconnier@camptocamp.com
65+
- Simone Orsi simahawk@gmail.com
66+
- `PyTech <https://www.pytech.it>`__:
67+
68+
- Simone Rubino <simone.rubino@pytech.it>
69+
70+
Maintainers
71+
-----------
72+
73+
This module is maintained by the OCA.
74+
75+
.. image:: https://odoo-community.org/logo.png
76+
:alt: Odoo Community Association
77+
:target: https://odoo-community.org
78+
79+
OCA, or the Odoo Community Association, is a nonprofit organization whose
80+
mission is to support the collaborative development of Odoo features and
81+
promote its widespread use.
82+
83+
.. |maintainer-paradoxxxzero| image:: https://github.com/paradoxxxzero.png?size=40px
84+
:target: https://github.com/paradoxxxzero
85+
:alt: paradoxxxzero
86+
87+
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
88+
89+
|maintainer-paradoxxxzero|
90+
91+
This module is part of the `OCA/rest-framework <https://github.com/OCA/rest-framework/tree/18.0/api_log>`_ project on GitHub.
92+
93+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

api_log/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import models

api_log/__manifest__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Copyright 2025 Akretion (http://www.akretion.com).
2+
# @author Florian Mounier <florian.mounier@akretion.com>
3+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
4+
5+
{
6+
"name": "API Log",
7+
"version": "18.0.1.0.0",
8+
"author": "Akretion, Odoo Community Association (OCA)",
9+
"license": "AGPL-3",
10+
"summary": "Log API requests in database",
11+
"category": "Tools",
12+
"depends": ["web"],
13+
"website": "https://github.com/OCA/rest-framework",
14+
"data": [
15+
"security/res_groups.xml",
16+
"security/ir_model_access.xml",
17+
"views/api_log_views.xml",
18+
],
19+
"maintainers": ["paradoxxxzero"],
20+
}

api_log/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import api_log_collection
2+
from . import api_log

api_log/models/api_log.py

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
# Copyright 2025 Akretion (http://www.akretion.com).
2+
# @author Florian Mounier <florian.mounier@akretion.com>
3+
# Copyright 2025 Simone Rubino - PyTech
4+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
5+
6+
import base64
7+
import json
8+
import time
9+
from traceback import format_exception
10+
11+
from werkzeug.exceptions import HTTPException as WerkzeugHTTPException
12+
13+
from odoo import api, fields, models
14+
15+
16+
class APILog(models.Model):
17+
_name = "api.log"
18+
_description = "Log for API"
19+
_order = "id desc"
20+
21+
collection_ref = fields.Reference(
22+
selection="_selection_collection_ref",
23+
)
24+
collection_model = fields.Char(
25+
compute="_compute_collection",
26+
store=True,
27+
index=True,
28+
)
29+
collection_id = fields.Integer(
30+
compute="_compute_collection",
31+
store=True,
32+
index=True,
33+
)
34+
35+
# Request
36+
request_url = fields.Char()
37+
request_method = fields.Char()
38+
request_headers = fields.Json()
39+
request_body = fields.Binary(attachment=False)
40+
request_date = fields.Datetime()
41+
request_time = fields.Float()
42+
43+
# Response
44+
response_status_code = fields.Integer()
45+
response_headers = fields.Json()
46+
response_body = fields.Binary(attachment=False)
47+
response_date = fields.Datetime()
48+
response_time = fields.Float()
49+
50+
stack_trace = fields.Text()
51+
52+
# Derived fields
53+
name = fields.Char(compute="_compute_name", store=True)
54+
time = fields.Float(compute="_compute_time", store=True)
55+
request_preview = fields.Text(compute="_compute_request_preview")
56+
response_preview = fields.Text(compute="_compute_response_preview")
57+
# Binary fields are useful to download the payload in case of file download/upload
58+
request_b64 = fields.Binary(
59+
string="Request Content", compute="_compute_request_b64"
60+
)
61+
response_b64 = fields.Binary(
62+
string="Response Content", compute="_compute_response_b64"
63+
)
64+
request_headers_preview = fields.Text(compute="_compute_headers_preview")
65+
response_headers_preview = fields.Text(compute="_compute_headers_preview")
66+
request_content_type = fields.Char(
67+
compute="_compute_request_headers_derived", store=True
68+
)
69+
request_content_length = fields.Integer(
70+
compute="_compute_request_headers_derived", store=True
71+
)
72+
referrer = fields.Char(compute="_compute_request_headers_derived", store=True)
73+
response_content_type = fields.Char(
74+
compute="_compute_response_headers_derived", store=True
75+
)
76+
response_content_length = fields.Integer(
77+
compute="_compute_response_headers_derived", store=True
78+
)
79+
80+
@api.model
81+
def _selection_collection_ref(self):
82+
return []
83+
84+
@api.depends(
85+
"collection_ref",
86+
)
87+
def _compute_collection(self):
88+
for log in self:
89+
collection = log.collection_ref
90+
if collection:
91+
collection_model = collection._name
92+
collection_id = collection.id
93+
else:
94+
collection_model = False
95+
collection_id = False
96+
log.collection_model = collection_model
97+
log.collection_id = collection_id
98+
99+
@api.model
100+
def _headers_hidden_keys(self):
101+
"""Header keys that should not be logged.
102+
103+
They might contains sensitive data.
104+
"""
105+
return (
106+
"Api-Key",
107+
"Cookie",
108+
)
109+
110+
@api.model
111+
def _sanitize_headers_dict(self, headers_dict):
112+
keys_to_hide = self._headers_hidden_keys()
113+
for key in headers_dict:
114+
if key in keys_to_hide:
115+
headers_dict[key] = "<redacted>"
116+
return headers_dict
117+
118+
@api.model
119+
def _headers_to_dict(self, headers):
120+
headers_dict = {key: value for key, value in headers.items()}
121+
return self._sanitize_headers_dict(headers_dict)
122+
123+
def _current_time(self):
124+
return time.time_ns() / 1e9
125+
126+
@api.model
127+
def _get_http_request(self, request):
128+
return request.httprequest
129+
130+
@api.model
131+
def _get_request_body(self, request):
132+
"""Take extra care with the request's body because it might get consumed."""
133+
httprequest = self._get_http_request(request)
134+
return httprequest.data
135+
136+
@api.model
137+
def _prepare_log_request(self, request):
138+
httprequest = self._get_http_request(request)
139+
log_request_values = {
140+
"request_url": httprequest.url,
141+
"request_method": httprequest.method,
142+
"request_headers": self._headers_to_dict(httprequest.headers),
143+
"request_body": self._get_request_body(request),
144+
"request_date": fields.Datetime.now(),
145+
"request_time": self._current_time(),
146+
}
147+
return log_request_values
148+
149+
@api.model
150+
def log_request(self, request, override_log_values=None):
151+
log_request_values = self._prepare_log_request(request)
152+
log_request_values.update(override_log_values or {})
153+
return self.sudo().create(log_request_values)
154+
155+
def _inject_log_entry(self, values_dict):
156+
values_dict["API-Log-Entry-ID"] = str(self.id)
157+
return values_dict
158+
159+
def _prepare_log_response(self, response):
160+
self._inject_log_entry(response.headers)
161+
headers_dict = self._headers_to_dict(response.headers)
162+
return {
163+
"response_status_code": response.status_code,
164+
"response_headers": headers_dict,
165+
"response_body": response.data,
166+
"response_date": fields.Datetime.now(),
167+
"response_time": self._current_time(),
168+
}
169+
170+
def log_response(self, response):
171+
log_response_values = self._prepare_log_response(response)
172+
return self.sudo().write(log_response_values)
173+
174+
def _prepare_log_exception(self, exception):
175+
exception.headers = getattr(exception, "headers", {})
176+
values = {
177+
"stack_trace": "".join(format_exception(exception)),
178+
"response_headers": self._inject_log_entry(exception.headers),
179+
"response_body": str(exception),
180+
"response_date": fields.Datetime.now(),
181+
"response_time": self._current_time(),
182+
}
183+
184+
if isinstance(exception, WerkzeugHTTPException):
185+
values.update(
186+
{
187+
"response_status_code": exception.code,
188+
"response_headers": self._headers_to_dict(exception.get_headers()),
189+
"response_body": exception.get_body(),
190+
}
191+
)
192+
return values
193+
194+
def log_exception(self, exception):
195+
try:
196+
exc_handling_response = self.env.registry["ir.http"]._handle_error(
197+
exception
198+
)
199+
self.log_response(exc_handling_response)
200+
except Exception as handling_exception:
201+
exception = handling_exception
202+
log_exception_values = self._prepare_log_exception(exception)
203+
return self.sudo().write(log_exception_values)
204+
205+
@api.depends("request_url", "request_method", "request_date")
206+
def _compute_name(self):
207+
for log in self:
208+
log.name = (
209+
f"{log.request_date.isoformat()} - "
210+
f"[{log.request_method}] {log.request_url}"
211+
)
212+
213+
@api.depends("request_time", "response_time")
214+
def _compute_time(self):
215+
for log in self:
216+
if log.request_time and log.response_time:
217+
log.time = log.response_time - log.request_time
218+
else:
219+
log.time = 0
220+
221+
@api.depends("request_headers")
222+
def _compute_request_headers_derived(self):
223+
for log in self:
224+
headers = log.request_headers or {}
225+
log.request_content_type = headers.get("content-type", "")
226+
log.request_content_length = headers.get("content-length", 0)
227+
log.referrer = headers.get("referer", "")
228+
229+
@api.depends("response_headers")
230+
def _compute_response_headers_derived(self):
231+
for log in self:
232+
headers = log.response_headers or {}
233+
log.response_content_type = headers.get("content-type", "")
234+
log.response_content_length = headers.get("content-length", 0)
235+
236+
@api.depends("request_body")
237+
def _compute_request_preview(self):
238+
for log in self.with_context(bin_size=False):
239+
log.request_preview = log._body_preview(log.request_body)
240+
241+
@api.depends("response_body")
242+
def _compute_response_preview(self):
243+
for log in self.with_context(bin_size=False):
244+
log.response_preview = log._body_preview(log.response_body)
245+
246+
def _body_preview(self, body):
247+
# Display the first 1000 characters of the body if it's a text content
248+
body_preview = False
249+
if body:
250+
try:
251+
body_preview = body.decode("utf-8", errors="ignore")
252+
if len(body_preview) > 1000:
253+
body_preview = body_preview[:1000] + "...\n(...)"
254+
except UnicodeDecodeError:
255+
body_preview = False
256+
return body_preview
257+
258+
@api.depends("request_headers", "response_headers")
259+
def _compute_headers_preview(self):
260+
for log in self:
261+
log.request_headers_preview = log._headers_preview(log.request_headers)
262+
log.response_headers_preview = log._headers_preview(log.response_headers)
263+
264+
def _headers_preview(self, headers):
265+
return json.dumps(headers, sort_keys=True, indent=4) if headers else False
266+
267+
@api.depends("request_body")
268+
def _compute_request_b64(self):
269+
for log in self:
270+
log.request_b64 = base64.b64encode(log.request_body or b"")
271+
272+
@api.depends("response_body")
273+
def _compute_response_b64(self):
274+
for log in self:
275+
log.response_b64 = base64.b64encode(log.response_body or b"")

0 commit comments

Comments
 (0)