Skip to content
Merged
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
7 changes: 6 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions packages/python/src/getanyapi/_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion packages/python/src/getanyapi/_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/python/src/getanyapi/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions packages/python/tests/test_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from conftest import json_response, make_async_client, make_sync_client
from getanyapi import (
AnyAPIError,
DiscoveryPricing,
FlatPricingOffer,
LinearPricingOffer,
Expand Down Expand Up @@ -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"])
Expand Down
17 changes: 15 additions & 2 deletions packages/typescript/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CatalogSearchResults> {
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));
Expand Down
7 changes: 6 additions & 1 deletion packages/typescript/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
30 changes: 30 additions & 0 deletions packages/typescript/tests/account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
5 changes: 5 additions & 0 deletions scripts/live-discovery-python.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions scripts/live-discovery-ts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading