diff --git a/.changeset/guard-unsupported-search.md b/.changeset/guard-unsupported-search.md new file mode 100644 index 0000000..4285f59 --- /dev/null +++ b/.changeset/guard-unsupported-search.md @@ -0,0 +1,5 @@ +--- +"@api-wrappers/igdb-wrapper": patch +--- + +Reject the high-level search helper on endpoints that IGDB does not document as searchable, and narrow the exported searchable endpoint path type accordingly. diff --git a/docs/endpoints.md b/docs/endpoints.md index 2767708..17ce457 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -7,16 +7,16 @@ property on `IGDBClient`. Each property is an `IGDBEndpoint` with: endpoint.query(); // QueryBuilder endpoint.findMany(); // query().limit(50) endpoint.findById(id); // first result where id = id -endpoint.search("zelda"); // query().search("zelda").limit(50) +endpoint.search("zelda"); // searchable IGDB endpoints only endpoint.request("fields *;"); // raw APICalypse body endpoint.count("where id > 0;"); endpoint.meta(); // GET /{endpoint}/meta endpoint.requestProtobuf("fields id;"); ``` -IGDB only supports `search` on selected endpoints. The wrapper exposes the -method uniformly so new searchable endpoints do not require a wrapper release; -the current documented searchable endpoints are exported as +IGDB only supports `search` on selected endpoints. The high-level `search()` +helper validates the endpoint and throws `IGDBValidationError` for unsupported +paths. The current documented searchable endpoints are exported as `IGDB_SEARCHABLE_ENDPOINTS`. Endpoint properties and path exports are derived from @@ -28,7 +28,7 @@ Use endpoint properties for known IGDB resources: ```ts await client.games.query().limit(10).execute(); await client.platforms.findById(48); -await client.companies.search("Nintendo").execute(); +await client.platforms.search("PlayStation").execute(); ``` Use `client.endpoint(path)` when IGDB adds a new endpoint before the wrapper publishes typed model support: diff --git a/src/__tests__/search-capabilities.test.ts b/src/__tests__/search-capabilities.test.ts new file mode 100644 index 0000000..d408f24 --- /dev/null +++ b/src/__tests__/search-capabilities.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import { IGDBClient } from "../client/IGDBClient"; +import type { IGDBSearchableEndpointPath } from "../endpoints/registry"; +import { IGDBValidationError } from "../errors"; + +const testConfig = { + clientId: "client-id", + clientSecret: "client-secret", + fetch: (async () => new Response()) as unknown as typeof fetch, +}; + +describe("endpoint search capabilities", () => { + test("allows the high-level search helper only on searchable IGDB endpoints", async () => { + const client = new IGDBClient(testConfig); + + expect(client.games.search("zelda").raw()).toContain('search "zelda";'); + expect(() => client.companies.search("Nintendo")).toThrow( + IGDBValidationError, + ); + + await client.dispose(); + }); + + test("narrows IGDBSearchableEndpointPath to documented searchable paths", () => { + const path: IGDBSearchableEndpointPath = "games"; + expect(path).toBe("games"); + + // @ts-expect-error genres is not a searchable IGDB endpoint + const unsupported: IGDBSearchableEndpointPath = "genres"; + void unsupported; + }); +}); diff --git a/src/endpoints/Endpoint.ts b/src/endpoints/Endpoint.ts index 17f8b1d..084ff56 100644 --- a/src/endpoints/Endpoint.ts +++ b/src/endpoints/Endpoint.ts @@ -1,7 +1,10 @@ -import { IGDBNotFoundError } from "../errors"; +import { IGDBNotFoundError, IGDBValidationError } from "../errors"; import type { HttpClient } from "../http/HttpClient"; import { QueryBuilder } from "../query/QueryBuilder"; import type { IGDBEntity, MetaField } from "../types/models"; +import { IGDB_SEARCHABLE_ENDPOINTS } from "./registry"; + +const SEARCHABLE_ENDPOINTS = new Set(IGDB_SEARCHABLE_ENDPOINTS); export class IGDBEndpoint { readonly #http: HttpClient; @@ -37,6 +40,11 @@ export class IGDBEndpoint { } search(term: string): QueryBuilder { + if (!SEARCHABLE_ENDPOINTS.has(this.#path)) { + throw new IGDBValidationError( + `search is not supported on the IGDB /${this.#path} endpoint`, + ); + } return this.query().search(term).limit(50); } diff --git a/src/endpoints/registry.ts b/src/endpoints/registry.ts index cc1a08a..f579d3b 100644 --- a/src/endpoints/registry.ts +++ b/src/endpoints/registry.ts @@ -1,14 +1,29 @@ -interface EndpointMetadata { +interface EndpointMetadata< + TKey extends string, + TPath extends string, + TSearchable extends boolean, +> { key: TKey; path: TPath; - searchable?: boolean; + searchable: TSearchable; } -const endpoint = ( +function endpoint( key: TKey, path: TPath, - options: { searchable?: boolean } = {}, -): EndpointMetadata => ({ key, path, ...options }); +): EndpointMetadata; +function endpoint( + key: TKey, + path: TPath, + options: { searchable: true }, +): EndpointMetadata; +function endpoint( + key: TKey, + path: TPath, + options?: { searchable?: boolean }, +): EndpointMetadata { + return { key, path, searchable: options?.searchable === true }; +} export const IGDB_ENDPOINT_METADATA = [ endpoint("ageRatings", "age_ratings"), @@ -95,6 +110,10 @@ export const IGDB_ENDPOINT_METADATA = [ ] as const; type EndpointMetadataEntry = (typeof IGDB_ENDPOINT_METADATA)[number]; +type SearchableEndpointMetadataEntry = Extract< + EndpointMetadataEntry, + { searchable: true } +>; export const IGDB_ENDPOINTS = Object.fromEntries( IGDB_ENDPOINT_METADATA.map(({ key, path }) => [key, path]), @@ -104,10 +123,9 @@ export const IGDB_ENDPOINTS = Object.fromEntries( export type IGDBEndpointKey = keyof typeof IGDB_ENDPOINTS; export type IGDBEndpointPath = (typeof IGDB_ENDPOINTS)[IGDBEndpointKey]; +export type IGDBSearchableEndpointPath = + SearchableEndpointMetadataEntry["path"]; export const IGDB_SEARCHABLE_ENDPOINTS = IGDB_ENDPOINT_METADATA.filter( (endpoint) => endpoint.searchable === true, -).map((endpoint) => endpoint.path) as readonly IGDBEndpointPath[]; - -export type IGDBSearchableEndpointPath = - (typeof IGDB_SEARCHABLE_ENDPOINTS)[number]; +).map((endpoint) => endpoint.path) as readonly IGDBSearchableEndpointPath[];