From 139427e1c57d9d1013f652a4815a1210ceb1b0db Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sat, 5 Sep 2026 01:55:46 -0700 Subject: [PATCH] Let both SDKs search the catalog by scope with no query GET /catalog/search takes any non-empty combination of q, category and platform. Both readers typed query as required and always set q, so "every reddit API" or "everything in the social category" was expressible against the gateway and against MCP and impossible through the SDKs. query is now optional in TypeScript SearchOptions and in the Python sync and async clients. Every existing call site keeps compiling and sends the same request: q is set exactly when there is a query to send. An absent or empty query omits q entirely rather than sending it empty, because an empty q is a different request. A call naming none of the three never reaches the gateway to earn a 400. Both readers raise AnyAPIError with status 0, the status this repository already uses for a caller error detected before any HTTP. The credentialless live discovery canaries now make a scope-only call against production in both languages, so CI proves the capability on the source tree rather than only in a mock. Co-Authored-By: Claude Opus 5 (1M context) --- SPEC.md | 7 +++- packages/python/src/getanyapi/_account.py | 19 ++++++++-- .../python/src/getanyapi/_async_client.py | 2 +- packages/python/src/getanyapi/_client.py | 2 +- packages/python/tests/test_account.py | 35 +++++++++++++++++++ packages/typescript/src/core/client.ts | 17 +++++++-- packages/typescript/src/core/types.ts | 7 +++- packages/typescript/tests/account.test.ts | 30 ++++++++++++++++ scripts/live-discovery-python.py | 5 +++ scripts/live-discovery-ts.mjs | 5 +++ 10 files changed, 120 insertions(+), 9 deletions(-) diff --git a/SPEC.md b/SPEC.md index fac150c..5137ecc 100644 --- a/SPEC.md +++ b/SPEC.md @@ -779,7 +779,12 @@ export interface AgentSignupResult { `signupGrantApplied`; keep `id`, `email`, `status`, `createdAt`, `onboardingComplete`). - `catalog(options?)` -> category-only GET `/v1/apis?category=` -> `CatalogEntry[]`. - `search(options)` -> dedicated GET `/catalog/search?q=&category=&platform=&limit=` -> - `{ results, total, ranking }` with nested pricing and relevance. + `{ results, total, ranking }` with nested pricing and relevance. The gateway accepts any + non-empty combination of `q`, `category`, and `platform`, so a scope with no query at all + is a valid search and `query` is optional in both readers. An absent or empty query omits + `q` from the query string rather than sending it empty, which is a different request. A + call naming none of the three never reaches the gateway: the reader raises `AnyAPIError` + with status `0`. - `describe(slug)` -> GET `/v1/apis/{slug}` -> one `CatalogEntry`. 404 -> `NotFoundError`. - Browse, search, and detail carry the gateway-authored `method`, `path`, and `execution.mode` unchanged. Ranked search also carries the gateway's `failover`, optional diff --git a/packages/python/src/getanyapi/_account.py b/packages/python/src/getanyapi/_account.py index 8a1261e..976b945 100644 --- a/packages/python/src/getanyapi/_account.py +++ b/packages/python/src/getanyapi/_account.py @@ -49,13 +49,26 @@ def catalog_request(category: str | None) -> tuple[str, dict[str, str]]: def search_request( - query: str, + query: str | None, category: str | None, platform: str | None, limit: int | None, ) -> tuple[str, dict[str, str]]: - """Path and query params for dedicated ranked discovery search.""" - params = {"q": query} + """Path and query params for dedicated ranked discovery search. + + The gateway needs at least one of query, category or platform, in any + combination, so a scope with no query at all is a valid search. An absent or + empty query omits ``q`` entirely, because an empty ``q`` is a different + request. + """ + if not query and category is None and platform is None: + raise AnyAPIError( + "search needs at least one of query, category, or platform", + status=0, + ) + params: dict[str, str] = {} + if query: + params["q"] = query if category is not None: params["category"] = category if platform is not None: diff --git a/packages/python/src/getanyapi/_async_client.py b/packages/python/src/getanyapi/_async_client.py index b4f0475..5e01e6e 100644 --- a/packages/python/src/getanyapi/_async_client.py +++ b/packages/python/src/getanyapi/_async_client.py @@ -370,7 +370,7 @@ async def catalog(self, *, category: str | None = None) -> list[CatalogEntry]: async def search( self, *, - query: str, + query: str | None = None, category: str | None = None, platform: str | None = None, limit: int | None = None, diff --git a/packages/python/src/getanyapi/_client.py b/packages/python/src/getanyapi/_client.py index c624a97..6c3f3d0 100644 --- a/packages/python/src/getanyapi/_client.py +++ b/packages/python/src/getanyapi/_client.py @@ -398,7 +398,7 @@ def catalog(self, *, category: str | None = None) -> list[CatalogEntry]: def search( self, *, - query: str, + query: str | None = None, category: str | None = None, platform: str | None = None, limit: int | None = None, diff --git a/packages/python/tests/test_account.py b/packages/python/tests/test_account.py index 81631d3..b909eb6 100644 --- a/packages/python/tests/test_account.py +++ b/packages/python/tests/test_account.py @@ -12,6 +12,7 @@ from conftest import json_response, make_async_client, make_sync_client from getanyapi import ( + AnyAPIError, DiscoveryPricing, FlatPricingOffer, LinearPricingOffer, @@ -331,6 +332,40 @@ def respond(req: httpx.Request) -> httpx.Response: assert found.model_dump(by_alias=True, exclude_defaults=True) == body +def test_search_scopes_without_a_query_and_omits_q_entirely() -> None: + body = discovery_search() + + def respond(req: httpx.Request) -> httpx.Response: + assert req.url.path == "/catalog/search" + assert dict(req.url.params) == {"platform": "reddit"} + return json_response(200, body) + + client, _ = make_sync_client(respond) + assert client.search(platform="reddit").results + + +def test_search_omits_an_empty_query_rather_than_sending_it_empty() -> None: + body = discovery_search() + + def respond(req: httpx.Request) -> httpx.Response: + assert "q" not in dict(req.url.params) + return json_response(200, body) + + client, _ = make_sync_client(respond) + assert client.search(query="", category="social").results + + +def test_search_without_query_category_or_platform_never_calls_the_gateway() -> None: + client, recorder = make_sync_client( + lambda _req: json_response(200, discovery_search()) + ) + with pytest.raises( + AnyAPIError, match="at least one of query, category, or platform" + ): + client.search(limit=5) + assert recorder.requests == [] + + def test_search_drops_safe_additive_result_and_envelope_fields() -> None: body = discovery_search() results = cast("list[dict[str, object]]", body["results"]) diff --git a/packages/typescript/src/core/client.ts b/packages/typescript/src/core/client.ts index b0b961c..2417dce 100644 --- a/packages/typescript/src/core/client.ts +++ b/packages/typescript/src/core/client.ts @@ -538,9 +538,22 @@ export class AnyAPI implements ClientCore { return mapCatalogList(raw); } - /** Ranked catalog search. GET /catalog/search. Browse never accepts a query. */ + /** + * Ranked catalog search. GET /catalog/search. Browse never accepts a query. + * + * The gateway needs at least one of query, category or platform, in any + * combination, so a scope with no query at all is a valid search. An absent or + * empty query omits `q` entirely, because an empty `q` is a different request. + */ async search(options: SearchOptions): Promise { - const search = new URLSearchParams({ q: options.query }); + if (!options.query && !options.category && !options.platform) { + throw new AnyAPIError( + "search needs at least one of query, category, or platform", + 0, + ); + } + const search = new URLSearchParams(); + if (options.query) search.set("q", options.query); if (options.category) search.set("category", options.category); if (options.platform) search.set("platform", options.platform); if (options.limit !== undefined) search.set("limit", String(options.limit)); diff --git a/packages/typescript/src/core/types.ts b/packages/typescript/src/core/types.ts index d94247d..237c00a 100644 --- a/packages/typescript/src/core/types.ts +++ b/packages/typescript/src/core/types.ts @@ -307,8 +307,13 @@ export interface CatalogEntry { latency?: DiscoveryLatency | null; } +/** + * Ranked catalog search accepts any non-empty combination of query, category and + * platform, so a scope with no query at all ("every reddit API") is expressible. + * At least one of the three is required; see `AnyAPI.search`. + */ export interface SearchOptions { - query: string; + query?: string; category?: string; platform?: string; limit?: number; diff --git a/packages/typescript/tests/account.test.ts b/packages/typescript/tests/account.test.ts index cc76306..fd48cc6 100644 --- a/packages/typescript/tests/account.test.ts +++ b/packages/typescript/tests/account.test.ts @@ -299,6 +299,36 @@ describe("search", () => { }); }); + it("searches a scope with no query and omits q entirely", async () => { + const body = clone(golden.rest.search); + const { fetch, calls } = mockFetch([{ body }]); + const client = new AnyAPI({ apiKey: "k", fetch }); + await client.search({ platform: "reddit" }); + const url = new URL(calls[0]!.url); + expect(url.pathname).toBe("/catalog/search"); + expect(url.searchParams.has("q")).toBe(false); + expect(Object.fromEntries(url.searchParams)).toEqual({ + platform: "reddit", + }); + }); + + it("omits an empty query rather than sending q=", async () => { + const body = clone(golden.rest.search); + const { fetch, calls } = mockFetch([{ body }]); + const client = new AnyAPI({ apiKey: "k", fetch }); + await client.search({ query: "", category: "social" }); + expect(new URL(calls[0]!.url).searchParams.has("q")).toBe(false); + }); + + it("rejects a search naming none of query, category, or platform", async () => { + const { fetch, calls } = mockFetch([]); + const client = new AnyAPI({ apiKey: "k", fetch }); + await expect(client.search({ limit: 5 })).rejects.toThrow( + "search needs at least one of query, category, or platform", + ); + expect(calls).toHaveLength(0); + }); + it("rejects an upstream provider identity", async () => { const body = clone(golden.rest.search); body.results[0]!.provider = "upstream"; diff --git a/scripts/live-discovery-python.py b/scripts/live-discovery-python.py index 08d4d4c..4b4ff36 100644 --- a/scripts/live-discovery-python.py +++ b/scripts/live-discovery-python.py @@ -59,6 +59,11 @@ def handler(request: httpx.Request) -> httpx.Response: search = client.search(query="web", limit=1) assert search.results, "search is empty" + # A scope with no query at all is a complete search: the gateway takes + # any non-empty combination of q, category and platform. + scoped = client.search(platform="reddit", limit=3) + assert scoped.results, "scope-only search is empty" + detail = client.describe(eligible.slug) assert detail.slug == eligible.slug, "detail slug does not match" assert detail.input_schema is not None, "detail input schema is missing" diff --git a/scripts/live-discovery-ts.mjs b/scripts/live-discovery-ts.mjs index 8ec4415..c5a3210 100644 --- a/scripts/live-discovery-ts.mjs +++ b/scripts/live-discovery-ts.mjs @@ -66,6 +66,11 @@ assert(eligible, "catalog has no try-eligible SKU"); const search = await client.search({ query: "web", limit: 1 }); assert(search.results.length > 0, "search is empty"); +// A scope with no query at all is a complete search: the gateway takes any +// non-empty combination of q, category and platform. +const scoped = await client.search({ platform: "reddit", limit: 3 }); +assert(scoped.results.length > 0, "scope-only search is empty"); + const detail = await client.describe(eligible.slug); assert(detail.slug === eligible.slug, "detail slug does not match"); assert(detail.inputSchema, "detail input schema is missing");