Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: @defer support #898

Open
wants to merge 20 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
cee50b7
added @defer support for requests with multipart/mixed; deferSpec=202…
igrlk Oct 24, 2022
4ad38b4
reverted mercurius version bump, moved @defer tests to their own file
igrlk Oct 26, 2022
08ee6d2
remove 'Mercurius' from the error message about wrong accept header, …
igrlk Oct 26, 2022
35fdb02
bump graphql to 17.0.0-alpha.2
igrlk Oct 26, 2022
d41fff6
moved missing multipart accept header error into errors.js
igrlk Oct 26, 2022
274c5bc
added opts.defer: boolean to enable @defer directive
igrlk Oct 26, 2022
3e3ca77
use @fastify/accepts instead of Negotiator package
igrlk Oct 26, 2022
2b3c25a
add @defer test with undici.fetch
igrlk Oct 26, 2022
369457e
Add space between merged SDLs to fix merging errors (#899)
Igloczek Nov 2, 2022
780c668
Explicitly say in the docs that JIT is disabled by default (#901)
igrlk Nov 2, 2022
b4d70fc
feat: add types for object in graphiql configuration (#907)
codeflyer Nov 2, 2022
e27e5b0
Prevent parsing schema exceptions when importing directives (#900)
Igloczek Nov 2, 2022
6933420
Bumped v11.3.0
mcollina Nov 2, 2022
d1d8485
Removed canUseIncrementalExecution check
igrlk Nov 2, 2022
2d599a7
Merge branch 'mercurius-js:master' into defer-directive-support
igrlk Nov 2, 2022
ef73394
Merge branch 'defer-directive-support' of github.com:igrlk/mercurius …
igrlk Nov 2, 2022
640300f
Throw an error if JIT is used together with defer, update the docs
igrlk Nov 2, 2022
8b7e9b2
Throw an error if JIT is used together with defer, update the docs
igrlk Nov 2, 2022
7a84f17
Merge branch 'defer-directive-support' of github.com:igrlk/mercurius …
igrlk Nov 2, 2022
093d0df
Merge branch 'next' into defer-directive-support
igrlk Nov 2, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/api/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@

- `jit`: Integer. The minimum number of execution a query needs to be
executed before being jit'ed.
- Default: `0`, jit is disabled.
- _jit can't be used together with `defer: true`_
- `defer`: boolean. Default: `false`. Enable @defer directive execution support.
- _defer can't be used together with `jit`_
- `routes`: boolean. Serves the Default: `true`. A graphql endpoint is
exposed at `/graphql`.
- `path`: string. Change default graphql `/graphql` route to another one.
Expand Down
39 changes: 37 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,17 +437,52 @@ export interface MercuriusSchemaOptions {
schemaTransforms?: ((originalSchema: GraphQLSchema) => GraphQLSchema) | Array<(originalSchema: GraphQLSchema) => GraphQLSchema>;
}

export interface MercuriusGraphiQLOptions {
/**
* Expose the graphiql app, default `true`
*/
enabled?: boolean;
/**
* The list of plugin to add to GraphiQL
*/
plugins?: Array<{
/**
* The name of the plugin, it should be the same exported in the `umd`
*/
name: string;
/**
* The props to be passed to the plugin
*/
props?: Object;
/**
* The urls of the plugin, it's downloaded at runtime. (eg. https://unpkg.com/myplugin/....)
*/
umdUrl: string;
/**
* A function name exported by the plugin to read/enrich the fetch response
*/
fetcherWrapper?: string;
}>
}

export interface MercuriusCommonOptions {
/**
* Serve GraphiQL on /graphiql if true or 'graphiql' and if routes is true
*/
graphiql?: boolean | 'graphiql';
ide?: boolean | 'graphiql';
graphiql?: boolean | 'graphiql' | MercuriusGraphiQLOptions;
ide?: boolean | 'graphiql' | MercuriusGraphiQLOptions;
/**
* The minimum number of execution a query needs to be executed before being jit'ed.
* Can't be enabled with MercuriusCommonOptions.defer
* @default 0 - disabled
*/
jit?: number;
/**
* Enable @defer directive execution support.
* Can't be enabled with MercuriusCommonOptions.jit
* @default false
*/
defer?: boolean;
/**
* A graphql endpoint is exposed at /graphql when true
* @default true
Expand Down
104 changes: 100 additions & 4 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ const {
extendSchema,
validate,
validateSchema,
specifiedRules,
execute
specifiedRules
} = require('graphql')
const { buildExecutionContext } = require('graphql/execution/execute')
const { Readable } = require('stream')
const queryDepth = require('./lib/queryDepth')
const buildFederationSchema = require('./lib/federation')
const { initGateway } = require('./lib/gateway')
Expand All @@ -37,7 +37,8 @@ const {
MER_ERR_GQL_VALIDATION,
MER_ERR_INVALID_OPTS,
MER_ERR_METHOD_NOT_ALLOWED,
MER_ERR_INVALID_METHOD
MER_ERR_INVALID_METHOD,
MER_ERR_INVALID_MULTIPART_ACCEPT_HEADER
} = require('./lib/errors')
const { Hooks, assignLifeCycleHooksToContext } = require('./lib/hooks')
const { kLoaders, kFactory, kSubscriptionFactory, kHooks } = require('./lib/symbols')
Expand All @@ -47,6 +48,7 @@ const {
preExecutionHandler,
onResolutionHandler
} = require('./lib/handlers')
const { executeGraphql, MEDIA_TYPES } = require('./lib/util')

// Required for module bundlers
// istanbul ignore next
Expand Down Expand Up @@ -187,6 +189,24 @@ const plugin = fp(async function (app, opts) {
})
}

if (opts.defer) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you throw if jit is enabled with defer?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added an error throwing when both jit and defer are enabled & updated the docs to have defer option there and in typescript definitions

if (opts.jit) {
throw new MER_ERR_INVALID_OPTS("@defer and JIT can't be used together")
}

app.register(require('@fastify/accepts'))

schema = extendSchema(
schema,
parse(`
directive @defer(
if: Boolean! = true
label: String
) on FRAGMENT_SPREAD | INLINE_FRAGMENT
`)
)
}

fastifyGraphQl.schema = schema

app.addHook('onReady', async function () {
Expand Down Expand Up @@ -546,7 +566,7 @@ const plugin = fp(async function (app, opts) {
return maybeFormatErrors(execution, context)
}

const execution = await execute({
const execution = await executeGraphql(opts.defer, {
schema: modifiedSchema || fastifyGraphQl.schema,
document: modifiedDocument || document,
rootValue: root,
Expand All @@ -555,9 +575,85 @@ const plugin = fp(async function (app, opts) {
operationName
})

/* istanbul ignore next */
if (execution.initialResult) {
const accept = reply.request.accepts() // Accepts object

if (
!(
accept.negotiator.mediaType([
// mediaType() will return the first one that matches, so if the client
// doesn't include the deferSpec parameter it will match this one here,
// which isn't good enough.
MEDIA_TYPES.MULTIPART_MIXED_NO_DEFER_SPEC,
MEDIA_TYPES.MULTIPART_MIXED_EXPERIMENTAL
]) === MEDIA_TYPES.MULTIPART_MIXED_EXPERIMENTAL
)
mcollina marked this conversation as resolved.
Show resolved Hide resolved
) {
// The client ran an operation that would yield multiple parts, but didn't
// specify `accept: multipart/mixed`. We return an error.
throw new MER_ERR_INVALID_MULTIPART_ACCEPT_HEADER()
}
mcollina marked this conversation as resolved.
Show resolved Hide resolved

reply.header('content-type', 'multipart/mixed; boundary="-"; deferSpec=20220824')

return Readable.from(
mcollina marked this conversation as resolved.
Show resolved Hide resolved
writeMultipartBody(
execution.initialResult,
execution.subsequentResults
)
)
}

return maybeFormatErrors(execution, context)
}

/* istanbul ignore next */
async function * writeMultipartBody (initialResult, subsequentResults) {
yield `\r\n---\r\ncontent-type: application/json; charset=utf-8\r\n\r\n${JSON.stringify(
orderInitialIncrementalExecutionResultFields(initialResult)
)}\r\n---${initialResult.hasNext ? '' : '--'}\r\n`

for await (const result of subsequentResults) {
yield `content-type: application/json; charset=utf-8\r\n\r\n${JSON.stringify(
orderSubsequentIncrementalExecutionResultFields(result)
)}\r\n---${result.hasNext ? '' : '--'}\r\n`
}
}

/* istanbul ignore next */
function orderInitialIncrementalExecutionResultFields (result) {
return {
hasNext: result.hasNext,
errors: result.errors,
data: result.data,
incremental: orderIncrementalResultFields(result.incremental),
extensions: result.extensions
}
}

/* istanbul ignore next */
function orderSubsequentIncrementalExecutionResultFields (result) {
return {
hasNext: result.hasNext,
incremental: orderIncrementalResultFields(result.incremental),
extensions: result.extensions
}
}

/* istanbul ignore next */
function orderIncrementalResultFields (incremental) {
return incremental?.map((i) => ({
hasNext: i.hasNext,
errors: i.errors,
path: i.path,
label: i.label,
data: i.data,
items: i.items,
extensions: i.extensions
}))
}

async function maybeFormatErrors (execution, context) {
execution = addErrorsToExecutionResult(execution, context.errors)

Expand Down
18 changes: 12 additions & 6 deletions lib/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,14 @@ function toGraphQLError (err) {

const gqlError = new GraphQLError(
err.message,
err.nodes,
err.source,
err.positions,
err.path,
err,
err.extensions
{
nodes: err.nodes,
source: err.source,
positions: err.positions,
path: err.path,
originalError: err,
extensions: err.extensions
}
mcollina marked this conversation as resolved.
Show resolved Hide resolved
)

gqlError.locations = err.locations
Expand Down Expand Up @@ -137,6 +139,10 @@ const errors = {
'Method not allowed',
405
),
MER_ERR_INVALID_MULTIPART_ACCEPT_HEADER: createError(
'MER_ERR_INVALID_MULTIPART_ACCEPT_HEADER',
'Server received an operation that uses incremental delivery (@defer or @stream), but the client does not accept multipart/mixed HTTP responses. To enable incremental delivery support, add the HTTP header "Accept: multipart/mixed; deferSpec=20220824".'
),
/**
* General graphql errors
*/
Expand Down
2 changes: 1 addition & 1 deletion lib/federation.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ function getStubTypes (schemaDefinitions, isGateway) {
const directiveDefinitions = []

for (const definition of schemaDefinitions) {
if (definition.kind === 'SchemaDefinition') {
if (definition.kind === 'SchemaDefinition' || definition.kind === 'SchemaExtension') {
continue
}

Expand Down
6 changes: 3 additions & 3 deletions lib/gateway/build-gateway.js
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ async function buildGateway (gatewayOpts, app) {
throw new MER_ERR_GQL_GATEWAY_INIT('No valid service SDLs were provided')
}

const schema = buildFederatedSchema(serviceSDLs.join(''), true)
const schema = buildFederatedSchema(serviceSDLs.join(' '), true)

const typeToServiceMap = {}
const typeFieldsToService = {}
Expand Down Expand Up @@ -419,7 +419,7 @@ async function buildGateway (gatewayOpts, app) {
async refresh (isRetry) {
const failedMandatoryServices = []
if (this._serviceSDLs === undefined) {
this._serviceSDLs = serviceSDLs.join('')
this._serviceSDLs = serviceSDLs.join(' ')
}

const $refreshResult = await Promise.allSettled(
Expand Down Expand Up @@ -458,7 +458,7 @@ async function buildGateway (gatewayOpts, app) {

const _serviceSDLs = Object.values(serviceMap)
.map((service) => service.schemaDefinition)
.join('')
.join(' ')

if (this._serviceSDLs === _serviceSDLs) {
return null
Expand Down
21 changes: 20 additions & 1 deletion lib/util.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
'use strict'

const { execute } = require('graphql')
const { experimentalExecuteIncrementally } = require('graphql/execution')

function hasDirective (directiveName, node) {
if (!node.directives || node.directives.length < 1) {
return false
Expand All @@ -23,7 +26,23 @@ function hasExtensionDirective (node) {
}
}

// istanbul ignore next
function executeGraphql (isDeferEnabled, args) {
if (isDeferEnabled) {
return experimentalExecuteIncrementally(args)
}

return execute(args)
}

const MEDIA_TYPES = {
MULTIPART_MIXED_NO_DEFER_SPEC: 'multipart/mixed',
MULTIPART_MIXED_EXPERIMENTAL: 'multipart/mixed; deferSpec=20220824'
}

module.exports = {
hasDirective,
hasExtensionDirective
hasExtensionDirective,
executeGraphql,
MEDIA_TYPES
}
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
},
"homepage": "https://mercurius.dev",
"peerDependencies": {
"graphql": "^16.0.0"
"graphql": "^17.0.0-alpha.2"
},
"devDependencies": {
"@graphql-tools/merge": "^8.0.0",
Expand Down Expand Up @@ -56,12 +56,13 @@
"wait-on": "^6.0.0"
},
"dependencies": {
"@fastify/accepts": "^4.0.1",
"@fastify/error": "^3.0.0",
"@fastify/static": "^6.0.0",
"@fastify/websocket": "^7.0.0",
"events.on": "^1.0.1",
"fastify-plugin": "^4.2.0",
"graphql": "^16.0.0",
"graphql": "^17.0.0-alpha.2",
"graphql-jit": "^0.7.3",
"mqemitter": "^5.0.0",
"p-map": "^4.0.0",
Expand Down
Loading