@lde/search-api-graphql 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,6 +59,60 @@ const gqlSchema = buildGraphQLSchema(searchSchema(DATASET, PERSON), {
59
59
  Shared types (`LanguageString`, the facet buckets, filter inputs and reference
60
60
  types such as a common `Agent`) are created once and reused across root types.
61
61
 
62
+ ## Serving the API
63
+
64
+ `createSearchGraphQLHandler` turns the schema into a **served API**: one
65
+ framework-agnostic `(request: Request) => Promise<Response>` handler (built on
66
+ [graphql-yoga](https://the-guild.dev/graphql/yoga-server), see
67
+ [ADR 14](../../docs/decisions/0014-serve-the-search-graphql-api-with-graphql-yoga.md))
68
+ covering POST execution, introspection, error shaping and per-request
69
+ `Accept-Language` parsing:
70
+
71
+ ```ts
72
+ import { createSearchGraphQLHandler } from '@lde/search-api-graphql';
73
+
74
+ const handler = createSearchGraphQLHandler({
75
+ searchSchema: searchSchema(DATASET, PERSON),
76
+ engine, // e.g. createTypesenseSearchEngine(…)
77
+ });
78
+
79
+ // SvelteKit (src/routes/graphql/+server.ts):
80
+ export const GET = ({ request }) => handler(request);
81
+ export const POST = GET;
82
+
83
+ // Plain node:http:
84
+ import { createServerAdapter } from '@whatwg-node/server';
85
+ createServer(createServerAdapter(handler)).listen(4000);
86
+ ```
87
+
88
+ Every host that speaks `Request`/`Response` (SvelteKit, Hono, Fastify via a
89
+ bridge, plain Node) mounts it the same way. Batteries included:
90
+
91
+ - **Playground**: `GET /graphql` serves the bundled GraphiQL –
92
+ self-contained (no external CDN) and sent without framing headers, so a docs
93
+ site can `<iframe>` the deployed playground as a live client. Disable it per
94
+ environment (`playground: false`) or swap the renderer (`renderPlayground`).
95
+ - **SDL**: `GET /graphql?sdl` returns the schema contract as SDL – publish it
96
+ or generate static docs in CI without a running introspection query.
97
+ - **CORS** for cross-origin browser clients (configurable via `cors`).
98
+ - **Depth and cost limits** ([graphql-armor](https://escape.tech/graphql-armor/);
99
+ `maxDepth`, default 15, and `maxCost`, default 5000) guard the public
100
+ endpoint against arbitrarily expensive queries; introspection stays exempt.
101
+
102
+ To serve **custom fields next to the generated search API**, merge your own
103
+ schema with `buildGraphQLSchema()`’s output (e.g. `@graphql-tools/schema`’s
104
+ `mergeSchemas`) and pass the union as `schema` instead of `searchSchema`; the
105
+ same endpoint and playground serve both:
106
+
107
+ ```ts
108
+ const handler = createSearchGraphQLHandler({
109
+ schema: mergeSchemas({
110
+ schemas: [buildGraphQLSchema(searchSchema(DATASET)), myCustomSchema],
111
+ }),
112
+ engine,
113
+ });
114
+ ```
115
+
62
116
  ## Serving a subset of the schema
63
117
 
64
118
  `types` never filters: every `SearchType` in the schema you pass gets a root
@@ -0,0 +1,72 @@
1
+ import { type GraphQLSchema } from 'graphql';
2
+ import { type CORSOptions, type GraphiQLOptions } from 'graphql-yoga';
3
+ import type { SearchEngine, SearchSchema } from '@lde/search';
4
+ import { type BuildGraphQLSchemaOptions, type SearchContext } from './build-schema.js';
5
+ /**
6
+ * A framework-agnostic request handler: SvelteKit, Fastify (via a
7
+ * `Request`/`Response` bridge), Hono and plain `node:http` (via
8
+ * `@whatwg-node/server`, which graphql-yoga uses internally) all mount it
9
+ * directly.
10
+ */
11
+ export type SearchGraphQLHandler = (request: Request) => Promise<Response>;
12
+ /**
13
+ * Renders the playground page served on `GET` requests to the endpoint.
14
+ * The default is the self-contained GraphiQL bundled with graphql-yoga
15
+ * (no external CDN); supply your own to swap the renderer (e.g. Altair).
16
+ */
17
+ export type PlaygroundRenderer = (options?: GraphiQLOptions) => BodyInit | Promise<BodyInit>;
18
+ export interface SearchGraphQLHandlerOptions {
19
+ /**
20
+ * The search schema to serve; the handler builds the GraphQL schema from it
21
+ * with {@link buildGraphQLSchema}. Provide either this or {@link schema}.
22
+ */
23
+ readonly searchSchema?: SearchSchema;
24
+ /**
25
+ * A ready {@link GraphQLSchema} to serve instead – bring your own fields by
26
+ * merging a custom schema with {@link buildGraphQLSchema}’s output (e.g.
27
+ * `@graphql-tools/schema` `mergeSchemas`) and passing the union here.
28
+ * Provide either this or {@link searchSchema}.
29
+ */
30
+ readonly schema?: GraphQLSchema;
31
+ /** The engine every request’s resolvers search with. */
32
+ readonly engine: SearchEngine;
33
+ /** Options for {@link buildGraphQLSchema} when {@link searchSchema} is given. */
34
+ readonly schemaOptions?: BuildGraphQLSchemaOptions;
35
+ /** Endpoint path the handler serves. @default '/graphql' */
36
+ readonly graphqlEndpoint?: string;
37
+ /**
38
+ * Serve the playground on `GET` requests to the endpoint. The page is
39
+ * self-contained (no external CDN) and sends no framing headers, so a docs
40
+ * site can `<iframe>` it as a live client. Disable per environment.
41
+ * @default true
42
+ */
43
+ readonly playground?: boolean;
44
+ /** Custom playground renderer; defaults to the bundled GraphiQL. */
45
+ readonly renderPlayground?: PlaygroundRenderer;
46
+ /**
47
+ * CORS headers for cross-origin browser clients. Defaults to graphql-yoga’s
48
+ * permissive default (reflects the request origin); pass `false` to send no
49
+ * CORS headers.
50
+ */
51
+ readonly cors?: CORSOptions | boolean;
52
+ /**
53
+ * Reject queries nested deeper than this – a public playground invites
54
+ * arbitrary queries. Introspection is exempt. @default 15
55
+ */
56
+ readonly maxDepth?: number;
57
+ /**
58
+ * Reject queries costlier than this (graphql-armor cost limit; introspection
59
+ * is exempt). @default 5000
60
+ */
61
+ readonly maxCost?: number;
62
+ /** Forwarded to {@link SearchContext.onFacetError} on every request. */
63
+ readonly onFacetError?: SearchContext['onFacetError'];
64
+ }
65
+ /**
66
+ * The served search API: one `fetch` handler covering POST execution, the GET
67
+ * playground, introspection, CORS, depth/cost limits, `Accept-Language`
68
+ * parsing, and the SDL (`GET <endpoint>?sdl` – the schema contract without a
69
+ * running introspection query).
70
+ */
71
+ export declare function createSearchGraphQLHandler(options: SearchGraphQLHandlerOptions): SearchGraphQLHandler;
72
+ //# sourceMappingURL=handler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAC1D,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,eAAe,EACrB,MAAM,cAAc,CAAC;AAKtB,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC9D,OAAO,EAEL,KAAK,yBAAyB,EAC9B,KAAK,aAAa,EACnB,MAAM,mBAAmB,CAAC;AAE3B;;;;;GAKG;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAE3E;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAC/B,OAAO,CAAC,EAAE,eAAe,KACtB,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;AAElC,MAAM,WAAW,2BAA2B;IAC1C;;;OAGG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,YAAY,CAAC;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;IAChC,wDAAwD;IACxD,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAC9B,iFAAiF;IACjF,QAAQ,CAAC,aAAa,CAAC,EAAE,yBAAyB,CAAC;IACnD,4DAA4D;IAC5D,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC;;;;;OAKG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC;IAC9B,oEAAoE;IACpE,QAAQ,CAAC,gBAAgB,CAAC,EAAE,kBAAkB,CAAC;IAC/C;;;;OAIG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC;IACtC;;;OAGG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,wEAAwE;IACxE,QAAQ,CAAC,YAAY,CAAC,EAAE,aAAa,CAAC,cAAc,CAAC,CAAC;CACvD;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,2BAA2B,GACnC,oBAAoB,CA2CtB"}
@@ -0,0 +1,60 @@
1
+ import { printSchema } from 'graphql';
2
+ import { createYoga, } from 'graphql-yoga';
3
+ import { renderGraphiQL } from '@graphql-yoga/render-graphiql';
4
+ import { maxDepthPlugin } from '@escape.tech/graphql-armor-max-depth';
5
+ import { costLimitPlugin } from '@escape.tech/graphql-armor-cost-limit';
6
+ import Negotiator from 'negotiator';
7
+ import { buildGraphQLSchema, } from './build-schema.js';
8
+ /**
9
+ * The served search API: one `fetch` handler covering POST execution, the GET
10
+ * playground, introspection, CORS, depth/cost limits, `Accept-Language`
11
+ * parsing, and the SDL (`GET <endpoint>?sdl` – the schema contract without a
12
+ * running introspection query).
13
+ */
14
+ export function createSearchGraphQLHandler(options) {
15
+ if ((options.searchSchema === undefined) === (options.schema === undefined)) {
16
+ throw new Error('Provide exactly one of searchSchema or schema.');
17
+ }
18
+ const schema = options.schema ??
19
+ buildGraphQLSchema(options.searchSchema, options.schemaOptions);
20
+ const graphqlEndpoint = options.graphqlEndpoint ?? '/graphql';
21
+ const yoga = createYoga({
22
+ schema,
23
+ graphqlEndpoint,
24
+ landingPage: false,
25
+ graphiql: options.playground !== false,
26
+ renderGraphiQL: options.renderPlayground ?? renderGraphiQL,
27
+ cors: options.cors,
28
+ plugins: [
29
+ maxDepthPlugin({ n: options.maxDepth ?? 15 }),
30
+ costLimitPlugin({ maxCost: options.maxCost ?? 5000 }),
31
+ ],
32
+ context: ({ request }) => ({
33
+ engine: options.engine,
34
+ acceptLanguage: parseAcceptLanguage(request.headers.get('accept-language')),
35
+ onFacetError: options.onFacetError,
36
+ }),
37
+ });
38
+ const sdl = printSchema(schema);
39
+ return async (request) => {
40
+ const url = new URL(request.url);
41
+ if (request.method === 'GET' &&
42
+ url.pathname === graphqlEndpoint &&
43
+ url.searchParams.has('sdl')) {
44
+ return new Response(sdl, {
45
+ headers: { 'content-type': 'application/graphql; charset=utf-8' },
46
+ });
47
+ }
48
+ return await yoga.fetch(request, {});
49
+ };
50
+ }
51
+ /** Ordered languages from an `Accept-Language` header (best first, by q-value). */
52
+ function parseAcceptLanguage(header) {
53
+ if (header === null) {
54
+ return [];
55
+ }
56
+ const negotiator = new Negotiator({
57
+ headers: { 'accept-language': header },
58
+ });
59
+ return negotiator.languages().filter((language) => language !== '*');
60
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export { buildGraphQLSchema, printGraphQLSchema } from './build-schema.js';
2
+ export { createSearchGraphQLHandler } from './handler.js';
3
+ export type { SearchGraphQLHandler, SearchGraphQLHandlerOptions, PlaygroundRenderer, } from './handler.js';
2
4
  export type { SearchContext, BuildGraphQLSchemaOptions, SearchTypeOptions, } from './build-schema.js';
3
5
  export { defaultLanguageOrder } from './language.js';
4
6
  export type { LanguageString, LanguageOrder } from './language.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC3E,YAAY,EACV,aAAa,EACb,yBAAyB,EACzB,iBAAiB,GAClB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AACrD,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC3E,OAAO,EAAE,0BAA0B,EAAE,MAAM,cAAc,CAAC;AAC1D,YAAY,EACV,oBAAoB,EACpB,2BAA2B,EAC3B,kBAAkB,GACnB,MAAM,cAAc,CAAC;AACtB,YAAY,EACV,aAAa,EACb,yBAAyB,EACzB,iBAAiB,GAClB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AACrD,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC"}
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { buildGraphQLSchema, printGraphQLSchema } from './build-schema.js';
2
+ export { createSearchGraphQLHandler } from './handler.js';
2
3
  export { defaultLanguageOrder } from './language.js';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lde/search-api-graphql",
3
- "version": "0.11.0",
4
- "description": "Engine- and domain-agnostic GraphQL surface for @lde/search: builds an executable GraphQLSchema from a whole SearchSchema at runtime (no codegen) — one root query field per SearchType — served by generic resolvers over any SearchEngine. You supply the schema and per-type typeNames; it names neither your domain nor your engine.",
3
+ "version": "0.12.0",
4
+ "description": "Engine- and domain-agnostic GraphQL surface for @lde/search: builds an executable GraphQLSchema from a whole SearchSchema at runtime (no codegen) – one root query field per SearchType – served by generic resolvers over any SearchEngine – and serves it as a framework-agnostic fetch handler with a self-contained playground, SDL endpoint, CORS and depth/cost limits. You supply the schema and per-type typeNames; it names neither your domain nor your engine.",
5
5
  "repository": {
6
6
  "url": "git+https://github.com/ldelements/lde.git",
7
7
  "directory": "packages/search-api-graphql"
@@ -25,9 +25,14 @@
25
25
  "!**/*.tsbuildinfo"
26
26
  ],
27
27
  "dependencies": {
28
+ "@escape.tech/graphql-armor-cost-limit": "^2.4.3",
29
+ "@escape.tech/graphql-armor-max-depth": "^2.4.2",
30
+ "@graphql-yoga/render-graphiql": "^5.21.2",
28
31
  "@lde/search": "^0.12.0",
29
32
  "dataloader": "^2.2.3",
30
- "graphql": "^15.8.0",
33
+ "graphql": "^16.0.0",
34
+ "graphql-yoga": "^5.21.2",
35
+ "negotiator": "^0.6.4",
31
36
  "tslib": "^2.3.0"
32
37
  }
33
38
  }