Skip to content

Commit 0a6e695

Browse files
jschfflrclaude
andauthored
fix: generalize search-filter validation across all search_* methods (#18)
* fix: validate filters across all search_* methods Generalizes the search_accounts filter-key guard to every search method. Apollo silently drops unknown filter keys and returns an unfiltered default page that looks like a real match. - Strict (raise on unknown), documented flat-filter endpoints: contacts, deals, people (+ accounts, refactored onto the shared helper). - Lenient (logging.warning, non-breaking), undocumented activity endpoints: notes, calls, tasks, emails, conversations, calendar_events — where the full valid filter set isn't published and a hard allowlist could reject valid filters. Live-verified: valid filters pass; unknown keys raise (strict) or warn (lenient) while still returning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: address peqy review — 'Known filters' wording in lenient mode peqy: lenient allowlists are intentionally incomplete, so 'Supported filters' over-claimed. Strict mode still says 'Supported filters'; lenient (warn) mode now says 'Known filters' so the message doesn't imply the key is definitely invalid. (The search_people endpoint deprecation peqy asked about is handled by the separate people-api-search PR.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c856745 commit 0a6e695

2 files changed

Lines changed: 171 additions & 15 deletions

File tree

‎src/qodev_apollo_api/client.py‎

Lines changed: 109 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,22 @@
4646

4747
logger = logging.getLogger(__name__)
4848

49-
# Filters accepted by POST /accounts/search (besides page/per_page, which are
50-
# passed explicitly). Apollo *silently drops* unrecognised keys and returns an
51-
# unfiltered default page, so we validate against this allowlist and raise
52-
# instead of returning wrong data. See ``ApolloClient.search_accounts``.
49+
# ---------------------------------------------------------------------------
50+
# Search-filter allowlists
51+
#
52+
# Apollo's /search endpoints *silently drop* unrecognised filter keys and return
53+
# an unfiltered default page that looks like a real match (e.g. a typo'd
54+
# ``query=`` on accounts returned ~28k rows, "Google" first). To stop that, each
55+
# search method validates its ``**filters`` against the relevant allowlist below.
56+
#
57+
# Endpoints with a documented, stable flat-filter vocabulary are validated
58+
# *strictly* (raise on unknown). The activity endpoints below have no published
59+
# filter docs, so an over-tight allowlist would reject valid filters — those are
60+
# validated *leniently* (log a warning, still send the request). See
61+
# ``_validate_search_filters``.
62+
# ---------------------------------------------------------------------------
63+
64+
# Strict (raise on unknown) — documented flat-filter endpoints.
5365
ACCOUNT_SEARCH_FILTERS = frozenset(
5466
{
5567
"q_organization_name",
@@ -59,6 +71,87 @@
5971
"sort_ascending",
6072
}
6173
)
74+
CONTACT_SEARCH_FILTERS = frozenset(
75+
{
76+
"q_keywords",
77+
"contact_stage_ids",
78+
"contact_label_ids",
79+
"linkedin_url",
80+
"sort_by_field",
81+
"sort_ascending",
82+
}
83+
)
84+
DEAL_SEARCH_FILTERS = frozenset(
85+
{
86+
"q_keywords",
87+
"opportunity_stage_ids",
88+
"sort_by_field",
89+
"sort_ascending",
90+
}
91+
)
92+
# search_people passes *everything* (incl. page/per_page) through **filters, so
93+
# those are part of the allowlist here (unlike the methods with explicit args).
94+
PEOPLE_SEARCH_FILTERS = frozenset(
95+
{
96+
"q_keywords",
97+
"person_titles",
98+
"include_similar_titles",
99+
"person_seniorities",
100+
"person_locations",
101+
"organization_locations",
102+
"organization_ids",
103+
"organization_num_employees_ranges",
104+
"q_organization_domains_list",
105+
"revenue_range",
106+
"currently_using_all_of_technology_uids",
107+
"currently_using_any_of_technology_uids",
108+
"currently_not_using_any_of_technology_uids",
109+
"q_organization_job_titles",
110+
"organization_job_locations",
111+
"organization_num_jobs_range",
112+
"organization_job_posted_at_range",
113+
"contact_email_status",
114+
"page",
115+
"per_page",
116+
}
117+
)
118+
119+
# Lenient (warn on unknown) — undocumented activity endpoints. Seeded from known
120+
# usage; incompleteness only costs a log line, never a broken call.
121+
NOTE_SEARCH_FILTERS = frozenset({"contact_ids", "account_ids", "opportunity_ids", "q_keywords"})
122+
CALL_SEARCH_FILTERS = frozenset({"contact_ids", "account_ids", "user_ids", "q_keywords"})
123+
TASK_SEARCH_FILTERS = frozenset({"contact_ids", "account_ids", "opportunity_ids", "q_keywords"})
124+
EMAIL_SEARCH_FILTERS = frozenset({"contact_ids", "emailer_campaign_ids", "q_keywords"})
125+
CONVERSATION_SEARCH_FILTERS = frozenset({"q_keywords"})
126+
CALENDAR_EVENT_SEARCH_FILTERS = frozenset({"contact_ids", "user_ids", "q_keywords"})
127+
128+
129+
def _validate_search_filters(
130+
filters: dict, allowed: frozenset[str], resource: str, *, strict: bool
131+
) -> None:
132+
"""Guard against Apollo silently dropping unknown ``**filters`` keys.
133+
134+
Apollo ignores unrecognised keys on its /search endpoints and returns an
135+
unfiltered default page that looks like a real match. When ``strict``, raise
136+
``ValueError`` on any unknown key (documented endpoints); otherwise log a
137+
warning and let the request through (undocumented endpoints, where the full
138+
valid set isn't published and a hard allowlist would reject valid filters).
139+
"""
140+
unknown = set(filters) - allowed
141+
if not unknown:
142+
return
143+
# Strict allowlists are authoritative ("Supported filters"); lenient ones are
144+
# seeded from known usage and may be incomplete ("Known filters"), so the
145+
# wording doesn't imply the warned-about key is definitely invalid.
146+
label = "Supported filters" if strict else "Known filters"
147+
msg = (
148+
f"Unknown {resource} search filter(s): {', '.join(sorted(unknown))}. "
149+
f"Apollo silently ignores unrecognised keys and returns an unfiltered "
150+
f"default page. {label}: {', '.join(sorted(allowed))}."
151+
)
152+
if strict:
153+
raise ValueError(msg)
154+
logger.warning(msg)
62155

63156

64157
class ApolloClient:
@@ -202,6 +295,7 @@ async def search_contacts(
202295
Returns:
203296
Paginated response with Contact items
204297
"""
298+
_validate_search_filters(filters, CONTACT_SEARCH_FILTERS, "contact", strict=True)
205299
data = {"page": page, "per_page": min(limit, 100), **filters}
206300
result = await self._post("/contacts/search", data)
207301

@@ -369,17 +463,7 @@ async def search_accounts(
369463
wrong accounts. Note ``query=`` is **not** a valid filter — use
370464
``q_organization_name=`` to search by name.
371465
"""
372-
unknown = set(filters) - ACCOUNT_SEARCH_FILTERS
373-
if unknown:
374-
raise ValueError(
375-
"Unknown account search filter(s): "
376-
+ ", ".join(sorted(unknown))
377-
+ ". Apollo silently ignores unrecognised keys and returns an unfiltered "
378-
"default page. Supported filters: "
379-
+ ", ".join(sorted(ACCOUNT_SEARCH_FILTERS))
380-
+ " (to search by name use q_organization_name=)."
381-
)
382-
466+
_validate_search_filters(filters, ACCOUNT_SEARCH_FILTERS, "account", strict=True)
383467
data = {"page": page, "per_page": min(limit, 100), **filters}
384468
result = await self._post("/accounts/search", data)
385469

@@ -421,6 +505,7 @@ async def search_deals(
421505
Returns:
422506
Paginated response with Deal items
423507
"""
508+
_validate_search_filters(filters, DEAL_SEARCH_FILTERS, "deal", strict=True)
424509
data = {"page": page, "per_page": min(limit, 100), **filters}
425510
result = await self._post("/opportunities/search", data)
426511

@@ -676,6 +761,7 @@ async def search_people(self, **filters) -> dict:
676761
Returns:
677762
Raw Apollo response dict: ``people`` (list) and ``total_entries`` (int).
678763
"""
764+
_validate_search_filters(filters, PEOPLE_SEARCH_FILTERS, "people", strict=True)
679765
return await self._post("/mixed_people/api_search", filters)
680766

681767
# ========================================================================
@@ -695,6 +781,7 @@ async def search_notes(
695781
Returns:
696782
Paginated response with Note items (content converted to Markdown)
697783
"""
784+
_validate_search_filters(filters, NOTE_SEARCH_FILTERS, "note", strict=False)
698785
data = {"page": page, "per_page": min(limit, 100), **filters}
699786
result = await self._post("/notes/search", data)
700787

@@ -780,6 +867,7 @@ async def search_calls(
780867
Returns:
781868
Paginated response with Call items
782869
"""
870+
_validate_search_filters(filters, CALL_SEARCH_FILTERS, "call", strict=False)
783871
data = {"page": page, "per_page": min(limit, 100), **filters}
784872
result = await self._post("/phone_calls/search", data)
785873

@@ -819,6 +907,7 @@ async def search_tasks(
819907
Returns:
820908
Paginated response with specific Task subclass items
821909
"""
910+
_validate_search_filters(filters, TASK_SEARCH_FILTERS, "task", strict=False)
822911
data: dict[str, Any] = {"page": page, "per_page": min(limit, 100), **filters}
823912
if task_type_cds is not None:
824913
data["task_type_cds"] = task_type_cds
@@ -862,6 +951,7 @@ async def search_emails(
862951
Returns:
863952
Paginated response with Email items
864953
"""
954+
_validate_search_filters(filters, EMAIL_SEARCH_FILTERS, "email", strict=False)
865955
data = {"page": page, "per_page": min(limit, 100), **filters}
866956
result = await self._post("/emailer_messages/search", data)
867957

@@ -1208,6 +1298,9 @@ async def search_calendar_events(
12081298
Returns:
12091299
Paginated response with CalendarEvent items
12101300
"""
1301+
_validate_search_filters(
1302+
filters, CALENDAR_EVENT_SEARCH_FILTERS, "calendar event", strict=False
1303+
)
12111304
data = {"page": page, "per_page": min(limit, 100), **filters}
12121305
result = await self._post("/calendar_events/search", data)
12131306

@@ -1237,6 +1330,7 @@ async def search_conversations(
12371330
Returns:
12381331
Paginated response with Conversation items
12391332
"""
1333+
_validate_search_filters(filters, CONVERSATION_SEARCH_FILTERS, "conversation", strict=False)
12401334
data = {"page": page, "per_page": min(limit, 25), **filters}
12411335
result = await self._post("/conversations/search", data)
12421336

‎tests/test_client.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,68 @@ async def test_search_accounts_allows_documented_filters(client: ApolloClient):
290290
assert body["sort_by_field"] == "account_created_at"
291291

292292

293+
# --- Generalized search-filter validation (strict raise vs lenient warn) ------
294+
295+
296+
@pytest.mark.parametrize(
297+
"method,good_filter",
298+
[
299+
("search_contacts", {"q_keywords": "x"}),
300+
("search_deals", {"opportunity_stage_ids": ["s1"]}),
301+
("search_people", {"person_titles": ["CEO"]}),
302+
],
303+
)
304+
async def test_strict_search_methods_reject_unknown_filter(client, method, good_filter):
305+
"""contacts/deals/people raise on an unknown key (like accounts) and never call Apollo."""
306+
client._client.request.return_value = _make_response({})
307+
with pytest.raises(ValueError, match=r"Unknown .* search filter"):
308+
await getattr(client, method)(query="typo")
309+
client._client.request.assert_not_called()
310+
311+
# A documented filter passes validation and reaches Apollo.
312+
await getattr(client, method)(**good_filter)
313+
assert client._client.request.called
314+
315+
316+
async def test_search_people_allows_page_and_per_page(client: ApolloClient):
317+
"""search_people has no explicit page/limit, so page/per_page are valid filters."""
318+
client._client.request.return_value = _make_response({"people": [], "contacts": []})
319+
await client.search_people(q_keywords="x", page=2, per_page=50)
320+
assert client._client.request.call_args[1]["json"]["per_page"] == 50
321+
322+
323+
@pytest.mark.parametrize(
324+
"method,endpoint_key",
325+
[
326+
("search_notes", "notes"),
327+
("search_calls", "phone_calls"),
328+
("search_tasks", "tasks"),
329+
("search_emails", "emailer_messages"),
330+
("search_conversations", "conversations"),
331+
("search_calendar_events", "calendar_events"),
332+
],
333+
)
334+
async def test_lenient_search_methods_warn_but_still_send(client, method, endpoint_key, caplog):
335+
"""Activity endpoints log a warning on an unknown key but still send the request."""
336+
client._client.request.return_value = _make_response({endpoint_key: [], "pagination": {}})
337+
338+
with caplog.at_level("WARNING", logger="qodev_apollo_api.client"):
339+
await getattr(client, method)(bogus_filter="x")
340+
341+
assert any("Unknown" in r.message and "search filter" in r.message for r in caplog.records)
342+
# Lenient: the request is still sent (unknown key forwarded, not blocked).
343+
assert client._client.request.called
344+
assert client._client.request.call_args[1]["json"]["bogus_filter"] == "x"
345+
346+
347+
async def test_lenient_search_method_no_warn_on_known_filter(client, caplog):
348+
"""A known activity filter passes without a warning."""
349+
client._client.request.return_value = _make_response({"notes": [], "pagination": {}})
350+
with caplog.at_level("WARNING", logger="qodev_apollo_api.client"):
351+
await client.search_notes(contact_ids=["c1"])
352+
assert not any("Unknown" in r.message for r in caplog.records)
353+
354+
293355
async def test_search_deals(client: ApolloClient):
294356
"""Test POST /opportunities/search returns PaginatedResponse[Deal]."""
295357
client._client.request.return_value = _make_response(

0 commit comments

Comments
 (0)