@znight/graphql 0.2.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/README.md +288 -5
- package/dist/compiler.d.mts +24 -3
- package/dist/compiler.mjs +1876 -109
- package/dist/compiler.mjs.map +1 -1
- package/dist/index.d.mts +49 -8
- package/dist/index.mjs +10 -4
- package/dist/index.mjs.map +1 -1
- package/dist/names-BlzDM5cF.mjs.map +1 -1
- package/dist/{plugin-BdBxr5NQ.mjs → plugin-CR_wVfRU.mjs} +247 -24
- package/dist/plugin-CR_wVfRU.mjs.map +1 -0
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs +1 -1
- package/dist/{types-CgbV9LjD.d.mts → types-DQ713UgI.d.mts} +34 -2
- package/package.json +8 -8
- package/src/compiler/analyze.ts +129 -10
- package/src/compiler/builder.ts +6 -85
- package/src/compiler/checks.ts +105 -0
- package/src/compiler/conformance.ts +1068 -0
- package/src/compiler/constants.ts +6 -0
- package/src/compiler/data.ts +10 -0
- package/src/compiler/generate.ts +41 -27
- package/src/compiler/graphql-js.ts +42 -0
- package/src/compiler/index.ts +8 -3
- package/src/compiler/locate.ts +39 -1
- package/src/compiler/model.ts +16 -1
- package/src/compiler/options.ts +39 -8
- package/src/compiler/schema-first.ts +595 -0
- package/src/compiler/sdl-source.ts +529 -0
- package/src/compiler/sdl-view.ts +3 -1
- package/src/compiler/suggest.ts +39 -0
- package/src/decorators.ts +9 -3
- package/src/index.ts +5 -0
- package/src/metadata.ts +28 -1
- package/src/options.ts +23 -1
- package/src/pipeline.ts +66 -5
- package/src/plugin.ts +27 -5
- package/src/results.ts +206 -0
- package/src/scalars.ts +10 -3
- package/src/schema.ts +90 -12
- package/src/sdl.ts +17 -2
- package/src/types.ts +35 -0
- package/dist/plugin-BdBxr5NQ.mjs.map +0 -1
package/src/metadata.ts
CHANGED
|
@@ -43,6 +43,7 @@ export interface GraphQLRootTypes {
|
|
|
43
43
|
/** A named type of the schema. */
|
|
44
44
|
export type GraphQLTypeDefinition =
|
|
45
45
|
| GraphQLObjectTypeDefinition
|
|
46
|
+
| GraphQLInterfaceTypeDefinition
|
|
46
47
|
| GraphQLInputTypeDefinition
|
|
47
48
|
| GraphQLEnumTypeDefinition
|
|
48
49
|
| GraphQLUnionTypeDefinition
|
|
@@ -54,10 +55,27 @@ export interface GraphQLObjectTypeDefinition {
|
|
|
54
55
|
readonly kind: 'object'
|
|
55
56
|
readonly name: string
|
|
56
57
|
readonly description?: string
|
|
58
|
+
/** Interfaces the type implements (schema-first, RFC 0007), in declaration order. Absent: none. */
|
|
59
|
+
readonly interfaces?: readonly string[]
|
|
57
60
|
/** Declaration order (base members first), then field resolvers by resolver token and source order. */
|
|
58
61
|
readonly fields: readonly GraphQLFieldDefinition[]
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
/**
|
|
65
|
+
* An interface type (schema-first, RFC 0007; code-first emits none). Its fields have no
|
|
66
|
+
* `resolver`: graphql-js resolves the fields of the concrete object type.
|
|
67
|
+
*/
|
|
68
|
+
export interface GraphQLInterfaceTypeDefinition {
|
|
69
|
+
readonly kind: 'interface'
|
|
70
|
+
readonly name: string
|
|
71
|
+
readonly description?: string
|
|
72
|
+
/** Interfaces the interface implements, in declaration order. Absent: none. */
|
|
73
|
+
readonly interfaces?: readonly string[]
|
|
74
|
+
readonly fields: readonly GraphQLFieldDefinition[]
|
|
75
|
+
/** How the runtime tells the implementing object types apart (`resolveType`). */
|
|
76
|
+
readonly discriminator: GraphQLDiscriminator
|
|
77
|
+
}
|
|
78
|
+
|
|
61
79
|
/** An input object type. */
|
|
62
80
|
export interface GraphQLInputTypeDefinition {
|
|
63
81
|
readonly kind: 'input'
|
|
@@ -97,6 +115,15 @@ export interface GraphQLBuiltinScalarDefinition {
|
|
|
97
115
|
export interface GraphQLCustomScalarDefinition {
|
|
98
116
|
readonly kind: 'scalar'
|
|
99
117
|
readonly name: string
|
|
118
|
+
/** Wins over the description of the definition (schema-first: the SDL's). */
|
|
119
|
+
readonly description?: string
|
|
120
|
+
/** Wins over the `specifiedByURL` of the definition (schema-first: the SDL's `@specifiedBy`). */
|
|
121
|
+
readonly specifiedByURL?: string
|
|
122
|
+
/**
|
|
123
|
+
* Schema-first (RFC 0007): `description` and `specifiedByURL` are the SDL's and replace the
|
|
124
|
+
* definition's, also when absent, so that the served schema is the SDL.
|
|
125
|
+
*/
|
|
126
|
+
readonly sdl?: true
|
|
100
127
|
/** `ScalarDefinition<T>` of any `T` (its methods are bivariant), or a graphql-js scalar named `name`. */
|
|
101
128
|
readonly scalar: ScalarDefinition<unknown> | GraphQLScalarType
|
|
102
129
|
}
|
|
@@ -141,7 +168,7 @@ export interface GraphQLEnumValueDefinition {
|
|
|
141
168
|
readonly deprecationReason?: string
|
|
142
169
|
}
|
|
143
170
|
|
|
144
|
-
/** How the runtime tells the members of a union apart (`resolveType`). */
|
|
171
|
+
/** How the runtime tells the members of a union or the implementations of an interface apart (`resolveType`). */
|
|
145
172
|
export type GraphQLDiscriminator =
|
|
146
173
|
| {
|
|
147
174
|
readonly by: 'property'
|
package/src/options.ts
CHANGED
|
@@ -3,7 +3,15 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { ByteSize, GuardRef, RouteMetaEntry } from '@znight/http'
|
|
5
5
|
import type { SubscribeOptions } from './pubsub.ts'
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
GraphQLErrorMapper,
|
|
8
|
+
GraphQLErrorMapperClass,
|
|
9
|
+
GraphQLGuard,
|
|
10
|
+
GraphQLGuardClass,
|
|
11
|
+
GraphQLResultMapper,
|
|
12
|
+
GraphQLResultMapperClass,
|
|
13
|
+
GraphQLResultTypes,
|
|
14
|
+
} from './types.ts'
|
|
7
15
|
|
|
8
16
|
import { ErrorCode, ZnightError } from '@znight/core'
|
|
9
17
|
import { parseByteSize } from '@znight/http'
|
|
@@ -23,6 +31,12 @@ export type GraphQLGuardRef = GraphQLGuardClass | GraphQLGuard
|
|
|
23
31
|
/** An error mapper: a provider class or an instance. */
|
|
24
32
|
export type GraphQLErrorMapperRef = GraphQLErrorMapperClass | GraphQLErrorMapper
|
|
25
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
|
+
|
|
26
40
|
/** Options of the GraphQL-over-HTTP endpoint. */
|
|
27
41
|
export interface GraphQLHttpOptions {
|
|
28
42
|
/** Route metadata of the endpoint (`[Public.meta()]`), seen by HTTP guards and the route decision check. */
|
|
@@ -72,6 +86,11 @@ export interface GraphQLPluginOptions {
|
|
|
72
86
|
readonly guards?: readonly GraphQLGuardRef[]
|
|
73
87
|
/** Error mappers (classes or instances), tried in order. */
|
|
74
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[]
|
|
75
94
|
/** Unmasked messages for unexpected errors (development). Default `false`. */
|
|
76
95
|
readonly exposeErrors?: boolean
|
|
77
96
|
/** Introspection; off, suggestions are hidden too. Default `NODE_ENV !== 'production'`. */
|
|
@@ -98,6 +117,7 @@ export interface ResolvedGraphQLOptions {
|
|
|
98
117
|
readonly http: Required<GraphQLHttpOptions> | false
|
|
99
118
|
readonly guards: readonly GraphQLGuardRef[]
|
|
100
119
|
readonly errorMappers: readonly GraphQLErrorMapperRef[]
|
|
120
|
+
readonly resultMappers: readonly GraphQLResultMapperRef[]
|
|
101
121
|
readonly exposeErrors: boolean
|
|
102
122
|
readonly introspection: boolean
|
|
103
123
|
readonly graphiql: boolean
|
|
@@ -126,6 +146,7 @@ const KEYS: ReadonlySet<string> = new Set([
|
|
|
126
146
|
'http',
|
|
127
147
|
'guards',
|
|
128
148
|
'errorMappers',
|
|
149
|
+
'resultMappers',
|
|
129
150
|
'exposeErrors',
|
|
130
151
|
'introspection',
|
|
131
152
|
'graphiql',
|
|
@@ -291,6 +312,7 @@ export function resolveGraphQLOptions(
|
|
|
291
312
|
http,
|
|
292
313
|
guards: refs<GraphQLGuardRef>('guards', options['guards'], 'canResolve'),
|
|
293
314
|
errorMappers: refs<GraphQLErrorMapperRef>('errorMappers', options['errorMappers'], 'map'),
|
|
315
|
+
resultMappers: refs<GraphQLResultMapperRef>('resultMappers', options['resultMappers'], 'toResult'),
|
|
294
316
|
exposeErrors: boolean('exposeErrors', options['exposeErrors'], false),
|
|
295
317
|
introspection: boolean('introspection', options['introspection'], nodeEnv !== 'production'),
|
|
296
318
|
graphiql: boolean('graphiql', options['graphiql'], nodeEnv === 'development'),
|
package/src/pipeline.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
*
|
|
12
12
|
* A field without guards nor normaliser calls `invoke` from the closure graphql-js needs anyway:
|
|
13
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.
|
|
14
16
|
*/
|
|
15
17
|
import type { Container, Token } from '@znight/core'
|
|
16
18
|
import type { GraphQLFieldResolver, GraphQLResolveInfo } from 'graphql'
|
|
@@ -23,6 +25,7 @@ import type {
|
|
|
23
25
|
GraphQLObjectTypeDefinition,
|
|
24
26
|
GraphQLResolverBinding,
|
|
25
27
|
} from './metadata.ts'
|
|
28
|
+
import type { Recover } from './results.ts'
|
|
26
29
|
import type { FieldFunctions, FieldFunctionsFactory } from './schema.ts'
|
|
27
30
|
import type { GraphQLTypeRef } from './type-ref.ts'
|
|
28
31
|
import type { GraphQLArgs, GraphQLContext, GraphQLGuard, ResolverContext } from './types.ts'
|
|
@@ -44,6 +47,8 @@ export interface PipelineOptions {
|
|
|
44
47
|
readonly resolver: (binding: GraphQLResolverBinding, coordinate: string) => Resolvable<object>
|
|
45
48
|
/** Input object types by name (null handling of nested input values). */
|
|
46
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
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
/** The generated invoker of a binding. */
|
|
@@ -80,6 +85,11 @@ function fieldFunctions(
|
|
|
80
85
|
...binding.guards.map(token => options.guard(token, coordinate)),
|
|
81
86
|
]
|
|
82
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
|
|
83
93
|
|
|
84
94
|
let resolve: GraphQLFieldResolver<unknown, unknown>
|
|
85
95
|
if (guards.length === 0) {
|
|
@@ -104,9 +114,6 @@ function fieldFunctions(
|
|
|
104
114
|
}
|
|
105
115
|
}
|
|
106
116
|
} else {
|
|
107
|
-
const route = Object.freeze({ meta: binding.meta, coordinate })
|
|
108
|
-
const parentType = parent.name
|
|
109
|
-
const fieldName = field.name
|
|
110
117
|
const forbidden = (): never => {
|
|
111
118
|
throw new GraphQLException(`Not allowed to resolve ${coordinate}`, { code: GraphQLErrorCode.Forbidden })
|
|
112
119
|
}
|
|
@@ -116,7 +123,7 @@ function fieldFunctions(
|
|
|
116
123
|
const allowed = runGuards(
|
|
117
124
|
container,
|
|
118
125
|
guards,
|
|
119
|
-
new FieldContext(route, operation, parentType, fieldName, args, p, i),
|
|
126
|
+
new FieldContext(route as ResolverContext['route'], operation, parentType, fieldName, args, p, i),
|
|
120
127
|
)
|
|
121
128
|
// §8.5: guards (they see the coerced arguments), then null handling, then invoke
|
|
122
129
|
const call = (): unknown => {
|
|
@@ -128,7 +135,11 @@ function fieldFunctions(
|
|
|
128
135
|
return allowed.then(ok => (ok ? call() : forbidden()))
|
|
129
136
|
}
|
|
130
137
|
}
|
|
131
|
-
|
|
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) {
|
|
132
143
|
// the method gives the source event stream; each event is the field value
|
|
133
144
|
return { subscribe: resolve, resolve: identity }
|
|
134
145
|
}
|
|
@@ -139,6 +150,56 @@ function identity(payload: unknown): unknown {
|
|
|
139
150
|
return payload
|
|
140
151
|
}
|
|
141
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
|
+
|
|
142
203
|
/** Runs guards in order, synchronously until one returns a promise. */
|
|
143
204
|
function runGuards(
|
|
144
205
|
container: Container,
|
package/src/plugin.ts
CHANGED
|
@@ -22,7 +22,7 @@ import type { ExecuteOptions, GraphQLRequest, GraphQLResultStream } from './exec
|
|
|
22
22
|
import type { Resolvable } from './format.ts'
|
|
23
23
|
import type { GraphQLInputTypeDefinition, GraphQLMetadata, GraphQLResolverBinding } from './metadata.ts'
|
|
24
24
|
import type { GraphQLPluginOptions, ResolvedGraphQLOptions } from './options.ts'
|
|
25
|
-
import type { GraphQLErrorMapper, GraphQLGuard } from './types.ts'
|
|
25
|
+
import type { GraphQLErrorMapper, GraphQLGuard, GraphQLResultMapper } from './types.ts'
|
|
26
26
|
|
|
27
27
|
import { displayName, ErrorCode, resolveOptions, ZnightError } from '@znight/core'
|
|
28
28
|
import { printSchema } from 'graphql'
|
|
@@ -35,7 +35,8 @@ import { MemoryPubSub } from './memory-pubsub.ts'
|
|
|
35
35
|
import { invalidOption, resolveGraphQLOptions } from './options.ts'
|
|
36
36
|
import { globalGuardsApply, pipelineFactory } from './pipeline.ts'
|
|
37
37
|
import { PubSub } from './pubsub.ts'
|
|
38
|
-
import {
|
|
38
|
+
import { checkResultTypes, declaredTypes, ResultMappers } from './results.ts'
|
|
39
|
+
import { buildGraphQLSchema, checkGraphQLSection, graphqlSection } from './schema.ts'
|
|
39
40
|
import { GraphQLTokens } from './tokens.ts'
|
|
40
41
|
import { checkSseOptions, HttpTransport } from './transport/http.ts'
|
|
41
42
|
|
|
@@ -120,7 +121,7 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
|
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
/**
|
|
123
|
-
* Providers `TestApp` creates at boot: the classes of the `guards` and `
|
|
124
|
+
* Providers `TestApp` creates at boot: the classes of the `guards`, `errorMappers` and `resultMappers` options, and
|
|
124
125
|
* the resolvers, guards and loaders of the metadata.
|
|
125
126
|
*/
|
|
126
127
|
initRoots(context?: PluginRootsContext): readonly ProviderKey[] {
|
|
@@ -128,7 +129,7 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
|
|
|
128
129
|
try {
|
|
129
130
|
const options: unknown = this.#options
|
|
130
131
|
if (options !== null && typeof options === 'object') {
|
|
131
|
-
for (const key of ['guards', 'errorMappers'] as const) {
|
|
132
|
+
for (const key of ['guards', 'errorMappers', 'resultMappers'] as const) {
|
|
132
133
|
const refs: unknown = (options as GraphQLPluginOptions)[key]
|
|
133
134
|
if (Array.isArray(refs)) for (const ref of refs) if (typeof ref === 'function') roots.add(ref as ProviderKey)
|
|
134
135
|
}
|
|
@@ -349,6 +350,10 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
|
|
|
349
350
|
const providers = new Providers(container, metadata)
|
|
350
351
|
const globalGuards = options.guards.map(ref => providers.ref<GraphQLGuard>(ref, 'guard', 'canResolve'))
|
|
351
352
|
const mappers = options.errorMappers.map(ref => providers.ref<GraphQLErrorMapper>(ref, 'error mapper', 'map'))
|
|
353
|
+
const resultMappers = options.resultMappers.map(ref => ({
|
|
354
|
+
...providers.ref<GraphQLResultMapper>(ref, 'result mapper', 'toResult'),
|
|
355
|
+
types: declaredTypes(ref),
|
|
356
|
+
}))
|
|
352
357
|
const inputs = new Map<string, GraphQLInputTypeDefinition>()
|
|
353
358
|
const fields: CheckedField[] = []
|
|
354
359
|
const types = Array.isArray(section?.types) ? section.types : []
|
|
@@ -371,6 +376,18 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
|
|
|
371
376
|
})
|
|
372
377
|
}
|
|
373
378
|
}
|
|
379
|
+
// a malformed section is reported by buildGraphQLSchema, not as types missing from it
|
|
380
|
+
if (resultMappers.length > 0 && checkGraphQLSection(section).length === 0) checkResultTypes(resultMappers, types)
|
|
381
|
+
const results = new ResultMappers(
|
|
382
|
+
container,
|
|
383
|
+
ctx.logger,
|
|
384
|
+
resultMappers.map(mapper => ({
|
|
385
|
+
resolvable: mapper.resolvable,
|
|
386
|
+
name: mapper.name,
|
|
387
|
+
types: mapper.types as readonly string[],
|
|
388
|
+
})),
|
|
389
|
+
types,
|
|
390
|
+
)
|
|
374
391
|
const schema = buildGraphQLSchema(
|
|
375
392
|
section,
|
|
376
393
|
pipelineFactory({
|
|
@@ -380,6 +397,7 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
|
|
|
380
397
|
resolver: (binding, coordinate) =>
|
|
381
398
|
providers.token<object>(binding.token, `resolver of ${coordinate}`, binding.method),
|
|
382
399
|
inputs,
|
|
400
|
+
results: (field, coordinate) => results.recover(field, coordinate),
|
|
383
401
|
}),
|
|
384
402
|
)
|
|
385
403
|
checkFieldDecisions(fields, section.groups, options.enforcement, ctx.logger)
|
|
@@ -406,7 +424,11 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
|
|
|
406
424
|
documentCache: options.documentCache,
|
|
407
425
|
maxActiveSubscriptions: options.subscriptions === false ? undefined : options.subscriptions.maxActive,
|
|
408
426
|
})
|
|
409
|
-
ctx.logger.debug('GraphQL schema built', {
|
|
427
|
+
ctx.logger.debug('GraphQL schema built', {
|
|
428
|
+
types: types.length,
|
|
429
|
+
schemaHash: section.schemaHash,
|
|
430
|
+
...(results.fields === 0 ? {} : { resultFields: results.fields }),
|
|
431
|
+
})
|
|
410
432
|
return { schema, section, executor }
|
|
411
433
|
}
|
|
412
434
|
}
|
package/src/results.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors as data (RFC 0008): result mappers turn the error of a field into a value of its union.
|
|
3
|
+
*
|
|
4
|
+
* Decided at boot, per resolver-backed field: a field carries results when its type, without its
|
|
5
|
+
* non-null wrapper, is a union (not a list) holding an object type declared by a result mapper
|
|
6
|
+
* (`static types`). Only those fields get a wrapper (`pipeline.ts`); the others keep their resolve
|
|
7
|
+
* function. On an error, the applicable mappers run in order; the first value resolving to a member
|
|
8
|
+
* of the union is the field's value, otherwise the error is thrown back, to `errors[]`.
|
|
9
|
+
*/
|
|
10
|
+
import type { Container, CoreLogger } from '@znight/core'
|
|
11
|
+
import type { GraphQLResolveInfo } from 'graphql'
|
|
12
|
+
import type { Resolvable } from './format.ts'
|
|
13
|
+
import type { GraphQLFieldDefinition, GraphQLTypeDefinition } from './metadata.ts'
|
|
14
|
+
import type { GraphQLResultMapper, ResolverContext } from './types.ts'
|
|
15
|
+
|
|
16
|
+
import { ErrorCode, ZnightError } from '@znight/core'
|
|
17
|
+
import { isThenable } from '@znight/http'
|
|
18
|
+
|
|
19
|
+
import { resolveIn } from './format.ts'
|
|
20
|
+
import { typeResolver } from './schema.ts'
|
|
21
|
+
import { parseTypeRef } from './type-ref.ts'
|
|
22
|
+
|
|
23
|
+
/** A result mapper resolved at boot. */
|
|
24
|
+
export interface ResultMapperEntry {
|
|
25
|
+
readonly resolvable: Resolvable<GraphQLResultMapper>
|
|
26
|
+
/** For logs: the class name. */
|
|
27
|
+
readonly name: string
|
|
28
|
+
/** Declared object types, checked by {@link checkResultTypes}. */
|
|
29
|
+
readonly types: readonly string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The value of a field for an error, or the error thrown back. */
|
|
33
|
+
export type Recover = (error: unknown, ctx: ResolverContext) => unknown
|
|
34
|
+
|
|
35
|
+
/** The object types a result mapper ref declares: `static types` of a class; for an instance, its `types` property or its class's. */
|
|
36
|
+
export function declaredTypes(ref: unknown): unknown {
|
|
37
|
+
try {
|
|
38
|
+
if (typeof ref === 'function') return (ref as { types?: unknown }).types
|
|
39
|
+
const own = (ref as { types?: unknown }).types
|
|
40
|
+
if (own !== undefined) return own
|
|
41
|
+
return ((ref as { constructor?: unknown }).constructor as { types?: unknown } | undefined)?.types
|
|
42
|
+
} catch {
|
|
43
|
+
// a throwing getter: reported as no types
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Checks the declared types of the result mappers against the schema's types: a non-empty array of
|
|
50
|
+
* names of object types. Every problem in one ZNIGHT066 error.
|
|
51
|
+
*/
|
|
52
|
+
export function checkResultTypes(
|
|
53
|
+
mappers: readonly { readonly name: string; readonly types: unknown }[],
|
|
54
|
+
types: readonly GraphQLTypeDefinition[],
|
|
55
|
+
): void {
|
|
56
|
+
if (mappers.length === 0) return
|
|
57
|
+
const kinds = new Map(types.map(type => [type?.name, type?.kind]))
|
|
58
|
+
const problems: string[] = []
|
|
59
|
+
for (const mapper of mappers) {
|
|
60
|
+
const declared = mapper.types
|
|
61
|
+
if (!Array.isArray(declared) || declared.length === 0) {
|
|
62
|
+
problems.push(
|
|
63
|
+
`the result mapper ${mapper.name} declares no types (static readonly types = [...object type names])`,
|
|
64
|
+
)
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
for (const name of declared as unknown[]) {
|
|
68
|
+
if (typeof name !== 'string' || kinds.get(name) !== 'object') {
|
|
69
|
+
problems.push(
|
|
70
|
+
`the result mapper ${mapper.name} declares ${typeof name === 'string' ? name : String(name)}, which is not an object type of the schema`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (problems.length === 0) return
|
|
76
|
+
throw new ZnightError(ErrorCode.InvalidGraphQLSetup, `GraphQLPlugin: ${problems.join('; ')}`, {
|
|
77
|
+
hints: [
|
|
78
|
+
"A result mapper declares the object types its values may have: static readonly types = ['NotFoundRejection'] (RFC 0008)",
|
|
79
|
+
],
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The union of a field type and its members, by union name. */
|
|
84
|
+
interface Union {
|
|
85
|
+
readonly name: string
|
|
86
|
+
readonly members: ReadonlySet<string>
|
|
87
|
+
readonly typeOf: (value: unknown) => string | undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Decides which fields carry results, and builds their {@link Recover} functions. */
|
|
91
|
+
export class ResultMappers {
|
|
92
|
+
readonly #container: Container
|
|
93
|
+
readonly #logger: CoreLogger
|
|
94
|
+
readonly #mappers: readonly ResultMapperEntry[]
|
|
95
|
+
readonly #types: readonly GraphQLTypeDefinition[]
|
|
96
|
+
/** Built on the first field, once the schema construction checked the section's structure. */
|
|
97
|
+
#index: { readonly objects: ReadonlySet<string>; readonly unions: ReadonlyMap<string, Union> } | undefined
|
|
98
|
+
#fields = 0
|
|
99
|
+
|
|
100
|
+
constructor(
|
|
101
|
+
container: Container,
|
|
102
|
+
logger: CoreLogger,
|
|
103
|
+
mappers: readonly ResultMapperEntry[],
|
|
104
|
+
types: readonly GraphQLTypeDefinition[],
|
|
105
|
+
) {
|
|
106
|
+
this.#container = container
|
|
107
|
+
this.#logger = logger
|
|
108
|
+
this.#mappers = mappers
|
|
109
|
+
this.#types = types
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Fields that carry results so far. */
|
|
113
|
+
get fields(): number {
|
|
114
|
+
return this.#fields
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The {@link Recover} function of `field`, `undefined` when it carries no result. */
|
|
118
|
+
recover(field: GraphQLFieldDefinition, coordinate: string): Recover | undefined {
|
|
119
|
+
if (this.#mappers.length === 0) return undefined
|
|
120
|
+
const ref = parseTypeRef(field.type)
|
|
121
|
+
if (ref?.kind !== 'named') return undefined
|
|
122
|
+
this.#index ??= index(this.#types)
|
|
123
|
+
const objects = this.#index.objects
|
|
124
|
+
const union = this.#index.unions.get(ref.name)
|
|
125
|
+
if (union === undefined) return undefined
|
|
126
|
+
const applicable = this.#mappers.filter(mapper => mapper.types.some(type => union.members.has(type)))
|
|
127
|
+
if (applicable.length === 0) return undefined
|
|
128
|
+
this.#fields++
|
|
129
|
+
const container = this.#container
|
|
130
|
+
const logger = this.#logger
|
|
131
|
+
return (error, ctx) => {
|
|
132
|
+
for (const mapper of applicable) {
|
|
133
|
+
let value: unknown
|
|
134
|
+
let type: string | undefined
|
|
135
|
+
try {
|
|
136
|
+
value = resolveIn(container, mapper.resolvable).toResult(error, ctx)
|
|
137
|
+
if (value === undefined || value === null) continue
|
|
138
|
+
if (isThenable(value)) {
|
|
139
|
+
// not awaited: the field cannot wait for a mapper, and its rejection must not go unhandled
|
|
140
|
+
Promise.resolve(value).catch(noop)
|
|
141
|
+
logger.error(
|
|
142
|
+
`GraphQLPlugin: the result mapper ${mapper.name} returned a promise: toResult() must be synchronous`,
|
|
143
|
+
details(ctx, coordinate, error),
|
|
144
|
+
)
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
// the discriminator reads the value (a getter, a class check): a failure is the mapper's
|
|
148
|
+
type = union.typeOf(value)
|
|
149
|
+
} catch (failure) {
|
|
150
|
+
logger.error(`GraphQLPlugin: the result mapper ${mapper.name} failed`, details(ctx, coordinate, failure))
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
if (type !== undefined && union.members.has(type)) {
|
|
154
|
+
// the business path: no request id (it may be minted on first read) nor path built for a debug line
|
|
155
|
+
logger.debug(`GraphQL: error resolved as ${type} in ${coordinate}`, { coordinate, type })
|
|
156
|
+
return value
|
|
157
|
+
}
|
|
158
|
+
if (type !== undefined && objects.has(type)) {
|
|
159
|
+
logger.debug(
|
|
160
|
+
`GraphQLPlugin: the result mapper ${mapper.name} returned a value of ${type}, not a member of ${union.name} (${coordinate}): skipped`,
|
|
161
|
+
details(ctx, coordinate, error),
|
|
162
|
+
)
|
|
163
|
+
} else {
|
|
164
|
+
logger.warn(
|
|
165
|
+
`GraphQLPlugin: the result mapper ${mapper.name} returned a value of no type of the schema (${coordinate}): skipped`,
|
|
166
|
+
details(ctx, coordinate, error),
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
throw error
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function index(types: readonly GraphQLTypeDefinition[]): {
|
|
176
|
+
readonly objects: ReadonlySet<string>
|
|
177
|
+
readonly unions: ReadonlyMap<string, Union>
|
|
178
|
+
} {
|
|
179
|
+
const objects = new Set<string>()
|
|
180
|
+
const unions = new Map<string, Union>()
|
|
181
|
+
for (const type of types) {
|
|
182
|
+
if (type.kind === 'object') objects.add(type.name)
|
|
183
|
+
else if (type.kind === 'union') {
|
|
184
|
+
unions.set(type.name, {
|
|
185
|
+
name: type.name,
|
|
186
|
+
members: new Set(type.members),
|
|
187
|
+
typeOf: typeResolver(type.discriminator),
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { objects, unions }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function noop(): void {}
|
|
195
|
+
|
|
196
|
+
function details(ctx: ResolverContext, coordinate: string, err: unknown): Record<string, unknown> {
|
|
197
|
+
return { err, requestId: ctx.operation.requestId, coordinate, path: pathOf(ctx.info).join('.') }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function pathOf(info: GraphQLResolveInfo): (string | number)[] {
|
|
201
|
+
const path: (string | number)[] = []
|
|
202
|
+
for (let step: GraphQLResolveInfo['path'] | undefined = info.path; step !== undefined; step = step.prev) {
|
|
203
|
+
path.push(step.key)
|
|
204
|
+
}
|
|
205
|
+
return path.reverse()
|
|
206
|
+
}
|
package/src/scalars.ts
CHANGED
|
@@ -115,12 +115,19 @@ export function builtinScalar(name: string): GraphQLScalarType | undefined {
|
|
|
115
115
|
* `parseValue` → `coerceInputValue`, `parseLiteral` → `coerceInputLiteral` (default: `parseValue`
|
|
116
116
|
* of the literal's JSON value). The definition's methods are called on the definition.
|
|
117
117
|
*/
|
|
118
|
-
export function definitionScalar(
|
|
118
|
+
export function definitionScalar(
|
|
119
|
+
name: string,
|
|
120
|
+
definition: ScalarDefinition<unknown>,
|
|
121
|
+
overrides: { readonly description?: string; readonly specifiedByURL?: string } = {},
|
|
122
|
+
): GraphQLScalarType {
|
|
119
123
|
const parseLiteral = definition.parseLiteral
|
|
124
|
+
// a key present in `overrides` wins, even undefined (schema-first: the SDL is the truth)
|
|
125
|
+
const description = 'description' in overrides ? overrides.description : definition.description
|
|
126
|
+
const specifiedByURL = 'specifiedByURL' in overrides ? overrides.specifiedByURL : definition.specifiedByURL
|
|
120
127
|
return new GraphQLScalarType({
|
|
121
128
|
name,
|
|
122
|
-
...(
|
|
123
|
-
...(
|
|
129
|
+
...(description === undefined ? {} : { description }),
|
|
130
|
+
...(specifiedByURL === undefined ? {} : { specifiedByURL }),
|
|
124
131
|
coerceOutputValue: (value: unknown) => definition.serialize(value),
|
|
125
132
|
coerceInputValue: (value: unknown) => definition.parseValue(value),
|
|
126
133
|
coerceInputLiteral:
|