@znight/graphql 0.2.2 → 0.4.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/README.md +262 -17
- package/dist/compiler.d.mts +21 -1
- package/dist/compiler.mjs +116 -15
- package/dist/compiler.mjs.map +1 -1
- package/dist/{types-DQ713UgI.d.mts → driver-DgbpFj4-.d.mts} +193 -9
- package/dist/http-BWcayUtH.mjs +2165 -0
- package/dist/http-BWcayUtH.mjs.map +1 -0
- package/dist/index.d.mts +52 -51
- package/dist/index.mjs +2 -1
- package/dist/index.mjs.map +1 -1
- package/dist/names-BlzDM5cF.mjs.map +1 -1
- package/dist/{plugin-CR_wVfRU.mjs → plugin-BssB7wtn.mjs} +242 -1778
- package/dist/plugin-BssB7wtn.mjs.map +1 -0
- package/dist/testing.d.mts +40 -1
- package/dist/testing.mjs +649 -4
- package/dist/testing.mjs.map +1 -1
- package/dist/yoga.d.mts +64 -0
- package/dist/yoga.mjs +379 -0
- package/dist/yoga.mjs.map +1 -0
- package/package.json +19 -10
- package/src/compiler/analyze.ts +27 -0
- package/src/compiler/data.ts +20 -1
- package/src/compiler/generate.ts +137 -19
- package/src/conformance.ts +700 -0
- package/src/context.ts +13 -0
- package/src/driver.ts +63 -0
- package/src/engine.ts +146 -0
- package/src/execution.ts +602 -67
- package/src/index.ts +7 -0
- package/src/limits.ts +22 -1
- package/src/metadata.ts +20 -4
- package/src/options.ts +53 -4
- package/src/pipeline.ts +72 -9
- package/src/plugin.ts +172 -29
- package/src/testing.ts +3 -0
- package/src/transport/builtin.ts +57 -0
- package/src/transport/fetch.ts +57 -0
- package/src/transport/http.ts +12 -9
- package/src/types.ts +22 -4
- package/src/yoga.ts +471 -0
- package/dist/plugin-CR_wVfRU.mjs.map +0 -1
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The driver conformance suite (RFC 0009 §5.4, §12): the same scenarios against the built-in
|
|
3
|
+
* transport and every route-mounted driver, on the HTTP adapters given, through the whole
|
|
4
|
+
* `HttpPlugin` pipeline (`executeOperation()`, `subscribeOperation()`). Exported from
|
|
5
|
+
* `@znight/graphql/testing` as `describeDriverConformance()` for third-party drivers, and
|
|
6
|
+
* framework-neutral: the caller passes its `describe` / `it` / `beforeAll` / `afterAll` (`bun:test`,
|
|
7
|
+
* Vitest, Jest, `node:test`), the assertions are `node:assert`'s.
|
|
8
|
+
*
|
|
9
|
+
* The application is hand-written metadata (what the compiler generates): a catalog with a
|
|
10
|
+
* mutation publishing an event, a field resolved through a loader, a request-scoped session read by
|
|
11
|
+
* a field guard and by a resolver, a result mapper, an error to mask, subscriptions (a PubSub topic,
|
|
12
|
+
* a finite and an endless generator), an HTTP guard on the endpoint, introspection off and limits.
|
|
13
|
+
*/
|
|
14
|
+
// biome-ignore-all lint/suspicious/useExpect: a framework-neutral suite, asserting with node:assert
|
|
15
|
+
import type { CanActivate, HttpAdapter, HttpContext } from '@znight/http'
|
|
16
|
+
import type { GraphQLDriver } from './driver.ts'
|
|
17
|
+
import type { GraphQLResolverBinding, GraphQLResolverOperation, GraphQLTypeDefinition } from './metadata.ts'
|
|
18
|
+
import type { PubSub } from './pubsub.ts'
|
|
19
|
+
import type { OperationResponse, OperationResult, OperationStream } from './testing.ts'
|
|
20
|
+
import type { BatchLoad, GraphQLContext, GraphQLResultMapper } from './types.ts'
|
|
21
|
+
|
|
22
|
+
import { deepStrictEqual, ok, strictEqual } from 'node:assert/strict'
|
|
23
|
+
import { HttpPlugin, NotFoundException } from '@znight/http'
|
|
24
|
+
import { defineTestMetadata, TestApp, testToken } from '@znight/testing'
|
|
25
|
+
|
|
26
|
+
import { GraphQLPlugin } from './plugin.ts'
|
|
27
|
+
import { executeOperation, subscribeOperation } from './testing.ts'
|
|
28
|
+
|
|
29
|
+
/** The test functions of the framework running the suite. */
|
|
30
|
+
export interface ConformanceTestApi {
|
|
31
|
+
describe(name: string, body: () => void): unknown
|
|
32
|
+
it(name: string, body: () => Promise<void>): unknown
|
|
33
|
+
beforeAll(body: () => Promise<void>): unknown
|
|
34
|
+
afterAll(body: () => Promise<void>): unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Options of {@link describeDriverConformance}. */
|
|
38
|
+
export interface DriverConformanceOptions {
|
|
39
|
+
/**
|
|
40
|
+
* A new driver for each application the suite boots (a driver serves one application at a
|
|
41
|
+
* time); `undefined` for the built-in transport.
|
|
42
|
+
*/
|
|
43
|
+
readonly driver: () => GraphQLDriver | undefined
|
|
44
|
+
/** `describe`, `it`, `beforeAll` and `afterAll` of the test framework. */
|
|
45
|
+
readonly test: ConformanceTestApi
|
|
46
|
+
/** The adapters to run on, by name (`{ node: () => nodeAdapter(), hono: () => honoAdapter() }`). Default: `HttpPlugin`'s. */
|
|
47
|
+
readonly adapters?: Readonly<Record<string, () => HttpAdapter>>
|
|
48
|
+
/** Whether the driver serves subscriptions over SSE (`subscribeOperation()`). Default `true`. */
|
|
49
|
+
readonly subscriptions?: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Describes the conformance scenarios of a GraphQL driver (RFC 0009 §12): queries, mutations,
|
|
54
|
+
* field guards and HTTP guards, loaders batching, request-scoped providers, result mappers,
|
|
55
|
+
* masking, introspection, limits, the `graphql:operation` hook, `POST` bodies other than JSON and
|
|
56
|
+
* mutations over `GET` refused (CSRF) and, over SSE, subscriptions (events, completion, client
|
|
57
|
+
* abort, `maxActive`, shutdown whatever the plugin order).
|
|
58
|
+
*
|
|
59
|
+
* ```ts
|
|
60
|
+
* import { describeDriverConformance } from '@znight/graphql/testing'
|
|
61
|
+
* import { yogaDriver } from '@znight/graphql/yoga'
|
|
62
|
+
* import { afterAll, beforeAll, describe, it } from 'bun:test'
|
|
63
|
+
*
|
|
64
|
+
* describeDriverConformance({ driver: () => yogaDriver(), test: { describe, it, beforeAll, afterAll } })
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export function describeDriverConformance(options: DriverConformanceOptions): void {
|
|
68
|
+
const { test } = options
|
|
69
|
+
const adapters = Object.entries(options.adapters ?? { default: undefined })
|
|
70
|
+
const name = options.driver()?.name ?? 'builtin'
|
|
71
|
+
test.describe(`GraphQL driver conformance: ${name}`, () => {
|
|
72
|
+
for (const [adapterName, adapter] of adapters) {
|
|
73
|
+
test.describe(`${adapterName} adapter`, () => {
|
|
74
|
+
describeOperations(options, name, adapter)
|
|
75
|
+
if (options.subscriptions !== false) describeSubscriptions(options, adapter)
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ------------------------------------------------------------------------------------------------
|
|
82
|
+
// the application
|
|
83
|
+
|
|
84
|
+
interface Book {
|
|
85
|
+
readonly id: string
|
|
86
|
+
readonly title: string
|
|
87
|
+
readonly authorId: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface Author {
|
|
91
|
+
readonly id: string
|
|
92
|
+
readonly name: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface Events {
|
|
96
|
+
bookAdded: Book
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** What the resolvers observed, per application. */
|
|
100
|
+
interface Observed {
|
|
101
|
+
readonly batches: (readonly string[])[]
|
|
102
|
+
readonly ended: string[]
|
|
103
|
+
sessions: number
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const OBSERVED = Symbol('conformance.observed')
|
|
107
|
+
|
|
108
|
+
class Catalog {
|
|
109
|
+
readonly #pubsub: PubSub<Events>
|
|
110
|
+
readonly #books: Book[] = [
|
|
111
|
+
{ id: '1', title: 'Dune', authorId: 'herbert' },
|
|
112
|
+
{ id: '2', title: 'Hyperion', authorId: 'simmons' },
|
|
113
|
+
{ id: '3', title: 'Children of Dune', authorId: 'herbert' },
|
|
114
|
+
]
|
|
115
|
+
readonly [OBSERVED]: Observed = { batches: [], ended: [], sessions: 0 }
|
|
116
|
+
|
|
117
|
+
constructor(pubsub: PubSub<Events>) {
|
|
118
|
+
this.#pubsub = pubsub
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
books(): readonly Book[] {
|
|
122
|
+
return this.#books
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
book(args: { id: string }): Book {
|
|
126
|
+
const book = this.#books.find(candidate => candidate.id === args.id)
|
|
127
|
+
if (book === undefined) throw new NotFoundException(`No book ${args.id}`)
|
|
128
|
+
return book
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
secret(): string {
|
|
132
|
+
throw new Error('postgres://admin:secret@db')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async addBook(args: { title: string }): Promise<Book> {
|
|
136
|
+
const book = { id: String(this.#books.length + 1), title: args.title, authorId: 'herbert' }
|
|
137
|
+
this.#books.push(book)
|
|
138
|
+
await this.#pubsub.publish('bookAdded', book)
|
|
139
|
+
return book
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
bookAdded(ctx: GraphQLContext): AsyncIterableIterator<Book> {
|
|
143
|
+
return this.#pubsub.subscribe('bookAdded', { signal: ctx.signal })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async *countdown(args: { from: number }): AsyncGenerator<number> {
|
|
147
|
+
for (let n = args.from; n > 0; n--) yield await Promise.resolve(n)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async *forever(ctx: GraphQLContext): AsyncGenerator<number> {
|
|
151
|
+
const signal = ctx.signal
|
|
152
|
+
try {
|
|
153
|
+
for (let n = 1; !signal.aborted; n++) {
|
|
154
|
+
yield n
|
|
155
|
+
await new Promise<void>(resolve => {
|
|
156
|
+
const timer = setTimeout(resolve, 10)
|
|
157
|
+
signal.addEventListener(
|
|
158
|
+
'abort',
|
|
159
|
+
() => {
|
|
160
|
+
clearTimeout(timer)
|
|
161
|
+
resolve()
|
|
162
|
+
},
|
|
163
|
+
{ once: true },
|
|
164
|
+
)
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
this[OBSERVED].ended.push('forever')
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
class AuthorsById implements BatchLoad<string, Author> {
|
|
174
|
+
readonly #catalog: Catalog
|
|
175
|
+
|
|
176
|
+
constructor(catalog: Catalog) {
|
|
177
|
+
this.#catalog = catalog
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
load(keys: readonly string[]): Promise<readonly Author[]> {
|
|
181
|
+
this.#catalog[OBSERVED].batches.push([...keys])
|
|
182
|
+
return Promise.resolve(keys.map(id => ({ id, name: id === 'herbert' ? 'Frank Herbert' : 'Dan Simmons' })))
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Request-scoped: one per HTTP request, shared by the guard and the resolver of an operation. */
|
|
187
|
+
class Session {
|
|
188
|
+
readonly id: number
|
|
189
|
+
readonly user: string | undefined
|
|
190
|
+
|
|
191
|
+
constructor(http: HttpContext, catalog: Catalog) {
|
|
192
|
+
this.id = ++catalog[OBSERVED].sessions
|
|
193
|
+
this.user = http.header('x-user')
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
class Viewer {
|
|
198
|
+
readonly #session: Session
|
|
199
|
+
|
|
200
|
+
constructor(session: Session) {
|
|
201
|
+
this.#session = session
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
me(): { id: number; user: string | null } {
|
|
205
|
+
return { id: this.#session.id, user: this.#session.user ?? null }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A field guard: editors only, read from the request-scoped session. */
|
|
210
|
+
class EditorGuard {
|
|
211
|
+
readonly #session: Session
|
|
212
|
+
|
|
213
|
+
constructor(session: Session) {
|
|
214
|
+
this.#session = session
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
canResolve(): boolean {
|
|
218
|
+
return this.#session.user === 'editor'
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** An HTTP guard on the endpoint (`http.guards`). */
|
|
223
|
+
class Gate implements CanActivate {
|
|
224
|
+
canActivate(ctx: HttpContext): boolean {
|
|
225
|
+
return ctx.header('x-block') !== 'yes'
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Errors as data (RFC 0008): a missing book is a `NotFound` of `BookResult`. */
|
|
230
|
+
class Rejections implements GraphQLResultMapper {
|
|
231
|
+
static readonly types: readonly string[] = ['NotFound']
|
|
232
|
+
|
|
233
|
+
toResult(error: unknown): unknown {
|
|
234
|
+
return error instanceof NotFoundException ? { __typename: 'NotFound', message: error.message } : undefined
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
type Invoke = (resolver: never, parent: never, args: never, ctx: never) => unknown
|
|
239
|
+
|
|
240
|
+
/** The binding of a resolver method (what the compiler generates for `@Query()`, `@ResolveField()`...). */
|
|
241
|
+
function resolver(
|
|
242
|
+
cls: { readonly name: string },
|
|
243
|
+
method: string,
|
|
244
|
+
operation: GraphQLResolverOperation,
|
|
245
|
+
invoke: Invoke,
|
|
246
|
+
extra: Partial<GraphQLResolverBinding> = {},
|
|
247
|
+
): GraphQLResolverBinding {
|
|
248
|
+
return {
|
|
249
|
+
token: testToken(cls as never),
|
|
250
|
+
method,
|
|
251
|
+
operation,
|
|
252
|
+
guards: [],
|
|
253
|
+
meta: {},
|
|
254
|
+
invoke: invoke as unknown as GraphQLResolverBinding['invoke'],
|
|
255
|
+
...extra,
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const ID = [{ name: 'id', type: 'ID!' }]
|
|
260
|
+
|
|
261
|
+
const TYPES: readonly GraphQLTypeDefinition[] = [
|
|
262
|
+
{ kind: 'object', name: 'Author', fields: [{ name: 'name', type: 'String!' }] },
|
|
263
|
+
{
|
|
264
|
+
kind: 'object',
|
|
265
|
+
name: 'Book',
|
|
266
|
+
fields: [
|
|
267
|
+
{ name: 'id', type: 'ID!' },
|
|
268
|
+
{ name: 'title', type: 'String!' },
|
|
269
|
+
{
|
|
270
|
+
name: 'author',
|
|
271
|
+
type: 'Author!',
|
|
272
|
+
resolver: resolver(
|
|
273
|
+
Catalog,
|
|
274
|
+
'books',
|
|
275
|
+
'field',
|
|
276
|
+
((_r: Catalog, parent: Book, _a: unknown, ctx: GraphQLContext) =>
|
|
277
|
+
ctx.loader(testToken(AuthorsById)).load(parent.authorId)) as Invoke,
|
|
278
|
+
{ loaders: [testToken(AuthorsById)] },
|
|
279
|
+
),
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
},
|
|
283
|
+
{ kind: 'object', name: 'NotFound', fields: [{ name: 'message', type: 'String!' }] },
|
|
284
|
+
{ kind: 'union', name: 'BookResult', members: ['Book', 'NotFound'], discriminator: { by: 'typename' } },
|
|
285
|
+
{
|
|
286
|
+
kind: 'object',
|
|
287
|
+
name: 'Me',
|
|
288
|
+
fields: [
|
|
289
|
+
{ name: 'id', type: 'Int!' },
|
|
290
|
+
{ name: 'user', type: 'String' },
|
|
291
|
+
],
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
kind: 'object',
|
|
295
|
+
name: 'Query',
|
|
296
|
+
fields: [
|
|
297
|
+
{
|
|
298
|
+
name: 'books',
|
|
299
|
+
type: '[Book!]!',
|
|
300
|
+
resolver: resolver(Catalog, 'books', 'query', ((r: Catalog) => r.books()) as Invoke),
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
name: 'book',
|
|
304
|
+
type: 'BookResult!',
|
|
305
|
+
args: ID,
|
|
306
|
+
resolver: resolver(Catalog, 'book', 'query', ((r: Catalog, _p: unknown, a: { id: string }) => ({
|
|
307
|
+
__typename: 'Book',
|
|
308
|
+
...r.book(a),
|
|
309
|
+
})) as Invoke),
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
name: 'secret',
|
|
313
|
+
type: 'String',
|
|
314
|
+
resolver: resolver(Catalog, 'secret', 'query', ((r: Catalog) => r.secret()) as Invoke),
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
name: 'me',
|
|
318
|
+
type: 'Me!',
|
|
319
|
+
resolver: resolver(Viewer, 'me', 'query', ((r: Viewer) => r.me()) as Invoke),
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
kind: 'object',
|
|
325
|
+
name: 'Mutation',
|
|
326
|
+
fields: [
|
|
327
|
+
{
|
|
328
|
+
name: 'addBook',
|
|
329
|
+
type: 'Book!',
|
|
330
|
+
args: [{ name: 'title', type: 'String!' }],
|
|
331
|
+
resolver: resolver(
|
|
332
|
+
Catalog,
|
|
333
|
+
'addBook',
|
|
334
|
+
'mutation',
|
|
335
|
+
((r: Catalog, _p: unknown, a: { title: string }) => r.addBook(a)) as Invoke,
|
|
336
|
+
{ guards: [testToken(EditorGuard)] },
|
|
337
|
+
),
|
|
338
|
+
},
|
|
339
|
+
],
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
kind: 'object',
|
|
343
|
+
name: 'Subscription',
|
|
344
|
+
fields: [
|
|
345
|
+
{
|
|
346
|
+
name: 'bookAdded',
|
|
347
|
+
type: 'Book!',
|
|
348
|
+
resolver: resolver(Catalog, 'bookAdded', 'subscription', ((
|
|
349
|
+
r: Catalog,
|
|
350
|
+
_p: unknown,
|
|
351
|
+
_a: unknown,
|
|
352
|
+
c: GraphQLContext,
|
|
353
|
+
) => r.bookAdded(c)) as Invoke),
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
name: 'countdown',
|
|
357
|
+
type: 'Int!',
|
|
358
|
+
args: [{ name: 'from', type: 'Int!' }],
|
|
359
|
+
resolver: resolver(Catalog, 'countdown', 'subscription', ((r: Catalog, _p: unknown, a: { from: number }) =>
|
|
360
|
+
r.countdown(a)) as Invoke),
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
name: 'forever',
|
|
364
|
+
type: 'Int!',
|
|
365
|
+
resolver: resolver(Catalog, 'forever', 'subscription', ((
|
|
366
|
+
r: Catalog,
|
|
367
|
+
_p: unknown,
|
|
368
|
+
_a: unknown,
|
|
369
|
+
c: GraphQLContext,
|
|
370
|
+
) => r.forever(c)) as Invoke),
|
|
371
|
+
},
|
|
372
|
+
],
|
|
373
|
+
},
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
function conformanceMetadata() {
|
|
377
|
+
return defineTestMetadata({
|
|
378
|
+
providers: [
|
|
379
|
+
{ class: Catalog, deps: ['@znight/graphql#PubSub'] },
|
|
380
|
+
{ class: AuthorsById, deps: [Catalog] },
|
|
381
|
+
{ class: Session, scope: 'request', deps: ['@znight/http#HttpContext', Catalog] },
|
|
382
|
+
{ class: Viewer, scope: 'request', deps: [Session] },
|
|
383
|
+
{ class: EditorGuard, scope: 'request', deps: [Session] },
|
|
384
|
+
Gate,
|
|
385
|
+
Rejections,
|
|
386
|
+
],
|
|
387
|
+
externals: [
|
|
388
|
+
{ token: '@znight/graphql#GraphQLContext', scope: 'request', providedBy: 'GraphQLPlugin' },
|
|
389
|
+
{ token: '@znight/graphql#PubSub', providedBy: 'GraphQLPlugin' },
|
|
390
|
+
{ token: '@znight/graphql#GraphQLPlugin', providedBy: 'GraphQLPlugin' },
|
|
391
|
+
{ token: '@znight/http#HttpContext', scope: 'request', providedBy: 'HttpPlugin' },
|
|
392
|
+
{ token: '@znight/http#RequestId', scope: 'request', providedBy: 'HttpPlugin' },
|
|
393
|
+
],
|
|
394
|
+
extensions: {
|
|
395
|
+
graphql: {
|
|
396
|
+
version: 1,
|
|
397
|
+
schemaHash: 'c0nf0rmance00000',
|
|
398
|
+
roots: { query: 'Query', mutation: 'Mutation', subscription: 'Subscription' },
|
|
399
|
+
types: [...TYPES].sort((a, b) => (a.name < b.name ? -1 : 1)),
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The drain timeout of `HttpPlugin` in the suite: a shutdown waiting for it is a failure. */
|
|
406
|
+
const DRAIN_TIMEOUT_MS = 5_000
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Boots the conformance application on `adapter` with `driver`, `GraphQLPlugin` after `HttpPlugin`
|
|
410
|
+
* (or before it, `graphqlFirst`).
|
|
411
|
+
*/
|
|
412
|
+
function boot(
|
|
413
|
+
options: DriverConformanceOptions,
|
|
414
|
+
adapter: (() => HttpAdapter) | undefined,
|
|
415
|
+
graphqlFirst = false,
|
|
416
|
+
): Promise<TestApp> {
|
|
417
|
+
const driver = options.driver()
|
|
418
|
+
const http = HttpPlugin.configure({
|
|
419
|
+
port: 0,
|
|
420
|
+
host: '127.0.0.1',
|
|
421
|
+
drainTimeoutMs: DRAIN_TIMEOUT_MS,
|
|
422
|
+
...(adapter === undefined ? {} : { adapter: adapter() }),
|
|
423
|
+
})
|
|
424
|
+
const graphql = GraphQLPlugin.configure({
|
|
425
|
+
introspection: false,
|
|
426
|
+
resultMappers: [Rejections],
|
|
427
|
+
limits: { maxAliases: 3, maxDepth: 4 },
|
|
428
|
+
http: { guards: [Gate] },
|
|
429
|
+
subscriptions: { maxActive: 2 },
|
|
430
|
+
...(driver === undefined ? {} : { driver }),
|
|
431
|
+
})
|
|
432
|
+
return TestApp.create({
|
|
433
|
+
metadata: conformanceMetadata(),
|
|
434
|
+
plugins: graphqlFirst ? [graphql, http] : [http, graphql],
|
|
435
|
+
logger: { debug() {}, info() {}, warn() {}, error() {} },
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function observed(app: TestApp): Observed {
|
|
440
|
+
return (app.get(testToken(Catalog) as never) as Catalog)[OBSERVED]
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function graphql(app: TestApp): GraphQLPlugin {
|
|
444
|
+
return app.app.getPlugin('graphql') as GraphQLPlugin
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function until(condition: () => boolean, what: string, timeoutMs = 2_000): Promise<void> {
|
|
448
|
+
const started = Date.now()
|
|
449
|
+
while (!condition()) {
|
|
450
|
+
if (Date.now() - started > timeoutMs) throw new Error(`conformance: ${what} did not happen within ${timeoutMs} ms`)
|
|
451
|
+
await new Promise(resolve => setTimeout(resolve, 5))
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function codes(result: OperationResult): readonly unknown[] {
|
|
456
|
+
return (result.errors ?? []).map(error => error.extensions?.['code'])
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function result(response: OperationResponse): OperationResult {
|
|
460
|
+
return {
|
|
461
|
+
...('data' in response ? { data: response.data } : {}),
|
|
462
|
+
...(response.errors === undefined ? {} : { errors: response.errors }),
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ------------------------------------------------------------------------------------------------
|
|
467
|
+
// the scenarios
|
|
468
|
+
|
|
469
|
+
function describeOperations(
|
|
470
|
+
options: DriverConformanceOptions,
|
|
471
|
+
driverName: string,
|
|
472
|
+
adapter: (() => HttpAdapter) | undefined,
|
|
473
|
+
): void {
|
|
474
|
+
const { test } = options
|
|
475
|
+
let app: TestApp
|
|
476
|
+
const events: { driver: string; transport: string; operationName: string | undefined }[] = []
|
|
477
|
+
|
|
478
|
+
test.beforeAll(async () => {
|
|
479
|
+
app = await boot(options, adapter)
|
|
480
|
+
app.app.hooks.on('graphql:operation', event => {
|
|
481
|
+
events.push({ driver: event.driver, transport: event.transport, operationName: event.operationName })
|
|
482
|
+
})
|
|
483
|
+
})
|
|
484
|
+
test.afterAll(async () => {
|
|
485
|
+
await app?.close()
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
test.it('executes queries, with field resolvers batched through a loader', async () => {
|
|
489
|
+
const response = await executeOperation(app, { query: '{ books { title author { name } } }' })
|
|
490
|
+
strictEqual(response.status, 200)
|
|
491
|
+
deepStrictEqual(result(response), {
|
|
492
|
+
data: {
|
|
493
|
+
books: [
|
|
494
|
+
{ title: 'Dune', author: { name: 'Frank Herbert' } },
|
|
495
|
+
{ title: 'Hyperion', author: { name: 'Dan Simmons' } },
|
|
496
|
+
{ title: 'Children of Dune', author: { name: 'Frank Herbert' } },
|
|
497
|
+
],
|
|
498
|
+
},
|
|
499
|
+
})
|
|
500
|
+
deepStrictEqual(observed(app).batches.at(-1), ['herbert', 'simmons'])
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
test.it('executes mutations behind a field guard reading a request-scoped provider', async () => {
|
|
504
|
+
const denied = await executeOperation(app, { query: 'mutation { addBook(title: "Ilium") { title } }' })
|
|
505
|
+
strictEqual(denied.status, 200)
|
|
506
|
+
deepStrictEqual(denied.data, null)
|
|
507
|
+
deepStrictEqual(codes(denied), ['FORBIDDEN'])
|
|
508
|
+
const added = await executeOperation(app, {
|
|
509
|
+
query: 'mutation Add($title: String!) { addBook(title: $title) { title author { name } } }',
|
|
510
|
+
variables: { title: 'Ilium' },
|
|
511
|
+
headers: { 'x-user': 'editor' },
|
|
512
|
+
})
|
|
513
|
+
deepStrictEqual(result(added), { data: { addBook: { title: 'Ilium', author: { name: 'Frank Herbert' } } } })
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
test.it('creates request-scoped providers once per request', async () => {
|
|
517
|
+
const first = await executeOperation<{ a: { id: number }; b: { id: number; user: string } }>(app, {
|
|
518
|
+
query: '{ a: me { id } b: me { id user } }',
|
|
519
|
+
headers: { 'x-user': 'ada' },
|
|
520
|
+
})
|
|
521
|
+
const second = await executeOperation<{ me: { id: number } }>(app, { query: '{ me { id } }' })
|
|
522
|
+
ok(first.data !== undefined && first.data !== null && second.data !== undefined && second.data !== null)
|
|
523
|
+
strictEqual(first.data.a.id, first.data.b.id)
|
|
524
|
+
strictEqual(first.data.b.user, 'ada')
|
|
525
|
+
ok(second.data.me.id > first.data.a.id)
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
test.it('turns errors into data with result mappers, and masks unexpected errors', async () => {
|
|
529
|
+
const missing = await executeOperation(app, {
|
|
530
|
+
query: '{ book(id: "404") { __typename ... on NotFound { message } ... on Book { title } } }',
|
|
531
|
+
})
|
|
532
|
+
deepStrictEqual(result(missing), { data: { book: { __typename: 'NotFound', message: 'No book 404' } } })
|
|
533
|
+
const secret = await executeOperation(app, { query: '{ secret }' })
|
|
534
|
+
strictEqual(secret.status, 200)
|
|
535
|
+
deepStrictEqual(result(secret), {
|
|
536
|
+
data: { secret: null },
|
|
537
|
+
errors: [
|
|
538
|
+
{
|
|
539
|
+
message: 'Unexpected error.',
|
|
540
|
+
locations: [{ line: 1, column: 3 }],
|
|
541
|
+
path: ['secret'],
|
|
542
|
+
extensions: { code: 'INTERNAL_SERVER_ERROR' },
|
|
543
|
+
},
|
|
544
|
+
],
|
|
545
|
+
})
|
|
546
|
+
ok(!secret.body.includes('postgres'), 'the original error leaked')
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
test.it('refuses introspection, documents past the limits and invalid documents (400)', async () => {
|
|
550
|
+
for (const [query, code] of [
|
|
551
|
+
['{ __schema { queryType { name } } }', 'GRAPHQL_VALIDATION_FAILED'],
|
|
552
|
+
['{ a: me { id } b: me { id } c: me { id } d: me { id } }', 'QUERY_TOO_COMPLEX'],
|
|
553
|
+
['{ books { author { name } author2: author { name } } }', undefined],
|
|
554
|
+
['{ nope }', 'GRAPHQL_VALIDATION_FAILED'],
|
|
555
|
+
['{ books', 'GRAPHQL_PARSE_FAILED'],
|
|
556
|
+
] as const) {
|
|
557
|
+
const response = await executeOperation(app, { query })
|
|
558
|
+
if (code === undefined) {
|
|
559
|
+
strictEqual(response.status, 200, query)
|
|
560
|
+
continue
|
|
561
|
+
}
|
|
562
|
+
strictEqual(response.status, 400, query)
|
|
563
|
+
strictEqual(response.data, undefined, query)
|
|
564
|
+
deepStrictEqual([...new Set(codes(response))], [code], query)
|
|
565
|
+
}
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
test.it('runs behind the HTTP guards of the endpoint', async () => {
|
|
569
|
+
const blocked = await executeOperation(app, { query: '{ books { title } }', headers: { 'x-block': 'yes' } })
|
|
570
|
+
strictEqual(blocked.status, 403)
|
|
571
|
+
strictEqual(blocked.data, undefined)
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
test.it('reports every executed operation to the graphql:operation hook, with the driver', async () => {
|
|
575
|
+
events.length = 0
|
|
576
|
+
await executeOperation(app, { query: 'query Named { books { id } }' })
|
|
577
|
+
deepStrictEqual(events, [{ driver: driverName, transport: 'http', operationName: 'Named' }])
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
test.it('refuses POST bodies other than JSON (415): a cross-site form never runs an operation', async () => {
|
|
581
|
+
const mutation = 'mutation { addBook(title: "Forged") { id } }'
|
|
582
|
+
const before = await executeOperation<{ books: unknown[] }>(app, { query: '{ books { id } }' })
|
|
583
|
+
for (const [type, body] of [
|
|
584
|
+
['application/x-www-form-urlencoded', `query=${encodeURIComponent(mutation)}`],
|
|
585
|
+
[
|
|
586
|
+
'multipart/form-data; boundary=x',
|
|
587
|
+
`--x\r\ncontent-disposition: form-data; name="query"\r\n\r\n${mutation}\r\n--x--\r\n`,
|
|
588
|
+
],
|
|
589
|
+
['text/plain', JSON.stringify({ query: mutation })],
|
|
590
|
+
['application/graphql', mutation],
|
|
591
|
+
] as const) {
|
|
592
|
+
const response = await app.inject({
|
|
593
|
+
method: 'POST',
|
|
594
|
+
url: '/graphql',
|
|
595
|
+
headers: { 'content-type': type, 'x-user': 'editor' },
|
|
596
|
+
body,
|
|
597
|
+
})
|
|
598
|
+
strictEqual(response.status, 415, type)
|
|
599
|
+
deepStrictEqual(
|
|
600
|
+
response.json(),
|
|
601
|
+
{
|
|
602
|
+
errors: [
|
|
603
|
+
{
|
|
604
|
+
message: 'Send GraphQL requests as application/json (content-type: application/json)',
|
|
605
|
+
extensions: { code: 'BAD_REQUEST' },
|
|
606
|
+
},
|
|
607
|
+
],
|
|
608
|
+
},
|
|
609
|
+
type,
|
|
610
|
+
)
|
|
611
|
+
}
|
|
612
|
+
const after = await executeOperation<{ books: unknown[] }>(app, { query: '{ books { id } }' })
|
|
613
|
+
strictEqual(after.data?.books.length, before.data?.books.length)
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
test.it('refuses mutations over GET (405, allow: POST)', async () => {
|
|
617
|
+
const before = await executeOperation<{ books: unknown[] }>(app, { query: '{ books { id } }' })
|
|
618
|
+
const response = await app.inject({
|
|
619
|
+
method: 'GET',
|
|
620
|
+
url: '/graphql',
|
|
621
|
+
query: { query: 'mutation { addBook(title: "Fetched") { id } }' },
|
|
622
|
+
headers: { 'x-user': 'editor' },
|
|
623
|
+
})
|
|
624
|
+
strictEqual(response.status, 405)
|
|
625
|
+
strictEqual(response.headers['allow'], 'POST')
|
|
626
|
+
const after = await executeOperation<{ books: unknown[] }>(app, { query: '{ books { id } }' })
|
|
627
|
+
strictEqual(after.data?.books.length, before.data?.books.length)
|
|
628
|
+
})
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function describeSubscriptions(options: DriverConformanceOptions, adapter: (() => HttpAdapter) | undefined): void {
|
|
632
|
+
const { test } = options
|
|
633
|
+
let app: TestApp
|
|
634
|
+
|
|
635
|
+
test.beforeAll(async () => {
|
|
636
|
+
app = await boot(options, adapter)
|
|
637
|
+
})
|
|
638
|
+
test.afterAll(async () => {
|
|
639
|
+
await app?.close()
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
test.it('streams the events of a subscription until the client leaves', async () => {
|
|
643
|
+
const events = await subscribeOperation(app, { query: 'subscription { bookAdded { title } }' })
|
|
644
|
+
strictEqual(events.status, 200)
|
|
645
|
+
await executeOperation(app, {
|
|
646
|
+
query: 'mutation { addBook(title: "Endymion") { id } }',
|
|
647
|
+
headers: { 'x-user': 'editor' },
|
|
648
|
+
})
|
|
649
|
+
deepStrictEqual((await events.next()).value, { data: { bookAdded: { title: 'Endymion' } } })
|
|
650
|
+
await events.return()
|
|
651
|
+
await until(() => graphql(app).activeSubscriptions === 0, 'the end of the subscription')
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
test.it('completes a finite subscription', async () => {
|
|
655
|
+
const events = await subscribeOperation(app, { query: 'subscription { countdown(from: 2) }' })
|
|
656
|
+
deepStrictEqual(await drain(events), [{ data: { countdown: 2 } }, { data: { countdown: 1 } }])
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
test.it('ends the source when the client aborts, and caps the active subscriptions', async () => {
|
|
660
|
+
const ended = observed(app).ended
|
|
661
|
+
ended.length = 0
|
|
662
|
+
const first = await subscribeOperation(app, { query: 'subscription { forever }' })
|
|
663
|
+
const second = await subscribeOperation(app, { query: 'subscription { forever }' })
|
|
664
|
+
deepStrictEqual((await first.next()).value, { data: { forever: 1 } })
|
|
665
|
+
deepStrictEqual((await second.next()).value, { data: { forever: 1 } })
|
|
666
|
+
const third = await subscribeOperation(app, { query: 'subscription { forever }' })
|
|
667
|
+
const refused = await third.next()
|
|
668
|
+
deepStrictEqual(codes(refused.value ?? {}), ['SERVICE_UNAVAILABLE'])
|
|
669
|
+
await third.return()
|
|
670
|
+
await first.return()
|
|
671
|
+
await second.return()
|
|
672
|
+
await until(() => ended.length === 2, 'the end of both sources')
|
|
673
|
+
await until(() => graphql(app).activeSubscriptions === 0, 'the end of the subscriptions')
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
for (const graphqlFirst of [false, true]) {
|
|
677
|
+
const order = graphqlFirst ? 'GraphQLPlugin before HttpPlugin' : 'HttpPlugin before GraphQLPlugin'
|
|
678
|
+
test.it(`ends the subscriptions when the application stops, without waiting for the drain (${order})`, async () => {
|
|
679
|
+
const stopping = await boot(options, adapter, graphqlFirst)
|
|
680
|
+
const ended = observed(stopping).ended
|
|
681
|
+
const events = await subscribeOperation(stopping, { query: 'subscription { forever }' })
|
|
682
|
+
deepStrictEqual((await events.next()).value, { data: { forever: 1 } })
|
|
683
|
+
// the client keeps reading, as a real one does (the stream ends once read)
|
|
684
|
+
const rest = drain(events)
|
|
685
|
+
const started = Date.now()
|
|
686
|
+
await stopping.close()
|
|
687
|
+
const elapsed = Date.now() - started
|
|
688
|
+
ok(elapsed < DRAIN_TIMEOUT_MS / 2, `the shutdown waited ${elapsed} ms for the drain`)
|
|
689
|
+
await until(() => ended.includes('forever'), 'the end of the source on shutdown')
|
|
690
|
+
await rest
|
|
691
|
+
strictEqual(graphql(stopping).activeSubscriptions, 0)
|
|
692
|
+
})
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function drain(events: OperationStream): Promise<OperationResult[]> {
|
|
697
|
+
const results: OperationResult[] = []
|
|
698
|
+
for (let step = await events.next(); step.done !== true; step = await events.next()) results.push(step.value)
|
|
699
|
+
return results
|
|
700
|
+
}
|