@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/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
+ }
@@ -90,8 +90,12 @@ const GET_NOT_ALLOWED = errorReply(
90
90
  Object.freeze({ allow: 'POST' }),
91
91
  )
92
92
 
93
- /** `content-type` values of JSON bodies seen most often, compared before parsing. */
94
- function isJsonRequest(contentType: string | undefined): boolean {
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
- /** How an operation reached the server: GraphQL over HTTP, GraphQL over SSE, or `plugin.execute()`. */
21
- export type GraphQLTransport = 'http' | 'sse' | 'direct'
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). It satisfies the
126
- * `RouteMetaCarrier` of `@znight/http`, so route decorators read it: `Roles.get(ctx)`.
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. */