From 3af6666a37f7cf24f5567d5555c8087ccdcbdc6f Mon Sep 17 00:00:00 2001 From: Mauro Murru Date: Wed, 6 Oct 2021 18:06:15 +0200 Subject: [PATCH 01/55] feat: add status code support to ErrorWithProps (#595) * feat: add status code support to ErrorWithProps * chore: restore default status 500 & type tests * docs: update error docs --- docs/api/options.md | 92 +++++++++++++++++++++++++++++++++++++-------- index.d.ts | 3 +- lib/errors.js | 3 +- test/errors.js | 41 ++++++++++++++++++++ test/types/index.ts | 8 +++- 5 files changed, 128 insertions(+), 19 deletions(-) diff --git a/docs/api/options.md b/docs/api/options.md index 0bb20226..b6a76d96 100644 --- a/docs/api/options.md +++ b/docs/api/options.md @@ -1,20 +1,26 @@ # mercurius -- [Plugin options](#plugin-options) -- [HTTP endpoints](#http-endpoints) - - [GET /graphql](#get-graphql) - - [POST /graphql](#post-graphql) - - [POST /graphql with Content-type: application/graphql](#post-graphql-with-content-type-applicationgraphql) - - [GET /graphiql](#get-graphiql) -- [Decorators](#decorators) - - [app.graphql(source, context, variables, operationName)](#appgraphqlsource-context-variables-operationname) - - [app.graphql.extendSchema(schema), app.graphql.defineResolvers(resolvers) and app.graphql.defineLoaders(loaders)](#appgraphqlextendschemaschema-appgraphqldefineresolversresolvers-and-appgraphqldefineloadersloaders) - - [app.graphql.replaceSchema(schema)](#appgraphqlreplaceschemaschema) - - [app.graphql.transformSchema(transforms)](#appgraphqltransformschematransforms) - - [app.graphql.schema](#appgraphqlschema) - - [reply.graphql(source, context, variables, operationName)](#replygraphqlsource-context-variables-operationname) -- [Error extensions](#use-errors-extension-to-provide-additional-information-to-query-errors) - +- [mercurius](#mercurius) + - [API](#api) + - [Plugin options](#plugin-options) + - [queryDepth example](#querydepth-example) + - [HTTP endpoints](#http-endpoints) + - [GET /graphql](#get-graphql) + - [POST /graphql](#post-graphql) + - [POST /graphql with Content-type: application/graphql](#post-graphql-with-content-type-applicationgraphql) + - [GET /graphiql](#get-graphiql) + - [Decorators](#decorators) + - [app.graphql(source, context, variables, operationName)](#appgraphqlsource-context-variables-operationname) + - [app.graphql.extendSchema(schema), app.graphql.defineResolvers(resolvers) and app.graphql.defineLoaders(loaders)](#appgraphqlextendschemaschema-appgraphqldefineresolversresolvers-and-appgraphqldefineloadersloaders) + - [app.graphql.replaceSchema(schema)](#appgraphqlreplaceschemaschema) + - [app.graphql.transformSchema(transforms)](#appgraphqltransformschematransforms) + - [app.graphql.schema](#appgraphqlschema) + - [reply.graphql(source, context, variables, operationName)](#replygraphqlsource-context-variables-operationname) + - [Errors](#errors) + - [ErrorWithProps](#errorwithprops) + - [Extensions](#extensions) + - [Status code](#status-code) + - [Error formatter](#error-formatter) ## API ### Plugin options @@ -439,7 +445,33 @@ async function run() { run() ``` -### Use errors extension to provide additional information to query errors +### Errors +Mercurius help the error handling with two useful tools. + +- ErrorWithProps class +- ErrorFormatter option + +### ErrorWithProps + +ErrorWithProps can be used to create Errors to be thrown inside the resolvers or plugins. + +it takes 3 parameters: + +- message +- extensions +- statusCode + +```js +'use strict' + +throw new ErrorWithProps('message', { + ... +}, 200) +``` + +#### Extensions + +Use errors `extensions` to provide additional information to query errors GraphQL services may provide an additional entry to errors with the key `extensions` in the result. @@ -495,3 +527,31 @@ app.register(mercurius, { app.listen(3000) ``` + +#### Status code + +To control the status code for the response, the third optional parameter can be used. + +```js + + throw new mercurius.ErrorWithProps('Invalid User ID', {moreErrorInfo}) + // using de defaultErrorFormatter the response statusCode will be 500 + + throw new mercurius.ErrorWithProps('Invalid User ID', {moreErrorInfo}, 200) + // using de defaultErrorFormatter the response statusCode will be 200 + + const error = new mercurius.ErrorWithProps('Invalid User ID', {moreErrorInfo}, 500) + error.data = {foo: 'bar'} + throw error + // using de defaultErrorFormatter the response status code will be always 200 because error.data is defined + + +``` + +### Error formatter + +Allows the status code of the response to be set, and a GraphQL response for the error to be defined. + +By default uses the defaultErrorFormatter, but it can be overridden in the [mercurius options](/docs/api/options.md#plugin-options) changing the errorFormatter parameter. + +**Important**: *using the default formatter, when the error has a data property the response status code will be always 200* diff --git a/index.d.ts b/index.d.ts index 5ba18d8a..aeb54dcb 100644 --- a/index.d.ts +++ b/index.d.ts @@ -592,11 +592,12 @@ declare namespace mercurius { * Extended errors for adding additional information in error responses */ class ErrorWithProps extends Error { - constructor(message: string, extensions?: object); + constructor(message: string, extensions?: object, statusCode?: number); /** * Custom additional properties of this error */ extensions?: object; + statusCode?: number; } /** diff --git a/lib/errors.js b/lib/errors.js index ebeef58d..09abe477 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -4,9 +4,10 @@ const { formatError, GraphQLError } = require('graphql') const createError = require('fastify-error') class ErrorWithProps extends Error { - constructor (message, extensions) { + constructor (message, extensions, statusCode) { super(message) this.extensions = extensions + this.statusCode = statusCode || 500 } } diff --git a/test/errors.js b/test/errors.js index 722372f8..d01010ae 100644 --- a/test/errors.js +++ b/test/errors.js @@ -7,6 +7,11 @@ const { ErrorWithProps } = GQL const { FederatedError } = require('../lib/errors') const split = require('split2') +test('ErrorWithProps - support status code in the constructor', async (t) => { + const error = new ErrorWithProps('error', { }, 500) + t.equal(error.statusCode, 500) +}) + test('errors - multiple extended errors', async (t) => { const schema = ` type Query { @@ -726,3 +731,39 @@ test('app.graphql which throws, with JIT enabled, twice', async (t) => { t.equal(errors.length, 0) }) + +test('errors - should override statusCode to 200 if the data is present', async (t) => { + const schema = ` + type Query { + error: String + successful: String + } + ` + + const resolvers = { + Query: { + error () { + throw new ErrorWithProps('Error', undefined, 500) + }, + successful () { + return 'Runs OK' + } + } + } + + const app = Fastify() + + app.register(GQL, { + schema, + resolvers + }) + + await app.ready() + + const res = await app.inject({ + method: 'GET', + url: '/graphql?query={error,successful}' + }) + + t.equal(res.statusCode, 200) +}) diff --git a/test/types/index.ts b/test/types/index.ts index a7c39deb..64ebfa63 100644 --- a/test/types/index.ts +++ b/test/types/index.ts @@ -1,4 +1,4 @@ -import { expectAssignable } from 'tsd' +import { expectAssignable, expectError } from 'tsd' /* eslint-disable no-unused-expressions */ import { EventEmitter } from 'events' // eslint-disable-next-line no-unused-vars @@ -593,3 +593,9 @@ app.graphql.addHook('onGatewayReplaceSchema', async function (instance, schema) expectAssignable(instance) expectAssignable(schema) }) + +expectError(() => { + return new mercurius.ErrorWithProps('mess', {}, 'wrong statusCode') +}) + +expectAssignable(new mercurius.ErrorWithProps('mess', {}, 200)) From eb8d327669fc77e93ccc6a54e29efc30347ada24 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 6 Oct 2021 18:07:29 +0200 Subject: [PATCH 02/55] Bumped v8.6.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 855615c5..da4f3379 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mercurius", - "version": "8.5.0", + "version": "8.6.0", "description": "Fastify GraphQL adapter with gateway and subscription support", "main": "index.js", "types": "index.d.ts", From 7de98c8d3af50c8134bdcc841c89c753ebca58f6 Mon Sep 17 00:00:00 2001 From: Jiri Spac Date: Thu, 7 Oct 2021 14:47:53 +0200 Subject: [PATCH 03/55] fix(type): export MercuriusPlugin interface and narrow down two opts (#597) --- index.d.ts | 6 +++--- test/types/index.ts | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/index.d.ts b/index.d.ts index aeb54dcb..721a7e51 100644 --- a/index.d.ts +++ b/index.d.ts @@ -231,7 +231,7 @@ interface Gateway { serviceMap: Record; } -interface MercuriusPlugin { +export interface MercuriusPlugin { < TData extends Record = Record, TVariables extends Record = Record @@ -428,8 +428,8 @@ export interface MercuriusCommonOptions { /** * Serve GraphiQL on /graphiql if true or 'graphiql' and if routes is true */ - graphiql?: boolean | string; - ide?: boolean | string; + graphiql?: boolean | 'graphiql'; + ide?: boolean | 'graphiql'; /** * The minimum number of execution a query needs to be executed before being jit'ed. * @default true diff --git a/test/types/index.ts b/test/types/index.ts index 64ebfa63..75233cb5 100644 --- a/test/types/index.ts +++ b/test/types/index.ts @@ -6,7 +6,7 @@ import Fastify, { FastifyReply, FastifyRequest, FastifyInstance } from 'fastify' // eslint-disable-next-line no-unused-vars import { Readable } from 'stream' // eslint-disable-next-line no-unused-vars -import mercurius, { MercuriusOptions, IResolvers, MercuriusContext, MercuriusServiceMetadata } from '../..' +import mercurius, { MercuriusOptions, IResolvers, MercuriusContext, MercuriusServiceMetadata, MercuriusPlugin } from '../..' // eslint-disable-next-line no-unused-vars import { DocumentNode, ExecutionResult, GraphQLSchema, ValidationContext, ValidationRule } from 'graphql' import { makeExecutableSchema } from '@graphql-tools/schema' @@ -599,3 +599,16 @@ expectError(() => { }) expectAssignable(new mercurius.ErrorWithProps('mess', {}, 200)) + +expectError(() => { + app.register(mercurius, { + graphiql: 'nonexistent' + }) +}) + +declare module 'fastify' { +// eslint-disable-next-line no-unused-vars + interface FastifyInstance { + graphql: MercuriusPlugin + } +} From 76dfc5ee1421d68fec437ec339a6df9fd8f2c2e6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 Oct 2021 16:14:59 +0000 Subject: [PATCH 04/55] build(deps): bump actions/checkout from 2.3.4 to 2.3.5 (#605) Bumps [actions/checkout](https://github.com/actions/checkout) from 2.3.4 to 2.3.5. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v2.3.4...v2.3.5) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04636fa7..142e45b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ jobs: node-version: [12.x, 14.x, 16.x] os: [ubuntu-latest, windows-latest, macOS-latest] steps: - - uses: actions/checkout@v2.3.4 + - uses: actions/checkout@v2.3.5 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v2.4.1 with: From 644256d5d4fc2526835a661a05d2b6f4b9d4eb8c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 Oct 2021 16:26:01 +0000 Subject: [PATCH 05/55] build(deps-dev): bump split2 from 3.2.2 to 4.0.0 (#606) Bumps [split2](https://github.com/mcollina/split2) from 3.2.2 to 4.0.0. - [Release notes](https://github.com/mcollina/split2/releases) - [Commits](https://github.com/mcollina/split2/compare/v3.2.2...v4.0.0) --- updated-dependencies: - dependency-name: split2 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index da4f3379..ee82e4b3 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "pre-commit": "^1.2.2", "proxyquire": "^2.1.3", "snazzy": "^9.0.0", - "split2": "^3.2.2", + "split2": "^4.0.0", "standard": "^16.0.3", "tap": "^15.0.9", "tsd": "^0.17.0", From 6a4b19877237e4504d78843e4ca3b2483f27ac8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 Oct 2021 16:37:09 +0000 Subject: [PATCH 06/55] build(deps-dev): bump tsd from 0.17.0 to 0.18.0 (#602) Bumps [tsd](https://github.com/SamVerschueren/tsd) from 0.17.0 to 0.18.0. - [Release notes](https://github.com/SamVerschueren/tsd/releases) - [Commits](https://github.com/SamVerschueren/tsd/compare/v0.17.0...v0.18.0) --- updated-dependencies: - dependency-name: tsd dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ee82e4b3..89fed9b2 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "split2": "^4.0.0", "standard": "^16.0.3", "tap": "^15.0.9", - "tsd": "^0.17.0", + "tsd": "^0.18.0", "typescript": "^4.3.5", "wait-on": "^6.0.0" }, From 522ce3fe858fcc9f888cc4a545813452f670e194 Mon Sep 17 00:00:00 2001 From: Ben van Enckevort Date: Mon, 18 Oct 2021 11:07:01 +0100 Subject: [PATCH 07/55] [bugfix]: graphiql broken due to invalid websocket prototcol "wss::" (#607) Issue: A stray colon causing problems when using graphiql over https. Impact: Graphiql fails to start, with the following error in the console: ```DOMException: Failed to construct 'WebSocket': The URL 'wss:://[redacted]/graphql' is invalid.``` Solution: Remove the extra colon --- static/main.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/main.js b/static/main.js index 32bb79d7..8e3f793e 100644 --- a/static/main.js +++ b/static/main.js @@ -19,10 +19,10 @@ const importer = { function render () { const host = window.location.host - const websocketProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws' + const websocketProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const url = `${window.location.protocol}//${host}${window.GRAPHQL_ENDPOINT}` - const subscriptionUrl = `${websocketProtocol}://${host}${window.GRAPHQL_ENDPOINT}` + const subscriptionUrl = `${websocketProtocol}//${host}${window.GRAPHQL_ENDPOINT}` const fetcher = GraphiQL.createFetcher({ url, From 411f3c32485e7f32127fc8fd7e4cc6503004b007 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 19 Oct 2021 17:35:54 +0200 Subject: [PATCH 08/55] Added Load Balancing support to the Gateway (#608) * Added Load Balancing support to the Gateway * Added docs --- docs/api/options.md | 2 +- lib/gateway/request.js | 15 ++- lib/gateway/service-map.js | 2 +- package.json | 2 +- test/gateway/load-balancing.js | 195 +++++++++++++++++++++++++++++++++ 5 files changed, 210 insertions(+), 6 deletions(-) create mode 100644 test/gateway/load-balancing.js diff --git a/docs/api/options.md b/docs/api/options.md index b6a76d96..edbc77c4 100644 --- a/docs/api/options.md +++ b/docs/api/options.md @@ -64,7 +64,7 @@ - `gateway.services`: Service[] An array of GraphQL services that are part of the gateway - `service.name`: A unique name for the service. Required. - - `service.url`: The url of the service endpoint. Required + - `service.url`: The URL of the service endpoint. It can also be an `Array` of URLs and in which case all the requests will be load balanced throughout the URLs. Required. - `service.mandatory`: `Boolean` Marks service as mandatory. If any of the mandatory services are unavailable, gateway will exit with an error. (Default: `false`) - `service.rewriteHeaders`: `Function` A function that gets the original headers as a parameter and returns an object containing values that should be added to the headers - `service.initHeaders`: `Function` or `Object` An object or a function that returns the headers sent to the service for the initial \_service SDL query. diff --git a/lib/gateway/request.js b/lib/gateway/request.js index c92aa062..9421bef2 100644 --- a/lib/gateway/request.js +++ b/lib/gateway/request.js @@ -1,5 +1,5 @@ 'use strict' -const { Pool } = require('undici') +const { BalancedPool, Pool } = require('undici') const { URL } = require('url') const eos = require('end-of-stream') const { FederatedError } = require('../errors') @@ -19,8 +19,17 @@ function agentOption (opts) { } function buildRequest (opts) { - const url = new URL(opts.url) - const agent = new Pool(url.origin, agentOption(opts)) + let agent + if (Array.isArray(opts.url)) { + const upstreams = [] + for (const url of opts.url) { + upstreams.push(new URL(url).origin) + } + + agent = new BalancedPool(upstreams, agentOption(opts)) + } else { + agent = new Pool(new URL(opts.url).origin, agentOption(opts)) + } const rewriteHeaders = opts.rewriteHeaders || function () { return {} } diff --git a/lib/gateway/service-map.js b/lib/gateway/service-map.js index 33001db2..fa40aca9 100644 --- a/lib/gateway/service-map.js +++ b/lib/gateway/service-map.js @@ -114,7 +114,7 @@ async function buildServiceMap (services, errorHandler) { for (const service of services) { const { name, mandatory = false, initHeaders, ...opts } = service const { request, close } = buildRequest(opts) - const url = new URL(opts.url) + const url = new URL(Array.isArray(opts.url) ? opts.url[0] : opts.url) const serviceConfig = { mandatory: mandatory, diff --git a/package.json b/package.json index 89fed9b2..9fec3a34 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "secure-json-parse": "^2.4.0", "single-user-cache": "^0.5.0", "tiny-lru": "^7.0.6", - "undici": "^4.1.0", + "undici": "^4.8.0", "ws": "^8.2.2" }, "tsd": { diff --git a/test/gateway/load-balancing.js b/test/gateway/load-balancing.js new file mode 100644 index 00000000..a3908dce --- /dev/null +++ b/test/gateway/load-balancing.js @@ -0,0 +1,195 @@ +'use strict' + +const { test } = require('tap') +const Fastify = require('fastify') +const GQL = require('../..') + +async function createTestService (t, schema, resolvers = {}, fn = async () => {}) { + const service = Fastify() + service.addHook('preHandler', fn) + service.register(GQL, { + schema, + resolvers, + federationMetadata: true + }) + await service.listen(0) + return [service, service.server.address().port] +} + +const users = { + u1: { + id: 'u1', + name: 'John' + }, + u2: { + id: 'u2', + name: 'Jane' + } +} + +const posts = { + p1: { + pid: 'p1', + title: 'Post 1', + content: 'Content 1', + authorId: 'u1' + }, + p2: { + pid: 'p2', + title: 'Post 2', + content: 'Content 2', + authorId: 'u2' + }, + p3: { + pid: 'p3', + title: 'Post 3', + content: 'Content 3', + authorId: 'u1' + }, + p4: { + pid: 'p4', + title: 'Post 4', + content: 'Content 4', + authorId: 'u1' + } +} + +test('load balances two peers', async (t) => { + // User service + const userServiceSchema = ` + type Query @extends { + me: User + } + + type Metadata { + info: String! + } + + type User @key(fields: "id") { + id: ID! + name: String! + metadata(input: String!): Metadata! + }` + const userServiceResolvers = { + Query: { + me: (root, args, context, info) => { + return users.u1 + } + }, + User: { + metadata: (user, args, context, info) => { + return { + info: args.input + } + } + } + } + let user1called = 0 + let user2called = 0 + const [userService1, userServicePort1] = await createTestService(t, userServiceSchema, userServiceResolvers, async () => { + user1called++ + }) + const [userService2, userServicePort2] = await createTestService(t, userServiceSchema, userServiceResolvers, async () => { + user2called++ + }) + + // Post service + const postServiceSchema = ` + type Post @key(fields: "pid") { + pid: ID! + } + + type User @key(fields: "id") @extends { + id: ID! @external + topPosts(count: Int!): [Post] + }` + const postServiceResolvers = { + User: { + topPosts: (user, { count }, context, info) => { + return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count) + } + } + } + const [postService, postServicePort] = await createTestService(t, postServiceSchema, postServiceResolvers) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await userService1.close() + await userService2.close() + await postService.close() + }) + + gateway.register(GQL, { + gateway: { + services: [{ + name: 'user', + url: [`http://localhost:${userServicePort1}/graphql`, `http://localhost:${userServicePort2}/graphql`] + }, { + name: 'post', + url: `http://localhost:${postServicePort}/graphql` + }] + } + }) + await gateway + + const variables = { + shouldSkip: true, + input: 'hello' + } + const query = ` + query GetMe($input: String!, $shouldSkip: Boolean!) { + me { + id + name + metadata(input: $input) @skip(if: $shouldSkip) { + info + } + topPosts(count: 1) @skip(if: $shouldSkip) { + pid + } + } + }` + + { + const res = await gateway.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query, variables }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John' + } + } + }) + } + + { + const res = await gateway.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query, variables }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John' + } + } + }) + } + + // Called two times, one to get the schema and one for the query + t.equal(user1called, 2) + + // Called one time, one one for the query + t.equal(user2called, 1) +}) From 4ce28d40f6404a85991f783cef6b5747f2f55592 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 19 Oct 2021 17:37:28 +0200 Subject: [PATCH 09/55] Bumped v8.7.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9fec3a34..cec451e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mercurius", - "version": "8.6.0", + "version": "8.7.0", "description": "Fastify GraphQL adapter with gateway and subscription support", "main": "index.js", "types": "index.d.ts", From 7ec10306b2d6261d26986da02893b3c4fe459687 Mon Sep 17 00:00:00 2001 From: Jonny Green Date: Thu, 21 Oct 2021 17:50:41 +0100 Subject: [PATCH 10/55] Add load balancing support to typings (#611) --- index.d.ts | 2 +- test/types/index.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/index.d.ts b/index.d.ts index 721a7e51..958b6a0b 100644 --- a/index.d.ts +++ b/index.d.ts @@ -380,7 +380,7 @@ interface WsConnectionParams { export interface MercuriusGatewayService { name: string; - url: string; + url: string | string[]; schema?: string; wsUrl?: string; mandatory?: boolean; diff --git a/test/types/index.ts b/test/types/index.ts index 75233cb5..ce6050ed 100644 --- a/test/types/index.ts +++ b/test/types/index.ts @@ -323,6 +323,22 @@ gateway.register(mercurius, { } }) +// Gateway mode with load balanced services +gateway.register(mercurius, { + gateway: { + services: [ + { + name: 'user', + url: ['http://localhost:4001/graphql', 'http://localhost:4002/graphql'] + }, + { + name: 'post', + url: 'http://localhost:4003/graphql' + } + ] + } +}) + // Executable schema const executableSchema = makeExecutableSchema({ From f19a60ffc0aef13ffb4ac70912a6748919ab9b70 Mon Sep 17 00:00:00 2001 From: Mauro Murru Date: Sun, 24 Oct 2021 23:18:45 +0200 Subject: [PATCH 11/55] feat: gateway keepalive feature (#612) --- docs/api/options.md | 1 + examples/gateway-subscription.js | 3 ++- lib/gateway/service-map.js | 3 ++- lib/subscription-client.js | 23 ++++++++++++++++++++++- test/subscription-client.js | 28 ++++++++++++++++++++++++++++ 5 files changed, 55 insertions(+), 3 deletions(-) diff --git a/docs/api/options.md b/docs/api/options.md index edbc77c4..0c959eea 100644 --- a/docs/api/options.md +++ b/docs/api/options.md @@ -73,6 +73,7 @@ - `service.headersTimeout`: The amount of time the parser will wait to receive the complete HTTP headers, in milliseconds. (Default: `30e3` - 30 seconds) - `service.keepAliveMaxTimeout`: The maximum allowed keepAliveTimeout. (Default: `5e3` - 5 seconds) - `service.maxHeaderSize`: The maximum length of request headers in bytes. (Default: `16384` - 16KiB) + - `service.keepAlive`: The amount of time pass between the keep-alive messages sent from the gateway to the service, if `undefined`, no keep-alive messages will be sent. (Default: `undefined`) - `service.wsUrl`: The url of the websocket endpoint - `service.wsConnectionParams`: `Function` or `Object` - `wsConnectionParams.connectionInitPayload`: `Function` or `Object` An object or a function that returns the `connection_init` payload sent to the service. diff --git a/examples/gateway-subscription.js b/examples/gateway-subscription.js index bdcdef6f..715fc5cd 100644 --- a/examples/gateway-subscription.js +++ b/examples/gateway-subscription.js @@ -270,7 +270,8 @@ async function start () { wsUrl: 'ws://localhost:4003/graphql', wsConnectionParams: { protocols: ['graphql-transport-ws'] // optional, if not set, will use the default protocol (graphql-ws) - } + }, + keepAlive: 3000 }] } }) diff --git a/lib/gateway/service-map.js b/lib/gateway/service-map.js index fa40aca9..ab53f754 100644 --- a/lib/gateway/service-map.js +++ b/lib/gateway/service-map.js @@ -52,7 +52,8 @@ function createTypeMap (schemaDefinition) { async function getWsOpts (service) { let opts = { - serviceName: service.name + serviceName: service.name, + keepAlive: service.keepAlive } if (typeof service.wsConnectionParams === 'object') { opts = { ...opts, ...service.wsConnectionParams } diff --git a/lib/subscription-client.js b/lib/subscription-client.js index f588df18..4bf0638a 100644 --- a/lib/subscription-client.js +++ b/lib/subscription-client.js @@ -27,7 +27,8 @@ class SubscriptionClient { failedConnectionCallback, failedReconnectCallback, connectionInitPayload, - rewriteConnectionInitPayload + rewriteConnectionInitPayload, + keepAlive } = config this.tryReconnect = reconnect @@ -39,6 +40,7 @@ class SubscriptionClient { this.failedReconnectCallback = failedReconnectCallback this.connectionInitPayload = connectionInitPayload this.rewriteConnectionInitPayload = rewriteConnectionInitPayload + this.keepAlive = keepAlive if (Array.isArray(protocols) && protocols.length > 0) { this.protocols = protocols @@ -47,6 +49,7 @@ class SubscriptionClient { } this.protocolMessageTypes = getProtocolByName(this.protocols[0]) + this.keepAliveInterval = undefined if (this.protocolMessageTypes === null) { throw new MER_ERR_INVALID_OPTS(`${this.protocols[0]} is not a valid gateway subscription protocol`) @@ -65,6 +68,9 @@ class SubscriptionClient { ? await this.connectionInitPayload() : this.connectionInitPayload this.sendMessage(null, this.protocolMessageTypes.GQL_CONNECTION_INIT, payload) + if (this.keepAlive) { + this.startKeepAliveInterval() + } } catch (err) { this.close(this.tryReconnect, false) } @@ -93,6 +99,10 @@ class SubscriptionClient { this.unsubscribeAll() } + if (this.keepAlive && this.keepAliveTimeoutId) { + this.stopKeepAliveInterval() + } + this.socket.close() this.socket = null this.reconnecting = false @@ -292,6 +302,17 @@ class SubscriptionClient { return operationId } + + startKeepAliveInterval () { + this.keepAliveTimeoutId = setInterval(() => { + this.sendMessage(null, this.protocolMessageTypes.GQL_CONNECTION_KEEP_ALIVE) + }, this.keepAlive) + this.keepAliveTimeoutId.unref() + } + + stopKeepAliveInterval () { + clearTimeout(this.keepAliveTimeoutId) + } } module.exports = SubscriptionClient diff --git a/test/subscription-client.js b/test/subscription-client.js index 107d2edc..ae3fe888 100644 --- a/test/subscription-client.js +++ b/test/subscription-client.js @@ -367,6 +367,34 @@ test('subscription client sending empty object payload on connection init', (t) }) }) +test('subscription client sends GQL_CONNECTION_KEEP_ALIVE when the keep alive option is active', (t) => { + const server = new WS.Server({ port: 0 }) + const port = server.address().port + const clock = FakeTimers.createClock() + + server.on('connection', function connection (ws) { + ws.on('message', function incoming (message, isBinary) { + const data = JSON.parse(isBinary ? message : message.toString()) + if (data.type === 'connection_init') { + ws.send(JSON.stringify({ id: '1', type: 'connection_ack' })) + } else if (data.type === 'start') { + ws.send(JSON.stringify({ id: '2', type: 'complete' })) + } else if (data.type === 'ka') { + client.close() + server.close() + t.end() + } + }) + }) + + const client = new SubscriptionClient(`ws://localhost:${port}`, { + reconnect: false, + serviceName: 'test-service', + keepAlive: 1000 + }) + clock.tick(1000) +}) + test('subscription client not throwing error on GQL_CONNECTION_KEEP_ALIVE type payload received', (t) => { const clock = FakeTimers.createClock() const server = new WS.Server({ port: 0 }) From 828bb47a878fe29b9e3dcd2bff3913e7c636b522 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 25 Oct 2021 14:55:04 +0200 Subject: [PATCH 12/55] Bumped v8.8.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cec451e4..ad76ab06 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mercurius", - "version": "8.7.0", + "version": "8.8.0", "description": "Fastify GraphQL adapter with gateway and subscription support", "main": "index.js", "types": "index.d.ts", From 6c523d96f57eb08a33a3a2b7b2e1d48c4e12e869 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Oct 2021 16:31:16 +0000 Subject: [PATCH 13/55] build(deps): bump single-user-cache from 0.5.0 to 0.6.0 (#613) Bumps [single-user-cache](https://github.com/mcollina/single-user-cache) from 0.5.0 to 0.6.0. - [Release notes](https://github.com/mcollina/single-user-cache/releases) - [Commits](https://github.com/mcollina/single-user-cache/commits/v0.6.0) --- updated-dependencies: - dependency-name: single-user-cache dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ad76ab06..1855abb3 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "promise.allsettled": "^1.0.4", "readable-stream": "^3.6.0", "secure-json-parse": "^2.4.0", - "single-user-cache": "^0.5.0", + "single-user-cache": "^0.6.0", "tiny-lru": "^7.0.6", "undici": "^4.8.0", "ws": "^8.2.2" From 8c45e6fa012d4f53329d4d4d876c2d1b9e6d2b4a Mon Sep 17 00:00:00 2001 From: Giacomo Rebonato Date: Sun, 31 Oct 2021 09:26:29 +0100 Subject: [PATCH 14/55] - adding missing import for example (#618) --- docs/integrations/type-graphql.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/integrations/type-graphql.md b/docs/integrations/type-graphql.md index 5ab1d3c8..c50c5ca2 100644 --- a/docs/integrations/type-graphql.md +++ b/docs/integrations/type-graphql.md @@ -13,7 +13,7 @@ Now you can define a schema using classes and decorators: ```ts // recipe.ts -import { Field, ObjectType, Int, Float, Resolver, Query } from "type-graphql"; +import { Arg, Field, ObjectType, Int, Float, Resolver, Query } from "type-graphql"; @ObjectType({ description: "Object representing cooking recipe" }) export class Recipe { @@ -44,7 +44,7 @@ export class Recipe { @Resolver() export class RecipeResolver { @Query((returns) => Recipe, { nullable: true }) - async recipe(@Arg("title") title: string): Promise { + async recipe(@Arg("title") title: string): Promise | undefined> { return { description: "Desc 1", title: "Recipe 1", @@ -62,6 +62,7 @@ This can be linked to the Mercurius plugin: import "reflect-metadata"; import fastify from "fastify"; import mercurius from "mercurius"; +import { buildSchema } from 'type-graphql' import { RecipeResolver } from "./recipe"; From c6b2f4907b0043c4c9e74838c2f88c38d7f21a07 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Nov 2021 16:28:53 +0000 Subject: [PATCH 15/55] build(deps): bump graphql-jit from 0.6.0 to 0.7.0 (#621) Bumps [graphql-jit](https://github.com/ruiaraujo/graphql-jit) from 0.6.0 to 0.7.0. - [Release notes](https://github.com/ruiaraujo/graphql-jit/releases) - [Commits](https://github.com/ruiaraujo/graphql-jit/compare/v0.6.0...v0.7.0) --- updated-dependencies: - dependency-name: graphql-jit dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1855abb3..7fa969f1 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "fastify-static": "^4.2.2", "fastify-websocket": "^4.0.0", "graphql": "^15.5.1", - "graphql-jit": "^0.6.0", + "graphql-jit": "^0.7.0", "mqemitter": "^4.4.1", "p-map": "^4.0.0", "promise.allsettled": "^1.0.4", From 813e940dc2941efa3d85551bc45224e73a19d0ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Nov 2021 16:16:49 +0000 Subject: [PATCH 16/55] build(deps): bump actions/checkout from 2.3.5 to 2.4.0 (#624) Bumps [actions/checkout](https://github.com/actions/checkout) from 2.3.5 to 2.4.0. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v2.3.5...v2.4.0) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 142e45b8..a14d71fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ jobs: node-version: [12.x, 14.x, 16.x] os: [ubuntu-latest, windows-latest, macOS-latest] steps: - - uses: actions/checkout@v2.3.5 + - uses: actions/checkout@v2.4.0 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v2.4.1 with: From 40def58c9bcd315a20fd8bd8b328c0b91a64f360 Mon Sep 17 00:00:00 2001 From: Sameer Srivastava Date: Tue, 9 Nov 2021 14:56:42 +0530 Subject: [PATCH 17/55] fix: graphiql not loading without sw (#637) --- static/main.js | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/static/main.js b/static/main.js index 8e3f793e..526a0c08 100644 --- a/static/main.js +++ b/static/main.js @@ -39,25 +39,28 @@ function render () { ) } +function importDependencies () { + const link = document.createElement('link') + link.href = 'https://unpkg.com/graphiql@1.4.2/graphiql.css' + link.type = 'text/css' + link.rel = 'stylesheet' + link.media = 'screen,print' + document.getElementsByTagName('head')[0].appendChild(link) + + return importer.urls([ + 'https://unpkg.com/react@16.8.0/umd/react.production.min.js', + 'https://unpkg.com/react-dom@16.8.0/umd/react-dom.production.min.js', + 'https://unpkg.com/graphiql@1.4.2/graphiql.min.js', + 'https://unpkg.com/subscriptions-transport-ws@0.9.19/browser/client.js' + ]) +} + if ('serviceWorker' in navigator) { navigator .serviceWorker .register('./graphiql/sw.js') - .then(function () { - const link = document.createElement('link') - link.href = 'https://unpkg.com/graphiql@1.4.2/graphiql.css' - link.type = 'text/css' - link.rel = 'stylesheet' - link.media = 'screen,print' - document.getElementsByTagName('head')[0].appendChild(link) - - return importer.urls([ - 'https://unpkg.com/react@16.8.0/umd/react.production.min.js', - 'https://unpkg.com/react-dom@16.8.0/umd/react-dom.production.min.js', - 'https://unpkg.com/graphiql@1.4.2/graphiql.min.js', - 'https://unpkg.com/subscriptions-transport-ws@0.9.19/browser/client.js' - ]) - }).then(render) + .then(importDependencies).then(render) } else { - render() + importDependencies() + .then(render) } From 1be6d41ad305b9a5e031d1f45b749ca376097479 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Tue, 9 Nov 2021 11:12:12 +0100 Subject: [PATCH 18/55] docs: add onResolution description (#631) --- docs/hooks.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/hooks.md b/docs/hooks.md index 4de8e2d8..c92119a4 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -68,7 +68,7 @@ fastify.graphql.addHook('preExecution', async (schema, document, context) => { const { modifiedDocument, errors } = await asyncMethod(document) return { - document: modifiedDocument + document: modifiedDocument, errors } }) @@ -90,7 +90,7 @@ fastify.graphql.addHook('preGatewayExecution', async (schema, document, context, const { modifiedDocument, errors } = await asyncMethod(document) return { - document: modifiedDocument + document: modifiedDocument, errors } }) @@ -98,6 +98,8 @@ fastify.graphql.addHook('preGatewayExecution', async (schema, document, context, ### onResolution +The `onResolution` hooks run after the GraphQL query execution and you can access the result via the `execution` argument. + ```js fastify.graphql.addHook('onResolution', async (execution, context) => { await asyncMethod() From 620379cefa59881c09913f1e1f1767fef1f6b51c Mon Sep 17 00:00:00 2001 From: chemicalkosek Date: Tue, 9 Nov 2021 15:16:31 +0100 Subject: [PATCH 19/55] Update readme.md (#640) Add faq page to readme docs links --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 514e5a4f..305a1bd2 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Features: - [Integrations](docs/integrations/) - [Related Plugins](docs/plugins.md) - [Protocol Extensions](/docs/protocol-extension.md) +- [Faq](/docs/faq.md) - [Acknowledgements](#acknowledgements) - [License](#license) From 59006856cbfd85f0651b80e015a7e0f5c524e9ba Mon Sep 17 00:00:00 2001 From: chemicalkosek Date: Tue, 9 Nov 2021 16:00:38 +0100 Subject: [PATCH 20/55] Update sidebar in docs (#639) Add Faq page to sidebar. --- docsify/sidebar.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docsify/sidebar.md b/docsify/sidebar.md index 0df840a9..fb0ca99f 100644 --- a/docsify/sidebar.md +++ b/docsify/sidebar.md @@ -30,3 +30,4 @@ * [mercurius-upload](/docs/plugins#mercurius-upload) * [altair-fastify-plugin](/docs/plugins#altair-fastify-plugin) * [Protocol Extensions](/docs/protocol-extension) +* [Faq](/docs/faq) From 2b68bf3310a2c6067e985e4e4a2c84570ba57d17 Mon Sep 17 00:00:00 2001 From: Oleksii Pysanko Date: Tue, 9 Nov 2021 17:00:55 +0200 Subject: [PATCH 21/55] add __currentQuery to subscription context (#427) * add __currentQuery to subscription context * add cache refresh in subscriptions * fix * add headers for subscriptions * fix empty payload subscription headers * improve subscription headers setting * improve subscription headers setting * chore: add gateway subscription tests * - add __currentQuery getter (#417) Co-authored-by: Mikhailo Marynenko <0x77dev@protonmail.com> Co-authored-by: Misha Marinenko --- lib/gateway/make-resolver.js | 4 +- lib/subscription-connection.js | 20 +- lib/subscription.js | 2 +- test/gateway/subscription.js | 424 +++++++++++++++++++++++++++++++++ 4 files changed, 443 insertions(+), 7 deletions(-) diff --git a/lib/gateway/make-resolver.js b/lib/gateway/make-resolver.js index ef25c726..118cf60c 100644 --- a/lib/gateway/make-resolver.js +++ b/lib/gateway/make-resolver.js @@ -506,7 +506,9 @@ function makeResolver ({ service, createOperation, transformData, isQuery, isRef return transformData(response) } - const response = await reply[kEntityResolvers][`${service.name}Entity`]({ + const entityResolvers = reply.entityResolversFactory ? reply.entityResolversFactory.create() : reply[kEntityResolvers] + + const response = await entityResolvers[`${service.name}Entity`]({ query, variables, originalRequestHeaders: reply.request.headers, diff --git a/lib/subscription-connection.js b/lib/subscription-connection.js index 7305b48b..6911cef1 100644 --- a/lib/subscription-connection.js +++ b/lib/subscription-connection.js @@ -1,7 +1,7 @@ 'use strict' const on = require('events.on') -const { subscribe, parse } = require('graphql') +const { subscribe, parse, print } = require('graphql') const { SubscriptionContext } = require('./subscriber') const { kEntityResolvers } = require('./gateway/make-resolver') const sJSON = require('secure-json-parse') @@ -15,7 +15,7 @@ module.exports = class SubscriptionConnection { subscriber, fastify, lruGatewayResolvers, - entityResolvers, + entityResolversFactory, context = {}, onConnect, onDisconnect, @@ -24,7 +24,7 @@ module.exports = class SubscriptionConnection { this.fastify = fastify this.socket = socket this.lruGatewayResolvers = lruGatewayResolvers - this.entityResolvers = entityResolvers + this.entityResolversFactory = entityResolversFactory this.subscriber = subscriber this.onConnect = onConnect this.onDisconnect = onDisconnect @@ -33,6 +33,8 @@ module.exports = class SubscriptionConnection { this.context = context this.isReady = false this.resolveContext = resolveContext + this.headers = {} + this.protocolMessageTypes = getProtocolByName(socket.protocol) this.socket.on('error', this.handleConnectionClose.bind(this)) this.handleConnection() @@ -123,6 +125,10 @@ module.exports = class SubscriptionConnection { this.context._connectionInit = data.payload + if (data.payload && data.payload.headers) { + this.headers = data.payload.headers + } + this.sendMessage(this.protocolMessageTypes.GQL_CONNECTION_ACK) this.isReady = true } @@ -183,12 +189,16 @@ module.exports = class SubscriptionConnection { {}, // rootValue { ...context, + get __currentQuery () { + return print(document) + }, pubsub: sc, lruGatewayResolvers: this.lruGatewayResolvers, reply: { + entityResolversFactory: this.entityResolversFactory, + request: { headers: this.headers }, [kLoaders]: subscriptionLoaders && subscriptionLoaders.create(context), - [kEntityResolvers]: this.entityResolvers, - request: { headers: {} } + [kEntityResolvers]: this.entityResolvers } }, variables, diff --git a/lib/subscription.js b/lib/subscription.js index b0026e9d..009cc289 100644 --- a/lib/subscription.js +++ b/lib/subscription.js @@ -42,7 +42,7 @@ function createConnectionHandler ({ subscriber, fastify, onConnect, onDisconnect onConnect, onDisconnect, lruGatewayResolvers, - entityResolvers: entityResolversFactory && entityResolversFactory.create(), + entityResolversFactory, context, resolveContext }) diff --git a/test/gateway/subscription.js b/test/gateway/subscription.js index 97e79aed..4355806a 100644 --- a/test/gateway/subscription.js +++ b/test/gateway/subscription.js @@ -760,3 +760,427 @@ test('subscriptions work with scalars', async t => { await createGateway() await runSubscription() }) + +test('subscriptions work with different contexts', async (t) => { + let testService + let gateway + + const schema = ` + extend type Query { + ignored: Boolean! + } + + extend type Mutation { + addTestEvent(value: Int!): Int! + } + + type Event @key(fields: "value") { + value: Int! @external + } + + extend type Subscription { + testEvent(value: Int!): Int! + }` + + const resolvers = { + Query: { + ignored: () => true + }, + Mutation: { + addTestEvent: async (_, { value }, { pubsub }) => { + await pubsub.publish({ + topic: 'testEvent', + payload: { testEvent: value } + }) + + return value + } + }, + Subscription: { + testEvent: { + subscribe: GQL.withFilter( + async (_, __, { pubsub }) => { + return await pubsub.subscribe('testEvent') + }, + ({ testEvent }, { value }) => { + return testEvent === value + } + ) + } + } + } + + function createTestService () { + testService = Fastify() + testService.register(GQL, { + schema, + resolvers, + federationMetadata: true, + subscription: true + }) + + return testService.listen(0) + } + + function createGateway () { + const testServicePort = testService.server.address().port + + gateway = Fastify() + gateway.register(GQL, { + subscription: true, + gateway: { + services: [{ + name: 'testService', + url: `http://localhost:${testServicePort}/graphql`, + wsUrl: `ws://localhost:${testServicePort}/graphql` + }] + } + }) + + return gateway.listen(0) + } + + function runSubscription (id) { + const ws = new WebSocket(`ws://localhost:${(gateway.server.address()).port}/graphql`, 'graphql-ws') + const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8', objectMode: true }) + t.teardown(async () => { + client.destroy() + }) + client.setEncoding('utf8') + + client.write(JSON.stringify({ + type: 'connection_init' + })) + + client.write(JSON.stringify({ + id: 1, + type: 'start', + payload: { + query: ` + subscription TestEvent($value: Int!) { + testEvent(value: $value) + } + `, + variables: { value: id } + } + })) + + client.write(JSON.stringify({ + id: 2, + type: 'start', + payload: { + query: ` + subscription TestEvent($value: Int!) { + testEvent(value: $value) + } + `, + variables: { value: id } + } + })) + + client.write(JSON.stringify({ + id: 2, + type: 'stop' + })) + + let end + + const endPromise = new Promise(resolve => { + end = resolve + }) + + client.on('data', (chunk) => { + const data = JSON.parse(chunk) + + if (data.id === 1 && data.type === 'data') { + t.equal(chunk, JSON.stringify({ + type: 'data', + id: 1, + payload: { + data: { + testEvent: id + } + } + })) + + client.end() + end() + } else if (data.id === 2 && data.type === 'complete') { + gateway.inject({ + method: 'POST', + url: '/graphql', + body: { + query: ` + mutation AddTestEvent($value: Int!) { + addTestEvent(value: $value) + } + `, + variables: { value: id } + } + }) + } + }) + + return endPromise + } + + await createTestService() + await createGateway() + const subscriptions = new Array(10).fill(null).map((_, i) => runSubscription(i)) + await Promise.all(subscriptions) + + t.teardown(async () => { + await gateway.close() + await testService.close() + }) +}) + +test('connection_init headers available in federation event resolver', async (t) => { + let subscriptionService + let resolverService + let gateway + + const onConnect = (data) => { + if (data.payload.gateway) { + return { headers: {} } + } else { + return { + headers: data.payload.headers + } + } + } + + const wsConnectionParams = { + connectionInitPayload () { + return { + gateway: true + } + } + } + + function createResolverService () { + const schema = ` + extend type Query { + ignoredResolver: Boolean! + } + + extend type Event @key(fields: "value") { + id: ID! @external + userId: Int! + } + ` + + const resolvers = { + Query: { + ignoredResolver: () => true + }, + Event: { + userId: (root, args, ctx) => { + return parseInt(root.id) + } + } + } + + resolverService = Fastify() + resolverService.register(GQL, { + schema, + resolvers, + federationMetadata: true, + subscription: { onConnect } + }) + + return resolverService.listen(0) + } + + function createSubscriptionService () { + const schema = ` + extend type Query { + ignored: Boolean! + } + + type Event @key(fields: "id") { + id: ID! + } + + extend type Mutation { + addTestEvent(value: Int!): Int! + } + + extend type Subscription { + testEvent(value: Int!): Event! + } + ` + + const resolvers = { + Query: { + ignored: () => true + }, + Mutation: { + addTestEvent: async (_, { value }, { pubsub }) => { + await pubsub.publish({ + topic: 'testEvent', + payload: { testEvent: { id: value } } + }) + + return value + } + }, + Subscription: { + testEvent: { + subscribe: GQL.withFilter( + async (_, __, { pubsub }) => { + return await pubsub.subscribe('testEvent') + }, + (root, args, { headers }) => { + return headers.userId === root.testEvent.id + } + ) + } + } + } + + subscriptionService = Fastify() + subscriptionService.register(GQL, { + schema, + resolvers, + federationMetadata: true, + subscription: { onConnect } + }) + + return subscriptionService.listen(0) + } + + function createGateway () { + const subscriptionServicePort = subscriptionService.server.address().port + const resolverServicePort = resolverService.server.address().port + + gateway = Fastify() + gateway.register(GQL, { + subscription: true, + gateway: { + services: [ + { + name: 'subscriptionService', + url: `http://localhost:${subscriptionServicePort}/graphql`, + wsUrl: `ws://localhost:${subscriptionServicePort}/graphql`, + wsConnectionParams + }, + { + name: 'resolverService', + url: `http://localhost:${resolverServicePort}/graphql`, + wsUrl: `ws://localhost:${resolverServicePort}/graphql`, + wsConnectionParams + } + ] + } + }) + + return gateway.listen(0) + } + + function runSubscription (id) { + const ws = new WebSocket(`ws://localhost:${(gateway.server.address()).port}/graphql`, 'graphql-ws') + const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8', objectMode: true }) + t.teardown(async () => { + client.destroy() + }) + client.setEncoding('utf8') + + client.write(JSON.stringify({ + type: 'connection_init', + payload: { headers: { userId: id } } + })) + + client.write(JSON.stringify({ + id: 1, + type: 'start', + payload: { + query: ` + subscription TestEvent($value: Int!) { + testEvent(value: $value) { + id + userId + } + } + `, + variables: { value: id } + } + })) + + client.write(JSON.stringify({ + id: 2, + type: 'start', + payload: { + query: ` + subscription TestEvent($value: Int!) { + testEvent(value: $value) { + id + userId + } + } + `, + variables: { value: id } + } + })) + + client.write(JSON.stringify({ + id: 2, + type: 'stop' + })) + + let end + + const endPromise = new Promise(resolve => { + end = resolve + }) + + client.on('data', (chunk) => { + const data = JSON.parse(chunk) + + if (data.id === 1 && data.type === 'data') { + t.equal(chunk, JSON.stringify({ + type: 'data', + id: 1, + payload: { + data: { + testEvent: { + id: String(id), + userId: id + } + } + } + })) + + client.end() + end() + } else if (data.id === 2 && data.type === 'complete') { + gateway.inject({ + method: 'POST', + url: '/graphql', + body: { + query: ` + mutation AddTestEvent($value: Int!) { + addTestEvent(value: $value) + } + `, + variables: { value: id } + } + }) + } + }) + + return endPromise + } + + await createSubscriptionService() + await createResolverService() + await createGateway() + const subscriptions = new Array(10).fill(null).map((_, i) => runSubscription(i)) + await Promise.all(subscriptions) + + t.teardown(async () => { + await gateway.close() + await subscriptionService.close() + await resolverService.close() + }) +}) From a2366ceaaa0f18403b7e99ef5c176e5c7d97f3bb Mon Sep 17 00:00:00 2001 From: Sameer Srivastava Date: Tue, 9 Nov 2021 21:28:38 +0530 Subject: [PATCH 22/55] Add info object to loaders (#630) --- docs/loaders.md | 10 +- index.js | 6 +- test/loaders.js | 285 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 295 insertions(+), 6 deletions(-) diff --git a/docs/loaders.md b/docs/loaders.md index c390e446..68341961 100644 --- a/docs/loaders.md +++ b/docs/loaders.md @@ -9,10 +9,12 @@ also cache the results, so that other parts of the GraphQL do not have to fetch the same data. Each loader function has the signature `loader(queries, context)`. -`queries` is an array of objects defined as `{ obj, params }` where -`obj` is the current object and `params` are the GraphQL params (those -are the first two parameters of a normal resolver). The `context` is the -GraphQL context, and it includes a `reply` object. +`queries` is an array of objects defined as `{ obj, params, info }` where +`obj` is the current object, `params` are the GraphQL params (those +are the first two parameters of a normal resolver) and `info` contains +additional information about the query and execution. `info` object is +only available in the loader if the cache is set to `false`. The `context` +is the GraphQL context, and it includes a `reply` object. Example: diff --git a/index.js b/index.js index e88b0c72..f44eda6b 100644 --- a/index.js +++ b/index.js @@ -368,12 +368,14 @@ const plugin = fp(async function (app, opts) { function defineLoader (name) { // async needed because of throw - return async function (obj, params, { reply }) { + return async function (obj, params, { reply }, info) { if (!reply) { throw new MER_ERR_INVALID_OPTS('loaders only work via reply.graphql()') } - return reply[kLoaders][name]({ obj, params }) + const query = opts.cache === false ? { obj, params, info } : { obj, params } + + return reply[kLoaders][name](query) } } diff --git a/test/loaders.js b/test/loaders.js index ed9523e8..28cd24f8 100644 --- a/test/loaders.js +++ b/test/loaders.js @@ -623,3 +623,288 @@ test('subscriptions properly execute loaders', t => { }) }) }) + +test('Pass info to loader if cache is disabled', async (t) => { + const app = Fastify() + + const dogs = [{ + dogName: 'Max', + age: 10 + }, { + dogName: 'Charlie', + age: 13 + }, { + dogName: 'Buddy', + age: 15 + }, { + dogName: 'Max', + age: 17 + }] + + const cats = [{ + catName: 'Charlie', + age: 10 + }, { + catName: 'Max', + age: 13 + }, { + catName: 'Buddy', + age: 15 + }] + + const owners = { + Max: { + nickName: 'Jennifer', + age: 25 + }, + Charlie: { + nickName: 'Sarah', + age: 35 + }, + Buddy: { + nickName: 'Tracy', + age: 45 + } + } + + const schema = ` + type Human { + nickName: String! + age: Int! + } + + type Dog { + dogName: String! + age: Int! + owner: Human + } + + type Cat { + catName: String! + age: Int! + owner: Human + } + + type Query { + dogs: [Dog] + cats: [Cat] + } + ` + + const query = `{ + dogs { + dogName + age + owner { + nickName + age + } + } + cats { + catName + owner { + age + } + } + }` + const resolvers = { + Query: { + dogs: (_, params, context) => { + return dogs + }, + cats: (_, params, context) => { + return cats + } + } + } + + const loaders = { + Dog: { + async owner (queries, context) { + t.equal(context.app, app) + return queries.map(({ obj, info }) => { + // verify info properties + t.equal(info.operation.operation, 'query') + + const resolverOutputParams = info.operation.selectionSet.selections[0].selectionSet.selections + t.equal(resolverOutputParams.length, 3) + t.equal(resolverOutputParams[0].name.value, 'dogName') + t.equal(resolverOutputParams[1].name.value, 'age') + t.equal(resolverOutputParams[2].name.value, 'owner') + + const loaderOutputParams = resolverOutputParams[2].selectionSet.selections + + t.equal(loaderOutputParams.length, 2) + t.equal(loaderOutputParams[0].name.value, 'nickName') + t.equal(loaderOutputParams[1].name.value, 'age') + + return owners[obj.dogName] + }) + } + }, + Cat: { + async owner (queries, context) { + t.equal(context.app, app) + return queries.map(({ obj, info }) => { + // verify info properties + t.equal(info.operation.operation, 'query') + + const resolverOutputParams = info.operation.selectionSet.selections[1].selectionSet.selections + t.equal(resolverOutputParams.length, 2) + t.equal(resolverOutputParams[0].name.value, 'catName') + t.equal(resolverOutputParams[1].name.value, 'owner') + + const loaderOutputParams = resolverOutputParams[1].selectionSet.selections + + t.equal(loaderOutputParams.length, 1) + t.equal(loaderOutputParams[0].name.value, 'age') + + return owners[obj.catName] + }) + } + } + } + + app.register(GQL, { + schema, + resolvers, + loaders, + cache: false + }) + + await app.ready() + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + body: { + query + } + }) + + t.equal(res.statusCode, 200) + t.strictSame(JSON.parse(res.body), { + data: { + dogs: [ + { + dogName: 'Max', + age: 10, + owner: { + nickName: 'Jennifer', + age: 25 + } + }, + { + dogName: 'Charlie', + age: 13, + owner: { + nickName: 'Sarah', + age: 35 + } + }, + { + dogName: 'Buddy', + age: 15, + owner: { + nickName: 'Tracy', + age: 45 + } + }, + { + dogName: 'Max', + age: 17, + owner: { + nickName: 'Jennifer', + age: 25 + } + } + ], + cats: [ + { + catName: 'Charlie', + owner: { + age: 35 + } + }, + { + catName: 'Max', + owner: { + age: 25 + } + }, + { + catName: 'Buddy', + owner: { + age: 45 + } + } + ] + } + }) +}) + +test('should not pass info to loader if cache is enabled', async (t) => { + const app = Fastify() + + const resolvers = { + Query: { + dogs: (_, params, context) => { + return dogs + } + } + } + + const loaders = { + Dog: { + async owner (queries) { + t.equal(queries[0].info, undefined) + return queries.map(({ obj }) => owners[obj.name]) + } + } + } + + app.register(GQL, { + schema, + resolvers, + loaders, + cache: true + }) + + // needed so that graphql is defined + await app.ready() + + const query = 'query { dogs { name owner { name } } }' + const res = await app.inject({ + method: 'POST', + url: '/graphql', + body: { + query + } + }) + + t.same(JSON.parse(res.body), { + data: { + dogs: [{ + name: 'Max', + owner: { + name: 'Jennifer' + } + }, { + name: 'Charlie', + owner: { + name: 'Sarah' + } + }, { + name: 'Buddy', + owner: { + name: 'Tracy' + } + }, { + name: 'Max', + owner: { + name: 'Jennifer' + } + }] + } + }) +}) From 1bb74f9e9da44e5d8420af207675bddf54b7ba00 Mon Sep 17 00:00:00 2001 From: Tobias Date: Wed, 10 Nov 2021 03:05:58 -0500 Subject: [PATCH 23/55] [bugfix]: "Cannot read property 'create' of undefined" with encapsulated app where parent object has loaders defined (#633) --- index.js | 2 +- .../test/reply-decorator.js.test.cjs | 4 ++ test/reply-decorator.js | 52 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index f44eda6b..340add25 100644 --- a/index.js +++ b/index.js @@ -258,7 +258,7 @@ const plugin = fp(async function (app, opts) { context = Object.assign(context, { reply: this, app }) if (app[kFactory]) { - this[kLoaders] = factory.create(context) + this[kLoaders] = app[kFactory].create(context) } return app.graphql(source, context, variables, operationName) diff --git a/tap-snapshots/test/reply-decorator.js.test.cjs b/tap-snapshots/test/reply-decorator.js.test.cjs index f47f10d3..6a458c61 100644 --- a/tap-snapshots/test/reply-decorator.js.test.cjs +++ b/tap-snapshots/test/reply-decorator.js.test.cjs @@ -8,3 +8,7 @@ exports['test/reply-decorator.js TAP reply decorator set status code to 400 with bad query > must match snapshot 1'] = ` {"errors":[{"message":"Syntax Error: Expected Name, found .","locations":[{"line":1,"column":18}]}]} ` + +exports['test/reply-decorator.js TAP reply decorator supports encapsulation when loaders are defined in parent object > must match snapshot 1'] = ` +{"data":{"multiply":25}} +` diff --git a/test/reply-decorator.js b/test/reply-decorator.js index 21a3afba..f2307755 100644 --- a/test/reply-decorator.js +++ b/test/reply-decorator.js @@ -123,3 +123,55 @@ test('reply decorator set status code to 400 with bad query', async (t) => { t.equal(res.statusCode, 400) t.matchSnapshot(JSON.stringify(JSON.parse(res.body))) }) + +test('reply decorator supports encapsulation when loaders are defined in parent object', async (t) => { + const app = Fastify() + const schema = ` + type Query { + add(x: Int, y: Int): Int + } + ` + const resolvers = { + add: async ({ x, y }) => x + y + } + + app.register(GQL, { + schema, + resolvers, + loaders: {} + }) + + app.register(async (app) => { + const schema = ` + type Query { + multiply(x: Int, y: Int): Int + } + ` + const resolvers = { + multiply: async ({ x, y }) => x * y + } + + app.register(GQL, { + schema, + resolvers, + prefix: '/prefix' + }) + }) + + const res = await app.inject({ + method: 'POST', + url: '/prefix/graphql', + payload: { + query: '{ multiply(x: 5, y: 5) }' + } + }) + + t.equal(res.statusCode, 200) + t.same(JSON.parse(res.body), { + data: { + multiply: 25 + } + }) + + t.matchSnapshot(JSON.stringify(JSON.parse(res.body))) +}) From 28198fea3c0829b6b56b0e440146797b6f058521 Mon Sep 17 00:00:00 2001 From: AliG Date: Wed, 10 Nov 2021 09:25:02 +0100 Subject: [PATCH 24/55] Fix incorrect message type for GQL_STOP when using graphql-transport-ws (#638) --- lib/subscription-protocol.js | 7 +++- test/subscription-connection.js | 72 +++++++++++++++++++++++++++++++-- test/subscription-protocol.js | 2 +- 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/lib/subscription-protocol.js b/lib/subscription-protocol.js index a480555a..79654b99 100644 --- a/lib/subscription-protocol.js +++ b/lib/subscription-protocol.js @@ -1,11 +1,14 @@ 'use strict' const GRAPHQL_WS = 'graphql-ws' +const GRAPHQL_TRANSPORT_WS = 'graphql-transport-ws' + module.exports.GRAPHQL_WS = GRAPHQL_WS +module.exports.GRAPHQL_TRANSPORT_WS = GRAPHQL_TRANSPORT_WS module.exports.getProtocolByName = function (name) { switch (true) { - case (name.indexOf('graphql-transport-ws') !== -1): + case (name.indexOf(GRAPHQL_TRANSPORT_WS) !== -1): return { GQL_CONNECTION_INIT: 'connection_init', // Client -> Server GQL_CONNECTION_ACK: 'connection_ack', // Server -> Client @@ -16,7 +19,7 @@ module.exports.getProtocolByName = function (name) { GQL_DATA: 'next', // Server -> Client GQL_ERROR: 'error', // Server -> Client GQL_COMPLETE: 'complete', // Server -> Client - GQL_STOP: 'stop' // Client -> Server + GQL_STOP: 'complete' // Client -> Server } case (name.indexOf(GRAPHQL_WS) !== -1): return { diff --git a/test/subscription-connection.js b/test/subscription-connection.js index 99b8bbef..efb8b42b 100644 --- a/test/subscription-connection.js +++ b/test/subscription-connection.js @@ -6,7 +6,7 @@ const fastify = require('fastify') const mq = require('mqemitter') const SubscriptionConnection = require('../lib/subscription-connection') const { PubSub } = require('../lib/subscriber') -const { GRAPHQL_WS } = require('../lib/subscription-protocol') +const { GRAPHQL_WS, GRAPHQL_TRANSPORT_WS } = require('../lib/subscription-protocol') test('socket is closed on unhandled promise rejection in handleMessage', t => { t.plan(1) @@ -95,7 +95,7 @@ test('subscription connection handles GQL_CONNECTION_TERMINATE message correctly })) }) -test('subscription connection closes context on GQL_STOP message correctly', async (t) => { +test('subscription connection closes context on GQL_STOP message correctly (protocol: graphql-ws)', async (t) => { t.plan(2) const sc = new SubscriptionConnection({ on () {}, @@ -119,7 +119,31 @@ test('subscription connection closes context on GQL_STOP message correctly', asy t.equal(sc.subscriptionContexts.size, 0) }) -test('subscription connection completes resolver iterator on GQL_STOP message correctly', async (t) => { +test('subscription connection closes context on GQL_STOP message correctly (protocol: graphql-transport-ws)', async (t) => { + t.plan(2) + const sc = new SubscriptionConnection({ + on () {}, + close () {}, + send (message) {}, + protocol: GRAPHQL_TRANSPORT_WS + }, {}) + + sc.subscriptionContexts = new Map() + sc.subscriptionContexts.set(1, { + close () { + t.pass() + } + }) + + await sc.handleMessage(JSON.stringify({ + id: 1, + type: 'complete' + })) + + t.equal(sc.subscriptionContexts.size, 0) +}) + +test('subscription connection completes resolver iterator on GQL_STOP message correctly (protocol: graphql-ws)', async (t) => { t.plan(2) const sc = new SubscriptionConnection({ on () {}, @@ -143,6 +167,30 @@ test('subscription connection completes resolver iterator on GQL_STOP message co t.equal(sc.subscriptionIters.size, 0) }) +test('subscription connection completes resolver iterator on GQL_STOP message correctly (protocol: graphql-transport-ws)', async (t) => { + t.plan(2) + const sc = new SubscriptionConnection({ + on () {}, + close () {}, + send (message) {}, + protocol: GRAPHQL_TRANSPORT_WS + }, {}) + + sc.subscriptionIters = new Map() + sc.subscriptionIters.set(1, { + return () { + t.pass() + } + }) + + await sc.handleMessage(JSON.stringify({ + id: 1, + type: 'complete' + })) + + t.equal(sc.subscriptionIters.size, 0) +}) + test('handles error in send and closes connection', async t => { t.plan(1) @@ -163,7 +211,7 @@ test('handles error in send and closes connection', async t => { await sc.sendMessage('foo') }) -test('subscription connection handles GQL_STOP message correctly, with no data', async (t) => { +test('subscription connection handles GQL_STOP message correctly, with no data (protocol: graphql-ws)', async (t) => { const sc = new SubscriptionConnection({ on () {}, close () {}, @@ -179,6 +227,22 @@ test('subscription connection handles GQL_STOP message correctly, with no data', t.notOk(sc.subscriptionContexts.get(0)) }) +test('subscription connection handles GQL_STOP message correctly, with no data (protocol: graphql-transport-ws)', async (t) => { + const sc = new SubscriptionConnection({ + on () {}, + close () {}, + send (message) {}, + protocol: GRAPHQL_TRANSPORT_WS + }, {}) + + await sc.handleMessage(JSON.stringify({ + id: 1, + type: 'complete' + })) + + t.notOk(sc.subscriptionContexts.get(0)) +}) + test('subscription connection send error message when GQL_START handler errs', async (t) => { t.plan(1) diff --git a/test/subscription-protocol.js b/test/subscription-protocol.js index b7c8472c..4bc8c26b 100644 --- a/test/subscription-protocol.js +++ b/test/subscription-protocol.js @@ -26,7 +26,7 @@ test('getProtocolByName returns correct protocol message types', t => { GQL_DATA: 'next', GQL_ERROR: 'error', GQL_COMPLETE: 'complete', - GQL_STOP: 'stop' + GQL_STOP: 'complete' }) t.equal(getProtocolByName('unsupported-protocol'), null) }) From aa8eaaa0cb84ce662b84cd3b9fb4a6cf29abddb9 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 10 Nov 2021 09:28:17 +0100 Subject: [PATCH 25/55] Bumped v8.9.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7fa969f1..d2ef85fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mercurius", - "version": "8.8.0", + "version": "8.9.0", "description": "Fastify GraphQL adapter with gateway and subscription support", "main": "index.js", "types": "index.d.ts", From d35528ce1ec6d9ebcd73dcfc1aee6480ab6fb305 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Wed, 10 Nov 2021 16:57:05 +0100 Subject: [PATCH 26/55] fix cache usage (#644) --- docs/hooks.md | 4 +- index.js | 6 +-- test/cache.js | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 test/cache.js diff --git a/docs/hooks.md b/docs/hooks.md index c92119a4..943a1b1d 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -63,6 +63,8 @@ In the `preExecution` hook, you can modify the following items by returning them - `document` - `errors` +Note that if you modify the `document` object, the jit compilation will be disabled for the request. + ```js fastify.graphql.addHook('preExecution', async (schema, document, context) => { const { modifiedDocument, errors } = await asyncMethod(document) @@ -143,7 +145,7 @@ Note, the original query will still execute. Adding the above will result in the ```json { "data": { - foo: "bar" + "foo": "bar" }, "errors": [ { diff --git a/index.js b/index.js index 340add25..69be7d8f 100644 --- a/index.js +++ b/index.js @@ -535,11 +535,11 @@ const plugin = fp(async function (app, opts) { } // minJit is 0 by default - if (shouldCompileJit) { - cached.jit = compileQuery(fastifyGraphQl.schema, modifiedDocument || document, operationName) + if (shouldCompileJit && !modifiedDocument) { + cached.jit = compileQuery(fastifyGraphQl.schema, document, operationName) } - if (cached && cached.jit !== null) { + if (cached && cached.jit !== null && !modifiedDocument) { const execution = await cached.jit.query(root, context, variables || {}) return maybeFormatErrors(execution, context) diff --git a/test/cache.js b/test/cache.js new file mode 100644 index 00000000..109d82d0 --- /dev/null +++ b/test/cache.js @@ -0,0 +1,101 @@ +'use strict' + +const { test } = require('tap') +const Fastify = require('fastify') +const GQL = require('..') + +const schema = ` +type User { + name: String! + password: String! +} + +type Query { + read: [User] +} +` + +const resolvers = { + Query: { + read: async (_, obj) => { + return [ + { + name: 'foo', + password: 'bar' + } + ] + } + } +} + +test('cache skipped when the GQL Schema has been changed', async t => { + t.plan(4) + + const app = Fastify() + t.teardown(() => app.close()) + + await app.register(GQL, { schema, resolvers, jit: 1 }) + + app.graphql.addHook('preExecution', async (schema, document, context) => { + if (context.reply.request.headers.super === 'true') { + return + } + + const documentClone = JSON.parse(JSON.stringify(document)) + documentClone.definitions[0].selectionSet.selections[0].selectionSet.selections = + document.definitions[0].selectionSet.selections[0].selectionSet.selections.filter(sel => sel.name.value !== 'password') + + return { + document: documentClone + } + }) + + const query = `{ + read { + name + password + } + }` + + await superUserCall('this call warm up the jit counter') + await superUserCall('this call triggers the jit cache') + + { + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json', super: 'false' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + t.same(res.json(), { + data: { + read: [ + { + name: 'foo' + } + ] + } + }, 'this query should not use the cached query') + } + + await superUserCall('this call must use the cache') + + async function superUserCall (msg) { + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json', super: 'true' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + t.same(res.json(), { + data: { + read: [ + { + name: 'foo', + password: 'bar' + } + ] + } + }, msg) + } +}) From 594a2694e43bdc784383ee65a9d4daf889005ec5 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 10 Nov 2021 17:31:21 +0100 Subject: [PATCH 27/55] Bumped v8.9.1. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d2ef85fc..14364167 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mercurius", - "version": "8.9.0", + "version": "8.9.1", "description": "Fastify GraphQL adapter with gateway and subscription support", "main": "index.js", "types": "index.d.ts", From 558929510b82e1ccbc1b8594ff36c78a5b07cd2c Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Thu, 11 Nov 2021 10:24:16 +0100 Subject: [PATCH 28/55] feat: add modifiedSchema on preExecution hooks (#641) * add modifiedSchema on preExecution hooks * concurrent test * add docs * skip caching on modified schema * fix jit cache * fix lint * types * add modifiedQuery getter * removed unused modifiedQuery --- docs/hooks.md | 6 +- index.d.ts | 1 + index.js | 19 +++--- lib/handlers.js | 15 +++-- lib/hooks.js | 11 +++- package.json | 1 + test/hooks.js | 137 +++++++++++++++++++++++++++++++++++++++++++- test/types/index.ts | 1 + 8 files changed, 175 insertions(+), 16 deletions(-) diff --git a/docs/hooks.md b/docs/hooks.md index 943a1b1d..f92c0971 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -61,15 +61,17 @@ fastify.graphql.addHook('preValidation', async (schema, document, context) => { In the `preExecution` hook, you can modify the following items by returning them in the hook definition: - `document` + - `schema` - `errors` -Note that if you modify the `document` object, the jit compilation will be disabled for the request. +Note that if you modify the `schema` or the `document` object, the [jit](./api/options.md#plugin-options) compilation will be disabled for the request. ```js fastify.graphql.addHook('preExecution', async (schema, document, context) => { - const { modifiedDocument, errors } = await asyncMethod(document) + const { modifiedSchema, modifiedDocument, errors } = await asyncMethod(document) return { + schema: modifiedSchema, // ⚠️ changing the schema may break the query execution. Use it carefully. document: modifiedDocument, errors } diff --git a/index.d.ts b/index.d.ts index 958b6a0b..64629d2f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -786,6 +786,7 @@ type ValidationRules = }) => ValidationRule[]); export interface PreExecutionHookResponse { + schema?: GraphQLSchema document?: DocumentNode errors?: TError[] } diff --git a/index.js b/index.js index 69be7d8f..17b3db37 100644 --- a/index.js +++ b/index.js @@ -517,7 +517,6 @@ const plugin = fp(async function (app, opts) { } const shouldCompileJit = cached && cached.count++ === minJit - // Validate variables if (variables !== undefined && !shouldCompileJit) { const executionContext = buildExecutionContext(fastifyGraphQl.schema, document, root, context, variables, operationName) @@ -529,24 +528,30 @@ const plugin = fp(async function (app, opts) { } // Trigger preExecution hook + let modifiedSchema let modifiedDocument if (context.preExecution !== null) { - ({ modifiedDocument } = await preExecutionHandler({ schema: fastifyGraphQl.schema, document, context })) + ({ modifiedSchema, modifiedDocument } = await preExecutionHandler({ schema: fastifyGraphQl.schema, document, context })) } // minJit is 0 by default - if (shouldCompileJit && !modifiedDocument) { - cached.jit = compileQuery(fastifyGraphQl.schema, document, operationName) + if (shouldCompileJit) { + if (!modifiedSchema && !modifiedDocument) { + // can compile only when the schema and document are not modified + cached.jit = compileQuery(fastifyGraphQl.schema, document, operationName) + } else { + // the counter must decrease to ignore the query + cached && cached.count-- + } } - if (cached && cached.jit !== null && !modifiedDocument) { + if (cached && cached.jit !== null && !modifiedSchema && !modifiedDocument) { const execution = await cached.jit.query(root, context, variables || {}) - return maybeFormatErrors(execution, context) } const execution = await execute( - fastifyGraphQl.schema, + modifiedSchema || fastifyGraphQl.schema, modifiedDocument || document, root, context, diff --git a/lib/handlers.js b/lib/handlers.js index 1c8d2b46..bdc29a0a 100644 --- a/lib/handlers.js +++ b/lib/handlers.js @@ -29,16 +29,20 @@ async function preValidationHandler (request) { } async function preExecutionHandler (request) { - const { errors, modifiedDocument } = await preExecutionHooksRunner( + const { errors, modifiedDocument, modifiedSchema } = await preExecutionHooksRunner( request.context.preExecution, request ) if (errors.length > 0) { addErrorsToContext(request.context, errors) } - if (typeof modifiedDocument !== 'undefined') { - return { modifiedDocument, modifiedQuery: print(request.document) } + if (typeof modifiedDocument !== 'undefined' || typeof modifiedSchema !== 'undefined') { + return Object.create(null, { + modifiedDocument: { value: modifiedDocument }, + modifiedSchema: { value: modifiedSchema } + }) } + return {} } @@ -51,7 +55,10 @@ async function preGatewayExecutionHandler (request) { addErrorsToContext(request.context, errors) } if (typeof modifiedDocument !== 'undefined') { - return { modifiedDocument, modifiedQuery: print(modifiedDocument) } + return Object.create(null, { + modifiedDocument: { value: modifiedDocument }, + modifiedQuery: { get: () => print(modifiedDocument) } + }) } return {} } diff --git a/lib/hooks.js b/lib/hooks.js index f059e6b9..394f9b5b 100644 --- a/lib/hooks.js +++ b/lib/hooks.js @@ -87,12 +87,19 @@ async function hooksRunner (functions, runner, request) { async function preExecutionHooksRunner (functions, request) { let errors = [] + let modifiedSchema let modifiedDocument for (const fn of functions) { - const result = await fn(request.schema, modifiedDocument || request.document, request.context) + const result = await fn(modifiedSchema || request.schema, + modifiedDocument || request.document, + request.context + ) if (result) { + if (typeof result.schema !== 'undefined') { + modifiedSchema = result.schema + } if (typeof result.document !== 'undefined') { modifiedDocument = result.document } @@ -102,7 +109,7 @@ async function preExecutionHooksRunner (functions, request) { } } - return { errors, modifiedDocument } + return { errors, modifiedDocument, modifiedSchema } } async function preGatewayExecutionHooksRunner (functions, request) { diff --git a/package.json b/package.json index 14364167..2aa2ee73 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "unit": "tap test/*.js test/gateway/*.js test/internals/*.js", "cov": "tap --coverage-report=html -J test/*.js test/gateway/*.js", "lint": "npm run lint:standard && npm run lint:typescript", + "lint:fix": "standard --fix", "lint:standard": "standard | snazzy", "lint:typescript": "standard --parser @typescript-eslint/parser --plugin @typescript-eslint/eslint-plugin test/types/*.ts", "typescript": "tsd", diff --git a/test/hooks.js b/test/hooks.js index 941a2e02..7b674d1c 100644 --- a/test/hooks.js +++ b/test/hooks.js @@ -2,7 +2,9 @@ const { test } = require('tap') const Fastify = require('fastify') -const { parse, GraphQLSchema } = require('graphql') +const proxyquire = require('proxyquire') +const { mapSchema } = require('@graphql-tools/utils') +const { parse, buildSchema, GraphQLSchema } = require('graphql') const { promisify } = require('util') const GQL = require('..') @@ -525,6 +527,139 @@ test('preExecution hooks should be able to modify the query document AST', async }) }) +test('preExecution hooks should be able to modify the schema document AST', async t => { + t.plan(8) + const app = await createTestServer(t) + + const query = `{ + __type(name:"Query") { + name + fields { + name + } + } + }` + + app.graphql.addHook('preExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + + if (context.reply.request.headers.role === 'super-user') { + const modifiedSchema = ` + type Query { + add(x: Int, y: Int): Int + subtract(x: Int, y: Int): Int + } + ` + + return { + schema: buildSchema(modifiedSchema) + } + } + }) + + const reqSuper = app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json', role: 'super-user' }, + url: '/graphql', + body: JSON.stringify({ query }) + }).then(res => { + t.same(JSON.parse(res.body), { + data: { + __type: { + name: 'Query', + fields: [ + { name: 'add' }, + { name: 'subtract' } + ] + } + } + }) + }) + + const reqNotSuper = app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json', role: 'not-a-super-user' }, + url: '/graphql', + body: JSON.stringify({ query }) + }).then(res => { + t.same(JSON.parse(res.body), { + data: { + __type: { + name: 'Query', + fields: [ + { name: 'add' } + ] + } + } + }) + }) + + await Promise.all([reqSuper, reqNotSuper]) +}) + +test('cache skipped when the GQL Schema has been changed', async t => { + t.plan(4) + + const app = Fastify() + t.teardown(() => app.close()) + + const plugin = proxyquire('../index', { + 'graphql-jit': { + compileQuery () { + t.pass('the jit is called once') + return null + } + } + }) + + await app.register(plugin, { schema, resolvers, jit: 1 }) + await app + + app.graphql.addHook('preExecution', async (schema, document, context) => { + if (context.reply.request.headers.original === 'ok') { + return + } + + return { + schema: mapSchema(schema) + } + }) + + const query = '{ add(x:1, y:2) }' + + { + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json', original: 'ok' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + t.same(res.json(), { data: { add: 3 } }, 'this call warm up the jit counter') + } + + { + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json', original: 'NO' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + t.same(res.json(), { data: { add: 3 } }, 'this call MUST not trigger the jit') + } + + { + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json', original: 'ok' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + t.same(res.json(), { data: { add: 3 } }, 'this call triggers the jit cache') + } +}) + test('preExecution hooks should be able to add to the errors array', async t => { t.plan(7) const app = await createTestServer(t) diff --git a/test/types/index.ts b/test/types/index.ts index ce6050ed..d93bf42e 100644 --- a/test/types/index.ts +++ b/test/types/index.ts @@ -537,6 +537,7 @@ app.graphql.addHook('preExecution', async function (schema, document, context) { expectAssignable(document) expectAssignable(context) return { + schema, document, errors: [ new Error('foo') From 25500ddf95f3cfafaab23d03248ac6c52740142c Mon Sep 17 00:00:00 2001 From: Moritz Gruber Date: Thu, 11 Nov 2021 11:55:51 +0100 Subject: [PATCH 29/55] fix: change request for the schema validation in gateway mode in regards to federation (#645) --- lib/federation.js | 12 +- test/gateway/schema.js | 296 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+), 1 deletion(-) diff --git a/lib/federation.js b/lib/federation.js index 4c8736f1..b5bcf534 100644 --- a/lib/federation.js +++ b/lib/federation.js @@ -276,6 +276,16 @@ function buildFederationSchema (schema, isGateway) { const parsedOriginalSchema = parse(schema) const { typeStubs, extensions, definitions } = getStubTypes(parsedOriginalSchema.definitions, isGateway) + // before we validate the federationSchema, we want to remove the _service field from the extended query, + // if there is one, as this field should be excluded from the validation to provide broader support + // for different federation implementations => https://github.com/mercurius-js/mercurius/issues/643 + const filteredExtensions = extensions.map(extension => { + if (extension.name.value === 'Query') { + extension.fields = extension.fields.filter(field => field.name.value !== '_service') + } + return extension + }) + // Add type stubs - only needed for federation federationSchema = extendSchema(federationSchema, { kind: Kind.DOCUMENT, @@ -291,7 +301,7 @@ function buildFederationSchema (schema, isGateway) { // Add all extensions const extensionsDocument = { kind: Kind.DOCUMENT, - definitions: extensions + definitions: filteredExtensions } // instead of relying on extendSchema internal validations diff --git a/test/gateway/schema.js b/test/gateway/schema.js index 29f21b9b..f465ed56 100644 --- a/test/gateway/schema.js +++ b/test/gateway/schema.js @@ -2458,3 +2458,299 @@ test('Update the schema without any changes', async (t) => { t.equal(newSchema, null) }) + +test('It builds the gateway schema correctly with two services query extension having the _service fields', async (t) => { + const users = { + u1: { + id: 'u1', + name: 'John' + }, + u2: { + id: 'u2', + name: 'Jane' + }, + u3: { + id: 'u3', + name: 'Jack' + } + } + + const posts = { + p1: { + pid: 'p1', + title: 'Post 1', + content: 'Content 1', + authorId: 'u1' + }, + p2: { + pid: 'p2', + title: 'Post 2', + content: 'Content 2', + authorId: 'u2' + }, + p3: { + pid: 'p3', + title: 'Post 3', + content: 'Content 3', + authorId: 'u1' + }, + p4: { + pid: 'p4', + title: 'Post 4', + content: 'Content 4', + authorId: 'u2' + } + } + + const [userService, userServicePort] = await createService(t, ` + directive @customDirective on FIELD_DEFINITION + + type _Service { + sdl: String + } + + extend type Query { + me: User + hello: String + _service: _Service! + } + + type User @key(fields: "id") { + id: ID! + name: String! + avatar(size: AvatarSize): String + friends: [User] + } + + enum AvatarSize { + small + medium + large + } + `, { + Query: { + me: (root, args, context, info) => { + return users.u1 + }, + hello: () => 'World' + }, + User: { + __resolveReference: (user, args, context, info) => { + return users[user.id] + }, + avatar: (user, { size }) => `avatar-${size}.jpg`, + friends: (user) => Object.values(users).filter(u => u.id !== user.id) + } + }) + + const [postService, postServicePort] = await createService(t, ` + type Post @key(fields: "pid") { + pid: ID! + title: String + content: String + author: User @requires(fields: "title") + } + + type _Service { + sdl: String + } + + extend type Query { + topPosts(count: Int): [Post] + _service: _Service! + } + + type User @key(fields: "id") @extends { + id: ID! @external + name: String @external + posts: [Post] + numberOfPosts: Int @requires(fields: "id") + } + `, { + Post: { + __resolveReference: (post, args, context, info) => { + return posts[post.pid] + }, + author: (post, args, context, info) => { + return { + __typename: 'User', + id: post.authorId + } + } + }, + User: { + posts: (user, args, context, info) => { + return Object.values(posts).filter(p => p.authorId === user.id) + }, + numberOfPosts: (user) => { + return Object.values(posts).filter(p => p.authorId === user.id).length + } + }, + Query: { + topPosts: (root, { count = 2 }) => Object.values(posts).slice(0, count) + } + }) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await postService.close() + await userService.close() + }) + gateway.register(GQL, { + gateway: { + services: [{ + name: 'user', + url: `http://localhost:${userServicePort}/graphql`, + rewriteHeaders: (headers) => { + if (headers.authorization) { + return { + authorization: headers.authorization + } + } + } + }, { + name: 'post', + url: `http://localhost:${postServicePort}/graphql` + }] + } + }) + + const query = ` + query MainQuery( + $size: AvatarSize + $count: Int + ) { + me { + id + name + avatar(size: $size) + friends { + ...UserFragment + friends { + ...UserFragment + } + } + posts { + ...PostFragment + } + numberOfPosts + } + topPosts(count: $count) { + ...PostFragment + } + hello + } + + fragment UserFragment on User { + id + name + avatar(size: medium) + numberOfPosts + } + + fragment PostFragment on Post { + pid + title + content + ...AuthorFragment + } + + fragment AuthorFragment on Post { + author { + ...UserFragment + } + }` + const res = await gateway.inject({ + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: 'bearer supersecret' + }, + url: '/graphql', + body: JSON.stringify({ + query, + variables: { + size: 'small', + count: 1 + } + }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + avatar: 'avatar-small.jpg', + friends: [{ + id: 'u2', + name: 'Jane', + avatar: 'avatar-medium.jpg', + numberOfPosts: 2, + friends: [{ + id: 'u1', + name: 'John', + avatar: 'avatar-medium.jpg', + numberOfPosts: 2 + }, { + id: 'u3', + name: 'Jack', + avatar: 'avatar-medium.jpg', + numberOfPosts: 0 + }] + }, { + id: 'u3', + name: 'Jack', + avatar: 'avatar-medium.jpg', + numberOfPosts: 0, + friends: [{ + id: 'u1', + name: 'John', + avatar: 'avatar-medium.jpg', + numberOfPosts: 2 + }, { + id: 'u2', + name: 'Jane', + avatar: 'avatar-medium.jpg', + numberOfPosts: 2 + }] + }], + posts: [{ + pid: 'p1', + title: 'Post 1', + content: 'Content 1', + author: { + id: 'u1', + name: 'John', + avatar: 'avatar-medium.jpg', + numberOfPosts: 2 + } + }, { + pid: 'p3', + title: 'Post 3', + content: 'Content 3', + author: { + id: 'u1', + name: 'John', + avatar: 'avatar-medium.jpg', + numberOfPosts: 2 + } + }], + numberOfPosts: 2 + }, + topPosts: [{ + pid: 'p1', + title: 'Post 1', + content: 'Content 1', + author: { + id: 'u1', + name: 'John', + avatar: 'avatar-medium.jpg', + numberOfPosts: 2 + } + }], + hello: 'World' + } + }) +}) From 36999b7a129dd34df8eb7268df806b2ee5f8352f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Nov 2021 16:17:48 +0000 Subject: [PATCH 30/55] build(deps): bump fastify/github-action-merge-dependabot (#654) Bumps [fastify/github-action-merge-dependabot](https://github.com/fastify/github-action-merge-dependabot) from 2.5.0 to 2.6.0. - [Release notes](https://github.com/fastify/github-action-merge-dependabot/releases) - [Commits](https://github.com/fastify/github-action-merge-dependabot/compare/v2.5.0...v2.6.0) --- updated-dependencies: - dependency-name: fastify/github-action-merge-dependabot dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a14d71fd..982977ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,6 @@ jobs: needs: test runs-on: ubuntu-latest steps: - - uses: fastify/github-action-merge-dependabot@v2.5.0 + - uses: fastify/github-action-merge-dependabot@v2.6.0 with: github-token: ${{secrets.GITHUB_TOKEN}} From 281e2c46c258c0cc3cd9ff65a2c97074483c3dce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Nov 2021 16:29:50 +0000 Subject: [PATCH 31/55] build(deps-dev): bump tsd from 0.18.0 to 0.19.0 (#655) Bumps [tsd](https://github.com/SamVerschueren/tsd) from 0.18.0 to 0.19.0. - [Release notes](https://github.com/SamVerschueren/tsd/releases) - [Commits](https://github.com/SamVerschueren/tsd/compare/v0.18.0...v0.19.0) --- updated-dependencies: - dependency-name: tsd dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2aa2ee73..2a1acc3a 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "split2": "^4.0.0", "standard": "^16.0.3", "tap": "^15.0.9", - "tsd": "^0.18.0", + "tsd": "^0.19.0", "typescript": "^4.3.5", "wait-on": "^6.0.0" }, From 27c401f57c5e5b277b56d654dec52a38518578af Mon Sep 17 00:00:00 2001 From: Radomir Drndarski <90759187+radomird@users.noreply.github.com> Date: Fri, 19 Nov 2021 17:02:48 +0100 Subject: [PATCH 32/55] Add new option useSecureParse, move secure parsing under it and add normal parsing as default (#656) * Added useSecureParse flage and added normal parsing as default * Added unit tests for the sendRequest method * fix: Comment typo Co-authored-by: Simone Busoli * Minor refactoring, shortened expression * Added documentation for the new flag Co-authored-by: Simone Busoli --- docs/api/options.md | 1 + docs/federation.md | 31 +++++++++++++++ lib/gateway/request.js | 6 +-- lib/gateway/service-map.js | 4 +- test/gateway/request.js | 79 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 5 deletions(-) diff --git a/docs/api/options.md b/docs/api/options.md index 0c959eea..b28c9cc6 100644 --- a/docs/api/options.md +++ b/docs/api/options.md @@ -66,6 +66,7 @@ - `service.name`: A unique name for the service. Required. - `service.url`: The URL of the service endpoint. It can also be an `Array` of URLs and in which case all the requests will be load balanced throughout the URLs. Required. - `service.mandatory`: `Boolean` Marks service as mandatory. If any of the mandatory services are unavailable, gateway will exit with an error. (Default: `false`) + - `service.useSecureParse`: `Boolean` Marks if the service response needs to be parsed securely using [secure-json-parse](https://github.com/fastify/secure-json-parse). (Default: `false`) - `service.rewriteHeaders`: `Function` A function that gets the original headers as a parameter and returns an object containing values that should be added to the headers - `service.initHeaders`: `Function` or `Object` An object or a function that returns the headers sent to the service for the initial \_service SDL query. - `service.connections`: The number of clients to create. (Default: `10`) diff --git a/docs/federation.md b/docs/federation.md index 51f1ef49..c485d960 100644 --- a/docs/federation.md +++ b/docs/federation.md @@ -388,3 +388,34 @@ server.listen(3002) ``` _Note: The default behavior of `errorHandler` is call `errorFormatter` to send the result. When is provided an `errorHandler` make sure to **call `errorFormatter` manually if needed**._ + +#### Securely parse service responses in Gateway mode + +Gateway service responses can be securely parsed using the `useSecureParse` flag. By default, the target service is considered trusted and thus this flag is set to `false`. If there is a need to securely parse the JSON response from a service, this flag can be set to `true` and it will use the [secure-json-parse](https://github.com/fastify/secure-json-parse) library. + +```js +const Fastify = require('fastify') +const mercurius = require('mercurius') + +const server = Fastify() + +server.register(mercurius, { + graphiql: true, + gateway: { + services: [ + { + name: 'user', + url: 'http://localhost:3000/graphql', + useSecureParse: true + }, + { + name: 'company', + url: 'http://localhost:3001/graphql' + } + ] + }, + pollingInterval: 2000 +}) + +server.listen(3002) +``` diff --git a/lib/gateway/request.js b/lib/gateway/request.js index 9421bef2..b340a064 100644 --- a/lib/gateway/request.js +++ b/lib/gateway/request.js @@ -58,7 +58,7 @@ function buildRequest (opts) { } } -function sendRequest (request, url) { +function sendRequest (request, url, useSecureParse) { return function (opts) { return new Promise((resolve, reject) => { request({ @@ -89,7 +89,7 @@ function sendRequest (request, url) { } try { - const json = sJSON.parse(data.toString()) + const json = (useSecureParse ? sJSON : JSON).parse(data.toString()) if (json.errors && json.errors.length) { // return a `FederatedError` instance to keep `graphql` happy @@ -102,7 +102,7 @@ function sendRequest (request, url) { json }) } catch (e) { - reject(e) + reject(new FederatedError(e)) } }) }) diff --git a/lib/gateway/service-map.js b/lib/gateway/service-map.js index ab53f754..88b13cd7 100644 --- a/lib/gateway/service-map.js +++ b/lib/gateway/service-map.js @@ -113,13 +113,13 @@ async function buildServiceMap (services, errorHandler) { const serviceMap = {} for (const service of services) { - const { name, mandatory = false, initHeaders, ...opts } = service + const { name, mandatory = false, initHeaders, useSecureParse = false, ...opts } = service const { request, close } = buildRequest(opts) const url = new URL(Array.isArray(opts.url) ? opts.url[0] : opts.url) const serviceConfig = { mandatory: mandatory, - sendRequest: sendRequest(request, url), + sendRequest: sendRequest(request, url, useSecureParse), close, async refresh () { // if this is using a supplied schema refresh is done manually with setSchema diff --git a/test/gateway/request.js b/test/gateway/request.js index f0909a0e..7c859913 100644 --- a/test/gateway/request.js +++ b/test/gateway/request.js @@ -84,3 +84,82 @@ test('sendRequest method rejects when response contains errors', async (t) => { t.end() }) + +test('sendRequest method should accept useSecureParse flag and parse the response securely', async (t) => { + const app = fastify() + app.post('/', async (request, reply) => { + return '{" __proto__": { "foo": "bar" } }' + }) + + await app.listen(0) + + const url = new URL(`http://localhost:${app.server.address().port}`) + const { request, close } = buildRequest({ url }) + t.teardown(() => { + close() + return app.close() + }) + const result = await + sendRequest( + request, + url, + true + )({ + method: 'POST', + body: JSON.stringify({ + query: ` + query ServiceInfo { + _service { + sdl + } + } + ` + }) + }) + + // checking for prototype leakage: https://github.com/fastify/secure-json-parse#introduction + // secure parsing should not allow it + t.ok(result.json) + t.notOk(result.json.foo) + const testObject = Object.assign({}, result.json) + t.notOk(testObject.foo) + + t.end() +}) + +test('sendRequest method should run without useSecureParse flag', async (t) => { + const app = fastify() + app.post('/', async (request, reply) => { + return '{ "foo": "bar" }' + }) + + await app.listen(0) + + const url = new URL(`http://localhost:${app.server.address().port}`) + const { request, close } = buildRequest({ url }) + t.teardown(() => { + close() + return app.close() + }) + const result = await + sendRequest( + request, + url, + false + )({ + method: 'POST', + body: JSON.stringify({ + query: ` + query ServiceInfo { + _service { + sdl + } + } + ` + }) + }) + + t.same(result.json, { foo: 'bar' }) + + t.end() +}) From 231702c2327b1128f969be989f2d3e2e4e83504a Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Fri, 19 Nov 2021 20:49:01 +0100 Subject: [PATCH 33/55] chore: add logo (#657) --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 305a1bd2..6a01c35f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ + +![Mercurius Logo](https://github.com/mercurius-js/graphics/blob/main/mercurius-horizontal.svg) + # mercurius ![CI workflow](https://github.com/mercurius-js/mercurius/workflows/CI%20workflow/badge.svg) From 9ff5c54024e3330361d9059005d74a32a9dcca3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wiktor=20Obr=C4=99bski?= Date: Sat, 20 Nov 2021 11:22:39 +0100 Subject: [PATCH 34/55] Polishing/error formatter context (#653) --- docs/api/options.md | 4 ++-- lib/errors.js | 13 ++++-------- lib/routes.js | 44 ++++++++++++++++++++++++++++++--------- lib/symbols.js | 3 ++- test/batched.js | 49 ++++++++++++++++++++++++++++++++++++++++++++ test/routes.js | 50 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 141 insertions(+), 22 deletions(-) diff --git a/docs/api/options.md b/docs/api/options.md index b28c9cc6..90a8e961 100644 --- a/docs/api/options.md +++ b/docs/api/options.md @@ -49,7 +49,7 @@ - `prefix`: String. Change the route prefix of the graphql endpoint if enabled. - `defineMutation`: Boolean. Add the empty Mutation definition if schema is not defined (Default: `false`). - `errorHandler`: `Function`  or `boolean`. Change the default error handler (Default: `true`). _Note: If a custom error handler is defined, it should return the standardized response format according to [GraphQL spec](https://graphql.org/learn/serving-over-http/#response)._ -- `errorFormatter`: `Function`. Change the default error formatter. Allows the status code of the response to be set, and a GraphQL response for the error to be defined. This can be used to format errors for batched queries, which return a successful response overall but individual errors, or to obfuscate or format internal errors. The first argument is the error object, while the second one _might_ be the context if it is available. +- `errorFormatter`: `Function`. Change the default error formatter. Allows the status code of the response to be set, and a GraphQL response for the error to be defined. This can be used to format errors for batched queries, which return a successful response overall but individual errors, or to obfuscate or format internal errors. The first argument is the error object, while the second one is the context object. - `queryDepth`: `Integer`. The maximum depth allowed for a single query. _Note: GraphiQL IDE sends an introspection query when it starts up. This query has a depth of 7 so when the `queryDepth` value is smaller than 7 this query will fail with a `Bad Request` error_ - `validationRules`: `Function` or `Function[]`. Optional additional validation rules that the queries must satisfy in addition to those defined by the GraphQL specification. When using `Function`, arguments include additional data from graphql request and the return value must be validation rules `Function[]`. - `subscription`: Boolean | Object. Enable subscriptions. It uses [mqemitter](https://github.com/mcollina/mqemitter) when it is true and exposes the pubsub interface to `app.graphql.pubsub`. To use a custom emitter set the value to an object containing the emitter. @@ -554,6 +554,6 @@ To control the status code for the response, the third optional parameter can be Allows the status code of the response to be set, and a GraphQL response for the error to be defined. -By default uses the defaultErrorFormatter, but it can be overridden in the [mercurius options](/docs/api/options.md#plugin-options) changing the errorFormatter parameter. +By default uses the `defaultErrorFormatter`, but it can be overridden in the [mercurius options](/docs/api/options.md#plugin-options) changing the errorFormatter parameter. **Important**: *using the default formatter, when the error has a data property the response status code will be always 200* diff --git a/lib/errors.js b/lib/errors.js index 09abe477..e82c6339 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -63,18 +63,13 @@ function toGraphQLError (err) { function defaultErrorFormatter (err, ctx) { let errors = [{ message: err.message }] - let log - - if (ctx) { - // There is always app if there is a context - log = ctx.reply ? ctx.reply.log : ctx.app.log - } + // There is always app if there is a context + const log = ctx.reply ? ctx.reply.log : ctx.app.log if (err.errors) { errors = err.errors.map((error, idx) => { - if (log) { - log.error({ err: error }, error.message) - } + log.error({ err: error }, error.message) + // parses, converts & combines errors if they are the result of a federated request if (error.message === FEDERATED_ERROR.toString()) { return error.extensions.errors.map(err => formatError(toGraphQLError(err))) diff --git a/lib/routes.js b/lib/routes.js index abcc4766..7921f86c 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -3,6 +3,7 @@ const { join } = require('path') const Static = require('fastify-static') const subscription = require('./subscription') +const { kRequestContext } = require('./symbols') const sJSON = require('secure-json-parse') const { defaultErrorFormatter, @@ -134,14 +135,19 @@ module.exports = async function (app, opts) { if (typeof opts.errorHandler === 'function') { app.setErrorHandler(opts.errorHandler) } else if (opts.errorHandler === true || opts.errorHandler === undefined) { - app.setErrorHandler((error, _, reply) => { - const { statusCode, response } = errorFormatter(error) + app.setErrorHandler((error, request, reply) => { + const { statusCode, response } = errorFormatter( + error, + request[kRequestContext] + ) reply.code(statusCode).send(response) }) } const contextFn = opts.context const { subscriptionContextFn } = opts + app.decorateRequest(kRequestContext) + const { path: graphqlPath = '/graphql', subscriber, @@ -171,14 +177,16 @@ module.exports = async function (app, opts) { return new MER_ERR_GQL_PERSISTED_QUERY_NOT_FOUND('Unknown query') } - // Generate the context for this request - let context = {} - if (contextFn) { - context = await contextFn(request, reply) - } - // Handle the query, throwing an error if required - return reply.graphql(query, Object.assign(context, { pubsub: subscriber, __currentQuery: query }), variables, operationName) + return reply.graphql( + query, + Object.assign( + request[kRequestContext], + { pubsub: subscriber, __currentQuery: query } + ), + variables, + operationName + ) } function executeRegularQuery (body, request, reply) { @@ -243,6 +251,14 @@ module.exports = async function (app, opts) { schema: getSchema, attachValidation: true, handler: async function (request, reply) { + // Generate the context for this request + if (contextFn) { + request[kRequestContext] = await contextFn(request, reply) + Object.assign(request[kRequestContext], { reply, app }) + } else { + request[kRequestContext] = { reply, app } + } + validationHandler(request.validationError) const { variables, extensions } = request.query @@ -279,6 +295,14 @@ module.exports = async function (app, opts) { schema: postSchema(allowBatchedQueries), attachValidation: true }, async function (request, reply) { + // Generate the context for this request + if (contextFn) { + request[kRequestContext] = await contextFn(request, reply) + Object.assign(request[kRequestContext], { reply, app }) + } else { + request[kRequestContext] = { reply, app } + } + validationHandler(request.validationError) if (allowBatchedQueries && Array.isArray(request.body)) { @@ -286,7 +310,7 @@ module.exports = async function (app, opts) { return Promise.all(request.body.map(r => execute(r, request, reply) .catch(e => { - const { response } = errorFormatter(e) + const { response } = errorFormatter(e, request[kRequestContext]) return response }) )) diff --git a/lib/symbols.js b/lib/symbols.js index f22a70cb..fff68735 100644 --- a/lib/symbols.js +++ b/lib/symbols.js @@ -4,7 +4,8 @@ const keys = { kLoaders: Symbol('mercurius.loaders'), kFactory: Symbol('mercurius.loadersFactory'), kSubscriptionFactory: Symbol('mercurius.subscriptionLoadersFactory'), - kHooks: Symbol('mercurius.hooks') + kHooks: Symbol('mercurius.hooks'), + kRequestContext: Symbol('mercurius.requestContext') } module.exports = keys diff --git a/test/batched.js b/test/batched.js index 435bc081..2a1d47b1 100644 --- a/test/batched.js +++ b/test/batched.js @@ -111,6 +111,55 @@ test('POST single bad batched query', async (t) => { t.same(JSON.parse(res.body), [{ data: null, errors: [{ message: 'Syntax Error: Expected "$", found .', locations: [{ line: 2, column: 37 }] }] }]) }) +test('POST single bad batched query with cutom error formatter and custom async context', async (t) => { + t.plan(2) + + const app = Fastify() + + const schema = ` + type Query { + add(x: Int, y: Int): Int + } + ` + + const resolvers = { + add: async ({ x, y }) => x + y + } + + app.register(GQL, { + schema, + resolvers, + allowBatchedQueries: true, + context: async () => { + return { topic: 'NOTIFICATIONS_ADDED' } + }, + errorFormatter: (_execution, context) => { + t.include(context, { topic: 'NOTIFICATIONS_ADDED' }) + return { + response: { + data: null, + errors: [{ message: 'Internal Server Error' }] + } + } + } + }) + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + body: [ + { + operationName: 'AddQuery', + variables: { x: 1, y: 2 }, + query: ` + query AddQuery ($x: Int!` + } + ] + }) + + t.same(JSON.parse(res.body), [{ data: null, errors: [{ message: 'Internal Server Error' }] }]) +}) + test('POST batched query', async (t) => { const app = Fastify() diff --git a/test/routes.js b/test/routes.js index 8cbb0a10..7c6c01a5 100644 --- a/test/routes.js +++ b/test/routes.js @@ -582,6 +582,49 @@ test('POST return 400 on error', async (t) => { t.matchSnapshot(JSON.stringify(JSON.parse(res.body), null, 2)) }) +test('POST return 400 error handler provide custom context to custom async error formatter', async (t) => { + t.plan(2) + + const app = Fastify() + const schema = ` + type Query { + add(x: Int, y: Int): Int + } + ` + + const resolvers = { + add: async ({ x, y }) => x + y + } + + app.register(GQL, { + schema, + resolvers, + errorHandler: true, + context: async () => { + return { topic: 'POINT_ADDED' } + }, + errorFormatter: (_execution, context) => { + t.has(context, { topic: 'POINT_ADDED' }) + return { + statusCode: 400, + response: { + data: { add: null }, + errors: [{ message: 'Internal Server Error' }] + } + } + } + }) + + // Invalid query + const res = await app.inject({ + method: 'POST', + url: '/graphql', + body: { query: '{ add(x: 2, y: 2)' } + }) + + t.equal(res.statusCode, 400) +}) + test('mutation with POST', async (t) => { const app = Fastify() const schema = ` @@ -1655,7 +1698,11 @@ test('error thrown from onDisconnect is logged', t => { const error = new Error('error') const app = Fastify() + + // override `app.log` to avoid polluting other tests + app.log = Object.create(app.log) app.log.error = (e) => { t.same(error, e) } + t.teardown(() => app.close()) const schema = ` @@ -1702,6 +1749,9 @@ test('promise rejection from onDisconnect is logged', t => { const error = new Error('error') const app = Fastify() + + // override `app.log` to avoid polluting other tests + app.log = Object.create(app.log) app.log.error = (e) => { t.same(error, e) } t.teardown(() => app.close()) From 7dfc5dc6f7312528caecf5e2eafde06a79059aa8 Mon Sep 17 00:00:00 2001 From: Radomir Drndarski <90759187+radomird@users.noreply.github.com> Date: Tue, 23 Nov 2021 11:55:43 +0100 Subject: [PATCH 35/55] Update gateway/request to use Promise API from undici (#661) * Added useSecureParse flage and added normal parsing as default * Added unit tests for the sendRequest method * fix: Comment typo Co-authored-by: Simone Busoli * Minor refactoring, shortened expression * Added documentation for the new flag * Changed request to use the promise API from undici * Removed 'done' callback * Removed 'end-of-stream' package Co-authored-by: Simone Busoli --- lib/gateway/request.js | 83 +++++++++++++++++------------------------- package.json | 1 - 2 files changed, 34 insertions(+), 50 deletions(-) diff --git a/lib/gateway/request.js b/lib/gateway/request.js index b340a064..8cc733f3 100644 --- a/lib/gateway/request.js +++ b/lib/gateway/request.js @@ -1,7 +1,6 @@ 'use strict' const { BalancedPool, Pool } = require('undici') const { URL } = require('url') -const eos = require('end-of-stream') const { FederatedError } = require('../errors') const sJSON = require('secure-json-parse') @@ -37,19 +36,22 @@ function buildRequest (opts) { agent.destroy() } - function request (opts, done) { - agent.request({ - method: opts.method, - path: opts.url.pathname + (opts.qs || ''), - headers: { - ...rewriteHeaders(opts.originalRequestHeaders, opts.context), - ...opts.headers - }, - body: opts.body - }, (err, res) => { - if (err) return done(err) - done(null, { statusCode: res.statusCode, headers: res.headers, stream: res.body }) - }) + async function request (opts) { + try { + const response = await agent.request({ + method: opts.method, + path: opts.url.pathname + (opts.qs || ''), + headers: { + ...rewriteHeaders(opts.originalRequestHeaders, opts.context), + ...opts.headers + }, + body: opts.body + }) + + return response + } catch (err) { + throw new FederatedError(err) + } } return { @@ -59,9 +61,9 @@ function buildRequest (opts) { } function sendRequest (request, url, useSecureParse) { - return function (opts) { - return new Promise((resolve, reject) => { - request({ + return async function (opts) { + try { + const { body, statusCode } = await request({ url, method: 'POST', body: opts.body, @@ -72,41 +74,24 @@ function sendRequest (request, url, useSecureParse) { }, originalRequestHeaders: opts.originalRequestHeaders || {}, context: opts.context - }, (err, response) => { - if (err) { - return reject(err) - } - - let data = '' - response.stream.on('data', chunk => { - data += chunk - }) - - eos(response.stream, (err) => { - /* istanbul ignore if */ - if (err) { - return reject(err) - } + }) - try { - const json = (useSecureParse ? sJSON : JSON).parse(data.toString()) + const data = await body.text() + const json = (useSecureParse ? sJSON : JSON).parse(data.toString()) - if (json.errors && json.errors.length) { - // return a `FederatedError` instance to keep `graphql` happy - // e.g. have something that derives from `Error` - return reject(new FederatedError(json.errors)) - } + if (json.errors && json.errors.length) { + // return a `FederatedError` instance to keep `graphql` happy + // e.g. have something that derives from `Error` + throw new FederatedError(json.errors) + } - resolve({ - statusCode: response.statusCode, - json - }) - } catch (e) { - reject(new FederatedError(e)) - } - }) - }) - }) + return { + statusCode, + json + } + } catch (err) { + throw new FederatedError(err) + } } } diff --git a/package.json b/package.json index 2a1acc3a..080aea52 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,6 @@ }, "dependencies": { "@types/isomorphic-form-data": "^2.0.0", - "end-of-stream": "^1.4.4", "events.on": "^1.0.1", "fastify-error": "^0.3.1", "fastify-plugin": "^3.0.0", From 1b87e64c879f5e0dd4f64e733685428c412a5243 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Wed, 24 Nov 2021 11:55:00 +0100 Subject: [PATCH 36/55] fix wrapping FederatedError (#666) --- lib/gateway/request.js | 3 +++ test/gateway/request.js | 15 ++++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/gateway/request.js b/lib/gateway/request.js index 8cc733f3..f7a9d63b 100644 --- a/lib/gateway/request.js +++ b/lib/gateway/request.js @@ -90,6 +90,9 @@ function sendRequest (request, url, useSecureParse) { json } } catch (err) { + if (err instanceof FederatedError) { + throw err + } throw new FederatedError(err) } } diff --git a/test/gateway/request.js b/test/gateway/request.js index 7c859913..fb5d3343 100644 --- a/test/gateway/request.js +++ b/test/gateway/request.js @@ -1,6 +1,7 @@ const { test } = require('tap') const fastify = require('fastify') const { sendRequest, buildRequest } = require('../../lib/gateway/request') +const { FederatedError } = require('../../lib/errors') test('sendRequest method rejects when request errs', t => { const url = new URL('http://localhost:3001') @@ -51,6 +52,7 @@ test('sendRequest method rejects when response is not valid json', async (t) => }) test('sendRequest method rejects when response contains errors', async (t) => { + t.plan(2) const app = fastify() app.post('/', async (request, reply) => { return { errors: ['foo'] } @@ -64,8 +66,9 @@ test('sendRequest method rejects when response contains errors', async (t) => { close() return app.close() }) - t.rejects( - sendRequest( + + try { + await sendRequest( request, url )({ @@ -80,9 +83,11 @@ test('sendRequest method rejects when response contains errors', async (t) => { ` }) }) - ) - - t.end() + t.fail('it must throw') + } catch (error) { + t.type(error, FederatedError) + t.same(error.extensions, { errors: ['foo'] }) + } }) test('sendRequest method should accept useSecureParse flag and parse the response securely', async (t) => { From 54c22e2c52939d07dcfdeba8e211df21fc3b6372 Mon Sep 17 00:00:00 2001 From: Frazer Smith Date: Wed, 24 Nov 2021 10:55:12 +0000 Subject: [PATCH 37/55] build(dependabot): ignore minor and patch github-actions updates (#652) --- .github/dependabot.yml | 4 ++++ .github/workflows/ci.yml | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ae17f3da..912e4782 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,6 +2,10 @@ version: 2 updates: - package-ecosystem: github-actions directory: "/" + ignore: + - dependency-name: "actions/*" + update-types: + ["version-update:semver-minor", "version-update:semver-patch"] schedule: interval: daily open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 982977ef..39272129 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,9 +8,9 @@ jobs: node-version: [12.x, 14.x, 16.x] os: [ubuntu-latest, windows-latest, macOS-latest] steps: - - uses: actions/checkout@v2.4.0 + - uses: actions/checkout@v2 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2.4.1 + uses: actions/setup-node@v2 with: node-version: ${{ matrix.node-version }} - name: Install Dependencies From 3b861dd8c35c09568b65d8293e4206bba9a23213 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 24 Nov 2021 11:55:53 +0100 Subject: [PATCH 38/55] Bumped v8.10.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 080aea52..8aae187c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mercurius", - "version": "8.9.1", + "version": "8.10.0", "description": "Fastify GraphQL adapter with gateway and subscription support", "main": "index.js", "types": "index.d.ts", From 8b1d6400806d39b38d18908216ad8025997b6f7e Mon Sep 17 00:00:00 2001 From: Jose Bravo Date: Thu, 25 Nov 2021 17:51:49 +0100 Subject: [PATCH 39/55] Add mercurius-postgraphile to plugin docs (#668) --- docs/plugins.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/plugins.md b/docs/plugins.md index e2667bbd..d4299429 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -9,6 +9,7 @@ Related plugins for mercurius - [altair-fastify-plugin](#altair-fastify-plugin) - [mercurius-apollo-registry](#mercurius-apollo-registry) - [mercurius-apollo-tracing](#mercurius-apollo-tracing) +- [mercurius-postgraphile](#mercurius-postgraphile) ## mercurius-auth @@ -118,3 +119,8 @@ app.register(mercuriusTracing, { graphRef: 'yourGraph@ref' // replace 'yourGraph@ref'' with the one from apollo studio }) ``` + +## mercurius-postgraphile +A Mercurius plugin for integrating PostGraphile schemas with Mercurius + +Check [https://github.com/autotelic/mercurius-postgraphile](https://github.com/autotelic/mercurius-postgraphile) for usage and readme. From c18f79ba9fb14e591c82ca8fa0b3ac1eeca3bbd6 Mon Sep 17 00:00:00 2001 From: Giacomo Rebonato Date: Sun, 28 Nov 2021 00:48:26 +0100 Subject: [PATCH 40/55] feat: optimize gateway by batching queries (#667) --- docs/federation.md | 55 +- lib/gateway.js | 78 +- lib/gateway/get-query-result.js | 177 +++ lib/gateway/make-resolver.js | 4 +- lib/gateway/service-map.js | 1 + test/gateway/aliases-with-batching.js | 213 +++ .../batching-on-both-gateway-and-services.js | 216 +++ .../custom-directives-with-batching.js | 286 ++++ test/gateway/errors-with-batching.js | 152 ++ test/gateway/get-query-result.js | 137 ++ test/gateway/hooks-with-batching.js | 1240 +++++++++++++++++ .../include-directive-with-batching.js | 214 +++ test/gateway/load-balancing-with-batching.js | 198 +++ test/gateway/with-batching.js | 188 +++ 14 files changed, 3080 insertions(+), 79 deletions(-) create mode 100644 lib/gateway/get-query-result.js create mode 100644 test/gateway/aliases-with-batching.js create mode 100644 test/gateway/batching-on-both-gateway-and-services.js create mode 100644 test/gateway/custom-directives-with-batching.js create mode 100644 test/gateway/errors-with-batching.js create mode 100644 test/gateway/get-query-result.js create mode 100644 test/gateway/hooks-with-batching.js create mode 100644 test/gateway/include-directive-with-batching.js create mode 100644 test/gateway/load-balancing-with-batching.js create mode 100644 test/gateway/with-batching.js diff --git a/docs/federation.md b/docs/federation.md index c485d960..8034a78f 100644 --- a/docs/federation.md +++ b/docs/federation.md @@ -1,13 +1,17 @@ # mercurius -- [Federation metadata support](#federation-metadata-support) -- [Federation with \_\_resolveReference caching](#federation-with-__resolvereference-caching) -- [Use GraphQL server as a Gateway for federated schemas](#use-graphql-server-as-a-gateway-for-federated-schemas) - - [Periodically refresh federated schemas in Gateway mode](#periodically-refresh-federated-schemas-in-gateway-mode) - - [Programmatically refresh federated schemas in Gateway mode](#programmatically-refresh-federated-schemas-in-gateway-mode) - - [Using Gateway mode with a schema registry](#using-gateway-mode-with-a-schema-registry) - - [Flag service as mandatory in Gateway mode](#flag-service-as-mandatory-in-gateway-mode) - - [Using a custom errorHandler for handling downstream service errors in Gateway mode](#using-a-custom-errorhandler-for-handling-downstream-service-errors-in-gateway-mode) +- [mercurius](#mercurius) + - [Federation](#federation) + - [Federation metadata support](#federation-metadata-support) + - [Federation with \_\_resolveReference caching](#federation-with-__resolvereference-caching) + - [Use GraphQL server as a Gateway for federated schemas](#use-graphql-server-as-a-gateway-for-federated-schemas) + - [Periodically refresh federated schemas in Gateway mode](#periodically-refresh-federated-schemas-in-gateway-mode) + - [Programmatically refresh federated schemas in Gateway mode](#programmatically-refresh-federated-schemas-in-gateway-mode) + - [Using Gateway mode with a schema registry](#using-gateway-mode-with-a-schema-registry) + - [Flag service as mandatory in Gateway mode](#flag-service-as-mandatory-in-gateway-mode) + - [Batched Queries to services](#batched-queries-to-services) + - [Using a custom errorHandler for handling downstream service errors in Gateway mode](#using-a-custom-errorhandler-for-handling-downstream-service-errors-in-gateway-mode) + - [Securely parse service responses in Gateway mode](#securely-parse-service-responses-in-gateway-mode) ## Federation @@ -351,6 +355,41 @@ server.register(mercurius, { server.listen(3002) ``` +#### Batched Queries to services + +To fully leverage the DataLoader pattern we can tell the Gateway which of its services support [batched queries](batched-queries.md). +In this case the service will receive a request body with an array of queries to execute. +Enabling batched queries for a service that doesn't support it will generate errors. + + +```js +const Fastify = require('fastify') +const mercurius = require('mercurius') + +const server = Fastify() + +server.register(mercurius, { + graphiql: true, + gateway: { + services: [ + { + name: 'user', + url: 'http://localhost:3000/graphql' + allowBatchedQueries: true + }, + { + name: 'company', + url: 'http://localhost:3001/graphql', + allowBatchedQueries: false + } + ] + }, + pollingInterval: 2000 +}) + +server.listen(3002) +``` + #### Using a custom errorHandler for handling downstream service errors in Gateway mode Service which uses Gateway mode can process different types of issues that can be obtained from remote services (for example, Network Error, Downstream Error, etc.). A developer can provide a function (`gateway.errorHandler`) that can process these errors. diff --git a/lib/gateway.js b/lib/gateway.js index 530721da..68469d04 100644 --- a/lib/gateway.js +++ b/lib/gateway.js @@ -4,8 +4,7 @@ const { getNamedType, isObjectType, isScalarType, - Kind, - parse + Kind } = require('graphql') const { Factory } = require('single-user-cache') const buildFederatedSchema = require('./federation') @@ -18,9 +17,8 @@ const { kEntityResolvers } = require('./gateway/make-resolver') const { MER_ERR_GQL_GATEWAY_REFRESH, MER_ERR_GQL_GATEWAY_INIT } = require('./errors') -const { preGatewayExecutionHandler } = require('./handlers') const findValueTypes = require('./gateway/find-value-types') - +const getQueryResult = require('./gateway/get-query-result') const allSettled = require('promise.allsettled') function isDefaultType (type) { @@ -354,72 +352,12 @@ async function buildGateway (gatewayOpts, app) { * */ factory.add(`${service}Entity`, async (queries) => { - const q = [...new Set(queries.map(q => q.query))] - - const resultIndexes = [] - let queryIndex = 0 - const mergedQueries = queries.reduce((acc, curr) => { - if (!acc[curr.query]) { - acc[curr.query] = curr.variables - resultIndexes[q.indexOf(curr.query)] = [] - } else { - acc[curr.query].representations = [ - ...acc[curr.query].representations, - ...curr.variables.representations - ] - } - - for (let i = 0; i < curr.variables.representations.length; i++) { - resultIndexes[q.indexOf(curr.query)].push(queryIndex) - } - - queryIndex++ - - return acc - }, {}) - - const result = [] - - // Gateway query here - await Promise.all(Object.entries(mergedQueries).map(async ([query, variables], queryIndex, entries) => { - // Trigger preGatewayExecution hook for entities - let modifiedQuery - if (queries[queryIndex].context.preGatewayExecution !== null) { - ({ modifiedQuery } = await preGatewayExecutionHandler({ - schema: serviceDefinition.schema, - document: parse(query), - context: queries[queryIndex].context, - service: { name: service } - })) - } - - const response = await serviceDefinition.sendRequest({ - originalRequestHeaders: queries[queryIndex].originalRequestHeaders, - body: JSON.stringify({ - query: modifiedQuery || query, - variables - }), - context: queries[queryIndex].context - }) - - let entityIndex = 0 - for (const entity of response.json.data._entities) { - if (!result[resultIndexes[queryIndex][entityIndex]]) { - result[resultIndexes[queryIndex][entityIndex]] = { - ...response, - json: { - data: { - _entities: [entity] - } - } - } - } else { - result[resultIndexes[queryIndex][entityIndex]].json.data._entities.push(entity) - } - - entityIndex++ - } - })) + // context is the same for each query, but unfortunately it's not acessible from onRequest + // where we do factory.create(). What is a cleaner option? + const context = queries[0].context + const result = await getQueryResult({ + context, queries, serviceDefinition, service + }) return result }, query => query.id) diff --git a/lib/gateway/get-query-result.js b/lib/gateway/get-query-result.js new file mode 100644 index 00000000..5e0a85c3 --- /dev/null +++ b/lib/gateway/get-query-result.js @@ -0,0 +1,177 @@ +'use strict' + +const { preGatewayExecutionHandler } = require('../handlers') + +/** + * @typedef {Object.} GroupedQueries + */ + +/** + * Group GraphQL queries by their string and map them to their variables and document. + * @param {Array} queries + * @returns {GroupedQueries} + */ +function groupQueriesByDefinition (queries) { + const q = [...new Set(queries.map(q => q.query))] + const resultIndexes = [] + const mergedQueries = queries.reduce((acc, curr, queryIndex) => { + if (!acc[curr.query]) { + acc[curr.query] = { + document: curr.document, + variables: curr.variables + } + resultIndexes[q.indexOf(curr.query)] = [] + } else { + acc[curr.query].variables.representations = [ + ...acc[curr.query].variables.representations, + ...curr.variables.representations + ] + } + + for (let i = 0; i < curr.variables.representations.length; i++) { + resultIndexes[q.indexOf(curr.query)].push(queryIndex) + } + + return acc + }, {}) + + return { mergedQueries, resultIndexes } +} + +/** + * Fetches queries result from the service with batching (1 request for all the queries). + * @param {Object} params + * @param {Object} params.service The service that will receive one request with the batched queries + * @returns {Array} result + */ +async function fetchBactchedResult ({ mergeQueriesResult, context, serviceDefinition, service }) { + const { mergedQueries, resultIndexes } = mergeQueriesResult + const batchedQueries = [] + + for (const [query, { document, variables }] of Object.entries(mergedQueries)) { + let modifiedQuery + + if (context.preGatewayExecution !== null) { + ({ modifiedQuery } = await preGatewayExecutionHandler({ + schema: serviceDefinition.schema, + document, + context, + service: { name: service } + })) + } + + batchedQueries.push({ + operationName: document.definitions.find(d => d.kind === 'OperationDefinition').name.value, + query: modifiedQuery || query, + variables + }) + } + + const response = await serviceDefinition.sendRequest({ + originalRequestHeaders: context.reply.request.headers, + body: JSON.stringify(batchedQueries), + context + }) + + return buildResult({ resultIndexes, data: response.json }) +} + +/** + * + * @param {Object} params + * @param {Array} params.resultIndexes Array used to map results with queries + * @param {Array} params.data Array of data returned from GraphQL end point + * @returns {Array} result + */ +function buildResult ({ resultIndexes, data }) { + const result = [] + + for (const [queryIndex, queryResponse] of data.entries()) { + let entityIndex = 0 + + for (const entity of queryResponse.data._entities) { + if (!result[resultIndexes[queryIndex][entityIndex]]) { + result[resultIndexes[queryIndex][entityIndex]] = { + ...queryResponse, + json: { + data: { + _entities: [entity] + } + } + } + } else { + result[resultIndexes[queryIndex][entityIndex]].json.data._entities.push(entity) + } + + entityIndex++ + } + } + + return result +} + +/** + * Fetches queries result from the service without batching (1 request for each query) + * @param {Object} params + * @param {GroupedQueries} params.mergeQueriesResult + * @param {Object} params.service The service that will receive requests for the queries + * @returns {Array} result + */ +async function fetchResult ({ mergeQueriesResult, serviceDefinition, context, service }) { + const { mergedQueries, resultIndexes } = mergeQueriesResult + const queriesEntries = Object.entries(mergedQueries) + const data = await Promise.all( + queriesEntries.map(async ([query, { document, variables }]) => { + let modifiedQuery + + if (context.preGatewayExecution !== null) { + ({ modifiedQuery } = await preGatewayExecutionHandler({ + schema: serviceDefinition.schema, + document, + context, + service: { name: service } + })) + } + + const response = await serviceDefinition.sendRequest({ + originalRequestHeaders: context.reply.request.headers, + body: JSON.stringify({ + query: modifiedQuery || query, + variables + }), + context + }) + + return response.json + }) + ) + + return buildResult({ data, resultIndexes }) +} + +/** + * Fetches queries results from their shared service and returns array of data. + * It batches queries into one request if allowBatchedQueries is true for the service. + * @param {Object} params + * @param {Array} params.queries The list of queries to be executed + * @param {Object} params.service The service to send requests to + * @returns {Array} The array of results + */ +async function getQueryResult ({ context, queries, serviceDefinition, service }) { + const mergeQueriesResult = groupQueriesByDefinition(queries) + const params = { + mergeQueriesResult, + service, + serviceDefinition, + queries, + context + } + + if (serviceDefinition.allowBatchedQueries) { + return fetchBactchedResult({ ...params }) + } + + return fetchResult({ ...params }) +} + +module.exports = getQueryResult diff --git a/lib/gateway/make-resolver.js b/lib/gateway/make-resolver.js index 118cf60c..db47537f 100644 --- a/lib/gateway/make-resolver.js +++ b/lib/gateway/make-resolver.js @@ -508,10 +508,12 @@ function makeResolver ({ service, createOperation, transformData, isQuery, isRef const entityResolvers = reply.entityResolversFactory ? reply.entityResolversFactory.create() : reply[kEntityResolvers] + // This method is declared in gateway.js inside of onRequest + // hence it's unique per request. const response = await entityResolvers[`${service.name}Entity`]({ + document: operation, query, variables, - originalRequestHeaders: reply.request.headers, context, id: queryId }) diff --git a/lib/gateway/service-map.js b/lib/gateway/service-map.js index 88b13cd7..82000455 100644 --- a/lib/gateway/service-map.js +++ b/lib/gateway/service-map.js @@ -221,6 +221,7 @@ async function buildServiceMap (services, errorHandler) { } serviceMap[service.name].name = service.name + serviceMap[service.name].allowBatchedQueries = service.allowBatchedQueries } await pmap(services, mapper, { concurrency: 8 }) diff --git a/test/gateway/aliases-with-batching.js b/test/gateway/aliases-with-batching.js new file mode 100644 index 00000000..a570cab5 --- /dev/null +++ b/test/gateway/aliases-with-batching.js @@ -0,0 +1,213 @@ +'use strict' + +const { test } = require('tap') +const Fastify = require('fastify') +const GQL = require('../..') + +async function createTestService (t, schema, resolvers = {}) { + const service = Fastify() + service.register(GQL, { + schema, + resolvers, + federationMetadata: true, + allowBatchedQueries: true + }) + await service.listen(0) + return [service, service.server.address().port] +} + +const users = { + u1: { + id: 'u1', + name: 'John' + }, + u2: { + id: 'u2', + name: 'Jane' + } +} + +const posts = { + p1: { + pid: 'p1', + title: 'Post 1', + content: 'Content 1', + authorId: 'u1' + }, + p2: { + pid: 'p2', + title: 'Post 2', + content: 'Content 2', + authorId: 'u2' + }, + p3: { + pid: 'p3', + title: 'Post 3', + content: 'Content 3', + authorId: 'u1' + }, + p4: { + pid: 'p4', + title: 'Post 4', + content: 'Content 4', + authorId: 'u1' + } +} + +async function createTestGatewayServer (t) { + // User service + const userServiceSchema = ` + type Query @extends { + me: User + } + + type Metadata { + info: String! + } + + type User @key(fields: "id") { + id: ID! + name: String! + quote(input: String!): String! + metadata(input: String!): Metadata! + }` + const userServiceResolvers = { + Query: { + me: (root, args, context, info) => { + return users.u1 + } + }, + User: { + quote: (user, args, context, info) => { + return args.input + }, + metadata: (user, args, context, info) => { + return { + info: args.input + } + }, + __resolveReference: (user, args, context, info) => { + return users[user.id] + } + } + } + const [userService, userServicePort] = await createTestService(t, userServiceSchema, userServiceResolvers) + + // Post service + const postServiceSchema = ` + type Post @key(fields: "pid") { + pid: ID! + } + + type User @key(fields: "id") @extends { + id: ID! @external + topPosts(count: Int!): [Post] + }` + const postServiceResolvers = { + User: { + topPosts: (user, { count }, context, info) => { + return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count) + } + } + } + const [postService, postServicePort] = await createTestService(t, postServiceSchema, postServiceResolvers) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await userService.close() + await postService.close() + }) + gateway.register(GQL, { + gateway: { + services: [{ + name: 'user', + url: `http://localhost:${userServicePort}/graphql`, + allowBatchedQueries: true + }, { + name: 'post', + url: `http://localhost:${postServicePort}/graphql`, + allowBatchedQueries: true + }] + } + }) + return gateway +} + +test('gateway with batching - should support aliases', async (t) => { + t.plan(1) + const app = await createTestGatewayServer(t) + + const query = ` + query { + user: me { + id + name + newName: name + otherName: name + quote(input: "quote") + firstQuote: quote(input: "foo") + secondQuote: quote(input: "bar") + metadata(input: "info") { + info + } + originalMetadata: metadata(input: "hello") { + hi: info + ho: info + } + moreMetadata: metadata(input: "hi") { + info + } + somePosts: topPosts(count: 1) { + pid + } + morePosts: topPosts(count: 2) { + pid + } + } + }` + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + user: { + id: 'u1', + name: 'John', + newName: 'John', + otherName: 'John', + quote: 'quote', + firstQuote: 'foo', + secondQuote: 'bar', + metadata: { + info: 'info' + }, + originalMetadata: { + hi: 'hello', + ho: 'hello' + }, + moreMetadata: { + info: 'hi' + }, + somePosts: [ + { + pid: 'p1' + } + ], + morePosts: [ + { + pid: 'p1' + }, + { + pid: 'p3' + } + ] + } + } + }) +}) diff --git a/test/gateway/batching-on-both-gateway-and-services.js b/test/gateway/batching-on-both-gateway-and-services.js new file mode 100644 index 00000000..82d26dbb --- /dev/null +++ b/test/gateway/batching-on-both-gateway-and-services.js @@ -0,0 +1,216 @@ +'use strict' + +const { test } = require('tap') +const Fastify = require('fastify') +const GQL = require('../..') + +async function createTestService (t, schema, resolvers = {}) { + const service = Fastify() + service.register(GQL, { + schema, + resolvers, + federationMetadata: true, + allowBatchedQueries: true + }) + await service.listen(0) + return [service, service.server.address().port] +} + +const users = { + u1: { + id: 'u1', + name: 'John' + }, + u2: { + id: 'u2', + name: 'Jane' + } +} + +const posts = { + p1: { + pid: 'p1', + title: 'Post 1', + content: 'Content 1', + authorId: 'u1' + }, + p2: { + pid: 'p2', + title: 'Post 2', + content: 'Content 2', + authorId: 'u2' + }, + p3: { + pid: 'p3', + title: 'Post 3', + content: 'Content 3', + authorId: 'u1' + }, + p4: { + pid: 'p4', + title: 'Post 4', + content: 'Content 4', + authorId: 'u1' + } +} + +async function createTestGatewayServer (t) { + // User service + const userServiceSchema = ` + type Query @extends { + me: User + } + + type Metadata { + info: String! + } + + type User @key(fields: "id") { + id: ID! + name: String! + quote(input: String!): String! + metadata(input: String!): Metadata! + }` + const userServiceResolvers = { + Query: { + me: (root, args, context, info) => { + return users.u1 + } + }, + User: { + quote: (user, args, context, info) => { + return args.input + }, + metadata: (user, args, context, info) => { + return { + info: args.input + } + }, + __resolveReference: (user, args, context, info) => { + return users[user.id] + } + } + } + const [userService, userServicePort] = await createTestService(t, userServiceSchema, userServiceResolvers) + + // Post service + const postServiceSchema = ` + type Post @key(fields: "pid") { + pid: ID! + } + + type User @key(fields: "id") @extends { + id: ID! @external + topPosts(count: Int!): [Post] + }` + const postServiceResolvers = { + User: { + topPosts: (user, { count }, context, info) => { + return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count) + } + } + } + const [postService, postServicePort] = await createTestService(t, postServiceSchema, postServiceResolvers) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await userService.close() + await postService.close() + }) + gateway.register(GQL, { + allowBatchedQueries: true, + gateway: { + services: [{ + name: 'user', + url: `http://localhost:${userServicePort}/graphql`, + allowBatchedQueries: true + }, { + name: 'post', + url: `http://localhost:${postServicePort}/graphql`, + allowBatchedQueries: true + }] + } + }) + return gateway +} + +test('gateway with batching - should support aliases', async (t) => { + t.plan(1) + const app = await createTestGatewayServer(t) + + const query = ` + query getUser { + user: me { + id + name + newName: name + otherName: name + quote(input: "quote") + firstQuote: quote(input: "foo") + secondQuote: quote(input: "bar") + metadata(input: "info") { + info + } + originalMetadata: metadata(input: "hello") { + hi: info + ho: info + } + moreMetadata: metadata(input: "hi") { + info + } + somePosts: topPosts(count: 1) { + pid + } + morePosts: topPosts(count: 2) { + pid + } + } + }` + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify([ + { operationName: 'getUser', query } + ]) + }) + + t.same(JSON.parse(res.body)[0], { + data: { + user: { + id: 'u1', + name: 'John', + newName: 'John', + otherName: 'John', + quote: 'quote', + firstQuote: 'foo', + secondQuote: 'bar', + metadata: { + info: 'info' + }, + originalMetadata: { + hi: 'hello', + ho: 'hello' + }, + moreMetadata: { + info: 'hi' + }, + somePosts: [ + { + pid: 'p1' + } + ], + morePosts: [ + { + pid: 'p1' + }, + { + pid: 'p3' + } + ] + } + } + }) +}) diff --git a/test/gateway/custom-directives-with-batching.js b/test/gateway/custom-directives-with-batching.js new file mode 100644 index 00000000..51e041d2 --- /dev/null +++ b/test/gateway/custom-directives-with-batching.js @@ -0,0 +1,286 @@ +'use strict' + +const t = require('tap') +const Fastify = require('fastify') +const GQL = require('../..') +const { MER_ERR_GQL_GATEWAY_DUPLICATE_DIRECTIVE } = require('../../lib/errors') + +async function createTestService (t, schema, resolvers = {}) { + const service = Fastify() + service.register(GQL, { + schema, + resolvers, + federationMetadata: true, + allowBatchedQueries: true + }) + await service.listen(0) + return [service, service.server.address().port] +} + +const users = { + u1: { + id: 'u1', + name: 'John' + }, + u2: { + id: 'u2', + name: 'Jane' + } +} + +const posts = { + p1: { + pid: 'p1', + title: 'Post 1', + content: 'Content 1', + authorId: 'u1' + }, + p2: { + pid: 'p2', + title: 'Post 2', + content: 'Content 2', + authorId: 'u2' + }, + p3: { + pid: 'p3', + title: 'Post 3', + content: 'Content 3', + authorId: 'u1' + }, + p4: { + pid: 'p4', + title: 'Post 4', + content: 'Content 4', + authorId: 'u1' + } +} + +const query = ` + query { + me { + id + name + topPosts(count: 2) { + pid + author { + id + } + } + } + topPosts(count: 2) { + pid + } + } +` + +async function createUserService (directiveDefinition) { + const userServiceSchema = ` + ${directiveDefinition} + + type Query @extends { + me: User @custom + } + + type User @key(fields: "id") { + id: ID! + name: String! @custom + }` + const userServiceResolvers = { + Query: { + me: (root, args, context, info) => { + return users.u1 + } + }, + User: { + __resolveReference: (user, args, context, info) => { + return users[user.id] + } + } + } + return createTestService(t, userServiceSchema, userServiceResolvers) +} + +async function createPostService (directiveDefinition) { + const postServiceSchema = ` + ${directiveDefinition} + + type Post @key(fields: "pid") { + pid: ID! @custom + author: User + } + + extend type Query { + topPosts(count: Int): [Post] + } + + type User @key(fields: "id") @extends { + id: ID! @external + topPosts(count: Int!): [Post] + }` + const postServiceResolvers = { + Post: { + __resolveReference: (post, args, context, info) => { + return posts[post.pid] + }, + author: (post, args, context, info) => { + return { + __typename: 'User', + id: post.authorId + } + } + }, + User: { + topPosts: (user, { count }, context, info) => { + return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count) + } + }, + Query: { + topPosts: (root, { count = 2 }) => Object.values(posts).slice(0, count) + } + } + return createTestService(t, postServiceSchema, postServiceResolvers) +} + +t.test('gateway with batching', t => { + t.plan(2) + + t.test('should de-duplicate custom directives on the gateway', async (t) => { + t.plan(4) + + const [userService, userServicePort] = await createUserService('directive @custom(input: ID) on OBJECT | FIELD_DEFINITION') + const [postService, postServicePort] = await createPostService('directive @custom(input: ID) on OBJECT | FIELD_DEFINITION') + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await userService.close() + await postService.close() + }) + gateway.register(GQL, { + gateway: { + services: [{ + name: 'user', + url: `http://localhost:${userServicePort}/graphql`, + allowBatchedQueries: true + }, { + name: 'post', + url: `http://localhost:${postServicePort}/graphql`, + allowBatchedQueries: true + }] + } + }) + await gateway.ready() + + const userDirectiveNames = userService.graphql.schema.getDirectives().map(directive => directive.name) + t.same(userDirectiveNames, [ + 'include', + 'skip', + 'deprecated', + 'specifiedBy', + 'external', + 'requires', + 'provides', + 'key', + 'extends', + 'custom' + ]) + + const postDirectiveNames = userService.graphql.schema.getDirectives().map(directive => directive.name) + t.same(postDirectiveNames, [ + 'include', + 'skip', + 'deprecated', + 'specifiedBy', + 'external', + 'requires', + 'provides', + 'key', + 'extends', + 'custom' + ]) + + const gatewayDirectiveNames = gateway.graphql.schema.getDirectives().map(directive => directive.name) + t.same(gatewayDirectiveNames, [ + 'include', + 'skip', + 'deprecated', + 'specifiedBy', + 'custom' + ]) + + 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: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) + }) + + t.test('should error on startup when different definitions of custom directives with the same name are present in federated services', async (t) => { + t.plan(1) + + const [userService, userServicePort] = await createUserService('directive @custom(input: ID) on OBJECT | FIELD_DEFINITION') + const [postService, postServicePort] = await createPostService('directive @custom(input: String) on OBJECT | FIELD_DEFINITION') + const serviceOpts = { + keepAliveTimeout: 10, // milliseconds + keepAliveMaxTimeout: 10 // milliseconds + } + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await userService.close() + await postService.close() + }) + gateway.register(GQL, { + gateway: { + services: [ + { + ...serviceOpts, + name: 'user', + url: `http://localhost:${userServicePort}/graphql`, + allowBatchedQueries: true + }, + { + ...serviceOpts, + name: 'post', + url: `http://localhost:${postServicePort}/graphql`, + allowBatchedQueries: true + } + ] + } + }) + + await t.rejects(gateway.ready(), new MER_ERR_GQL_GATEWAY_DUPLICATE_DIRECTIVE('custom')) + }) +}) diff --git a/test/gateway/errors-with-batching.js b/test/gateway/errors-with-batching.js new file mode 100644 index 00000000..09063985 --- /dev/null +++ b/test/gateway/errors-with-batching.js @@ -0,0 +1,152 @@ +'use strict' + +const { test } = require('tap') +const Fastify = require('fastify') +const GQL = require('../..') +const { ErrorWithProps } = require('../../') + +async function createTestService (t, schema, resolvers = {}, allowBatchedQueries = false) { + const service = Fastify() + service.register(GQL, { + schema, + resolvers, + federationMetadata: true, + allowBatchedQueries + }) + await service.listen(0) + return [service, service.server.address().port] +} + +async function createTestGatewayServer (t, allowBatchedQueries = false) { + // User service + const userServiceSchema = ` + type Query @extends { + me: User + } + + type Metadata { + info: String! + } + + type User @key(fields: "id") { + id: ID! + name: String! + quote(input: String!): String! + metadata(input: String!): Metadata! + }` + const userServiceResolvers = { + Query: { + me: (root, args, context, info) => { + throw new ErrorWithProps('Invalid User ID', { + id: 4, + code: 'USER_ID_INVALID' + }) + } + }, + User: { + quote: (user, args, context, info) => { + throw new ErrorWithProps('Invalid Quote', { + id: 4, + code: 'QUOTE_ID_INVALID' + }) + } + } + } + const [userService, userServicePort] = await createTestService(t, userServiceSchema, userServiceResolvers, allowBatchedQueries) + + // Post service + const postServiceSchema = ` + type Post @key(fields: "pid") { + pid: ID! + } + + type User @key(fields: "id") @extends { + id: ID! @external + topPosts(count: Int!): [Post] + }` + const postServiceResolvers = { + User: { + topPosts: (user, { count }, context, info) => { + throw new ErrorWithProps('Invalid Quote', { + id: 4, + code: 'NO_TOP_POSTS' + }) + } + } + } + const [postService, postServicePort] = await createTestService(t, postServiceSchema, postServiceResolvers, allowBatchedQueries) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await userService.close() + await postService.close() + }) + gateway.register(GQL, { + gateway: { + services: [{ + name: 'user', + url: `http://localhost:${userServicePort}/graphql`, + allowBatchedQueries + }, { + name: 'post', + url: `http://localhost:${postServicePort}/graphql`, + allowBatchedQueries + }] + } + }) + return gateway +} + +test('it returns the same error if batching is enabled', async (t) => { + t.plan(1) + const app1 = await createTestGatewayServer(t) + const app2 = await createTestGatewayServer(t, true) + + const query = ` + query { + user: me { + id + name + newName: name + otherName: name + quote(input: "quote") + firstQuote: quote(input: "foo") + secondQuote: quote(input: "bar") + metadata(input: "info") { + info + } + originalMetadata: metadata(input: "hello") { + hi: info + ho: info + } + moreMetadata: metadata(input: "hi") { + info + } + somePosts: topPosts(count: 1) { + pid + } + morePosts: topPosts(count: 2) { + pid + } + } + }` + + const res1 = await app1.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + await app1.close() + + const res2 = await app2.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res1.body), JSON.parse(res2.body)) +}) diff --git a/test/gateway/get-query-result.js b/test/gateway/get-query-result.js new file mode 100644 index 00000000..093212c3 --- /dev/null +++ b/test/gateway/get-query-result.js @@ -0,0 +1,137 @@ +'use strict' + +const { parse } = require('graphql') +const getQueryResult = require('../../lib/gateway/get-query-result') +const { test } = require('tap') + +const getQueryWithCount = (count) => ` +query EntitiesQuery($representations: [_Any!]!) { + _entities(representations: $representations) { + __typename + ... on User { + topPosts(count: ${count}) { + pid + __typename + pid + } + } + } +} +` + +const createEntity = (pid) => ({ + __typename: 'User', + topPosts: { + pid, + __typename: 'Post' + } +}) + +const createNotBatchedResponse = (...entities) => ({ + json: { + data: { + _entities: [...entities] + } + } +}) + +const createBatchedResponse = (...entities) => ({ + json: [ + { + data: { + _entities: [...entities] + } + }, + { + data: { + _entities: [...entities] + } + } + ] +}) + +test('it works with a basic example', async (t) => { + const entity1 = createEntity('p1') + const entity2 = createEntity('p2') + const result = await getQueryResult({ + context: { + preGatewayExecution: null, + reply: { + request: { + headers: {} + } + } + }, + + queries: [ + { + document: parse(getQueryWithCount(1)), + query: getQueryWithCount(1), + variables: { + representations: [ + { + __typename: 'User', + id: 'u1' + } + ] + } + } + ], + serviceDefinition: { + sendRequest: async () => createNotBatchedResponse(entity1, entity2) + } + }) + + t.same(result[0].data._entities[0], entity1) + t.same(result[0].data._entities[1], entity2) +}) + +test('it works with a basic example and batched queries', async (t) => { + const entity1 = createEntity('p3') + const entity2 = createEntity('p4') + const result = await getQueryResult({ + context: { + preGatewayExecution: null, + reply: { + request: { + headers: {} + } + } + }, + queries: [ + { + document: parse(getQueryWithCount(1)), + query: getQueryWithCount(1), + variables: { + representations: [ + { + __typename: 'User', + id: 'u1' + } + ] + } + }, + { + document: parse(getQueryWithCount(2)), + query: getQueryWithCount(2), + variables: { + representations: [ + { + __typename: 'User', + id: 'u1' + } + ] + } + } + ], + serviceDefinition: { + allowBatchedQueries: true, + sendRequest: async () => createBatchedResponse(entity1, entity2) + } + }) + + t.same(result[0].data._entities[0], entity1) + t.same(result[0].data._entities[1], entity2) + t.same(result[1].data._entities[0], entity1) + t.same(result[1].data._entities[1], entity2) +}) diff --git a/test/gateway/hooks-with-batching.js b/test/gateway/hooks-with-batching.js new file mode 100644 index 00000000..9ff9518e --- /dev/null +++ b/test/gateway/hooks-with-batching.js @@ -0,0 +1,1240 @@ +'use strict' + +const { test } = require('tap') +const Fastify = require('fastify') +const { GraphQLSchema, parse } = require('graphql') +const { promisify } = require('util') +const GQL = require('../..') + +const immediate = promisify(setImmediate) + +async function createTestService (t, schema, resolvers = {}) { + const service = Fastify() + service.register(GQL, { + schema, + resolvers, + federationMetadata: true, + allowBatchedQueries: true + }) + await service.listen(0) + return [service, service.server.address().port] +} + +const users = { + u1: { + id: 'u1', + name: 'John' + }, + u2: { + id: 'u2', + name: 'Jane' + } +} + +const posts = { + p1: { + pid: 'p1', + title: 'Post 1', + content: 'Content 1', + authorId: 'u1' + }, + p2: { + pid: 'p2', + title: 'Post 2', + content: 'Content 2', + authorId: 'u2' + }, + p3: { + pid: 'p3', + title: 'Post 3', + content: 'Content 3', + authorId: 'u1' + }, + p4: { + pid: 'p4', + title: 'Post 4', + content: 'Content 4', + authorId: 'u1' + } +} + +const query = ` + query { + me { + id + name + topPosts(count: 2) { + pid + author { + id + } + } + } + topPosts(count: 2) { + pid + } + } +` + +async function createTestGatewayServer (t, opts = {}) { + // User service + const userServiceSchema = ` + type Query @extends { + me: User + } + + type User @key(fields: "id") { + id: ID! + name: String! + }` + const userServiceResolvers = { + Query: { + me: (root, args, context, info) => { + return users.u1 + } + }, + User: { + __resolveReference: (user, args, context, info) => { + return users[user.id] + } + } + } + const [userService, userServicePort] = await createTestService(t, userServiceSchema, userServiceResolvers) + + // Post service + const postServiceSchema = ` + type Post @key(fields: "pid") { + pid: ID! + author: User + } + + extend type Query { + topPosts(count: Int): [Post] + } + + type User @key(fields: "id") @extends { + id: ID! @external + topPosts(count: Int!): [Post] + }` + const postServiceResolvers = { + Post: { + __resolveReference: (post, args, context, info) => { + return posts[post.pid] + }, + author: (post, args, context, info) => { + return { + __typename: 'User', + id: post.authorId + } + } + }, + User: { + topPosts: (user, { count }, context, info) => { + return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count) + } + }, + Query: { + topPosts: (root, { count = 2 }) => Object.values(posts).slice(0, count) + } + } + const [postService, postServicePort] = await createTestService(t, postServiceSchema, postServiceResolvers) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await userService.close() + await postService.close() + }) + gateway.register(GQL, { + ...opts, + gateway: { + services: [{ + name: 'user', + url: `http://localhost:${userServicePort}/graphql`, + allowBatchedQueries: true + }, { + name: 'post', + url: `http://localhost:${postServicePort}/graphql`, + allowBatchedQueries: true + }] + } + }) + return gateway +} + +// ----- +// hooks +// ----- +test('gateway - hooks', async (t) => { + t.plan(32) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preParsing', async function (schema, source, context) { + await immediate() + t.type(schema, GraphQLSchema) + t.equal(source, query) + t.type(context, 'object') + t.ok('preParsing called') + }) + + app.graphql.addHook('preValidation', async function (schema, document, context) { + await immediate() + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + t.ok('preValidation called') + }) + + app.graphql.addHook('preExecution', async function (schema, document, context) { + await immediate() + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + t.ok('preExecution called') + }) + + // Execution events: + // - once for user service query + // - once for post service query + // - once for reference type topPosts on User + // - once for reference type author on Post + app.graphql.addHook('preGatewayExecution', async function (schema, document, context) { + await immediate() + t.type(schema, GraphQLSchema) + t.type(document, 'object') + t.type(context, 'object') + t.ok('preGatewayExecution called') + }) + + app.graphql.addHook('onResolution', async function (execution, context) { + await immediate() + t.type(execution, 'object') + t.type(context, 'object') + t.ok('onResolution called') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) +}) + +test('gateway - hooks validation should handle invalid hook names', async (t) => { + t.plan(1) + const app = await createTestGatewayServer(t) + + try { + app.graphql.addHook('unsupportedHook', async () => {}) + } catch (e) { + t.equal(e.message, 'unsupportedHook hook not supported!') + } +}) + +test('gateway - hooks validation should handle invalid hook name types', async (t) => { + t.plan(2) + const app = await createTestGatewayServer(t) + + try { + app.graphql.addHook(1, async () => {}) + } catch (e) { + t.equal(e.code, 'MER_ERR_HOOK_INVALID_TYPE') + t.equal(e.message, 'The hook name must be a string') + } +}) + +test('gateway - hooks validation should handle invalid hook handlers', async (t) => { + t.plan(2) + const app = await createTestGatewayServer(t) + + try { + app.graphql.addHook('preParsing', 'not a function') + } catch (e) { + t.equal(e.code, 'MER_ERR_HOOK_INVALID_HANDLER') + t.equal(e.message, 'The hook callback must be a function') + } +}) + +test('gateway - hooks should trigger when JIT is enabled', async (t) => { + t.plan(60) + const app = await createTestGatewayServer(t, { jit: 1 }) + + app.graphql.addHook('preParsing', async function (schema, source, context) { + await immediate() + t.type(schema, GraphQLSchema) + t.equal(source, query) + t.type(context, 'object') + t.ok('preParsing called') + }) + + // preValidation is not triggered a second time + app.graphql.addHook('preValidation', async function (schema, document, context) { + await immediate() + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + t.ok('preValidation called') + }) + + app.graphql.addHook('preExecution', async function (schema, document, context) { + await immediate() + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + t.ok('preExecution called') + }) + + // Execution events: + // - once for user service query + // - once for post service query + // - once for reference type topPosts on User + // - once for reference type author on Post + app.graphql.addHook('preGatewayExecution', async function (schema, document, context) { + await immediate() + t.type(schema, GraphQLSchema) + t.type(document, 'object') + t.type(context, 'object') + t.ok('preGatewayExecution called') + }) + + app.graphql.addHook('onResolution', async function (execution, context) { + await immediate() + t.type(execution, 'object') + t.type(context, 'object') + t.ok('onResolution called') + }) + + { + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) + } + + { + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) + } +}) + +// -------------------- +// preParsing +// -------------------- +test('gateway - preParsing hooks should handle errors', async t => { + t.plan(4) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preParsing', async (schema, source, context) => { + t.type(schema, GraphQLSchema) + t.equal(source, query) + t.type(context, 'object') + throw new Error('a preParsing error occured') + }) + + app.graphql.addHook('preParsing', async (schema, source, context) => { + t.fail('this should not be called') + }) + + app.graphql.addHook('preValidation', async (schema, document, context) => { + t.fail('this should not be called') + }) + + app.graphql.addHook('preExecution', async (schema, operation, context) => { + t.fail('this should not be called') + }) + + app.graphql.addHook('onResolution', async (execution, context) => { + t.fail('this should not be called') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: null, + errors: [ + { + message: 'a preParsing error occured' + } + ] + }) +}) + +test('gateway - preParsing hooks should be able to put values onto the context', async t => { + t.plan(8) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preParsing', async (schema, source, context) => { + t.type(schema, GraphQLSchema) + t.equal(source, query) + t.type(context, 'object') + context.foo = 'bar' + }) + + app.graphql.addHook('preParsing', async (schema, source, context) => { + t.type(schema, GraphQLSchema) + t.equal(source, query) + t.type(context, 'object') + t.equal(context.foo, 'bar') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) +}) + +// -------------- +// preValidation +// -------------- +test('gateway - preValidation hooks should handle errors', async t => { + t.plan(4) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preValidation', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + throw new Error('a preValidation error occured') + }) + + app.graphql.addHook('preValidation', async (schema, document, context) => { + t.fail('this should not be called') + }) + + app.graphql.addHook('preExecution', async (schema, document, context) => { + t.fail('this should not be called') + }) + + app.graphql.addHook('onResolution', async (execution, context) => { + t.fail('this should not be called') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: null, + errors: [ + { + message: 'a preValidation error occured' + } + ] + }) +}) + +test('gateway - preValidation hooks should be able to put values onto the context', async t => { + t.plan(8) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preValidation', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + context.foo = 'bar' + }) + + app.graphql.addHook('preValidation', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + t.equal(context.foo, 'bar') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) +}) + +// ------------- +// preExecution +// ------------- +test('gateway - preExecution hooks should handle errors', async t => { + t.plan(4) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + throw new Error('a preExecution error occured') + }) + + app.graphql.addHook('preExecution', async (schema, document, context) => { + t.fail('this should not be called') + }) + + app.graphql.addHook('onResolution', async (execution, context) => { + t.fail('this should not be called') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: null, + errors: [ + { + message: 'a preExecution error occured' + } + ] + }) +}) + +test('gateway - preExecution hooks should be able to put values onto the context', async t => { + t.plan(8) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + context.foo = 'bar' + }) + + app.graphql.addHook('preExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + t.equal(context.foo, 'bar') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) +}) + +test('gateway - preExecution hooks should be able to modify the request document', async t => { + t.plan(5) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + t.ok('preExecution called') + const documentClone = JSON.parse(JSON.stringify(document)) + documentClone.definitions[0].selectionSet.selections = [documentClone.definitions[0].selectionSet.selections[0]] + return { + document: documentClone + } + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + } + } + }) +}) + +test('gateway - preExecution hooks should be able to add to the errors array', async t => { + t.plan(9) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + t.ok('preExecution called for foo error') + return { + errors: [new Error('foo')] + } + }) + + app.graphql.addHook('preExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.same(document, parse(query)) + t.type(context, 'object') + t.ok('preExecution called for foo error') + return { + errors: [new Error('bar')] + } + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + }, + errors: [ + { + message: 'foo' + }, + { + message: 'bar' + } + ] + }) +}) + +// ------------------- +// preGatewayExecution +// ------------------- +test('gateway - preGatewayExecution hooks should handle errors', async t => { + t.plan(10) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preGatewayExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.type(document, 'object') + t.type(context, 'object') + throw new Error('a preGatewayExecution error occured') + }) + + app.graphql.addHook('preGatewayExecution', async (schema, document, context) => { + t.fail('this should not be called') + }) + + // This should still be called in the gateway + app.graphql.addHook('onResolution', async (execution, context) => { + t.type(execution, 'object') + t.type(context, 'object') + t.ok('onResolution called') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: null, + topPosts: null + }, + errors: [ + { + message: 'a preGatewayExecution error occured', + locations: [{ line: 3, column: 5 }], + path: ['me'] + }, + { + message: 'a preGatewayExecution error occured', + locations: [{ line: 13, column: 5 }], + path: ['topPosts'] + } + ] + }) +}) + +test('gateway - preGatewayExecution hooks should be able to put values onto the context', async t => { + t.plan(29) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preGatewayExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.type(document, 'object') + t.type(context, 'object') + context[document.definitions[0].name.value] = 'bar' + }) + + app.graphql.addHook('preGatewayExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.type(document, 'object') + t.type(context, 'object') + t.equal(context[document.definitions[0].name.value], 'bar') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) +}) + +test('gateway - preGatewayExecution hooks should be able to add to the errors array', async t => { + t.plan(33) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preGatewayExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.type(document, 'object') + t.type(context, 'object') + t.ok('preGatewayExecution called for foo error') + return { + errors: [new Error(`foo - ${document.definitions[0].name.value}`)] + } + }) + + app.graphql.addHook('preGatewayExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.type(document, 'object') + t.type(context, 'object') + t.ok('preGatewayExecution called for foo error') + return { + errors: [new Error(`bar - ${document.definitions[0].name.value}`)] + } + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + }, + errors: [ + { + message: 'foo - Query_me' + }, + { + message: 'bar - Query_me' + }, + { + message: 'foo - Query_topPosts' + }, + { + message: 'bar - Query_topPosts' + }, + { + message: 'foo - EntitiesQuery' + }, + { + message: 'bar - EntitiesQuery' + }, + { + message: 'foo - EntitiesQuery' + }, + { + message: 'bar - EntitiesQuery' + } + ] + }) +}) + +test('gateway - preGatewayExecution hooks should be able to modify the request document', async t => { + t.plan(17) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('preGatewayExecution', async (schema, document, context) => { + t.type(schema, GraphQLSchema) + t.type(document, 'object') + t.type(context, 'object') + t.ok('preGatewayExecution called') + if (document.definitions[0].name.value === 'EntitiesQuery') { + if (document.definitions[0].selectionSet.selections[0].selectionSet.selections[1].selectionSet.selections[0].arguments[0]) { + const documentClone = JSON.parse(JSON.stringify(document)) + documentClone.definitions[0].selectionSet.selections[0].selectionSet.selections[1].selectionSet.selections[0].arguments[0].value.value = 1 + return { + document: documentClone + } + } + } + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) +}) + +test('gateway - preGatewayExecution hooks should contain service metadata', async (t) => { + t.plan(21) + const app = await createTestGatewayServer(t) + + // Execution events: + // - user service: once for user service query + // - post service: once for post service query + // - post service: once for reference type topPosts on User + // - user service: once for reference type author on Post + app.graphql.addHook('preGatewayExecution', async function (schema, document, context, service) { + await immediate() + t.type(schema, GraphQLSchema) + t.type(document, 'object') + t.type(context, 'object') + if (typeof service === 'object' && service.name === 'user') { + t.equal(service.name, 'user') + } else if (typeof service === 'object' && service.name === 'post') { + t.equal(service.name, 'post') + } else { + t.fail('service metadata should be correctly populated') + return + } + t.ok('preGatewayExecution called') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) +}) + +// ------------- +// onResolution +// ------------- +test('gateway - onResolution hooks should handle errors', async t => { + t.plan(3) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('onResolution', async (execution, context) => { + t.type(execution, 'object') + t.type(context, 'object') + throw new Error('a onResolution error occured') + }) + + app.graphql.addHook('onResolution', async (execution, context) => { + t.fail('this should not be called') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: null, + errors: [ + { + message: 'a onResolution error occured' + } + ] + }) +}) + +test('gateway - onResolution hooks should be able to put values onto the context', async t => { + t.plan(6) + const app = await createTestGatewayServer(t) + + app.graphql.addHook('onResolution', async (execution, context) => { + t.type(execution, 'object') + t.type(context, 'object') + context.foo = 'bar' + }) + + app.graphql.addHook('onResolution', async (execution, context) => { + t.type(execution, 'object') + t.type(context, 'object') + t.equal(context.foo, 'bar') + }) + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + topPosts: [ + { + pid: 'p1', + author: { + id: 'u1' + } + }, + { + pid: 'p3', + author: { + id: 'u1' + } + } + ] + }, + topPosts: [ + { + pid: 'p1' + }, + { + pid: 'p2' + } + ] + } + }) +}) diff --git a/test/gateway/include-directive-with-batching.js b/test/gateway/include-directive-with-batching.js new file mode 100644 index 00000000..01630b83 --- /dev/null +++ b/test/gateway/include-directive-with-batching.js @@ -0,0 +1,214 @@ +'use strict' + +const { test } = require('tap') +const Fastify = require('fastify') +const GQL = require('../..') + +async function createTestService (t, schema, resolvers = {}) { + const service = Fastify() + service.register(GQL, { + schema, + resolvers, + federationMetadata: true, + allowBatchedQueries: true + }) + await service.listen(0) + return [service, service.server.address().port] +} + +const users = { + u1: { + id: 'u1', + name: 'John' + }, + u2: { + id: 'u2', + name: 'Jane' + } +} + +const posts = { + p1: { + pid: 'p1', + title: 'Post 1', + content: 'Content 1', + authorId: 'u1' + }, + p2: { + pid: 'p2', + title: 'Post 2', + content: 'Content 2', + authorId: 'u2' + }, + p3: { + pid: 'p3', + title: 'Post 3', + content: 'Content 3', + authorId: 'u1' + }, + p4: { + pid: 'p4', + title: 'Post 4', + content: 'Content 4', + authorId: 'u1' + } +} + +async function createTestGatewayServer (t) { + // User service + const userServiceSchema = ` + type Query @extends { + me: User + } + + type Metadata { + info: String! + } + + type User @key(fields: "id") { + id: ID! + name: String! + metadata(input: String!): Metadata! + }` + const userServiceResolvers = { + Query: { + me: (root, args, context, info) => { + return users.u1 + } + }, + User: { + metadata: (user, args, context, info) => { + return { + info: args.input + } + } + } + } + const [userService, userServicePort] = await createTestService(t, userServiceSchema, userServiceResolvers) + + // Post service + const postServiceSchema = ` + type Post @key(fields: "pid") { + pid: ID! + } + + type User @key(fields: "id") @extends { + id: ID! @external + topPosts(count: Int!): [Post] + }` + const postServiceResolvers = { + User: { + topPosts: (user, { count }, context, info) => { + return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count) + } + } + } + const [postService, postServicePort] = await createTestService(t, postServiceSchema, postServiceResolvers) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await userService.close() + await postService.close() + }) + gateway.register(GQL, { + gateway: { + services: [{ + name: 'user', + url: `http://localhost:${userServicePort}/graphql`, + allowBatchedQueries: true + }, { + name: 'post', + url: `http://localhost:${postServicePort}/graphql`, + allowBatchedQueries: true + }] + } + }) + return gateway +} + +test('gateway - should support truthy include directive', async (t) => { + t.plan(1) + const app = await createTestGatewayServer(t) + + const variables = { + shouldInclude: true, + input: 'hello' + } + const query = ` + query GetMe($input: String!, $shouldInclude: Boolean!) { + me { + id + name + metadata(input: $input) @include(if: $shouldInclude) { + info + } + topPosts(count: 1) @include(if: $shouldInclude) { + pid + } + } + }` + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query, variables }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John', + metadata: { + info: 'hello' + }, + topPosts: [ + { + pid: 'p1' + } + ] + } + } + }) +}) + +test('gateway - should support falsy include directive', async (t) => { + t.plan(1) + const app = await createTestGatewayServer(t) + + const variables = { + shouldInclude: false, + input: 'hello' + } + const query = ` + query GetMe($input: String!, $shouldInclude: Boolean!) { + me { + id + name + metadata(input: $input) @include(if: $shouldInclude) { + info + } + topPosts(count: 1) @include(if: $shouldInclude) { + pid + } + } + }` + + const res = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query, variables }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John' + } + } + }) +}) diff --git a/test/gateway/load-balancing-with-batching.js b/test/gateway/load-balancing-with-batching.js new file mode 100644 index 00000000..a5b49b7e --- /dev/null +++ b/test/gateway/load-balancing-with-batching.js @@ -0,0 +1,198 @@ +'use strict' + +const { test } = require('tap') +const Fastify = require('fastify') +const GQL = require('../..') + +async function createTestService (t, schema, resolvers = {}, fn = async () => {}) { + const service = Fastify() + service.addHook('preHandler', fn) + service.register(GQL, { + schema, + resolvers, + federationMetadata: true, + allowBatchedQueries: true + }) + await service.listen(0) + return [service, service.server.address().port] +} + +const users = { + u1: { + id: 'u1', + name: 'John' + }, + u2: { + id: 'u2', + name: 'Jane' + } +} + +const posts = { + p1: { + pid: 'p1', + title: 'Post 1', + content: 'Content 1', + authorId: 'u1' + }, + p2: { + pid: 'p2', + title: 'Post 2', + content: 'Content 2', + authorId: 'u2' + }, + p3: { + pid: 'p3', + title: 'Post 3', + content: 'Content 3', + authorId: 'u1' + }, + p4: { + pid: 'p4', + title: 'Post 4', + content: 'Content 4', + authorId: 'u1' + } +} + +test('load balances two peers', async (t) => { + // User service + const userServiceSchema = ` + type Query @extends { + me: User + } + + type Metadata { + info: String! + } + + type User @key(fields: "id") { + id: ID! + name: String! + metadata(input: String!): Metadata! + }` + const userServiceResolvers = { + Query: { + me: (root, args, context, info) => { + return users.u1 + } + }, + User: { + metadata: (user, args, context, info) => { + return { + info: args.input + } + } + } + } + let user1called = 0 + let user2called = 0 + const [userService1, userServicePort1] = await createTestService(t, userServiceSchema, userServiceResolvers, async () => { + user1called++ + }) + const [userService2, userServicePort2] = await createTestService(t, userServiceSchema, userServiceResolvers, async () => { + user2called++ + }) + + // Post service + const postServiceSchema = ` + type Post @key(fields: "pid") { + pid: ID! + } + + type User @key(fields: "id") @extends { + id: ID! @external + topPosts(count: Int!): [Post] + }` + const postServiceResolvers = { + User: { + topPosts: (user, { count }, context, info) => { + return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count) + } + } + } + const [postService, postServicePort] = await createTestService(t, postServiceSchema, postServiceResolvers) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await userService1.close() + await userService2.close() + await postService.close() + }) + + gateway.register(GQL, { + gateway: { + services: [{ + name: 'user', + url: [`http://localhost:${userServicePort1}/graphql`, `http://localhost:${userServicePort2}/graphql`], + allowBatchedQueries: true + }, { + name: 'post', + url: `http://localhost:${postServicePort}/graphql`, + allowBatchedQueries: true + }] + } + }) + await gateway + + const variables = { + shouldSkip: true, + input: 'hello' + } + const query = ` + query GetMe($input: String!, $shouldSkip: Boolean!) { + me { + id + name + metadata(input: $input) @skip(if: $shouldSkip) { + info + } + topPosts(count: 1) @skip(if: $shouldSkip) { + pid + } + } + }` + + { + const res = await gateway.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query, variables }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John' + } + } + }) + } + + { + const res = await gateway.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query, variables }) + }) + + t.same(JSON.parse(res.body), { + data: { + me: { + id: 'u1', + name: 'John' + } + } + }) + } + + // Called two times, one to get the schema and one for the query + t.equal(user1called, 2) + + // Called one time, one one for the query + t.equal(user2called, 1) +}) diff --git a/test/gateway/with-batching.js b/test/gateway/with-batching.js new file mode 100644 index 00000000..c6db28aa --- /dev/null +++ b/test/gateway/with-batching.js @@ -0,0 +1,188 @@ +'use strict' + +const { test } = require('tap') +const Fastify = require('fastify') +const GQL = require('../..') + +async function createTestService (t, schema, resolvers = {}, allowBatchedQueries = false) { + const service = Fastify() + service.register(GQL, { + schema, + resolvers, + federationMetadata: true, + allowBatchedQueries + }) + await service.listen(0) + return [service, service.server.address().port] +} + +const users = { + u1: { + id: 'u1', + name: 'John' + }, + u2: { + id: 'u2', + name: 'Jane' + } +} + +const posts = { + p1: { + pid: 'p1', + title: 'Post 1', + content: 'Content 1', + authorId: 'u1' + }, + p2: { + pid: 'p2', + title: 'Post 2', + content: 'Content 2', + authorId: 'u2' + }, + p3: { + pid: 'p3', + title: 'Post 3', + content: 'Content 3', + authorId: 'u1' + }, + p4: { + pid: 'p4', + title: 'Post 4', + content: 'Content 4', + authorId: 'u1' + } +} + +async function createTestGatewayServer (t, allowBatchedQueries = false) { + // User service + const userServiceSchema = ` + type Query @extends { + me: User + } + + type Metadata { + info: String! + } + + type User @key(fields: "id") { + id: ID! + name: String! + quote(input: String!): String! + metadata(input: String!): Metadata! + }` + const userServiceResolvers = { + Query: { + me: (root, args, context, info) => { + return users.u1 + } + }, + User: { + quote: (user, args, context, info) => { + return args.input + }, + metadata: (user, args, context, info) => { + return { + info: args.input + } + }, + __resolveReference: (user, args, context, info) => { + return users[user.id] + } + } + } + const [userService, userServicePort] = await createTestService(t, userServiceSchema, userServiceResolvers, allowBatchedQueries) + + // Post service + const postServiceSchema = ` + type Post @key(fields: "pid") { + pid: ID! + } + + type User @key(fields: "id") @extends { + id: ID! @external + topPosts(count: Int!): [Post] + }` + const postServiceResolvers = { + User: { + topPosts: (user, { count }, context, info) => { + return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count) + } + } + } + const [postService, postServicePort] = await createTestService(t, postServiceSchema, postServiceResolvers, allowBatchedQueries) + + const gateway = Fastify() + t.teardown(async () => { + await gateway.close() + await userService.close() + await postService.close() + }) + gateway.register(GQL, { + gateway: { + services: [{ + name: 'user', + url: `http://localhost:${userServicePort}/graphql`, + allowBatchedQueries + }, { + name: 'post', + url: `http://localhost:${postServicePort}/graphql`, + allowBatchedQueries + }] + } + }) + return gateway +} + +test('it returns the same data if batching is enabled', async (t) => { + t.plan(1) + const app1 = await createTestGatewayServer(t) + const app2 = await createTestGatewayServer(t, true) + + const query = ` + query { + user: me { + id + name + newName: name + otherName: name + quote(input: "quote") + firstQuote: quote(input: "foo") + secondQuote: quote(input: "bar") + metadata(input: "info") { + info + } + originalMetadata: metadata(input: "hello") { + hi: info + ho: info + } + moreMetadata: metadata(input: "hi") { + info + } + somePosts: topPosts(count: 1) { + pid + } + morePosts: topPosts(count: 2) { + pid + } + } + }` + + const res1 = await app1.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + await app1.close() + + const res2 = await app2.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(res1.body), JSON.parse(res2.body)) +}) From 59c8cd7397e5363a41a826312924a931de190a90 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 29 Nov 2021 14:08:43 +0100 Subject: [PATCH 41/55] Include the info object in loaders when the loader cache is off (#671) Signed-off-by: Matteo Collina --- docs/loaders.md | 33 +++++++- index.js | 30 +++++-- test/loaders.js | 203 ++++++++++++++++++++++++++++++++++++------------ 3 files changed, 207 insertions(+), 59 deletions(-) diff --git a/docs/loaders.md b/docs/loaders.md index 68341961..d84f189f 100644 --- a/docs/loaders.md +++ b/docs/loaders.md @@ -21,8 +21,8 @@ Example: ```js const loaders = { Dog: { - async owner(queries, { reply }) { - return queries.map(({ obj }) => owners[obj.name]) + async owner (queries, { reply }) { + return queries.map(({ obj, params }) => owners[obj.name]) } } } @@ -40,8 +40,11 @@ It is also possible disable caching with: const loaders = { Dog: { owner: { - async loader(queries, { reply }) { - return queries.map(({ obj }) => owners[obj.name]) + async loader (queries, { reply }) { + return queries.map(({ obj, params, info }) => { + // info is available only if the loader is not cached + owners[obj.name] + }) }, opts: { cache: false @@ -57,6 +60,28 @@ app.register(mercurius, { }) ``` +Alternatively, globally disabling caching also disable the Loader cache: + +```js +const loaders = { + Dog: { + async owner (queries, { reply }) { + return queries.map(({ obj, params, info }) => { + // info is available only if the loader is not cached + owners[obj.name] + }) + } + } +} + +app.register(mercurius, { + schema, + resolvers, + loaders, + cache: false +}) +``` + Disabling caching has the advantage to avoid the serialization at the cost of more objects to fetch in the resolvers. diff --git a/index.js b/index.js index 17b3db37..74f3f0ec 100644 --- a/index.js +++ b/index.js @@ -28,6 +28,7 @@ const buildGateway = require('./lib/gateway') const mq = require('mqemitter') const { PubSub, withFilter } = require('./lib/subscriber') const persistedQueryDefaults = require('./lib/persistedQueryDefaults') +const stringify = require('safe-stable-stringify') const { ErrorWithProps, defaultErrorFormatter, @@ -366,9 +367,10 @@ const plugin = fp(async function (app, opts) { app.decorate(kSubscriptionFactory, subscriptionFactory) } - function defineLoader (name) { + function defineLoader (name, opts) { // async needed because of throw - return async function (obj, params, { reply }, info) { + return async function (obj, params, ctx, info) { + const { reply } = ctx if (!reply) { throw new MER_ERR_INVALID_OPTS('loaders only work via reply.graphql()') } @@ -379,19 +381,33 @@ const plugin = fp(async function (app, opts) { } } + function serialize (query) { + if (query.info) { + return stringify({ obj: query.obj, params: query.params }) + } + return query + } + const resolvers = {} for (const typeKey of Object.keys(loaders)) { const type = loaders[typeKey] resolvers[typeKey] = {} for (const prop of Object.keys(type)) { const name = typeKey + '-' + prop - resolvers[typeKey][prop] = defineLoader(name) + const toAssign = [{}, type[prop].opts || {}] + if (opts.cache === false) { + toAssign.push({ + cache: false + }) + } + const factoryOpts = Object.assign(...toAssign) + resolvers[typeKey][prop] = defineLoader(name, factoryOpts) if (typeof type[prop] === 'function') { - factory.add(name, type[prop]) - subscriptionFactory.add(name, { cache: false }, type[prop]) + factory.add(name, factoryOpts, type[prop], serialize) + subscriptionFactory.add(name, { cache: false }, type[prop], serialize) } else { - factory.add(name, type[prop].opts, type[prop].loader) - subscriptionFactory.add(name, Object.assign({}, type[prop].opts, { cache: false }), type[prop].loader) + factory.add(name, factoryOpts, type[prop].loader, serialize) + subscriptionFactory.add(name, Object.assign({}, type[prop].opts, { cache: false }), type[prop].loader, serialize) } } } diff --git a/test/loaders.js b/test/loaders.js index 28cd24f8..530ac11a 100644 --- a/test/loaders.js +++ b/test/loaders.js @@ -14,7 +14,7 @@ const dogs = [{ name: 'Buddy' }, { name: 'Max' -}] +}].map(Object.freeze) const owners = { Max: { @@ -87,7 +87,9 @@ test('loaders create batching resolvers', async (t) => { }, params: {} }]) - return queries.map(({ obj }) => owners[obj.name]) + return queries.map(({ obj }) => { + return { ...owners[obj.name] } + }) } } } @@ -142,7 +144,10 @@ test('disable cache for each loader', async (t) => { owner: { async loader (queries, { reply }) { // note that the second entry for max is NOT cached - t.same(queries, [{ + const found = queries.map((q) => { + return { obj: q.obj, params: q.params } + }) + t.same(found, [{ obj: { name: 'Max' }, @@ -163,7 +168,9 @@ test('disable cache for each loader', async (t) => { }, params: {} }]) - return queries.map(({ obj }) => owners[obj.name]) + return queries.map(({ obj }) => { + return { ...owners[obj.name] } + }) }, opts: { cache: false @@ -220,7 +227,9 @@ test('defineLoaders method, if factory exists', async (t) => { const loaders = { Dog: { async owner (queries, { reply }) { - return queries.map(({ obj }) => owners[obj.name]) + return queries.map(({ obj }) => { + return { ...owners[obj.name] } + }) } } } @@ -285,7 +294,9 @@ test('support context in loader', async (t) => { Dog: { async owner (queries, context) { t.equal(context.app, app) - return queries.map(({ obj }) => owners[obj.name]) + return queries.map(({ obj }) => { + return { ...owners[obj.name] } + }) } } } @@ -440,7 +451,9 @@ test('reply is empty, throw error', async (t) => { const loaders = { Dog: { async owner (queries) { - return queries.map(({ obj }) => owners[obj.name]) + return queries.map(({ obj }) => { + return { ...owners[obj.name] } + }) } } } @@ -497,7 +510,9 @@ test('loaders support custom context', async (t) => { }, params: {} }]) - return queries.map(({ obj }) => owners[obj.name]) + return queries.map(({ obj }) => { + return { ...owners[obj.name] } + }) } } } @@ -565,7 +580,7 @@ test('subscriptions properly execute loaders', t => { }, loaders: { Dog: { - owner: async () => [owners[dogs[0].name]] + owner: async () => [{ ...owners[dogs[0].name] }] } }, subscription: { @@ -608,10 +623,10 @@ test('subscriptions properly execute loaders', t => { if (data.type === 'connection_ack') { app.graphql.pubsub.publish({ topic: 'PINGED_DOG', - payload: { onPingDog: dogs[0] } + payload: { onPingDog: { ...dogs[0] } } }) } else if (data.id === 1) { - const expectedDog = dogs[0] + const expectedDog = { ...dogs[0] } expectedDog.owner = owners[dogs[0].name] t.same(data.payload.data.onPingDog, expectedDog) @@ -720,47 +735,57 @@ test('Pass info to loader if cache is disabled', async (t) => { const loaders = { Dog: { - async owner (queries, context) { - t.equal(context.app, app) - return queries.map(({ obj, info }) => { - // verify info properties - t.equal(info.operation.operation, 'query') - - const resolverOutputParams = info.operation.selectionSet.selections[0].selectionSet.selections - t.equal(resolverOutputParams.length, 3) - t.equal(resolverOutputParams[0].name.value, 'dogName') - t.equal(resolverOutputParams[1].name.value, 'age') - t.equal(resolverOutputParams[2].name.value, 'owner') - - const loaderOutputParams = resolverOutputParams[2].selectionSet.selections - - t.equal(loaderOutputParams.length, 2) - t.equal(loaderOutputParams[0].name.value, 'nickName') - t.equal(loaderOutputParams[1].name.value, 'age') - - return owners[obj.dogName] - }) + owner: { + async loader (queries, context) { + t.equal(context.app, app) + return queries.map(({ obj, info }) => { + // verify info properties + t.equal(info.operation.operation, 'query') + + const resolverOutputParams = info.operation.selectionSet.selections[0].selectionSet.selections + t.equal(resolverOutputParams.length, 3) + t.equal(resolverOutputParams[0].name.value, 'dogName') + t.equal(resolverOutputParams[1].name.value, 'age') + t.equal(resolverOutputParams[2].name.value, 'owner') + + const loaderOutputParams = resolverOutputParams[2].selectionSet.selections + + t.equal(loaderOutputParams.length, 2) + t.equal(loaderOutputParams[0].name.value, 'nickName') + t.equal(loaderOutputParams[1].name.value, 'age') + + return { ...owners[obj.dogName] } + }) + }, + opts: { + cache: false + } } }, Cat: { - async owner (queries, context) { - t.equal(context.app, app) - return queries.map(({ obj, info }) => { - // verify info properties - t.equal(info.operation.operation, 'query') + owner: { + async loader (queries, context) { + t.equal(context.app, app) + return queries.map(({ obj, info }) => { + // verify info properties + t.equal(info.operation.operation, 'query') - const resolverOutputParams = info.operation.selectionSet.selections[1].selectionSet.selections - t.equal(resolverOutputParams.length, 2) - t.equal(resolverOutputParams[0].name.value, 'catName') - t.equal(resolverOutputParams[1].name.value, 'owner') + const resolverOutputParams = info.operation.selectionSet.selections[1].selectionSet.selections + t.equal(resolverOutputParams.length, 2) + t.equal(resolverOutputParams[0].name.value, 'catName') + t.equal(resolverOutputParams[1].name.value, 'owner') - const loaderOutputParams = resolverOutputParams[1].selectionSet.selections + const loaderOutputParams = resolverOutputParams[1].selectionSet.selections - t.equal(loaderOutputParams.length, 1) - t.equal(loaderOutputParams[0].name.value, 'age') + t.equal(loaderOutputParams.length, 1) + t.equal(loaderOutputParams[0].name.value, 'age') - return owners[obj.catName] - }) + return { ...owners[obj.catName] } + }) + }, + opts: { + cache: false + } } } } @@ -768,8 +793,7 @@ test('Pass info to loader if cache is disabled', async (t) => { app.register(GQL, { schema, resolvers, - loaders, - cache: false + loaders }) await app.ready() @@ -858,7 +882,9 @@ test('should not pass info to loader if cache is enabled', async (t) => { Dog: { async owner (queries) { t.equal(queries[0].info, undefined) - return queries.map(({ obj }) => owners[obj.name]) + return queries.map(({ obj }) => { + return { ...owners[obj.name] } + }) } } } @@ -908,3 +934,84 @@ test('should not pass info to loader if cache is enabled', async (t) => { } }) }) + +test('loaders create batching resolvers', { only: true }, async (t) => { + const app = Fastify() + + const loaders = { + Dog: { + async owner (queries, { reply }) { + // note that the second entry for max is cached + const found = queries.map((q) => { + return { obj: q.obj, params: q.params } + }) + t.same(found, [{ + obj: { + name: 'Max' + }, + params: {} + }, { + obj: { + name: 'Charlie' + }, + params: {} + }, { + obj: { + name: 'Buddy' + }, + params: {} + }, { + obj: { + name: 'Max' + }, + params: {} + }]) + return queries.map(({ obj }) => { + return { ...owners[obj.name] } + }) + } + } + } + + app.register(GQL, { + schema, + resolvers, + loaders, + cache: false + }) + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + body: { + query + } + }) + + t.equal(res.statusCode, 200) + t.same(JSON.parse(res.body), { + data: { + dogs: [{ + name: 'Max', + owner: { + name: 'Jennifer' + } + }, { + name: 'Charlie', + owner: { + name: 'Sarah' + } + }, { + name: 'Buddy', + owner: { + name: 'Tracy' + } + }, { + name: 'Max', + owner: { + name: 'Jennifer' + } + }] + } + }) +}) From 0495ac9a429fd31e8bfd2d4460a4ee572790d4c2 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 29 Nov 2021 14:09:51 +0100 Subject: [PATCH 42/55] Removed tap warning --- test/batched.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/batched.js b/test/batched.js index 2a1d47b1..949ff48b 100644 --- a/test/batched.js +++ b/test/batched.js @@ -134,7 +134,7 @@ test('POST single bad batched query with cutom error formatter and custom async return { topic: 'NOTIFICATIONS_ADDED' } }, errorFormatter: (_execution, context) => { - t.include(context, { topic: 'NOTIFICATIONS_ADDED' }) + t.has(context, { topic: 'NOTIFICATIONS_ADDED' }) return { response: { data: null, From fd6b64df10a0826907fc8197315ab39edca5c324 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 29 Nov 2021 14:11:27 +0100 Subject: [PATCH 43/55] Bumped v8.11.0 Signed-off-by: Matteo Collina --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8aae187c..e7f357e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mercurius", - "version": "8.10.0", + "version": "8.11.0", "description": "Fastify GraphQL adapter with gateway and subscription support", "main": "index.js", "types": "index.d.ts", From 9fa9c974bc74cef87de74fdc22c5bece6c81d352 Mon Sep 17 00:00:00 2001 From: MoritzLoewenstein <32882329+MoritzLoewenstein@users.noreply.github.com> Date: Tue, 30 Nov 2021 13:05:06 +0100 Subject: [PATCH 44/55] fix: typo in fetchBatchedResult (#672) --- lib/gateway/get-query-result.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/gateway/get-query-result.js b/lib/gateway/get-query-result.js index 5e0a85c3..32aa4b88 100644 --- a/lib/gateway/get-query-result.js +++ b/lib/gateway/get-query-result.js @@ -44,7 +44,7 @@ function groupQueriesByDefinition (queries) { * @param {Object} params.service The service that will receive one request with the batched queries * @returns {Array} result */ -async function fetchBactchedResult ({ mergeQueriesResult, context, serviceDefinition, service }) { +async function fetchBatchedResult ({ mergeQueriesResult, context, serviceDefinition, service }) { const { mergedQueries, resultIndexes } = mergeQueriesResult const batchedQueries = [] @@ -168,7 +168,7 @@ async function getQueryResult ({ context, queries, serviceDefinition, service }) } if (serviceDefinition.allowBatchedQueries) { - return fetchBactchedResult({ ...params }) + return fetchBatchedResult({ ...params }) } return fetchResult({ ...params }) From 15710f9acd2cd20ae2499972d296b68ce6a7b7de Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Nov 2021 16:18:21 +0000 Subject: [PATCH 45/55] build(deps): bump fastify/github-action-merge-dependabot (#673) Bumps [fastify/github-action-merge-dependabot](https://github.com/fastify/github-action-merge-dependabot) from 2.6.0 to 2.7.0. - [Release notes](https://github.com/fastify/github-action-merge-dependabot/releases) - [Commits](https://github.com/fastify/github-action-merge-dependabot/compare/v2.6.0...v2.7.0) --- updated-dependencies: - dependency-name: fastify/github-action-merge-dependabot dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39272129..0f38c4c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,6 @@ jobs: needs: test runs-on: ubuntu-latest steps: - - uses: fastify/github-action-merge-dependabot@v2.6.0 + - uses: fastify/github-action-merge-dependabot@v2.7.0 with: github-token: ${{secrets.GITHUB_TOKEN}} From 6231b316175c3d8354e62df0ce65aad8d7745261 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 1 Dec 2021 09:22:56 +0100 Subject: [PATCH 46/55] Add safe-stable-stringify to make yarn an pnpm happy. Closes https://github.com/mercurius-js/mercurius/issues/674 --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index e7f357e8..b0c4b384 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "p-map": "^4.0.0", "promise.allsettled": "^1.0.4", "readable-stream": "^3.6.0", + "safe-stable-stringify": "^2.3.0", "secure-json-parse": "^2.4.0", "single-user-cache": "^0.6.0", "tiny-lru": "^7.0.6", From fe98cbf56e74c84eeae1296ffac2879a9aa5d562 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 1 Dec 2021 09:24:30 +0100 Subject: [PATCH 47/55] Bumped v8.11.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b0c4b384..feb22777 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mercurius", - "version": "8.11.0", + "version": "8.11.1", "description": "Fastify GraphQL adapter with gateway and subscription support", "main": "index.js", "types": "index.d.ts", From a85dd558660d14b4537903ef4e6a985a545f04ca Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 1 Dec 2021 17:46:36 +0100 Subject: [PATCH 48/55] Improve error handler (#675) * Improve error handling * Make sure errors are always logged by the default error handler --- lib/errors.js | 6 ++++-- lib/routes.js | 6 ++++-- test/routes.js | 19 +++++++++++++++++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/lib/errors.js b/lib/errors.js index e82c6339..58d934fd 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -68,16 +68,18 @@ function defaultErrorFormatter (err, ctx) { if (err.errors) { errors = err.errors.map((error, idx) => { - log.error({ err: error }, error.message) + log.info({ err: error }, error.message) // parses, converts & combines errors if they are the result of a federated request - if (error.message === FEDERATED_ERROR.toString()) { + if (error.message === FEDERATED_ERROR.toString() && error.extensions) { return error.extensions.errors.map(err => formatError(toGraphQLError(err))) } return error instanceof GraphQLError ? formatError(error) : { message: error.message } // as the result of the outer map could potentially contain arrays with federated errors // the result needs to be flattened }).reduce((acc, val) => acc.concat(val), []) + } else { + log.info({ err }, err.message) } return { diff --git a/lib/routes.js b/lib/routes.js index 7921f86c..1088d2fc 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -124,8 +124,10 @@ function tryJSONParse (request, value) { try { return sJSON.parse(value) } catch (err) { - request.log.info({ err: err }) - throw new MER_ERR_GQL_VALIDATION(err.message) + const wrap = new MER_ERR_GQL_VALIDATION() + err.code = wrap.code + err.statusCode = wrap.statusCode + throw err } } diff --git a/test/routes.js b/test/routes.js index 7c6c01a5..c32977ce 100644 --- a/test/routes.js +++ b/test/routes.js @@ -2,6 +2,7 @@ const { test } = require('tap') const Fastify = require('fastify') +const split = require('split2') const querystring = require('querystring') const WebSocket = require('ws') const { GraphQLError } = require('graphql') @@ -319,8 +320,14 @@ test('GET route with extensions', async (t) => { }) }) -test('GET route with bad JSON extensions', async (t) => { - const app = Fastify() +test('GET route with bad JSON extensions', { only: true }, async (t) => { + t.plan(3) + const lines = split(JSON.parse) + const app = Fastify({ + logger: { + stream: lines + } + }) const schema = ` type Query { add(x: Int, y: Int): Int @@ -345,6 +352,14 @@ test('GET route with bad JSON extensions', async (t) => { }) t.equal(res.statusCode, 400) + + for await (const line of lines) { + if (line.err) { + t.equal(line.err.message, 'Unexpected token o in JSON at position 1') + t.equal(line.err.code, 'MER_ERR_GQL_VALIDATION') + break + } + } }) test('POST route variables', async (t) => { From d34a103fd9fe3df941acdd6072c6a602d235fcbb Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 1 Dec 2021 19:34:54 +0100 Subject: [PATCH 49/55] Fix regression in handling badly formed JSON (#678) --- lib/routes.js | 17 ++++++++-- test/errors.js | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/lib/routes.js b/lib/routes.js index 1088d2fc..f3057979 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -135,14 +135,27 @@ module.exports = async function (app, opts) { const errorFormatter = typeof opts.errorFormatter === 'function' ? opts.errorFormatter : defaultErrorFormatter if (typeof opts.errorHandler === 'function') { - app.setErrorHandler(opts.errorHandler) + app.setErrorHandler((error, request, reply) => { + const errorHandler = opts.errorHandler + if (!request[kRequestContext]) { + // Generate the context for this request + request[kRequestContext] = { reply, app } + } + + return errorHandler(error, request, reply) + }) } else if (opts.errorHandler === true || opts.errorHandler === undefined) { app.setErrorHandler((error, request, reply) => { + if (!request[kRequestContext]) { + // Generate the context for this request + request[kRequestContext] = { reply, app } + } + const { statusCode, response } = errorFormatter( error, request[kRequestContext] ) - reply.code(statusCode).send(response) + return reply.code(statusCode).send(response) }) } const contextFn = opts.context diff --git a/test/errors.js b/test/errors.js index d01010ae..1c1ae1b0 100644 --- a/test/errors.js +++ b/test/errors.js @@ -767,3 +767,90 @@ test('errors - should override statusCode to 200 if the data is present', async t.equal(res.statusCode, 200) }) + +test('bad json', async (t) => { + const schema = ` + type Query { + successful: String + } + ` + + const resolvers = { + Query: { + successful () { + t.fail('Should not be called') + return 'Runs OK' + } + } + } + + const app = Fastify() + + app.register(GQL, { + schema, + resolvers + }) + + await app.ready() + + const res = await app.inject({ + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: 'this is not a json', + url: '/graphql' + }) + + t.equal(res.statusCode, 400) + t.same(res.json(), + { data: null, errors: [{ message: 'Unexpected token h in JSON at position 1' }] } + ) +}) + +test('bad json with custom error handler', async (t) => { + t.plan(3) + const schema = ` + type Query { + successful: String + } + ` + + const resolvers = { + Query: { + successful () { + t.fail('Should not be called') + return 'Runs OK' + } + } + } + + const app = Fastify() + + app.register(GQL, { + schema, + resolvers, + errorHandler: (_, request, reply) => { + t.pass('custom error handler called') + reply.code(400).send({ + is: 'error' + }) + } + }) + + await app.ready() + + const res = await app.inject({ + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: 'this is not a json', + url: '/graphql' + }) + + t.equal(res.statusCode, 400) + t.same(res.json(), { + is: 'error' + }) +}) From 2bfe0bb35689b6384c69f3320295e6e628b25f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wiktor=20Obr=C4=99bski?= Date: Sat, 4 Dec 2021 12:12:54 +0100 Subject: [PATCH 50/55] Extend regression fix in handling bad json to include custom context (#679) --- lib/routes.js | 20 +++++++--- test/errors.js | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 5 deletions(-) diff --git a/lib/routes.js b/lib/routes.js index f3057979..88461306 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -133,22 +133,33 @@ function tryJSONParse (request, value) { module.exports = async function (app, opts) { const errorFormatter = typeof opts.errorFormatter === 'function' ? opts.errorFormatter : defaultErrorFormatter + const contextFn = opts.context if (typeof opts.errorHandler === 'function') { - app.setErrorHandler((error, request, reply) => { + app.setErrorHandler(async (error, request, reply) => { const errorHandler = opts.errorHandler if (!request[kRequestContext]) { // Generate the context for this request - request[kRequestContext] = { reply, app } + if (contextFn) { + request[kRequestContext] = await contextFn(request, reply) + Object.assign(request[kRequestContext], { reply, app }) + } else { + request[kRequestContext] = { reply, app } + } } return errorHandler(error, request, reply) }) } else if (opts.errorHandler === true || opts.errorHandler === undefined) { - app.setErrorHandler((error, request, reply) => { + app.setErrorHandler(async (error, request, reply) => { if (!request[kRequestContext]) { // Generate the context for this request - request[kRequestContext] = { reply, app } + if (contextFn) { + request[kRequestContext] = await contextFn(request, reply) + Object.assign(request[kRequestContext], { reply, app }) + } else { + request[kRequestContext] = { reply, app } + } } const { statusCode, response } = errorFormatter( @@ -158,7 +169,6 @@ module.exports = async function (app, opts) { return reply.code(statusCode).send(response) }) } - const contextFn = opts.context const { subscriptionContextFn } = opts app.decorateRequest(kRequestContext) diff --git a/test/errors.js b/test/errors.js index 1c1ae1b0..005cde67 100644 --- a/test/errors.js +++ b/test/errors.js @@ -5,6 +5,7 @@ const Fastify = require('fastify') const GQL = require('..') const { ErrorWithProps } = GQL const { FederatedError } = require('../lib/errors') +const { kRequestContext } = require('../lib/symbols') const split = require('split2') test('ErrorWithProps - support status code in the constructor', async (t) => { @@ -808,6 +809,55 @@ test('bad json', async (t) => { ) }) +test('bad json with custom error formatter and custom context', async (t) => { + const schema = ` + type Query { + successful: String + } + ` + + const resolvers = { + Query: { + successful () { + t.fail('Should not be called') + return 'Runs OK' + } + } + } + + const app = Fastify() + + app.register(GQL, { + schema, + resolvers, + context: (_request, _reply) => ({ customValue: true }), + errorFormatter: (_execution, context) => { + t.equal(context.customValue, true) + t.pass('custom error formatter called') + return { + statusCode: 400, + response: { data: null, errors: [{ message: 'Unexpected token h' }] } + } + } + }) + + await app.ready() + + const res = await app.inject({ + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: 'this is not a json', + url: '/graphql' + }) + + t.equal(res.statusCode, 400) + t.same(res.json(), + { data: null, errors: [{ message: 'Unexpected token h' }] } + ) +}) + test('bad json with custom error handler', async (t) => { t.plan(3) const schema = ` @@ -854,3 +904,52 @@ test('bad json with custom error handler', async (t) => { is: 'error' }) }) + +test('bad json with custom error handler, custom error formatter and custom context', async (t) => { + const schema = ` + type Query { + successful: String + } + ` + + const resolvers = { + Query: { + successful () { + t.fail('Should not be called') + return 'Runs OK' + } + } + } + + const app = Fastify() + + app.register(GQL, { + schema, + resolvers, + context: (_request, _reply) => ({ customValue: true }), + + errorHandler: (_, request, reply) => { + t.equal(request[kRequestContext].customValue, true) + t.pass('custom error handler called') + reply.code(400).send({ + is: 'error' + }) + } + }) + + await app.ready() + + const res = await app.inject({ + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: 'this is not a json', + url: '/graphql' + }) + + t.equal(res.statusCode, 400) + t.same(res.json(), { + is: 'error' + }) +}) From 151028b0a9fa70b2edc7c6a713a8cbc3b65d9fc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wiktor=20Obr=C4=99bski?= Date: Tue, 7 Dec 2021 16:43:02 +0100 Subject: [PATCH 51/55] Clarify `preValidation` documentation (#680) --- docs/hooks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/hooks.md b/docs/hooks.md index f92c0971..66039a7e 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -49,7 +49,7 @@ fastify.graphql.addHook('preParsing', async (schema, source, context) => { ### preValidation -By the time the `preValidation` hook triggers, the query string has been parsed into a GraphQL Document AST. +By the time the `preValidation` hook triggers, the query string has been parsed into a GraphQL Document AST. The hook will not be triggered for cached queries, as they are not validated. ```js fastify.graphql.addHook('preValidation', async (schema, document, context) => { From e5aed89ba944e06fffa727c4e2777cf34358f28a Mon Sep 17 00:00:00 2001 From: Sameer Srivastava Date: Wed, 8 Dec 2021 02:25:15 +0530 Subject: [PATCH 52/55] Added graphql-jit compiler options (#676) --- docs/api/options.md | 2 ++ index.js | 2 +- package.json | 1 + test/cache.js | 6 +++- test/options.js | 86 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 test/options.js diff --git a/docs/api/options.md b/docs/api/options.md index 90a8e961..abcfc33b 100644 --- a/docs/api/options.md +++ b/docs/api/options.md @@ -97,6 +97,8 @@ - `notSupportedError?: string`: An error message to return when a query matches `isPersistedQuery`, but returns no valid hash from `getHash`. Defaults to `Bad Request`. - `allowBatchedQueries`: Boolean. Flag to control whether to allow batched queries. When `true`, the server supports recieving an array of queries and returns an array of results. +- `compilerOptions`: Object. Configurable options for the graphql-jit compiler. For more details check https://github.com/zalando-incubator/graphql-jit + #### queryDepth example ``` diff --git a/index.js b/index.js index 74f3f0ec..5d0f0bea 100644 --- a/index.js +++ b/index.js @@ -554,7 +554,7 @@ const plugin = fp(async function (app, opts) { if (shouldCompileJit) { if (!modifiedSchema && !modifiedDocument) { // can compile only when the schema and document are not modified - cached.jit = compileQuery(fastifyGraphQl.schema, document, operationName) + cached.jit = compileQuery(fastifyGraphQl.schema, document, operationName, opts.compilerOptions) } else { // the counter must decrease to ignore the query cached && cached.count-- diff --git a/package.json b/package.json index feb22777..650e20e2 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "graphql-tools": "^8.0.0", "pre-commit": "^1.2.2", "proxyquire": "^2.1.3", + "sinon": "^12.0.1", "snazzy": "^9.0.0", "split2": "^4.0.0", "standard": "^16.0.3", diff --git a/test/cache.js b/test/cache.js index 109d82d0..c5c28ab1 100644 --- a/test/cache.js +++ b/test/cache.js @@ -34,7 +34,11 @@ test('cache skipped when the GQL Schema has been changed', async t => { const app = Fastify() t.teardown(() => app.close()) - await app.register(GQL, { schema, resolvers, jit: 1 }) + await app.register(GQL, { + schema, + resolvers, + jit: 1 + }) app.graphql.addHook('preExecution', async (schema, document, context) => { if (context.reply.request.headers.super === 'true') { diff --git a/test/options.js b/test/options.js new file mode 100644 index 00000000..bf294f60 --- /dev/null +++ b/test/options.js @@ -0,0 +1,86 @@ +'use strict' + +const proxyquire = require('proxyquire') +const sinon = require('sinon') +const { test } = require('tap') +const Fastify = require('fastify') + +const schema = ` +type User { + name: String! + password: String! +} + +type Query { + read: [User] +} +` + +const resolvers = { + Query: { + read: async (_, obj) => { + return [ + { + name: 'foo', + password: 'bar' + } + ] + } + } +} + +test('call compileQuery with correct options if compilerOptions specified', async t => { + t.plan(1) + + const app = Fastify() + t.teardown(() => app.close()) + + const compileQueryStub = sinon.stub() + + const GQL = proxyquire('../index', { + 'graphql-jit': { + compileQuery: compileQueryStub + } + }) + + await app.register(GQL, { + schema, + resolvers, + jit: 1, + compilerOptions: { + customJSONSerializer: true + } + }) + + const queryStub = sinon.stub() + + compileQueryStub.returns({ + query: queryStub + }) + + queryStub.resolves({ errors: [] }) + + const query = `{ + read { + name + password + } + }` + + // warm up the jit counter + await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json', super: 'false' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json', super: 'false' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.ok(compileQueryStub.calledOnceWith(sinon.match.any, sinon.match.any, sinon.match.any, { customJSONSerializer: true })) +}) From 71479c4a747449b54a5f6d2134f629eaa4170d68 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Dec 2021 16:19:26 +0000 Subject: [PATCH 53/55] build(deps): bump fastify/github-action-merge-dependabot (#682) Bumps [fastify/github-action-merge-dependabot](https://github.com/fastify/github-action-merge-dependabot) from 2.7.0 to 2.7.1. - [Release notes](https://github.com/fastify/github-action-merge-dependabot/releases) - [Commits](https://github.com/fastify/github-action-merge-dependabot/compare/v2.7.0...v2.7.1) --- updated-dependencies: - dependency-name: fastify/github-action-merge-dependabot dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f38c4c5..46c850bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,6 @@ jobs: needs: test runs-on: ubuntu-latest steps: - - uses: fastify/github-action-merge-dependabot@v2.7.0 + - uses: fastify/github-action-merge-dependabot@v2.7.1 with: github-token: ${{secrets.GITHUB_TOKEN}} From ad8d35a346ed3c793f87c1ee68d8ed4392e64e85 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 13 Dec 2021 15:20:31 +0100 Subject: [PATCH 54/55] Bumped v8.12.0 Signed-off-by: Matteo Collina --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 650e20e2..0511e20c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mercurius", - "version": "8.11.1", + "version": "8.12.0", "description": "Fastify GraphQL adapter with gateway and subscription support", "main": "index.js", "types": "index.d.ts", From 86da40836fd12defa98315a65b8cb9a3c2f951d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Dec 2021 16:21:17 +0000 Subject: [PATCH 55/55] build(deps-dev): bump @types/node from 16.11.14 to 17.0.0 (#687) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 16.11.14 to 17.0.0. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0511e20c..1a661a1d 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@graphql-tools/schema": "^8.0.0", "@graphql-tools/utils": "^8.0.0", "@sinonjs/fake-timers": "^8.0.1", - "@types/node": "^16.0.0", + "@types/node": "^17.0.0", "@types/ws": "^8.2.0", "@typescript-eslint/eslint-plugin": "^4.28.2", "@typescript-eslint/parser": "^4.28.2",