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
12 changes: 12 additions & 0 deletions docs/reference/search-api-graphql.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 26 additions & 6 deletions packages/search-api-graphql/src/build-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string>;
/**
* 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
Expand Down Expand Up @@ -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]),
Expand Down Expand Up @@ -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;
},
},
});
Expand Down Expand Up @@ -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) : [];
},
});

Expand Down
57 changes: 52 additions & 5 deletions packages/search-api-graphql/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <endpoint>?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 <endpoint>?sdl` – the
* schema contract without a running introspection query).
*/
export function createSearchGraphQLHandler(
options: SearchGraphQLHandlerOptions,
Expand All @@ -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<string> }>({
schema,
graphqlEndpoint,
// Build responses with the runtime’s own Fetch classes, so the handler’s
Expand All @@ -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,
}),
});
Expand All @@ -147,10 +149,55 @@ export function createSearchGraphQLHandler(
headers: { 'content-type': 'application/graphql; charset=utf-8' },
});
}
return await yoga.fetch(request, {});
const servedLanguages = new Set<string>();
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>,
): string | undefined {
const tagsByLowerCase = new Map<string, string>();
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) {
Expand Down
4 changes: 3 additions & 1 deletion packages/search-api-graphql/test/build-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,12 +320,13 @@ describe('buildGraphQLSchema', () => {
keyword: [{ value: 'kaarten', count: 3 }],
},
});
const servedLanguages = new Set<string>();
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<string, unknown>)
.facets as {
Expand All @@ -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 () => {
Expand Down
88 changes: 86 additions & 2 deletions packages/search-api-graphql/test/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} }));
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion packages/search-api-graphql/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
Expand Down
Loading