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
5 changes: 5 additions & 0 deletions .changeset/guard-unsupported-search.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 5 additions & 5 deletions docs/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@ property on `IGDBClient`. Each property is an `IGDBEndpoint<T>` with:
endpoint.query(); // QueryBuilder<T>
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
Expand All @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions src/__tests__/search-capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -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;
});
});
10 changes: 9 additions & 1 deletion src/endpoints/Endpoint.ts
Original file line number Diff line number Diff line change
@@ -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<string>(IGDB_SEARCHABLE_ENDPOINTS);

export class IGDBEndpoint<TModel extends IGDBEntity = IGDBEntity> {
readonly #http: HttpClient;
Expand Down Expand Up @@ -37,6 +40,11 @@ export class IGDBEndpoint<TModel extends IGDBEntity = IGDBEntity> {
}

search(term: string): QueryBuilder<TModel> {
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);
}

Expand Down
36 changes: 27 additions & 9 deletions src/endpoints/registry.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
interface EndpointMetadata<TKey extends string, TPath extends string> {
interface EndpointMetadata<
TKey extends string,
TPath extends string,
TSearchable extends boolean,
> {
key: TKey;
path: TPath;
searchable?: boolean;
searchable: TSearchable;
}

const endpoint = <TKey extends string, TPath extends string>(
function endpoint<TKey extends string, TPath extends string>(
key: TKey,
path: TPath,
options: { searchable?: boolean } = {},
): EndpointMetadata<TKey, TPath> => ({ key, path, ...options });
): EndpointMetadata<TKey, TPath, false>;
function endpoint<TKey extends string, TPath extends string>(
key: TKey,
path: TPath,
options: { searchable: true },
): EndpointMetadata<TKey, TPath, true>;
function endpoint<TKey extends string, TPath extends string>(
key: TKey,
path: TPath,
options?: { searchable?: boolean },
): EndpointMetadata<TKey, TPath, boolean> {
return { key, path, searchable: options?.searchable === true };
}

export const IGDB_ENDPOINT_METADATA = [
endpoint("ageRatings", "age_ratings"),
Expand Down Expand Up @@ -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]),
Expand All @@ -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[];
Loading