diff --git a/docs/reference/search-api-graphql.md b/docs/reference/search-api-graphql.md index 2636fbfa..3ad18332 100644 --- a/docs/reference/search-api-graphql.md +++ b/docs/reference/search-api-graphql.md @@ -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 diff --git a/packages/search-api-graphql/src/handler.ts b/packages/search-api-graphql/src/handler.ts index 10f228cc..8e3572ca 100644 --- a/packages/search-api-graphql/src/handler.ts +++ b/packages/search-api-graphql/src/handler.ts @@ -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. */ diff --git a/packages/search-api-graphql/test/handler.test.ts b/packages/search-api-graphql/test/handler.test.ts index bbb2873f..1b69023a 100644 --- a/packages/search-api-graphql/test/handler.test.ts +++ b/packages/search-api-graphql/test/handler.test.ts @@ -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 { + 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({