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
16 changes: 11 additions & 5 deletions docs/reference/search-api-graphql.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,17 @@ fails this – accepts. The endpoint path defaults to `/graphql`
- **Depth and cost limits** ([graphql-armor](https://escape.tech/graphql-armor/);
`maxDepth`, default 15, and `maxCost`, default 5000) guard the public
endpoint against arbitrarily expensive queries; introspection stays exempt.
Fragments are flattened, so a fragment spread adds no depth and no longer
multiplies the cost of what it contains. Each spread still adds a small cost,
more at deeper levels, and every repeat of a reused fragment costs 1.5× the
fragment: a query with many fragments scores somewhat higher than its
inlined form.
Fragments are flattened, so a fragment adds no depth and, nested in a
field, no longer multiplies the cost of what it contains: it adds a fixed
cost instead – 2 for an inline fragment, 3 for a named spread – scaled like
any field by the levels above it. Two cases still cost more than the inlined
form. A named spread directly at the operation root (`{ ...Q }`) costs its
contents 1.5×. And every repeat of a named fragment costs 1 plus 1.5× what
its first spread scored, even in another operation of the same document.
The cost limit bounds a query’s shape, not how many results it fetches:
`perPage` does not enter the cost, so a page of 100 scores like a page of 1.
What bounds result volume is `maxPerPage` (a `buildGraphQLSchema` option,
default 100), which rejects any larger `perPage`.

To serve **custom fields next to the generated search API**, merge your own
schema with `buildGraphQLSchema()`’s output (e.g. `@graphql-tools/schema`’s
Expand Down
8 changes: 6 additions & 2 deletions packages/search-api-graphql/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,16 @@ export interface SearchGraphQLHandlerOptions {
readonly cors?: CORSOptions | boolean;
/**
* Reject queries nested deeper than this – a public playground invites
* arbitrary queries. Introspection is exempt. @default 15
* arbitrary queries. Introspection is exempt, and a fragment adds no level.
* @default 15
*/
readonly maxDepth?: number;
/**
* Reject queries costlier than this (graphql-armor cost limit; introspection
* is exempt). @default 5000
* is exempt). Fragments are flattened: a nested fragment adds a fixed cost
* (2 inline, 3 for a named spread) rather than multiplying its contents,
* while a spread at the operation root costs its contents 1.5×, and a
* repeated named fragment 1 plus 1.5× its first scoring. @default 5000
*/
readonly maxCost?: number;
/** Forwarded to {@link SearchContext.onFacetError} on every request. */
Expand Down
49 changes: 49 additions & 0 deletions packages/search-api-graphql/test/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,55 @@ describe('createSearchGraphQLHandler', () => {
expect(data.datasets.items).toBeDefined();
});

describe('the cost of a fragment', () => {
const page = '{ pagination { total } items { id keyword } }';
const inlined = `{ datasets ${page} }`;

/** The cost graphql-armor scores a query at, read off its rejection. */
async function costOf(query: string): Promise<number> {
const { engine } = fakeEngine();
const handler = createSearchGraphQLHandler({
searchSchema: searchSchema(schema),
engine,
maxCost: 0,
});
const { errors } = await (await post(handler, query)).json();
return Number(/found ([\d.]+)/.exec(errors[0].message)?.[1]);
}

it('adds 2 for an inline fragment', async () => {
expect(
await costOf(`{ datasets { ... on DatasetSearchResult ${page} } }`),
).toBe((await costOf(inlined)) + 2);
});

it('adds 3 for a named spread', async () => {
expect(
await costOf(
`{ datasets { ...Page } } fragment Page on DatasetSearchResult ${page}`,
),
).toBe((await costOf(inlined)) + 3);
});

it('multiplies the contents of a spread at the operation root by 1.5', async () => {
expect(
await costOf(`{ ...Query } fragment Query on Query ${inlined}`),
).toBe(3 + 1.5 * (await costOf(inlined)));
});

it('charges a repeated spread 1 plus 1.5× what its first spread scored', async () => {
const fragment = `fragment Page on DatasetSearchResult ${page}`;
const once = await costOf(`{ datasets { ...Page } } ${fragment}`);
// `once` is the `datasets` field (2) plus the first spread (1 + F).
const fragmentCost = once - 3;
expect(
await costOf(
`{ a: datasets { ...Page } b: datasets { ...Page } } ${fragment}`,
),
).toBe(once + 2 + 1 + 1.5 * fragmentCost);
});
});

it('answers introspection within the default limits', async () => {
const { engine } = fakeEngine();
const handler = createSearchGraphQLHandler({
Expand Down
Loading