@lde/search-api-graphql 0.12.2 → 0.12.3
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 +7 -157
- package/dist/build-schema.d.ts.map +1 -1
- package/dist/build-schema.js +23 -13
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,27 +4,13 @@ The GraphQL surface for the [`@lde/search`](../search) core. **Both engine- and
|
|
|
4
4
|
domain-agnostic:** it builds an executable
|
|
5
5
|
[graphql-js](https://graphql.org/graphql-js/) `GraphQLSchema` from your whole
|
|
6
6
|
[`SearchSchema`](../search/README.md#terminology) at runtime – one root query
|
|
7
|
-
field per `SearchType`, each searchable in its own way.
|
|
8
|
-
served by the same resolver implementation (no per-type code, no codegen);
|
|
9
|
-
each root field gets its own instance of it, bound to that field’s
|
|
10
|
-
`SearchType`, over any `SearchEngine`. It names neither your **domain** (each type’s GraphQL name
|
|
11
|
-
is the `SearchType`’s own logical `name` – `Dataset`, `Person`, `CreativeWork`,
|
|
12
|
-
…) nor your **engine** (the resolver calls the schema-bound `context.engine`, be it
|
|
13
|
-
[`@lde/search-typesense`](../search-typesense) or another adapter).
|
|
7
|
+
field per `SearchType`, each searchable in its own way.
|
|
14
8
|
|
|
15
|
-
##
|
|
9
|
+
## Installation
|
|
16
10
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
and no stale artifact that can drift from the declaration – change the
|
|
21
|
-
`SearchType`, restart, and the API is current. (The flip side, no artifact
|
|
22
|
-
showing contract changes as diffs, is restored by the
|
|
23
|
-
[snapshot guard](#guarding-the-contract).) The field model
|
|
24
|
-
is the single source; the GraphQL contract is derived from it. Type names
|
|
25
|
-
come from each `SearchType`’s `name`; output types, the `where`/`orderBy`/facet
|
|
26
|
-
inputs, reference types and nullability are all derived from each field’s
|
|
27
|
-
`kind` and capability flags. The common case needs no options at all:
|
|
11
|
+
```sh
|
|
12
|
+
npm install @lde/search-api-graphql
|
|
13
|
+
```
|
|
28
14
|
|
|
29
15
|
```ts
|
|
30
16
|
import { searchSchema } from '@lde/search';
|
|
@@ -37,142 +23,6 @@ const gqlSchema = buildGraphQLSchema(searchSchema(DATASET, PERSON));
|
|
|
37
23
|
// { engine: SearchEngine, acceptLanguage: string[] }
|
|
38
24
|
```
|
|
39
25
|
|
|
40
|
-
|
|
41
|
-
`queryField` when the default root field (the lowercased plural of the type’s
|
|
42
|
-
`name`) is wrong, and a `queryDefaults` policy applied to every query of that
|
|
43
|
-
type:
|
|
44
|
-
|
|
45
|
-
```ts
|
|
46
|
-
const gqlSchema = buildGraphQLSchema(searchSchema(DATASET, PERSON), {
|
|
47
|
-
types: {
|
|
48
|
-
Dataset: {
|
|
49
|
-
queryDefaults: (query) => ({
|
|
50
|
-
...query,
|
|
51
|
-
where: [...query.where, { field: 'status', in: ['valid'] }],
|
|
52
|
-
}),
|
|
53
|
-
},
|
|
54
|
-
Person: { queryField: 'people' },
|
|
55
|
-
},
|
|
56
|
-
});
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
Shared types (`LanguageString`, the facet buckets, filter inputs and reference
|
|
60
|
-
types such as a common `Agent`) are created once and reused across root types.
|
|
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
|
-
|
|
116
|
-
## Serving a subset of the schema
|
|
117
|
-
|
|
118
|
-
`types` never filters: every `SearchType` in the schema you pass gets a root
|
|
119
|
-
field (options for a type not in the schema are a build-time error). To expose
|
|
120
|
-
only part of what you index, narrow the **schema argument** you hand
|
|
121
|
-
`buildGraphQLSchema` (`searchSchema(…)` is a cheap constructor, so build one per
|
|
122
|
-
consumer):
|
|
123
|
-
|
|
124
|
-
```ts
|
|
125
|
-
// Index a superset: hand a three-type schema to the pipeline, which projects and
|
|
126
|
-
// stores one collection per type (see @lde/search-pipeline). INTERNAL is indexed
|
|
127
|
-
// (e.g. a label source references resolve against) but never served.
|
|
128
|
-
const indexed = searchSchema(DATASET, PERSON, INTERNAL);
|
|
129
|
-
|
|
130
|
-
// Serve a subset: the GraphQL API exposes only two of those types.
|
|
131
|
-
const gqlSchema = buildGraphQLSchema(searchSchema(DATASET, PERSON));
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
## What it builds (per root type)
|
|
135
|
-
|
|
136
|
-
- **Output type** (the `SearchType`’s `name`): localized text → best-first `[LanguageString!]!`
|
|
137
|
-
(`[0].language` is the language actually served); references → named per-shape
|
|
138
|
-
types (`Organization`, `Term`) with a `name`; scalars/booleans per kind; `date`
|
|
139
|
-
→ ISO 8601 string; nullability from `required` / `array` / `kind`.
|
|
140
|
-
- **`where`** one input per `filterable` field (`StringFilter`, `IntRange` /
|
|
141
|
-
`FloatRange` / `DateRange`, or `Boolean`); omitted entirely for a type with no
|
|
142
|
-
filterable fields.
|
|
143
|
-
- **`orderBy`**: `RELEVANCE` plus every `sortable` field, as an enum.
|
|
144
|
-
- **Facets**: a keyed object with one field per `facetable` field; a bucket
|
|
145
|
-
carries `value` + `count` + a nullable `label` – the resolved data label for
|
|
146
|
-
**reference** facets, `null` for token/free-string facets whose display the
|
|
147
|
-
consumer owns (its own i18n, or the value itself). Selecting facet fields IS
|
|
148
|
-
the request: each selected facet is computed with its own `where`-filter
|
|
149
|
-
removed (skip-own-filter), and the whole selection is **batched per
|
|
150
|
-
request** – facets whose field carries no active filter share one query
|
|
151
|
-
(the unfiltered browse collapses to a single query) and everything is
|
|
152
|
-
dispatched as one `engine.searchFacets` call, so a typical page costs the
|
|
153
|
-
listing search plus one batched facet round-trip.
|
|
154
|
-
|
|
155
|
-
## Guarding the contract
|
|
156
|
-
|
|
157
|
-
Why the API, the index and a future REST surface cannot drift apart is the
|
|
158
|
-
search family’s overall approach – one field model, one query IR – described
|
|
159
|
-
in [`@lde/search`](../search/README.md). Specific to this surface: the GraphQL
|
|
160
|
-
contract is **frozen** (breaking to change), yet generated rather than
|
|
161
|
-
handwritten, so nothing in the repo shows a contract change as a reviewable
|
|
162
|
-
diff. A _consumer_ restores that with one snapshot test over its **own**
|
|
163
|
-
search schema:
|
|
164
|
-
|
|
165
|
-
```ts
|
|
166
|
-
import { printGraphQLSchema } from '@lde/search-api-graphql';
|
|
167
|
-
|
|
168
|
-
it('keeps the public GraphQL contract stable', () => {
|
|
169
|
-
expect(printGraphQLSchema(searchSchema(DATASET, PERSON))).toMatchSnapshot();
|
|
170
|
-
});
|
|
171
|
-
```
|
|
26
|
+
## Documentation
|
|
172
27
|
|
|
173
|
-
|
|
174
|
-
run re-emits and diffs against it. Any contract change – your own schema edit,
|
|
175
|
-
or a new version of this library emitting different GraphQL for the same
|
|
176
|
-
declaration – fails the test and shows the SDL diff, until you consciously
|
|
177
|
-
accept it (`vitest -u`) and the reviewer sees the contract change spelled out
|
|
178
|
-
in the PR.
|
|
28
|
+
See the [full documentation](https://ldelements.org/reference/search-api-graphql).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-schema.d.ts","sourceRoot":"","sources":["../src/build-schema.ts"],"names":[],"mappings":"AAAA,OAAO,EASL,aAAa,EAQd,MAAM,SAAS,CAAC;AACjB,OAAO,EAIL,KAAK,YAAY,EAEjB,KAAK,WAAW,EAChB,KAAK,YAAY,EAElB,MAAM,aAAa,CAAC;AAWrB,OAAO,EAGL,KAAK,aAAa,EACnB,MAAM,eAAe,CAAC;AAGvB,8EAA8E;AAC9E,MAAM,WAAW,aAAa;IAC5B;;sDAEkD;IAClD,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAC9B,mFAAmF;IACnF,QAAQ,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C;;;;;;OAMG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACjE;AAED;2EAC2E;AAC3E,MAAM,WAAW,iBAAiB;IAChC;yCACqC;IACrC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B;uBACmB;IACnB,QAAQ,CAAC,aAAa,CAAC,EAAE,CACvB,KAAK,EAAE,WAAW,EAClB,OAAO,EAAE,aAAa,KACnB,WAAW,CAAC;CAClB;AAED,MAAM,WAAW,yBAAyB;IACxC;;kEAE8D;IAC9D,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC,CAAC;IAC7D,+EAA+E;IAC/E,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAC;IACvC;;gDAE4C;IAC5C,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAkBD;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,aAAa,
|
|
1
|
+
{"version":3,"file":"build-schema.d.ts","sourceRoot":"","sources":["../src/build-schema.ts"],"names":[],"mappings":"AAAA,OAAO,EASL,aAAa,EAQd,MAAM,SAAS,CAAC;AACjB,OAAO,EAIL,KAAK,YAAY,EAEjB,KAAK,WAAW,EAChB,KAAK,YAAY,EAElB,MAAM,aAAa,CAAC;AAWrB,OAAO,EAGL,KAAK,aAAa,EACnB,MAAM,eAAe,CAAC;AAGvB,8EAA8E;AAC9E,MAAM,WAAW,aAAa;IAC5B;;sDAEkD;IAClD,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAC9B,mFAAmF;IACnF,QAAQ,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C;;;;;;OAMG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACjE;AAED;2EAC2E;AAC3E,MAAM,WAAW,iBAAiB;IAChC;yCACqC;IACrC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B;uBACmB;IACnB,QAAQ,CAAC,aAAa,CAAC,EAAE,CACvB,KAAK,EAAE,WAAW,EAClB,OAAO,EAAE,aAAa,KACnB,WAAW,CAAC;CAClB;AAED,MAAM,WAAW,yBAAyB;IACxC;;kEAE8D;IAC9D,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC,CAAC;IAC7D,+EAA+E;IAC/E,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAC;IACvC;;gDAE4C;IAC5C,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAkBD;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,aAAa,CAoXf;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,MAAM,CAER"}
|
package/dist/build-schema.js
CHANGED
|
@@ -89,26 +89,36 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
89
89
|
// searchSchema() – which rejects duplicates – is the only constructor.
|
|
90
90
|
// One reference type per referenced shape, shared across every root type and
|
|
91
91
|
// reused by every field (Person and CreativeWork both referencing Agent yield
|
|
92
|
-
// one Agent type).
|
|
92
|
+
// one Agent type). A reference may name a root type – the `labelOnly` way to
|
|
93
|
+
// carry an id plus a label resolved from that root’s collection (`creator` →
|
|
94
|
+
// `Person`) – but GraphQL type names must be unique, so such a reference is
|
|
95
|
+
// served under `‹Name›Reference` instead. An `inline` reference can never
|
|
96
|
+
// collide: searchSchema resolves its typeName to a declared Reference Type
|
|
97
|
+
// and rejects duplicate names schema-wide.
|
|
93
98
|
const referenceTypes = new Map();
|
|
99
|
+
const takenTypeNames = new Set(rootTypeNames);
|
|
94
100
|
for (const searchType of schema.values()) {
|
|
95
101
|
for (const field of outputFields(searchType)) {
|
|
96
|
-
if (field.kind !== 'reference' ||
|
|
102
|
+
if (field.kind !== 'reference' ||
|
|
103
|
+
field.ref === undefined ||
|
|
104
|
+
referenceTypes.has(field.ref.typeName)) {
|
|
97
105
|
continue;
|
|
98
106
|
}
|
|
99
107
|
const { typeName } = field.ref;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
name: typeName,
|
|
106
|
-
fields: {
|
|
107
|
-
id: { type: new GraphQLNonNull(GraphQLString) },
|
|
108
|
-
name: labelList((source) => source.label),
|
|
109
|
-
},
|
|
110
|
-
}));
|
|
108
|
+
const graphQLName = rootTypeNames.has(typeName)
|
|
109
|
+
? `${typeName}Reference`
|
|
110
|
+
: typeName;
|
|
111
|
+
if (takenTypeNames.has(graphQLName)) {
|
|
112
|
+
throw new Error(`Reference type “${typeName}” (field “${field.name}” of “${searchType.name}”) would be served as “${graphQLName}”, which collides with another type name; rename one.`);
|
|
111
113
|
}
|
|
114
|
+
takenTypeNames.add(graphQLName);
|
|
115
|
+
referenceTypes.set(typeName, new GraphQLObjectType({
|
|
116
|
+
name: graphQLName,
|
|
117
|
+
fields: {
|
|
118
|
+
id: { type: new GraphQLNonNull(GraphQLString) },
|
|
119
|
+
name: labelList((source) => source.label),
|
|
120
|
+
},
|
|
121
|
+
}));
|
|
112
122
|
}
|
|
113
123
|
}
|
|
114
124
|
function outputFieldConfig(field) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lde/search-api-graphql",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.3",
|
|
4
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",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"@escape.tech/graphql-armor-cost-limit": "^2.4.3",
|
|
29
29
|
"@escape.tech/graphql-armor-max-depth": "^2.4.2",
|
|
30
30
|
"@graphql-yoga/render-graphiql": "^5.21.2",
|
|
31
|
-
"@lde/search": "^0.12.
|
|
31
|
+
"@lde/search": "^0.12.3",
|
|
32
32
|
"dataloader": "^2.2.3",
|
|
33
33
|
"graphql": "^16.0.0",
|
|
34
34
|
"graphql-yoga": "^5.21.2",
|