From d4e4872f8aa0dc3e36a9e1a3fb3de924ba869376 Mon Sep 17 00:00:00 2001 From: David de Boer Date: Fri, 16 Oct 2026 14:09:35 +0200 Subject: [PATCH 1/4] fix(search-api-graphql): send Vary and Content-Language for Accept-Language - Append Vary: Accept-Language to every GraphQL response, so a shared cache keys on the header that output order and search locale follow - Collect the languages of served LanguageStrings on an optional SearchContext.servedLanguages set; report them in Content-Language - Leave malformed tags out of the header and fold case-only duplicates, since tags come from indexed data --- docs/reference/search-api-graphql.md | 9 ++ .../search-api-graphql/src/build-schema.ts | 31 +++++-- packages/search-api-graphql/src/handler.ts | 43 ++++++++-- .../test/build-schema.test.ts | 4 +- .../search-api-graphql/test/handler.test.ts | 85 ++++++++++++++++++- packages/search-api-graphql/vite.config.ts | 2 +- 6 files changed, 159 insertions(+), 15 deletions(-) diff --git a/docs/reference/search-api-graphql.md b/docs/reference/search-api-graphql.md index 2636fbfa..5cad2fe4 100644 --- a/docs/reference/search-api-graphql.md +++ b/docs/reference/search-api-graphql.md @@ -277,6 +277,15 @@ first (in request order), then the remaining tagged languages, then untagged `languageOrder` schema option; the default ordering is exported as `defaultLanguageOrder` for composing your own. +Because the response depends on `Accept-Language`, the handler sends +`Vary: Accept-Language` on every GraphQL response, so a shared cache or CDN +never serves one requester’s language order to the next. It also sends +`Content-Language` listing every tagged language the response contains, in the +order first served – a mixed-language result page lists all of them (e.g. +`Content-Language: en, nl`). A response with no tagged values sends none. +Negotiation never fails: a language you ask for but the data lacks still gets +the best available value. + ## Finding which fields accept an IRI In Linked Data one conceptual filter maps to several predicates, so a consumer diff --git a/packages/search-api-graphql/src/build-schema.ts b/packages/search-api-graphql/src/build-schema.ts index 87c9dfd2..5bdd782e 100644 --- a/packages/search-api-graphql/src/build-schema.ts +++ b/packages/search-api-graphql/src/build-schema.ts @@ -56,6 +56,7 @@ import { defaultLanguageOrder, toLanguageStrings, type LanguageOrder, + type LanguageString, } from './language.js'; import { createFacetLoader, type FacetLoader } from './facet-batch.js'; import { projectionFor } from './projection.js'; @@ -68,6 +69,12 @@ export interface SearchContext { readonly engine: SearchEngine; /** Parsed, ordered `Accept-Language`; drives locale selection and output order. */ readonly acceptLanguage: readonly string[]; + /** + * Collects the language of every tagged `LanguageString` the response + * serves, so the transport can report them in `Content-Language`. Optional – + * omit when the transport doesn’t report served languages. + */ + readonly servedLanguages?: Set; /** * Called once per affected facet field when its computation fails – for a * failed facet query only that query's fields, for a failed batch dispatch @@ -226,6 +233,22 @@ export function buildGraphQLSchema( options: BuildGraphQLSchemaOptions = {}, ): GraphQLSchema { const languageOrder = options.languageOrder ?? defaultLanguageOrder; + const serveLanguageStrings = ( + value: LocalizedValue, + context: SearchContext, + ): LanguageString[] => { + const languageStrings = toLanguageStrings( + value, + context.acceptLanguage, + languageOrder, + ); + for (const { language } of languageStrings) { + if (language !== null) { + context.servedLanguages?.add(language); + } + } + return languageStrings; + }; const maxPerPage = options.maxPerPage ?? 100; const rootTypesByName = new Map( [...schema.values()].map((searchType) => [searchType.name, searchType]), @@ -260,9 +283,7 @@ export function buildGraphQLSchema( type: new GraphQLList(new GraphQLNonNull(languageString)), resolve: (bucket: Source, _args: unknown, context: SearchContext) => { const label = bucket.label as LocalizedValue | undefined; - return label - ? toLanguageStrings(label, context.acceptLanguage, languageOrder) - : null; + return label ? serveLanguageStrings(label, context) : null; }, }, }); @@ -389,9 +410,7 @@ export function buildGraphQLSchema( type: nonNullListOf(languageString), resolve: (source: Source, _args: unknown, context: SearchContext) => { const value = resolveLabel(source); - return value - ? toLanguageStrings(value, context.acceptLanguage, languageOrder) - : []; + return value ? serveLanguageStrings(value, context) : []; }, }); diff --git a/packages/search-api-graphql/src/handler.ts b/packages/search-api-graphql/src/handler.ts index 10f228cc..f12e0002 100644 --- a/packages/search-api-graphql/src/handler.ts +++ b/packages/search-api-graphql/src/handler.ts @@ -85,8 +85,9 @@ export interface SearchGraphQLHandlerOptions { /** * The served search API: one `fetch` handler covering POST execution, the GET * playground, introspection, CORS, depth/cost limits, `Accept-Language` - * parsing, and the SDL (`GET ?sdl` – the schema contract without a - * running introspection query). + * parsing (answered with `Vary: Accept-Language` and a `Content-Language` + * listing every language served), and the SDL (`GET ?sdl` – the + * schema contract without a running introspection query). */ export function createSearchGraphQLHandler( options: SearchGraphQLHandlerOptions, @@ -99,7 +100,7 @@ export function createSearchGraphQLHandler( buildGraphQLSchema(options.searchSchema!, options.schemaOptions); const graphqlEndpoint = options.graphqlEndpoint ?? '/graphql'; - const yoga = createYoga({ + const yoga = createYoga<{ servedLanguages: Set }>({ schema, graphqlEndpoint, // Build responses with the runtime’s own Fetch classes, so the handler’s @@ -126,11 +127,12 @@ export function createSearchGraphQLHandler( flattenFragments: true, }), ], - context: ({ request }): SearchContext => ({ + context: ({ request, servedLanguages }): SearchContext => ({ engine: options.engine, acceptLanguage: parseAcceptLanguage( request.headers.get('accept-language'), ), + servedLanguages, onFacetError: options.onFacetError, }), }); @@ -147,10 +149,41 @@ export function createSearchGraphQLHandler( headers: { 'content-type': 'application/graphql; charset=utf-8' }, }); } - return await yoga.fetch(request, {}); + const servedLanguages = new Set(); + const response = await yoga.fetch(request, { servedLanguages }); + // Results differ per Accept-Language (output order, search and sort + // locale), so a shared cache must key on it. + response.headers.append('vary', 'Accept-Language'); + const contentLanguage = toContentLanguage(servedLanguages); + if (contentLanguage !== undefined) { + response.headers.set('content-language', contentLanguage); + } + return response; }; } +/** + * A `Content-Language` value for the served languages: tags come from indexed + * data, so drop any that is not a well-formed language tag (it could make an + * invalid header) and fold case-only duplicates (tags are case-insensitive). + */ +function toContentLanguage( + servedLanguages: ReadonlySet, +): string | undefined { + const tagsByLowerCase = new Map(); + for (const language of servedLanguages) { + const lowerCase = language.toLowerCase(); + if (languageTag.test(language) && !tagsByLowerCase.has(lowerCase)) { + tagsByLowerCase.set(lowerCase, language); + } + } + return tagsByLowerCase.size > 0 + ? [...tagsByLowerCase.values()].join(', ') + : undefined; +} + +const languageTag = /^[A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})*$/; + /** Ordered languages from an `Accept-Language` header (best first, by q-value). */ function parseAcceptLanguage(header: string | null): readonly string[] { if (header === null) { diff --git a/packages/search-api-graphql/test/build-schema.test.ts b/packages/search-api-graphql/test/build-schema.test.ts index 2e7ff8b3..b43ac151 100644 --- a/packages/search-api-graphql/test/build-schema.test.ts +++ b/packages/search-api-graphql/test/build-schema.test.ts @@ -320,12 +320,13 @@ describe('buildGraphQLSchema', () => { keyword: [{ value: 'kaarten', count: 3 }], }, }); + const servedLanguages = new Set(); const result = await run( `{ datasets { facets { publisher { value count label { language value } } keyword { value count label { language value } } } } }`, - { engine, acceptLanguage: ['nl'] }, + { engine, acceptLanguage: ['nl'], servedLanguages }, ); const facets = (result.data?.datasets as Record) .facets as { @@ -342,6 +343,7 @@ describe('buildGraphQLSchema', () => { expect(facets.keyword).toEqual([ { value: 'kaarten', count: 3, label: null }, ]); + expect([...servedLanguages]).toEqual(['nl']); }); it('drops an unselectable bucket rather than nulling the response around it', async () => { diff --git a/packages/search-api-graphql/test/handler.test.ts b/packages/search-api-graphql/test/handler.test.ts index bbb2873f..322148ea 100644 --- a/packages/search-api-graphql/test/handler.test.ts +++ b/packages/search-api-graphql/test/handler.test.ts @@ -47,14 +47,17 @@ const canned: SearchResult = { facets: {}, }; -function fakeEngine(): { engine: SearchEngine; received: () => SearchQuery } { +function fakeEngine(result: SearchResult = canned): { + engine: SearchEngine; + received: () => SearchQuery; +} { let captured: SearchQuery; return { engine: { schema: searchSchema(schema), async search(_searchType, query) { captured = query; - return canned; + return result; }, async searchFacets(_searchType, queries) { return queries.map((): FacetsOutcome => ({ facets: {} })); @@ -191,6 +194,84 @@ describe('createSearchGraphQLHandler', () => { expect(received().locale).toBe('nl'); }); + it('declares that responses vary by Accept-Language', async () => { + const { engine } = fakeEngine(); + const handler = createSearchGraphQLHandler({ + searchSchema: searchSchema(schema), + engine, + }); + + const response = await post( + handler, + '{ datasets { pagination { total } } }', + ); + + expect(response.headers.get('vary')).toContain('Accept-Language'); + }); + + it('reports the languages it served in Content-Language', async () => { + const { engine } = fakeEngine(); + const handler = createSearchGraphQLHandler({ + searchSchema: searchSchema(schema), + engine, + }); + + const response = await post( + handler, + '{ datasets { items { title { language value } } } }', + { 'accept-language': 'en' }, + ); + + expect(response.headers.get('content-language')).toBe('en, nl'); + }); + + it('leaves malformed and case-duplicate tags out of Content-Language', async () => { + const { engine } = fakeEngine({ + ...canned, + hits: [ + { + id: 'https://d/1', + document: { + title: { + nl: ['Titel'], + NL: ['TITEL'], + 'en\nX-Injected: 1': ['Title'], + und: ['Naamloos'], + }, + }, + }, + ], + }); + const handler = createSearchGraphQLHandler({ + searchSchema: searchSchema(schema), + engine, + }); + + const response = await post( + handler, + '{ datasets { items { title { language value } } } }', + ); + + expect(response.status).toBe(200); + expect(response.headers.get('content-language')).toBe('nl'); + }); + + it('sends no Content-Language when it served no language-tagged value', async () => { + const { engine } = fakeEngine(); + const handler = createSearchGraphQLHandler({ + searchSchema: searchSchema(schema), + engine, + }); + + const response = await post( + handler, + '{ datasets { pagination { total } } }', + { 'accept-language': 'en' }, + ); + + expect(response.headers.get('content-language')).toBeNull(); + }); + it('serves a self-contained playground on GET', async () => { const { engine } = fakeEngine(); const handler = createSearchGraphQLHandler({ diff --git a/packages/search-api-graphql/vite.config.ts b/packages/search-api-graphql/vite.config.ts index 7a01dae5..51f6eead 100644 --- a/packages/search-api-graphql/vite.config.ts +++ b/packages/search-api-graphql/vite.config.ts @@ -29,7 +29,7 @@ export default mergeConfig( // Re-anchored for the selection-set projection: its defensive reads // (an unresolvable target, an absent field list) are reachable only // from a direct caller, since GraphQL validates the query first. - branches: 97.07, + branches: 97.16, statements: 100, }, }, From 8c42bd444cab4d4045434807fb0236517af7d359 Mon Sep 17 00:00:00 2001 From: David de Boer Date: Fri, 16 Oct 2026 14:17:47 +0200 Subject: [PATCH 2/4] refactor(search-api-graphql): validate Content-Language tags with Intl - Canonicalise served tags with Intl.getCanonicalLocales, which rejects malformed tags and folds case, instead of a hand-written pattern - Re-anchor the branch threshold for the removed branches --- packages/search-api-graphql/src/handler.ts | 17 +++++++---------- packages/search-api-graphql/vite.config.ts | 2 +- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/search-api-graphql/src/handler.ts b/packages/search-api-graphql/src/handler.ts index f12e0002..02e2dfbf 100644 --- a/packages/search-api-graphql/src/handler.ts +++ b/packages/search-api-graphql/src/handler.ts @@ -165,25 +165,22 @@ export function createSearchGraphQLHandler( /** * A `Content-Language` value for the served languages: tags come from indexed * data, so drop any that is not a well-formed language tag (it could make an - * invalid header) and fold case-only duplicates (tags are case-insensitive). + * invalid header) and canonicalise the rest, which folds case-only duplicates. */ function toContentLanguage( servedLanguages: ReadonlySet, ): string | undefined { - const tagsByLowerCase = new Map(); + const canonicalTags = new Set(); for (const language of servedLanguages) { - const lowerCase = language.toLowerCase(); - if (languageTag.test(language) && !tagsByLowerCase.has(lowerCase)) { - tagsByLowerCase.set(lowerCase, language); + try { + canonicalTags.add(Intl.getCanonicalLocales(language)[0]!); + } catch { + // Not a well-formed BCP 47 tag. } } - return tagsByLowerCase.size > 0 - ? [...tagsByLowerCase.values()].join(', ') - : undefined; + return canonicalTags.size > 0 ? [...canonicalTags].join(', ') : undefined; } -const languageTag = /^[A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})*$/; - /** Ordered languages from an `Accept-Language` header (best first, by q-value). */ function parseAcceptLanguage(header: string | null): readonly string[] { if (header === null) { diff --git a/packages/search-api-graphql/vite.config.ts b/packages/search-api-graphql/vite.config.ts index 51f6eead..8f799c56 100644 --- a/packages/search-api-graphql/vite.config.ts +++ b/packages/search-api-graphql/vite.config.ts @@ -29,7 +29,7 @@ export default mergeConfig( // Re-anchored for the selection-set projection: its defensive reads // (an unresolvable target, an absent field list) are reachable only // from a direct caller, since GraphQL validates the query first. - branches: 97.16, + branches: 97.13, statements: 100, }, }, From d346df89eb9b1f3b61e3dadd0d835e441a9617ae Mon Sep 17 00:00:00 2001 From: David de Boer Date: Fri, 16 Oct 2026 14:20:49 +0200 Subject: [PATCH 3/4] fix(search-api-graphql): keep served tag spelling in Content-Language - Use Intl only to validate tags, so the header names a language the way LanguageString.language serves it (iw, not its alias he) - Skip untagged values in any case (UND), not only lowercase und --- packages/search-api-graphql/src/handler.ts | 31 ++++++++++++++----- .../search-api-graphql/test/handler.test.ts | 7 +++-- packages/search-api-graphql/vite.config.ts | 2 +- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/packages/search-api-graphql/src/handler.ts b/packages/search-api-graphql/src/handler.ts index 02e2dfbf..3ed94b23 100644 --- a/packages/search-api-graphql/src/handler.ts +++ b/packages/search-api-graphql/src/handler.ts @@ -165,20 +165,37 @@ export function createSearchGraphQLHandler( /** * A `Content-Language` value for the served languages: tags come from indexed * data, so drop any that is not a well-formed language tag (it could make an - * invalid header) and canonicalise the rest, which folds case-only duplicates. + * invalid header) or is untagged in another case (`UND`), and fold case-only + * duplicates. Tags keep the spelling the body serves them in, so the header + * names the same languages as `LanguageString.language`. */ function toContentLanguage( servedLanguages: ReadonlySet, ): string | undefined { - const canonicalTags = new Set(); + const tagsByLowerCase = new Map(); for (const language of servedLanguages) { - try { - canonicalTags.add(Intl.getCanonicalLocales(language)[0]!); - } catch { - // Not a well-formed BCP 47 tag. + const lowerCase = language.toLowerCase(); + if ( + lowerCase !== 'und' && + !tagsByLowerCase.has(lowerCase) && + isLanguageTag(language) + ) { + tagsByLowerCase.set(lowerCase, language); } } - return canonicalTags.size > 0 ? [...canonicalTags].join(', ') : undefined; + return tagsByLowerCase.size > 0 + ? [...tagsByLowerCase.values()].join(', ') + : undefined; +} + +/** Whether a string is a well-formed BCP 47 language tag. */ +function isLanguageTag(language: string): boolean { + try { + Intl.getCanonicalLocales(language); + return true; + } catch { + return false; + } } /** Ordered languages from an `Accept-Language` header (best first, by q-value). */ diff --git a/packages/search-api-graphql/test/handler.test.ts b/packages/search-api-graphql/test/handler.test.ts index 322148ea..5f4865f3 100644 --- a/packages/search-api-graphql/test/handler.test.ts +++ b/packages/search-api-graphql/test/handler.test.ts @@ -225,7 +225,7 @@ describe('createSearchGraphQLHandler', () => { expect(response.headers.get('content-language')).toBe('en, nl'); }); - it('leaves malformed and case-duplicate tags out of Content-Language', async () => { + it('leaves malformed, untagged and case-duplicate tags out of Content-Language', async () => { const { engine } = fakeEngine({ ...canned, hits: [ @@ -236,6 +236,8 @@ describe('createSearchGraphQLHandler', () => { nl: ['Titel'], NL: ['TITEL'], 'en\nX-Injected: 1': ['Title'], + UND: ['Zonder taal'], + iw: ['כותרת'], und: ['Naamloos'], }, }, @@ -253,7 +255,8 @@ describe('createSearchGraphQLHandler', () => { ); expect(response.status).toBe(200); - expect(response.headers.get('content-language')).toBe('nl'); + // `iw` stays as served, not canonicalised to `he`. + expect(response.headers.get('content-language')).toBe('nl, iw'); }); it('sends no Content-Language when it served no language-tagged value', async () => { diff --git a/packages/search-api-graphql/vite.config.ts b/packages/search-api-graphql/vite.config.ts index 8f799c56..77ed1c74 100644 --- a/packages/search-api-graphql/vite.config.ts +++ b/packages/search-api-graphql/vite.config.ts @@ -29,7 +29,7 @@ export default mergeConfig( // Re-anchored for the selection-set projection: its defensive reads // (an unresolvable target, an absent field list) are reachable only // from a direct caller, since GraphQL validates the query first. - branches: 97.13, + branches: 97.17, statements: 100, }, }, From 9e49afce585f424c215e8f24e6c415e30d749b60 Mon Sep 17 00:00:00 2001 From: David de Boer Date: Fri, 16 Oct 2026 14:24:05 +0200 Subject: [PATCH 4/4] fix(search-api-graphql): list only displayed languages in Content-Language - Record each LanguageString list's [0] language, the value a client displays, rather than every language behind it - Bound the header by what was requested instead of by how many languages the data holds --- docs/reference/search-api-graphql.md | 13 +++--- .../search-api-graphql/src/build-schema.ts | 15 +++---- .../search-api-graphql/test/handler.test.ts | 40 +++++++++---------- 3 files changed, 36 insertions(+), 32 deletions(-) diff --git a/docs/reference/search-api-graphql.md b/docs/reference/search-api-graphql.md index 5cad2fe4..cfbe9af0 100644 --- a/docs/reference/search-api-graphql.md +++ b/docs/reference/search-api-graphql.md @@ -278,11 +278,14 @@ first (in request order), then the remaining tagged languages, then untagged `defaultLanguageOrder` for composing your own. Because the response depends on `Accept-Language`, the handler sends -`Vary: Accept-Language` on every GraphQL response, so a shared cache or CDN -never serves one requester’s language order to the next. It also sends -`Content-Language` listing every tagged language the response contains, in the -order first served – a mixed-language result page lists all of them (e.g. -`Content-Language: en, nl`). A response with no tagged values sends none. +`Vary: Accept-Language` on every response from the endpoint, so a shared cache +or CDN never serves one requester’s language order to the next. It also sends +`Content-Language` listing the displayed language – `[0].language` – of every +localized value in the response, in the order first served. A mixed-language +result page lists each of them (e.g. `Content-Language: en, nl` when one item +has no English value); the further languages behind `[0]` are left out, so the +header grows with what was requested, not with how many languages the data +holds. A response with no tagged values sends none. Negotiation never fails: a language you ask for but the data lacks still gets the best available value. diff --git a/packages/search-api-graphql/src/build-schema.ts b/packages/search-api-graphql/src/build-schema.ts index 5bdd782e..883d7dae 100644 --- a/packages/search-api-graphql/src/build-schema.ts +++ b/packages/search-api-graphql/src/build-schema.ts @@ -70,9 +70,10 @@ export interface SearchContext { /** Parsed, ordered `Accept-Language`; drives locale selection and output order. */ readonly acceptLanguage: readonly string[]; /** - * Collects the language of every tagged `LanguageString` the response - * serves, so the transport can report them in `Content-Language`. Optional – - * omit when the transport doesn’t report served languages. + * Collects the displayed (`[0]`) language of every tagged `LanguageString` + * list the response serves, so the transport can report them in + * `Content-Language`. Optional – omit when the transport doesn’t report + * served languages. */ readonly servedLanguages?: Set; /** @@ -242,10 +243,10 @@ export function buildGraphQLSchema( context.acceptLanguage, languageOrder, ); - for (const { language } of languageStrings) { - if (language !== null) { - context.servedLanguages?.add(language); - } + // Only `[0]` is displayed, so only its language is served. + const displayedLanguage = languageStrings[0]?.language; + if (displayedLanguage) { + context.servedLanguages?.add(displayedLanguage); } return languageStrings; }; diff --git a/packages/search-api-graphql/test/handler.test.ts b/packages/search-api-graphql/test/handler.test.ts index 5f4865f3..12285d9f 100644 --- a/packages/search-api-graphql/test/handler.test.ts +++ b/packages/search-api-graphql/test/handler.test.ts @@ -209,8 +209,17 @@ describe('createSearchGraphQLHandler', () => { expect(response.headers.get('vary')).toContain('Accept-Language'); }); - it('reports the languages it served in Content-Language', async () => { - const { engine } = fakeEngine(); + it('reports the displayed languages in Content-Language', async () => { + const { engine } = fakeEngine({ + ...canned, + hits: [ + ...canned.hits, + { + id: 'https://d/2', + document: { title: { nl: ['Alleen Nederlands'] } }, + }, + ], + }); const handler = createSearchGraphQLHandler({ searchSchema: searchSchema(schema), engine, @@ -222,28 +231,19 @@ describe('createSearchGraphQLHandler', () => { { 'accept-language': 'en' }, ); + // The first item displays `en` (its `nl` is served but not displayed); + // the second has only `nl`. expect(response.headers.get('content-language')).toBe('en, nl'); }); it('leaves malformed, untagged and case-duplicate tags out of Content-Language', async () => { - const { engine } = fakeEngine({ - ...canned, - hits: [ - { - id: 'https://d/1', - document: { - title: { - nl: ['Titel'], - NL: ['TITEL'], - 'en\nX-Injected: 1': ['Title'], - UND: ['Zonder taal'], - iw: ['כותרת'], - und: ['Naamloos'], - }, - }, - }, - ], - }); + const titles = ['nl', 'NL', 'en\nX-Injected: 1', 'UND', 'iw', 'und'].map( + (language, index) => ({ + id: `https://d/${index}`, + document: { title: { [language]: ['Titel'] } }, + }), + ); + const { engine } = fakeEngine({ ...canned, hits: titles }); const handler = createSearchGraphQLHandler({ searchSchema: searchSchema(schema), engine,