Skip to content

Commit 01b4d79

Browse files
committed
feat(spp_api_v2): add outgoing API log model, service, and menu
Wire up the spp.api.outgoing.log model with manifest registration, ACL rules (viewer read-only, officer/manager read+write), the OutgoingApiLogService wrapper with payload truncation and error resilience, and an "Outgoing API Logs" menu item under API V2.
1 parent ce27e66 commit 01b4d79

File tree

8 files changed

+394
-53
lines changed

8 files changed

+394
-53
lines changed

spp_api_v2/__manifest__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"views/api_extension_views.xml",
3838
"views/api_path_views.xml",
3939
"views/consent_views.xml",
40+
"views/api_outgoing_log_views.xml",
4041
"views/menu.xml",
4142
],
4243
"assets": {},

spp_api_v2/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from . import api_audit_log
22
from . import api_client
3+
from . import api_outgoing_log
34
from . import api_client_scope
45
from . import api_extension
56
from . import api_filter_preset

spp_api_v2/security/ir.model.access.csv

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,6 @@ access_spp_api_client_show_secret_wizard_admin,spp.api.client.show.secret.wizard
5757
access_spp_api_audit_log_viewer,spp.api.audit.log viewer,model_spp_api_audit_log,group_api_v2_viewer,1,0,0,0
5858
access_spp_api_audit_log_officer,spp.api.audit.log officer,model_spp_api_audit_log,group_api_v2_officer,1,0,1,0
5959
access_spp_api_audit_log_manager,spp.api.audit.log manager,model_spp_api_audit_log,group_api_v2_manager,1,0,1,0
60+
access_spp_api_outgoing_log_viewer,spp.api.outgoing.log viewer,model_spp_api_outgoing_log,group_api_v2_viewer,1,0,0,0
61+
access_spp_api_outgoing_log_officer,spp.api.outgoing.log officer,model_spp_api_outgoing_log,group_api_v2_officer,1,0,1,0
62+
access_spp_api_outgoing_log_manager,spp.api.outgoing.log manager,model_spp_api_outgoing_log,group_api_v2_manager,1,0,1,0

spp_api_v2/services/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from . import api_audit_service
22
from . import auth_service
3+
from . import outgoing_api_log_service
34
from . import bundle_service
45
from . import consent_service
56
from . import filter_service
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
"""Service for logging outgoing API calls."""
3+
4+
import json
5+
import logging
6+
7+
import psycopg2
8+
9+
from odoo.api import Environment
10+
11+
_logger = logging.getLogger(__name__)
12+
13+
14+
class OutgoingApiLogService:
15+
"""
16+
Service for logging outgoing HTTP calls to the audit log.
17+
18+
Wraps spp.api.outgoing.log with try/except so logging failures
19+
never prevent the actual API call from succeeding.
20+
21+
Usage:
22+
service = OutgoingApiLogService(env, "DCI Client", "crvs_main")
23+
service.log_call(
24+
url="https://crvs.example.org/api/registry/sync/search",
25+
endpoint="/registry/sync/search",
26+
http_method="POST",
27+
request_summary={"header": {...}},
28+
response_summary={"header": {...}},
29+
response_status_code=200,
30+
duration_ms=350,
31+
origin_model="spp.dci.data.source",
32+
origin_record_id=42,
33+
status="success",
34+
)
35+
"""
36+
37+
def __init__(
38+
self,
39+
env: Environment,
40+
service_name: str,
41+
service_code: str,
42+
user_id: int = None,
43+
):
44+
"""
45+
Initialize the outgoing API log service.
46+
47+
Args:
48+
env: Odoo environment
49+
service_name: Human-readable service name (e.g. "DCI Client")
50+
service_code: Machine-readable service code (e.g. "crvs_main")
51+
user_id: User ID to record (defaults to env.uid)
52+
"""
53+
self.env = env
54+
self.service_name = service_name
55+
self.service_code = service_code
56+
self.user_id = user_id or env.uid
57+
58+
def log_call(
59+
self,
60+
url: str,
61+
endpoint: str = None,
62+
http_method: str = "POST",
63+
request_summary: dict = None,
64+
response_summary: dict = None,
65+
response_status_code: int = None,
66+
duration_ms: int = None,
67+
origin_model: str = None,
68+
origin_record_id: int = None,
69+
status: str = "success",
70+
error_detail: str = None,
71+
):
72+
"""
73+
Log an outgoing API call.
74+
75+
Returns the created record, or None if logging fails.
76+
Logging failures never raise exceptions.
77+
"""
78+
try:
79+
# Truncate large payloads
80+
truncated_request = self._truncate_payload(request_summary)
81+
truncated_response = self._truncate_payload(response_summary)
82+
83+
return (
84+
self.env["spp.api.outgoing.log"]
85+
.sudo()
86+
.log_call(
87+
url=url,
88+
endpoint=endpoint,
89+
http_method=http_method,
90+
request_summary=truncated_request,
91+
response_summary=truncated_response,
92+
response_status_code=response_status_code,
93+
user_id=self.user_id,
94+
origin_model=origin_model,
95+
origin_record_id=origin_record_id,
96+
duration_ms=duration_ms,
97+
service_name=self.service_name,
98+
service_code=self.service_code,
99+
status=status,
100+
error_detail=error_detail,
101+
)
102+
)
103+
except (KeyError, AttributeError, TypeError) as e:
104+
_logger.warning("Failed to log outgoing API call due to data error: %s", type(e).__name__)
105+
return None
106+
except (psycopg2.Error, ValueError, RuntimeError):
107+
_logger.exception("Failed to log outgoing API call")
108+
return None
109+
110+
def _truncate_payload(self, payload, max_length=10000):
111+
"""Truncate large payloads for DB storage.
112+
113+
Args:
114+
payload: Dict payload to potentially truncate
115+
max_length: Maximum JSON string length (default 10000)
116+
117+
Returns:
118+
Original payload if within limit, or truncated version
119+
"""
120+
if payload is None:
121+
return None
122+
123+
try:
124+
serialized = json.dumps(payload)
125+
except (TypeError, ValueError):
126+
return {"_truncated": True, "_error": "Could not serialize payload"}
127+
128+
if len(serialized) <= max_length:
129+
return payload
130+
131+
return {
132+
"_truncated": True,
133+
"_original_length": len(serialized),
134+
"_preview": serialized[:max_length],
135+
}

