@znight/graphql 0.0.0 → 0.2.1
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/LICENSE +21 -0
- package/README.md +1346 -0
- package/dist/compiler.d.mts +114 -0
- package/dist/compiler.mjs +4599 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/index.d.mts +583 -0
- package/dist/index.mjs +134 -0
- package/dist/index.mjs.map +1 -0
- package/dist/names-BlzDM5cF.mjs +111 -0
- package/dist/names-BlzDM5cF.mjs.map +1 -0
- package/dist/plugin-CR_wVfRU.mjs +3445 -0
- package/dist/plugin-CR_wVfRU.mjs.map +1 -0
- package/dist/testing.d.mts +101 -0
- package/dist/testing.mjs +306 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/types-DQ713UgI.d.mts +266 -0
- package/package.json +97 -1
- package/src/compiler/analyze.ts +727 -0
- package/src/compiler/builder.ts +1561 -0
- package/src/compiler/checks.ts +105 -0
- package/src/compiler/conformance.ts +1068 -0
- package/src/compiler/constants.ts +80 -0
- package/src/compiler/data.ts +83 -0
- package/src/compiler/defaults.ts +92 -0
- package/src/compiler/discriminators.ts +53 -0
- package/src/compiler/docs.ts +55 -0
- package/src/compiler/generate.ts +419 -0
- package/src/compiler/graphql-js.ts +42 -0
- package/src/compiler/ids.ts +128 -0
- package/src/compiler/index.ts +98 -0
- package/src/compiler/locate.ts +82 -0
- package/src/compiler/model.ts +137 -0
- package/src/compiler/options.ts +85 -0
- package/src/compiler/scalars.ts +138 -0
- package/src/compiler/schema-first.ts +595 -0
- package/src/compiler/sdl-source.ts +529 -0
- package/src/compiler/sdl-view.ts +61 -0
- package/src/compiler/suggest.ts +39 -0
- package/src/context.ts +181 -0
- package/src/decisions.ts +169 -0
- package/src/decorators.ts +202 -0
- package/src/errors.ts +89 -0
- package/src/execution.ts +1064 -0
- package/src/format.ts +285 -0
- package/src/index.ts +89 -0
- package/src/limits.ts +177 -0
- package/src/loaders.ts +149 -0
- package/src/memory-pubsub.ts +133 -0
- package/src/metadata.ts +222 -0
- package/src/names.ts +47 -0
- package/src/options.ts +331 -0
- package/src/pipeline.ts +338 -0
- package/src/plugin.ts +579 -0
- package/src/pubsub.ts +36 -0
- package/src/results.ts +206 -0
- package/src/scalars.ts +138 -0
- package/src/schema-hash.ts +18 -0
- package/src/schema.ts +635 -0
- package/src/sdl.ts +530 -0
- package/src/testing.ts +454 -0
- package/src/tokens.ts +22 -0
- package/src/transport/abort.ts +80 -0
- package/src/transport/accept.ts +155 -0
- package/src/transport/graphiql.ts +106 -0
- package/src/transport/http.ts +373 -0
- package/src/transport/sse.ts +45 -0
- package/src/type-ref.ts +89 -0
- package/src/types.ts +225 -0
- package/src/validate-args.ts +48 -0
package/src/options.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options of `GraphQLPlugin` (RFC 0005 §5.11): their types, defaults and validation (ZNIGHT066).
|
|
3
|
+
*/
|
|
4
|
+
import type { ByteSize, GuardRef, RouteMetaEntry } from '@znight/http'
|
|
5
|
+
import type { SubscribeOptions } from './pubsub.ts'
|
|
6
|
+
import type {
|
|
7
|
+
GraphQLErrorMapper,
|
|
8
|
+
GraphQLErrorMapperClass,
|
|
9
|
+
GraphQLGuard,
|
|
10
|
+
GraphQLGuardClass,
|
|
11
|
+
GraphQLResultMapper,
|
|
12
|
+
GraphQLResultMapperClass,
|
|
13
|
+
GraphQLResultTypes,
|
|
14
|
+
} from './types.ts'
|
|
15
|
+
|
|
16
|
+
import { ErrorCode, ZnightError } from '@znight/core'
|
|
17
|
+
import { parseByteSize } from '@znight/http'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A `PubSub` of any event map, for the `pubsub` option: `PubSub<Events>` types are not assignable
|
|
21
|
+
* to each other (generic methods over `keyof Events`), so the option accepts the structure.
|
|
22
|
+
*/
|
|
23
|
+
export interface AnyPubSub {
|
|
24
|
+
publish(topic: never, payload: never): Promise<void>
|
|
25
|
+
subscribe(topic: never, options?: SubscribeOptions): AsyncIterableIterator<unknown>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A global GraphQL guard: a provider class or an instance. */
|
|
29
|
+
export type GraphQLGuardRef = GraphQLGuardClass | GraphQLGuard
|
|
30
|
+
|
|
31
|
+
/** An error mapper: a provider class or an instance. */
|
|
32
|
+
export type GraphQLErrorMapperRef = GraphQLErrorMapperClass | GraphQLErrorMapper
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A result mapper: a provider class with `static types`, or an instance (its `types` property, or the
|
|
36
|
+
* `static types` of its class).
|
|
37
|
+
*/
|
|
38
|
+
export type GraphQLResultMapperRef = GraphQLResultMapperClass | (GraphQLResultMapper & Partial<GraphQLResultTypes>)
|
|
39
|
+
|
|
40
|
+
/** Options of the GraphQL-over-HTTP endpoint. */
|
|
41
|
+
export interface GraphQLHttpOptions {
|
|
42
|
+
/** Route metadata of the endpoint (`[Public.meta()]`), seen by HTTP guards and the route decision check. */
|
|
43
|
+
readonly meta?: readonly RouteMetaEntry[]
|
|
44
|
+
/** Guards of the endpoint (HTTP `CanActivate`), after the global `HttpPlugin` guards. */
|
|
45
|
+
readonly guards?: readonly GuardRef[]
|
|
46
|
+
/** Body limit of the endpoint. Default `'100kb'`. */
|
|
47
|
+
readonly bodyLimit?: ByteSize
|
|
48
|
+
/** Queries over `GET`. Default `true`. */
|
|
49
|
+
readonly get?: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Request limits; `false` disables one. */
|
|
53
|
+
export interface GraphQLLimits {
|
|
54
|
+
/** Tokens of a document (`parse`). Default 2 000. */
|
|
55
|
+
readonly maxTokens?: number | false
|
|
56
|
+
/** Field nesting of an operation (introspection fields exempt). Default 12. */
|
|
57
|
+
readonly maxDepth?: number | false
|
|
58
|
+
/**
|
|
59
|
+
* Aliases of a document, as written and as one operation executes them (a fragment spread several
|
|
60
|
+
* times counts each time). Default 30.
|
|
61
|
+
*/
|
|
62
|
+
readonly maxAliases?: number | false
|
|
63
|
+
/** Directives of a document. Default 50. */
|
|
64
|
+
readonly maxDirectives?: number | false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Subscription options. */
|
|
68
|
+
export interface GraphQLSubscriptionOptions {
|
|
69
|
+
/** Active subscriptions cap (every transport); beyond it, 503. Default 10 000. */
|
|
70
|
+
readonly maxActive?: number
|
|
71
|
+
/** Events buffered per subscriber by the default `MemoryPubSub`. Default 100. */
|
|
72
|
+
readonly bufferSize?: number
|
|
73
|
+
/** SSE heartbeat (ms), `false` for none. Default: the `HttpPlugin` `sse` option. */
|
|
74
|
+
readonly heartbeat?: number | false
|
|
75
|
+
/** SSE maximum duration (ms). Default: none. */
|
|
76
|
+
readonly maxDuration?: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Options of `GraphQLPlugin.configure()`. */
|
|
80
|
+
export interface GraphQLPluginOptions {
|
|
81
|
+
/** Path of the endpoint, joined with the `HttpPlugin` prefix. Default `'/graphql'`. */
|
|
82
|
+
readonly path?: string
|
|
83
|
+
/** `false`: no HTTP transport (schema, `execute()` and `subscribe()` only). Default `{}`. */
|
|
84
|
+
readonly http?: GraphQLHttpOptions | false
|
|
85
|
+
/** Global GraphQL guards (classes or instances): root fields, and field resolvers with metadata. */
|
|
86
|
+
readonly guards?: readonly GraphQLGuardRef[]
|
|
87
|
+
/** Error mappers (classes or instances), tried in order. */
|
|
88
|
+
readonly errorMappers?: readonly GraphQLErrorMapperRef[]
|
|
89
|
+
/**
|
|
90
|
+
* Result mappers (classes or instances), tried in order: errors as data (RFC 0008), on the fields
|
|
91
|
+
* whose union holds one of their `types`.
|
|
92
|
+
*/
|
|
93
|
+
readonly resultMappers?: readonly GraphQLResultMapperRef[]
|
|
94
|
+
/** Unmasked messages for unexpected errors (development). Default `false`. */
|
|
95
|
+
readonly exposeErrors?: boolean
|
|
96
|
+
/** Introspection; off, suggestions are hidden too. Default `NODE_ENV !== 'production'`. */
|
|
97
|
+
readonly introspection?: boolean
|
|
98
|
+
/** GraphiQL on `GET path` with `accept: text/html`. Default `NODE_ENV === 'development'`. */
|
|
99
|
+
readonly graphiql?: boolean
|
|
100
|
+
readonly limits?: GraphQLLimits
|
|
101
|
+
/** Distinct documents kept parsed and validated (LRU); 0 disables the cache. Default 1 000. */
|
|
102
|
+
readonly documentCache?: number
|
|
103
|
+
/** `false` disables subscriptions over SSE. */
|
|
104
|
+
readonly subscriptions?: GraphQLSubscriptionOptions | false
|
|
105
|
+
/** The `PubSub` provided to the container. Default: a `MemoryPubSub` (closed with the application). */
|
|
106
|
+
readonly pubsub?: AnyPubSub
|
|
107
|
+
/**
|
|
108
|
+
* How the boot check of route decisions on GraphQL fields (RFC 0001, ZNIGHT023) reports problems:
|
|
109
|
+
* `'error'` (default) fails the boot, `'warn'` logs.
|
|
110
|
+
*/
|
|
111
|
+
readonly enforcement?: 'error' | 'warn'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Options with their defaults, validated. */
|
|
115
|
+
export interface ResolvedGraphQLOptions {
|
|
116
|
+
readonly path: string
|
|
117
|
+
readonly http: Required<GraphQLHttpOptions> | false
|
|
118
|
+
readonly guards: readonly GraphQLGuardRef[]
|
|
119
|
+
readonly errorMappers: readonly GraphQLErrorMapperRef[]
|
|
120
|
+
readonly resultMappers: readonly GraphQLResultMapperRef[]
|
|
121
|
+
readonly exposeErrors: boolean
|
|
122
|
+
readonly introspection: boolean
|
|
123
|
+
readonly graphiql: boolean
|
|
124
|
+
readonly limits: {
|
|
125
|
+
readonly maxTokens: number | undefined
|
|
126
|
+
readonly maxDepth: number | undefined
|
|
127
|
+
readonly maxAliases: number | undefined
|
|
128
|
+
readonly maxDirectives: number | undefined
|
|
129
|
+
}
|
|
130
|
+
readonly documentCache: number
|
|
131
|
+
readonly subscriptions:
|
|
132
|
+
| {
|
|
133
|
+
readonly maxActive: number
|
|
134
|
+
readonly bufferSize: number
|
|
135
|
+
readonly heartbeat: number | false | undefined
|
|
136
|
+
readonly maxDuration: number | undefined
|
|
137
|
+
}
|
|
138
|
+
| false
|
|
139
|
+
readonly bufferSize: number
|
|
140
|
+
readonly pubsub: AnyPubSub | undefined
|
|
141
|
+
readonly enforcement: 'error' | 'warn'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const KEYS: ReadonlySet<string> = new Set([
|
|
145
|
+
'path',
|
|
146
|
+
'http',
|
|
147
|
+
'guards',
|
|
148
|
+
'errorMappers',
|
|
149
|
+
'resultMappers',
|
|
150
|
+
'exposeErrors',
|
|
151
|
+
'introspection',
|
|
152
|
+
'graphiql',
|
|
153
|
+
'limits',
|
|
154
|
+
'documentCache',
|
|
155
|
+
'subscriptions',
|
|
156
|
+
'pubsub',
|
|
157
|
+
'enforcement',
|
|
158
|
+
])
|
|
159
|
+
const PATH = /^\/[A-Za-z0-9\-._~!$&'()*+,;=:@/]*$/
|
|
160
|
+
const LIMIT_DEFAULTS = { maxTokens: 2000, maxDepth: 12, maxAliases: 30, maxDirectives: 50 } as const
|
|
161
|
+
|
|
162
|
+
function show(value: unknown): string {
|
|
163
|
+
if (typeof value === 'function') return 'a function'
|
|
164
|
+
try {
|
|
165
|
+
return JSON.stringify(value) ?? String(value)
|
|
166
|
+
} catch {
|
|
167
|
+
return typeof value
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** ZNIGHT066 for an invalid option. */
|
|
172
|
+
export function invalidOption(name: string, expected: string, received: unknown): ZnightError {
|
|
173
|
+
return new ZnightError(
|
|
174
|
+
ErrorCode.InvalidGraphQLSetup,
|
|
175
|
+
`GraphQLPlugin: invalid option ${name}: expected ${expected} (received ${show(received)})`,
|
|
176
|
+
{ hints: ['See the options of GraphQLPlugin.configure() (RFC 0005 §5.11)'] },
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
181
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function boolean(name: string, value: unknown, fallback: boolean): boolean {
|
|
185
|
+
if (value === undefined) return fallback
|
|
186
|
+
if (typeof value !== 'boolean') throw invalidOption(name, 'a boolean', value)
|
|
187
|
+
return value
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function positiveInteger(name: string, value: unknown, fallback: number): number {
|
|
191
|
+
if (value === undefined) return fallback
|
|
192
|
+
if (!(Number.isSafeInteger(value) && (value as number) >= 1)) throw invalidOption(name, 'a positive integer', value)
|
|
193
|
+
return value as number
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function limit(name: keyof typeof LIMIT_DEFAULTS, value: unknown): number | undefined {
|
|
197
|
+
if (value === false) return undefined
|
|
198
|
+
return positiveInteger(`limits.${name}`, value, LIMIT_DEFAULTS[name])
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Refs given as classes or instances with `method`. */
|
|
202
|
+
function refs<T>(name: string, value: unknown, method: string): readonly T[] {
|
|
203
|
+
if (value === undefined) return []
|
|
204
|
+
if (!Array.isArray(value))
|
|
205
|
+
throw invalidOption(name, `an array of classes or objects with a ${method}() method`, value)
|
|
206
|
+
for (const ref of value) {
|
|
207
|
+
const ok =
|
|
208
|
+
typeof ref === 'function' ||
|
|
209
|
+
(ref !== null && typeof ref === 'object' && typeof (ref as Record<string, unknown>)[method] === 'function')
|
|
210
|
+
if (!ok) throw invalidOption(name, `classes or objects with a ${method}() method`, ref)
|
|
211
|
+
}
|
|
212
|
+
return value as readonly T[]
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Validates the options and applies the defaults (`env`: `process.env`). */
|
|
216
|
+
export function resolveGraphQLOptions(
|
|
217
|
+
options: unknown,
|
|
218
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
219
|
+
): ResolvedGraphQLOptions {
|
|
220
|
+
if (!isRecord(options)) throw invalidOption('options', 'an object', options)
|
|
221
|
+
for (const key of Object.keys(options)) {
|
|
222
|
+
if (!KEYS.has(key)) throw invalidOption(key, `one of ${[...KEYS].join(', ')}`, key)
|
|
223
|
+
}
|
|
224
|
+
const nodeEnv = env['NODE_ENV']
|
|
225
|
+
const path = options['path'] ?? '/graphql'
|
|
226
|
+
if (typeof path !== 'string' || !PATH.test(path)) throw invalidOption('path', "a path starting with '/'", path)
|
|
227
|
+
|
|
228
|
+
const rawHttp = options['http'] ?? {}
|
|
229
|
+
let http: ResolvedGraphQLOptions['http']
|
|
230
|
+
if (rawHttp === false) {
|
|
231
|
+
http = false
|
|
232
|
+
} else {
|
|
233
|
+
if (!isRecord(rawHttp)) throw invalidOption('http', 'false or { meta?, guards?, bodyLimit?, get? }', rawHttp)
|
|
234
|
+
const meta = rawHttp['meta'] ?? []
|
|
235
|
+
if (
|
|
236
|
+
!Array.isArray(meta) ||
|
|
237
|
+
!meta.every(entry => isRecord(entry) && typeof entry['key'] === 'string' && entry['key'] !== '')
|
|
238
|
+
) {
|
|
239
|
+
throw invalidOption('http.meta', 'an array of route metadata entries (Decorator.meta())', meta)
|
|
240
|
+
}
|
|
241
|
+
const bodyLimit = rawHttp['bodyLimit'] ?? '100kb'
|
|
242
|
+
if (parseByteSize(bodyLimit) === undefined)
|
|
243
|
+
throw invalidOption('http.bodyLimit', "a byte size ('100kb', 1048576)", bodyLimit)
|
|
244
|
+
http = {
|
|
245
|
+
meta: meta as readonly RouteMetaEntry[],
|
|
246
|
+
guards: refs<GuardRef>('http.guards', rawHttp['guards'], 'canActivate'),
|
|
247
|
+
bodyLimit: bodyLimit as ByteSize,
|
|
248
|
+
get: boolean('http.get', rawHttp['get'], true),
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const rawLimits = options['limits'] ?? {}
|
|
253
|
+
if (!isRecord(rawLimits))
|
|
254
|
+
throw invalidOption('limits', '{ maxTokens?, maxDepth?, maxAliases?, maxDirectives? }', rawLimits)
|
|
255
|
+
for (const key of Object.keys(rawLimits)) {
|
|
256
|
+
if (!(key in LIMIT_DEFAULTS))
|
|
257
|
+
throw invalidOption(`limits.${key}`, 'maxTokens, maxDepth, maxAliases or maxDirectives', key)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const documentCache = options['documentCache'] ?? 1000
|
|
261
|
+
if (!(Number.isSafeInteger(documentCache) && (documentCache as number) >= 0)) {
|
|
262
|
+
throw invalidOption('documentCache', 'a non-negative integer', documentCache)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const rawSubscriptions = options['subscriptions'] ?? {}
|
|
266
|
+
let subscriptions: ResolvedGraphQLOptions['subscriptions']
|
|
267
|
+
let bufferSize = 100
|
|
268
|
+
if (rawSubscriptions === false) {
|
|
269
|
+
subscriptions = false
|
|
270
|
+
} else {
|
|
271
|
+
if (!isRecord(rawSubscriptions)) {
|
|
272
|
+
throw invalidOption(
|
|
273
|
+
'subscriptions',
|
|
274
|
+
'false or { maxActive?, bufferSize?, heartbeat?, maxDuration? }',
|
|
275
|
+
rawSubscriptions,
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
bufferSize = positiveInteger('subscriptions.bufferSize', rawSubscriptions['bufferSize'], 100)
|
|
279
|
+
const heartbeat = rawSubscriptions['heartbeat']
|
|
280
|
+
if (heartbeat !== undefined && heartbeat !== false && !(typeof heartbeat === 'number' && heartbeat > 0)) {
|
|
281
|
+
throw invalidOption('subscriptions.heartbeat', 'a positive number of milliseconds or false', heartbeat)
|
|
282
|
+
}
|
|
283
|
+
const maxDuration = rawSubscriptions['maxDuration']
|
|
284
|
+
if (maxDuration !== undefined && !(typeof maxDuration === 'number' && maxDuration > 0)) {
|
|
285
|
+
throw invalidOption('subscriptions.maxDuration', 'a positive number of milliseconds', maxDuration)
|
|
286
|
+
}
|
|
287
|
+
subscriptions = {
|
|
288
|
+
maxActive: positiveInteger('subscriptions.maxActive', rawSubscriptions['maxActive'], 10_000),
|
|
289
|
+
bufferSize,
|
|
290
|
+
heartbeat: heartbeat as number | false | undefined,
|
|
291
|
+
maxDuration: maxDuration as number | undefined,
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const pubsub = options['pubsub']
|
|
296
|
+
if (
|
|
297
|
+
pubsub !== undefined &&
|
|
298
|
+
!(
|
|
299
|
+
isRecord(pubsub) &&
|
|
300
|
+
typeof (pubsub as { publish?: unknown }).publish === 'function' &&
|
|
301
|
+
typeof (pubsub as { subscribe?: unknown }).subscribe === 'function'
|
|
302
|
+
)
|
|
303
|
+
) {
|
|
304
|
+
throw invalidOption('pubsub', 'a PubSub (publish() and subscribe() methods)', pubsub)
|
|
305
|
+
}
|
|
306
|
+
const enforcement = options['enforcement'] ?? 'error'
|
|
307
|
+
if (enforcement !== 'error' && enforcement !== 'warn')
|
|
308
|
+
throw invalidOption('enforcement', "'error' or 'warn'", enforcement)
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
path,
|
|
312
|
+
http,
|
|
313
|
+
guards: refs<GraphQLGuardRef>('guards', options['guards'], 'canResolve'),
|
|
314
|
+
errorMappers: refs<GraphQLErrorMapperRef>('errorMappers', options['errorMappers'], 'map'),
|
|
315
|
+
resultMappers: refs<GraphQLResultMapperRef>('resultMappers', options['resultMappers'], 'toResult'),
|
|
316
|
+
exposeErrors: boolean('exposeErrors', options['exposeErrors'], false),
|
|
317
|
+
introspection: boolean('introspection', options['introspection'], nodeEnv !== 'production'),
|
|
318
|
+
graphiql: boolean('graphiql', options['graphiql'], nodeEnv === 'development'),
|
|
319
|
+
limits: {
|
|
320
|
+
maxTokens: limit('maxTokens', rawLimits['maxTokens']),
|
|
321
|
+
maxDepth: limit('maxDepth', rawLimits['maxDepth']),
|
|
322
|
+
maxAliases: limit('maxAliases', rawLimits['maxAliases']),
|
|
323
|
+
maxDirectives: limit('maxDirectives', rawLimits['maxDirectives']),
|
|
324
|
+
},
|
|
325
|
+
documentCache: documentCache as number,
|
|
326
|
+
subscriptions,
|
|
327
|
+
bufferSize,
|
|
328
|
+
pubsub: pubsub as AnyPubSub | undefined,
|
|
329
|
+
enforcement,
|
|
330
|
+
}
|
|
331
|
+
}
|
package/src/pipeline.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The resolver pipeline (RFC 0005 §5.9, §7.3, §8.5): the resolve function of every resolver-backed
|
|
3
|
+
* field, decided once at boot.
|
|
4
|
+
*
|
|
5
|
+
* 1. the resolver instance: the singleton resolved at boot, or `container.get(token)` in the
|
|
6
|
+
* current scope (request-scoped and transient resolvers);
|
|
7
|
+
* 2. guards: global ones (on root fields, and on field resolvers carrying their own metadata), class
|
|
8
|
+
* `@UseGuard`, method `@UseGuard`; `false` fails the field with `FORBIDDEN`;
|
|
9
|
+
* 3. null handling of arguments (`nullAsUndefined`), only where a flag is reachable;
|
|
10
|
+
* 4. `invoke(resolver, parent, args, context, info)`.
|
|
11
|
+
*
|
|
12
|
+
* A field without guards nor normaliser calls `invoke` from the closure graphql-js needs anyway:
|
|
13
|
+
* no extra wrapper, no promise, no `await`. Plain property fields keep graphql-js' default resolver.
|
|
14
|
+
* A field that carries results (RFC 0008, `results.ts`) gets one wrapper around the whole pipeline,
|
|
15
|
+
* catching its errors to turn them into values; no other field does.
|
|
16
|
+
*/
|
|
17
|
+
import type { Container, Token } from '@znight/core'
|
|
18
|
+
import type { GraphQLFieldResolver, GraphQLResolveInfo } from 'graphql'
|
|
19
|
+
import type { OperationContext } from './context.ts'
|
|
20
|
+
import type { Resolvable } from './format.ts'
|
|
21
|
+
import type {
|
|
22
|
+
GraphQLArgumentDefinition,
|
|
23
|
+
GraphQLFieldDefinition,
|
|
24
|
+
GraphQLInputTypeDefinition,
|
|
25
|
+
GraphQLObjectTypeDefinition,
|
|
26
|
+
GraphQLResolverBinding,
|
|
27
|
+
} from './metadata.ts'
|
|
28
|
+
import type { Recover } from './results.ts'
|
|
29
|
+
import type { FieldFunctions, FieldFunctionsFactory } from './schema.ts'
|
|
30
|
+
import type { GraphQLTypeRef } from './type-ref.ts'
|
|
31
|
+
import type { GraphQLArgs, GraphQLContext, GraphQLGuard, ResolverContext } from './types.ts'
|
|
32
|
+
|
|
33
|
+
import { isThenable } from '@znight/http'
|
|
34
|
+
|
|
35
|
+
import { GraphQLErrorCode, GraphQLException } from './errors.ts'
|
|
36
|
+
import { resolveIn } from './format.ts'
|
|
37
|
+
import { parseTypeRef } from './type-ref.ts'
|
|
38
|
+
|
|
39
|
+
/** What the pipeline needs, resolved at boot. */
|
|
40
|
+
export interface PipelineOptions {
|
|
41
|
+
readonly container: Container
|
|
42
|
+
/** Global guards (plugin option), in order. */
|
|
43
|
+
readonly globalGuards: readonly Resolvable<GraphQLGuard>[]
|
|
44
|
+
/** The guard provider of a `@UseGuard` token (memoised by the caller). */
|
|
45
|
+
readonly guard: (token: Token, coordinate: string) => Resolvable<GraphQLGuard>
|
|
46
|
+
/** The resolver provider of a binding (memoised by the caller). */
|
|
47
|
+
readonly resolver: (binding: GraphQLResolverBinding, coordinate: string) => Resolvable<object>
|
|
48
|
+
/** Input object types by name (null handling of nested input values). */
|
|
49
|
+
readonly inputs: ReadonlyMap<string, GraphQLInputTypeDefinition>
|
|
50
|
+
/** The {@link Recover} function of a field that carries results (RFC 0008), `undefined` for the others. */
|
|
51
|
+
readonly results?: (field: GraphQLFieldDefinition, coordinate: string) => Recover | undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The generated invoker of a binding. */
|
|
55
|
+
type Invoke = GraphQLResolverBinding['invoke']
|
|
56
|
+
|
|
57
|
+
/** Mutates a coerced argument or input value in place: `null` → `undefined` where flagged. */
|
|
58
|
+
type Normalize = (value: unknown) => void
|
|
59
|
+
|
|
60
|
+
/** Whether global guards run on a field: root fields always, field resolvers when they carry metadata (§5.9). */
|
|
61
|
+
export function globalGuardsApply(binding: GraphQLResolverBinding): boolean {
|
|
62
|
+
return binding.operation !== 'field' || Object.keys(binding.meta).length > 0
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The {@link FieldFunctionsFactory} of the plugin. */
|
|
66
|
+
export function pipelineFactory(options: PipelineOptions): FieldFunctionsFactory {
|
|
67
|
+
const normalizers = new NormalizerBuilder(options.inputs)
|
|
68
|
+
return (binding, coordinate, field, parent) =>
|
|
69
|
+
fieldFunctions(options, normalizers, binding, coordinate, field, parent)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function fieldFunctions(
|
|
73
|
+
options: PipelineOptions,
|
|
74
|
+
normalizers: NormalizerBuilder,
|
|
75
|
+
binding: GraphQLResolverBinding,
|
|
76
|
+
coordinate: string,
|
|
77
|
+
field: GraphQLFieldDefinition,
|
|
78
|
+
parent: GraphQLObjectTypeDefinition,
|
|
79
|
+
): FieldFunctions {
|
|
80
|
+
const container = options.container
|
|
81
|
+
const invoke: Invoke = binding.invoke
|
|
82
|
+
const target = options.resolver(binding, coordinate)
|
|
83
|
+
const guards: Resolvable<GraphQLGuard>[] = [
|
|
84
|
+
...(globalGuardsApply(binding) ? options.globalGuards : []),
|
|
85
|
+
...binding.guards.map(token => options.guard(token, coordinate)),
|
|
86
|
+
]
|
|
87
|
+
const normalize = field.args === undefined ? undefined : normalizers.args(field.args)
|
|
88
|
+
const recover = options.results?.(field, coordinate)
|
|
89
|
+
const route =
|
|
90
|
+
guards.length === 0 && recover === undefined ? undefined : Object.freeze({ meta: binding.meta, coordinate })
|
|
91
|
+
const parentType = parent.name
|
|
92
|
+
const fieldName = field.name
|
|
93
|
+
|
|
94
|
+
let resolve: GraphQLFieldResolver<unknown, unknown>
|
|
95
|
+
if (guards.length === 0) {
|
|
96
|
+
// the common case: one closure, invoke called directly (no promise, no await)
|
|
97
|
+
if (target.token === undefined) {
|
|
98
|
+
const instance = target.instance
|
|
99
|
+
resolve =
|
|
100
|
+
normalize === undefined
|
|
101
|
+
? (p, a, c, i) => invoke(instance, p, a as GraphQLArgs, c as GraphQLContext, i)
|
|
102
|
+
: (p, a, c, i) => {
|
|
103
|
+
normalize(a)
|
|
104
|
+
return invoke(instance, p, a as GraphQLArgs, c as GraphQLContext, i)
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
const token = target.token
|
|
108
|
+
resolve =
|
|
109
|
+
normalize === undefined
|
|
110
|
+
? (p, a, c, i) => invoke(container.get(token), p, a as GraphQLArgs, c as GraphQLContext, i)
|
|
111
|
+
: (p, a, c, i) => {
|
|
112
|
+
normalize(a)
|
|
113
|
+
return invoke(container.get(token), p, a as GraphQLArgs, c as GraphQLContext, i)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
const forbidden = (): never => {
|
|
118
|
+
throw new GraphQLException(`Not allowed to resolve ${coordinate}`, { code: GraphQLErrorCode.Forbidden })
|
|
119
|
+
}
|
|
120
|
+
resolve = (p, a, c, i) => {
|
|
121
|
+
const operation = c as OperationContext
|
|
122
|
+
const args = a as GraphQLArgs
|
|
123
|
+
const allowed = runGuards(
|
|
124
|
+
container,
|
|
125
|
+
guards,
|
|
126
|
+
new FieldContext(route as ResolverContext['route'], operation, parentType, fieldName, args, p, i),
|
|
127
|
+
)
|
|
128
|
+
// §8.5: guards (they see the coerced arguments), then null handling, then invoke
|
|
129
|
+
const call = (): unknown => {
|
|
130
|
+
if (normalize !== undefined) normalize(args)
|
|
131
|
+
return invoke(resolveIn(container, target), p, args, operation, i)
|
|
132
|
+
}
|
|
133
|
+
if (allowed === true) return call()
|
|
134
|
+
if (allowed === false) return forbidden()
|
|
135
|
+
return allowed.then(ok => (ok ? call() : forbidden()))
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const subscription = binding.operation === 'subscription'
|
|
139
|
+
if (recover !== undefined) {
|
|
140
|
+
resolve = withResults(resolve, recover, route as ResolverContext['route'], parentType, fieldName, subscription)
|
|
141
|
+
}
|
|
142
|
+
if (subscription) {
|
|
143
|
+
// the method gives the source event stream; each event is the field value
|
|
144
|
+
return { subscribe: resolve, resolve: identity }
|
|
145
|
+
}
|
|
146
|
+
return { resolve }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function identity(payload: unknown): unknown {
|
|
150
|
+
return payload
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The resolve function of a field that carries results (RFC 0008): errors of the whole pipeline
|
|
155
|
+
* (guards, null handling, `invoke`), thrown or rejected, go through `recover`. Synchronous results
|
|
156
|
+
* stay synchronous; a promise gets one rejection handler. For a subscription, a value is a source
|
|
157
|
+
* stream of that single event.
|
|
158
|
+
*/
|
|
159
|
+
function withResults(
|
|
160
|
+
resolve: GraphQLFieldResolver<unknown, unknown>,
|
|
161
|
+
recover: Recover,
|
|
162
|
+
route: ResolverContext['route'],
|
|
163
|
+
parentType: string,
|
|
164
|
+
fieldName: string,
|
|
165
|
+
subscription: boolean,
|
|
166
|
+
): GraphQLFieldResolver<unknown, unknown> {
|
|
167
|
+
const fail = (error: unknown, p: unknown, a: unknown, c: unknown, i: GraphQLResolveInfo): unknown => {
|
|
168
|
+
const value = recover(
|
|
169
|
+
error,
|
|
170
|
+
new FieldContext(route, c as OperationContext, parentType, fieldName, a as GraphQLArgs, p, i),
|
|
171
|
+
)
|
|
172
|
+
return subscription ? once(value) : value
|
|
173
|
+
}
|
|
174
|
+
// captures nothing per call: the rejection handler is created in recoverAsync, on the promise path only
|
|
175
|
+
return (p, a, c, i) => {
|
|
176
|
+
let result: unknown
|
|
177
|
+
try {
|
|
178
|
+
result = resolve(p, a, c, i)
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return fail(error, p, a, c, i)
|
|
181
|
+
}
|
|
182
|
+
return isThenable(result) ? recoverAsync(result, fail, p, a, c, i) : result
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function recoverAsync(
|
|
187
|
+
result: PromiseLike<unknown>,
|
|
188
|
+
fail: (error: unknown, p: unknown, a: unknown, c: unknown, i: GraphQLResolveInfo) => unknown,
|
|
189
|
+
p: unknown,
|
|
190
|
+
a: unknown,
|
|
191
|
+
c: unknown,
|
|
192
|
+
i: GraphQLResolveInfo,
|
|
193
|
+
): Promise<unknown> {
|
|
194
|
+
return Promise.resolve(result).then(undefined, error => fail(error, p, a, c, i))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A source event stream of one event. */
|
|
198
|
+
// biome-ignore lint/suspicious/useAwait: an async generator is the shortest AsyncIterable of one value
|
|
199
|
+
async function* once(value: unknown): AsyncGenerator<unknown> {
|
|
200
|
+
yield value
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Runs guards in order, synchronously until one returns a promise. */
|
|
204
|
+
function runGuards(
|
|
205
|
+
container: Container,
|
|
206
|
+
guards: readonly Resolvable<GraphQLGuard>[],
|
|
207
|
+
ctx: ResolverContext,
|
|
208
|
+
start = 0,
|
|
209
|
+
): boolean | Promise<boolean> {
|
|
210
|
+
for (let index = start; index < guards.length; index++) {
|
|
211
|
+
const result = resolveIn(container, guards[index] as Resolvable<GraphQLGuard>).canResolve(ctx)
|
|
212
|
+
if (isThenable(result)) {
|
|
213
|
+
return Promise.resolve(result).then(ok => (ok ? runGuards(container, guards, ctx, index + 1) : false))
|
|
214
|
+
}
|
|
215
|
+
if (!result) return false
|
|
216
|
+
}
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** What a guard receives (§5.9): a `RouteMetaCarrier` with the field's effective metadata. */
|
|
221
|
+
class FieldContext implements ResolverContext {
|
|
222
|
+
readonly route: ResolverContext['route']
|
|
223
|
+
readonly operation: OperationContext
|
|
224
|
+
readonly parentType: string
|
|
225
|
+
readonly field: string
|
|
226
|
+
readonly args: GraphQLArgs
|
|
227
|
+
readonly parent: unknown
|
|
228
|
+
readonly info: GraphQLResolveInfo
|
|
229
|
+
|
|
230
|
+
constructor(
|
|
231
|
+
route: ResolverContext['route'],
|
|
232
|
+
operation: OperationContext,
|
|
233
|
+
parentType: string,
|
|
234
|
+
field: string,
|
|
235
|
+
args: GraphQLArgs,
|
|
236
|
+
parent: unknown,
|
|
237
|
+
info: GraphQLResolveInfo,
|
|
238
|
+
) {
|
|
239
|
+
this.route = route
|
|
240
|
+
this.operation = operation
|
|
241
|
+
this.parentType = parentType
|
|
242
|
+
this.field = field
|
|
243
|
+
this.args = args
|
|
244
|
+
this.parent = parent
|
|
245
|
+
this.info = info
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
get http(): ResolverContext['http'] {
|
|
249
|
+
return this.operation.http
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** One step of a normaliser: an argument or input field. */
|
|
254
|
+
interface Step {
|
|
255
|
+
readonly name: string
|
|
256
|
+
/** The value itself: `null` → `undefined`. */
|
|
257
|
+
readonly own: boolean
|
|
258
|
+
/** Nested input values. */
|
|
259
|
+
readonly nested: Normalize | undefined
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Builds normalisers (§7.3) at boot. Values are mutated in place: graphql-js creates the argument
|
|
264
|
+
* object of every call and the coerced input objects, and the flags of an input type are the same
|
|
265
|
+
* wherever it is used, so a value shared through a variable is normalised the same way twice.
|
|
266
|
+
*/
|
|
267
|
+
class NormalizerBuilder {
|
|
268
|
+
readonly #inputs: ReadonlyMap<string, GraphQLInputTypeDefinition>
|
|
269
|
+
/** Input type → its normaliser (`null`: nothing to do), `PENDING` while being built (cycles). */
|
|
270
|
+
readonly #types = new Map<string, Normalize | null | typeof PENDING>()
|
|
271
|
+
|
|
272
|
+
constructor(inputs: ReadonlyMap<string, GraphQLInputTypeDefinition>) {
|
|
273
|
+
this.#inputs = inputs
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The normaliser of a field's arguments, `undefined` when no flag is reachable. */
|
|
277
|
+
args(args: readonly GraphQLArgumentDefinition[]): ((args: unknown) => void) | undefined {
|
|
278
|
+
const steps = this.#steps(args)
|
|
279
|
+
return steps === undefined ? undefined : record => applySteps(record, steps)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#steps(values: readonly GraphQLArgumentDefinition[]): readonly Step[] | undefined {
|
|
283
|
+
const steps: Step[] = []
|
|
284
|
+
for (const value of values) {
|
|
285
|
+
const own = value.nullAsUndefined === true
|
|
286
|
+
const ref = parseTypeRef(value.type)
|
|
287
|
+
const nested = ref === undefined ? undefined : this.#value(ref)
|
|
288
|
+
if (own || nested !== undefined) steps.push({ name: value.name, own, nested })
|
|
289
|
+
}
|
|
290
|
+
return steps.length === 0 ? undefined : steps
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#value(ref: GraphQLTypeRef): Normalize | undefined {
|
|
294
|
+
if (ref.kind === 'list') {
|
|
295
|
+
const item = this.#value(ref.of)
|
|
296
|
+
return item === undefined
|
|
297
|
+
? undefined
|
|
298
|
+
: value => {
|
|
299
|
+
if (Array.isArray(value)) for (const entry of value) item(entry)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return this.#input(ref.name)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
#input(name: string): Normalize | undefined {
|
|
306
|
+
const known = this.#types.get(name)
|
|
307
|
+
if (known === PENDING) {
|
|
308
|
+
// a cycle through this type: delegate to its normaliser once built
|
|
309
|
+
return value => {
|
|
310
|
+
const normalize = this.#types.get(name)
|
|
311
|
+
if (typeof normalize === 'function') normalize(value)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (known !== undefined) return known ?? undefined
|
|
315
|
+
const type = this.#inputs.get(name)
|
|
316
|
+
if (type === undefined) return undefined
|
|
317
|
+
this.#types.set(name, PENDING)
|
|
318
|
+
const steps = this.#steps(type.fields)
|
|
319
|
+
const normalize: Normalize | null = steps === undefined ? null : value => applySteps(value, steps)
|
|
320
|
+
this.#types.set(name, normalize)
|
|
321
|
+
return normalize ?? undefined
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const PENDING: unique symbol = Symbol('pending')
|
|
326
|
+
|
|
327
|
+
function applySteps(value: unknown, steps: readonly Step[]): void {
|
|
328
|
+
if (value === null || typeof value !== 'object') return
|
|
329
|
+
const record = value as Record<string, unknown>
|
|
330
|
+
for (const step of steps) {
|
|
331
|
+
const entry = record[step.name]
|
|
332
|
+
if (entry === null) {
|
|
333
|
+
if (step.own) record[step.name] = undefined
|
|
334
|
+
} else if (entry !== undefined && step.nested !== undefined) {
|
|
335
|
+
step.nested(entry)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|