Skip to content

Commit add156f

Browse files
committed
[REF] fastapi_log: Extract common features to api_log
This way other APIs might use the new module `api_log` to store logs.
1 parent cac2381 commit add156f

25 files changed

+1011
-370
lines changed

api_log/README.rst

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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/16.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-16-0/rest-framework-16-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=16.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+
**Table of contents**
34+
35+
.. contents::
36+
:local:
37+
38+
Bug Tracker
39+
===========
40+
41+
Bugs are tracked on `GitHub Issues <https://github.com/OCA/rest-framework/issues>`_.
42+
In case of trouble, please check there if your issue has already been reported.
43+
If you spotted it first, help us to smash it by providing a detailed and welcomed
44+
`feedback <https://github.com/OCA/rest-framework/issues/new?body=module:%20api_log%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
45+
46+
Do not contact contributors directly about support or help with technical issues.
47+
48+
Credits
49+
=======
50+
51+
Authors
52+
-------
53+
54+
* Akretion
55+
56+
Contributors
57+
------------
58+
59+
- Florian Mounier [email protected]
60+
61+
Maintainers
62+
-----------
63+
64+
This module is maintained by the OCA.
65+
66+
.. image:: https://odoo-community.org/logo.png
67+
:alt: Odoo Community Association
68+
:target: https://odoo-community.org
69+
70+
OCA, or the Odoo Community Association, is a nonprofit organization whose
71+
mission is to support the collaborative development of Odoo features and
72+
promote its widespread use.
73+
74+
.. |maintainer-paradoxxxzero| image:: https://github.com/paradoxxxzero.png?size=40px
75+
:target: https://github.com/paradoxxxzero
76+
:alt: paradoxxxzero
77+
78+
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
79+
80+
|maintainer-paradoxxxzero|
81+
82+
This module is part of the `OCA/rest-framework <https://github.com/OCA/rest-framework/tree/16.0/api_log>`_ project on GitHub.
83+
84+
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 <[email protected]>
3+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
4+
5+
{
6+
"name": "API Log",
7+
"version": "16.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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import api_log

api_log/models/api_log.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
# Copyright 2025 Akretion (http://www.akretion.com).
2+
# @author Florian Mounier <[email protected]>
3+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
4+
import base64
5+
import json
6+
import time
7+
from traceback import format_exception
8+
9+
from werkzeug.exceptions import HTTPException as WerkzeugHTTPException
10+
11+
from odoo import api, fields, models
12+
13+
14+
class APILog(models.Model):
15+
_name = "api.log"
16+
_description = "Log for API"
17+
18+
# Request
19+
request_url = fields.Char()
20+
request_method = fields.Char()
21+
request_headers = fields.Json()
22+
request_body = fields.Binary(attachment=False)
23+
request_date = fields.Datetime()
24+
request_time = fields.Float()
25+
26+
# Response
27+
response_status_code = fields.Integer()
28+
response_headers = fields.Json()
29+
response_body = fields.Binary(attachment=False)
30+
response_date = fields.Datetime()
31+
response_time = fields.Float()
32+
33+
stack_trace = fields.Text()
34+
35+
# Derived fields
36+
name = fields.Char(compute="_compute_name", store=True)
37+
time = fields.Float(compute="_compute_time", store=True)
38+
request_preview = fields.Text(compute="_compute_request_preview")
39+
response_preview = fields.Text(compute="_compute_response_preview")
40+
request_b64 = fields.Binary(
41+
string="Request Content", compute="_compute_request_b64"
42+
)
43+
response_b64 = fields.Binary(
44+
string="Response Content", compute="_compute_response_b64"
45+
)
46+
request_headers_preview = fields.Text(compute="_compute_headers_preview")
47+
response_headers_preview = fields.Text(compute="_compute_headers_preview")
48+
request_content_type = fields.Char(
49+
compute="_compute_request_headers_derived", store=True
50+
)
51+
request_content_length = fields.Integer(
52+
compute="_compute_request_headers_derived", store=True
53+
)
54+
referrer = fields.Char(compute="_compute_request_headers_derived", store=True)
55+
response_content_type = fields.Char(
56+
compute="_compute_response_headers_derived", store=True
57+
)
58+
response_content_length = fields.Integer(
59+
compute="_compute_response_headers_derived", store=True
60+
)
61+
62+
def _headers_to_dict(self, headers):
63+
try:
64+
return {key.lower(): value for key, value in headers.items()}
65+
except AttributeError:
66+
return {}
67+
68+
def _current_time(self):
69+
return time.time_ns() / 1e9
70+
71+
@api.model
72+
def log_request(self, request):
73+
log_request_values = {
74+
"request_url": request.url,
75+
"request_method": request.method,
76+
"request_headers": self._headers_to_dict(request.headers),
77+
"request_body": request.data,
78+
"request_date": fields.Datetime.now(),
79+
"request_time": self._current_time(),
80+
}
81+
return self.create(log_request_values)
82+
83+
def log_response(self, response):
84+
log_response_values = {
85+
"response_status_code": response.status_code,
86+
"response_headers": self._headers_to_dict(response.headers),
87+
"response_body": response.data,
88+
"response_date": fields.Datetime.now(),
89+
"response_time": self._current_time(),
90+
}
91+
return self.write(log_response_values)
92+
93+
def _prepare_log_exception(self, exception):
94+
values = {
95+
"stack_trace": "".join(format_exception(exception)),
96+
"response_body": str(exception),
97+
"response_date": fields.Datetime.now(),
98+
"response_time": self._current_time(),
99+
}
100+
101+
if isinstance(exception, WerkzeugHTTPException):
102+
values.update(
103+
{
104+
"response_status_code": exception.code,
105+
"response_headers": self._headers_to_dict(exception.get_headers()),
106+
"response_body": exception.get_body(),
107+
}
108+
)
109+
return values
110+
111+
def log_exception(self, exception):
112+
try:
113+
exc_handling_response = self.env.registry["ir.http"]._handle_error(
114+
exception
115+
)
116+
self.log_response(exc_handling_response)
117+
except Exception as handling_exception:
118+
exception = handling_exception
119+
log_exception_values = self._prepare_log_exception(exception)
120+
return self.write(log_exception_values)
121+
122+
@api.depends("request_url", "request_method", "request_date")
123+
def _compute_name(self):
124+
for log in self:
125+
log.name = (
126+
f"{log.request_date.isoformat()} - "
127+
f"[{log.request_method}] {log.request_url}"
128+
)
129+
130+
@api.depends("request_time", "response_time")
131+
def _compute_time(self):
132+
for log in self:
133+
if log.request_time and log.response_time:
134+
log.time = log.response_time - log.request_time
135+
else:
136+
log.time = 0
137+
138+
@api.depends("request_headers")
139+
def _compute_request_headers_derived(self):
140+
for log in self:
141+
headers = log.request_headers or {}
142+
log.request_content_type = headers.get("content-type", "")
143+
log.request_content_length = headers.get("content-length", 0)
144+
log.referrer = headers.get("referer", "")
145+
146+
@api.depends("response_headers")
147+
def _compute_response_headers_derived(self):
148+
for log in self:
149+
headers = log.response_headers or {}
150+
log.response_content_type = headers.get("content-type", "")
151+
log.response_content_length = headers.get("content-length", 0)
152+
153+
@api.depends("request_body")
154+
def _compute_request_preview(self):
155+
for log in self.with_context(bin_size=False):
156+
log.request_preview = log._body_preview(log.request_body)
157+
158+
@api.depends("response_body")
159+
def _compute_response_preview(self):
160+
for log in self.with_context(bin_size=False):
161+
log.response_preview = log._body_preview(log.response_body)
162+
163+
def _body_preview(self, body):
164+
# Display the first 1000 characters of the body if it's a text content
165+
body_preview = False
166+
if body:
167+
try:
168+
body_preview = body.decode("utf-8", errors="ignore")
169+
if len(body_preview) > 1000:
170+
body_preview = body_preview[:1000] + "...\n(...)"
171+
except UnicodeDecodeError:
172+
body_preview = False
173+
return body_preview
174+
175+
@api.depends("request_headers", "response_headers")
176+
def _compute_headers_preview(self):
177+
for log in self:
178+
log.request_headers_preview = log._headers_preview(log.request_headers)
179+
log.response_headers_preview = log._headers_preview(log.response_headers)
180+
181+
def _headers_preview(self, headers):
182+
return json.dumps(headers, sort_keys=True, indent=4) if headers else False
183+
184+
@api.depends("request_body")
185+
def _compute_request_b64(self):
186+
for log in self:
187+
log.request_b64 = base64.b64encode(log.request_body or b"")
188+
189+
@api.depends("response_body")
190+
def _compute_response_b64(self):
191+
for log in self:
192+
log.response_b64 = base64.b64encode(log.response_body or b"")

api_log/readme/CONTRIBUTORS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Florian Mounier <[email protected]>

api_log/readme/DESCRIPTION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
This module allows to store request and response logs for any API.

fastapi_log/security/ir_model_access.xml renamed to api_log/security/ir_model_access.xml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
66
-->
77
<odoo>
8-
<record id="access_fastapi_log" model="ir.model.access">
9-
<field name="name">Fastapi Log: Read access</field>
10-
<field name="model_id" ref="model_fastapi_log" />
11-
<field name="group_id" ref="group_fastapi_log" />
8+
<record id="access_api_log" model="ir.model.access">
9+
<field name="name">API Log: Read access</field>
10+
<field name="model_id" ref="model_api_log" />
11+
<field name="group_id" ref="group_api_log" />
1212
<field name="perm_read" eval="True" />
1313
<field name="perm_write" eval="False" />
1414
<field name="perm_create" eval="False" />

fastapi_log/security/res_groups.xml renamed to api_log/security/res_groups.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
-->
77
<odoo>
88

9-
<record id="group_fastapi_log" model="res.groups">
10-
<field name="name">Fastapi Log Access</field>
9+
<record id="group_api_log" model="res.groups">
10+
<field name="name">API Log Access</field>
1111
<field
1212
name="users"
1313
eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"

0 commit comments

Comments
 (0)