4646
4747logger = 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.
5365ACCOUNT_SEARCH_FILTERS = frozenset (
5466 {
5567 "q_organization_name" ,
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
64157class 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
0 commit comments