diff --git a/docs/api/options.md b/docs/api/options.md index 3f89d568..2743aa7d 100644 --- a/docs/api/options.md +++ b/docs/api/options.md @@ -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. diff --git a/index.d.ts b/index.d.ts index 39df195b..1d84a466 100644 --- a/index.d.ts +++ b/index.d.ts @@ -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 diff --git a/index.js b/index.js index 790a8350..2e547690 100644 --- a/index.js +++ b/index.js @@ -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') @@ -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') @@ -47,6 +48,7 @@ const { preExecutionHandler, onResolutionHandler } = require('./lib/handlers') +const { executeGraphql, MEDIA_TYPES } = require('./lib/util') // Required for module bundlers // istanbul ignore next @@ -187,6 +189,24 @@ const plugin = fp(async function (app, opts) { }) } + if (opts.defer) { + 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 () { @@ -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, @@ -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 + ) + ) { + // 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() + } + + reply.header('content-type', 'multipart/mixed; boundary="-"; deferSpec=20220824') + + return Readable.from( + 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) diff --git a/lib/errors.js b/lib/errors.js index b6f7a31b..e55242ee 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -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 + } ) gqlError.locations = err.locations @@ -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 */ diff --git a/lib/federation.js b/lib/federation.js index 6c1258e4..11d6131f 100644 --- a/lib/federation.js +++ b/lib/federation.js @@ -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 } diff --git a/lib/gateway/build-gateway.js b/lib/gateway/build-gateway.js index bdac04c7..758792f3 100644 --- a/lib/gateway/build-gateway.js +++ b/lib/gateway/build-gateway.js @@ -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 = {} @@ -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( @@ -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 diff --git a/lib/util.js b/lib/util.js index 8a836203..b2509f2a 100644 --- a/lib/util.js +++ b/lib/util.js @@ -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 @@ -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 } diff --git a/package.json b/package.json index 82f0ee5a..8ed04cec 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/test/defer.js b/test/defer.js new file mode 100644 index 00000000..3b3f559a --- /dev/null +++ b/test/defer.js @@ -0,0 +1,222 @@ +const { test } = require('tap') +const Fastify = require('fastify') +const { fetch } = require('undici') +const mercurius = require('../index') + +const schema = ` + type Query { + allProducts: [Product!]! + } + + type Product { + delivery: DeliveryEstimates! + sku: String! + id: ID! + } + + type DeliveryEstimates { + estimatedDelivery: String! + fastestDelivery: String! + }` + +const allProducts = new Array(1).fill(0).map((_, index) => ({ + id: `${index}`, + sku: 'sku' +})) + +const resolvers = { + Query: { + allProducts: () => { + return allProducts + } + }, + Product: { + delivery: () => { + return { + estimatedDelivery: '25.01.2000', + fastestDelivery: '25.01.2000' + } + } + } +} + +const query = ` + query deferVariation { + allProducts { + delivery { + ...MyFragment @defer + } + sku + id + } + } + + fragment MyFragment on DeliveryEstimates { + estimatedDelivery + fastestDelivery + } +` + +test('errors with @defer when opts.defer is not true', async t => { + const app = Fastify() + await app.register(mercurius, { schema, resolvers, graphiql: true }) + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ query }) + }) + + t.match(res, { + statusCode: 400, + body: JSON.stringify({ + data: null, + errors: [{ + message: 'Unknown directive "@defer".', locations: [{ line: 5, column: 23 }] + }] + }) + }) + + await app.close() + t.end() +}) + +test('errors when used with both JIT and @defer', async t => { + const app = Fastify() + + try { + await app.register(mercurius, { jit: 1, defer: true }) + t.fail('Should not successfully start the server') + } catch (ex) { + t.equal(ex.message, "Invalid options: @defer and JIT can't be used together") + } + + await app.close() + t.end() +}) + +const wrongAcceptValues = [ + '', + 'application/json', + 'multipart/mixed', + 'multipart/mixed; deferSpec=12345' +] + +for (const accept of wrongAcceptValues) { + test('errors with @defer when used with wrong "accept" header', async t => { + const app = Fastify() + await app.register(mercurius, { schema, resolvers, graphiql: true, defer: true }) + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + headers: { + 'content-type': 'application/json', + accept + }, + body: JSON.stringify({ query }) + }) + + t.match(res, { + statusCode: 500, + body: JSON.stringify({ + data: null, + errors: [{ + message: '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".' + }] + }) + }) + + await app.close() + t.end() + }) +} + +const correctAcceptValues = [ + 'multipart/mixed; deferSpec=20220824', + 'multipart/mixed; deferSpec=20220824, application/json', + 'application/json, multipart/mixed; deferSpec=20220824' +] + +for (const accept of correctAcceptValues) { + test('works with @defer when used with correct "accept" header', async t => { + const app = Fastify() + await app.register(mercurius, { schema, resolvers, graphiql: true, defer: true }) + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + headers: { + 'content-type': 'application/json', + accept + }, + body: JSON.stringify({ query }) + }) + + t.match(res, { + statusCode: 200, + headers: { + 'content-type': 'multipart/mixed; boundary="-"; deferSpec=20220824' + }, + body: `\r +---\r +content-type: application/json; charset=utf-8\r +\r +{"hasNext":true,"data":{"allProducts":[{"delivery":{},"sku":"sku","id":"0"}]}}\r +---\r +content-type: application/json; charset=utf-8\r +\r +{"hasNext":false,"incremental":[{"path":["allProducts",0,"delivery"],"data":{"estimatedDelivery":"25.01.2000","fastestDelivery":"25.01.2000"}}]}\r +-----\r +` + }) + + await app.close() + t.end() + }) +} + +test('returns stream when using undici.fetch with @defer', async t => { + const app = Fastify() + await app.register(mercurius, { schema, resolvers, graphiql: true, defer: true }) + const url = await app.listen({ port: 0 }) + + const res = await fetch(`${url}/graphql`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'multipart/mixed; deferSpec=20220824' + }, + body: JSON.stringify({ query }) + }) + + const reader = res.body.getReader() + const { value } = await reader.read() + const result = new TextDecoder('utf-8').decode(value) + + t.same(result, `\r +---\r +content-type: application/json; charset=utf-8\r +\r +{"hasNext":true,"data":{"allProducts":[{"delivery":{},"sku":"sku","id":"0"}]}}\r +---\r +content-type: application/json; charset=utf-8\r +\r +{"hasNext":false,"incremental":[{"path":["allProducts",0,"delivery"],"data":{"estimatedDelivery":"25.01.2000","fastestDelivery":"25.01.2000"}}]}\r +-----\r +`) + + t.same(res.status, 200) + t.same(res.headers.get('content-type'), 'multipart/mixed; boundary="-"; deferSpec=20220824') + + t.teardown(async () => { + await reader.releaseLock() + app.close() + process.exit() + }) + + t.end() +}) diff --git a/test/federation.js b/test/federation.js index 2c551039..4c1bcfa4 100644 --- a/test/federation.js +++ b/test/federation.js @@ -1087,3 +1087,45 @@ test('basic federation support with \'schema\' in the schema', async (t) => { } }) }) + +test('should support directives import syntax', async (t) => { + const app = Fastify() + + const schema = ` + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.0", + import: ["@key", "@shareable", "@override"]) + + extend type Query { + hello: String + } + ` + + const resolvers = { + Query: { + hello: () => 'world' + } + } + + app.register(GQL, { + schema, + resolvers, + federationMetadata: true + }) + + await app.ready() + + const query = '{ _service { sdl } }' + const res = await app.inject({ + method: 'GET', + url: `/graphql?query=${query}` + }) + + t.same(JSON.parse(res.body), { + data: { + _service: { + sdl: schema + } + } + }) +}) diff --git a/test/gateway/schema.js b/test/gateway/schema.js index 13bdecd7..e2fce8fe 100644 --- a/test/gateway/schema.js +++ b/test/gateway/schema.js @@ -302,6 +302,65 @@ test('It builds the gateway schema correctly', async (t) => { }) }) +test('Should merge schemas correctly', async (t) => { + const [helloService, helloServicePort] = await createService( + t, + 'extend type Query { hello: String } directive @customDirective on FIELD_DEFINITION', + { Query: { hello: () => 'World' } } + ) + + const [worldService, worldServicePort] = await createService( + t, + 'extend type Query { world: String }', + { Query: { world: () => 'Hello' } } + ) + + const gateway = Fastify() + + t.teardown(async () => { + await gateway.close() + await helloService.close() + await worldService.close() + }) + + gateway.register(GQL, { + gateway: { + services: [{ + name: 'hello', + url: `http://localhost:${helloServicePort}/graphql` + }, { + name: 'world', + url: `http://localhost:${worldServicePort}/graphql` + }] + } + }) + + const query = ` + query { + hello + world + } + ` + + const res = await gateway.inject({ + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + url: '/graphql', + body: JSON.stringify({ + query + }) + }) + + t.same(JSON.parse(res.body), { + data: { + hello: 'World', + world: 'Hello' + } + }) +}) + test('It support variable inside nested arguments', async (t) => { const user = { id: 'u1', diff --git a/test/types/index.ts b/test/types/index.ts index 175a51c3..3e56828d 100644 --- a/test/types/index.ts +++ b/test/types/index.ts @@ -879,3 +879,40 @@ app.graphql.addHook('onResolution', async function (_execution, context) { expectType(context.operationsCount) expectType(context.__currentQuery) }) + +// Test graphiql configuration using an object as params +app.register(mercurius, { schema, resolvers, graphiql: { plugins: [] } }) + +app.register(mercurius, { schema, resolvers, ide: { enabled: false } }) + +app.register(mercurius, { + schema, + resolvers, + graphiql: { + enabled: true, + plugins: [ + { + fetcherWrapper: 'testFetchWrapper', + umdUrl: 'http://some-url', + props: { foo: 'bar' }, + name: 'pluginName' + } + ] + } +}) + +expectError(() => { + app.register(mercurius, { + schema, + resolvers, + graphiql: { + enabled: true, + plugins: [ + { + fetcherWrapper: 'testFetchWrapper', + props: { foo: 'bar' } + } + ] + } + }) +})