diff --git a/docs/reference/search-api-graphql.md b/docs/reference/search-api-graphql.md index 2636fbfa..cfbe9af0 100644 --- a/docs/reference/search-api-graphql.md +++ b/docs/reference/search-api-graphql.md @@ -277,6 +277,18 @@ 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 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. + ## 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..883d7dae 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,13 @@ export interface SearchContext { readonly engine: SearchEngine; /** Parsed, ordered `Accept-Language`; drives locale selection and output order. */ readonly acceptLanguage: readonly string[]; + /** + * 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; /** * 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 +234,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, + ); + // Only `[0]` is displayed, so only its language is served. + const displayedLanguage = languageStrings[0]?.language; + if (displayedLanguage) { + context.servedLanguages?.add(displayedLanguage); + } + return languageStrings; + }; const maxPerPage = options.maxPerPage ?? 100; const rootTypesByName = new Map( [...schema.values()].map((searchType) => [searchType.name, searchType]), @@ -260,9 +284,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 +411,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..3ed94b23 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,55 @@ 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) 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 tagsByLowerCase = new Map(); + for (const language of servedLanguages) { + const lowerCase = language.toLowerCase(); + if ( + lowerCase !== 'und' && + !tagsByLowerCase.has(lowerCase) && + isLanguageTag(language) + ) { + tagsByLowerCase.set(lowerCase, language); + } + } + 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). */ 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..12285d9f 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,87 @@ 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 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, + }); + + const response = await post( + handler, + '{ datasets { items { title { language value } } } }', + { '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 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, + }); + + const response = await post( + handler, + '{ datasets { items { title { language value } } } }', + ); + + expect(response.status).toBe(200); + // `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 () => { + 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..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.07, + branches: 97.17, statements: 100, }, },