@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
package/src/testing.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* `GraphQLPlugin.execute()`;
|
|
7
7
|
* - `subscribeOperation()` streams an operation over GraphQL over SSE in memory, or through
|
|
8
8
|
* `GraphQLPlugin.subscribe()` without HTTP transport;
|
|
9
|
+
* - `describeDriverConformance()` describes the conformance scenarios of a GraphQL driver (RFC 0009);
|
|
9
10
|
* - `fakeLoader()` builds a `Loader<L>` whose `load` / `loadMany` are spies of the running test
|
|
10
11
|
* framework (`bun:test`, Vitest, Jest, or the built-in spy of `@znight/testing`).
|
|
11
12
|
*
|
|
@@ -23,6 +24,8 @@ import { resolveMockFn } from '@znight/testing'
|
|
|
23
24
|
|
|
24
25
|
import { endpointOf, executorOf, type GraphQLPlugin } from './plugin.ts'
|
|
25
26
|
|
|
27
|
+
export { type ConformanceTestApi, type DriverConformanceOptions, describeDriverConformance } from './conformance.ts'
|
|
28
|
+
|
|
26
29
|
/** An operation for {@link executeOperation} and {@link subscribeOperation}. */
|
|
27
30
|
export interface OperationRequest {
|
|
28
31
|
readonly query: string
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in transport as a driver (RFC 0009 §4.11): GraphQL over HTTP and SSE, the default when
|
|
3
|
+
* the `driver` option is absent. It keeps its privileged access to the internal executor (statuses,
|
|
4
|
+
* formatted results, SSE sessions, the memoised negotiation): its handlers are the ones of
|
|
5
|
+
* `HttpTransport`, unchanged, and it never goes through the public engine.
|
|
6
|
+
*/
|
|
7
|
+
import type { SseOptions } from '@znight/http'
|
|
8
|
+
import type { GraphQLDriver, GraphQLDriverRoute, GraphQLDriverRoutesContext } from '../driver.ts'
|
|
9
|
+
import type { GraphQLExecutor } from '../execution.ts'
|
|
10
|
+
|
|
11
|
+
import { HttpTransport } from './http.ts'
|
|
12
|
+
|
|
13
|
+
/** What the built-in driver is built from (resolved plugin options). */
|
|
14
|
+
export interface BuiltinDriverOptions {
|
|
15
|
+
/** The executor of the plugin, read on the first request (routes may be collected before the bootstrap). */
|
|
16
|
+
readonly executor: () => GraphQLExecutor
|
|
17
|
+
/** Queries over `GET`. */
|
|
18
|
+
readonly get: boolean
|
|
19
|
+
readonly graphiql: boolean
|
|
20
|
+
/** `HttpResponse.sse()` options of GraphQL over SSE; `false`: no event streams. */
|
|
21
|
+
readonly sse: SseOptions | false
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The name of the built-in transport (`GraphQLOperationEvent.driver`), reserved. */
|
|
25
|
+
export const BUILTIN_DRIVER = 'builtin'
|
|
26
|
+
|
|
27
|
+
/** The built-in GraphQL-over-HTTP and SSE endpoint. */
|
|
28
|
+
export class BuiltinDriver implements GraphQLDriver {
|
|
29
|
+
readonly name: string = BUILTIN_DRIVER
|
|
30
|
+
readonly #options: BuiltinDriverOptions
|
|
31
|
+
|
|
32
|
+
constructor(options: BuiltinDriverOptions) {
|
|
33
|
+
this.#options = options
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `POST` on the path, and `GET` when queries over `GET` or GraphiQL are on (the router answers 405 otherwise). */
|
|
37
|
+
routes(context: GraphQLDriverRoutesContext): readonly GraphQLDriverRoute[] {
|
|
38
|
+
const options = this.#options
|
|
39
|
+
const transport = new HttpTransport({
|
|
40
|
+
executor: options.executor,
|
|
41
|
+
endpoint: context.endpoint,
|
|
42
|
+
get: options.get,
|
|
43
|
+
graphiql: options.graphiql,
|
|
44
|
+
sse: options.sse,
|
|
45
|
+
})
|
|
46
|
+
const routes: GraphQLDriverRoute[] = [
|
|
47
|
+
{ method: 'POST', path: context.path, name: 'handle', handler: transport.post },
|
|
48
|
+
]
|
|
49
|
+
if (options.get || options.graphiql) {
|
|
50
|
+
routes.unshift({ method: 'GET', path: context.path, name: 'handle', handler: transport.get })
|
|
51
|
+
}
|
|
52
|
+
return routes
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Nothing to start: the handlers read the executor on the first request. */
|
|
56
|
+
start(): void {}
|
|
57
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Fetch bridge of route-mounted drivers (RFC 0009 §4.4, Q10): the Web `Request` a Fetch server
|
|
3
|
+
* (GraphQL Yoga) handles, built from the `HttpContext` of the route. Internal to `@znight/graphql`
|
|
4
|
+
* (a candidate for `@znight/http` once a second user appears).
|
|
5
|
+
*
|
|
6
|
+
* - On `honoAdapter()` the native request already is a Web `Request`: it is handed over as is when
|
|
7
|
+
* its body was not read and cannot exceed the route's body limit (a `GET`, or a declared
|
|
8
|
+
* `content-length` within the limit), so nothing is copied.
|
|
9
|
+
* - Otherwise (Node-style adapters, chunked bodies) a `Request` is built from the method, the URL,
|
|
10
|
+
* the headers and the body read by `ctx.rawBody()`: the route's body limit then answers with
|
|
11
|
+
* `HttpPlugin`'s 413 on every adapter, before the server sees the request. GraphQL servers buffer
|
|
12
|
+
* the body to parse it anyway, so reading it first costs no extra copy of note.
|
|
13
|
+
*/
|
|
14
|
+
import type { HttpContext } from '@znight/http'
|
|
15
|
+
|
|
16
|
+
/** The Web `Request` of the route's request, read under `limit` bytes. */
|
|
17
|
+
export function fetchRequest(ctx: HttpContext, limit: number): Request | Promise<Request> {
|
|
18
|
+
const native = ctx.request
|
|
19
|
+
const method = ctx.method
|
|
20
|
+
const bodyless = method === 'GET' || method === 'HEAD'
|
|
21
|
+
if (native instanceof Request && !native.bodyUsed) {
|
|
22
|
+
if (bodyless) return native
|
|
23
|
+
const length = ctx.headers['content-length']
|
|
24
|
+
if (length !== undefined && ctx.headers['transfer-encoding'] === undefined) {
|
|
25
|
+
// past the limit, ctx.rawBody() below rejects with HttpPlugin's own 413
|
|
26
|
+
const declared = Number(length)
|
|
27
|
+
if (Number.isSafeInteger(declared) && declared >= 0 && declared <= limit) return native
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const url = requestUrl(ctx)
|
|
31
|
+
const headers = requestHeaders(ctx)
|
|
32
|
+
if (bodyless) return new Request(url, { method, headers, signal: ctx.signal })
|
|
33
|
+
return ctx.rawBody().then(body => new Request(url, { method, headers, body, signal: ctx.signal }))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The absolute URL of the request: the raw target on the request's protocol and host. */
|
|
37
|
+
function requestUrl(ctx: HttpContext): string {
|
|
38
|
+
const host = ctx.headers['host']
|
|
39
|
+
const target = ctx.url
|
|
40
|
+
if (host !== undefined && target.startsWith('/') && !target.startsWith('//')) {
|
|
41
|
+
const url = `${ctx.protocol}://${host}${target}`
|
|
42
|
+
if (URL.canParse(url)) return url
|
|
43
|
+
}
|
|
44
|
+
// no host header, or one a URL cannot hold: the path and query string still route the request
|
|
45
|
+
return new URL(target.startsWith('/') ? target.replace(/^\/+/, '/') : `/${target}`, `${ctx.protocol}://localhost`)
|
|
46
|
+
.href
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function requestHeaders(ctx: HttpContext): Headers {
|
|
50
|
+
const headers = new Headers()
|
|
51
|
+
const source = ctx.headers
|
|
52
|
+
for (const name in source) {
|
|
53
|
+
const value = source[name]
|
|
54
|
+
if (value !== undefined) headers.append(name, value)
|
|
55
|
+
}
|
|
56
|
+
return headers
|
|
57
|
+
}
|
package/src/transport/http.ts
CHANGED
|
@@ -90,8 +90,12 @@ const GET_NOT_ALLOWED = errorReply(
|
|
|
90
90
|
Object.freeze({ allow: 'POST' }),
|
|
91
91
|
)
|
|
92
92
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Whether a `POST` body is JSON (`application/json`, UTF-8): the only body the endpoint takes (and
|
|
95
|
+
* the Yoga driver), so that a cross-site request, which cannot send it without a CORS preflight,
|
|
96
|
+
* never executes an operation. `content-type` values seen most often are compared before parsing.
|
|
97
|
+
*/
|
|
98
|
+
export function isJsonRequest(contentType: string | undefined): boolean {
|
|
95
99
|
if (contentType === 'application/json' || contentType === 'application/json; charset=utf-8') return true
|
|
96
100
|
// parsed once per distinct value (memoised by @znight/http)
|
|
97
101
|
const media = parseMediaType(contentType)
|
|
@@ -99,6 +103,11 @@ function isJsonRequest(contentType: string | undefined): boolean {
|
|
|
99
103
|
return media.charset === undefined || media.charset === 'utf-8' || media.charset === 'utf8'
|
|
100
104
|
}
|
|
101
105
|
|
|
106
|
+
/** The 415 of a `POST` body that is not JSON, in the negotiated JSON type. */
|
|
107
|
+
export function notJson(media: MediaKind): HttpResponse<FormattedExecutionResult> {
|
|
108
|
+
return errorReply(media, 415, errorBody('Send GraphQL requests as application/json (content-type: application/json)'))
|
|
109
|
+
}
|
|
110
|
+
|
|
102
111
|
/** Why the parameters of a request are invalid, `undefined` when they are valid. */
|
|
103
112
|
function invalidParams(params: Readonly<Record<string, unknown>>): string | undefined {
|
|
104
113
|
const query = params['query']
|
|
@@ -211,13 +220,7 @@ export class HttpTransport {
|
|
|
211
220
|
const negotiated = this.#post.choose(headers['accept'])
|
|
212
221
|
const media = (negotiated & MEDIA) as MediaKind
|
|
213
222
|
if (media === Media.None) return NOT_ACCEPTABLE
|
|
214
|
-
if (!isJsonRequest(headers['content-type']))
|
|
215
|
-
return errorReply(
|
|
216
|
-
media,
|
|
217
|
-
415,
|
|
218
|
-
errorBody('Send GraphQL requests as application/json (content-type: application/json)'),
|
|
219
|
-
)
|
|
220
|
-
}
|
|
223
|
+
if (!isJsonRequest(headers['content-type'])) return notJson(media)
|
|
221
224
|
const encoding = headers['content-encoding']
|
|
222
225
|
if (encoding !== undefined && encoding.trim().toLowerCase() !== 'identity' && encoding.trim() !== '') {
|
|
223
226
|
return errorReply(media, 415, errorBody(`Unsupported content encoding "${encoding}"`))
|
package/src/types.ts
CHANGED
|
@@ -17,8 +17,11 @@ export type GraphQLArgs = Readonly<Record<string, unknown>>
|
|
|
17
17
|
/** Kind of a GraphQL operation. */
|
|
18
18
|
export type GraphQLOperationKind = 'query' | 'mutation' | 'subscription'
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* How an operation reached the server: GraphQL over HTTP, GraphQL over SSE, GraphQL over WebSocket
|
|
22
|
+
* (`ws`, reserved for `graphql-ws`), or `plugin.execute()` / `plugin.subscribe()` (`direct`).
|
|
23
|
+
*/
|
|
24
|
+
export type GraphQLTransport = 'http' | 'sse' | 'ws' | 'direct'
|
|
22
25
|
|
|
23
26
|
/**
|
|
24
27
|
* Options of a loader (§5.5), read once per loader class: the `options` property of a
|
|
@@ -112,6 +115,13 @@ export interface GraphQLContext {
|
|
|
112
115
|
readonly signal: AbortSignal
|
|
113
116
|
/** Per-operation storage. */
|
|
114
117
|
readonly state: Map<string | symbol, unknown>
|
|
118
|
+
/**
|
|
119
|
+
* The context of the GraphQL server that received the operation through a driver (RFC 0009): the
|
|
120
|
+
* `contextValue` it passed to `GraphQLEngine.execute()` (Yoga's initial context, Apollo's context
|
|
121
|
+
* value). `undefined` for the built-in transport and direct calls. Resolvers do not need it: the
|
|
122
|
+
* request is `http`, the user a request-scoped provider.
|
|
123
|
+
*/
|
|
124
|
+
readonly serverContext: unknown
|
|
115
125
|
/** The DataLoader of the current execution for a loader class (`ctx.loader(AuthorsById)`). */
|
|
116
126
|
loader<L extends BatchLoad<unknown, unknown>>(loader: BatchLoadClass<L>): BatchLoader<LoaderKey<L>, LoaderValue<L>>
|
|
117
127
|
/**
|
|
@@ -121,9 +131,17 @@ export interface GraphQLContext {
|
|
|
121
131
|
loader(token: Token): BatchLoader<unknown, unknown>
|
|
122
132
|
}
|
|
123
133
|
|
|
134
|
+
declare module '@znight/core' {
|
|
135
|
+
/** Custom parameter decorators (`createParamDecorator`) resolve GraphQL parameters from the {@link ResolverContext}. */
|
|
136
|
+
interface ParamContexts {
|
|
137
|
+
graphql: ResolverContext
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
124
141
|
/**
|
|
125
|
-
* What a {@link GraphQLGuard} receives for one resolver call (§5.9)
|
|
126
|
-
* `RouteMetaCarrier` of `@znight/http`, so route
|
|
142
|
+
* What a {@link GraphQLGuard} receives for one resolver call (§5.9), and the context of custom
|
|
143
|
+
* parameter resolvers (RFC 0010). It satisfies the `RouteMetaCarrier` of `@znight/http`, so route
|
|
144
|
+
* decorators read it: `Roles.get(ctx)`.
|
|
127
145
|
*/
|
|
128
146
|
export interface ResolverContext<TParent = unknown, TArgs extends GraphQLArgs = GraphQLArgs> extends RouteMetaCarrier {
|
|
129
147
|
/** Effective metadata of the field (method decisions over class decisions) and its schema coordinate. */
|