spp_api_v2/tests/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from . import common
55
from . import test_api_audit_log
66
from . import test_api_audit_service
7+
from . import test_api_outgoing_log
8+
from . import test_outgoing_api_log_service
79
from . import test_api_auth_enforcement
810
from . import test_api_client
911
from . import test_api_consent_matching
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
"""Tests for OutgoingApiLogService"""
3+
4+
from odoo.tests.common import TransactionCase
5+
6+
from ..services.outgoing_api_log_service import OutgoingApiLogService
7+
8+
9+
class TestOutgoingApiLogService(TransactionCase):
10+
"""Test OutgoingApiLogService functionality"""
11+
12+
def setUp(self):
13+
super().setUp()
14+
self.outgoing_log_model = self.env["spp.api.outgoing.log"]
15+
16+
def test_log_call_creates_record(self):
17+
"""log_call creates outgoing log record via the service"""
18+
service = OutgoingApiLogService(
19+
self.env,
20+
service_name="DCI Client",
21+
service_code="crvs_main",
22+
)
23+
24+
result = service.log_call(
25+
url="https://crvs.example.org/api/registry/sync/search",
26+
endpoint="/registry/sync/search",
27+
http_method="POST",
28+
request_summary={"header": {"action": "search"}},
29+
response_summary={"header": {"status": "success"}},
30+
response_status_code=200,
31+
duration_ms=350,
32+
origin_model="spp.dci.data.source",
33+
origin_record_id=42,
34+
status="success",
35+
)
36+
37+
self.assertTrue(result)
38+
self.assertEqual(result.url, "https://crvs.example.org/api/registry/sync/search")
39+
self.assertEqual(result.service_name, "DCI Client")
40+
self.assertEqual(result.service_code, "crvs_main")
41+
self.assertEqual(result.status, "success")
42+
43+
def test_log_call_failure_returns_none(self):
44+
"""Logging failures return None and don't raise exceptions"""
45+
# Create a service with a broken env to trigger a failure
46+
bad_service = OutgoingApiLogService(
47+
self.env,
48+
service_name="Bad Service",
49+
service_code="bad",
50+
)
51+
52+
# Monkey-patch the model to raise an error
53+
original_log_call = self.outgoing_log_model.__class__.log_call
54+
55+
def broken_log_call(self_model, **kwargs):
56+
raise RuntimeError("Database error")
57+
58+
self.outgoing_log_model.__class__.log_call = broken_log_call
59+
try:
60+
result = bad_service.log_call(
61+
url="https://example.org/test",
62+
)
63+
self.assertIsNone(result)
64+
finally:
65+
self.outgoing_log_model.__class__.log_call = original_log_call
66+
67+
def test_truncate_payload(self):
68+
"""_truncate_payload truncates large payloads"""
69+
service = OutgoingApiLogService(
70+
self.env,
71+
service_name="Test",
72+
service_code="test",
73+
)
74+
75+
# Small payload should pass through unchanged
76+
small = {"key": "value"}
77+
self.assertEqual(service._truncate_payload(small), small)
78+
79+
# None should return None
80+
self.assertIsNone(service._truncate_payload(None))
81+
82+
# Large payload should be truncated
83+
large = {"data": "x" * 20000}
84+
result = service._truncate_payload(large, max_length=100)
85+
self.assertTrue(result["_truncated"])
86+
self.assertIn("_original_length", result)
87+
self.assertIn("_preview", result)
88+
89+
def test_service_stores_user_id(self):
90+
"""Service records the correct user_id"""
91+
service = OutgoingApiLogService(
92+
self.env,
93+
service_name="Test",
94+
service_code="test",
95+
user_id=self.env.uid,
96+
)
97+
98+
result = service.log_call(
99+
url="https://example.org/test",
100+
)
101+
102+
self.assertTrue(result)
103+
self.assertEqual(result.user_id.id, self.env.uid)
104+
105+
def test_service_stores_service_context(self):
106+
"""Service stores service_name and service_code on log records"""
107+
service = OutgoingApiLogService(
108+
self.env,
109+
service_name="My Integration",
110+
service_code="my_integration_v1",
111+
)
112+
113+
result = service.log_call(
114+
url="https://example.org/test",
115+
)
116+
117+
self.assertTrue(result)
118+
self.assertEqual(result.service_name, "My Integration")
119+
self.assertEqual(result.service_code, "my_integration_v1")
120+
121+
def test_service_default_user_id(self):
122+
"""Service defaults to env.uid when user_id not provided"""
123+
service = OutgoingApiLogService(
124+
self.env,
125+
service_name="Test",
126+
service_code="test",
127+
)
128+
129+
self.assertEqual(service.user_id, self.env.uid)
130+
131+
def test_truncate_payload_non_serializable(self):
132+
"""_truncate_payload handles non-JSON-serializable payloads"""
133+
service = OutgoingApiLogService(
134+
self.env,
135+
service_name="Test",
136+
service_code="test",
137+
)
138+
139+
# Object that can't be serialized
140+
result = service._truncate_payload({"key": object()})
141+
self.assertTrue(result["_truncated"])
142+
self.assertIn("_error", result)
143+
144+
def test_truncate_payload_exact_boundary(self):
145+
"""_truncate_payload passes through payload at exactly max_length"""
146+
service = OutgoingApiLogService(
147+
self.env,
148+
service_name="Test",
149+
service_code="test",
150+
)
151+
152+
# Build a payload whose JSON serialization is exactly max_length
153+
import json
154+
155+
max_length = 50
156+
# {"k": "..."} — adjust value to hit exact length
157+
base = json.dumps({"k": ""}) # '{"k": ""}' = 10 chars
158+
filler = "x" * (max_length - len(base))
159+
payload = {"k": filler}
160+
serialized = json.dumps(payload)
161+
self.assertEqual(len(serialized), max_length)
162+
163+
result = service._truncate_payload(payload, max_length=max_length)
164+
# Should pass through unchanged (equal to limit)
165+
self.assertEqual(result, payload)
166+
self.assertNotIn("_truncated", result)
167+
168+
def test_truncate_payload_one_over_boundary(self):
169+
"""_truncate_payload truncates payload one byte over max_length"""
170+
service = OutgoingApiLogService(
171+
self.env,
172+
service_name="Test",
173+
service_code="test",
174+
)
175+
176+
import json
177+
178+
max_length = 50
179+
base = json.dumps({"k": ""})
180+
filler = "x" * (max_length - len(base) + 1)
181+
payload = {"k": filler}
182+
serialized = json.dumps(payload)
183+
self.assertEqual(len(serialized), max_length + 1)
184+
185+
result = service._truncate_payload(payload, max_length=max_length)
186+
self.assertTrue(result["_truncated"])
187+
self.assertEqual(result["_original_length"], max_length + 1)
188+
self.assertEqual(len(result["_preview"]), max_length)

0 commit comments

Comments
 (0)