diff --git a/src/glean/api_client/_hooks/answer_likes_null_fix_hook.py b/src/glean/api_client/_hooks/answer_likes_null_fix_hook.py new file mode 100644 index 00000000..3cc7f473 --- /dev/null +++ b/src/glean/api_client/_hooks/answer_likes_null_fix_hook.py @@ -0,0 +1,55 @@ +"""Hook to normalize AnswerLikes payloads that return null likedBy values.""" + +from typing import Any, Tuple + +from glean.api_client._hooks.types import SDKInitHook +from glean.api_client.httpclient import HttpClient + + +class AnswerLikesNullFixHook(SDKInitHook): + """ + Normalizes API payloads where AnswerLikes.likedBy is null. + + The API can return {"likedBy": null, "likedByUser": ..., "numLikes": ...} + even though the generated model expects likedBy to be a list. This hook patches the + unmarshal step so SDK responses continue to validate after regeneration. + """ + + def sdk_init(self, base_url: str, client: HttpClient) -> Tuple[str, HttpClient]: + self._patch_unmarshal() + return base_url, client + + def _patch_unmarshal(self) -> None: + from glean.api_client.utils import serializers + + if getattr(serializers.unmarshal, "_glean_answer_likes_null_fix", False): + return + + original_unmarshal = serializers.unmarshal + + def fixed_unmarshal(val: Any, typ: Any) -> Any: + return original_unmarshal(self._normalize_answer_likes_nulls(val), typ) + + fixed_unmarshal._glean_answer_likes_null_fix = True + serializers.unmarshal = fixed_unmarshal + + def _normalize_answer_likes_nulls(self, val: Any) -> Any: + if isinstance(val, list): + for item in val: + self._normalize_answer_likes_nulls(item) + return val + + if isinstance(val, dict): + for item in val.values(): + self._normalize_answer_likes_nulls(item) + + if self._is_answer_likes_payload(val) and val["likedBy"] is None: + val["likedBy"] = [] + + return val + + @staticmethod + def _is_answer_likes_payload(val: Any) -> bool: + return isinstance(val, dict) and all( + key in val for key in ("likedBy", "likedByUser", "numLikes") + ) diff --git a/src/glean/api_client/_hooks/registration.py b/src/glean/api_client/_hooks/registration.py index 8d8610f1..0f1ebf04 100644 --- a/src/glean/api_client/_hooks/registration.py +++ b/src/glean/api_client/_hooks/registration.py @@ -1,4 +1,5 @@ from .types import Hooks +from .answer_likes_null_fix_hook import AnswerLikesNullFixHook from .server_url_normalizer import ServerURLNormalizerHook from .multipart_fix_hook import MultipartFileFieldFixHook from .agent_file_upload_error_hook import AgentFileUploadErrorHook @@ -22,6 +23,9 @@ def init_hooks(hooks: Hooks): # Register hook to fix multipart file field names that incorrectly have '[]' suffix hooks.register_sdk_init_hook(MultipartFileFieldFixHook()) + # Register hook to normalize null likedBy payloads before response validation + hooks.register_sdk_init_hook(AnswerLikesNullFixHook()) + # Register hook to provide helpful error messages for agent file upload issues hooks.register_after_error_hook(AgentFileUploadErrorHook()) diff --git a/tests/test_answer_likes_null_fix_hook.py b/tests/test_answer_likes_null_fix_hook.py new file mode 100644 index 00000000..a8835346 --- /dev/null +++ b/tests/test_answer_likes_null_fix_hook.py @@ -0,0 +1,77 @@ +"""Tests for the AnswerLikes null fix hook.""" + +from unittest.mock import Mock + +from glean.api_client import models +from glean.api_client._hooks.answer_likes_null_fix_hook import AnswerLikesNullFixHook +from glean.api_client.httpclient import HttpClient +from glean.api_client.utils import serializers + + +class TestAnswerLikesNullFixHook: + """Test cases for the AnswerLikes null fix hook.""" + + def setup_method(self): + """Set up test fixtures.""" + self.hook = AnswerLikesNullFixHook() + self.mock_client = Mock(spec=HttpClient) + + def test_sdk_init_returns_unchanged_params(self): + """SDK init should not change the base URL or client.""" + base_url = "https://api.example.com" + + result_url, result_client = self.hook.sdk_init(base_url, self.mock_client) + + assert result_url == base_url + assert result_client == self.mock_client + + def test_patch_unmarshal_is_idempotent(self, monkeypatch): + """The unmarshal patch should only be applied once.""" + monkeypatch.setattr(serializers, "unmarshal", serializers.unmarshal) + + self.hook._patch_unmarshal() + first = serializers.unmarshal + + self.hook._patch_unmarshal() + second = serializers.unmarshal + + assert first is second + assert getattr(first, "_glean_answer_likes_null_fix", False) is True + + def test_normalize_answer_likes_nulls_updates_nested_payloads(self): + """Nested AnswerLikes payloads should convert null likedBy values to empty lists.""" + payload = { + "message": { + "likes": { + "likedBy": None, + "likedByUser": False, + "numLikes": 0, + } + } + } + + normalized = self.hook._normalize_answer_likes_nulls(payload) + + assert normalized is payload + assert payload["message"]["likes"]["likedBy"] == [] + + def test_normalize_answer_likes_nulls_ignores_non_matching_payloads(self): + """Unrelated payloads should not be modified.""" + payload = {"likedBy": None} + + normalized = self.hook._normalize_answer_likes_nulls(payload) + + assert normalized is payload + assert payload["likedBy"] is None + + def test_patched_unmarshal_normalizes_null_liked_by(self, monkeypatch): + """Patched unmarshal should make null likedBy payloads validate cleanly.""" + monkeypatch.setattr(serializers, "unmarshal", serializers.unmarshal) + self.hook._patch_unmarshal() + + likes = serializers.unmarshal( + {"likedBy": None, "likedByUser": False, "numLikes": 0}, + models.AnswerLikes, + ) + + assert likes.liked_by == [] diff --git a/tests/test_model_regressions.py b/tests/test_model_regressions.py new file mode 100644 index 00000000..6df2b5ab --- /dev/null +++ b/tests/test_model_regressions.py @@ -0,0 +1,28 @@ +import json + +from glean.api_client import Glean, models, utils + + +def test_answer_likes_normalizes_null_liked_by_after_sdk_init(): + payload = json.dumps( + { + "likedBy": None, + "likedByUser": False, + "numLikes": 0, + } + ) + + with Glean(api_token="token", instance="test-instance"): + likes = utils.unmarshal_json(payload, models.AnswerLikes) + + assert likes.liked_by == [] + assert likes.liked_by_user is False + assert likes.num_likes == 0 + + +def test_document_content_model_dump_keeps_alias_field_value(): + content = models.DocumentContent(fullTextList=["This is a test document."]) + + dumped = content.model_dump() + + assert dumped["fullTextList"] == ["This is a test document."]