Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/glean/api_client/_hooks/answer_likes_null_fix_hook.py
Original file line number Diff line number Diff line change
@@ -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")
)
4 changes: 4 additions & 0 deletions src/glean/api_client/_hooks/registration.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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())

Expand Down
77 changes: 77 additions & 0 deletions tests/test_answer_likes_null_fix_hook.py
Original file line number Diff line number Diff line change
@@ -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 == []
28 changes: 28 additions & 0 deletions tests/test_model_regressions.py
Original file line number Diff line number Diff line change
@@ -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."]