@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/format.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error formatting (RFC 0005 §5.10, §8.8): what clients see of the errors of a result.
|
|
3
|
+
*
|
|
4
|
+
* Execution errors, in order: a `GraphQLException` (or a `GraphQLError` thrown by user code) as is →
|
|
5
|
+
* the error mappers, in order → an `HttpException` mapped by status (5xx masked) → anything else
|
|
6
|
+
* masked as `Unexpected error.` with `INTERNAL_SERVER_ERROR`, logged once (unless `exposeErrors`).
|
|
7
|
+
* The `cause` of a shown `GraphQLException` is logged once too, never sent.
|
|
8
|
+
* Request errors (parse, validation, variables, limits) carry their code and are never logged
|
|
9
|
+
* above `debug`.
|
|
10
|
+
*
|
|
11
|
+
* Kept apart from the import-free `errors.ts`, which the compiler side may reach.
|
|
12
|
+
*/
|
|
13
|
+
import type { Container, CoreLogger, Token } from '@znight/core'
|
|
14
|
+
import type {
|
|
15
|
+
ASTNode,
|
|
16
|
+
DocumentNode,
|
|
17
|
+
FieldNode,
|
|
18
|
+
FragmentDefinitionNode,
|
|
19
|
+
GraphQLField,
|
|
20
|
+
GraphQLFormattedError,
|
|
21
|
+
GraphQLNamedType,
|
|
22
|
+
GraphQLSchema,
|
|
23
|
+
OperationDefinitionNode,
|
|
24
|
+
SelectionSetNode,
|
|
25
|
+
} from 'graphql'
|
|
26
|
+
import type { GraphQLContext, GraphQLErrorMapper } from './types.ts'
|
|
27
|
+
|
|
28
|
+
import { isHttpException, ValidationException } from '@znight/http'
|
|
29
|
+
import { GraphQLError, getNamedType, isObjectType, Kind } from 'graphql'
|
|
30
|
+
|
|
31
|
+
import { GraphQLErrorCode, GraphQLException } from './errors.ts'
|
|
32
|
+
|
|
33
|
+
/** A provider resolved once (singleton, instance) or per use, in the current scope (token). */
|
|
34
|
+
export type Resolvable<T> = { readonly instance: T; readonly token?: undefined } | { readonly token: Token }
|
|
35
|
+
|
|
36
|
+
/** Resolves a {@link Resolvable} (in the current scope for scoped providers). */
|
|
37
|
+
export function resolveIn<T>(container: Container, resolvable: Resolvable<T>): T {
|
|
38
|
+
return resolvable.token === undefined ? resolvable.instance : (container.get(resolvable.token) as T)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Message of masked errors. */
|
|
42
|
+
export const MASKED_MESSAGE = 'Unexpected error.'
|
|
43
|
+
|
|
44
|
+
/** `HttpException` status → `extensions.code` (other 4xx: `BAD_REQUEST`, 5xx: masked). */
|
|
45
|
+
const STATUS_CODES: ReadonlyMap<number, string> = new Map([
|
|
46
|
+
[400, GraphQLErrorCode.BadUserInput],
|
|
47
|
+
[401, GraphQLErrorCode.Unauthenticated],
|
|
48
|
+
[403, GraphQLErrorCode.Forbidden],
|
|
49
|
+
[404, GraphQLErrorCode.NotFound],
|
|
50
|
+
[409, GraphQLErrorCode.Conflict],
|
|
51
|
+
[429, GraphQLErrorCode.TooManyRequests],
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
/** Options of {@link ErrorFormatter}. */
|
|
55
|
+
export interface ErrorFormatterOptions {
|
|
56
|
+
readonly container: Container
|
|
57
|
+
readonly mappers: readonly Resolvable<GraphQLErrorMapper>[]
|
|
58
|
+
readonly exposeErrors: boolean
|
|
59
|
+
readonly logger: CoreLogger
|
|
60
|
+
readonly schema: GraphQLSchema
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Formats the errors of execution results. One per plugin, built at boot. */
|
|
64
|
+
export class ErrorFormatter {
|
|
65
|
+
readonly #container: Container
|
|
66
|
+
readonly #mappers: readonly Resolvable<GraphQLErrorMapper>[]
|
|
67
|
+
readonly #expose: boolean
|
|
68
|
+
readonly #logger: CoreLogger
|
|
69
|
+
readonly #schema: GraphQLSchema
|
|
70
|
+
|
|
71
|
+
constructor(options: ErrorFormatterOptions) {
|
|
72
|
+
this.#container = options.container
|
|
73
|
+
this.#mappers = options.mappers
|
|
74
|
+
this.#expose = options.exposeErrors
|
|
75
|
+
this.#logger = options.logger
|
|
76
|
+
this.#schema = options.schema
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Formats the errors of an executed operation (field errors). */
|
|
80
|
+
format(errors: readonly GraphQLError[], ctx: GraphQLContext): GraphQLFormattedError[] {
|
|
81
|
+
const result: GraphQLFormattedError[] = new Array(errors.length)
|
|
82
|
+
// the same cause may fail several fields (a loader rejecting every key): logged once
|
|
83
|
+
let logged: Set<unknown> | undefined
|
|
84
|
+
for (let index = 0; index < errors.length; index++) {
|
|
85
|
+
const error = errors[index] as GraphQLError
|
|
86
|
+
const original = error.originalError
|
|
87
|
+
if (original === undefined || original === null) {
|
|
88
|
+
// produced by graphql-js during execution (non-null violation...): about the schema, shown as is
|
|
89
|
+
result[index] = withCode(
|
|
90
|
+
error,
|
|
91
|
+
error.extensions['code'] === undefined ? GraphQLErrorCode.InternalServerError : undefined,
|
|
92
|
+
)
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
const exposed = this.#exposed(original, ctx)
|
|
96
|
+
if (exposed !== undefined) {
|
|
97
|
+
result[index] = render(error, exposed)
|
|
98
|
+
// the original failure behind a presented error (`{ cause }`): logged once, never sent
|
|
99
|
+
const cause = exposed.cause
|
|
100
|
+
if (cause !== undefined) {
|
|
101
|
+
logged ??= new Set()
|
|
102
|
+
if (!logged.has(cause)) {
|
|
103
|
+
logged.add(cause)
|
|
104
|
+
this.#log(error, cause, ctx, exposed.code)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
if (original instanceof GraphQLError && !isCompletionError(original)) {
|
|
110
|
+
result[index] = { ...original.toJSON(), ...located(error) }
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
logged ??= new Set()
|
|
114
|
+
if (!logged.has(original)) {
|
|
115
|
+
logged.add(original)
|
|
116
|
+
this.#log(error, original, ctx, undefined)
|
|
117
|
+
}
|
|
118
|
+
result[index] = {
|
|
119
|
+
message: this.#expose && original instanceof Error ? original.message : MASKED_MESSAGE,
|
|
120
|
+
...located(error),
|
|
121
|
+
extensions: { code: GraphQLErrorCode.InternalServerError },
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return result
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The exception shown for `original`, `undefined` when it must be masked. */
|
|
128
|
+
#exposed(original: unknown, ctx: GraphQLContext): GraphQLException | undefined {
|
|
129
|
+
if (original instanceof GraphQLException) return original
|
|
130
|
+
for (const resolvable of this.#mappers) {
|
|
131
|
+
let mapped: GraphQLException | undefined
|
|
132
|
+
try {
|
|
133
|
+
mapped = resolveIn(this.#container, resolvable).map(original, ctx)
|
|
134
|
+
} catch (error) {
|
|
135
|
+
this.#logger.error('GraphQLPlugin: an error mapper failed', { err: error, requestId: ctx.requestId })
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
if (mapped !== undefined) return mapped
|
|
139
|
+
}
|
|
140
|
+
if (isHttpException(original)) {
|
|
141
|
+
const status = original.status
|
|
142
|
+
if (original instanceof ValidationException) {
|
|
143
|
+
return new GraphQLException(original.message, {
|
|
144
|
+
code: GraphQLErrorCode.BadUserInput,
|
|
145
|
+
extensions: { issues: original.details },
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
if (status >= 500) return undefined
|
|
149
|
+
return new GraphQLException(original.message, {
|
|
150
|
+
code: STATUS_CODES.get(status) ?? GraphQLErrorCode.BadRequest,
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
return undefined
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Logs an unexpected error, or the cause of a presented one (`code`), at `error` level. */
|
|
157
|
+
#log(error: GraphQLError, cause: unknown, ctx: GraphQLContext, code: string | undefined): void {
|
|
158
|
+
const coordinate = coordinateOf(this.#schema, ctx.document, ctx.operationName, error.path)
|
|
159
|
+
const what = code === undefined ? 'unexpected error' : `${code} error`
|
|
160
|
+
this.#logger.error(`GraphQL: ${what}${coordinate === undefined ? '' : ` in ${coordinate}`}`, {
|
|
161
|
+
err: cause,
|
|
162
|
+
requestId: ctx.requestId,
|
|
163
|
+
...(code === undefined ? {} : { code }),
|
|
164
|
+
...(coordinate === undefined ? {} : { coordinate }),
|
|
165
|
+
...(error.path === undefined ? {} : { path: error.path.join('.') }),
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* What graphql-js raises while completing a resolver's value: a built-in scalar or an enum that
|
|
172
|
+
* cannot represent it (`String cannot represent value: { passwordHash: ... }`), a list value that
|
|
173
|
+
* is not iterable, a union or interface value that resolves to no member. A server bug whose message
|
|
174
|
+
* holds the value, masked and logged like any unexpected error. graphql-js throws plain
|
|
175
|
+
* `GraphQLError`s without code there, told apart from the ones of user code by their message.
|
|
176
|
+
*/
|
|
177
|
+
const COMPLETION_ERROR =
|
|
178
|
+
/^(?:(?:Int|Float|String|Boolean|ID) cannot represent |Enum "[^"]*" cannot represent |Expected Iterable, but did not find one for field "|Abstract type "|Runtime Object type ")/
|
|
179
|
+
|
|
180
|
+
function isCompletionError(error: GraphQLError): boolean {
|
|
181
|
+
return (
|
|
182
|
+
error.constructor === GraphQLError && error.extensions['code'] === undefined && COMPLETION_ERROR.test(error.message)
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function located(error: GraphQLError): Pick<GraphQLFormattedError, 'locations' | 'path'> {
|
|
187
|
+
return {
|
|
188
|
+
...(error.locations === undefined ? {} : { locations: error.locations }),
|
|
189
|
+
...(error.path === undefined ? {} : { path: error.path }),
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** `{ message, locations, path, extensions: { ...extensions, code } }`. */
|
|
194
|
+
function render(error: GraphQLError, exception: GraphQLException): GraphQLFormattedError {
|
|
195
|
+
return {
|
|
196
|
+
message: exception.message,
|
|
197
|
+
...located(error),
|
|
198
|
+
extensions: { ...exception.extensions, code: exception.code },
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A graphql-js error formatted with `extensions.code` set to `code` (kept as is without code). */
|
|
203
|
+
export function withCode(error: GraphQLError, code: string | undefined): GraphQLFormattedError {
|
|
204
|
+
const formatted = error.toJSON()
|
|
205
|
+
if (code === undefined) return formatted
|
|
206
|
+
return { ...formatted, extensions: { ...formatted.extensions, code } }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A request error (no location): `{ message, extensions: { code } }`. */
|
|
210
|
+
export function requestError(
|
|
211
|
+
message: string,
|
|
212
|
+
code: string,
|
|
213
|
+
nodes?: ASTNode | readonly ASTNode[],
|
|
214
|
+
): GraphQLFormattedError {
|
|
215
|
+
return withCode(new GraphQLError(message, nodes === undefined ? {} : { nodes }), code)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The schema coordinate (`Book.author`) of the field at `path`, found by walking the operation's
|
|
220
|
+
* selections (aliases, fragments, inline fragments). Best effort, for logs: `undefined` when the
|
|
221
|
+
* path crosses a union without a type condition.
|
|
222
|
+
*/
|
|
223
|
+
export function coordinateOf(
|
|
224
|
+
schema: GraphQLSchema,
|
|
225
|
+
document: DocumentNode,
|
|
226
|
+
operationName: string | undefined,
|
|
227
|
+
path: readonly (string | number)[] | undefined,
|
|
228
|
+
): string | undefined {
|
|
229
|
+
if (path === undefined || path.length === 0) return undefined
|
|
230
|
+
const fragments = new Map<string, FragmentDefinitionNode>()
|
|
231
|
+
let operation: OperationDefinitionNode | undefined
|
|
232
|
+
for (const definition of document.definitions) {
|
|
233
|
+
if (definition.kind === Kind.FRAGMENT_DEFINITION) fragments.set(definition.name.value, definition)
|
|
234
|
+
else if (
|
|
235
|
+
definition.kind === Kind.OPERATION_DEFINITION &&
|
|
236
|
+
(operationName === undefined || definition.name?.value === operationName)
|
|
237
|
+
) {
|
|
238
|
+
operation ??= definition
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (operation === undefined) return undefined
|
|
242
|
+
let type: GraphQLNamedType | undefined | null = schema.getRootType(operation.operation)
|
|
243
|
+
let selections: SelectionSetNode[] = [operation.selectionSet]
|
|
244
|
+
let coordinate: string | undefined
|
|
245
|
+
for (const segment of path) {
|
|
246
|
+
if (typeof segment === 'number') continue
|
|
247
|
+
const found = findField(selections, segment, fragments, type?.name)
|
|
248
|
+
if (found === undefined || type === undefined || type === null) return undefined
|
|
249
|
+
const parent: GraphQLNamedType | undefined | null =
|
|
250
|
+
found.typeCondition === undefined ? type : schema.getType(found.typeCondition)
|
|
251
|
+
if (!isObjectType(parent)) return undefined
|
|
252
|
+
const field: GraphQLField<unknown, unknown> | undefined = parent.getFields()[found.field.name.value]
|
|
253
|
+
coordinate = `${parent.name}.${found.field.name.value}`
|
|
254
|
+
if (field === undefined) return coordinate
|
|
255
|
+
type = getNamedType(field.type)
|
|
256
|
+
selections = found.field.selectionSet === undefined ? [] : [found.field.selectionSet]
|
|
257
|
+
}
|
|
258
|
+
return coordinate
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function findField(
|
|
262
|
+
sets: readonly SelectionSetNode[],
|
|
263
|
+
key: string,
|
|
264
|
+
fragments: ReadonlyMap<string, FragmentDefinitionNode>,
|
|
265
|
+
typeCondition: string | undefined,
|
|
266
|
+
): { readonly field: FieldNode; readonly typeCondition: string | undefined } | undefined {
|
|
267
|
+
for (const set of sets) {
|
|
268
|
+
for (const selection of set.selections) {
|
|
269
|
+
if (selection.kind === Kind.FIELD) {
|
|
270
|
+
if ((selection.alias ?? selection.name).value === key) return { field: selection, typeCondition }
|
|
271
|
+
} else {
|
|
272
|
+
const nested = selection.kind === Kind.INLINE_FRAGMENT ? selection : fragments.get(selection.name.value)
|
|
273
|
+
if (nested === undefined) continue
|
|
274
|
+
const found = findField(
|
|
275
|
+
[nested.selectionSet],
|
|
276
|
+
key,
|
|
277
|
+
fragments,
|
|
278
|
+
nested.typeCondition?.name.value ?? typeCondition,
|
|
279
|
+
)
|
|
280
|
+
if (found !== undefined) return found
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return undefined
|
|
285
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@znight/graphql`: code-first GraphQL whose schema is derived from TypeScript types at build time
|
|
3
|
+
* (RFC 0005). Resolvers are providers marked with `@Resolver`, `@Query`, `@Mutation`,
|
|
4
|
+
* `@Subscription` and `@ResolveField`; the compiler extension (`@znight/graphql/compiler`) generates
|
|
5
|
+
* `metadata.extensions.graphql`, served by `GraphQLPlugin` on graphql-js 17.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type { StandardSchemaV1 } from '@znight/http'
|
|
9
|
+
export type { Float, ID, Int, JSONValue, ResolverOptions } from './decorators.ts'
|
|
10
|
+
export type { GraphQLRequest, GraphQLResultStream } from './execution.ts'
|
|
11
|
+
export type {
|
|
12
|
+
AnyPubSub,
|
|
13
|
+
GraphQLErrorMapperRef,
|
|
14
|
+
GraphQLGuardRef,
|
|
15
|
+
GraphQLHttpOptions,
|
|
16
|
+
GraphQLLimits,
|
|
17
|
+
GraphQLPluginOptions,
|
|
18
|
+
GraphQLSubscriptionOptions,
|
|
19
|
+
} from './options.ts'
|
|
20
|
+
export type {
|
|
21
|
+
BatchLoad,
|
|
22
|
+
BatchLoadClass,
|
|
23
|
+
BatchLoader,
|
|
24
|
+
GraphQLArgs,
|
|
25
|
+
GraphQLContext,
|
|
26
|
+
GraphQLErrorMapper,
|
|
27
|
+
GraphQLErrorMapperClass,
|
|
28
|
+
GraphQLGuard,
|
|
29
|
+
GraphQLGuardClass,
|
|
30
|
+
GraphQLOperationKind,
|
|
31
|
+
GraphQLTransport,
|
|
32
|
+
LoaderKey,
|
|
33
|
+
LoaderOptions,
|
|
34
|
+
LoaderValue,
|
|
35
|
+
ResolverContext,
|
|
36
|
+
ScalarDefinition,
|
|
37
|
+
} from './types.ts'
|
|
38
|
+
|
|
39
|
+
/** `Loader` is both the `@Loader()` parameter decorator and the `Loader<L>` parameter type. */
|
|
40
|
+
export {
|
|
41
|
+
Args,
|
|
42
|
+
Ctx,
|
|
43
|
+
Info,
|
|
44
|
+
Loader,
|
|
45
|
+
Mutation,
|
|
46
|
+
Parent,
|
|
47
|
+
Query,
|
|
48
|
+
ResolveField,
|
|
49
|
+
Resolver,
|
|
50
|
+
Subscription,
|
|
51
|
+
scalar,
|
|
52
|
+
UseGuard,
|
|
53
|
+
} from './decorators.ts'
|
|
54
|
+
export {
|
|
55
|
+
GraphQLErrorCode,
|
|
56
|
+
type GraphQLErrorCodeValue,
|
|
57
|
+
GraphQLException,
|
|
58
|
+
type GraphQLExceptionOptions,
|
|
59
|
+
} from './errors.ts'
|
|
60
|
+
export { type OrderByKeysOptions, orderByKeys } from './loaders.ts'
|
|
61
|
+
export { MemoryPubSub, type MemoryPubSubOptions } from './memory-pubsub.ts'
|
|
62
|
+
export {
|
|
63
|
+
GRAPHQL_METADATA_VERSION,
|
|
64
|
+
type GraphQLArgumentDefinition,
|
|
65
|
+
type GraphQLBuiltinScalarDefinition,
|
|
66
|
+
type GraphQLCustomScalarDefinition,
|
|
67
|
+
type GraphQLDiscriminator,
|
|
68
|
+
type GraphQLEnumTypeDefinition,
|
|
69
|
+
type GraphQLEnumValueDefinition,
|
|
70
|
+
type GraphQLFieldDefinition,
|
|
71
|
+
type GraphQLInputFieldDefinition,
|
|
72
|
+
type GraphQLInputTypeDefinition,
|
|
73
|
+
type GraphQLMetadata,
|
|
74
|
+
type GraphQLObjectTypeDefinition,
|
|
75
|
+
type GraphQLResolverBinding,
|
|
76
|
+
type GraphQLResolverOperation,
|
|
77
|
+
type GraphQLRootTypes,
|
|
78
|
+
type GraphQLTypeDefinition,
|
|
79
|
+
type GraphQLUnionTypeDefinition,
|
|
80
|
+
} from './metadata.ts'
|
|
81
|
+
export { type DirectExecuteOptions, GraphQLPlugin, type MaybePromise } from './plugin.ts'
|
|
82
|
+
export { PubSub, type SubscribeOptions } from './pubsub.ts'
|
|
83
|
+
export { GraphQLTokens } from './tokens.ts'
|
|
84
|
+
export { validateArgs } from './validate-args.ts'
|
package/src/limits.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document limits (RFC 0005 §8.9): depth, aliases and directives, checked in one walk of the parsed
|
|
3
|
+
* document **before** the specified validation rules, so that an abusive document is rejected
|
|
4
|
+
* before rules whose cost grows with it (`OverlappingFieldsCanBeMerged`) run. Aliases are counted as
|
|
5
|
+
* written, and, when the document spreads fragments, as each operation executes them (a fragment
|
|
6
|
+
* spread several times counts each time). `maxTokens` is enforced by `parse()` itself.
|
|
7
|
+
*/
|
|
8
|
+
import type { DirectiveNode, DocumentNode, FragmentDefinitionNode, SelectionSetNode } from 'graphql'
|
|
9
|
+
|
|
10
|
+
import { GraphQLError, Kind } from 'graphql'
|
|
11
|
+
|
|
12
|
+
import { GraphQLErrorCode } from './errors.ts'
|
|
13
|
+
|
|
14
|
+
/** Resolved limits: `undefined` disables one. */
|
|
15
|
+
export interface DocumentLimits {
|
|
16
|
+
readonly maxDepth: number | undefined
|
|
17
|
+
readonly maxAliases: number | undefined
|
|
18
|
+
readonly maxDirectives: number | undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The limit errors of `document` (`QUERY_TOO_COMPLEX`), `undefined` when it is within its limits. */
|
|
22
|
+
export function checkLimits(document: DocumentNode, limits: DocumentLimits): GraphQLError[] | undefined {
|
|
23
|
+
const { maxDepth, maxAliases, maxDirectives } = limits
|
|
24
|
+
if (maxDepth === undefined && maxAliases === undefined && maxDirectives === undefined) return undefined
|
|
25
|
+
const counter = { aliases: 0, directives: 0, spreads: 0 }
|
|
26
|
+
const fragments = new Map<string, FragmentDefinitionNode>()
|
|
27
|
+
for (const definition of document.definitions) {
|
|
28
|
+
if (definition.kind === Kind.FRAGMENT_DEFINITION) fragments.set(definition.name.value, definition)
|
|
29
|
+
}
|
|
30
|
+
let errors: GraphQLError[] | undefined
|
|
31
|
+
const fail = (message: string): void => {
|
|
32
|
+
errors ??= []
|
|
33
|
+
errors.push(new GraphQLError(message, { extensions: { code: GraphQLErrorCode.QueryTooComplex } }))
|
|
34
|
+
}
|
|
35
|
+
// counts every selection once, where it is written (fragment spreads expanded below for aliases)
|
|
36
|
+
for (const definition of document.definitions) {
|
|
37
|
+
if (definition.kind === Kind.OPERATION_DEFINITION || definition.kind === Kind.FRAGMENT_DEFINITION) {
|
|
38
|
+
countDirectives(definition.directives, counter)
|
|
39
|
+
if (definition.kind === Kind.OPERATION_DEFINITION) {
|
|
40
|
+
for (const variable of definition.variableDefinitions ?? []) countDirectives(variable.directives, counter)
|
|
41
|
+
}
|
|
42
|
+
count(definition.selectionSet, counter)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (maxAliases !== undefined) {
|
|
46
|
+
if (counter.aliases > maxAliases) {
|
|
47
|
+
fail(`The document has ${counter.aliases} aliases, more than the limit of ${maxAliases}.`)
|
|
48
|
+
} else if (counter.spreads > 0) {
|
|
49
|
+
// a fragment spread several times executes its aliases each time: counted as executed
|
|
50
|
+
const memo = new Map<string, number>()
|
|
51
|
+
for (const definition of document.definitions) {
|
|
52
|
+
if (definition.kind !== Kind.OPERATION_DEFINITION) continue
|
|
53
|
+
const executed = aliasesOf(definition.selectionSet, fragments, memo, new Set(), maxAliases + 1)
|
|
54
|
+
if (executed > maxAliases) {
|
|
55
|
+
const name = definition.name === undefined ? 'The operation' : `The operation ${definition.name.value}`
|
|
56
|
+
fail(`${name} has more than ${maxAliases} aliases once its fragments are expanded.`)
|
|
57
|
+
break
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (maxDirectives !== undefined && counter.directives > maxDirectives) {
|
|
63
|
+
fail(`The document has ${counter.directives} directives, more than the limit of ${maxDirectives}.`)
|
|
64
|
+
}
|
|
65
|
+
if (maxDepth !== undefined) {
|
|
66
|
+
const depths = new Map<string, number>()
|
|
67
|
+
for (const definition of document.definitions) {
|
|
68
|
+
if (definition.kind !== Kind.OPERATION_DEFINITION) continue
|
|
69
|
+
const depth = depthOf(definition.selectionSet, fragments, depths, new Set())
|
|
70
|
+
if (depth > maxDepth) {
|
|
71
|
+
const name = definition.name === undefined ? 'The operation' : `The operation ${definition.name.value}`
|
|
72
|
+
fail(`${name} is ${depth} levels deep, more than the limit of ${maxDepth}.`)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return errors
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface Counter {
|
|
80
|
+
aliases: number
|
|
81
|
+
directives: number
|
|
82
|
+
spreads: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function countDirectives(directives: readonly DirectiveNode[] | undefined, counter: Counter): void {
|
|
86
|
+
if (directives !== undefined) counter.directives += directives.length
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function count(set: SelectionSetNode, counter: Counter): void {
|
|
90
|
+
for (const selection of set.selections) {
|
|
91
|
+
countDirectives(selection.directives, counter)
|
|
92
|
+
if (selection.kind === Kind.FIELD) {
|
|
93
|
+
if (selection.alias !== undefined) counter.aliases++
|
|
94
|
+
if (selection.selectionSet !== undefined) count(selection.selectionSet, counter)
|
|
95
|
+
} else if (selection.kind === Kind.INLINE_FRAGMENT) {
|
|
96
|
+
count(selection.selectionSet, counter)
|
|
97
|
+
} else {
|
|
98
|
+
counter.spreads++
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Aliases a selection set executes, fragment spreads expanded (`a: f { ...F } b: f { ...F }` counts
|
|
105
|
+
* the aliases of `F` twice). Fragment counts are memoised, so the walk stays linear in the document;
|
|
106
|
+
* counts saturate at `cap` (one more than the limit) and a fragment cycle counts 0 (validation
|
|
107
|
+
* rejects it).
|
|
108
|
+
*/
|
|
109
|
+
function aliasesOf(
|
|
110
|
+
set: SelectionSetNode,
|
|
111
|
+
fragments: ReadonlyMap<string, FragmentDefinitionNode>,
|
|
112
|
+
memo: Map<string, number>,
|
|
113
|
+
visiting: Set<string>,
|
|
114
|
+
cap: number,
|
|
115
|
+
): number {
|
|
116
|
+
let total = 0
|
|
117
|
+
for (const selection of set.selections) {
|
|
118
|
+
if (selection.kind === Kind.FIELD) {
|
|
119
|
+
if (selection.alias !== undefined) total++
|
|
120
|
+
if (selection.selectionSet !== undefined)
|
|
121
|
+
total += aliasesOf(selection.selectionSet, fragments, memo, visiting, cap)
|
|
122
|
+
} else if (selection.kind === Kind.INLINE_FRAGMENT) {
|
|
123
|
+
total += aliasesOf(selection.selectionSet, fragments, memo, visiting, cap)
|
|
124
|
+
} else {
|
|
125
|
+
const name = selection.name.value
|
|
126
|
+
let known = memo.get(name)
|
|
127
|
+
if (known === undefined) {
|
|
128
|
+
const fragment = fragments.get(name)
|
|
129
|
+
if (fragment === undefined || visiting.has(name)) continue
|
|
130
|
+
visiting.add(name)
|
|
131
|
+
known = aliasesOf(fragment.selectionSet, fragments, memo, visiting, cap)
|
|
132
|
+
visiting.delete(name)
|
|
133
|
+
memo.set(name, known)
|
|
134
|
+
}
|
|
135
|
+
total += known
|
|
136
|
+
}
|
|
137
|
+
if (total >= cap) return cap
|
|
138
|
+
}
|
|
139
|
+
return total
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Field nesting of a selection set (`{ a { b } }` is 2). Introspection fields (`__schema`,
|
|
144
|
+
* `__type`) do not count: `MaxIntrospectionDepthRule` bounds them. Fragment depths are memoised; a
|
|
145
|
+
* fragment cycle counts 0 (validation rejects it).
|
|
146
|
+
*/
|
|
147
|
+
function depthOf(
|
|
148
|
+
set: SelectionSetNode,
|
|
149
|
+
fragments: ReadonlyMap<string, FragmentDefinitionNode>,
|
|
150
|
+
depths: Map<string, number>,
|
|
151
|
+
visiting: Set<string>,
|
|
152
|
+
): number {
|
|
153
|
+
let max = 0
|
|
154
|
+
for (const selection of set.selections) {
|
|
155
|
+
let depth = 0
|
|
156
|
+
if (selection.kind === Kind.FIELD) {
|
|
157
|
+
if (selection.name.value.startsWith('__')) continue
|
|
158
|
+
depth =
|
|
159
|
+
selection.selectionSet === undefined ? 1 : 1 + depthOf(selection.selectionSet, fragments, depths, visiting)
|
|
160
|
+
} else if (selection.kind === Kind.INLINE_FRAGMENT) {
|
|
161
|
+
depth = depthOf(selection.selectionSet, fragments, depths, visiting)
|
|
162
|
+
} else {
|
|
163
|
+
const name = selection.name.value
|
|
164
|
+
const known = depths.get(name)
|
|
165
|
+
if (known === undefined) {
|
|
166
|
+
const fragment = fragments.get(name)
|
|
167
|
+
if (fragment === undefined || visiting.has(name)) continue
|
|
168
|
+
visiting.add(name)
|
|
169
|
+
depth = depthOf(fragment.selectionSet, fragments, depths, visiting)
|
|
170
|
+
visiting.delete(name)
|
|
171
|
+
depths.set(name, depth)
|
|
172
|
+
} else depth = known
|
|
173
|
+
}
|
|
174
|
+
if (depth > max) max = depth
|
|
175
|
+
}
|
|
176
|
+
return max
|
|
177
|
+
}
|
package/src/loaders.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loaders (RFC 0005 §5.5, §8.6): the DataLoader of a `BatchLoad` provider for one execution, and
|
|
3
|
+
* `orderByKeys()`.
|
|
4
|
+
*
|
|
5
|
+
* The registry of an execution (`Map<token, DataLoader>`) lives in its `GraphQLContext` and is
|
|
6
|
+
* created on the first `loader()` call: operations that use no loader allocate nothing.
|
|
7
|
+
*/
|
|
8
|
+
import type { Container, Token } from '@znight/core'
|
|
9
|
+
import type { BatchLoad, BatchLoader, LoaderOptions } from './types.ts'
|
|
10
|
+
|
|
11
|
+
import { ErrorCode, ZnightError } from '@znight/core'
|
|
12
|
+
import DataLoader from 'dataloader'
|
|
13
|
+
|
|
14
|
+
import { GraphQLErrorCode, GraphQLException } from './errors.ts'
|
|
15
|
+
|
|
16
|
+
/** DataLoader options of a loader class, read once. */
|
|
17
|
+
type ResolvedLoaderOptions = DataLoader.Options<unknown, unknown, unknown>
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Creates the per-execution DataLoaders of loader providers: the provider is resolved in the
|
|
21
|
+
* current scope (loaders may be request-scoped), its `options` are read once per token.
|
|
22
|
+
*/
|
|
23
|
+
export class LoaderFactory {
|
|
24
|
+
readonly #container: Container
|
|
25
|
+
readonly #options = new Map<Token, ResolvedLoaderOptions>()
|
|
26
|
+
|
|
27
|
+
constructor(container: Container) {
|
|
28
|
+
this.#container = container
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A new DataLoader for the loader provider `token`, bound to the instance of the current scope. */
|
|
32
|
+
create(token: Token): BatchLoader<unknown, unknown> {
|
|
33
|
+
const provider = this.#container.get(token) as Partial<BatchLoad<unknown, unknown>> | undefined
|
|
34
|
+
if (provider === null || typeof provider !== 'object' || typeof provider.load !== 'function') {
|
|
35
|
+
throw new ZnightError(
|
|
36
|
+
ErrorCode.InvalidLoader,
|
|
37
|
+
`The loader ${token} does not implement BatchLoad (no load(keys) method)`,
|
|
38
|
+
{ hints: ['A loader is a provider implementing BatchLoad<K, V>: async load(keys) { ... }'] },
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
let options = this.#options.get(token)
|
|
42
|
+
if (options === undefined) {
|
|
43
|
+
options = readOptions(token, provider.options)
|
|
44
|
+
this.#options.set(token, options)
|
|
45
|
+
}
|
|
46
|
+
return new DataLoader<unknown, unknown, unknown>(
|
|
47
|
+
keys => loadBatch(token, keys, provider as BatchLoad<unknown, unknown>),
|
|
48
|
+
options,
|
|
49
|
+
) as BatchLoader<unknown, unknown>
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The values of a batch, or ZNIGHT065 (failing every key) when there is not one value per key.
|
|
55
|
+
* `async` so that a `load()` throwing synchronously rejects with its own error (DataLoader would
|
|
56
|
+
* replace it with a TypeError).
|
|
57
|
+
*/
|
|
58
|
+
async function loadBatch(
|
|
59
|
+
token: Token,
|
|
60
|
+
keys: readonly unknown[],
|
|
61
|
+
provider: BatchLoad<unknown, unknown>,
|
|
62
|
+
): Promise<readonly unknown[]> {
|
|
63
|
+
const values: unknown = await provider.load(keys)
|
|
64
|
+
if (Array.isArray(values) && values.length === keys.length) return values
|
|
65
|
+
throw new ZnightError(
|
|
66
|
+
ErrorCode.InvalidLoader,
|
|
67
|
+
`The loader ${token} returned ${Array.isArray(values) ? `${values.length} values` : 'no array'} for ${keys.length} keys`,
|
|
68
|
+
{ hints: ['load(keys) returns one value (or Error) per key, in the order of the keys: use orderByKeys()'] },
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readOptions(token: Token, options: LoaderOptions | undefined): ResolvedLoaderOptions {
|
|
73
|
+
if (options === undefined) return {}
|
|
74
|
+
const invalid = (reason: string): ZnightError =>
|
|
75
|
+
new ZnightError(ErrorCode.InvalidLoader, `Invalid options of the loader ${token}: ${reason}`, {
|
|
76
|
+
hints: ['options: { maxBatchSize?: positive integer, cache?: boolean, cacheKeyFn?: (key) => unknown }'],
|
|
77
|
+
})
|
|
78
|
+
if (options === null || typeof options !== 'object') throw invalid('expected an object')
|
|
79
|
+
const { maxBatchSize, cache, cacheKeyFn } = options
|
|
80
|
+
if (maxBatchSize !== undefined && !(Number.isSafeInteger(maxBatchSize) && maxBatchSize >= 1)) {
|
|
81
|
+
throw invalid(`maxBatchSize must be a positive integer (received ${String(maxBatchSize)})`)
|
|
82
|
+
}
|
|
83
|
+
if (cache !== undefined && typeof cache !== 'boolean') throw invalid('cache must be a boolean')
|
|
84
|
+
if (cacheKeyFn !== undefined && typeof cacheKeyFn !== 'function') throw invalid('cacheKeyFn must be a function')
|
|
85
|
+
return {
|
|
86
|
+
...(maxBatchSize === undefined ? {} : { maxBatchSize }),
|
|
87
|
+
...(cache === undefined ? {} : { cache }),
|
|
88
|
+
...(cacheKeyFn === undefined ? {} : { cacheKeyFn: (key: unknown) => cacheKeyFn.call(options, key) }),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Options of {@link orderByKeys}. */
|
|
93
|
+
export interface OrderByKeysOptions {
|
|
94
|
+
/**
|
|
95
|
+
* What a key without value gets: `'error'` (default) a `NOT_FOUND` `GraphQLException` failing that
|
|
96
|
+
* key only, `'null'` the value `null`.
|
|
97
|
+
*/
|
|
98
|
+
readonly missing?: 'error' | 'null'
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Reorders `values` (rows in any order, some keys possibly missing) to match `keys`, what a
|
|
103
|
+
* `BatchLoad.load()` must return. Keys are compared with `Map` semantics (`===`, `NaN` equal).
|
|
104
|
+
*
|
|
105
|
+
* ```ts
|
|
106
|
+
* async load(ids: readonly ID[]): Promise<readonly (Author | Error)[]> {
|
|
107
|
+
* return orderByKeys(ids, await this.authors.findMany(ids), author => author.id)
|
|
108
|
+
* }
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export function orderByKeys<K, V>(
|
|
112
|
+
keys: readonly K[],
|
|
113
|
+
values: Iterable<V>,
|
|
114
|
+
keyOf: (value: V) => K,
|
|
115
|
+
options?: OrderByKeysOptions & { readonly missing?: 'error' },
|
|
116
|
+
): (V | Error)[]
|
|
117
|
+
export function orderByKeys<K, V>(
|
|
118
|
+
keys: readonly K[],
|
|
119
|
+
values: Iterable<V>,
|
|
120
|
+
keyOf: (value: V) => K,
|
|
121
|
+
options: OrderByKeysOptions & { readonly missing: 'null' },
|
|
122
|
+
): (V | null)[]
|
|
123
|
+
export function orderByKeys<K, V>(
|
|
124
|
+
keys: readonly K[],
|
|
125
|
+
values: Iterable<V>,
|
|
126
|
+
keyOf: (value: V) => K,
|
|
127
|
+
options: OrderByKeysOptions = {},
|
|
128
|
+
): (V | Error | null)[] {
|
|
129
|
+
const missing = options.missing ?? 'error'
|
|
130
|
+
if (missing !== 'error' && missing !== 'null') {
|
|
131
|
+
throw new TypeError(`orderByKeys(): missing must be 'error' or 'null' (received ${String(missing)})`)
|
|
132
|
+
}
|
|
133
|
+
const byKey = new Map<K, V>()
|
|
134
|
+
for (const value of values) byKey.set(keyOf(value), value)
|
|
135
|
+
const result: (V | Error | null)[] = new Array(keys.length)
|
|
136
|
+
for (let index = 0; index < keys.length; index++) {
|
|
137
|
+
const key = keys[index] as K
|
|
138
|
+
const value = byKey.get(key)
|
|
139
|
+
if (value !== undefined || byKey.has(key)) result[index] = value as V
|
|
140
|
+
else result[index] = missing === 'null' ? null : notFound(key)
|
|
141
|
+
}
|
|
142
|
+
return result
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function notFound(key: unknown): GraphQLException {
|
|
146
|
+
return new GraphQLException(`Not found: ${typeof key === 'string' ? key : String(key)}`, {
|
|
147
|
+
code: GraphQLErrorCode.NotFound,
|
|
148
|
+
})
|
|
149
|
+
}
|