Skip to content

Commit f584647

Browse files
committed
Fix AnswerLikes null handling and DocumentContent serialization
1 parent 5608e34 commit f584647

File tree

4 files changed

+164
-0
lines changed

4 files changed

+164
-0
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Hook to normalize AnswerLikes payloads that return null likedBy values."""
2+
3+
from typing import Any, Tuple
4+
5+
from glean.api_client._hooks.types import SDKInitHook
6+
from glean.api_client.httpclient import HttpClient
7+
8+
9+
class AnswerLikesNullFixHook(SDKInitHook):
10+
"""
11+
Normalizes API payloads where AnswerLikes.likedBy is null.
12+
13+
The API can return {"likedBy": null, "likedByUser": ..., "numLikes": ...}
14+
even though the generated model expects likedBy to be a list. This hook patches the
15+
unmarshal step so SDK responses continue to validate after regeneration.
16+
"""
17+
18+
def sdk_init(self, base_url: str, client: HttpClient) -> Tuple[str, HttpClient]:
19+
self._patch_unmarshal()
20+
return base_url, client
21+
22+
def _patch_unmarshal(self) -> None:
23+
from glean.api_client.utils import serializers
24+
25+
if getattr(serializers.unmarshal, "_glean_answer_likes_null_fix", False):
26+
return
27+
28+
original_unmarshal = serializers.unmarshal
29+
30+
def fixed_unmarshal(val: Any, typ: Any) -> Any:
31+
return original_unmarshal(self._normalize_answer_likes_nulls(val), typ)
32+
33+
fixed_unmarshal._glean_answer_likes_null_fix = True
34+
serializers.unmarshal = fixed_unmarshal
35+
36+
def _normalize_answer_likes_nulls(self, val: Any) -> Any:
37+
if isinstance(val, list):
38+
for item in val:
39+
self._normalize_answer_likes_nulls(item)
40+
return val
41+
42+
if isinstance(val, dict):
43+
for item in val.values():
44+
self._normalize_answer_likes_nulls(item)
45+
46+
if self._is_answer_likes_payload(val) and val["likedBy"] is None:
47+
val["likedBy"] = []
48+
49+
return val
50+
51+
@staticmethod
52+
def _is_answer_likes_payload(val: Any) -> bool:
53+
return isinstance(val, dict) and all(
54+
key in val for key in ("likedBy", "likedByUser", "numLikes")
55+
)

src/glean/api_client/_hooks/registration.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .types import Hooks
2+
from .answer_likes_null_fix_hook import AnswerLikesNullFixHook
23
from .server_url_normalizer import ServerURLNormalizerHook
34
from .multipart_fix_hook import MultipartFileFieldFixHook
45
from .agent_file_upload_error_hook import AgentFileUploadErrorHook
@@ -22,6 +23,9 @@ def init_hooks(hooks: Hooks):
2223
# Register hook to fix multipart file field names that incorrectly have '[]' suffix
2324
hooks.register_sdk_init_hook(MultipartFileFieldFixHook())
2425

26+
# Register hook to normalize null likedBy payloads before response validation
27+
hooks.register_sdk_init_hook(AnswerLikesNullFixHook())
28+
2529
# Register hook to provide helpful error messages for agent file upload issues
2630
hooks.register_after_error_hook(AgentFileUploadErrorHook())
2731

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Tests for the AnswerLikes null fix hook."""
2+
3+
from unittest.mock import Mock
4+
5+
from glean.api_client import models
6+
from glean.api_client._hooks.answer_likes_null_fix_hook import AnswerLikesNullFixHook
7+
from glean.api_client.httpclient import HttpClient
8+
from glean.api_client.utils import serializers
9+
10+
11+
class TestAnswerLikesNullFixHook:
12+
"""Test cases for the AnswerLikes null fix hook."""
13+
14+
def setup_method(self):
15+
"""Set up test fixtures."""
16+
self.hook = AnswerLikesNullFixHook()
17+
self.mock_client = Mock(spec=HttpClient)
18+
19+
def test_sdk_init_returns_unchanged_params(self):
20+
"""SDK init should not change the base URL or client."""
21+
base_url = "https://api.example.com"
22+
23+
result_url, result_client = self.hook.sdk_init(base_url, self.mock_client)
24+
25+
assert result_url == base_url
26+
assert result_client == self.mock_client
27+
28+
def test_patch_unmarshal_is_idempotent(self, monkeypatch):
29+
"""The unmarshal patch should only be applied once."""
30+
monkeypatch.setattr(serializers, "unmarshal", serializers.unmarshal)
31+
32+
self.hook._patch_unmarshal()
33+
first = serializers.unmarshal
34+
35+
self.hook._patch_unmarshal()
36+
second = serializers.unmarshal
37+
38+
assert first is second
39+
assert getattr(first, "_glean_answer_likes_null_fix", False) is True
40+
41+
def test_normalize_answer_likes_nulls_updates_nested_payloads(self):
42+
"""Nested AnswerLikes payloads should convert null likedBy values to empty lists."""
43+
payload = {
44+
"message": {
45+
"likes": {
46+
"likedBy": None,
47+
"likedByUser": False,
48+
"numLikes": 0,
49+
}
50+
}
51+
}
52+
53+
normalized = self.hook._normalize_answer_likes_nulls(payload)
54+
55+
assert normalized is payload
56+
assert payload["message"]["likes"]["likedBy"] == []
57+
58+
def test_normalize_answer_likes_nulls_ignores_non_matching_payloads(self):
59+
"""Unrelated payloads should not be modified."""
60+
payload = {"likedBy": None}
61+
62+
normalized = self.hook._normalize_answer_likes_nulls(payload)
63+
64+
assert normalized is payload
65+
assert payload["likedBy"] is None
66+
67+
def test_patched_unmarshal_normalizes_null_liked_by(self, monkeypatch):
68+
"""Patched unmarshal should make null likedBy payloads validate cleanly."""
69+
monkeypatch.setattr(serializers, "unmarshal", serializers.unmarshal)
70+
self.hook._patch_unmarshal()
71+
72+
likes = serializers.unmarshal(
73+
{"likedBy": None, "likedByUser": False, "numLikes": 0},
74+
models.AnswerLikes,
75+
)
76+
77+
assert likes.liked_by == []

tests/test_model_regressions.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import json
2+
3+
from glean.api_client import Glean, models, utils
4+
5+
6+
def test_answer_likes_normalizes_null_liked_by_after_sdk_init():
7+
payload = json.dumps(
8+
{
9+
"likedBy": None,
10+
"likedByUser": False,
11+
"numLikes": 0,
12+
}
13+
)
14+
15+
with Glean(api_token="token", instance="test-instance"):
16+
likes = utils.unmarshal_json(payload, models.AnswerLikes)
17+
18+
assert likes.liked_by == []
19+
assert likes.liked_by_user is False
20+
assert likes.num_likes == 0
21+
22+
23+
def test_document_content_model_dump_keeps_alias_field_value():
24+
content = models.DocumentContent(fullTextList=["This is a test document."])
25+
26+
dumped = content.model_dump()
27+
28+
assert dumped["fullTextList"] == ["This is a test document."]

0 commit comments

Comments
 (0)