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");