@znight/graphql 0.0.0 → 0.2.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/LICENSE +21 -0
- package/README.md +1063 -0
- package/dist/compiler.d.mts +93 -0
- package/dist/compiler.mjs +2832 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/index.d.mts +542 -0
- package/dist/index.mjs +128 -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-BdBxr5NQ.mjs +3222 -0
- package/dist/plugin-BdBxr5NQ.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-CgbV9LjD.d.mts +234 -0
- package/package.json +97 -1
- package/src/compiler/analyze.ts +608 -0
- package/src/compiler/builder.ts +1640 -0
- package/src/compiler/constants.ts +74 -0
- package/src/compiler/data.ts +73 -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 +405 -0
- package/src/compiler/ids.ts +128 -0
- package/src/compiler/index.ts +93 -0
- package/src/compiler/locate.ts +44 -0
- package/src/compiler/model.ts +122 -0
- package/src/compiler/options.ts +54 -0
- package/src/compiler/scalars.ts +138 -0
- package/src/compiler/sdl-view.ts +59 -0
- package/src/context.ts +181 -0
- package/src/decisions.ts +169 -0
- package/src/decorators.ts +196 -0
- package/src/errors.ts +89 -0
- package/src/execution.ts +1064 -0
- package/src/format.ts +285 -0
- package/src/index.ts +84 -0
- package/src/limits.ts +177 -0
- package/src/loaders.ts +149 -0
- package/src/memory-pubsub.ts +133 -0
- package/src/metadata.ts +195 -0
- package/src/names.ts +47 -0
- package/src/options.ts +309 -0
- package/src/pipeline.ts +277 -0
- package/src/plugin.ts +557 -0
- package/src/pubsub.ts +36 -0
- package/src/scalars.ts +131 -0
- package/src/schema-hash.ts +18 -0
- package/src/schema.ts +557 -0
- package/src/sdl.ts +515 -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 +190 -0
- package/src/validate-args.ts +48 -0
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GraphQLPlugin` (RFC 0005 §5.11, §8.2): builds the schema from `metadata.extensions.graphql`,
|
|
3
|
+
* provides `GraphQLContext`, `PubSub` and itself to the container, executes operations
|
|
4
|
+
* (`execute()`, `subscribe()`) and contributes the GraphQL endpoint to `HttpPlugin`.
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
Container,
|
|
8
|
+
CoreLogger,
|
|
9
|
+
Hooks,
|
|
10
|
+
Plugin,
|
|
11
|
+
PluginContext,
|
|
12
|
+
PluginOptions,
|
|
13
|
+
PluginRootsContext,
|
|
14
|
+
ProviderKey,
|
|
15
|
+
Token,
|
|
16
|
+
ZnightMetadata,
|
|
17
|
+
} from '@znight/core'
|
|
18
|
+
import type { PluginHttpRoutes, PluginHttpRoutesContext, PluginRoute, SseOptions } from '@znight/http'
|
|
19
|
+
import type { FormattedExecutionResult, GraphQLSchema } from 'graphql'
|
|
20
|
+
import type { CheckedField } from './decisions.ts'
|
|
21
|
+
import type { ExecuteOptions, GraphQLRequest, GraphQLResultStream } from './execution.ts'
|
|
22
|
+
import type { Resolvable } from './format.ts'
|
|
23
|
+
import type { GraphQLInputTypeDefinition, GraphQLMetadata, GraphQLResolverBinding } from './metadata.ts'
|
|
24
|
+
import type { GraphQLPluginOptions, ResolvedGraphQLOptions } from './options.ts'
|
|
25
|
+
import type { GraphQLErrorMapper, GraphQLGuard } from './types.ts'
|
|
26
|
+
|
|
27
|
+
import { displayName, ErrorCode, resolveOptions, ZnightError } from '@znight/core'
|
|
28
|
+
import { printSchema } from 'graphql'
|
|
29
|
+
|
|
30
|
+
import { checkFieldDecisions } from './decisions.ts'
|
|
31
|
+
import { GraphQLExecutor, singleResult } from './execution.ts'
|
|
32
|
+
import { ErrorFormatter } from './format.ts'
|
|
33
|
+
import { LoaderFactory } from './loaders.ts'
|
|
34
|
+
import { MemoryPubSub } from './memory-pubsub.ts'
|
|
35
|
+
import { invalidOption, resolveGraphQLOptions } from './options.ts'
|
|
36
|
+
import { globalGuardsApply, pipelineFactory } from './pipeline.ts'
|
|
37
|
+
import { PubSub } from './pubsub.ts'
|
|
38
|
+
import { buildGraphQLSchema, graphqlSection } from './schema.ts'
|
|
39
|
+
import { GraphQLTokens } from './tokens.ts'
|
|
40
|
+
import { checkSseOptions, HttpTransport } from './transport/http.ts'
|
|
41
|
+
|
|
42
|
+
/** Options of {@link GraphQLPlugin.execute} and {@link GraphQLPlugin.subscribe}. */
|
|
43
|
+
export interface DirectExecuteOptions {
|
|
44
|
+
/** Cancels the operation (graphql-js `abortSignal`, `GraphQLContext.signal`). */
|
|
45
|
+
readonly signal?: AbortSignal
|
|
46
|
+
/** `GraphQLContext.requestId`. Default: the request id of the enclosing scope, or a UUID. */
|
|
47
|
+
readonly requestId?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What the plugin built at bootstrap. */
|
|
51
|
+
interface Runtime {
|
|
52
|
+
readonly schema: GraphQLSchema
|
|
53
|
+
readonly section: GraphQLMetadata
|
|
54
|
+
readonly executor: GraphQLExecutor
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let readExecutor: (plugin: GraphQLPlugin) => GraphQLExecutor
|
|
58
|
+
let readEndpoint: (plugin: GraphQLPlugin) => string | undefined
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The executor of a bootstrapped plugin: the internal API of the transports (statuses, streams).
|
|
62
|
+
* Not exported from the package entry.
|
|
63
|
+
*/
|
|
64
|
+
export function executorOf(plugin: GraphQLPlugin): GraphQLExecutor {
|
|
65
|
+
return readExecutor(plugin)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The absolute path of the GraphQL-over-HTTP endpoint (prefix included), `undefined` without HTTP
|
|
70
|
+
* transport (`http: false`, or before `HttpPlugin` collected the routes). Not exported from the
|
|
71
|
+
* package entry: `@znight/graphql/testing` sends its requests there.
|
|
72
|
+
*/
|
|
73
|
+
export function endpointOf(plugin: GraphQLPlugin): string | undefined {
|
|
74
|
+
return readEndpoint(plugin)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A value of type `T` or a promise of it. */
|
|
78
|
+
export type MaybePromise<T> = T | Promise<T>
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The GraphQL plugin: `GraphQLPlugin.configure(options)` (name `graphql`).
|
|
82
|
+
*
|
|
83
|
+
* ```ts
|
|
84
|
+
* await Application.create({
|
|
85
|
+
* metadata: __ZNIGHT_METADATA__,
|
|
86
|
+
* plugins: [HttpPlugin.configure(), GraphQLPlugin.configure({ guards: [AccessGuard] })],
|
|
87
|
+
* })
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
|
|
91
|
+
readonly name = 'graphql'
|
|
92
|
+
readonly #options: PluginOptions<GraphQLPluginOptions>
|
|
93
|
+
#resolved: ResolvedGraphQLOptions | undefined
|
|
94
|
+
#container: Container | undefined
|
|
95
|
+
#metadata: ZnightMetadata | undefined
|
|
96
|
+
#logger: CoreLogger | undefined
|
|
97
|
+
#hooks: Hooks | undefined
|
|
98
|
+
#pubsub: PubSub | undefined
|
|
99
|
+
#ownPubSub: MemoryPubSub | undefined
|
|
100
|
+
#runtime: Runtime | undefined
|
|
101
|
+
#stop: AbortController | undefined
|
|
102
|
+
#endpoint: string | undefined
|
|
103
|
+
#active = false
|
|
104
|
+
|
|
105
|
+
private constructor(options: PluginOptions<GraphQLPluginOptions>) {
|
|
106
|
+
this.#options = options
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
static {
|
|
110
|
+
readExecutor = plugin => plugin.#ready().executor
|
|
111
|
+
readEndpoint = plugin => plugin.#endpoint
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Creates the plugin. `options` may be a function of the container, evaluated once, when first
|
|
116
|
+
* needed after the container is initialised: `GraphQLPlugin.configure(c => ({ exposeErrors: c.get(Env).dev }))`.
|
|
117
|
+
*/
|
|
118
|
+
static configure(options: PluginOptions<GraphQLPluginOptions> = {}): GraphQLPlugin {
|
|
119
|
+
return new GraphQLPlugin(options)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Providers `TestApp` creates at boot: the classes of the `guards` and `errorMappers` options, and
|
|
124
|
+
* the resolvers, guards and loaders of the metadata.
|
|
125
|
+
*/
|
|
126
|
+
initRoots(context?: PluginRootsContext): readonly ProviderKey[] {
|
|
127
|
+
const roots = new Set<ProviderKey>()
|
|
128
|
+
try {
|
|
129
|
+
const options: unknown = this.#options
|
|
130
|
+
if (options !== null && typeof options === 'object') {
|
|
131
|
+
for (const key of ['guards', 'errorMappers'] as const) {
|
|
132
|
+
const refs: unknown = (options as GraphQLPluginOptions)[key]
|
|
133
|
+
if (Array.isArray(refs)) for (const ref of refs) if (typeof ref === 'function') roots.add(ref as ProviderKey)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const types: unknown = (context?.metadata.extensions?.graphql as { types?: unknown } | undefined)?.types
|
|
137
|
+
if (Array.isArray(types)) {
|
|
138
|
+
for (const type of types as { fields?: unknown }[]) {
|
|
139
|
+
if (!Array.isArray(type?.fields)) continue
|
|
140
|
+
for (const field of type.fields as { resolver?: Partial<GraphQLResolverBinding> }[]) {
|
|
141
|
+
const binding = field?.resolver
|
|
142
|
+
if (binding === undefined || typeof binding.token !== 'string') continue
|
|
143
|
+
roots.add(binding.token)
|
|
144
|
+
for (const token of [...(binding.guards ?? []), ...(binding.loaders ?? [])]) {
|
|
145
|
+
if (typeof token === 'string') roots.add(token)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// initRoots never throws: register() reports malformed options and metadata
|
|
152
|
+
}
|
|
153
|
+
return [...roots]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
register(ctx: PluginContext): void {
|
|
157
|
+
if (this.#active) {
|
|
158
|
+
throw new ZnightError(ErrorCode.DuplicateProvider, 'This GraphQLPlugin is already used by another application', {
|
|
159
|
+
hints: ['Call GraphQLPlugin.configure() once per application'],
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
this.#resolved = undefined
|
|
163
|
+
this.#runtime = undefined
|
|
164
|
+
this.#ownPubSub = undefined
|
|
165
|
+
this.#endpoint = undefined
|
|
166
|
+
this.#container = ctx.container
|
|
167
|
+
this.#metadata = ctx.metadata
|
|
168
|
+
this.#logger = ctx.logger
|
|
169
|
+
this.#hooks = ctx.hooks
|
|
170
|
+
if (typeof this.#options !== 'function') {
|
|
171
|
+
// static options: validated now, and the HTTP transport needs HttpPlugin
|
|
172
|
+
this.#checkHttp(ctx, this.#resolve())
|
|
173
|
+
}
|
|
174
|
+
this.#active = true
|
|
175
|
+
this.#stop = new AbortController()
|
|
176
|
+
ctx.container.registerExternal({ token: GraphQLTokens.Context, scope: 'request', providedBy: 'GraphQLPlugin' })
|
|
177
|
+
ctx.container.registerValue(
|
|
178
|
+
GraphQLTokens.PubSub,
|
|
179
|
+
typeof this.#options === 'function' ? new DeferredPubSub(() => this.pubsub) : this.pubsub,
|
|
180
|
+
)
|
|
181
|
+
ctx.container.registerValue(GraphQLTokens.Plugin, this)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The `PluginHttpRoutes` contract of `@znight/http`: the GraphQL endpoint (`GET` and `POST` on
|
|
186
|
+
* `path`). `[]` with `http: false`.
|
|
187
|
+
*/
|
|
188
|
+
httpRoutes(context: PluginHttpRoutesContext): readonly PluginRoute[] {
|
|
189
|
+
this.#container ??= context.container
|
|
190
|
+
const options = this.#resolve()
|
|
191
|
+
const http = options.http
|
|
192
|
+
if (http === false) return []
|
|
193
|
+
const endpoint = endpointPath(context.prefix, options.path)
|
|
194
|
+
const transport = new HttpTransport({
|
|
195
|
+
// called on the first request: the routes are collected before or after this plugin's onBootstrap
|
|
196
|
+
executor: () => this.#ready().executor,
|
|
197
|
+
endpoint,
|
|
198
|
+
get: http.get,
|
|
199
|
+
graphiql: options.graphiql,
|
|
200
|
+
sse: sseOptions(options),
|
|
201
|
+
})
|
|
202
|
+
this.#endpoint = endpoint
|
|
203
|
+
const common = {
|
|
204
|
+
name: 'handle',
|
|
205
|
+
path: options.path,
|
|
206
|
+
...(http.meta.length === 0 ? {} : { meta: http.meta }),
|
|
207
|
+
...(http.guards.length === 0 ? {} : { guards: http.guards }),
|
|
208
|
+
}
|
|
209
|
+
const routes: PluginRoute[] = [{ ...common, method: 'POST', handler: transport.post, bodyLimit: http.bodyLimit }]
|
|
210
|
+
// GET serves queries (`http.get`) and GraphiQL; without either, the router answers 405
|
|
211
|
+
if (http.get || options.graphiql) routes.unshift({ ...common, method: 'GET', handler: transport.get })
|
|
212
|
+
return routes
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
onBootstrap(ctx: PluginContext): void {
|
|
216
|
+
this.#container = ctx.container
|
|
217
|
+
const options = this.#resolve()
|
|
218
|
+
if (typeof this.#options === 'function') this.#checkHttp(ctx, options)
|
|
219
|
+
this.#checkSse(options)
|
|
220
|
+
this.#runtime = this.#build(ctx, options)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
onStart(ctx: PluginContext): void {
|
|
224
|
+
const runtime = this.#runtime
|
|
225
|
+
const options = this.#resolved
|
|
226
|
+
if (runtime === undefined || options === undefined) return
|
|
227
|
+
const details = { types: runtime.section.types.length, schemaHash: runtime.section.schemaHash }
|
|
228
|
+
if (options.http === false) ctx.logger.info('GraphQL schema ready', details)
|
|
229
|
+
else ctx.logger.info('GraphQL endpoint ready', { path: options.path, ...details })
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async onStop(): Promise<void> {
|
|
233
|
+
this.#stop?.abort(new Error('The application is stopping'))
|
|
234
|
+
await this.#runtime?.executor.stopDirect()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
onDispose(): void {
|
|
238
|
+
this.#active = false
|
|
239
|
+
this.#ownPubSub?.close()
|
|
240
|
+
this.#ownPubSub = undefined
|
|
241
|
+
this.#pubsub = undefined
|
|
242
|
+
this.#runtime?.executor.clear()
|
|
243
|
+
this.#runtime = undefined
|
|
244
|
+
this.#resolved = undefined
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The schema (after bootstrap). */
|
|
248
|
+
get schema(): GraphQLSchema {
|
|
249
|
+
return this.#ready().schema
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The SDL of the schema (graphql-js `printSchema`). */
|
|
253
|
+
printSchema(): string {
|
|
254
|
+
return printSchema(this.#ready().schema)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The `PubSub` of the application: the `pubsub` option, or the plugin's `MemoryPubSub`. */
|
|
258
|
+
get pubsub(): PubSub {
|
|
259
|
+
let pubsub = this.#pubsub
|
|
260
|
+
if (pubsub === undefined) {
|
|
261
|
+
const options = this.#resolve()
|
|
262
|
+
if (options.pubsub === undefined) {
|
|
263
|
+
this.#ownPubSub = new MemoryPubSub({ bufferSize: options.bufferSize })
|
|
264
|
+
pubsub = this.#ownPubSub
|
|
265
|
+
} else {
|
|
266
|
+
// the option accepts a PubSub of any event map; the container hands it out untyped
|
|
267
|
+
pubsub = options.pubsub as unknown as PubSub
|
|
268
|
+
}
|
|
269
|
+
this.#pubsub = pubsub
|
|
270
|
+
}
|
|
271
|
+
return pubsub
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Active subscriptions (every transport). */
|
|
275
|
+
get activeSubscriptions(): number {
|
|
276
|
+
return this.#runtime?.executor.activeSubscriptions ?? 0
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Executes a query or a mutation outside of HTTP (custom transports, tests, a controller calling
|
|
281
|
+
* GraphQL), in the current request scope or in a scope of its own. Returns the result directly
|
|
282
|
+
* when every resolver is synchronous; `await` it. A subscription is an error result: use
|
|
283
|
+
* {@link subscribe}. Rejects only when `options.signal` aborts the execution.
|
|
284
|
+
*/
|
|
285
|
+
execute(request: GraphQLRequest, options?: DirectExecuteOptions): MaybePromise<FormattedExecutionResult> {
|
|
286
|
+
const response = this.#ready().executor.execute(request, direct(options))
|
|
287
|
+
return isPromise(response) ? response.then(settled => settled.result) : response.result
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Subscribes outside of HTTP: the results of the subscription, one per event, until the source
|
|
292
|
+
* ends, `options.signal` aborts, `return()` is called or the application stops. A request error
|
|
293
|
+
* or a query gives a stream of one result.
|
|
294
|
+
*/
|
|
295
|
+
async subscribe(request: GraphQLRequest, options?: DirectExecuteOptions): Promise<GraphQLResultStream> {
|
|
296
|
+
const response = await this.#ready().executor.subscribe(request, direct(options))
|
|
297
|
+
return 'status' in response ? singleResult(response.result) : response
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ------------------------------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
#ready(): Runtime {
|
|
303
|
+
const runtime = this.#runtime
|
|
304
|
+
if (runtime === undefined) {
|
|
305
|
+
throw new ZnightError(ErrorCode.LifecycleFailed, 'GraphQLPlugin is not bootstrapped yet', {
|
|
306
|
+
hints: ['The schema and execute() are available once Application.create() resolved'],
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
return runtime
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#resolve(): ResolvedGraphQLOptions {
|
|
313
|
+
if (this.#resolved !== undefined) return this.#resolved
|
|
314
|
+
const container = this.#container
|
|
315
|
+
if (container === undefined && typeof this.#options === 'function') {
|
|
316
|
+
throw new ZnightError(ErrorCode.LifecycleFailed, 'GraphQLPlugin: the options are not available before register()')
|
|
317
|
+
}
|
|
318
|
+
this.#resolved = resolveGraphQLOptions(resolveOptions(this.#options, container as Container))
|
|
319
|
+
return this.#resolved
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** The SSE options are those of `HttpResponse.sse()` (heartbeat and maxDuration from 1 000 ms): checked at boot. */
|
|
323
|
+
#checkSse(options: ResolvedGraphQLOptions): void {
|
|
324
|
+
const sse = sseOptions(options)
|
|
325
|
+
if (sse === false) return
|
|
326
|
+
const problem = checkSseOptions(sse)
|
|
327
|
+
if (problem !== undefined) throw invalidOption('subscriptions', `valid event stream options (${problem})`, sse)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
#checkHttp(ctx: PluginContext, options: ResolvedGraphQLOptions): void {
|
|
331
|
+
if (options.http !== false && ctx.getPlugin('http') === undefined) {
|
|
332
|
+
throw new ZnightError(
|
|
333
|
+
ErrorCode.InvalidGraphQLSetup,
|
|
334
|
+
'GraphQLPlugin serves its endpoint through HttpPlugin, which is not registered',
|
|
335
|
+
{
|
|
336
|
+
hints: [
|
|
337
|
+
'Add HttpPlugin.configure() to the plugins of the application',
|
|
338
|
+
'Or set http: false to use the schema and execute() only',
|
|
339
|
+
],
|
|
340
|
+
},
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
#build(ctx: PluginContext, options: ResolvedGraphQLOptions): Runtime {
|
|
346
|
+
const container = ctx.container
|
|
347
|
+
const metadata = ctx.metadata
|
|
348
|
+
const section = graphqlSection(metadata)
|
|
349
|
+
const providers = new Providers(container, metadata)
|
|
350
|
+
const globalGuards = options.guards.map(ref => providers.ref<GraphQLGuard>(ref, 'guard', 'canResolve'))
|
|
351
|
+
const mappers = options.errorMappers.map(ref => providers.ref<GraphQLErrorMapper>(ref, 'error mapper', 'map'))
|
|
352
|
+
const inputs = new Map<string, GraphQLInputTypeDefinition>()
|
|
353
|
+
const fields: CheckedField[] = []
|
|
354
|
+
const types = Array.isArray(section?.types) ? section.types : []
|
|
355
|
+
for (const type of types) {
|
|
356
|
+
if (type?.kind === 'input') inputs.set(type.name, type)
|
|
357
|
+
if (type?.kind !== 'object' || !Array.isArray(type.fields)) continue
|
|
358
|
+
for (const field of type.fields) {
|
|
359
|
+
const binding = field?.resolver
|
|
360
|
+
if (binding === undefined || binding === null || typeof binding !== 'object') continue
|
|
361
|
+
const root = binding.operation !== 'field'
|
|
362
|
+
const guardRefs: readonly Token[] = Array.isArray(binding.guards) ? binding.guards : []
|
|
363
|
+
fields.push({
|
|
364
|
+
description: `${type.name}.${field.name} (${displayName(String(binding.token))}.${String(binding.method)})`,
|
|
365
|
+
root,
|
|
366
|
+
meta: binding.meta ?? {},
|
|
367
|
+
guards: [
|
|
368
|
+
...(globalGuardsApply(binding) ? globalGuards.map(guard => ({ cls: guard.cls, name: guard.name })) : []),
|
|
369
|
+
...guardRefs.map(token => ({ cls: metadata.providers[token]?.class, name: displayName(token) })),
|
|
370
|
+
],
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const schema = buildGraphQLSchema(
|
|
375
|
+
section,
|
|
376
|
+
pipelineFactory({
|
|
377
|
+
container,
|
|
378
|
+
globalGuards: globalGuards.map(guard => guard.resolvable),
|
|
379
|
+
guard: (token, coordinate) => providers.token<GraphQLGuard>(token, `guard of ${coordinate}`, 'canResolve'),
|
|
380
|
+
resolver: (binding, coordinate) =>
|
|
381
|
+
providers.token<object>(binding.token, `resolver of ${coordinate}`, binding.method),
|
|
382
|
+
inputs,
|
|
383
|
+
}),
|
|
384
|
+
)
|
|
385
|
+
checkFieldDecisions(fields, section.groups, options.enforcement, ctx.logger)
|
|
386
|
+
const formatter = new ErrorFormatter({
|
|
387
|
+
container,
|
|
388
|
+
mappers: mappers.map(mapper => mapper.resolvable),
|
|
389
|
+
exposeErrors: options.exposeErrors,
|
|
390
|
+
logger: ctx.logger,
|
|
391
|
+
schema,
|
|
392
|
+
})
|
|
393
|
+
const executor = new GraphQLExecutor({
|
|
394
|
+
schema,
|
|
395
|
+
container,
|
|
396
|
+
hooks: ctx.hooks,
|
|
397
|
+
logger: ctx.logger,
|
|
398
|
+
formatter,
|
|
399
|
+
runtime: {
|
|
400
|
+
container,
|
|
401
|
+
loaders: new LoaderFactory(container),
|
|
402
|
+
signal: (this.#stop as AbortController).signal,
|
|
403
|
+
},
|
|
404
|
+
limits: options.limits,
|
|
405
|
+
introspection: options.introspection,
|
|
406
|
+
documentCache: options.documentCache,
|
|
407
|
+
maxActiveSubscriptions: options.subscriptions === false ? undefined : options.subscriptions.maxActive,
|
|
408
|
+
})
|
|
409
|
+
ctx.logger.debug('GraphQL schema built', { types: types.length, schemaHash: section.schemaHash })
|
|
410
|
+
return { schema, section, executor }
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function direct(options: DirectExecuteOptions | undefined): ExecuteOptions {
|
|
415
|
+
if (options === undefined) return DIRECT
|
|
416
|
+
return {
|
|
417
|
+
transport: 'direct',
|
|
418
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
419
|
+
...(options.requestId === undefined ? {} : { requestId: options.requestId }),
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const DIRECT: ExecuteOptions = Object.freeze({ transport: 'direct' })
|
|
424
|
+
|
|
425
|
+
/** `prefix` (`''`, `/api`) joined with `path` (`/graphql`), without trailing slash: `/api/graphql`. */
|
|
426
|
+
function endpointPath(prefix: string, path: string): string {
|
|
427
|
+
const joined = `${prefix.replace(/\/+$/, '')}/${path.replace(/^\/+|\/+$/g, '')}`
|
|
428
|
+
return joined.length > 1 && joined.endsWith('/') ? joined.slice(0, -1) : joined
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** The `HttpResponse.sse()` options of GraphQL over SSE (`subscriptions`), `false` when disabled. */
|
|
432
|
+
function sseOptions(options: ResolvedGraphQLOptions): SseOptions | false {
|
|
433
|
+
const subscriptions = options.subscriptions
|
|
434
|
+
if (subscriptions === false) return false
|
|
435
|
+
return Object.freeze({
|
|
436
|
+
...(subscriptions.heartbeat === undefined ? {} : { heartbeat: subscriptions.heartbeat }),
|
|
437
|
+
...(subscriptions.maxDuration === undefined ? {} : { maxDuration: subscriptions.maxDuration }),
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
|
|
442
|
+
return typeof (value as { then?: unknown } | null)?.then === 'function'
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Resolves providers once at bootstrap: singletons (and registered values) to their instance,
|
|
447
|
+
* request-scoped and transient providers to their token, resolved per use in the current scope.
|
|
448
|
+
*/
|
|
449
|
+
class Providers {
|
|
450
|
+
readonly #container: Container
|
|
451
|
+
readonly #metadata: ZnightMetadata
|
|
452
|
+
readonly #resolvables = new Map<Token, Resolvable<unknown>>()
|
|
453
|
+
|
|
454
|
+
constructor(container: Container, metadata: ZnightMetadata) {
|
|
455
|
+
this.#container = container
|
|
456
|
+
this.#metadata = metadata
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** A provider token (`@UseGuard`, a resolver), checked to have `method` when its class is known. */
|
|
460
|
+
token<T>(token: Token, what: string, method: string): Resolvable<T> {
|
|
461
|
+
const cls: unknown = Object.hasOwn(this.#metadata.providers, token)
|
|
462
|
+
? this.#metadata.providers[token]?.class
|
|
463
|
+
: undefined
|
|
464
|
+
if (
|
|
465
|
+
typeof cls === 'function' &&
|
|
466
|
+
typeof (cls.prototype as Record<string, unknown> | undefined)?.[method] !== 'function'
|
|
467
|
+
) {
|
|
468
|
+
throw new ZnightError(
|
|
469
|
+
ErrorCode.InvalidGraphQLSetup,
|
|
470
|
+
`GraphQLPlugin: the ${what} (${displayName(token)}) has no ${method}() method`,
|
|
471
|
+
{
|
|
472
|
+
hints:
|
|
473
|
+
method === 'canResolve'
|
|
474
|
+
? ['A GraphQL guard implements GraphQLGuard: canResolve(ctx: ResolverContext)']
|
|
475
|
+
: [],
|
|
476
|
+
},
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
return this.#resolvable<T>(token, what)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** A global ref: a provider class or an instance with `method`. */
|
|
483
|
+
ref<T>(
|
|
484
|
+
ref: unknown,
|
|
485
|
+
what: string,
|
|
486
|
+
method: string,
|
|
487
|
+
): { readonly resolvable: Resolvable<T>; readonly cls: unknown; readonly name: string } {
|
|
488
|
+
if (typeof ref === 'function') {
|
|
489
|
+
const token = this.#container.tokenOf(ref as ProviderKey)
|
|
490
|
+
if (token === undefined) {
|
|
491
|
+
const name = (ref as { name?: string }).name || '<anonymous>'
|
|
492
|
+
throw new ZnightError(
|
|
493
|
+
ErrorCode.MissingProvider,
|
|
494
|
+
`GraphQLPlugin: the global ${what} ${name} is not a provider`,
|
|
495
|
+
{
|
|
496
|
+
hints: ['Add @Injectable() to the class, or pass an instance'],
|
|
497
|
+
},
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
const prototype: unknown = (ref as { prototype?: unknown }).prototype
|
|
501
|
+
if (typeof (prototype as Record<string, unknown> | undefined)?.[method] !== 'function') {
|
|
502
|
+
throw invalidOption(`${what}s`, `classes with a ${method}() method`, ref)
|
|
503
|
+
}
|
|
504
|
+
return { resolvable: this.#resolvable<T>(token, `global ${what}`), cls: ref, name: displayName(token) }
|
|
505
|
+
}
|
|
506
|
+
const cls: unknown = (ref as { constructor?: unknown }).constructor
|
|
507
|
+
const name = typeof cls === 'function' && cls !== Object ? (cls as { name?: string }).name : undefined
|
|
508
|
+
return { resolvable: { instance: ref as T }, cls, name: name || `${what} instance` }
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
#resolvable<T>(token: Token, what: string): Resolvable<T> {
|
|
512
|
+
let found = this.#resolvables.get(token)
|
|
513
|
+
if (found === undefined) {
|
|
514
|
+
found = this.#create(token, what)
|
|
515
|
+
this.#resolvables.set(token, found)
|
|
516
|
+
}
|
|
517
|
+
return found as Resolvable<T>
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
#create(token: Token, what: string): Resolvable<unknown> {
|
|
521
|
+
const container = this.#container
|
|
522
|
+
if (!container.has(token)) {
|
|
523
|
+
throw new ZnightError(ErrorCode.MissingProvider, `GraphQLPlugin: the ${what} (${token}) is not a provider`, {
|
|
524
|
+
hints: ['Add @Injectable() to the class (resolvers are providers through @Resolver())'],
|
|
525
|
+
})
|
|
526
|
+
}
|
|
527
|
+
const scope = Object.hasOwn(this.#metadata.providers, token) ? this.#metadata.providers[token]?.scope : undefined
|
|
528
|
+
if (scope === 'request' || scope === 'transient') return { token }
|
|
529
|
+
try {
|
|
530
|
+
return { instance: container.get(token) }
|
|
531
|
+
} catch (error) {
|
|
532
|
+
if ((error as { code?: unknown } | null)?.code === ErrorCode.NoActiveScope) return { token }
|
|
533
|
+
throw error
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* The `PubSub` registered when the options are a function of the container (they are only known
|
|
540
|
+
* after `container.init()`): delegates to the plugin's `PubSub`, resolved on first use.
|
|
541
|
+
*/
|
|
542
|
+
class DeferredPubSub extends PubSub {
|
|
543
|
+
readonly #target: () => PubSub
|
|
544
|
+
|
|
545
|
+
constructor(target: () => PubSub) {
|
|
546
|
+
super()
|
|
547
|
+
this.#target = target
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
publish(topic: string, payload: unknown): Promise<void> {
|
|
551
|
+
return this.#target().publish(topic, payload)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
subscribe(topic: string, options?: { readonly signal?: AbortSignal }): AsyncIterableIterator<unknown> {
|
|
555
|
+
return this.#target().subscribe(topic, options)
|
|
556
|
+
}
|
|
557
|
+
}
|
package/src/pubsub.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `PubSub` contract (RFC 0005 §5.6, §8.7): topics and payloads of subscriptions. `PubSub` is
|
|
3
|
+
* an injectable external of `GraphQLPlugin` (`@znight/graphql#PubSub`, singleton), in memory by
|
|
4
|
+
* default; multi-instance deployments supply their own implementation through the `pubsub` option.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Options of {@link PubSub.subscribe}. */
|
|
8
|
+
export interface SubscribeOptions {
|
|
9
|
+
/** Ends the subscription when it aborts (client gone, shutdown, `maxDuration`): pass `ctx.signal`. */
|
|
10
|
+
readonly signal?: AbortSignal
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Publishes events to the subscribers of a topic. `Events` maps each topic to its payload type.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* interface CatalogEvents {
|
|
18
|
+
* bookAdded: Book
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* constructor(private readonly events: PubSub<CatalogEvents>) {}
|
|
22
|
+
*
|
|
23
|
+
* await this.events.publish('bookAdded', book)
|
|
24
|
+
* for await (const book of this.events.subscribe('bookAdded', { signal: ctx.signal })) yield book
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export abstract class PubSub<Events extends object = Record<string, unknown>> {
|
|
28
|
+
/** Delivers `payload` to the current subscribers of `topic`. */
|
|
29
|
+
abstract publish<K extends keyof Events & string>(topic: K, payload: Events[K]): Promise<void>
|
|
30
|
+
|
|
31
|
+
/** The events of `topic` published from now on, until `options.signal` aborts or the iterator returns. */
|
|
32
|
+
abstract subscribe<K extends keyof Events & string>(
|
|
33
|
+
topic: K,
|
|
34
|
+
options?: SubscribeOptions,
|
|
35
|
+
): AsyncIterableIterator<Events[K]>
|
|
36
|
+
}
|