├── CNAME ├── .npmrc ├── _config.yml ├── .github ├── release-drafter.yml ├── tests_checker.yml ├── dependabot.yml └── workflows │ ├── backport.yml │ └── ci.yml ├── static ├── img │ └── favicon.ico ├── graphiql.html ├── sw.js └── main.js ├── eslint.config.js ├── .snapshots ├── 7080ab01f2741cc67112115ad4d1585a │ ├── 6.json │ ├── 3.json │ ├── 2.json │ ├── 5.json │ ├── 4.json │ ├── 0.json │ └── 1.json └── 0151da0d2cea3f89ecc602d082b249e6 │ ├── 0.json │ ├── 2.json │ ├── 1.json │ └── 3.json ├── examples ├── graphiql-plugin │ ├── plugin-sources │ │ ├── src │ │ │ ├── index.js │ │ │ ├── utils.js │ │ │ ├── sampleDataManager.js │ │ │ ├── useSampleData.js │ │ │ └── plugin.jsx │ │ ├── .gitignore │ │ ├── package.json │ │ └── rollup.config.js │ ├── index.js │ ├── plugin │ │ └── samplePlugin.js │ └── README.md ├── persisted-queries │ ├── queries.json │ └── index.js ├── basic.js ├── playground.js ├── executable-schema.js ├── disable-introspection.js ├── custom-http-behaviour.js ├── loaders.js ├── hooks.js ├── subscription │ ├── memory.js │ └── mqemitter-mongodb-subscription.js ├── full-ws-transport.js ├── schema-by-http-header.js ├── custom-directive.js ├── hooks-subscription.js ├── gateway.js └── hooks-gateway.js ├── tsconfig.json ├── bench ├── standalone.js ├── gateway.js ├── gateway-bench.js ├── standalone-bench.js ├── gateway-service-1.js ├── standalone-setup.js └── gateway-service-2.js ├── lib ├── symbols.js ├── persistedQueryDefaults.js ├── queryDepth.js ├── subscription-protocol.js ├── subscription.js ├── subscriber.js ├── handlers.js ├── csrf.js ├── hooks.js └── errors.js ├── docs ├── development.md ├── context.md ├── integrations │ ├── README.md │ ├── nexus.md │ ├── mercurius-integration-testing.md │ ├── open-telemetry.md │ ├── type-graphql.md │ └── prisma.md ├── batched-queries.md ├── contribute.md ├── http.md ├── loaders.md ├── lifecycle.md ├── graphql-over-websocket.md ├── faq.md ├── typescript.md ├── plugins.md └── persisted-queries.md ├── test ├── plugin-definition.test.js ├── subscription-protocol.test.js ├── fix-790.test.js ├── disable-instrospection.test.js ├── hooks-with-batching.test.js ├── aliases.test.js ├── options.test.js ├── graphql-option-override.test.js ├── subscriber.test.js ├── cache.test.js ├── reply-decorator.test.js ├── validation-rules.test.js └── custom-root-types.test.js ├── bench.sh ├── LICENSE ├── .gitignore ├── docsify └── sidebar.md ├── package.json ├── SECURITY.md └── README.md /CNAME: -------------------------------------------------------------------------------- 1 | mercurius.dev -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | template: | 2 | ## What’s Changed 3 | 4 | $CHANGES 5 | -------------------------------------------------------------------------------- /static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mercurius-js/mercurius/HEAD/static/img/favicon.ico -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = require('neostandard')({ 4 | ts: true, 5 | }) 6 | -------------------------------------------------------------------------------- /.snapshots/7080ab01f2741cc67112115ad4d1585a/6.json: -------------------------------------------------------------------------------- 1 | "window.GRAPHQL_ENDPOINT = '/app/graphql';\nwindow.GRAPHIQL_PLUGIN_LIST = []" -------------------------------------------------------------------------------- /examples/graphiql-plugin/plugin-sources/src/index.js: -------------------------------------------------------------------------------- 1 | export { graphiqlSamplePlugin, umdPlugin } from './plugin' 2 | export { parseFetchResponse } from './utils' 3 | -------------------------------------------------------------------------------- /.snapshots/7080ab01f2741cc67112115ad4d1585a/3.json: -------------------------------------------------------------------------------- 1 | "{\n \"data\": null,\n \"errors\": [\n {\n \"message\": \"Operation cannot be performed via a GET request\"\n }\n ]\n}" -------------------------------------------------------------------------------- /examples/graphiql-plugin/plugin-sources/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /dist 6 | -------------------------------------------------------------------------------- /.github/tests_checker.yml: -------------------------------------------------------------------------------- 1 | comment: 'Could you please add tests to make sure this change works as expected?', 2 | fileExtensions: ['.php', '.ts', '.js', '.c', '.cs', '.cpp', '.rb', '.java'] 3 | testDir: 'test' 4 | -------------------------------------------------------------------------------- /.snapshots/7080ab01f2741cc67112115ad4d1585a/2.json: -------------------------------------------------------------------------------- 1 | "{\n \"data\": null,\n \"errors\": [\n {\n \"message\": \"Syntax Error: Expected Name, found .\",\n \"locations\": [\n {\n \"line\": 1,\n \"column\": 18\n }\n ]\n }\n ]\n}" -------------------------------------------------------------------------------- /.snapshots/7080ab01f2741cc67112115ad4d1585a/5.json: -------------------------------------------------------------------------------- 1 | "{\n \"data\": null,\n \"errors\": [\n {\n \"message\": \"Simple error\",\n \"locations\": [\n {\n \"line\": 3,\n \"column\": 7\n }\n ],\n \"path\": [\n \"hello\"\n ]\n }\n ]\n}" -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "noEmit": true, 6 | "strict": true, 7 | "esModuleInterop": true 8 | }, 9 | "exclude": ["node_modules"], 10 | "files": [ 11 | "./test/types/index.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.snapshots/0151da0d2cea3f89ecc602d082b249e6/0.json: -------------------------------------------------------------------------------- 1 | "{\n \"data\": {\n \"bad\": null\n },\n \"errors\": [\n {\n \"message\": \"Bad Resolver\",\n \"locations\": [\n {\n \"line\": 3,\n \"column\": 15\n }\n ],\n \"path\": [\n \"bad\"\n ]\n }\n ]\n}" -------------------------------------------------------------------------------- /.snapshots/0151da0d2cea3f89ecc602d082b249e6/2.json: -------------------------------------------------------------------------------- 1 | "{\n \"errors\": [\n {\n \"message\": \"Bad Resolver\",\n \"locations\": [\n {\n \"line\": 3,\n \"column\": 9\n }\n ],\n \"path\": [\n \"bad\"\n ]\n }\n ],\n \"data\": {\n \"bad\": null\n }\n}" -------------------------------------------------------------------------------- /.snapshots/7080ab01f2741cc67112115ad4d1585a/4.json: -------------------------------------------------------------------------------- 1 | "{\n \"data\": {\n \"hello\": null\n },\n \"errors\": [\n {\n \"message\": \"Simple error\",\n \"locations\": [\n {\n \"line\": 3,\n \"column\": 7\n }\n ],\n \"path\": [\n \"hello\"\n ]\n }\n ]\n}" -------------------------------------------------------------------------------- /.snapshots/7080ab01f2741cc67112115ad4d1585a/0.json: -------------------------------------------------------------------------------- 1 | "{\n \"data\": {\n \"add\": null\n },\n \"errors\": [\n {\n \"message\": \"this is a dummy error\",\n \"locations\": [\n {\n \"line\": 1,\n \"column\": 2\n }\n ],\n \"path\": [\n \"add\"\n ]\n }\n ]\n}" -------------------------------------------------------------------------------- /.snapshots/7080ab01f2741cc67112115ad4d1585a/1.json: -------------------------------------------------------------------------------- 1 | "{\n \"data\": {\n \"add\": null\n },\n \"errors\": [\n {\n \"message\": \"this is a dummy error\",\n \"locations\": [\n {\n \"line\": 1,\n \"column\": 2\n }\n ],\n \"path\": [\n \"add\"\n ]\n }\n ]\n}" -------------------------------------------------------------------------------- /.snapshots/0151da0d2cea3f89ecc602d082b249e6/1.json: -------------------------------------------------------------------------------- 1 | "{\n \"data\": {\n \"bad\": null\n },\n \"errors\": [\n {\n \"message\": \"Int cannot represent non-integer value: [function bad]\",\n \"locations\": [\n {\n \"line\": 3,\n \"column\": 15\n }\n ],\n \"path\": [\n \"bad\"\n ]\n }\n ]\n}" -------------------------------------------------------------------------------- /.snapshots/0151da0d2cea3f89ecc602d082b249e6/3.json: -------------------------------------------------------------------------------- 1 | "{\n \"data\": {\n \"bad\": null\n },\n \"errors\": [\n {\n \"message\": \"Int cannot represent non-integer value: [function bad]\",\n \"locations\": [\n {\n \"line\": 3,\n \"column\": 9\n }\n ],\n \"path\": [\n \"bad\"\n ]\n }\n ]\n}" -------------------------------------------------------------------------------- /examples/graphiql-plugin/plugin-sources/src/utils.js: -------------------------------------------------------------------------------- 1 | import sampleDataManager from './sampleDataManager' 2 | 3 | /** 4 | * Intercept and store the data fetched by GQL in the DataManager. 5 | */ 6 | export function parseFetchResponse (data) { 7 | if (data.data) { 8 | sampleDataManager.setSampleData(data.data) 9 | } 10 | return data 11 | } 12 | -------------------------------------------------------------------------------- /bench/standalone.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('..') 5 | const { schema, resolvers } = require('./standalone-setup') 6 | 7 | const app = Fastify() 8 | 9 | app.register(mercurius, { 10 | schema, 11 | resolvers, 12 | graphiql: false, 13 | jit: 1 14 | }) 15 | 16 | app.listen({ port: 3000 }) 17 | -------------------------------------------------------------------------------- /examples/persisted-queries/queries.json: -------------------------------------------------------------------------------- 1 | { 2 | "248eb276edb4f22aced0a2848c539810b55f79d89abc531b91145e76838f5602": "{ add(x: 1, y: 1) }", 3 | "495ccd73abc8436544cfeedd65f24beee660d2c7be2c32536e3fbf911f935ddf": "query Add($x: Int!, $y: Int!) { add(x: $x, y: $y) }", 4 | "03ec1635d1a0ea530672bf33f28f3533239a5a7021567840c541c31d5e28c65e": "{ add(x: 3, y: 3) }" 5 | } 6 | -------------------------------------------------------------------------------- /lib/symbols.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const keys = { 4 | kLoaders: Symbol('mercurius.loaders'), 5 | kFactory: Symbol('mercurius.loadersFactory'), 6 | kSubscriptionFactory: Symbol('mercurius.subscriptionLoadersFactory'), 7 | kHooks: Symbol('mercurius.hooks'), 8 | kRequestContext: Symbol('mercurius.requestContext') 9 | } 10 | 11 | module.exports = keys 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | open-pull-requests-limit: 10 14 | ignore: 15 | - dependency-name: "undici" 16 | -------------------------------------------------------------------------------- /.github/workflows/backport.yml: -------------------------------------------------------------------------------- 1 | name: Backport 2 | on: 3 | pull_request_target: 4 | types: 5 | - closed 6 | - labeled 7 | 8 | jobs: 9 | backport: 10 | runs-on: ubuntu-latest 11 | name: Backport 12 | if: github.event.pull_request.merged 13 | steps: 14 | - name: Backport 15 | uses: tibdex/backport@v2 16 | with: 17 | github_token: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /bench/gateway.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('..') 5 | 6 | const app = Fastify() 7 | 8 | app.register(mercurius, { 9 | gateway: { 10 | services: [{ 11 | name: 'user', 12 | url: 'http://localhost:3001/graphql' 13 | }, { 14 | name: 'post', 15 | url: 'http://localhost:3002/graphql' 16 | }] 17 | }, 18 | graphiql: false, 19 | jit: 1 20 | }) 21 | 22 | app.listen({ port: 3000 }) 23 | -------------------------------------------------------------------------------- /examples/graphiql-plugin/plugin-sources/src/sampleDataManager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A data manager to collect the data. 3 | */ 4 | class SampleDataManager extends EventTarget { 5 | constructor () { 6 | super() 7 | this.sampleData = [] 8 | } 9 | 10 | getSampleData () { 11 | return this.sampleData 12 | } 13 | 14 | setSampleData (sampleData) { 15 | this.sampleData = sampleData || [] 16 | this.dispatchEvent(new Event('updateSampleData')) 17 | } 18 | } 19 | 20 | const sampleDataManager = new SampleDataManager() 21 | 22 | export default sampleDataManager 23 | -------------------------------------------------------------------------------- /static/graphiql.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | GraphiQL 6 | 7 | 8 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /examples/graphiql-plugin/plugin-sources/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphiql-sample-plugin", 3 | "version": "1.0.0", 4 | "description": "This is a sample plugin for graphiql.", 5 | "dependencies": {}, 6 | "scripts": { 7 | "build": "rollup -c" 8 | }, 9 | "devDependencies": { 10 | "@rollup/plugin-commonjs": "^23.0.0", 11 | "@rollup/plugin-node-resolve": "^15.0.0", 12 | "rollup": "^2.79.1", 13 | "rollup-plugin-jsx": "^1.0.3" 14 | }, 15 | "main": "dist/cjs/index.js", 16 | "module": "dist/esm/index.js", 17 | "umd": "dist/umd/index.js", 18 | "files": [ 19 | "dist" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /examples/basic.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('..') 5 | 6 | const app = Fastify() 7 | 8 | const schema = ` 9 | type Query { 10 | add(x: Int, y: Int): Int 11 | } 12 | ` 13 | 14 | const resolvers = { 15 | Query: { 16 | add: async (_, obj) => { 17 | const { x, y } = obj 18 | return x + y 19 | } 20 | } 21 | } 22 | 23 | app.register(mercurius, { 24 | schema, 25 | resolvers, 26 | graphiql: true 27 | }) 28 | 29 | app.get('/', async function (req, reply) { 30 | const query = '{ add(x: 2, y: 2) }' 31 | return reply.graphql(query) 32 | }) 33 | 34 | app.listen({ port: 3000 }) 35 | -------------------------------------------------------------------------------- /examples/playground.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('..') 5 | 6 | const app = Fastify() 7 | 8 | const schema = ` 9 | type Query { 10 | add(x: Int, y: Int): Int 11 | } 12 | ` 13 | 14 | const resolvers = { 15 | Query: { 16 | add: async (_, obj) => { 17 | const { x, y } = obj 18 | return x + y 19 | } 20 | } 21 | } 22 | 23 | app.register(mercurius, { 24 | schema, 25 | resolvers, 26 | graphiql: true 27 | }) 28 | 29 | app.get('/', async function (req, reply) { 30 | const query = '{ add(x: 2, y: 2) }' 31 | return reply.graphql(query) 32 | }) 33 | 34 | app.listen({ port: 3000 }) 35 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | ## Backporting 4 | 5 | The Mercurius repository supports backporting PRs that also need to be applied to older versions. 6 | 7 | ### How do we do this? 8 | 9 | As soon as one opens a PR against the default branch, and the change should be backported to `v8.x`, you should add the corresponding backport label. For example, if we need to backport something to `v8.x`, we add the following label: 10 | 11 | - `backport v8.x` 12 | 13 | And you are done! If there are no conflicts, the action will open a separate PR with the backport for review. 14 | 15 | If the PR can't be automatically backported, the GitHub bot will comment the failure on the PR. 16 | -------------------------------------------------------------------------------- /examples/executable-schema.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | const { makeExecutableSchema } = require('@graphql-tools/schema') 6 | 7 | const app = Fastify() 8 | 9 | const typeDefs = ` 10 | type Query { 11 | add(x: Int, y: Int): Int 12 | } 13 | ` 14 | 15 | const resolvers = { 16 | Query: { 17 | add: async (_, { x, y }) => x + y 18 | } 19 | } 20 | 21 | app.register(mercurius, { 22 | schema: makeExecutableSchema({ typeDefs, resolvers }) 23 | }) 24 | 25 | app.get('/', async function (req, reply) { 26 | const query = '{ add(x: 2, y: 2) }' 27 | return reply.graphql(query) 28 | }) 29 | 30 | app.listen({ port: 3000 }) 31 | -------------------------------------------------------------------------------- /examples/persisted-queries/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('../..') 5 | const persistedQueries = require('./queries.json') 6 | 7 | const app = Fastify() 8 | 9 | const schema = ` 10 | type Query { 11 | add(x: Int, y: Int): Int 12 | } 13 | ` 14 | 15 | const resolvers = { 16 | Query: { 17 | add: async (_, obj) => { 18 | const { x, y } = obj 19 | return x + y 20 | } 21 | } 22 | } 23 | 24 | app.register(mercurius, { 25 | schema, 26 | resolvers, 27 | persistedQueries, 28 | onlyPersisted: true, // will nullify the effect of the option below (graphiql) 29 | graphiql: true 30 | }) 31 | 32 | app.listen({ port: 3000 }) 33 | -------------------------------------------------------------------------------- /test/plugin-definition.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const fp = require('fastify-plugin') 5 | const Fastify = require('fastify') 6 | const GQL = require('..') 7 | 8 | test('plugin name definition', async (t) => { 9 | const app = Fastify() 10 | const schema = ` 11 | type Query { 12 | add(x: Int, y: Int): Int 13 | } 14 | ` 15 | app.register(GQL, { schema }) 16 | app.register(fp(async (app, opts) => {}, { 17 | dependencies: ['mercurius'] 18 | })) 19 | 20 | try { 21 | await app.ready() 22 | t.assert.ok('Fastify app is ready and plugins loaded successfully') 23 | } catch (err) { 24 | t.assert.fail(`App failed to be ready: ${err.message}`) 25 | } 26 | }) 27 | -------------------------------------------------------------------------------- /bench.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | # from https://github.com/mercurius-js/auth/tree/main/bench 4 | 5 | echo '==============================' 6 | echo '= Normal Mode =' 7 | echo '==============================' 8 | npx concurrently --raw -k \ 9 | "node ./bench/standalone.js" \ 10 | "npx wait-on tcp:3000 && node ./bench/standalone-bench.js" 11 | 12 | echo '===============================' 13 | echo '= Gateway Mode =' 14 | echo '===============================' 15 | npx concurrently --raw -k \ 16 | "node ./bench/gateway-service-1.js" \ 17 | "node ./bench/gateway-service-2.js" \ 18 | "npx wait-on tcp:3001 tcp:3002 && node ./bench/gateway.js" \ 19 | "npx wait-on tcp:3000 && node ./bench/gateway-bench.js" 20 | -------------------------------------------------------------------------------- /examples/graphiql-plugin/plugin-sources/src/useSampleData.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import sampleDataManager from './sampleDataManager' 3 | 4 | const useSampleData = () => { 5 | const [sampleData, setSampleData] = useState( 6 | sampleDataManager.getSampleData() 7 | ) 8 | 9 | useEffect(() => { 10 | const eventListener = sampleDataManager.addEventListener( 11 | 'updateSampleData', 12 | (e, value) => { 13 | setSampleData(_ => e.target.sampleData || []) 14 | } 15 | ) 16 | return () => { 17 | sampleDataManager.removeEventListener('updateSampleData', eventListener) 18 | } 19 | }, []) 20 | 21 | return { 22 | sampleData 23 | } 24 | } 25 | 26 | export default useSampleData 27 | -------------------------------------------------------------------------------- /examples/disable-introspection.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('..') 5 | const graphql = require('graphql') 6 | 7 | const app = Fastify() 8 | 9 | const schema = ` 10 | type Query { 11 | add(x: Int, y: Int): Int 12 | } 13 | ` 14 | 15 | const resolvers = { 16 | Query: { 17 | add: async (_, obj) => { 18 | const { x, y } = obj 19 | return x + y 20 | } 21 | } 22 | } 23 | 24 | app.register(mercurius, { 25 | schema, 26 | resolvers, 27 | graphiql: true, 28 | validationRules: [graphql.NoSchemaIntrospectionCustomRule] 29 | }) 30 | 31 | app.get('/', async function (req, reply) { 32 | const query = '{ add(x: 2, y: 2) }' 33 | return reply.graphql(query) 34 | }) 35 | 36 | app.listen({ port: 3000 }) 37 | -------------------------------------------------------------------------------- /examples/custom-http-behaviour.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('..') 5 | 6 | const app = Fastify() 7 | 8 | const schema = ` 9 | type Query { 10 | add(x: Int, y: Int): Int 11 | } 12 | ` 13 | 14 | const resolvers = { 15 | Query: { 16 | add: async (_, obj) => { 17 | const { x, y } = obj 18 | return x + y 19 | } 20 | } 21 | } 22 | 23 | /** 24 | * Define error formatter so we always return 200 OK 25 | */ 26 | function errorFormatter (err, ctx) { 27 | const response = mercurius.defaultErrorFormatter(err, ctx) 28 | response.statusCode = 200 29 | return response 30 | } 31 | 32 | app.register(mercurius, { 33 | schema, 34 | resolvers, 35 | errorFormatter 36 | }) 37 | 38 | app.listen({ port: 3000 }) 39 | -------------------------------------------------------------------------------- /examples/graphiql-plugin/plugin-sources/src/plugin.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import useSampleData from './useSampleData' 3 | 4 | export function Content () { 5 | const { sampleData } = useSampleData() 6 | 7 | return ( 8 |
9 |
This is a sample plugin
10 | {sampleData &&
{JSON.stringify(sampleData, null, 2)}
} 11 |
12 | ) 13 | } 14 | 15 | export function Icon () { 16 | return

GE

17 | } 18 | 19 | export function graphiqlSamplePlugin (props) { 20 | return { 21 | title: props.title || 'GraphiQL Sample', 22 | icon: () => , 23 | content: () => { 24 | return 25 | } 26 | } 27 | } 28 | 29 | export function umdPlugin (props) { 30 | return graphiqlSamplePlugin(props) 31 | } 32 | -------------------------------------------------------------------------------- /static/sw.js: -------------------------------------------------------------------------------- 1 | /* global fetch:false caches:false self:false */ 2 | 3 | self.addEventListener('install', function (e) { 4 | e.waitUntil( 5 | caches.open('graphiql-v3.8.3').then(function (cache) { 6 | return cache.addAll([ 7 | './main.js', 8 | 'https://unpkg.com/graphiql@3.8.3/graphiql.css', 9 | 'https://unpkg.com/react@18.3.1/umd/react.production.min.js', 10 | 'https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js', 11 | 'https://unpkg.com/graphiql@3.8.3/graphiql.min.js' 12 | ]) 13 | }) 14 | ) 15 | }) 16 | 17 | self.addEventListener('fetch', function (event) { 18 | console.log('loading', event.request.url) 19 | 20 | event.respondWith( 21 | caches.match(event.request).then(function (response) { 22 | return response || fetch(event.request) 23 | }, console.log) 24 | ) 25 | }) 26 | -------------------------------------------------------------------------------- /bench/gateway-bench.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const autocannon = require('autocannon') 4 | 5 | const query = `query { 6 | me { 7 | id 8 | name 9 | nickname: name 10 | topPosts(count: 2) { 11 | pid 12 | author { 13 | id 14 | } 15 | } 16 | } 17 | topPosts(count: 2) { 18 | pid 19 | } 20 | }` 21 | 22 | const instance = autocannon( 23 | { 24 | url: 'http://localhost:3000/graphql', 25 | connections: 100, 26 | title: '', 27 | method: 'POST', 28 | headers: { 29 | 'content-type': 'application/json', 'x-user': 'admin' 30 | }, 31 | body: JSON.stringify({ query }) 32 | }, 33 | (err) => { 34 | if (err) { 35 | console.error(err) 36 | } 37 | } 38 | ) 39 | 40 | process.once('SIGINT', () => { 41 | instance.stop() 42 | }) 43 | 44 | autocannon.track(instance, { renderProgressBar: true }) 45 | -------------------------------------------------------------------------------- /bench/standalone-bench.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const autocannon = require('autocannon') 4 | 5 | const query = `query { 6 | four: add(x: 2, y: 2) 7 | six: add(x: 3, y: 3) 8 | subtract(x: 3, y: 3) 9 | messages { 10 | title 11 | public 12 | private 13 | } 14 | adminMessages { 15 | title 16 | public 17 | private 18 | } 19 | }` 20 | 21 | const instance = autocannon( 22 | { 23 | url: 'http://localhost:3000/graphql', 24 | connections: 100, 25 | title: '', 26 | method: 'POST', 27 | headers: { 28 | 'content-type': 'application/json', 'x-user': 'admin' 29 | }, 30 | body: JSON.stringify({ query }) 31 | }, 32 | (err) => { 33 | if (err) { 34 | console.error(err) 35 | } 36 | } 37 | ) 38 | 39 | process.once('SIGINT', () => { 40 | instance.stop() 41 | }) 42 | 43 | autocannon.track(instance, { renderProgressBar: true }) 44 | -------------------------------------------------------------------------------- /docs/context.md: -------------------------------------------------------------------------------- 1 | # mercurius 2 | 3 | ## Context 4 | 5 | ### Access app context in resolver 6 | 7 | ```js 8 | ... 9 | 10 | const resolvers = { 11 | Query: { 12 | add: async (_, { x, y }, context) => { 13 | // do you need the request object? 14 | console.log(context.reply.request) 15 | return x + y 16 | } 17 | } 18 | } 19 | 20 | ... 21 | ``` 22 | 23 | ### Build a custom GraphQL context object 24 | 25 | ```js 26 | ... 27 | const resolvers = { 28 | Query: { 29 | me: async (obj, args, ctx) => { 30 | // access user_id in ctx 31 | console.log(ctx.user_id) 32 | } 33 | } 34 | } 35 | app.register(mercurius, { 36 | schema: makeExecutableSchema({ typeDefs, resolvers }), 37 | context: (request, reply) => { 38 | // Return an object that will be available in your GraphQL resolvers 39 | return { 40 | user_id: 1234 41 | } 42 | } 43 | }) 44 | ... 45 | ``` 46 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI workflow 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | runs-on: ${{ matrix.os }} 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | node-version: [20, 22, 24] 10 | os: [ubuntu-latest, windows-latest, macOS-latest] 11 | steps: 12 | - uses: actions/checkout@v5 13 | - name: Use Node.js ${{ matrix.node-version }} 14 | uses: actions/setup-node@v5.0.0 15 | with: 16 | node-version: ${{ matrix.node-version }} 17 | - name: Install Dependencies 18 | run: npm install --ignore-scripts 19 | - name: Test 20 | run: npm run test 21 | automerge: 22 | needs: test 23 | runs-on: ubuntu-latest 24 | permissions: 25 | pull-requests: write 26 | contents: write 27 | steps: 28 | - uses: fastify/github-action-merge-dependabot@v3 29 | with: 30 | github-token: ${{secrets.GITHUB_TOKEN}} 31 | -------------------------------------------------------------------------------- /docs/integrations/README.md: -------------------------------------------------------------------------------- 1 | # mercurius 2 | 3 | ## Integrations 4 | 5 | - [nexus](/docs/integrations/nexus.md) - Declarative, code-first and strongly typed GraphQL schema construction for TypeScript & JavaScript 6 | - [TypeGraphQL](/docs/integrations/type-graphql.md) - Modern framework for creating GraphQL API in Node.js, using only classes and decorators 7 | - [Prisma](/docs/integrations/prisma.md) - Prisma is an open-source ORM for Node.js and TypeScript. 8 | - [mercurius-integration-testing](/docs/integrations/mercurius-integration-testing.md) - Utility library for writing mercurius integration tests. 9 | - [@opentelemetry](/docs/integrations/open-telemetry.md) - A framework for collecting traces and metrics from applications. 10 | - [NestJS](/docs/integrations/nestjs.md) - Use Typescript classes and decorators, along with a powerful modularization system and a lot of other great tools for a very good developer experience in terms of GraphQL API automation. 11 | -------------------------------------------------------------------------------- /bench/gateway-service-1.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('..') 5 | 6 | const app = Fastify() 7 | 8 | const users = { 9 | u1: { 10 | id: 'u1', 11 | name: 'John' 12 | }, 13 | u2: { 14 | id: 'u2', 15 | name: 'Jane' 16 | } 17 | } 18 | 19 | const schema = ` 20 | directive @auth( 21 | requires: Role = ADMIN, 22 | ) on OBJECT | FIELD_DEFINITION 23 | 24 | enum Role { 25 | ADMIN 26 | REVIEWER 27 | USER 28 | UNKNOWN 29 | } 30 | 31 | type Query @extends { 32 | me: User 33 | } 34 | 35 | type User @key(fields: "id") { 36 | id: ID! 37 | name: String! @auth(requires: ADMIN) 38 | }` 39 | 40 | const resolvers = { 41 | Query: { 42 | me: (root, args, context, info) => { 43 | return users.u1 44 | } 45 | }, 46 | User: { 47 | __resolveReference: (user, args, context, info) => { 48 | return users[user.id] 49 | } 50 | } 51 | } 52 | 53 | app.register(mercurius, { 54 | schema, 55 | resolvers, 56 | federationMetadata: true, 57 | graphiql: false, 58 | jit: 1 59 | }) 60 | 61 | app.listen({ port: 3001 }) 62 | -------------------------------------------------------------------------------- /examples/loaders.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('..') 5 | 6 | const app = Fastify() 7 | 8 | const dogs = [{ 9 | name: 'Max' 10 | }, { 11 | name: 'Charlie' 12 | }, { 13 | name: 'Buddy' 14 | }, { 15 | name: 'Max' 16 | }] 17 | 18 | const owners = { 19 | Max: { 20 | name: 'Jennifer' 21 | }, 22 | Charlie: { 23 | name: 'Sarah' 24 | }, 25 | Buddy: { 26 | name: 'Tracy' 27 | } 28 | } 29 | 30 | const schema = ` 31 | type Human { 32 | name: String! 33 | } 34 | 35 | type Dog { 36 | name: String! 37 | owner: Human 38 | } 39 | 40 | type Query { 41 | dogs: [Dog] 42 | } 43 | ` 44 | 45 | const resolvers = { 46 | Query: { 47 | dogs (_, params, { reply }) { 48 | return dogs 49 | } 50 | } 51 | } 52 | 53 | const loaders = { 54 | Dog: { 55 | async owner (queries, { reply }) { 56 | return queries.map(({ obj }) => owners[obj.name]) 57 | } 58 | } 59 | } 60 | 61 | app.register(mercurius, { 62 | schema, 63 | resolvers, 64 | loaders, 65 | graphiql: true 66 | }) 67 | 68 | app.listen({ port: 3000 }) 69 | -------------------------------------------------------------------------------- /docs/batched-queries.md: -------------------------------------------------------------------------------- 1 | # mercurius 2 | 3 | ## Batched Queries 4 | 5 | Batched queries, like those sent by `apollo-link-batch-http` are supported by enabling the `allowBatchedQueries` option. 6 | 7 | Instead a single query object, an array of queries is accepted, and the response is returned as an array of results. Errors are returned on a per query basis. Note that the response will not be returned until the slowest query has been executed. 8 | 9 | Request: 10 | 11 | ```js 12 | [ 13 | { 14 | operationName: "AddQuery", 15 | variables: { x: 1, y: 2 }, 16 | query: "query AddQuery ($x: Int!, $y: Int!) { add(x: $x, y: $y) }", 17 | }, 18 | { 19 | operationName: "DoubleQuery", 20 | variables: { x: 1 }, 21 | query: "query DoubleQuery ($x: Int!) { add(x: $x, y: $x) }", 22 | }, 23 | { 24 | operationName: "BadQuery", 25 | query: "query DoubleQuery ($x: Int!) {---", // Malformed Query 26 | }, 27 | ]; 28 | ``` 29 | 30 | Response: 31 | 32 | ```js 33 | [ 34 | { 35 | data: { add: 3 }, 36 | }, 37 | { 38 | data: { add: 2 }, 39 | }, 40 | { 41 | errors: [{ message: "Bad Request" }], 42 | }, 43 | ]; 44 | ``` 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2025 Matteo Collina and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/graphiql-plugin/plugin-sources/rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve' 2 | import commonjs from '@rollup/plugin-commonjs' 3 | import jsx from 'rollup-plugin-jsx' 4 | 5 | const packageJson = require('./package.json') 6 | 7 | const rollup = [ 8 | { 9 | input: 'src/index.js', 10 | output: [ 11 | { 12 | file: packageJson.main, 13 | format: 'cjs', 14 | sourcemap: true 15 | }, 16 | { 17 | file: packageJson.module, 18 | format: 'esm', 19 | sourcemap: true 20 | }, 21 | { 22 | file: packageJson.umd, 23 | format: 'umd', 24 | sourcemap: true, 25 | name: 'samplePlugin' 26 | } 27 | ], 28 | external: ['react', '@graphiql/toolkit'], 29 | plugins: [ 30 | resolve({ 31 | extensions: ['.js', '.jsx'] 32 | }), 33 | commonjs(), 34 | jsx({ factory: 'React.createElement' }) 35 | ] 36 | } 37 | // This is for typescript in the future 38 | // { 39 | // input: 'dist/esm/types/index.d.ts', 40 | // output: [{ file: 'dist/index.d.ts', format: 'esm' }], 41 | // plugins: [dts()] 42 | // } 43 | ] 44 | 45 | export default rollup 46 | -------------------------------------------------------------------------------- /examples/graphiql-plugin/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('../..') 5 | const Static = require('@fastify/static') 6 | const { join } = require('path') 7 | 8 | const app = Fastify() 9 | 10 | const schema = ` 11 | type Query { 12 | add(x: Int, y: Int): Int 13 | } 14 | ` 15 | 16 | const resolvers = { 17 | Query: { 18 | add: async (_, obj) => { 19 | const { x, y } = obj 20 | return x + y 21 | } 22 | } 23 | } 24 | 25 | app.register(mercurius, { 26 | schema, 27 | resolvers, 28 | graphiql: { 29 | plugins: [ 30 | { 31 | name: 'samplePlugin', 32 | props: {}, 33 | umdUrl: 'http://localhost:3000/graphiql/samplePlugin.js', 34 | fetcherWrapper: 'parseFetchResponse' 35 | } 36 | ] 37 | } 38 | }) 39 | 40 | app.register(Static, { 41 | root: join(__dirname, './plugin'), 42 | wildcard: false, 43 | serve: false 44 | }) 45 | 46 | app.get('/', async function (req, reply) { 47 | const query = '{ add(x: 2, y: 2) }' 48 | return reply.graphql(query) 49 | }) 50 | 51 | app.get('/graphiql/samplePlugin.js', (req, reply) => { 52 | reply.sendFile('samplePlugin.js') 53 | }) 54 | 55 | app.listen({ port: 3000 }) 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # clinic 64 | *clinic* 65 | 66 | # vscode 67 | .vscode/ 68 | 69 | # intellij idea, webstorm 70 | .idea/ 71 | 72 | # lockfiles 73 | package-lock.json 74 | yarn.lock 75 | pnpm-lock.yaml 76 | 77 | # tap 78 | .tap 79 | 80 | # Claude 81 | CLAUDE.md 82 | .claude/ 83 | -------------------------------------------------------------------------------- /examples/hooks.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('..') 5 | 6 | async function start () { 7 | const app = Fastify() 8 | 9 | const schema = ` 10 | type Query { 11 | add(x: Int, y: Int): Int 12 | } 13 | ` 14 | 15 | const resolvers = { 16 | Query: { 17 | add: async (_, obj) => { 18 | const { x, y } = obj 19 | return x + y 20 | } 21 | } 22 | } 23 | 24 | app.register(mercurius, { 25 | schema, 26 | resolvers, 27 | graphiql: true 28 | }) 29 | 30 | await app.ready() 31 | 32 | app.graphql.addHook('preParsing', async function (schema, source, context) { 33 | console.log('preParsing called') 34 | }) 35 | 36 | app.graphql.addHook('preValidation', async function (schema, document, context) { 37 | console.log('preValidation called') 38 | }) 39 | 40 | app.graphql.addHook('preExecution', async function (schema, document, context) { 41 | console.log('preExecution called') 42 | return { 43 | document, 44 | errors: [ 45 | new Error('foo') 46 | ] 47 | } 48 | }) 49 | 50 | app.graphql.addHook('onResolution', async function (execution, context) { 51 | console.log('onResolution called') 52 | }) 53 | 54 | app.get('/', async function (req, reply) { 55 | const query = '{ add(x: 2, y: 2) }' 56 | 57 | return reply.graphql(query) 58 | }) 59 | 60 | app.listen({ port: 3000 }) 61 | } 62 | 63 | start() 64 | -------------------------------------------------------------------------------- /test/subscription-protocol.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const assert = require('node:assert') 5 | const { getProtocolByName, GRAPHQL_WS_PROTOCOL_SIGNALS, GRAPHQL_TRANSPORT_WS_PROTOCOL_SIGNALS } = require('../lib/subscription-protocol') 6 | 7 | const cases = [ 8 | { 9 | defaultProtocol: undefined, 10 | protocol: undefined, 11 | expected: undefined 12 | }, 13 | { 14 | defaultProtocol: undefined, 15 | protocol: 'graphql-ws', 16 | expected: GRAPHQL_WS_PROTOCOL_SIGNALS 17 | }, 18 | { 19 | defaultProtocol: undefined, 20 | protocol: 'graphql-transport-ws', 21 | expected: GRAPHQL_TRANSPORT_WS_PROTOCOL_SIGNALS 22 | }, 23 | { 24 | defaultProtocol: 'graphql-ws', 25 | protocol: 'graphql-ws', 26 | expected: GRAPHQL_WS_PROTOCOL_SIGNALS 27 | }, 28 | { 29 | defaultProtocol: 'graphql-ws', 30 | protocol: undefined, 31 | expected: GRAPHQL_WS_PROTOCOL_SIGNALS 32 | }, 33 | { 34 | defaultProtocol: 'graphql-transport-ws', 35 | protocol: 'graphql-ws', 36 | expected: GRAPHQL_WS_PROTOCOL_SIGNALS 37 | }, 38 | { 39 | defaultProtocol: 'graphql-transport-ws', 40 | protocol: undefined, 41 | expected: GRAPHQL_TRANSPORT_WS_PROTOCOL_SIGNALS 42 | }, 43 | 44 | ] 45 | 46 | for (const { defaultProtocol, protocol, expected } of cases) { 47 | test(`getProtocolByName returns correct protocol message types for defaultProtocol: ${defaultProtocol} and protocol: ${protocol}`, t => { 48 | assert.deepStrictEqual(getProtocolByName(protocol, defaultProtocol), expected) 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /docs/contribute.md: -------------------------------------------------------------------------------- 1 | # Contribute to Mercurius 2 | 3 | Mercurius is a growing and friendly community, and would be lucky to have your contributions! 🙇‍♂️ 4 | 5 | Contributions are always welcome, we only ask you follow the Contribution Guidelines and the Code Of Conduct. 6 | 7 | If you don't know where to start you can have a look at the list of good first issues below. 8 | 9 | ## Good First Issues 10 | 11 |
12 |
13 |
14 |
15 |

Error

16 |
17 |
{{ error }}
18 |
19 |
20 |
21 |
22 |
23 | 26 |
27 |
28 |

29 | {{ issue.title }} 30 |

31 |
32 |
33 | {{ label }} 34 |
35 |
36 |
37 | {{ issue.comments }} 38 | Comments 39 |
40 |
41 |
42 |
43 |
44 |
45 | -------------------------------------------------------------------------------- /bench/standalone-setup.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const schema = ` 4 | directive @auth( 5 | requires: Role = ADMIN, 6 | ) on OBJECT | FIELD_DEFINITION 7 | 8 | enum Role { 9 | ADMIN 10 | REVIEWER 11 | USER 12 | UNKNOWN 13 | } 14 | 15 | type Message { 16 | title: String! 17 | public: String! 18 | private: String! @auth(requires: ADMIN) 19 | } 20 | 21 | type Query { 22 | add(x: Int, y: Int): Int @auth(requires: ADMIN) 23 | subtract(x: Int, y: Int): Int 24 | messages: [Message!]! 25 | adminMessages: [Message!]! @auth(requires: ADMIN) 26 | } 27 | ` 28 | 29 | const resolvers = { 30 | Query: { 31 | add: async (_, obj) => { 32 | const { x, y } = obj 33 | return x + y 34 | }, 35 | subtract: async (_, obj) => { 36 | const { x, y } = obj 37 | return x - y 38 | }, 39 | messages: async () => { 40 | return [ 41 | { 42 | title: 'one', 43 | public: 'public one', 44 | private: 'private one' 45 | }, 46 | { 47 | title: 'two', 48 | public: 'public two', 49 | private: 'private two' 50 | } 51 | ] 52 | }, 53 | adminMessages: async () => { 54 | return [ 55 | { 56 | title: 'admin one', 57 | public: 'admin public one', 58 | private: 'admin private one' 59 | }, 60 | { 61 | title: 'admin two', 62 | public: 'admin public two', 63 | private: 'admin private two' 64 | } 65 | ] 66 | } 67 | } 68 | } 69 | 70 | module.exports = { 71 | schema, 72 | resolvers 73 | } 74 | -------------------------------------------------------------------------------- /lib/persistedQueryDefaults.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const crypto = require('crypto') 4 | const LRU = require('tiny-lru').lru 5 | 6 | const persistedQueryDefaults = { 7 | prepared: (persistedQueries) => ({ 8 | isPersistedQuery: (request) => request.persisted, 9 | getHash: (request) => request.query, 10 | getQueryFromHash: async (hash) => persistedQueries[hash] 11 | }), 12 | preparedOnly: (persistedQueries) => ({ 13 | isPersistedQuery: (request) => true, 14 | getHash: (request) => request.persisted ? request.query : false, // Only support persisted queries 15 | getQueryFromHash: async (hash) => persistedQueries[hash] 16 | }), 17 | automatic: (maxSize) => { 18 | // Initialize a LRU cache in the local scope. 19 | // LRU is used to prevent DoS attacks. 20 | const cache = LRU(maxSize || 1024) 21 | return ({ 22 | isPersistedQuery: (request) => !request.query && (request.extensions || {}).persistedQuery, 23 | isPersistedQueryRetry: (request) => request.query && (request.extensions || {}).persistedQuery, 24 | getHash: (request) => { 25 | const { version, sha256Hash } = request.extensions.persistedQuery 26 | return version === 1 ? sha256Hash : false 27 | }, 28 | getQueryFromHash: async (hash) => cache.get(hash), 29 | getHashForQuery: (query) => crypto.createHash('sha256').update(query, 'utf8').digest('hex'), 30 | saveQuery: async (hash, query) => cache.set(hash, query), 31 | notFoundError: 'PersistedQueryNotFound', 32 | notSupportedError: 'PersistedQueryNotSupported', 33 | mismatchError: 'provided sha does not match query' 34 | }) 35 | } 36 | } 37 | 38 | module.exports = persistedQueryDefaults 39 | -------------------------------------------------------------------------------- /examples/subscription/memory.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const mercurius = require('../..') 4 | const Fastify = require('fastify') 5 | 6 | const app = Fastify({ logger: { level: 'debug' } }) 7 | 8 | // list of products 9 | const products = [] 10 | 11 | // graphql schema 12 | const schema = ` 13 | type Product { 14 | name: String! 15 | state: String! 16 | } 17 | 18 | type Query { 19 | products: [Product] 20 | } 21 | 22 | type Mutation { 23 | addProduct(name: String!, state: String!): Product 24 | } 25 | 26 | type Subscription { 27 | productAdded: Product 28 | } 29 | ` 30 | 31 | // graphql resolvers 32 | const resolvers = { 33 | Query: { 34 | products: () => products 35 | }, 36 | Mutation: { 37 | addProduct: async (_, { name, state }, { pubsub }) => { 38 | const product = { name, state } 39 | 40 | products.push(product) 41 | 42 | pubsub.publish({ 43 | topic: 'new_product_updates', 44 | payload: { 45 | productAdded: product 46 | } 47 | }) 48 | 49 | return product 50 | } 51 | }, 52 | Subscription: { 53 | productAdded: { 54 | subscribe: async (_, __, { pubsub }) => { 55 | return await pubsub.subscribe('new_product_updates') 56 | } 57 | } 58 | } 59 | } 60 | 61 | // server start 62 | const start = async () => { 63 | try { 64 | // register GraphQl 65 | app.register(mercurius, { 66 | schema, 67 | resolvers, 68 | graphiql: true, 69 | subscription: { 70 | async onConnect ({ payload }) { 71 | app.log.info({ payload }, 'connection_init data') 72 | return true 73 | } 74 | } 75 | }) 76 | 77 | // start server 78 | await app.listen({ port: 3000 }) 79 | } catch (error) { 80 | app.log.error(error) 81 | } 82 | } 83 | 84 | start() 85 | -------------------------------------------------------------------------------- /docs/integrations/nexus.md: -------------------------------------------------------------------------------- 1 | # Integrating Nexus with Mercurius 2 | 3 | You can easily use [Nexus](https://github.com/graphql-nexus/nexus) in combination with Mercurius. 4 | This allows you to follow a code first approach instead of the SDL first. 5 | 6 | ## Installation 7 | 8 | ```bash 9 | npm install --save nexus 10 | ``` 11 | 12 | Now you can define a schema. 13 | 14 | ```js 15 | // schema.js 16 | const { objectType, intArg, nonNull } = require("nexus"); 17 | const args = { 18 | x: nonNull( 19 | intArg({ 20 | description: 'value of x', 21 | }) 22 | ), 23 | y: nonNull( 24 | intArg({ 25 | description: 'value of y', 26 | }) 27 | ), 28 | }; 29 | exports.Query = objectType({ 30 | name: "Query", 31 | definition(t) { 32 | t.int("add", { 33 | resolve(_, { x, y }) { 34 | return x + y; 35 | }, 36 | args, 37 | }); 38 | }, 39 | }); 40 | ``` 41 | 42 | This can be linked to the Mercurius plugin: 43 | 44 | ```js 45 | // index.js 46 | 47 | const Fastify = require("fastify"); 48 | const mercurius = require("mercurius"); 49 | const path = require("path"); 50 | const { makeSchema } = require("nexus"); 51 | const types = require("./schema"); 52 | 53 | const schema = makeSchema({ 54 | types, 55 | outputs: { 56 | schema: path.join(__dirname, "./my-schema.graphql"), 57 | typegen: path.join(__dirname, "./my-generated-types.d.ts"), 58 | }, 59 | }); 60 | 61 | const app = Fastify(); 62 | 63 | app.register(mercurius, { 64 | schema, 65 | graphiql: true, 66 | }); 67 | 68 | app.get("/", async function (req, reply) { 69 | const query = "{ add(x: 2, y: 2) }"; 70 | return reply.graphql(query); 71 | }); 72 | 73 | app.listen({ port: 3000 }); 74 | ``` 75 | 76 | If you run this, you will get type definitions and a generated GraphQL based on your code: 77 | 78 | ```bash 79 | node index.js 80 | ``` 81 | -------------------------------------------------------------------------------- /lib/queryDepth.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { Kind } = require('graphql') 4 | const { MER_ERR_GQL_QUERY_DEPTH } = require('./errors') 5 | 6 | /** 7 | * Returns the depth of nodes in a graphql query 8 | * Based on the GraphQL Depth Limit package from Stem (https://stem.is) 9 | * Project: graphql-depth-limit https://github.com/stems/graphql-depth-limit 10 | * Copyright (c) 2017 Stem 11 | * License (MIT License) https://github.com/stems/graphql-depth-limit/blob/master/LICENSE 12 | * @param {Array} [definition] the definitions from a graphQL document 13 | * @returns {Array} {Errors} An array of errors 14 | */ 15 | function queryDepth (definitions, queryDepthLimit) { 16 | const queries = getQueriesAndMutations(definitions) 17 | const queryDepth = {} 18 | 19 | for (const name in queries) { 20 | queryDepth[name] = determineDepth(queries[name]) 21 | } 22 | 23 | const errors = [] 24 | if (typeof queryDepthLimit === 'number') { 25 | for (const query of Object.keys(queryDepth)) { 26 | const totalDepth = queryDepth[query] 27 | if (totalDepth > queryDepthLimit) { 28 | const queryDepthError = new MER_ERR_GQL_QUERY_DEPTH(query, totalDepth, queryDepthLimit) 29 | errors.push(queryDepthError) 30 | } 31 | } 32 | } 33 | 34 | return errors 35 | } 36 | function determineDepth (node, current = 0) { 37 | if (node.selectionSet) { 38 | return Math.max(...node.selectionSet.selections.map((selection) => determineDepth(selection, current + 1))) 39 | } 40 | return current 41 | } 42 | function getQueriesAndMutations (definitions) { 43 | return definitions.reduce((map, definition) => { 44 | if (definition.kind === Kind.OPERATION_DEFINITION) { 45 | map[definition.name ? definition.name.value : 'unnamedQuery'] = definition 46 | } 47 | return map 48 | }, {}) 49 | } 50 | 51 | module.exports = queryDepth 52 | -------------------------------------------------------------------------------- /test/fix-790.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const Fastify = require('fastify') 5 | const GQL = require('..') 6 | 7 | test('Subscription type is not treated as a subscription when subscriptions disabled', async (t) => { 8 | const schema = ` 9 | type Query { 10 | subscription: Subscription! 11 | } 12 | 13 | type Subscription { 14 | id: ID! 15 | } 16 | ` 17 | 18 | const resolvers = { 19 | Query: { 20 | subscription: () => ({ id: '1' }) 21 | }, 22 | 23 | Subscription: { 24 | id: () => '1' 25 | } 26 | } 27 | 28 | const app = Fastify() 29 | app.register(GQL, { 30 | schema, 31 | resolvers, 32 | subscription: false 33 | }) 34 | 35 | // needed so that graphql is defined 36 | await app.ready() 37 | 38 | const query = '{ subscription { id } }' 39 | const result = await app.graphql(query) 40 | t.assert.deepEqual(result, { 41 | data: { 42 | subscription: { 43 | id: 1 44 | } 45 | } 46 | }) 47 | }) 48 | 49 | test('Subscription type is not treated as a subscription by default', async (t) => { 50 | const schema = ` 51 | type Query { 52 | subscription: Subscription! 53 | } 54 | 55 | type Subscription { 56 | id: ID! 57 | } 58 | ` 59 | 60 | const resolvers = { 61 | Query: { 62 | subscription: () => ({ id: '1' }) 63 | }, 64 | 65 | Subscription: { 66 | id: () => '1' 67 | } 68 | } 69 | 70 | const app = Fastify() 71 | app.register(GQL, { 72 | schema, 73 | resolvers 74 | }) 75 | 76 | // needed so that graphql is defined 77 | await app.ready() 78 | 79 | const query = '{ subscription { id } }' 80 | const result = await app.graphql(query) 81 | t.assert.deepEqual(result, { 82 | data: { 83 | subscription: { 84 | id: '1' 85 | } 86 | } 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /examples/full-ws-transport.js: -------------------------------------------------------------------------------- 1 | import Fastify from 'fastify' 2 | import mercurius from 'mercurius' 3 | 4 | const app = Fastify({ logger: true }) 5 | 6 | let x = 0 7 | 8 | const schema = ` 9 | type ResultChange { 10 | operation: String 11 | prev: Int 12 | current: Int 13 | } 14 | type Query { 15 | result: Int 16 | } 17 | type Mutation { 18 | add(num: Int): Int 19 | subtract(num: Int): Int 20 | } 21 | type Subscription { 22 | onResultChange: ResultChange 23 | } 24 | ` 25 | 26 | const resolvers = { 27 | Query: { 28 | result: async () => { 29 | return x 30 | } 31 | }, 32 | Mutation: { 33 | add: async (_, args, { pubsub }) => { 34 | const prev = x 35 | const { num } = args 36 | 37 | x = prev + num 38 | 39 | pubsub.publish({ 40 | topic: 'RESULT_TOPIC', 41 | payload: { 42 | onResultChange: { 43 | operation: 'add', 44 | prev, 45 | current: x 46 | } 47 | } 48 | }) 49 | 50 | return x 51 | }, 52 | subtract: async (_, args, { pubsub }) => { 53 | const prev = x 54 | const { num } = args 55 | 56 | x = prev - num 57 | 58 | pubsub.publish({ 59 | topic: 'RESULT_TOPIC', 60 | payload: { 61 | onResultChange: { 62 | operation: 'subtract', 63 | prev, 64 | current: x 65 | } 66 | } 67 | }) 68 | 69 | return x 70 | } 71 | }, 72 | Subscription: { 73 | onResultChange: { 74 | subscribe: async (_, __, { pubsub }) => { 75 | return await pubsub.subscribe('RESULT_TOPIC') 76 | } 77 | } 78 | } 79 | } 80 | 81 | app.register(mercurius, { 82 | schema, 83 | resolvers, 84 | graphiql: true, 85 | subscription: { 86 | fullWsTransport: true 87 | } 88 | }) 89 | 90 | app.listen({ port: 4000 }) 91 | -------------------------------------------------------------------------------- /bench/gateway-service-2.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('..') 5 | 6 | const app = Fastify() 7 | 8 | const posts = { 9 | p1: { 10 | pid: 'p1', 11 | title: 'Post 1', 12 | content: 'Content 1', 13 | authorId: 'u1' 14 | }, 15 | p2: { 16 | pid: 'p2', 17 | title: 'Post 2', 18 | content: 'Content 2', 19 | authorId: 'u2' 20 | }, 21 | p3: { 22 | pid: 'p3', 23 | title: 'Post 3', 24 | content: 'Content 3', 25 | authorId: 'u1' 26 | }, 27 | p4: { 28 | pid: 'p4', 29 | title: 'Post 4', 30 | content: 'Content 4', 31 | authorId: 'u1' 32 | } 33 | } 34 | 35 | const schema = ` 36 | directive @auth( 37 | requires: Role = ADMIN, 38 | ) on OBJECT | FIELD_DEFINITION 39 | 40 | enum Role { 41 | ADMIN 42 | REVIEWER 43 | USER 44 | UNKNOWN 45 | } 46 | 47 | type Post @key(fields: "pid") { 48 | pid: ID! 49 | author: User @auth(requires: ADMIN) 50 | } 51 | 52 | extend type Query { 53 | topPosts(count: Int): [Post] @auth(requires: ADMIN) 54 | } 55 | 56 | type User @key(fields: "id") @extends { 57 | id: ID! @external 58 | topPosts(count: Int!): [Post] 59 | }` 60 | 61 | const resolvers = { 62 | Post: { 63 | __resolveReference: (post, args, context, info) => { 64 | return posts[post.pid] 65 | }, 66 | author: (post, args, context, info) => { 67 | return { 68 | __typename: 'User', 69 | id: post.authorId 70 | } 71 | } 72 | }, 73 | User: { 74 | topPosts: (user, { count }, context, info) => { 75 | return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count) 76 | } 77 | }, 78 | Query: { 79 | topPosts: (root, { count = 2 }) => Object.values(posts).slice(0, count) 80 | } 81 | } 82 | 83 | app.register(mercurius, { 84 | schema, 85 | resolvers, 86 | federationMetadata: true, 87 | graphiql: false, 88 | jit: 1 89 | }) 90 | 91 | app.listen({ port: 3002 }) 92 | -------------------------------------------------------------------------------- /examples/subscription/mqemitter-mongodb-subscription.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const mercurius = require('mercurius') 4 | const Fastify = require('fastify') 5 | const mongodbMQEmitter = require('mqemitter-mongodb') 6 | 7 | const app = Fastify({ logger: true }) 8 | 9 | // mq 10 | let emitter 11 | 12 | // list of products 13 | const products = [] 14 | 15 | // graphql schema 16 | const schema = ` 17 | type Product { 18 | name: String! 19 | state: String! 20 | } 21 | 22 | type Query { 23 | products: [Product] 24 | } 25 | 26 | type Mutation { 27 | addProduct(name: String!, state: String!): Product 28 | } 29 | 30 | type Subscription { 31 | productAdded: Product 32 | } 33 | ` 34 | 35 | // graphql resolvers 36 | const resolvers = { 37 | Query: { 38 | products: () => products 39 | }, 40 | Mutation: { 41 | addProduct: async (_, { name, state }, { pubsub }) => { 42 | const product = { name, state } 43 | 44 | products.push(product) 45 | 46 | pubsub.publish({ 47 | topic: 'new_product_updates', 48 | payload: { 49 | productAdded: product 50 | } 51 | }) 52 | 53 | return product 54 | } 55 | }, 56 | Subscription: { 57 | productAdded: { 58 | subscribe: async (_, __, { pubsub }) => { 59 | return await pubsub.subscribe('new_product_updates') 60 | } 61 | } 62 | } 63 | } 64 | 65 | const handle = (conn) => conn.pipe(conn) 66 | 67 | // server start 68 | const start = async () => { 69 | try { 70 | // initialize emitter 71 | emitter = mongodbMQEmitter({ url: 'mongodb://localhost/test' }) 72 | 73 | // register GraphQl 74 | app.register(mercurius, { 75 | schema, 76 | resolvers, 77 | subscription: { 78 | emitter, 79 | handle 80 | } 81 | }) 82 | 83 | // start server 84 | await app.listen({ port: 3000 }) 85 | } catch (error) { 86 | app.log.error(error) 87 | } 88 | } 89 | 90 | start() 91 | -------------------------------------------------------------------------------- /test/disable-instrospection.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const Fastify = require('fastify') 5 | const mercurius = require('..') 6 | const graphql = require('graphql') 7 | 8 | const schema = ` 9 | type Query { 10 | add(x: Int, y: Int): Int 11 | } 12 | ` 13 | 14 | const resolvers = { 15 | Query: { 16 | add: async (_, obj) => { 17 | const { x, y } = obj 18 | return x + y 19 | } 20 | } 21 | } 22 | 23 | test('should disallow instrospection with "__schema" when NoSchemaIntrospectionCustomRule are applied to validationRules', async (t) => { 24 | const app = Fastify() 25 | 26 | const query = '{ __schema { queryType { name } } }' 27 | 28 | app.register(mercurius, { 29 | schema, 30 | resolvers, 31 | graphiql: true, 32 | validationRules: [graphql.NoSchemaIntrospectionCustomRule] 33 | }) 34 | 35 | // needed so that graphql is defined 36 | await app.ready() 37 | await t.assert.rejects( 38 | app.graphql(query), 39 | (err) => { 40 | t.assert.strictEqual(err.errors[0].message, 'GraphQL introspection has been disabled, but the requested query contained the field "__schema".') 41 | return true 42 | } 43 | ) 44 | }) 45 | 46 | test('should disallow instrospection with "__type" when NoSchemaIntrospectionCustomRule are applied to validationRules', async (t) => { 47 | const app = Fastify() 48 | 49 | const query = '{ __type(name: "Query"){ name } }' 50 | 51 | app.register(mercurius, { 52 | schema, 53 | resolvers, 54 | graphiql: true, 55 | validationRules: [graphql.NoSchemaIntrospectionCustomRule] 56 | }) 57 | 58 | // needed so that graphql is defined 59 | await app.ready() 60 | await t.assert.rejects(app.graphql(query), (err) => { 61 | t.assert.strictEqual( 62 | err.errors[0].message, 63 | 'GraphQL introspection has been disabled, but the requested query contained the field "__type".' 64 | ) 65 | return true 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /docsify/sidebar.md: -------------------------------------------------------------------------------- 1 | - [**Home**](/) 2 | - [Install](/#install) 3 | - [Quick Start](/#quick-start) 4 | - [Examples](/#examples) 5 | - [Acknowledgements](/#acknowledgements) 6 | - [License](/#license) 7 | - [API](/docs/api/options) 8 | - [Plugin Options](/docs/api/options#plugin-options) 9 | - [HTTP Endpoints](/docs/api/options#http-endpoints) 10 | - [Decorators](/docs/api/options#decorators) 11 | - [Error Extensions](/docs/api/options#errors) 12 | - [Context](/docs/context) 13 | - [Loaders](/docs/loaders) 14 | - [Hooks](/docs/hooks) 15 | - [Lifecycle](/docs/lifecycle) 16 | - [Federation](/docs/federation) 17 | - [Subscriptions](/docs/subscriptions) 18 | - [Custom directives](/docs/custom-directive.md) 19 | - [Batched Queries](/docs/batched-queries) 20 | - [Persisted Queries](/docs/persisted-queries) 21 | - [TypeScript Usage](/docs/typescript) 22 | - [HTTP](/docs/http) 23 | - [GraphQL over WebSocket](/docs/graphql-over-websocket.md) 24 | - [Security](/docs/security) 25 | - [CSRF Prevention](/docs/security/csrf-prevention.md) 26 | - [Integrations](/docs/integrations/) 27 | - [nexus](/docs/integrations/nexus) 28 | - [TypeGraphQL](/docs/integrations/type-graphql) 29 | - [Prisma](/docs/integrations/prisma) 30 | - [Testing](/docs/integrations/mercurius-integration-testing) 31 | - [Tracing - OpenTelemetry](/docs/integrations/open-telemetry) 32 | - [NestJS](/docs/integrations/nestjs.md) 33 | - [Related Plugins](/docs/plugins) 34 | - [mercurius-auth](/docs/plugins#mercurius-auth) 35 | - [mercurius-cache](/docs/plugins#mercurius-cache) 36 | - [mercurius-validation](/docs/plugins#mercurius-validation) 37 | - [mercurius-upload](/docs/plugins#mercurius-upload) 38 | - [altair-fastify-plugin](/docs/plugins#altair-fastify-plugin) 39 | - [mercurius-apollo-registry](/docs/plugins#mercurius-apollo-registry) 40 | - [mercurius-apollo-tracing](/docs/plugins#mercurius-apollo-tracing) 41 | - [Development](/docs/development) 42 | - [Faq](/docs/faq) 43 | - [Contribute](/docs/contribute) 44 | -------------------------------------------------------------------------------- /examples/schema-by-http-header.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('..') 5 | 6 | // Define the constraint custom strategy 7 | const schemaStrategy = { 8 | name: 'schema', 9 | storage: function () { 10 | const handlers = {} 11 | return { 12 | get: (type) => { return handlers[type] || null }, 13 | set: (type, store) => { handlers[type] = store } 14 | } 15 | }, 16 | deriveConstraint: (req, ctx) => { 17 | return req.headers.schema 18 | }, 19 | validate: () => true, 20 | mustMatchWhenDerived: true 21 | } 22 | 23 | // Initialize fastify 24 | const app = Fastify({ constraints: { schema: schemaStrategy } }) 25 | 26 | // Schema 1 definition 27 | const schema = ` 28 | type Query { 29 | add(x: Int, y: Int): Int 30 | } 31 | ` 32 | 33 | const resolvers = { 34 | Query: { 35 | add: async (_, obj) => { 36 | const { x, y } = obj 37 | return x + y 38 | } 39 | } 40 | } 41 | 42 | // Schema A registration with A constraint 43 | app.register(async childServer => { 44 | childServer.register(mercurius, { 45 | schema, 46 | resolvers, 47 | graphiql: false, 48 | routes: false 49 | }) 50 | 51 | childServer.route({ 52 | path: '/', 53 | method: 'POST', 54 | constraints: { schema: 'A' }, 55 | handler: (req, reply) => { 56 | const query = req.body 57 | return reply.graphql(query) 58 | } 59 | }) 60 | }) 61 | 62 | const schema2 = ` 63 | type Query { 64 | subtract(x: Int, y: Int): Int 65 | } 66 | ` 67 | 68 | const resolvers2 = { 69 | Query: { 70 | subtract: async (_, obj) => { 71 | const { x, y } = obj 72 | return x - y 73 | } 74 | } 75 | } 76 | 77 | app.register(async childServer => { 78 | childServer.register(mercurius, { 79 | schema: schema2, 80 | resolvers: resolvers2, 81 | graphiql: false, 82 | routes: false 83 | }) 84 | 85 | childServer.route({ 86 | path: '/', 87 | method: 'POST', 88 | constraints: { schema: 'B' }, 89 | handler: (req, reply) => { 90 | const query = req.body 91 | return reply.graphql(query) 92 | } 93 | }) 94 | }) 95 | 96 | app.listen({ port: 3000 }) 97 | -------------------------------------------------------------------------------- /docs/http.md: -------------------------------------------------------------------------------- 1 | # mercurius 2 | 3 | - [HTTP Status Codes](#http-status-codes) 4 | - [Default behaviour](#default-behaviour) 5 | - [Response with data](#response-with-data) 6 | - [Invalid input document](#invalid-input-document) 7 | - [Multiple errors](#multiple-errors) 8 | - [Single error with `statusCode` property](#single-error-with-statuscode-property) 9 | - [Single error with no `statusCode` property](#single-error-with-no-statuscode-property) 10 | - [Custom behaviour](#custom-behaviour) 11 | - [`200 OK` on all requests](#200-ok-on-all-requests) 12 | 13 | Mercurius exhibits the following behaviour when serving GraphQL over HTTP. 14 | 15 | ## HTTP Status Codes 16 | 17 | ### Default behaviour 18 | 19 | Mercurius has the following default behaviour for HTTP Status Codes. 20 | 21 | #### Response with data 22 | 23 | When a GraphQL response contains `data` that is defined, the HTTP Status Code is `200 OK`. 24 | 25 | - **HTTP Status Code**: `200 OK` 26 | - **Data**: `!== null` 27 | - **Errors**: `N/A` 28 | 29 | #### Invalid input document 30 | 31 | When a GraphQL input document is invalid and fails GraphQL validation, the HTTP Status Code is `400 Bad Request`. 32 | 33 | - **HTTP Status Code**: `400 Bad Request` 34 | - **Data**: `null` 35 | - **Errors**: `MER_ERR_GQL_VALIDATION` 36 | 37 | #### Response with errors 38 | 39 | When a GraphQL response contains errors, the HTTP Status Code is `200 OK` as defined in the [GraphQL Over HTTP 40 | Specification](https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#applicationjson). 41 | 42 | - **HTTP Status Code**: `200 OK` 43 | - **Data**: `null` 44 | - **Errors**: `Array` (`.length >= 1`) 45 | 46 | #### Single error with `statusCode` property 47 | 48 | When a GraphQL response contains a single error with the `statusCode` property set and no data, the HTTP Status Code is set to this value. See [ErrorWithProps](/docs/api/options.md#errorwithprops) for more details. 49 | 50 | - **HTTP Status Code**: `Error statusCode` 51 | - **Data**: `null` 52 | - **Errors**: `Array` (`.length === 1`) 53 | 54 | ### Custom behaviour 55 | 56 | If you wish to customise the default HTTP Status Code behaviour, one can do this using the [`errorFormatter`](/docs/api/options.md#plugin-options) option. 57 | -------------------------------------------------------------------------------- /examples/custom-directive.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('..') 5 | const { makeExecutableSchema } = require('@graphql-tools/schema') 6 | const { mapSchema, getDirective, MapperKind } = require('@graphql-tools/utils') 7 | 8 | const app = Fastify() 9 | 10 | const resolvers = { 11 | Query: { 12 | documents: async (_, _obj, _ctx) => { 13 | return [{ 14 | excerpt: 'Proin info@mercurius.dev rutrum pulvinar lectus sed placerat.', 15 | text: 'Proin 33 222-33355 rutrum pulvinar lectus sed placerat.' 16 | }] 17 | } 18 | } 19 | } 20 | 21 | // Define the executable schema 22 | const schema = makeExecutableSchema({ 23 | typeDefs: ` 24 | # Define the directive schema 25 | directive @redact(find: String) on FIELD_DEFINITION 26 | 27 | type Document { 28 | excerpt: String! @redact(find: "email") 29 | text: String! @redact(find: "phone") 30 | } 31 | 32 | type Query { 33 | documents: [Document] 34 | } 35 | `, 36 | resolvers 37 | }) 38 | 39 | const PHONE_REGEXP = /(?:\+?\d{2}[ -]?\d{3}[ -]?\d{5}|\d{4})/g 40 | const EMAIL_REGEXP = /([^\s@])+@[^\s@]+\.[^\s@]+/g 41 | 42 | const redactionSchemaTransformer = (schema) => mapSchema(schema, { 43 | [MapperKind.OBJECT_FIELD]: fieldConfig => { 44 | const redactDirective = getDirective(schema, fieldConfig, 'redact')?.[0] 45 | 46 | if (redactDirective) { 47 | const { find } = redactDirective 48 | 49 | fieldConfig.resolve = async (obj, _args, ctx, info) => { 50 | const value = obj[info.fieldName] 51 | 52 | if (!ctx.redact) { 53 | return document 54 | } 55 | 56 | switch (find) { 57 | case 'email': 58 | return value.replace(EMAIL_REGEXP, '****@*****.***') 59 | case 'phone': 60 | return value.replace(PHONE_REGEXP, m => '*'.repeat(m.length)) 61 | default: 62 | return value 63 | } 64 | } 65 | } 66 | } 67 | }) 68 | 69 | // Register mercurius and run it 70 | app.register(mercurius, { 71 | schema: redactionSchemaTransformer(schema), 72 | context: (request, reply) => { 73 | return { 74 | redact: true 75 | } 76 | }, 77 | graphiql: true 78 | }) 79 | 80 | app.listen({ port: 3000 }) 81 | -------------------------------------------------------------------------------- /examples/hooks-subscription.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mq = require('mqemitter') 5 | const mercurius = require('..') 6 | 7 | async function start () { 8 | const app = Fastify() 9 | 10 | const schema = ` 11 | type Notification { 12 | id: ID! 13 | message: String 14 | } 15 | 16 | type Mutation { 17 | addNotification(message: String): Notification 18 | } 19 | 20 | type Subscription { 21 | notificationAdded: Notification 22 | } 23 | ` 24 | 25 | const emitter = mq() 26 | 27 | const resolvers = { 28 | Mutation: { 29 | addNotification: async (_, { message }) => { 30 | const notification = { 31 | id: 1, 32 | message 33 | } 34 | await emitter.emit({ 35 | topic: 'NOTIFICATION_ADDED', 36 | payload: { 37 | notificationAdded: notification 38 | } 39 | }) 40 | 41 | return notification 42 | } 43 | }, 44 | Subscription: { 45 | notificationAdded: { 46 | subscribe: (root, args, { pubsub }) => pubsub.subscribe('NOTIFICATION_ADDED') 47 | } 48 | } 49 | } 50 | 51 | app.register(mercurius, { 52 | schema, 53 | resolvers, 54 | subscription: { 55 | emitter 56 | } 57 | }) 58 | 59 | await app.ready() 60 | 61 | app.graphql.addHook('preSubscriptionParsing', async function (schema, source, context, id) { 62 | console.log('preSubscriptionParsing called') 63 | }) 64 | 65 | app.graphql.addHook('preSubscriptionExecution', async function (schema, document, context, id) { 66 | console.log('preSubscriptionExecution called') 67 | }) 68 | 69 | app.graphql.addHook('onSubscriptionResolution', async function (execution, context, id) { 70 | console.log('onSubscriptionResolution called') 71 | }) 72 | 73 | app.graphql.addHook('onSubscriptionEnd', async function (context, id) { 74 | console.log('onSubscriptionEnd called') 75 | }) 76 | 77 | app.graphql.addHook('onSubscriptionConnectionClose', async function (context, code, reason) { 78 | console.log('onSubscriptionConnectionClose called') 79 | }) 80 | 81 | app.graphql.addHook('onSubscriptionConnectionError', async function (context, error) { 82 | console.log('onSubscriptionConnectionError called') 83 | }) 84 | 85 | await app.listen({ port: 3000 }) 86 | } 87 | 88 | start() 89 | -------------------------------------------------------------------------------- /docs/loaders.md: -------------------------------------------------------------------------------- 1 | # mercurius 2 | 3 | ## Loaders 4 | 5 | A loader is an utility to avoid the 1 + N query problem of GraphQL. 6 | Each defined loader will register a resolver that coalesces each of the 7 | request and combines them into a single, bulk query. Moreover, it can 8 | also cache the results, so that other parts of the GraphQL do not have 9 | to fetch the same data. 10 | 11 | Each loader function has the signature `loader(queries, context)`. 12 | `queries` is an array of objects defined as `{ obj, params, info }` where 13 | `obj` is the current object, `params` are the GraphQL params (those 14 | are the first two parameters of a normal resolver) and `info` contains 15 | additional information about the query and execution. `info` object is 16 | only available in the loader if the cache is set to `false`. The `context` 17 | is the GraphQL context, and it includes a `reply` object. 18 | 19 | Example: 20 | 21 | ```js 22 | const loaders = { 23 | Dog: { 24 | async owner (queries, { reply }) { 25 | return queries.map(({ obj, params }) => owners[obj.name]) 26 | } 27 | } 28 | } 29 | 30 | app.register(mercurius, { 31 | schema, 32 | resolvers, 33 | loaders 34 | }) 35 | ``` 36 | 37 | It is also possible disable caching with: 38 | 39 | ```js 40 | const loaders = { 41 | Dog: { 42 | owner: { 43 | async loader (queries, { reply }) { 44 | return queries.map(({ obj, params, info }) => { 45 | // info is available only if the loader is not cached 46 | owners[obj.name] 47 | }) 48 | }, 49 | opts: { 50 | cache: false 51 | } 52 | } 53 | } 54 | } 55 | 56 | app.register(mercurius, { 57 | schema, 58 | resolvers, 59 | loaders 60 | }) 61 | ``` 62 | 63 | Alternatively, globally disabling caching also disable the Loader cache: 64 | 65 | ```js 66 | const loaders = { 67 | Dog: { 68 | async owner (queries, { reply }) { 69 | return queries.map(({ obj, params, info }) => { 70 | // info is available only if the loader is not cached 71 | owners[obj.name] 72 | }) 73 | } 74 | } 75 | } 76 | 77 | app.register(mercurius, { 78 | schema, 79 | resolvers, 80 | loaders, 81 | cache: false 82 | }) 83 | ``` 84 | 85 | Disabling caching has the advantage to avoid the serialization at 86 | the cost of more objects to fetch in the resolvers. 87 | 88 | Internally, it uses 89 | [single-user-cache](http://npm.im/single-user-cache). 90 | -------------------------------------------------------------------------------- /lib/subscription-protocol.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const GRAPHQL_WS = 'graphql-ws' 4 | const GRAPHQL_TRANSPORT_WS = 'graphql-transport-ws' 5 | 6 | const GRAPHQL_WS_PROTOCOL_SIGNALS = { 7 | GQL_CONNECTION_INIT: 'connection_init', // Client -> Server 8 | GQL_CONNECTION_ACK: 'connection_ack', // Server -> Client 9 | GQL_CONNECTION_ERROR: 'connection_error', // Server -> Client 10 | GQL_CONNECTION_KEEP_ALIVE: 'ka', // Server -> Client 11 | GQL_CONNECTION_TERMINATE: 'connection_terminate', // Client -> Server 12 | GQL_START: 'start', // Client -> Server 13 | GQL_DATA: 'data', // Server -> Client 14 | GQL_ERROR: 'error', // Server -> Client 15 | GQL_COMPLETE: 'complete', // Server -> Client 16 | GQL_STOP: 'stop' // Client -> Server 17 | } 18 | 19 | const GRAPHQL_TRANSPORT_WS_PROTOCOL_SIGNALS = { 20 | GQL_CONNECTION_INIT: 'connection_init', // Client -> Server 21 | GQL_CONNECTION_ACK: 'connection_ack', // Server -> Client 22 | GQL_CONNECTION_ERROR: 'connection_error', // Server -> Client 23 | GQL_CONNECTION_KEEP_ALIVE: 'ping', // Bidirectional 24 | GQL_CONNECTION_KEEP_ALIVE_ACK: 'pong', // Bidirectional 25 | GQL_CONNECTION_TERMINATE: 'connection_terminate', // Client -> Server 26 | GQL_START: 'subscribe', // Client -> Server 27 | GQL_DATA: 'next', // Server -> Client 28 | GQL_ERROR: 'error', // Server -> Client 29 | GQL_COMPLETE: 'complete', // Server -> Client 30 | GQL_STOP: 'complete' // Client -> Server 31 | } 32 | 33 | const PROTOCOLS = { 34 | [GRAPHQL_WS]: GRAPHQL_WS_PROTOCOL_SIGNALS, 35 | [GRAPHQL_TRANSPORT_WS]: GRAPHQL_TRANSPORT_WS_PROTOCOL_SIGNALS 36 | } 37 | 38 | module.exports.isValidClientProtocol = function (name, defaultProtocol) { 39 | return name === GRAPHQL_WS || name === GRAPHQL_TRANSPORT_WS || (defaultProtocol && !name) 40 | } 41 | 42 | module.exports.isValidServerProtocol = function (name) { 43 | return name === GRAPHQL_WS || name === GRAPHQL_TRANSPORT_WS 44 | } 45 | 46 | module.exports.getProtocolByName = function (name, defaultProtocol) { 47 | const signals = PROTOCOLS[name] 48 | if (signals) { 49 | return signals 50 | } 51 | 52 | if (defaultProtocol) { 53 | return PROTOCOLS[defaultProtocol] 54 | } 55 | } 56 | 57 | module.exports.GRAPHQL_WS = GRAPHQL_WS 58 | module.exports.GRAPHQL_TRANSPORT_WS = GRAPHQL_TRANSPORT_WS 59 | module.exports.GRAPHQL_TRANSPORT_WS_PROTOCOL_SIGNALS = GRAPHQL_TRANSPORT_WS_PROTOCOL_SIGNALS 60 | module.exports.GRAPHQL_WS_PROTOCOL_SIGNALS = GRAPHQL_WS_PROTOCOL_SIGNALS 61 | -------------------------------------------------------------------------------- /docs/integrations/mercurius-integration-testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | You can easily test your GraphQL API using `mercurius-integration-testing`. 4 | 5 | [More info here.](https://github.com/mercurius-js/mercurius-integration-testing) 6 | 7 | ## Installation 8 | 9 | ```bash 10 | npm install mercurius-integration-testing 11 | ``` 12 | 13 | ## Usage 14 | 15 | > Example using [node-tap](https://node-tap.org/) 16 | 17 | ```js 18 | // server.js 19 | const Fastify = require('fastify') 20 | const mercurius = require('mercurius') 21 | 22 | const app = Fastify() 23 | 24 | const schema = ` 25 | type Query { 26 | hello: String! 27 | } 28 | ` 29 | 30 | const resolvers = { 31 | Query: { 32 | hello: () => { 33 | return 'world' 34 | } 35 | } 36 | } 37 | 38 | app.register(mercurius, { 39 | schema, 40 | resolvers, 41 | // Only required to use .batchQueries() 42 | allowBatchedQueries: true 43 | }) 44 | 45 | exports.app = app 46 | ``` 47 | 48 | Then in your tests 49 | 50 | ```js 51 | // example.test.js 52 | 53 | const tap = require('tap') 54 | const { createMercuriusTestClient } = require('mercurius-integration-testing') 55 | const { app } = require('./server.js') 56 | 57 | tap.test('works', (t) => { 58 | t.plan(1) 59 | 60 | const client = createMercuriusTestClient(app) 61 | 62 | client 63 | .query( 64 | `query { 65 | hello 66 | }` 67 | ) 68 | .then((response) => { 69 | t.equivalent(response, { 70 | data: { 71 | hello: 'world' 72 | } 73 | }) 74 | }) 75 | }) 76 | ``` 77 | 78 | --- 79 | 80 | 🎉 81 | 82 | ``` 83 | $ npx tap 84 | 85 | PASS example.test.js 1 OK 129.664ms 86 | 87 | 88 | 🌈 SUMMARY RESULTS 🌈 89 | 90 | 91 | Suites: 1 passed, 1 of 1 completed 92 | Asserts: 1 passed, of 1 93 | Time: 2s 94 | -----------|----------|----------|----------|----------|-------------------| 95 | File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s | 96 | -----------|----------|----------|----------|----------|-------------------| 97 | All files | 100 | 100 | 100 | 100 | | 98 | server.js | 100 | 100 | 100 | 100 | | 99 | -----------|----------|----------|----------|----------|-------------------| 100 | 101 | ``` 102 | 103 | ## Docs 104 | 105 | Please check [https://github.com/mercurius-js/mercurius-integration-testing#api](https://github.com/mercurius-js/mercurius-integration-testing#api) for more documentation 106 | -------------------------------------------------------------------------------- /test/hooks-with-batching.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const Fastify = require('fastify') 5 | const sinon = require('sinon') 6 | const GQL = require('..') 7 | 8 | test('batched query has an individual context for each operation through all the lifecycle hooks', async (t) => { 9 | const app = Fastify() 10 | 11 | const preParsingSpy = sinon.spy() 12 | const preValidationSpy = sinon.spy() 13 | const preExecutionSpy = sinon.spy() 14 | const onResolutionSpy = sinon.spy() 15 | 16 | const schema = ` 17 | type Query { 18 | test: String 19 | } 20 | ` 21 | 22 | const resolvers = { 23 | test: () => 'test' 24 | } 25 | 26 | await app.register(GQL, { 27 | schema, 28 | resolvers, 29 | allowBatchedQueries: true 30 | }) 31 | 32 | app.graphql.addHook('preParsing', (_, __, ctx) => { 33 | preParsingSpy(ctx.operationId, ctx.operationsCount, ctx.__currentQuery) 34 | }) 35 | 36 | app.graphql.addHook('preValidation', (_, __, ctx) => { 37 | preValidationSpy(ctx.operationId, ctx.operationsCount, ctx.__currentQuery) 38 | }) 39 | 40 | app.graphql.addHook('preExecution', (_, __, ctx) => { 41 | preExecutionSpy(ctx.operationId, ctx.operationsCount, ctx.__currentQuery) 42 | }) 43 | 44 | app.graphql.addHook('onResolution', (_, ctx) => { 45 | onResolutionSpy(ctx.operationId, ctx.operationsCount, ctx.__currentQuery) 46 | }) 47 | 48 | await app.inject({ 49 | method: 'POST', 50 | url: '/graphql', 51 | body: [ 52 | { 53 | operationName: 'TestQuery', 54 | query: 'query TestQuery { test }' 55 | }, 56 | { 57 | operationName: 'DoubleQuery', 58 | query: 'query DoubleQuery { test }' 59 | } 60 | ] 61 | }) 62 | 63 | sinon.assert.calledTwice(preParsingSpy) 64 | sinon.assert.calledWith(preParsingSpy, 0, 2, sinon.match(/TestQuery/)) 65 | sinon.assert.calledWith(preParsingSpy, 1, 2, sinon.match(/DoubleQuery/)) 66 | 67 | sinon.assert.calledTwice(preValidationSpy) 68 | sinon.assert.calledWith(preValidationSpy, 0, 2, sinon.match(/TestQuery/)) 69 | sinon.assert.calledWith(preValidationSpy, 1, 2, sinon.match(/DoubleQuery/)) 70 | 71 | sinon.assert.calledTwice(preExecutionSpy) 72 | sinon.assert.calledWith(preExecutionSpy, 0, 2, sinon.match(/TestQuery/)) 73 | sinon.assert.calledWith(preExecutionSpy, 1, 2, sinon.match(/DoubleQuery/)) 74 | 75 | sinon.assert.calledTwice(onResolutionSpy) 76 | sinon.assert.calledWith(onResolutionSpy, 0, 2, sinon.match(/TestQuery/)) 77 | sinon.assert.calledWith(onResolutionSpy, 1, 2, sinon.match(/DoubleQuery/)) 78 | }) 79 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mercurius", 3 | "version": "16.6.0", 4 | "description": "Fastify GraphQL adapter with subscription support", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "scripts": { 8 | "docs": "docsify serve", 9 | "unit": "borp --no-typescript --timeout 300000 --reporter spec 'test/**/*.test.js'", 10 | "cov": "borp --coverage --no-typescript --check-coverage --lines 100 --functions 100 --statements 100 --branches 100 --timeout 300000 --reporter spec 'test/**/*.test.js'", 11 | "lint": "eslint", 12 | "lint:fix": "eslint --fix", 13 | "typescript": "tsd", 14 | "test": "npm run lint && npm run unit && npm run typescript" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/mercurius-js/mercurius.git" 19 | }, 20 | "author": "Matteo Collina ", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/mercurius-js/mercurius/issues" 24 | }, 25 | "homepage": "https://mercurius.dev", 26 | "peerDependencies": { 27 | "graphql": "^16.0.0" 28 | }, 29 | "devDependencies": { 30 | "@graphql-tools/merge": "^9.0.0", 31 | "@graphql-tools/schema": "^10.0.0", 32 | "@graphql-tools/utils": "^10.0.0", 33 | "@matteo.collina/snap": "^0.3.0", 34 | "@sinonjs/fake-timers": "^15.0.0", 35 | "@types/isomorphic-form-data": "^2.0.0", 36 | "@types/node": "^25.0.2", 37 | "@types/ws": "^8.2.0", 38 | "autocannon": "^8.0.0", 39 | "borp": "^0.20.2", 40 | "concurrently": "^9.0.0", 41 | "docsify-cli": "^4.4.3", 42 | "eslint": "^9.9.1", 43 | "fastify": "^5.0.0", 44 | "graphql": "^16.0.0", 45 | "graphql-tag": "^2.12.6", 46 | "graphql-ws": "^6.0.1", 47 | "neostandard": "^0.12.0", 48 | "pre-commit": "^1.2.2", 49 | "proxyquire": "^2.1.3", 50 | "semver": "^7.5.0", 51 | "sinon": "^21.0.0", 52 | "split2": "^4.0.0", 53 | "tsd": "^0.32.0", 54 | "typescript": "~5.9.2", 55 | "undici": "^7.0.0", 56 | "wait-on": "^9.0.1" 57 | }, 58 | "dependencies": { 59 | "@fastify/error": "^4.0.0", 60 | "@fastify/static": "^8.0.0", 61 | "@fastify/websocket": "^11.0.0", 62 | "borp": "^0.20.2", 63 | "fastify-plugin": "^5.0.0", 64 | "graphql-jit": "0.8.7", 65 | "mqemitter": "^7.0.0", 66 | "p-map": "^4.0.0", 67 | "quick-lru": "^7.0.0", 68 | "readable-stream": "^4.0.0", 69 | "safe-stable-stringify": "^2.3.0", 70 | "secure-json-parse": "^4.1.0", 71 | "single-user-cache": "^2.0.0", 72 | "tiny-lru": "^11.0.0", 73 | "ws": "^8.2.2" 74 | }, 75 | "tsd": { 76 | "directory": "test/types" 77 | }, 78 | "engines": { 79 | "node": "^20.9.0 || >=22.0.0" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /test/aliases.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const Fastify = require('fastify') 5 | const GQL = require('..') 6 | 7 | const schema = ` 8 | type Query { 9 | me: User 10 | } 11 | 12 | type Metadata { 13 | info: String! 14 | } 15 | 16 | type User { 17 | id: ID! 18 | name: String! 19 | quote(input: String!): String! 20 | metadata(input: String!): Metadata! 21 | }` 22 | 23 | const users = { 24 | u1: { 25 | id: 'u1', 26 | name: 'John' 27 | }, 28 | u2: { 29 | id: 'u2', 30 | name: 'Jane' 31 | } 32 | } 33 | 34 | const resolvers = { 35 | Query: { 36 | me: (root, args, context, info) => { 37 | return users.u1 38 | } 39 | }, 40 | User: { 41 | quote: (user, args, context, info) => { 42 | return args.input 43 | }, 44 | metadata: (user, args, context, info) => { 45 | return { 46 | info: args.input 47 | } 48 | } 49 | } 50 | } 51 | 52 | function createTestServer (t, customResolvers = resolvers) { 53 | const app = Fastify() 54 | t.after(() => app.close()) 55 | app.register(GQL, { schema, resolvers: customResolvers }) 56 | return app 57 | } 58 | 59 | test('should support aliases', async t => { 60 | t.plan(1) 61 | const app = await createTestServer(t) 62 | 63 | const query = ` 64 | query { 65 | user: me { 66 | id 67 | name 68 | newName: name 69 | otherName: name 70 | quote(input: "quote") 71 | firstQuote: quote(input: "foo") 72 | secondQuote: quote(input: "bar") 73 | metadata(input: "info") { 74 | info 75 | } 76 | originalMetadata: metadata(input: "hello") { 77 | hi: info 78 | ho: info 79 | } 80 | moreMetadata: metadata(input: "hi") { 81 | info 82 | } 83 | } 84 | }` 85 | 86 | const res = await app.inject({ 87 | method: 'POST', 88 | headers: { 'content-type': 'application/json' }, 89 | url: '/graphql', 90 | body: JSON.stringify({ query }) 91 | }) 92 | 93 | t.assert.deepStrictEqual(JSON.parse(res.body), { 94 | data: { 95 | user: { 96 | id: 'u1', 97 | name: 'John', 98 | newName: 'John', 99 | otherName: 'John', 100 | quote: 'quote', 101 | firstQuote: 'foo', 102 | secondQuote: 'bar', 103 | metadata: { 104 | info: 'info' 105 | }, 106 | originalMetadata: { 107 | hi: 'hello', 108 | ho: 'hello' 109 | }, 110 | moreMetadata: { 111 | info: 'hi' 112 | } 113 | } 114 | } 115 | }) 116 | }) 117 | -------------------------------------------------------------------------------- /docs/lifecycle.md: -------------------------------------------------------------------------------- 1 | # Lifecycle 2 | 3 | The schema of the internal lifecycle of Mercurius.
4 | 5 | On the right branch of every section there is the next phase of the lifecycle, on the left branch there is the corresponding GraphQL error(s) that will be generated if the parent throws an error *(note that all the errors are automatically handled by Mercurius)*. 6 | 7 | ## Normal lifecycle 8 | 9 | ``` 10 | Incoming GraphQL Request 11 | │ 12 | └─▶ Routing 13 | │ 14 | errors ◀─┴─▶ preParsing Hook 15 | │ 16 | errors ◀─┴─▶ Parsing 17 | │ 18 | errors ◀─┴─▶ preValidation Hook 19 | │ 20 | errors ◀─┴─▶ Validation 21 | │ 22 | errors ◀─┴─▶ preExecution Hook 23 | │ 24 | errors ◀─┴─▶ Execution 25 | │ 26 | errors ◀─┴─▶ Resolution 27 | │ 28 | └─▶ onResolution Hook 29 | ``` 30 | 31 | 32 | ## Subscription lifecycle 33 | 34 | ``` 35 | Incoming GraphQL Websocket subscription data 36 | │ 37 | └─▶ Routing 38 | │ 39 | errors ◀─┴─▶ preSubscriptionParsing Hook 40 | │ 41 | errors ◀─┴─▶ Subscription Parsing 42 | │ 43 | errors ◀─┴─▶ preSubscriptionExecution Hook 44 | │ 45 | errors ◀─┴─▶ Subscription Execution 46 | │ 47 | wait for subscription data 48 | │ 49 | subscription closed on error ◀─┴─▶ Subscription Resolution (when subscription data is received) 50 | │ 51 | └─▶ onSubscriptionResolution Hook 52 | │ 53 | keeping processing until subscription ended 54 | │ 55 | subscription closed on error ◀─┴─▶ Subscription End (when subscription stop is received) 56 | │ 57 | └─▶ onSubscriptionEnd Hook 58 | │ 59 | └─▶ Connection Close 60 | │ 61 | └─▶ onSubscriptionConnectionClose Hook 62 | │ 63 | └─▶ Connection Error 64 | │ 65 | └─▶ onSubscriptionConnectionError Hook 66 | 67 | ``` 68 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | This document describes the management of vulnerabilities for the 4 | Mercurius project and all modules within the Mercurius organization. 5 | 6 | ## Reporting vulnerabilities 7 | 8 | Individuals who find potential vulnerabilities in Mercurius are invited 9 | to report them via GitHub at https://github.com/mercurius-js/mercurius/security/advisories 10 | or via email at matteo.collina@gmail.com. 11 | 12 | ### Strict measures when reporting vulnerabilities 13 | 14 | Avoid creating new "informative" reports. Only create new 15 | report a potential vulnerability if you are absolutely sure this 16 | should be tagged as an actual vulnerability. Be careful on the maintainers time. 17 | 18 | ## Handling vulnerability reports 19 | 20 | When a potential vulnerability is reported, the following actions are taken: 21 | 22 | ### Triage 23 | 24 | **Delay:** 5 business days 25 | 26 | Within 5 business days, a member of the security team provides a first answer to the 27 | individual who submitted the potential vulnerability. The possible responses 28 | can be: 29 | 30 | * Acceptance: what was reported is considered as a new vulnerability 31 | * Rejection: what was reported is not considered as a new vulnerability 32 | * Need more information: the security team needs more information in order to evaluate what was reported. 33 | 34 | Triaging should include updating issue fields: 35 | * Asset - set/create the module affected by the report 36 | * Severity - TBD, currently left empty 37 | 38 | ### Correction follow-up 39 | 40 | **Delay:** 90 days 41 | 42 | When a vulnerability is confirmed, a member of the security team volunteers to follow 43 | up on this report. 44 | 45 | With the help of the individual who reported the vulnerability, they contact 46 | the maintainers of the vulnerable package to make them aware of the 47 | vulnerability. The maintainers can be invited as participants to the reported issue. 48 | 49 | With the package maintainer, they define a release date for the publication 50 | of the vulnerability. Ideally, this release date should not happen before 51 | the package has been patched. 52 | 53 | The report's vulnerable versions upper limit should be set to: 54 | * `*` if there is no fixed version available by the time of publishing the report. 55 | * the last vulnerable version. For example: `<=1.2.3` if a fix exists in `1.2.4` 56 | 57 | ### Publication 58 | 59 | **Delay:** 90 days 60 | 61 | Within 90 days after the triage date, the vulnerability must be made public. 62 | 63 | **Severity**: Vulnerability severity is assessed using [CVSS v.3](https://www.first.org/cvss/user-guide). 64 | 65 | If the package maintainer is actively developing a patch, an additional delay 66 | can be added with the approval of the security team and the individual who 67 | reported the vulnerability. 68 | 69 | At this point, a CVE will be requested by the team. 70 | -------------------------------------------------------------------------------- /lib/subscription.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastifyWebsocket = require('@fastify/websocket') 4 | const { assignLifeCycleHooksToContext, Hooks } = require('./hooks') 5 | const { kHooks } = require('./symbols') 6 | const SubscriptionConnection = require('./subscription-connection') 7 | const { isValidClientProtocol } = require('./subscription-protocol') 8 | 9 | function createConnectionHandler ({ 10 | subscriber, fastify, onConnect, onDisconnect, entityResolversFactory, subscriptionContextFn, keepAlive, 11 | fullWsTransport, wsDefaultSubprotocol, queueHighWaterMark, errorFormatter 12 | }) { 13 | return async (socket, request) => { 14 | if (!isValidClientProtocol(socket.protocol, wsDefaultSubprotocol)) { 15 | console.log('wrong websocket protocol: ' + socket.protocol) 16 | request.log.warn('wrong websocket protocol: ' + socket.protocol) 17 | // Close the connection with an error code, ws v2 ensures that the 18 | // connection is cleaned up even when the closing handshake fails. 19 | // 1002: protocol error 20 | socket.close(1002) 21 | 22 | return 23 | } 24 | 25 | let context = { 26 | app: fastify, 27 | pubsub: subscriber, 28 | request 29 | } 30 | 31 | if (context.app.graphql && context.app.graphql[kHooks]) { 32 | context = assignLifeCycleHooksToContext(context, context.app.graphql[kHooks]) 33 | } else { 34 | context = assignLifeCycleHooksToContext(context, new Hooks()) 35 | } 36 | 37 | let resolveContext 38 | 39 | if (subscriptionContextFn) { 40 | resolveContext = () => subscriptionContextFn(socket, request) 41 | } 42 | 43 | // eslint-disable-next-line no-new 44 | new SubscriptionConnection(socket, { 45 | subscriber, 46 | fastify, 47 | onConnect, 48 | onDisconnect, 49 | entityResolversFactory, 50 | context, 51 | resolveContext, 52 | keepAlive, 53 | fullWsTransport, 54 | wsDefaultSubprotocol, 55 | queueHighWaterMark, 56 | errorFormatter 57 | }) 58 | } 59 | } 60 | 61 | module.exports = async function (fastify, opts) { 62 | const { getOptions, subscriber, verifyClient, onConnect, onDisconnect, entityResolversFactory, subscriptionContextFn, keepAlive, fullWsTransport, wsDefaultSubprotocol, queueHighWaterMark, errorFormatter } = opts 63 | 64 | // If `fastify.websocketServer` exists, it means `@fastify/websocket` already registered. 65 | // Without this check, @fastify/websocket will be registered multiple times and raises FST_ERR_DEC_ALREADY_PRESENT. 66 | if (fastify.websocketServer === undefined) { 67 | await fastify.register(fastifyWebsocket, { 68 | options: { 69 | maxPayload: 1048576, 70 | verifyClient 71 | } 72 | }) 73 | } 74 | 75 | fastify.route({ 76 | ...getOptions, 77 | wsHandler: createConnectionHandler({ 78 | subscriber, 79 | fastify, 80 | onConnect, 81 | onDisconnect, 82 | entityResolversFactory, 83 | subscriptionContextFn, 84 | keepAlive, 85 | fullWsTransport, 86 | wsDefaultSubprotocol, 87 | queueHighWaterMark, 88 | errorFormatter 89 | }) 90 | }) 91 | } 92 | -------------------------------------------------------------------------------- /docs/graphql-over-websocket.md: -------------------------------------------------------------------------------- 1 | # GraphQL over WebSocket 2 | 3 | The GraphQL specification doesn't dictates which transport must be used in order to execute operations. In fact, GraphQL is _transport agnostic_, so implementors can choose which protocol makes more sense for each use case. 4 | 5 | Generally, `query` and `mutation` are carried via HTTP, while `subscription` via WebSocket: this is the default behavior in `mercurius` and many other server implementations. However, `query` and `mutation` can also be sent through WebSocket. 6 | 7 | ## WebSocket subprotocol 8 | 9 | As WebSocket is a generic and bidirectional way to send messages, **we need to agree about what each message _means_**: this is defined by the _subprotocol_. 10 | 11 | ### Supported subprotocols 12 | 13 | The GraphQL over WebSocket Protocol (i.e. the WebSocket sub-protocol) used by default is called `graphql-transport-ws` and it's defined here: 14 | 15 | - [`graphql-transport-ws` Protocol SPEC](https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md) 16 | 17 | Mercurius supports the `graphql-transport-ws` and `graphql-ws` subprotocols. If the client doesn't specify one, the connection will be closed by default. To allow a fallback, set a default using the `wsDefaultSubprotocol` option. 18 | 19 | > ⚠️ The subprotocol originally defined by Apollo's `subscriptions-transport-ws` library is also supported. However, that library is **UNMAINTAINED** so it's not recommended to be used: basically **deprecated**. More info [here](https://github.com/apollographql/subscriptions-transport-ws/). 20 | 21 | ### Supported clients 22 | 23 | You should be able to use any major GraphQL client library in order to send operations via WebSocket (e.g. `graphql-ws`, `graphql-hooks`, `apollo`, `urql`…). Depending on which client you use, you have built in support or you may need to use some plugins or middleware. 24 | 25 | ## Extensions 26 | 27 | The `extensions` field is reserved for things that implementors want to add on top of the spec. 28 | 29 | ### Message structure 30 | 31 | This is the structure allowed on each WS message: 32 | 33 | ```ts 34 | export interface OperationMessage { 35 | payload?: any; 36 | id?: string; 37 | type: string; 38 | 39 | extensions?: Array; 40 | } 41 | 42 | export interface OperationExtension { 43 | type: string; 44 | payload?: any; 45 | } 46 | ``` 47 | 48 | ### Server -> Server 49 | 50 | In order to achieve _gateway-to-service_ communication and handle `connection_init` per client, an extension is used on the protocol for _server-to-server_ communication. See https://github.com/mercurius-js/mercurius/issues/268 for more details about the original issue and the actual implemented solution. 51 | 52 | #### `connectionInit` extension 53 | 54 | Gateway uses this extension to share the `connection_init` payload with a service when the connection is already established between gateway and services. 55 | 56 | ```ts 57 | export interface ConnectionInitExtension extends OperationExtension { 58 | type: string; 59 | payload?: Object; 60 | } 61 | ``` 62 | 63 | - `type: String` : 'connectionInit' 64 | - `payload: Object` : optional parameters that the client specifies in connectionParams 65 | -------------------------------------------------------------------------------- /test/options.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const proxyquire = require('proxyquire') 4 | const sinon = require('sinon') 5 | const { test } = require('node:test') 6 | const Fastify = require('fastify') 7 | const { mercurius } = require('../index') 8 | 9 | const schema = ` 10 | type User { 11 | name: String! 12 | password: String! 13 | } 14 | 15 | type Query { 16 | read: [User] 17 | } 18 | ` 19 | 20 | const resolvers = { 21 | Query: { 22 | read: async (_, obj) => { 23 | return [ 24 | { 25 | name: 'foo', 26 | password: 'bar' 27 | } 28 | ] 29 | } 30 | } 31 | } 32 | 33 | test('call compileQuery with correct options if compilerOptions specified', async t => { 34 | const app = Fastify() 35 | t.after(() => app.close()) 36 | 37 | const compileQueryStub = sinon.stub() 38 | 39 | const GQL = proxyquire('../index', { 40 | 'graphql-jit': { 41 | compileQuery: compileQueryStub 42 | } 43 | }) 44 | 45 | await app.register(GQL, { 46 | schema, 47 | resolvers, 48 | jit: 1, 49 | compilerOptions: { 50 | customJSONSerializer: true 51 | } 52 | }) 53 | 54 | const queryStub = sinon.stub() 55 | 56 | compileQueryStub.returns({ 57 | query: queryStub 58 | }) 59 | 60 | queryStub.resolves({ errors: [] }) 61 | 62 | const query = `{ 63 | read { 64 | name 65 | password 66 | } 67 | }` 68 | 69 | // warm up the jit counter 70 | await app.inject({ 71 | method: 'POST', 72 | headers: { 'content-type': 'application/json', super: 'false' }, 73 | url: '/graphql', 74 | body: JSON.stringify({ query }) 75 | }) 76 | 77 | await app.inject({ 78 | method: 'POST', 79 | headers: { 'content-type': 'application/json', super: 'false' }, 80 | url: '/graphql', 81 | body: JSON.stringify({ query }) 82 | }) 83 | 84 | sinon.assert.calledOnceWithExactly(compileQueryStub, sinon.match.any, sinon.match.any, sinon.match.any, { customJSONSerializer: true }) 85 | }) 86 | 87 | test('invalid wsDefaultSubprotocol', async t => { 88 | const app = Fastify() 89 | t.after(() => app.close()) 90 | 91 | app.register(mercurius, { 92 | subscription: { 93 | wsDefaultSubprotocol: 'invalid' 94 | } 95 | }) 96 | 97 | await t.assert.rejects(app.ready(), { 98 | message: 'Invalid options: wsDefaultSubprotocol must be either graphql-ws or graphql-transport-ws' 99 | }) 100 | }) 101 | 102 | test('invalid queueHighWaterMark', async t => { 103 | const app = Fastify() 104 | t.after(() => app.close()) 105 | 106 | app.register(mercurius, { 107 | subscription: { 108 | queueHighWaterMark: 'invalid' 109 | } 110 | }) 111 | 112 | await t.assert.rejects(app.ready(), { 113 | message: 'Invalid options: queueHighWaterMark must be a positive number' 114 | }) 115 | }) 116 | 117 | test('invalid queueHighWaterMark', async t => { 118 | const app = Fastify() 119 | t.after(() => app.close()) 120 | 121 | app.register(mercurius, { 122 | subscription: { 123 | queueHighWaterMark: -1 124 | } 125 | }) 126 | 127 | await t.assert.rejects(app.ready(), { 128 | message: 'Invalid options: queueHighWaterMark must be a positive number' 129 | }) 130 | }) 131 | -------------------------------------------------------------------------------- /examples/graphiql-plugin/plugin/samplePlugin.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | (function (global, factory) { 3 | typeof exports === 'object' && typeof module !== 'undefined' 4 | ? factory(exports, require('react')) 5 | : typeof define === 'function' && define.amd 6 | ? define(['exports', 'react'], factory) 7 | : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.samplePlugin = {}, global.React)) 8 | })(this, function (exports, React) { 9 | 'use strict' 10 | 11 | function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e } } 12 | 13 | const React__default = /* #__PURE__ */_interopDefaultLegacy(React) 14 | 15 | /** 16 | * A data manager to collect the data. 17 | */ 18 | class SampleDataManager extends EventTarget { 19 | constructor () { 20 | super() 21 | this.sampleData = [] 22 | } 23 | 24 | getSampleData () { 25 | return this.sampleData 26 | } 27 | 28 | setSampleData (sampleData) { 29 | this.sampleData = sampleData || [] 30 | this.dispatchEvent(new Event('updateSampleData')) 31 | } 32 | } 33 | 34 | const sampleDataManager = new SampleDataManager() 35 | 36 | const useSampleData = () => { 37 | const [sampleData, setSampleData] = React.useState( 38 | sampleDataManager.getSampleData() 39 | ) 40 | 41 | React.useEffect(() => { 42 | const eventListener = sampleDataManager.addEventListener( 43 | 'updateSampleData', 44 | (e, value) => { 45 | setSampleData(_ => e.target.sampleData || []) 46 | } 47 | ) 48 | return () => { 49 | sampleDataManager.removeEventListener('updateSampleData', eventListener) 50 | } 51 | }, []) 52 | 53 | return { 54 | sampleData 55 | } 56 | } 57 | 58 | function Content () { 59 | const { sampleData } = useSampleData() 60 | 61 | return ( 62 | React__default.default.createElement('div', { style: { maxWidth: '300px' } }, [ 63 | React__default.default.createElement('div', { style: { height: '100%' } }, ['This is a sample plugin']), 64 | sampleData && React__default.default.createElement('pre', null, [JSON.stringify(sampleData, null, 2)]) 65 | ]) 66 | ) 67 | } 68 | 69 | function Icon () { 70 | return React__default.default.createElement('p', null, ['GE']) 71 | } 72 | 73 | function graphiqlSamplePlugin (props) { 74 | return { 75 | title: props.title || 'GraphiQL Sample', 76 | icon: () => Icon(), 77 | content: () => { 78 | return Content() 79 | } 80 | } 81 | } 82 | 83 | function umdPlugin (props) { 84 | return graphiqlSamplePlugin(props) 85 | } 86 | 87 | /** 88 | * Intercept and store the data fetched by GQL in the DataManager. 89 | */ 90 | function parseFetchResponse (data) { 91 | if (data.data) { 92 | sampleDataManager.setSampleData(data.data) 93 | } 94 | return data 95 | } 96 | 97 | exports.graphiqlSamplePlugin = graphiqlSamplePlugin 98 | exports.parseFetchResponse = parseFetchResponse 99 | exports.umdPlugin = umdPlugin 100 | 101 | Object.defineProperty(exports, '__esModule', { value: true }) 102 | }) 103 | -------------------------------------------------------------------------------- /test/graphql-option-override.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const Fastify = require('fastify') 5 | const mercurius = require('..') 6 | 7 | const schema = ` 8 | type User { 9 | name: String! 10 | password: String! 11 | } 12 | 13 | type Query { 14 | read: [User] 15 | } 16 | ` 17 | 18 | const resolvers = { 19 | Query: { 20 | read: async (_, obj) => { 21 | return [ 22 | { 23 | name: 'foo', 24 | password: 'bar' 25 | } 26 | ] 27 | } 28 | } 29 | } 30 | 31 | const query = `{ 32 | read { 33 | name 34 | password 35 | } 36 | }` 37 | 38 | const query2 = `{ 39 | read { 40 | intentionallyUnknownField1 41 | intentionallyUnknownField2 42 | intentionallyUnknownField3 43 | } 44 | }` 45 | 46 | test('do not override graphql function options', async t => { 47 | const app = Fastify() 48 | t.after(() => app.close()) 49 | 50 | await app.register(mercurius, { 51 | schema, 52 | resolvers 53 | }) 54 | 55 | await app.ready() 56 | 57 | const res = await app.graphql(query) 58 | 59 | const expectedResult = { 60 | data: { 61 | read: [{ 62 | name: 'foo', 63 | password: 'bar' 64 | }] 65 | } 66 | } 67 | 68 | t.assert.deepEqual(res, expectedResult) 69 | }) 70 | 71 | test('override graphql.parse options', async t => { 72 | const app = Fastify() 73 | t.after(() => app.close()) 74 | 75 | await app.register(mercurius, { 76 | schema, 77 | resolvers, 78 | graphql: { 79 | parseOptions: { 80 | maxTokens: 1 81 | } 82 | } 83 | }) 84 | 85 | await app.ready() 86 | 87 | const expectedErr = { 88 | errors: [{ 89 | message: 'Syntax Error: Document contains more that 1 tokens. Parsing aborted.' 90 | }] 91 | } 92 | 93 | await t.assert.rejects(app.graphql(query), expectedErr.errors[0].message) 94 | }) 95 | 96 | test('do not override graphql.validate options', async t => { 97 | const app = Fastify() 98 | t.after(() => app.close()) 99 | 100 | await app.register(mercurius, { 101 | schema, 102 | resolvers 103 | }) 104 | 105 | await app.ready() 106 | 107 | const expectedErr = { 108 | errors: [ 109 | { message: 'Cannot query field "intentionallyUnknownField1" on type "User".' }, 110 | { message: 'Cannot query field "intentionallyUnknownField2" on type "User".' }, 111 | { message: 'Cannot query field "intentionallyUnknownField3" on type "User".' } 112 | ] 113 | } 114 | 115 | await t.assert.rejects(app.graphql(query2), expectedErr.errors[0].message) 116 | }) 117 | 118 | test('override graphql.validate options', async t => { 119 | const app = Fastify() 120 | t.after(() => app.close()) 121 | 122 | await app.register(mercurius, { 123 | schema, 124 | resolvers, 125 | graphql: { 126 | validateOptions: { 127 | maxErrors: 1 128 | } 129 | } 130 | }) 131 | 132 | await app.ready() 133 | 134 | const expectedErr = { 135 | errors: [ 136 | { message: 'Cannot query field "intentionallyUnknownField1" on type "User".' }, 137 | { message: 'Too many validation errors, error limit reached. Validation aborted.' } 138 | ] 139 | } 140 | 141 | await t.assert.rejects(app.graphql(query2), expectedErr.errors[0].message) 142 | }) 143 | -------------------------------------------------------------------------------- /test/subscriber.test.js: -------------------------------------------------------------------------------- 1 | const { test } = require('node:test') 2 | const mq = require('mqemitter') 3 | const { PubSub, SubscriptionContext } = require('../lib/subscriber') 4 | 5 | function capture (obj, methodName) { 6 | const original = obj[methodName] 7 | const calls = [] 8 | 9 | obj[methodName] = function (...args) { 10 | calls.push(args) 11 | if (typeof original === 'function') { 12 | return original.apply(this, args) 13 | } 14 | } 15 | 16 | obj[methodName].calls = calls 17 | return obj[methodName] 18 | } 19 | 20 | test('subscriber published an event', async (t) => { 21 | class MyQueue { 22 | push (value) { 23 | t.assert.strictEqual(value, 1) 24 | } 25 | } 26 | 27 | const s = new PubSub(mq()) 28 | s.subscribe('TOPIC', new MyQueue()) 29 | s.publish({ 30 | topic: 'TOPIC', 31 | payload: 1 32 | }, () => { 33 | t.assert.ok('passed') 34 | }) 35 | }) 36 | 37 | test('subscription context not throw error on close', t => { 38 | t.plan(1) 39 | const pubsub = new PubSub(mq()) 40 | 41 | const sc = new SubscriptionContext({ pubsub }) 42 | 43 | sc.close() 44 | t.assert.ok('passed') 45 | }) 46 | 47 | test('subscription context publish event returns a promise', t => { 48 | t.plan(1) 49 | const pubsub = new PubSub(mq()) 50 | 51 | const sc = new SubscriptionContext({ pubsub }) 52 | 53 | sc.subscribe('TOPIC') 54 | sc.publish({ 55 | topic: 'TOPIC', 56 | payload: 1 57 | }).then(() => { 58 | t.assert.ok('passed') 59 | }) 60 | }) 61 | 62 | test('subscription context publish event errs, error is catched', t => { 63 | t.plan(1) 64 | const emitter = mq() 65 | const pubsub = new PubSub(emitter) 66 | 67 | const fastifyMock = { 68 | log: { 69 | error () { 70 | t.assert.ok('passed') 71 | } 72 | } 73 | } 74 | const sc = new SubscriptionContext({ pubsub, fastify: fastifyMock }) 75 | 76 | sc.subscribe('TOPIC') 77 | emitter.close(() => {}) 78 | sc.publish({ 79 | topic: 'TOPIC', 80 | payload: 1 81 | }) 82 | }) 83 | 84 | test('subscription context publish event returns a promise reject on error', async t => { 85 | const emitter = mq() 86 | const error = new Error('Dummy error') 87 | emitter.on = (topic, listener, done) => done(error) 88 | 89 | const pubsub = new PubSub(emitter) 90 | const sc = new SubscriptionContext({ pubsub }) 91 | 92 | await t.assert.rejects(sc.subscribe('TOPIC'), error) 93 | }) 94 | 95 | test('subscription context can handle multiple topics', async (t) => { 96 | const q = mq() 97 | const pubsub = new PubSub(q) 98 | const sc = new SubscriptionContext({ pubsub }) 99 | 100 | sc.subscribe(['TOPIC1', 'TOPIC2']) 101 | await sc.publish({ 102 | topic: 'TOPIC1', 103 | payload: 1 104 | }) 105 | await sc.publish({ 106 | topic: 'TOPIC2', 107 | payload: 2 108 | }) 109 | 110 | t.assert.strictEqual(q._matcher._trie.size, 2, 'Two listeners not found') 111 | sc.close() 112 | setImmediate(() => { t.assert.strictEqual(q._matcher._trie.size, 0, 'All listeners not removed') }) 113 | }) 114 | 115 | test('subscription context should not call removeListener more than one time when close called multiple times', async t => { 116 | const q = mq() 117 | const removeListener = capture(q, 'removeListener') 118 | const pubsub = new PubSub(q) 119 | const sc = new SubscriptionContext({ pubsub }) 120 | await sc.subscribe('foo') 121 | sc.close() 122 | sc.close() 123 | t.assert.strictEqual(removeListener.calls.length, 1) 124 | }) 125 | -------------------------------------------------------------------------------- /lib/subscriber.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { Readable } = require('readable-stream') 4 | 5 | class PubSub { 6 | constructor (emitter) { 7 | this.emitter = emitter 8 | } 9 | 10 | subscribe (topic, queue) { 11 | return new Promise((resolve, reject) => { 12 | function listener (value, cb) { 13 | queue.push(value.payload) 14 | cb() 15 | } 16 | 17 | const close = () => { 18 | this.emitter.removeListener(topic, listener) 19 | } 20 | 21 | this.emitter.on(topic, listener, (err) => { 22 | if (err) { 23 | return reject(err) 24 | } 25 | 26 | resolve() 27 | }) 28 | if (!queue.close) queue.close = [] 29 | queue.close.push(close) 30 | }) 31 | } 32 | 33 | publish (event, callback) { 34 | this.emitter.emit(event, callback) 35 | } 36 | } 37 | 38 | // One context - and queue for each subscription 39 | class SubscriptionContext { 40 | constructor ({ pubsub, fastify, queueHighWaterMark }) { 41 | this.fastify = fastify 42 | this.pubsub = pubsub 43 | this.queue = new Readable({ 44 | objectMode: true, 45 | highWaterMark: queueHighWaterMark, 46 | read: () => {} 47 | }) 48 | this.closed = false 49 | } 50 | 51 | // `topics` param can be: 52 | // - string: subscribe to a single topic 53 | // - array: subscribe to multiple topics 54 | subscribe (topics, ...customArgs) { 55 | if (typeof topics === 'string') { 56 | return this.pubsub.subscribe(topics, this.queue, ...customArgs).then(() => this.queue) 57 | } 58 | return Promise.all(topics.map((topic) => this.pubsub.subscribe(topic, this.queue, ...customArgs))).then(() => this.queue) 59 | } 60 | 61 | publish (event) { 62 | return new Promise((resolve, reject) => { 63 | this.pubsub.publish(event, (err) => { 64 | if (err) { 65 | return reject(err) 66 | } 67 | resolve() 68 | }) 69 | }).catch(err => { 70 | this.fastify.log.error(err) 71 | }) 72 | } 73 | 74 | close () { 75 | if (this.closed) { 76 | return false 77 | } 78 | // In rare cases when `subscribe()` not called (e.g. some network error) 79 | // `close` will be `undefined`. 80 | if (Array.isArray(this.queue.close)) { 81 | this.queue.close.map((close) => close()) 82 | delete this.queue.close 83 | } 84 | this.queue.push(null) 85 | this.closed = true 86 | return true 87 | } 88 | } 89 | 90 | function withFilter (subscribeFn, filterFn) { 91 | return async function * (root, args, context, info) { 92 | const subscription = (await subscribeFn(root, args, context, info))[Symbol.asyncIterator]() 93 | 94 | const newAsyncIterator = { 95 | next: async () => { 96 | while (true) { 97 | const { value, done } = await subscription.next() 98 | if (done) { 99 | return { done: true } 100 | } 101 | try { 102 | if (await filterFn(value, args, context, info)) { 103 | return { value, done: false } 104 | } 105 | } catch (err) { 106 | context.app.log.error(err) 107 | } 108 | } 109 | }, 110 | return: async () => { 111 | /* c8 ignore next 10 */ 112 | if (typeof subscription.return === 'function') { 113 | return await subscription.return() 114 | } 115 | return { done: true } 116 | }, 117 | [Symbol.asyncIterator] () { 118 | return this 119 | } 120 | } 121 | 122 | yield * newAsyncIterator 123 | } 124 | } 125 | 126 | module.exports = { 127 | PubSub, 128 | SubscriptionContext, 129 | withFilter 130 | } 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Mercurius Logo](https://raw.githubusercontent.com/mercurius-js/graphics/main/mercurius-horizontal.svg) 2 | 3 | # mercurius 4 | 5 | [![CI workflow](https://github.com/mercurius-js/mercurius/actions/workflows/ci.yml/badge.svg)](https://github.com/mercurius-js/mercurius/actions/workflows/ci.yml) 6 | [![NPM version](https://img.shields.io/npm/v/mercurius.svg?style=flat)](https://www.npmjs.com/package/mercurius) 7 | [![NPM downloads](https://img.shields.io/npm/dm/mercurius.svg?style=flat)](https://www.npmjs.com/package/mercurius) 8 | [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) 9 | 10 | Mercurius is a [**GraphQL**](https://graphql.org/) adapter for [**Fastify**](https://www.fastify.io) 11 | 12 | Features: 13 | 14 | - Caching of query parsing and validation. 15 | - Automatic loader integration to avoid 1 + N queries. 16 | - Just-In-Time compiler via [graphql-jit](http://npm.im/graphql-jit). 17 | - Subscriptions. 18 | - Federation support via [@mercuriusjs/federation](https://github.com/mercurius-js/mercurius-federation), including Subscriptions. 19 | - Gateway implementation via [@mercuriusjs/gateway](https://github.com/mercurius-js/mercurius-gateway), including Subscriptions. 20 | - Batched query support. 21 | - Customisable persisted queries. 22 | 23 | ## Docs 24 | 25 | - [Install](#install) 26 | - [Quick Start](#quick-start) 27 | - [Examples](#examples) 28 | - [API](docs/api/options.md) 29 | - [Context](docs/context.md) 30 | - [Loaders](docs/loaders.md) 31 | - [Hooks](docs/hooks.md) 32 | - [Lifecycle](docs/lifecycle.md) 33 | - [Federation](docs/federation.md) 34 | - [Subscriptions](docs/subscriptions.md) 35 | - [Batched Queries](docs/batched-queries.md) 36 | - [Persisted Queries](docs/persisted-queries.md) 37 | - [TypeScript Usage](/docs/typescript.md) 38 | - [HTTP](/docs/http.md) 39 | - [GraphQL over WebSocket](/docs/graphql-over-websocket.md) 40 | - [Integrations](docs/integrations/) 41 | - [Related Plugins](docs/plugins.md) 42 | - [Security - CSRF Prevention](docs/security/csrf-prevention.md) 43 | - [Faq](/docs/faq.md) 44 | - [Acknowledgements](#acknowledgements) 45 | - [License](#license) 46 | 47 | ## Install 48 | 49 | ```bash 50 | npm i fastify mercurius graphql 51 | # or 52 | yarn add fastify mercurius graphql 53 | ``` 54 | 55 | The previous name of this module was [fastify-gql](http://npm.im/fastify-gql) (< 6.0.0). 56 | 57 | ## Quick Start 58 | 59 | ```js 60 | 'use strict' 61 | 62 | const Fastify = require('fastify') 63 | const mercurius = require('mercurius') 64 | 65 | const app = Fastify() 66 | 67 | const schema = ` 68 | type Query { 69 | add(x: Int, y: Int): Int 70 | } 71 | ` 72 | 73 | const resolvers = { 74 | Query: { 75 | add: async (_, { x, y }) => x + y 76 | } 77 | } 78 | 79 | app.register(mercurius, { 80 | schema, 81 | resolvers 82 | }) 83 | 84 | app.get('/', async function (req, reply) { 85 | const query = '{ add(x: 2, y: 2) }' 86 | return reply.graphql(query) 87 | }) 88 | 89 | app.listen({ port: 3000 }) 90 | ``` 91 | 92 | ## Examples 93 | 94 | Check [GitHub repo](https://github.com/mercurius-js/mercurius/tree/master/examples) for more examples. 95 | 96 | ## Acknowledgements 97 | 98 | The project is kindly sponsored by: 99 | 100 | - [NearForm](https://www.nearform.com) 101 | - [Platformatic](https://platformatic.dev) 102 | 103 | The Mercurius name was gracefully donated by [Marco Castelluccio](https://github.com/marco-c). 104 | The usage of that library was described in https://hacks.mozilla.org/2015/12/web-push-notifications-from-irssi/, and 105 | you can find that codebase in https://github.com/marco-c/mercurius. 106 | 107 | ## License 108 | 109 | MIT 110 | -------------------------------------------------------------------------------- /lib/handlers.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { 4 | hooksRunner, preExecutionHooksRunner, hookRunner, preParsingHookRunner, onResolutionHookRunner, 5 | preSubscriptionParsingHookRunner, preSubscriptionExecutionHookRunner, onSubscriptionResolutionHookRunner, onSubscriptionEndHookRunner, onConnectionCloseHookRunner, onConnectionErrorHookRunner 6 | } = require('./hooks') 7 | const { addErrorsToContext } = require('./errors') 8 | 9 | async function preParsingHandler (request) { 10 | await hooksRunner( 11 | request.context.preParsing, 12 | preParsingHookRunner, 13 | request 14 | ) 15 | } 16 | 17 | async function preValidationHandler (request) { 18 | await hooksRunner( 19 | request.context.preValidation, 20 | hookRunner, 21 | request 22 | ) 23 | } 24 | 25 | async function preExecutionHandler (request) { 26 | const { 27 | errors, 28 | modifiedDocument, 29 | modifiedSchema, 30 | modifiedVariables 31 | } = await preExecutionHooksRunner( 32 | request.context.preExecution, 33 | request 34 | ) 35 | 36 | if (errors.length > 0) { 37 | addErrorsToContext(request.context, errors) 38 | } 39 | if ( 40 | typeof modifiedDocument !== 'undefined' || 41 | typeof modifiedSchema !== 'undefined' || 42 | typeof modifiedVariables !== 'undefined' 43 | ) { 44 | return Object.create(null, { 45 | modifiedDocument: { value: modifiedDocument }, 46 | modifiedSchema: { value: modifiedSchema }, 47 | modifiedVariables: { value: modifiedVariables } 48 | }) 49 | } 50 | 51 | return {} 52 | } 53 | 54 | async function onResolutionHandler (request) { 55 | await hooksRunner( 56 | request.context.onResolution, 57 | onResolutionHookRunner, 58 | request 59 | ) 60 | } 61 | 62 | async function preSubscriptionParsingHandler (request) { 63 | await hooksRunner( 64 | request.context.preSubscriptionParsing, 65 | preSubscriptionParsingHookRunner, 66 | request 67 | ) 68 | } 69 | 70 | async function preSubscriptionExecutionHandler (request) { 71 | await hooksRunner( 72 | request.context.preSubscriptionExecution, 73 | preSubscriptionExecutionHookRunner, 74 | request 75 | ) 76 | } 77 | 78 | async function onSubscriptionResolutionHandler (request) { 79 | await hooksRunner( 80 | request.context.onSubscriptionResolution, 81 | onSubscriptionResolutionHookRunner, 82 | request 83 | ) 84 | } 85 | 86 | async function onSubscriptionEndHandler (request) { 87 | await hooksRunner( 88 | request.context.onSubscriptionEnd, 89 | onSubscriptionEndHookRunner, 90 | request 91 | ) 92 | } 93 | 94 | async function onSubscriptionConnectionCloseHandler (request) { 95 | await hooksRunner( 96 | request.context.onSubscriptionConnectionClose, 97 | onConnectionCloseHookRunner, 98 | request 99 | ) 100 | /* c8 ignore next 1 something wrong with coverage */ 101 | } 102 | 103 | async function onSubscriptionConnectionErrorHandler (request) { 104 | await hooksRunner( 105 | request.context.onSubscriptionConnectionError, 106 | onConnectionErrorHookRunner, 107 | request 108 | ) 109 | } 110 | 111 | async function onExtendSchemaHandler (request) { 112 | await hooksRunner( 113 | request.context.onExtendSchema, 114 | hookRunner, 115 | request 116 | ) 117 | } 118 | 119 | module.exports = { 120 | preParsingHandler, 121 | preValidationHandler, 122 | preExecutionHandler, 123 | onResolutionHandler, 124 | preSubscriptionParsingHandler, 125 | preSubscriptionExecutionHandler, 126 | onSubscriptionResolutionHandler, 127 | onSubscriptionEndHandler, 128 | onSubscriptionConnectionCloseHandler, 129 | onSubscriptionConnectionErrorHandler, 130 | onExtendSchemaHandler 131 | } 132 | -------------------------------------------------------------------------------- /test/cache.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const Fastify = require('fastify') 5 | const proxyquire = require('proxyquire') 6 | const GQL = require('..') 7 | const { GraphQLError } = require('graphql-jit/dist/error') 8 | 9 | const schema = ` 10 | type User { 11 | name: String! 12 | password: String! 13 | } 14 | 15 | type Query { 16 | read: [User] 17 | } 18 | ` 19 | 20 | const resolvers = { 21 | Query: { 22 | read: async (_, obj) => { 23 | return [ 24 | { 25 | name: 'foo', 26 | password: 'bar' 27 | } 28 | ] 29 | } 30 | } 31 | } 32 | 33 | test('cache skipped when the GQL Schema has been changed', async t => { 34 | t.plan(4) 35 | 36 | const app = Fastify() 37 | t.after(() => app.close()) 38 | 39 | await app.register(GQL, { 40 | schema, 41 | resolvers, 42 | jit: 1 43 | }) 44 | 45 | app.graphql.addHook('preExecution', async (schema, document, context) => { 46 | if (context.reply.request.headers.super === 'true') { 47 | return 48 | } 49 | 50 | const documentClone = JSON.parse(JSON.stringify(document)) 51 | documentClone.definitions[0].selectionSet.selections[0].selectionSet.selections = 52 | document.definitions[0].selectionSet.selections[0].selectionSet.selections.filter(sel => sel.name.value !== 'password') 53 | 54 | return { 55 | document: documentClone 56 | } 57 | }) 58 | 59 | const query = `{ 60 | read { 61 | name 62 | password 63 | } 64 | }` 65 | 66 | await superUserCall('this call warm up the jit counter') 67 | await superUserCall('this call triggers the jit cache') 68 | 69 | { 70 | const res = await app.inject({ 71 | method: 'POST', 72 | headers: { 'content-type': 'application/json', super: 'false' }, 73 | url: '/graphql', 74 | body: JSON.stringify({ query }) 75 | }) 76 | t.assert.deepStrictEqual(res.json(), { 77 | data: { 78 | read: [ 79 | { 80 | name: 'foo' 81 | } 82 | ] 83 | } 84 | }, 'this query should not use the cached query') 85 | } 86 | 87 | await superUserCall('this call must use the cache') 88 | 89 | async function superUserCall (msg) { 90 | const res = await app.inject({ 91 | method: 'POST', 92 | headers: { 'content-type': 'application/json', super: 'true' }, 93 | url: '/graphql', 94 | body: JSON.stringify({ query }) 95 | }) 96 | t.assert.deepStrictEqual(res.json(), { 97 | data: { 98 | read: [ 99 | { 100 | name: 'foo', 101 | password: 'bar' 102 | } 103 | ] 104 | } 105 | }, msg) 106 | } 107 | }) 108 | 109 | const GQLMock = proxyquire('../index', { 110 | 'graphql-jit': { 111 | compileQuery: () => new GraphQLError('compileQuery stub') 112 | } 113 | }) 114 | 115 | test('cache skipped when no jit response', async t => { 116 | t.plan(1) 117 | 118 | const app = Fastify() 119 | t.after(() => app.close()) 120 | 121 | await app.register(GQLMock, { 122 | schema, 123 | resolvers, 124 | jit: 1 125 | }) 126 | 127 | const query = `{ 128 | read { 129 | name 130 | password 131 | } 132 | }` 133 | 134 | { 135 | const res = await app.inject({ 136 | method: 'POST', 137 | headers: { 'content-type': 'application/json', super: 'false' }, 138 | url: '/graphql', 139 | body: JSON.stringify({ query }) 140 | }) 141 | t.assert.deepStrictEqual(res.json(), { 142 | data: { 143 | read: [ 144 | { 145 | name: 'foo', 146 | password: 'bar' 147 | } 148 | ] 149 | } 150 | }, 'this query should not use the cached query') 151 | } 152 | }) 153 | -------------------------------------------------------------------------------- /docs/integrations/open-telemetry.md: -------------------------------------------------------------------------------- 1 | # mercurius 2 | 3 | ## OpenTelemetry (Tracing) 4 | 5 | Mercurius is compatible with open-telemetry (Note that, for now, jitted requests are not able to trace the graphql execution). Also make sure that registration of opentelemetry instrumentation happens before requiring `mercurius`. 6 | 7 | Here is a simple example on how to enable tracing on Mercurius with OpenTelemetry: 8 | 9 | tracer.js 10 | ```js 11 | 'use strict' 12 | 13 | const api = require('@opentelemetry/api') 14 | const { NodeTracerProvider } = require('@opentelemetry/node') 15 | const { SimpleSpanProcessor } = require('@opentelemetry/tracing') 16 | const { JaegerExporter } = require('@opentelemetry/exporter-jaeger') 17 | const { GraphQLInstrumentation } = require('@opentelemetry/instrumentation-graphql') 18 | const { W3CTraceContextPropagator } = require('@opentelemetry/core') 19 | const { ZipkinExporter } = require('@opentelemetry/exporter-zipkin') 20 | // or 21 | // const { JaegerExporter } = require('@opentelemetry/exporter-jaeger') 22 | 23 | module.exports = serviceName => { 24 | const provider = new NodeTracerProvider() 25 | const graphQLInstrumentation = new GraphQLInstrumentation() 26 | graphQLInstrumentation.setTracerProvider(provider) 27 | graphQLInstrumentation.enable() 28 | 29 | api.propagation.setGlobalPropagator(new W3CTraceContextPropagator()) 30 | api.trace.setGlobalTracerProvider(provider) 31 | 32 | provider.addSpanProcessor( 33 | new SimpleSpanProcessor( 34 | new ZipkinExporter({ 35 | serviceName 36 | }) 37 | // or 38 | // new JaegerExporter({ 39 | // serviceName, 40 | // }) 41 | ) 42 | ) 43 | provider.register() 44 | return provider 45 | } 46 | ``` 47 | 48 | serviceAdd.js 49 | ```js 50 | 'use strict' 51 | // Register tracer 52 | const serviceName = 'service-add' 53 | const tracer = require('./tracer') 54 | tracer(serviceName) 55 | 56 | const service = require('fastify')({ logger: { level: 'debug' } }) 57 | const mercurius = require('mercurius') 58 | const opentelemetry = require('@autotelic/fastify-opentelemetry') 59 | 60 | service.register(opentelemetry, { serviceName }) 61 | service.register(mercurius, { 62 | schema: ` 63 | extend type Query { 64 | add(x: Float, y: Float): Float 65 | } 66 | `, 67 | resolvers: { 68 | Query: { 69 | add: (_, { x, y }, { reply }) => { 70 | const { activeSpan, tracer } = reply.request.openTelemetry() 71 | 72 | activeSpan.setAttribute('arg.x', x) 73 | activeSpan.setAttribute('arg.y', y) 74 | 75 | const span = tracer.startSpan('compute-add', { parent: tracer.getCurrentSpan() }) 76 | const result = x + y 77 | span.end() 78 | 79 | return result 80 | } 81 | } 82 | }, 83 | }) 84 | 85 | service.listen({ port: 4001, host: 'localhost' }, err => { 86 | if (err) { 87 | console.error(err) 88 | process.exit(1) 89 | } 90 | }) 91 | ``` 92 | 93 | gateway.js 94 | ```js 95 | 'use strict' 96 | const serviceName = 'gateway' 97 | const tracer = require('./tracer') 98 | // Register tracer 99 | tracer(serviceName) 100 | 101 | const gateway = require('fastify')({ logger: { level: 'debug' } }) 102 | const mercurius = require('mercurius') 103 | const opentelemetry = require('@autotelic/fastify-opentelemetry') 104 | 105 | // Register fastify opentelemetry 106 | gateway.register(opentelemetry, { serviceName }) 107 | gateway.register(mercurius, { 108 | gateway: { 109 | services: [ 110 | { 111 | name: 'add', 112 | url: 'http://localhost:4001/graphql' 113 | } 114 | ] 115 | } 116 | }) 117 | 118 | gateway.listen({ port: 3000, host: 'localhost' }, err => { 119 | if (err) { 120 | process.exit(1) 121 | } 122 | }) 123 | ``` 124 | 125 | Start a zipkin service: 126 | 127 | ``` 128 | $ docker run -d -p 9411:9411 openzipkin/zipkin 129 | ``` 130 | 131 | Send some request to the gateway: 132 | 133 | ```bash 134 | $ curl localhost:3000/graphql -H 'Content-Type: application/json' --data '{"query":"{ add(x: 1, y: 2) }"}' 135 | ``` 136 | 137 | You can now browse through mercurius tracing at `http://localhost:9411` 138 | -------------------------------------------------------------------------------- /lib/csrf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { MER_ERR_GQL_CSRF_PREVENTION } = require('./errors') 4 | 5 | const CSRF_ERROR_MESSAGE = 'This operation has been blocked as a potential Cross-Site Request Forgery (CSRF).' 6 | const defaultCSRFConfig = { 7 | allowedContentTypes: ['application/json', 'application/graphql'], 8 | requiredHeaders: ['x-mercurius-operation-name', 'mercurius-require-preflight'] 9 | } 10 | 11 | /** 12 | * Check if a Content-Type header indicates a non-simple request 13 | * @param {string} contentType - The Content-Type header value 14 | * @param {string[]} allowedContentTypes - The allowed content types 15 | * @returns {boolean} - True if the content type makes the request non-simple 16 | */ 17 | function isValidContentType (contentType, allowedContentTypes) { 18 | if (!contentType) return false 19 | 20 | const index = contentType.indexOf(';') 21 | if (index === -1) { 22 | return allowedContentTypes.includes(contentType) 23 | } 24 | 25 | // Extract the main content type (ignore charset and other parameters) 26 | const type = contentType.substring(0, index).trim().toLowerCase() 27 | return allowedContentTypes.includes(type) 28 | } 29 | 30 | /** 31 | * Check if any of the required headers are present - note both are already lowercased 32 | * @param {Object} headers - Request headers 33 | * @param {string[]} requiredHeaders - Array of required header names 34 | * @returns {boolean} - True if at least one required header is present 35 | */ 36 | function hasRequiredHeader (headers, requiredHeaders) { 37 | for (let i = 0; i < requiredHeaders.length; i++) { 38 | if (Object.hasOwn(headers, requiredHeaders[i])) { 39 | return true 40 | } 41 | } 42 | } 43 | 44 | /** 45 | * Validate CSRF prevention configuration 46 | * @param {Object} config - CSRF configuration 47 | * @returns {Object} - Normalized configuration 48 | */ 49 | function normalizeCSRFConfig (config) { 50 | if (config === true) { 51 | return defaultCSRFConfig 52 | } 53 | 54 | if (!config) { 55 | return undefined 56 | } 57 | 58 | const normalized = {} 59 | 60 | let multipart = false 61 | if (config.requiredHeaders) { 62 | if (!Array.isArray(config.requiredHeaders)) { 63 | throw new Error('csrfPrevention.requiredHeaders must be an array') 64 | } 65 | normalized.requiredHeaders = config.requiredHeaders.map(h => h.toLowerCase()) 66 | } else { 67 | normalized.requiredHeaders = defaultCSRFConfig.requiredHeaders 68 | } 69 | 70 | if (config.allowedContentTypes) { 71 | if (!Array.isArray(config.allowedContentTypes)) { 72 | throw new Error('csrfPrevention.allowedContentTypes must be an array') 73 | } 74 | normalized.allowedContentTypes = config.allowedContentTypes.map(h => { 75 | if (h === 'multipart/form-data') { 76 | multipart = true 77 | } 78 | return h.toLowerCase() 79 | }) 80 | } else { 81 | normalized.allowedContentTypes = defaultCSRFConfig.allowedContentTypes 82 | } 83 | 84 | multipart && (normalized.multipart = true) 85 | 86 | return normalized 87 | } 88 | 89 | /** 90 | * Perform CSRF prevention check 91 | * @param {Object} request - Fastify request object 92 | * @param {Object} config - CSRF configuration 93 | * @throws {MER_ERR_GQL_CSRF_PREVENTION} - If CSRF check fails 94 | */ 95 | function checkCSRFPrevention (request, config) { 96 | // Check 1: Content-Type header indicates non-simple request 97 | if (isValidContentType(request.headers['content-type'], config.allowedContentTypes)) { 98 | if (config.multipart) { 99 | if (hasRequiredHeader(request.headers, config.requiredHeaders)) { 100 | return // Request is safe 101 | } else { 102 | const err = new MER_ERR_GQL_CSRF_PREVENTION() 103 | err.message = CSRF_ERROR_MESSAGE 104 | throw err 105 | } 106 | } 107 | 108 | return // Request is safe 109 | } 110 | 111 | // Check 2: Required headers are present 112 | if (hasRequiredHeader(request.headers, config.requiredHeaders)) { 113 | return // Request is safe 114 | } 115 | 116 | // Request failed CSRF prevention checks 117 | const err = new MER_ERR_GQL_CSRF_PREVENTION() 118 | err.message = CSRF_ERROR_MESSAGE 119 | throw err 120 | } 121 | 122 | module.exports = { 123 | normalizeCSRFConfig, 124 | checkCSRFPrevention, 125 | isValidContentType, 126 | hasRequiredHeader, 127 | CSRF_ERROR_MESSAGE 128 | } 129 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | This page answers commonly asked questions about Mercurius. 4 | 5 | ## Disable Graphql introspection 6 | To disable Graphql introspection you can use `NoSchemaIntrospectionCustomRule` from graphql. We have an example on "example/disable-instrospection.js", using this approach: 7 | 8 | ```js 9 | import { NoSchemaIntrospectionCustomRule } from 'graphql'; 10 | 11 | const schema = ` 12 | type Query { 13 | add(x: Int, y: Int): Int 14 | } 15 | ` 16 | const resolvers = { 17 | Query: { 18 | add: async (_, obj) => { 19 | const { x, y } = obj 20 | return x + y 21 | } 22 | } 23 | } 24 | 25 | app.register(mercurius, { 26 | context: buildContext, 27 | schema, 28 | resolvers, 29 | validationRules: process.env.NODE_ENV === 'production' && [NoSchemaIntrospectionCustomRule], 30 | }); 31 | ``` 32 | 33 | ## Execute against different schemas based on request headers 34 | 35 | Sometimes we may face the need to present a scheme that varies depending on specific situations. 36 | To accomplish this need we can use one powerful fastify/find-my-way feature called **Custom Constraints**. 37 | 38 | https://www.fastify.io/docs/latest/Reference/Routes/#asynchronous-custom-constraints 39 | 40 | > Fastify supports constraining routes to match only certain requests based on some property of the request, like the Host header, or any other value via find-my-way constraints. 41 | 42 | We can then create two mercurius instances that expose the two different schemas and use the constraint on the header to drive the request to one or other mercurius instance. 43 | 44 | ### 1. Create the constraint and initialize the fastify instance 45 | ```js 46 | const Fastify = require('fastify') 47 | const mercurius = require('..') 48 | 49 | // Define the constraint custom strategy 50 | const schemaStrategy = { 51 | name: 'schema', 52 | storage: function () { 53 | const handlers = {} 54 | return { 55 | get: (type) => { return handlers[type] || null }, 56 | set: (type, store) => { handlers[type] = store } 57 | } 58 | }, 59 | deriveConstraint: (req, ctx) => { 60 | return req.headers.schema 61 | }, 62 | validate: () => true, 63 | mustMatchWhenDerived: true 64 | } 65 | 66 | // Initialize fastify 67 | const app = Fastify({ constraints: { schema: schemaStrategy } }) 68 | ``` 69 | ### 2. Initialize the first mercurius instance and bind it to the `/` route only if the `schema` header value is equal to `A` 70 | 71 | ```js 72 | const schema = ` 73 | type Query { 74 | add(x: Int, y: Int): Int 75 | } 76 | ` 77 | 78 | const resolvers = { 79 | Query: { 80 | add: async (_, obj) => { 81 | const { x, y } = obj 82 | return x + y 83 | } 84 | } 85 | } 86 | 87 | // Schema A registration with A constraint 88 | app.register(async childServer => { 89 | childServer.register(mercurius, { 90 | schema, 91 | resolvers, 92 | graphiql: false, 93 | routes: false 94 | }) 95 | 96 | childServer.route({ 97 | path: '/', 98 | method: 'POST', 99 | constraints: { schema: 'A' }, 100 | handler: (req, reply) => { 101 | const query = req.body 102 | return reply.graphql(query) 103 | } 104 | }) 105 | }) 106 | ``` 107 | ### 3. Initialize the second mercurius instance and bind it to the `/` route only if the `schema` header value is equal to `B` 108 | 109 | ```js 110 | const schema2 = ` 111 | type Query { 112 | subtract(x: Int, y: Int): Int 113 | } 114 | ` 115 | 116 | const resolvers2 = { 117 | Query: { 118 | subtract: async (_, obj) => { 119 | const { x, y } = obj 120 | return x - y 121 | } 122 | } 123 | } 124 | 125 | app.register(async childServer => { 126 | childServer.register(mercurius, { 127 | schema: schema2, 128 | resolvers: resolvers2, 129 | graphiql: false, 130 | routes: false 131 | }) 132 | 133 | childServer.route({ 134 | path: '/', 135 | method: 'POST', 136 | constraints: { schema: 'B' }, 137 | handler: (req, reply) => { 138 | const query = req.body 139 | return reply.graphql(query) 140 | } 141 | }) 142 | }) 143 | ``` 144 | 145 | 4. Start the fastify server 146 | 147 | ```js 148 | app.listen({ port: 3000 }) 149 | ``` 150 | 151 | ### Important notes: 152 | 153 | In order to use graphql in constrained routes we need to set mercurius `routes` parameter to `false` in order to avoid that both the mercurius instances try to expose themself at `/graphql`. 154 | -------------------------------------------------------------------------------- /test/reply-decorator.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const Fastify = require('fastify') 5 | const GQL = require('..') 6 | 7 | test('reply decorator', async (t) => { 8 | const app = Fastify() 9 | const schema = ` 10 | type Query { 11 | add(x: Int, y: Int): Int 12 | } 13 | ` 14 | t.after(() => app.close()) 15 | 16 | const resolvers = { 17 | add: async ({ x, y }) => x + y 18 | } 19 | 20 | app.register(GQL, { 21 | schema, 22 | resolvers 23 | }) 24 | 25 | app.get('/', async function (req, reply) { 26 | const query = '{ add(x: 2, y: 2) }' 27 | return reply.graphql(query) 28 | }) 29 | 30 | const res = await app.inject({ 31 | method: 'GET', 32 | url: '/' 33 | }) 34 | 35 | t.assert.deepStrictEqual(JSON.parse(res.body), { 36 | data: { 37 | add: 4 38 | } 39 | }) 40 | }) 41 | 42 | test('reply decorator operationName', async (t) => { 43 | const app = Fastify() 44 | t.after(() => app.close()) 45 | const schema = ` 46 | type Query { 47 | add(x: Int, y: Int): Int 48 | } 49 | ` 50 | 51 | const resolvers = { 52 | add: async ({ x, y }) => x + y 53 | } 54 | 55 | app.register(GQL, { 56 | schema, 57 | resolvers 58 | }) 59 | 60 | app.get('/', async function (req, reply) { 61 | const query = ` 62 | query MyQuery ($x: Int!, $y: Int!) { 63 | add(x: $x, y: $y) 64 | } 65 | 66 | query Double ($x: Int!) { 67 | add(x: $x, y: $x) 68 | } 69 | ` 70 | return reply.graphql(query, null, { 71 | x: 2, 72 | y: 1 // useless but we need it verify we call Double 73 | }, 'Double') 74 | }) 75 | 76 | const res = await app.inject({ 77 | method: 'GET', 78 | url: '/' 79 | }) 80 | 81 | t.assert.deepStrictEqual(JSON.parse(res.body), { 82 | data: { 83 | add: 4 84 | } 85 | }) 86 | }) 87 | 88 | test('reply decorator set status code to 400 with bad query', async (t) => { 89 | t.plan(3) 90 | 91 | const app = Fastify() 92 | t.after(() => app.close()) 93 | const schema = ` 94 | type Query { 95 | add(x: Int, y: Int): Int 96 | } 97 | ` 98 | 99 | const resolvers = { 100 | add: async ({ x, y }) => x + y 101 | } 102 | 103 | app.register(GQL, { 104 | schema, 105 | resolvers 106 | }) 107 | 108 | app.setErrorHandler(async function (err, request, reply) { 109 | reply.code(err.statusCode) 110 | t.assert.strictEqual(err.statusCode, 400) 111 | return { errors: err.errors } 112 | }) 113 | 114 | app.get('/', function (req, reply) { 115 | const query = '{ add(x: 2, y: 2)' 116 | return reply.graphql(query) 117 | }) 118 | 119 | const res = await app.inject({ 120 | method: 'GET', 121 | url: '/' 122 | }) 123 | 124 | t.assert.strictEqual(res.statusCode, 400) 125 | t.assert.deepStrictEqual(res.json(), { 126 | errors: [ 127 | { 128 | message: 'Syntax Error: Expected Name, found .', 129 | locations: [ 130 | { 131 | line: 1, 132 | column: 18 133 | } 134 | ] 135 | } 136 | ] 137 | 138 | }) 139 | }) 140 | 141 | test('reply decorator supports encapsulation when loaders are defined in parent object', async (t) => { 142 | const app = Fastify() 143 | t.after(() => app.close()) 144 | const schema = ` 145 | type Query { 146 | add(x: Int, y: Int): Int 147 | } 148 | ` 149 | const resolvers = { 150 | add: async ({ x, y }) => x + y 151 | } 152 | 153 | app.register(GQL, { 154 | schema, 155 | resolvers, 156 | loaders: {} 157 | }) 158 | 159 | app.register(async (app) => { 160 | const schema = ` 161 | type Query { 162 | multiply(x: Int, y: Int): Int 163 | } 164 | ` 165 | const resolvers = { 166 | multiply: async ({ x, y }) => x * y 167 | } 168 | 169 | app.register(GQL, { 170 | schema, 171 | resolvers, 172 | prefix: '/prefix' 173 | }) 174 | }) 175 | 176 | const res = await app.inject({ 177 | method: 'POST', 178 | url: '/prefix/graphql', 179 | payload: { 180 | query: '{ multiply(x: 5, y: 5) }' 181 | } 182 | }) 183 | 184 | t.assert.strictEqual(res.statusCode, 200) 185 | t.assert.deepStrictEqual(JSON.parse(res.body), { 186 | data: { 187 | multiply: 25 188 | } 189 | }) 190 | 191 | t.assert.deepStrictEqual(res.json(), { 192 | data: { 193 | multiply: 25 194 | } 195 | }) 196 | }) 197 | -------------------------------------------------------------------------------- /examples/gateway.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const Fastify = require('fastify') 3 | const mercuriusWithFederation = require('@mercuriusjs/federation') 4 | const mercuriusWithGateway = require('@mercuriusjs/gateway') 5 | const mercurius = require('..') 6 | const { ErrorWithProps } = mercurius 7 | 8 | async function createService (port, schema, resolvers = {}) { 9 | const service = Fastify() 10 | 11 | service.register(mercuriusWithFederation, { 12 | schema, 13 | resolvers, 14 | graphiql: true, 15 | jit: 1 16 | }) 17 | await service.listen(port) 18 | } 19 | 20 | const users = { 21 | u1: { 22 | id: 'u1', 23 | name: 'John' 24 | }, 25 | u2: { 26 | id: 'u2', 27 | name: 'Jane' 28 | }, 29 | u3: { 30 | id: 'u3', 31 | name: 'Jack' 32 | } 33 | } 34 | 35 | const posts = { 36 | p1: { 37 | pid: 'p1', 38 | title: 'Post 1', 39 | content: 'Content 1', 40 | authorId: 'u1' 41 | }, 42 | p2: { 43 | pid: 'p2', 44 | title: 'Post 2', 45 | content: 'Content 2', 46 | authorId: 'u2' 47 | }, 48 | p3: { 49 | pid: 'p3', 50 | title: 'Post 3', 51 | content: 'Content 3', 52 | authorId: 'u1' 53 | }, 54 | p4: { 55 | pid: 'p4', 56 | title: 'Post 4', 57 | content: 'Content 4', 58 | authorId: 'u2' 59 | } 60 | } 61 | 62 | async function start () { 63 | await createService(4001, ` 64 | extend type Query { 65 | me: User 66 | you: User 67 | hello: String 68 | } 69 | 70 | type User @key(fields: "id") { 71 | id: ID! 72 | name: String! 73 | fullName: String 74 | avatar(size: AvatarSize): String 75 | friends: [User] 76 | } 77 | 78 | enum AvatarSize { 79 | small 80 | medium 81 | large 82 | } 83 | `, { 84 | Query: { 85 | me: (root, args, context, info) => { 86 | return users.u1 87 | }, 88 | you: (root, args, context, info) => { 89 | throw new ErrorWithProps('Can\'t fetch other users data', { code: 'NOT_ALLOWED' }) 90 | }, 91 | hello: () => 'world' 92 | }, 93 | User: { 94 | __resolveReference: (user, args, context, info) => { 95 | return users[user.id] 96 | }, 97 | avatar: (user, { size }) => `avatar-${size}.jpg`, 98 | friends: (user) => Object.values(users).filter(u => u.id !== user.id), 99 | fullName: (user) => user.name + ' Doe' 100 | } 101 | }) 102 | 103 | await createService(4002, ` 104 | type Post @key(fields: "pid") { 105 | pid: ID! 106 | title: String 107 | content: String 108 | author: User @requires(fields: "pid title") 109 | } 110 | 111 | type Query @extends { 112 | topPosts(count: Int): [Post] 113 | } 114 | 115 | type User @key(fields: "id") @extends { 116 | id: ID! @external 117 | name: String @external 118 | posts: [Post] 119 | numberOfPosts: Int @requires(fields: "id name") 120 | } 121 | 122 | extend type Mutation { 123 | createPost(post: PostInput!): Post 124 | updateHello: String 125 | } 126 | 127 | input PostInput { 128 | title: String! 129 | content: String! 130 | authorId: String! 131 | } 132 | `, { 133 | Post: { 134 | __resolveReference: (post, args, context, info) => { 135 | return posts[post.pid] 136 | }, 137 | author: (post, args, context, info) => { 138 | return { 139 | __typename: 'User', 140 | id: post.authorId 141 | } 142 | } 143 | }, 144 | User: { 145 | posts: (user, args, context, info) => { 146 | return Object.values(posts).filter(p => p.authorId === user.id) 147 | }, 148 | numberOfPosts: (user) => { 149 | return Object.values(posts).filter(p => p.authorId === user.id).length 150 | } 151 | }, 152 | Query: { 153 | topPosts: (root, { count = 2 }) => Object.values(posts).slice(0, count) 154 | }, 155 | Mutation: { 156 | createPost: (root, { post }) => { 157 | const pid = `p${Object.values(posts).length + 1}` 158 | 159 | const result = { 160 | pid, 161 | ...post 162 | } 163 | posts[pid] = result 164 | 165 | return result 166 | }, 167 | updateHello: () => 'World' 168 | } 169 | }) 170 | 171 | const gateway = Fastify() 172 | gateway.register(mercuriusWithGateway, { 173 | graphiql: true, 174 | jit: 1, 175 | gateway: { 176 | services: [{ 177 | name: 'user', 178 | url: 'http://localhost:4001/graphql', 179 | setResponseHeaders: (reply) => { 180 | reply.header('abc', 'abc') 181 | } 182 | }, { 183 | name: 'post', 184 | url: 'http://localhost:4002/graphql' 185 | }] 186 | } 187 | }) 188 | 189 | await gateway.listen({ port: 4000 }) 190 | } 191 | 192 | start() 193 | -------------------------------------------------------------------------------- /examples/graphiql-plugin/README.md: -------------------------------------------------------------------------------- 1 | # GraphiQL custom plugin 2 | 3 | ## Quick start 4 | 5 | Execute the local `index.js` app to try the GraphiQL plugin integration. 6 | 7 | ```javascript 8 | // examples/graphiql-plugin 9 | node ./index.js 10 | ``` 11 | 12 | ## Create the plugin 13 | 14 | You can easily create a GraphiQL plugin and integrate it in Mercurius GraphiQL instance. 15 | 16 | * [GraphiQL.](https://github.com/graphql/graphiql) 17 | * [GraphiQL Explorer Plugin example.](https://github.com/graphql/graphiql/tree/main/packages/graphiql-plugin-explorer) 18 | 19 | ### Plugin component 20 | 21 | A GraphiQL plugin is an object that contains these properties: 22 | 23 | * `title`: string. The title of the plugin 24 | * `icon`: React component. The icon shown in the toolbar 25 | * `content`: React component with the plugin implementation 26 | 27 | It can be created using the sample: 28 | 29 | ```javascript 30 | import React from 'react' 31 | 32 | function Content() { 33 | return ( 34 |
35 |
This is a sample plugin
36 |
37 | ) 38 | } 39 | 40 | function Icon() { 41 | return

P

42 | } 43 | 44 | /* 45 | * Enrich, extract or modify the data returned by the GraphiQL fetcher. 46 | * 47 | * Q: Why do I need to intercept the data? 48 | * A: GraphiQL do not provide a direct access to the fetched data. 49 | * The data are fetched and injected directly in the viewer in a stringified format. 50 | * 51 | * To provide a way to access the fetched data a similar function can implemented and passed 52 | * to the plugin in the attribute `fetcherWrapper` of the configuration. 53 | * 54 | * { 55 | * name: '...', 56 | * props: ..., 57 | * umdUrl: '...', 58 | * fetcherWrapper: 'parseFetchResponse' 59 | * } 60 | */ 61 | export function parseFetchResponse(data) { 62 | if (data) { 63 | // Eg. storeDataSomewhere(data) 64 | // Eg. addInfoToData(data) 65 | // Eg. removeAttributeFromData(data) 66 | } 67 | return data 68 | } 69 | 70 | export function graphiqlSamplePlugin(props) { 71 | return { 72 | title: props.title || 'GraphiQL Sample', 73 | icon: () => , 74 | content: () => { 75 | return 76 | } 77 | } 78 | } 79 | 80 | // This is required for the Mercurius integration 81 | export function umdPlugin(props) { 82 | return graphiqlSamplePlugin(props) 83 | } 84 | ``` 85 | 86 | ### Export as Umd 87 | 88 | ```javascript 89 | import resolve from '@rollup/plugin-node-resolve' 90 | import commonjs from '@rollup/plugin-commonjs' 91 | import jsx from 'rollup-plugin-jsx' 92 | 93 | const packageJson = require('./package.json') 94 | 95 | const rollup = [ 96 | { 97 | input: 'src/export.js', // path to the plugin entry point 98 | output: [ 99 | { 100 | file: packageJson.main, 101 | format: 'cjs', 102 | sourcemap: true 103 | }, 104 | { 105 | file: packageJson.module, 106 | format: 'esm', 107 | sourcemap: true 108 | }, 109 | { 110 | file: packageJson.umd, 111 | format: 'umd', 112 | sourcemap: true, 113 | name: 'mercuriusPluginSample' 114 | } 115 | ], 116 | external: ['react', '@graphiql/toolkit'], 117 | plugins: [ 118 | resolve({ 119 | extensions: ['.js', '.jsx'] 120 | }), 121 | svgr(), 122 | commonjs(), 123 | jsx({ factory: 'React.createElement' }) 124 | ] 125 | } 126 | ] 127 | 128 | export default rollup 129 | ``` 130 | 131 | Check the [plugin-sources](./plugin-sources) folder for a complete example. 132 | 133 | ### Serve the plugin 134 | 135 | To work with `Mercurius` the plugin should be available using a GET request. 136 | 137 | The preferred approach is to deploy the package on a public CDN like [unpkg.com](https://unpkg.com/). 138 | 139 | Alternatively it can be served by the local fastify. 140 | 141 | ```javascript 142 | app.get('/graphiql/samplePlugin.js', (req, reply) => { 143 | reply.sendFile('samplePlugin.js') 144 | }) 145 | ``` 146 | 147 | ## Add the plugin to Mercurius 148 | 149 | In the configuration file add the plugin in the `graphiql` parameter 150 | 151 | ```javascript 152 | app.register(mercurius, { 153 | schema, 154 | resolvers, 155 | graphiql: { 156 | enabled: true, 157 | plugins: [ 158 | { 159 | name: 'mercuriusPluginSample', 160 | props: {title: 'Sample plugin'}, 161 | umdUrl: 'http://localhost:3000/graphiql/samplePlugin.js', 162 | fetcherWrapper: 'parseFetchResponse' 163 | } 164 | ] 165 | } 166 | }) 167 | ``` 168 | 169 | * `name`, string. The same used in the `rollup` export `output(format === umd).name` 170 | * `props`, object. The props to be passed to the plugin 171 | * `umdUrl`, string. The url of the static `umd` file 172 | * `fetcherWrapper`, function. The name of an exported function that intercept the result from the fetch. 173 | -------------------------------------------------------------------------------- /docs/typescript.md: -------------------------------------------------------------------------------- 1 | # TypeScript usage 2 | 3 | > Complete example are available in [https://github.com/mercurius-js/mercurius-typescript](https://github.com/mercurius-js/mercurius-typescript). 4 | 5 | Mercurius has included type definitions, that you can use in your projects manually if you wish, but you can also use [mercurius-codegen](https://github.com/mercurius-js/mercurius-typescript/tree/master/packages/mercurius-codegen), which is designed to improve the TypeScript experience using [GraphQL Code Generator](https://graphql-code-generator.com/) seamlessly while you code, but this documentation will show you how to use both. 6 | 7 | ## Codegen 8 | 9 | Install [mercurius-codegen](https://github.com/mercurius-js/mercurius-typescript/tree/master/packages/mercurius-codegen): 10 | 11 | ```bash 12 | npm install mercurius-codegen 13 | # or your preferred package manager 14 | ``` 15 | 16 | Then in your code 17 | 18 | ```ts 19 | import Fastify, { FastifyRequest, FastifyReply } from 'fastify' 20 | import mercurius, { IResolvers } from 'mercurius' 21 | import mercuriusCodegen, { gql } from 'mercurius-codegen' 22 | 23 | const app = Fastify() 24 | 25 | const buildContext = async (req: FastifyRequest, _reply: FastifyReply) => { 26 | return { 27 | authorization: req.headers.authorization 28 | } 29 | } 30 | 31 | type PromiseType = T extends PromiseLike ? U : T 32 | 33 | declare module 'mercurius' { 34 | interface MercuriusContext extends PromiseType> {} 35 | } 36 | 37 | // Using the fake "gql" from mercurius-codegen gives tooling support for 38 | // "prettier formatting" and "IDE syntax highlighting". 39 | // It's optional 40 | const schema = gql` 41 | type Query { 42 | hello(name: String!): String! 43 | } 44 | ` 45 | 46 | const resolvers: IResolvers = { 47 | Query: { 48 | hello(root, { name }, ctx, info) { 49 | // root ~ {} 50 | // name ~ string 51 | // ctx.authorization ~ string | undefined 52 | // info ~ GraphQLResolveInfo 53 | return 'hello ' + name 54 | } 55 | } 56 | } 57 | 58 | app.register(mercurius, { 59 | schema, 60 | resolvers, 61 | context: buildContext 62 | }) 63 | 64 | mercuriusCodegen(app, { 65 | // Commonly relative to your root package.json 66 | targetPath: './src/graphql/generated.ts' 67 | }).catch(console.error) 68 | ``` 69 | 70 | Then automatically while you code the types are going to be generated and give you type-safety and auto-completion. 71 | 72 | You can check the more detailed documentation [here](https://github.com/mercurius-js/mercurius-typescript/tree/master/packages/mercurius-codegen) and two complete examples using GraphQL Operations, [Loaders](/docs/loaders.md), [Subscriptions](/docs/subscriptions.md), and [Full integration testing](/docs/integrations/mercurius-integration-testing.md) in [mercurius-typescript/examples/codegen](https://github.com/mercurius-js/mercurius-typescript/tree/master/examples/codegen), and an even further example that uses `.gql` files to make your GraphQL Schema in [**mercurius-typescript/examples/codegen-gql-files**](https://github.com/mercurius-js/mercurius-typescript/tree/master/examples/codegen-gql-files). 73 | 74 | ## Manually typing 75 | 76 | You can also use the included types with mercurius in your API 77 | 78 | ```ts 79 | import Fastify, { FastifyReply, FastifyRequest } from 'fastify' 80 | import mercurius, { 81 | IFieldResolver, 82 | IResolvers, 83 | MercuriusContext, 84 | MercuriusLoaders 85 | } from 'mercurius' 86 | 87 | export const app = Fastify() 88 | 89 | const buildContext = async (req: FastifyRequest, _reply: FastifyReply) => { 90 | return { 91 | authorization: req.headers.authorization 92 | } 93 | } 94 | 95 | type PromiseType = T extends PromiseLike ? U : T 96 | 97 | declare module 'mercurius' { 98 | interface MercuriusContext extends PromiseType> {} 99 | } 100 | 101 | const schema = ` 102 | type Query { 103 | helloTyped: String! 104 | helloInline: String! 105 | } 106 | ` 107 | 108 | const helloTyped: IFieldResolver< 109 | {} /** Root */, 110 | MercuriusContext /** Context */, 111 | {} /** Args */ 112 | > = (root, args, ctx, info) => { 113 | // root ~ {} 114 | root 115 | // args ~ {} 116 | args 117 | // ctx.authorization ~ string | undefined 118 | ctx.authorization 119 | // info ~ GraphQLResolveInfo 120 | info 121 | 122 | return 'world' 123 | } 124 | 125 | const resolvers: IResolvers = { 126 | Query: { 127 | helloTyped, 128 | helloInline: (root: {}, args: {}, ctx, info) => { 129 | // root ~ {} 130 | root 131 | // args ~ {} 132 | args 133 | // ctx.authorization ~ string | undefined 134 | ctx.authorization 135 | // info ~ GraphQLResolveInfo 136 | info 137 | 138 | return 'world' 139 | } 140 | } 141 | } 142 | 143 | app.register(mercurius, { 144 | schema, 145 | resolvers, 146 | context: buildContext 147 | }) 148 | ``` 149 | 150 | You can check [**mercurius-typescript/examples/manual**](https://github.com/mercurius-js/mercurius-typescript/tree/master/examples/manual) for more detailed usage, using [Loaders](/docs/loaders.md), [Subscriptions](/docs/subscriptions.md) and [Full integration testing](/docs/integrations/mercurius-integration-testing.md) 151 | -------------------------------------------------------------------------------- /docs/integrations/type-graphql.md: -------------------------------------------------------------------------------- 1 | # Integrating TypeGraphQL with Mercurius 2 | 3 | You can easily use [TypeGraphQL](https://github.com/MichalLytek/type-graphql) in combination with Mercurius. 4 | This allows you to follow a code first approach instead of the SDL first. 5 | 6 | ## Installation 7 | 8 | ```bash 9 | npm install --save type-graphql graphql reflect-metadata 10 | ``` 11 | 12 | Now you can define a schema using classes and decorators: 13 | 14 | ```ts 15 | // recipe.ts 16 | import { Arg, Field, ObjectType, Int, Float, Resolver, Query } from "type-graphql"; 17 | 18 | @ObjectType({ description: "Object representing cooking recipe" }) 19 | export class Recipe { 20 | @Field() 21 | title: string; 22 | 23 | @Field((type) => String, { 24 | nullable: true, 25 | deprecationReason: "Use `description` field instead", 26 | }) 27 | get specification(): string | undefined { 28 | return this.description; 29 | } 30 | 31 | @Field({ 32 | nullable: true, 33 | description: "The recipe description with preparation info", 34 | }) 35 | description?: string; 36 | 37 | @Field((type) => [Int]) 38 | ratings: number[]; 39 | 40 | @Field() 41 | creationDate: Date; 42 | } 43 | 44 | @Resolver() 45 | export class RecipeResolver { 46 | @Query((returns) => Recipe, { nullable: true }) 47 | async recipe(@Arg("title") title: string): Promise | undefined> { 48 | return { 49 | description: "Desc 1", 50 | title: title, 51 | ratings: [0, 3, 1], 52 | creationDate: new Date("2018-04-11"), 53 | }; 54 | } 55 | } 56 | ``` 57 | 58 | This can be linked to the Mercurius plugin: 59 | 60 | ```ts 61 | // index.ts 62 | import "reflect-metadata"; 63 | import fastify, {FastifyRegisterOptions} from "fastify"; 64 | import mercurius, {MercuriusOptions} from "mercurius"; 65 | import { buildSchema } from 'type-graphql' 66 | 67 | import { RecipeResolver } from "./recipe"; 68 | 69 | async function main() { 70 | // build TypeGraphQL executable schema 71 | const schema = await buildSchema({ 72 | resolvers: [RecipeResolver], 73 | }); 74 | 75 | const app = fastify(); 76 | 77 | const opts: FastifyRegisterOptions = { 78 | schema, 79 | graphiql: true 80 | } 81 | app.register(mercurius, opts); 82 | 83 | app.get("/", async (req, reply) => { 84 | const query = `{ 85 | recipe(title: "Recipe 1") { 86 | title 87 | description 88 | ratings 89 | creationDate 90 | } 91 | }`; 92 | return reply.graphql(query); 93 | }); 94 | 95 | app.listen({ port: 3000 }); 96 | } 97 | 98 | main().catch(console.error); 99 | ``` 100 | 101 | If you run this, you will get a GraphQL API based on your code: 102 | 103 | ```bash 104 | ts-node index.ts 105 | ``` 106 | 107 | ## Class validators 108 | 109 | One of the features of `type-graphql` is ability to add validation rules using decorators. Let's say we want to add 110 | a mutation with some simple validation rules for its input. First we need to define the class for the input: 111 | 112 | ```ts 113 | @InputType() 114 | export class RecipeInput { 115 | @Field() 116 | @MaxLength(30) 117 | title: string; 118 | 119 | @Field({ nullable: true }) 120 | @Length(30, 255) 121 | description?: string; 122 | } 123 | ``` 124 | 125 | Then add a method in the `RecipeResolver` that would serve as a mutation implementation: 126 | 127 | ```ts 128 | @Mutation(returns => Recipe) 129 | async addRecipe(@Arg("input") recipeInput: RecipeInput): Promise { 130 | const recipe = new Recipe(); 131 | recipe.description = recipeInput.description; 132 | recipe.title = recipeInput.title; 133 | recipe.creationDate = new Date(); 134 | return recipe; 135 | } 136 | ``` 137 | 138 | Now, here we can run into a problem. Getting the details of validation errors can get confusing. Normally, the default 139 | error formatter of `mercurius` will handle the error, log them and carry over the details to the response of API call. 140 | The problem is that validation errors coming from `type-graphql` are stored in `originalError` field (in contrast to 141 | the `extensions` field, which was designed to be carrying such data) of `GraphQLError` object, which is a non-enumerable 142 | property (meaning it won't get serialized/logged). 143 | 144 | An easy workaround would be to copy the validation details from `originalError` to `extensions` field using custom error 145 | formatter. The problem is that in GraphQLError's constructor method, if the extensions are empty initially, then this 146 | field is being marked as a non-enumerable as well. To work this problem around you could do something like this: 147 | 148 | ```ts 149 | const app = fastify({ logger: { level: 'info' } }); 150 | const opts: FastifyRegisterOptions = { 151 | schema, 152 | graphiql: true, 153 | errorFormatter: (executionResult, context) => { 154 | const log = context.reply ? context.reply.log : context.app.log; 155 | const errors = executionResult.errors.map((error) => { 156 | error.extensions.exception = error.originalError; 157 | Object.defineProperty(error, 'extensions', {enumerable: true}); 158 | return error; 159 | }); 160 | log.info({ err: executionResult.errors }, 'Argument Validation Error'); 161 | return { 162 | statusCode: 201, 163 | response: { 164 | data: executionResult.data, 165 | errors 166 | } 167 | } 168 | } 169 | } 170 | app.register(mercurius, opts); 171 | ``` -------------------------------------------------------------------------------- /docs/plugins.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | 3 | Related plugins for mercurius 4 | 5 | - [mercurius-auth](#mercurius-auth) 6 | - [mercurius-cache](#mercurius-cache) 7 | - [mercurius-validation](#mercurius-validation) 8 | - [mercurius-upload](#mercurius-upload) 9 | - [altair-fastify-plugin](#altair-fastify-plugin) 10 | - [mercurius-apollo-registry](#mercurius-apollo-registry) 11 | - [mercurius-apollo-tracing](#mercurius-apollo-tracing) 12 | - [mercurius-postgraphile](#mercurius-postgraphile) 13 | - [mercurius-logging](#mercurius-logging) 14 | - [mercurius-fetch](#mercurius-fetch) 15 | - [mercurius-hit-map](#mercurius-hit-map) 16 | 17 | ## mercurius-auth 18 | 19 | Mercurius Auth is a plugin for [Mercurius](https://mercurius.dev) that adds configurable Authentication and Authorization support. 20 | 21 | Check the [`mercurius-auth` documentation](https://github.com/mercurius-js/auth) for detailed usage. 22 | 23 | ## mercurius-cache 24 | 25 | Mercurius Cache is a plugin for [Mercurius](https://mercurius.dev) that caches the results of your GraphQL resolvers, for Mercurius. 26 | 27 | Check the [`mercurius-cache` documentation](https://github.com/mercurius-js/cache) for detailed usage. 28 | 29 | ## mercurius-validation 30 | 31 | Mercurius Validation is a plugin for [Mercurius](https://mercurius.dev) that adds configurable validation support. 32 | 33 | Check the [`mercurius-validation` documentation](https://github.com/mercurius-js/validation) for detailed usage. 34 | 35 | ## mercurius-upload 36 | 37 | Implementation of [graphql-upload](https://github.com/jaydenseric/graphql-upload) for File upload support. 38 | 39 | Check [https://github.com/mercurius-js/mercurius-upload](https://github.com/mercurius-js/mercurius-upload) for detailed usage. 40 | 41 | ## altair-fastify-plugin 42 | 43 | [**Altair**](https://altairgraphql.dev/) plugin. Fully featured GraphQL Client IDE, good alternative of `graphiql`. 44 | 45 | ```bash 46 | npm install altair-fastify-plugin 47 | ``` 48 | 49 | ```js 50 | const AltairFastify = require('altair-fastify-plugin') 51 | // ... 52 | const app = Fastify() 53 | 54 | app.register(mercurius, { 55 | // ... 56 | graphiql: false, 57 | ide: false, 58 | path: '/graphql' 59 | }) 60 | // ... 61 | app.register(AltairFastify, { 62 | path: '/altair', 63 | baseURL: '/altair/', 64 | // 'endpointURL' should be the same as the mercurius 'path' 65 | endpointURL: '/graphql' 66 | }) 67 | 68 | app.listen({ port: 3000 }) 69 | ``` 70 | 71 | And it will be available at `http://localhost:3000/altair` 🎉 72 | 73 | Check [here](https://github.com/imolorhe/altair/tree/staging/packages/altair-fastify-plugin) for more information. 74 | 75 | ## mercurius-apollo-registry 76 | 77 | A Mercurius plugin for schema reporting to Apollo Studio. 78 | 79 | Check [https://github.com/nearform/mercurius-apollo-registry](https://github.com/nearform/mercurius-apollo-registry) for usage and readme. 80 | 81 | ```bash 82 | npm install mercurius-apollo-registry 83 | ``` 84 | 85 | ```js 86 | const app = Fastify() 87 | const mercurius = require('mercurius') 88 | const mercuriusApolloRegistry = require('mercurius-apollo-registry') 89 | 90 | const schema = `define schema here` 91 | const resolvers = { 92 | // ... 93 | } 94 | 95 | app.register(mercurius, { 96 | schema, 97 | resolvers, 98 | graphiql: true 99 | }) 100 | 101 | app.register(mercuriusApolloRegistry, { 102 | schema, 103 | apiKey: 'REPLACE-THIS-VALUE-WITH-APOLLO-API-KEY' 104 | }) 105 | 106 | app.listen({ port: 3000 }) 107 | ``` 108 | 109 | ## mercurius-apollo-tracing 110 | 111 | A Mercurius plugin for reporting performance metrics and errors to Apollo Studio. 112 | 113 | ```bash 114 | npm install mercurius-apollo-tracing 115 | ``` 116 | 117 | ```js 118 | const mercuriusTracing = require('mercurius-apollo-tracing') 119 | 120 | app.register(mercuriusTracing, { 121 | apiKey: 'REPLACE-THIS-VALUE-WITH-APOLLO-API-KEY', // replace with the one from apollo studio 122 | graphRef: 'yourGraph@ref' // replace 'yourGraph@ref'' with the one from apollo studio 123 | }) 124 | ``` 125 | 126 | ## mercurius-postgraphile 127 | A Mercurius plugin for integrating PostGraphile schemas with Mercurius 128 | 129 | Check [https://github.com/autotelic/mercurius-postgraphile](https://github.com/autotelic/mercurius-postgraphile) for usage and readme. 130 | 131 | ## mercurius-logging 132 | A Mercurius plugin to enhance the GQL request logging adding useful insights: 133 | 134 | ```json 135 | { 136 | "level": 30, 137 | "time": 1660395516406, 138 | "hostname": "eomm", 139 | "reqId": "req-1", 140 | "graphql": { 141 | "queries": [ 142 | "firstQuery:myTeam", 143 | "secondQuery:myTeam" 144 | ] 145 | } 146 | } 147 | ``` 148 | 149 | Check the [`mercurius-logging`](https://github.com/Eomm/mercurius-logging) documentation for usage and settings. 150 | 151 | ## mercurius-fetch 152 | Mercurius Fetch is a plugin for [Mercurius](https://mercurius.dev) that adds fetch to a rest api directly on query or properties of query. 153 | 154 | Check the [`mercurius-fetch` documentation](https://github.com/rbonillajr/mercurius-fetch) for detailed usage. 155 | 156 | ## mercurius-hit-map 157 | A Mercurius plugin to count how many times the application's resolvers are executed by the clients. 158 | 159 | ```js 160 | const app = Fastify() 161 | app.register(mercurius, { 162 | schema, 163 | resolvers 164 | }) 165 | 166 | app.register(require('mercurius-hit-map')) 167 | 168 | app.get('/hit', async () => { 169 | const hitMap = await app.getHitMap() 170 | return hitMap 171 | }) 172 | ``` 173 | 174 | Check the [`mercurius-hit-map`](https://github.com/Eomm/mercurius-hit-map) documentation for usage and settings. 175 | -------------------------------------------------------------------------------- /test/validation-rules.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const Fastify = require('fastify') 5 | const GQL = require('..') 6 | 7 | const schema = ` 8 | type Query { 9 | add(x: Int, y: Int): Int 10 | } 11 | ` 12 | 13 | const resolvers = { 14 | Query: { 15 | add: async (_, obj) => { 16 | const { x, y } = obj 17 | return x + y 18 | } 19 | } 20 | } 21 | 22 | const query = '{ add(x: 2, y: 2) }' 23 | 24 | test('validationRules array - reports an error', async (t) => { 25 | const app = Fastify() 26 | 27 | app.register(GQL, { 28 | schema, 29 | resolvers, 30 | validationRules: [ 31 | // validation rule that reports an error 32 | function (context) { 33 | return { 34 | Document () { 35 | context.reportError({ message: 'Validation rule error' }) 36 | } 37 | } 38 | } 39 | ] 40 | }) 41 | 42 | // needed so that graphql is defined 43 | await app.ready() 44 | await t.assert.rejects(app.graphql(query), { errors: [{ message: 'Validation rule error' }] }) 45 | }) 46 | 47 | test('validationRules array - passes when no errors', async (t) => { 48 | t.plan(1) 49 | const app = Fastify() 50 | 51 | app.register(GQL, { 52 | schema, 53 | resolvers, 54 | validationRules: [ 55 | // validation rule that reports no errors 56 | function (_context) { 57 | return { 58 | Document () { 59 | return false 60 | } 61 | } 62 | } 63 | ] 64 | }) 65 | 66 | // needed so that graphql is defined 67 | await app.ready() 68 | 69 | const res = await app.graphql(query) 70 | t.assert.deepStrictEqual(res.data.add, 4) 71 | }) 72 | 73 | test('validationRules array - works with empty validationRules', async (t) => { 74 | t.plan(1) 75 | const app = Fastify() 76 | 77 | app.register(GQL, { 78 | schema, 79 | resolvers, 80 | validationRules: [] 81 | }) 82 | 83 | // needed so that graphql is defined 84 | await app.ready() 85 | 86 | const res = await app.graphql(query) 87 | t.assert.deepStrictEqual(res.data.add, 4) 88 | }) 89 | 90 | test('validationRules - reports an error', async (t) => { 91 | const app = Fastify() 92 | 93 | app.register(GQL, { 94 | schema, 95 | resolvers, 96 | cache: false, 97 | validationRules: () => [ 98 | // validation rule that reports an error 99 | function (context) { 100 | return { 101 | Document () { 102 | context.reportError({ message: 'Validation rule error' }) 103 | } 104 | } 105 | } 106 | ] 107 | }) 108 | 109 | // needed so that graphql is defined 110 | await app.ready() 111 | await t.assert.rejects(app.graphql(query), { errors: [{ message: 'Validation rule error' }] }) 112 | }) 113 | 114 | test('validationRules - passes when no errors', async (t) => { 115 | t.plan(1) 116 | const app = Fastify() 117 | 118 | app.register(GQL, { 119 | schema, 120 | resolvers, 121 | cache: false, 122 | validationRules: () => [ 123 | // validation rule that reports no errors 124 | function (_context) { 125 | return { 126 | Document () { 127 | return false 128 | } 129 | } 130 | } 131 | ] 132 | }) 133 | 134 | // needed so that graphql is defined 135 | await app.ready() 136 | 137 | const res = await app.graphql(query) 138 | t.assert.deepStrictEqual(res.data.add, 4) 139 | }) 140 | 141 | test('validationRules - works with empty validationRules', async (t) => { 142 | t.plan(1) 143 | const app = Fastify() 144 | 145 | app.register(GQL, { 146 | schema, 147 | resolvers, 148 | cache: false, 149 | validationRules: () => [] 150 | }) 151 | 152 | // needed so that graphql is defined 153 | await app.ready() 154 | 155 | const res = await app.graphql(query) 156 | t.assert.deepStrictEqual(res.data.add, 4) 157 | }) 158 | 159 | test('validationRules - works with missing validationRules', async (t) => { 160 | t.plan(1) 161 | const app = Fastify() 162 | 163 | app.register(GQL, { 164 | schema, 165 | resolvers, 166 | validationRules: undefined 167 | }) 168 | 169 | // needed so that graphql is defined 170 | await app.ready() 171 | 172 | const res = await app.graphql(query) 173 | t.assert.deepStrictEqual(res.data.add, 4) 174 | }) 175 | 176 | test('validationRules - includes graphql request metadata', async (t) => { 177 | t.plan(4) 178 | const app = Fastify() 179 | 180 | const query = ` 181 | query Add ($x: Int!, $y: Int!) { 182 | add(x: $x, y: $y) 183 | } 184 | ` 185 | 186 | app.register(GQL, { 187 | schema, 188 | resolvers, 189 | cache: false, 190 | validationRules: function ({ source, variables, operationName }) { 191 | t.assert.strictEqual(source, query) 192 | t.assert.deepStrictEqual(variables, { x: 2, y: 2 }) 193 | t.assert.deepStrictEqual(operationName, 'Add') 194 | return [ 195 | // validation rule that reports no errors 196 | function (_context) { 197 | return { 198 | Document () { 199 | return false 200 | } 201 | } 202 | } 203 | ] 204 | } 205 | }) 206 | 207 | // needed so that graphql is defined 208 | await app.ready() 209 | 210 | const res = await app.graphql(query, null, { x: 2, y: 2 }, 'Add') 211 | t.assert.deepStrictEqual(res.data.add, 4) 212 | }) 213 | 214 | test('validationRules - errors if cache is used with the function', async (t) => { 215 | const app = Fastify() 216 | 217 | app.register(GQL, { 218 | schema, 219 | resolvers, 220 | cache: true, 221 | validationRules: () => [] 222 | }) 223 | 224 | // needed so that graphql is defined 225 | await t.assert.rejects(app.ready(), { message: 'Invalid options: Using a function for the validationRules is incompatible with query caching' }) 226 | }) 227 | -------------------------------------------------------------------------------- /examples/hooks-gateway.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const Fastify = require('fastify') 3 | const mercuriusWithFederation = require('@mercuriusjs/federation') 4 | const mercuriusWithGateway = require('@mercuriusjs/gateway') 5 | const mercurius = require('..') 6 | const { ErrorWithProps } = mercurius 7 | 8 | async function createService (port, schema, resolvers = {}) { 9 | const service = Fastify() 10 | 11 | service.register(mercuriusWithFederation, { 12 | schema, 13 | resolvers, 14 | graphiql: true, 15 | jit: 1 16 | }) 17 | await service.listen(port) 18 | } 19 | 20 | const users = { 21 | u1: { 22 | id: 'u1', 23 | name: 'John' 24 | }, 25 | u2: { 26 | id: 'u2', 27 | name: 'Jane' 28 | }, 29 | u3: { 30 | id: 'u3', 31 | name: 'Jack' 32 | } 33 | } 34 | 35 | const posts = { 36 | p1: { 37 | pid: 'p1', 38 | title: 'Post 1', 39 | content: 'Content 1', 40 | authorId: 'u1' 41 | }, 42 | p2: { 43 | pid: 'p2', 44 | title: 'Post 2', 45 | content: 'Content 2', 46 | authorId: 'u2' 47 | }, 48 | p3: { 49 | pid: 'p3', 50 | title: 'Post 3', 51 | content: 'Content 3', 52 | authorId: 'u1' 53 | }, 54 | p4: { 55 | pid: 'p4', 56 | title: 'Post 4', 57 | content: 'Content 4', 58 | authorId: 'u2' 59 | } 60 | } 61 | 62 | async function start () { 63 | await createService(4001, ` 64 | extend type Query { 65 | me: User 66 | you: User 67 | hello: String 68 | } 69 | 70 | type User @key(fields: "id") { 71 | id: ID! 72 | name: String! 73 | fullName: String 74 | avatar(size: AvatarSize): String 75 | friends: [User] 76 | } 77 | 78 | enum AvatarSize { 79 | small 80 | medium 81 | large 82 | } 83 | `, { 84 | Query: { 85 | me: (root, args, context, info) => { 86 | return users.u1 87 | }, 88 | you: (root, args, context, info) => { 89 | throw new ErrorWithProps('Can\'t fetch other users data', { code: 'NOT_ALLOWED' }) 90 | }, 91 | hello: () => 'world' 92 | }, 93 | User: { 94 | __resolveReference: (user, args, context, info) => { 95 | return users[user.id] 96 | }, 97 | avatar: (user, { size }) => `avatar-${size}.jpg`, 98 | friends: (user) => Object.values(users).filter(u => u.id !== user.id), 99 | fullName: (user) => user.name + ' Doe' 100 | } 101 | }) 102 | 103 | await createService(4002, ` 104 | type Post @key(fields: "pid") { 105 | pid: ID! 106 | title: String 107 | content: String 108 | author: User @requires(fields: "pid title") 109 | } 110 | 111 | type Query @extends { 112 | topPosts(count: Int): [Post] 113 | } 114 | 115 | type User @key(fields: "id") @extends { 116 | id: ID! @external 117 | name: String @external 118 | posts: [Post] 119 | numberOfPosts: Int @requires(fields: "id name") 120 | } 121 | 122 | extend type Mutation { 123 | createPost(post: PostInput!): Post 124 | updateHello: String 125 | } 126 | 127 | input PostInput { 128 | title: String! 129 | content: String! 130 | authorId: String! 131 | } 132 | `, { 133 | Post: { 134 | __resolveReference: (post, args, context, info) => { 135 | return posts[post.pid] 136 | }, 137 | author: (post, args, context, info) => { 138 | return { 139 | __typename: 'User', 140 | id: post.authorId 141 | } 142 | } 143 | }, 144 | User: { 145 | posts: (user, args, context, info) => { 146 | return Object.values(posts).filter(p => p.authorId === user.id) 147 | }, 148 | numberOfPosts: (user) => { 149 | return Object.values(posts).filter(p => p.authorId === user.id).length 150 | } 151 | }, 152 | Query: { 153 | topPosts: (root, { count = 2 }) => Object.values(posts).slice(0, count) 154 | }, 155 | Mutation: { 156 | createPost: (root, { post }) => { 157 | const pid = `p${Object.values(posts).length + 1}` 158 | 159 | const result = { 160 | pid, 161 | ...post 162 | } 163 | posts[pid] = result 164 | 165 | return result 166 | }, 167 | updateHello: () => 'World' 168 | } 169 | }) 170 | 171 | const gateway = Fastify() 172 | gateway.register(mercuriusWithGateway, { 173 | graphiql: true, 174 | jit: 1, 175 | gateway: { 176 | services: [{ 177 | name: 'user', 178 | url: 'http://localhost:4001/graphql' 179 | }, { 180 | name: 'post', 181 | url: 'http://localhost:4002/graphql' 182 | }] 183 | } 184 | }) 185 | 186 | await gateway.ready() 187 | 188 | gateway.graphql.addHook('preParsing', async function (schema, source, context) { 189 | console.log('preParsing called') 190 | }) 191 | 192 | gateway.graphql.addHook('preValidation', async function (schema, document, context) { 193 | console.log('preValidation called') 194 | }) 195 | 196 | gateway.graphql.addHook('preExecution', async function (schema, document, context) { 197 | console.log('preExecution called') 198 | return { 199 | document, 200 | errors: [ 201 | new Error('foo') 202 | ] 203 | } 204 | }) 205 | 206 | gateway.graphql.addHook('preGatewayExecution', async function (schema, document, context, service) { 207 | console.log('preGatewayExecution called', service.name) 208 | return { 209 | document, 210 | errors: [ 211 | new Error('foo') 212 | ] 213 | } 214 | }) 215 | 216 | gateway.graphql.addHook('onResolution', async function (execution, context) { 217 | console.log('onResolution called') 218 | }) 219 | 220 | gateway.graphql.addHook('onGatewayReplaceSchema', async (instance, schema) => { 221 | console.log('onGatewayReplaceSchema called') 222 | }) 223 | 224 | await gateway.listen({ port: 4000 }) 225 | } 226 | 227 | start() 228 | -------------------------------------------------------------------------------- /lib/hooks.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const applicationHooks = [ 4 | 'onExtendSchema' 5 | ] 6 | 7 | const lifecycleHooks = [ 8 | 'preParsing', 9 | 'preValidation', 10 | 'preExecution', 11 | 'onResolution', 12 | 'preSubscriptionParsing', 13 | 'preSubscriptionExecution', 14 | 'onSubscriptionResolution', 15 | 'onSubscriptionEnd', 16 | 'onSubscriptionConnectionClose', 17 | 'onSubscriptionConnectionError' 18 | ] 19 | const supportedHooks = lifecycleHooks.concat(applicationHooks) 20 | const { MER_ERR_HOOK_INVALID_TYPE, MER_ERR_HOOK_INVALID_HANDLER, MER_ERR_HOOK_UNSUPPORTED_HOOK } = require('./errors') 21 | 22 | function Hooks () { 23 | this.preParsing = [] 24 | this.preValidation = [] 25 | this.preExecution = [] 26 | this.onResolution = [] 27 | this.preSubscriptionParsing = [] 28 | this.preSubscriptionExecution = [] 29 | this.onSubscriptionResolution = [] 30 | this.onSubscriptionEnd = [] 31 | this.onSubscriptionConnectionClose = [] 32 | this.onSubscriptionConnectionError = [] 33 | this.onExtendSchema = [] 34 | } 35 | 36 | Hooks.prototype.validate = function (hook, fn) { 37 | if (typeof hook !== 'string') throw new MER_ERR_HOOK_INVALID_TYPE() 38 | if (typeof fn !== 'function') throw new MER_ERR_HOOK_INVALID_HANDLER() 39 | if (supportedHooks.indexOf(hook) === -1) { 40 | throw new MER_ERR_HOOK_UNSUPPORTED_HOOK(hook) 41 | } 42 | } 43 | 44 | Hooks.prototype.add = function (hook, fn) { 45 | this.validate(hook, fn) 46 | this[hook].push(fn) 47 | } 48 | 49 | function assignLifeCycleHooksToContext (context, hooks) { 50 | const contextHooks = { 51 | preParsing: null, 52 | preValidation: null, 53 | preExecution: null, 54 | onResolution: null, 55 | preSubscriptionParsing: null, 56 | preSubscriptionExecution: null, 57 | onSubscriptionResolution: null, 58 | onSubscriptionEnd: null 59 | } 60 | if (hooks.preParsing.length > 0) contextHooks.preParsing = hooks.preParsing.slice() 61 | if (hooks.preValidation.length > 0) contextHooks.preValidation = hooks.preValidation.slice() 62 | if (hooks.preExecution.length > 0) contextHooks.preExecution = hooks.preExecution.slice() 63 | if (hooks.onResolution.length > 0) contextHooks.onResolution = hooks.onResolution.slice() 64 | if (hooks.preSubscriptionParsing.length > 0) contextHooks.preSubscriptionParsing = hooks.preSubscriptionParsing.slice() 65 | if (hooks.preSubscriptionExecution.length > 0) contextHooks.preSubscriptionExecution = hooks.preSubscriptionExecution.slice() 66 | if (hooks.onSubscriptionResolution.length > 0) contextHooks.onSubscriptionResolution = hooks.onSubscriptionResolution.slice() 67 | if (hooks.onSubscriptionEnd.length > 0) contextHooks.onSubscriptionEnd = hooks.onSubscriptionEnd.slice() 68 | if (hooks.onSubscriptionConnectionClose.length > 0) contextHooks.onSubscriptionConnectionClose = hooks.onSubscriptionConnectionClose.slice() 69 | if (hooks.onSubscriptionConnectionError.length > 0) contextHooks.onSubscriptionConnectionError = hooks.onSubscriptionConnectionError.slice() 70 | return Object.assign(context, contextHooks) 71 | } 72 | 73 | function assignApplicationHooksToContext (context, hooks) { 74 | const contextHooks = { 75 | onExtendSchema: null 76 | } 77 | if (hooks.onExtendSchema.length > 0) contextHooks.onExtendSchema = hooks.onExtendSchema.slice() 78 | return Object.assign(context, contextHooks) 79 | } 80 | 81 | async function hooksRunner (functions, runner, request) { 82 | for (const fn of functions) { 83 | await runner(fn, request) 84 | } 85 | } 86 | 87 | async function preExecutionHooksRunner (functions, request) { 88 | let errors = [] 89 | let modifiedSchema 90 | let modifiedDocument 91 | let modifiedVariables 92 | 93 | for (const fn of functions) { 94 | const result = await fn( 95 | modifiedSchema || request.schema, 96 | modifiedDocument || request.document, 97 | request.context, 98 | modifiedVariables || request.variables 99 | ) 100 | 101 | if (result) { 102 | if (typeof result.schema !== 'undefined') { 103 | modifiedSchema = result.schema 104 | } 105 | if (typeof result.document !== 'undefined') { 106 | modifiedDocument = result.document 107 | } 108 | if (typeof result.variables !== 'undefined') { 109 | modifiedVariables = result.variables 110 | } 111 | if (typeof result.errors !== 'undefined') { 112 | errors = errors.concat(result.errors) 113 | } 114 | } 115 | } 116 | 117 | return { errors, modifiedDocument, modifiedSchema, modifiedVariables } 118 | } 119 | 120 | function hookRunner (fn, request) { 121 | return fn(request.schema, request.document, request.context) 122 | } 123 | 124 | function preParsingHookRunner (fn, request) { 125 | return fn(request.schema, request.source, request.context) 126 | } 127 | 128 | function onResolutionHookRunner (fn, request) { 129 | return fn(request.execution, request.context) 130 | } 131 | 132 | function preSubscriptionParsingHookRunner (fn, request) { 133 | return fn(request.schema, request.source, request.context, request.id) 134 | } 135 | 136 | function preSubscriptionExecutionHookRunner (fn, request) { 137 | return fn(request.schema, request.document, request.context, request.id) 138 | } 139 | 140 | function onSubscriptionResolutionHookRunner (fn, request) { 141 | return fn(request.execution, request.context, request.id) 142 | } 143 | 144 | function onSubscriptionEndHookRunner (fn, request) { 145 | return fn(request.context, request.id) 146 | } 147 | 148 | function onConnectionCloseHookRunner (fn, request) { 149 | return fn(request.context, request.code, request.reason) 150 | } 151 | 152 | function onConnectionErrorHookRunner (fn, request) { 153 | return fn(request.context, request.error) 154 | } 155 | 156 | module.exports = { 157 | Hooks, 158 | assignLifeCycleHooksToContext, 159 | assignApplicationHooksToContext, 160 | hookRunner, 161 | hooksRunner, 162 | 163 | preExecutionHooksRunner, 164 | preParsingHookRunner, 165 | onResolutionHookRunner, 166 | 167 | preSubscriptionParsingHookRunner, 168 | preSubscriptionExecutionHookRunner, 169 | onSubscriptionResolutionHookRunner, 170 | onSubscriptionEndHookRunner, 171 | onConnectionCloseHookRunner, 172 | onConnectionErrorHookRunner 173 | } 174 | -------------------------------------------------------------------------------- /docs/persisted-queries.md: -------------------------------------------------------------------------------- 1 | # mercurius 2 | 3 | ## Persisted Queries 4 | 5 | GraphQL query strings are often larger than the URLs used in REST requests, sometimes by many kilobytes. 6 | 7 | Depending on the client, this can be a significant overhead for each request, especially given that upload speed is typically the most bandwidth-constrained part of the request lifecycle. Large queries can add significant performance overheads. 8 | 9 | Persisted Queries solve this problem by having the client send a generated ID, instead of the full query string, resulting in a smaller request. The server can use an internal lookup to turn this back into a full query and return the result. 10 | 11 | The `persistedQueryProvider` option lets you configure this for Fastify mercurius. There are a few default options available, included in `mercurius.persistedQueryDefaults`. 12 | 13 | ### Prepared 14 | 15 | Prepared queries give the best performance in all use cases, at the expense of tooling complexity. Queries must be hashed ahead of time, and a matching set of hashes must be available for both the client and the server. Additionally, version control of query hashes must be considered, e.g. queries used by old clients may need to be kept such that hashes can be calculated at build time. This can be very useful for non-public APIs, but is impractical for public APIs. 16 | 17 | Clients can provide a full query string, or set the `persisted` flag to true and provide a hash instead of the query in the request: 18 | 19 | ```js 20 | { 21 | query: '', 22 | persisted: true 23 | } 24 | ``` 25 | 26 | A map of hashes to queries must be provided to the server at startup: 27 | 28 | ```js 29 | const mercurius = require('mercurius') 30 | 31 | app.register(mercurius, { 32 | ... 33 | persistedQueryProvider: mercurius.persistedQueryDefaults.prepared({ 34 | '': '{ add(x: 1, y: 1) }' 35 | }) 36 | }) 37 | ``` 38 | 39 | Alternatively the `peristedQueries` option may be used directly, which will be internally mapped to the `prepared` default: 40 | 41 | ```js 42 | const mercurius = require('mercurius') 43 | 44 | app.register(mercurius, { 45 | ... 46 | persistedQueries: { 47 | '': '{ add(x: 1, y: 1) }' 48 | } 49 | }) 50 | ``` 51 | 52 | ### Prepared Only 53 | 54 | This offers similar performance and considerations to the `prepared` queries, but only allows persisted queries. This provides additional secuirity benefits, but means that the server **must** know all queries ahead of time or will reject the request. 55 | 56 | The API is the same as the `prepared` default. 57 | 58 | Alternatively the `peristedQueries` and `onlyPersisted` options may be used directly, which will be internally mapped to the `preparedOnly` default: 59 | 60 | ```js 61 | const mercurius = require('mercurius') 62 | 63 | app.register(mercurius, { 64 | ... 65 | persistedQueries: { 66 | '': '{ add(x: 1, y: 1) }' 67 | }, 68 | onlyPersisted: true 69 | }) 70 | ``` 71 | 72 | ### Automatic 73 | 74 | This default is compatible with `apollo-client`, and requires no additional tooling to set up at the cost of some performance. In order for this mode to be effective, you must have long lived server instances (i.e _not_ cloud functions). This mode is also appropriate for public APIs where queries are not known ahead of time. 75 | 76 | When an unrecognised hash is recieved by the server instance, an error is thrown informing the client that the persisted query has not been seen before. The client then re-sends the full query string. When a full query string is recieved, the server caches the hash of the query string and returns the response. _Note that sticky sessions should be used to ensure optimal performance here by making sure the follow up request is sent to the same server instance._ 77 | 78 | The next request for that query (from the same or a different client) will already have been cached and will then be looked up accordingly. 79 | 80 | When the server initially starts, no queries will be cached and additional latency will be added to the first requests recieved (due to the client re-sending the full query). However, the most common queries will rapidly be cached by the server. After a warmup (length dependent on the number of queries clients might send and how frequent they are) performance will match that of the `prepared` query option. 81 | 82 | Additional documentation on Apollo's automatic persisted queries implementation can be found [here](https://www.apollographql.com/docs/apollo-server/performance/apq/). 83 | 84 | Example: 85 | 86 | ```js 87 | const mercurius = require('mercurius') 88 | 89 | app.register(mercurius, { 90 | ... 91 | persistedQueryProvider: mercurius.persistedQueryDefaults.automatic() 92 | }) 93 | ``` 94 | 95 | An LRU cache is used to prevent DoS attacks on the storage of hashes & queries. The maximum size of this cache (maximum number of cached queries) can be adjusted by passing a value to the constructor, for example: 96 | 97 | ```js 98 | const mercurius = require('mercurius') 99 | 100 | app.register(mercurius, { 101 | ... 102 | persistedQueryProvider: mercurius.persistedQueryDefaults.automatic(5000) 103 | }) 104 | ``` 105 | 106 | ### Custom Persisted Queries 107 | 108 | It is also possible to extend or modify these persisted query implementations for custom cases, such as automatic Persisted Queries, but with a shared cache between servers. 109 | 110 | This would enable all persisted queries to be shared between all server instances in a cache which is dynamically populated. The lookup time from the cache is an additional overhead for each request, but a higher rate of persisted query matches would be achieved. This may be beneficial, for example, in a public facing API which supports persisted queries and uses cloud functions (short lived server instances). _Note the performance impacts of this need to be considered thoroughly: the latency added to each request must be less than the savings from smaller requests._ 111 | 112 | A example of using this with Redis would be: 113 | 114 | ```js 115 | const mercurius = require('mercurius') 116 | 117 | const persistedQueryProvider = { 118 | ...mercurius.persistedQueryDefaults.automatic(), 119 | getQueryFromHash: async (hash) => redis.get(hash), 120 | saveQuery: async (hash, query) => redis.set(hash, query) 121 | } 122 | 123 | app.register(mercurius, { 124 | ...persistedQueryProvider 125 | }) 126 | ``` 127 | -------------------------------------------------------------------------------- /test/custom-root-types.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const Fastify = require('fastify') 5 | const WebSocket = require('ws') 6 | const mq = require('mqemitter') 7 | const GQL = require('..') 8 | 9 | test('redefine query', async (t) => { 10 | const schema = ` 11 | schema { 12 | query: BetterQuery 13 | } 14 | 15 | type BetterQuery { 16 | q: Query 17 | } 18 | 19 | type Query { 20 | id: ID! 21 | } 22 | ` 23 | 24 | const resolvers = { 25 | BetterQuery: { 26 | q: async () => ({ id: '1' }) 27 | }, 28 | 29 | Query: { 30 | id: async () => '1' 31 | } 32 | } 33 | 34 | const app = Fastify() 35 | app.register(GQL, { 36 | schema, 37 | resolvers 38 | }) 39 | 40 | // needed so that graphql is defined 41 | await app.ready() 42 | 43 | const query = '{ q { id } }' 44 | const result = await app.graphql(query) 45 | t.assert.deepStrictEqual(result.data.q.id, '1') 46 | }) 47 | 48 | test('redefined mutation type', async (t) => { 49 | const schema = ` 50 | schema { 51 | query: Query 52 | mutation: BetterMutation 53 | } 54 | 55 | type BetterMutation { 56 | m: Mutation 57 | } 58 | 59 | type Mutation { 60 | name: String! 61 | } 62 | 63 | type Query { 64 | mut: Mutation! 65 | } 66 | ` 67 | 68 | const resolvers = { 69 | BetterMutation: { 70 | m: async () => ({ name: 'Bobby' }) 71 | }, 72 | 73 | Mutation: { 74 | name: async () => 'Bobby' 75 | }, 76 | 77 | Query: { 78 | mut: async () => ({ name: 'Bobby' }) 79 | } 80 | } 81 | 82 | const app = Fastify() 83 | app.register(GQL, { 84 | schema, 85 | resolvers 86 | }) 87 | 88 | // needed so that graphql is defined 89 | await app.ready() 90 | 91 | const mutation = 'mutation { m { name } }' 92 | const res = await app.graphql(mutation) 93 | t.assert.deepStrictEqual(res.data.m.name, 'Bobby') 94 | }) 95 | 96 | test('redefined subscription type', t => { 97 | const app = Fastify() 98 | t.after(() => app.close()) 99 | 100 | const sendTestQuery = () => { 101 | app.inject({ 102 | method: 'POST', 103 | url: '/graphql', 104 | body: { 105 | query: ` 106 | query { 107 | notifications { 108 | id 109 | message 110 | } 111 | } 112 | ` 113 | } 114 | }, () => { 115 | sendTestMutation() 116 | }) 117 | } 118 | 119 | const sendTestMutation = () => { 120 | app.inject({ 121 | method: 'POST', 122 | url: '/graphql', 123 | body: { 124 | query: ` 125 | mutation { 126 | addNotification(message: "Hello World") { 127 | id 128 | } 129 | } 130 | ` 131 | } 132 | }, () => {}) 133 | } 134 | 135 | const emitter = mq() 136 | const schema = ` 137 | schema { 138 | query: Query, 139 | mutation: Mutation, 140 | subscription: BetterSubscription 141 | } 142 | 143 | type Notification { 144 | id: ID! 145 | message: String 146 | } 147 | 148 | type Query { 149 | notifications: [Notification] 150 | } 151 | 152 | type Mutation { 153 | addNotification(message: String): Notification 154 | } 155 | 156 | type BetterSubscription { 157 | notificationAdded: Notification 158 | } 159 | ` 160 | 161 | let idCount = 1 162 | const notifications = [{ 163 | id: idCount, 164 | message: 'Notification message' 165 | }] 166 | 167 | const resolvers = { 168 | Query: { 169 | notifications: () => notifications 170 | }, 171 | Mutation: { 172 | addNotification: async (_, { message }) => { 173 | const id = idCount++ 174 | const notification = { 175 | id, 176 | message 177 | } 178 | notifications.push(notification) 179 | await emitter.emit({ 180 | topic: 'NOTIFICATION_ADDED', 181 | payload: { 182 | notificationAdded: notification 183 | } 184 | }) 185 | 186 | return notification 187 | } 188 | }, 189 | BetterSubscription: { 190 | notificationAdded: { 191 | subscribe: (root, args, { pubsub }) => pubsub.subscribe('NOTIFICATION_ADDED') 192 | } 193 | } 194 | } 195 | 196 | app.register(GQL, { 197 | schema, 198 | resolvers, 199 | subscription: { 200 | emitter 201 | } 202 | }) 203 | 204 | app.listen({ port: 0 }, err => { 205 | t.assert.ifError(err) 206 | 207 | const ws = new WebSocket('ws://localhost:' + (app.server.address()).port + '/graphql', 'graphql-ws') 208 | const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8', objectMode: true }) 209 | t.after(() => client.destroy.bind(client)) 210 | client.setEncoding('utf8') 211 | 212 | client.write(JSON.stringify({ 213 | type: 'connection_init' 214 | })) 215 | 216 | client.write(JSON.stringify({ 217 | id: 1, 218 | type: 'start', 219 | payload: { 220 | query: ` 221 | subscription { 222 | notificationAdded { 223 | id 224 | message 225 | } 226 | } 227 | ` 228 | } 229 | })) 230 | 231 | client.write(JSON.stringify({ 232 | id: 2, 233 | type: 'start', 234 | payload: { 235 | query: ` 236 | subscription { 237 | notificationAdded { 238 | id 239 | message 240 | } 241 | } 242 | ` 243 | } 244 | })) 245 | 246 | client.write(JSON.stringify({ 247 | id: 2, 248 | type: 'stop' 249 | })) 250 | 251 | client.on('data', chunk => { 252 | const data = JSON.parse(chunk) 253 | 254 | if (data.id === 1 && data.type === 'data') { 255 | t.assert.equal(chunk, JSON.stringify({ 256 | type: 'data', 257 | id: 1, 258 | payload: { 259 | data: { 260 | notificationAdded: { 261 | id: '1', 262 | message: 'Hello World' 263 | } 264 | } 265 | } 266 | })) 267 | 268 | client.end() 269 | } else if (data.id === 2 && data.type === 'complete') { 270 | sendTestQuery() 271 | } 272 | }) 273 | }) 274 | }) 275 | -------------------------------------------------------------------------------- /static/main.js: -------------------------------------------------------------------------------- 1 | /* global React:false ReactDOM:false GraphiQL:false */ 2 | 3 | const importer = { 4 | url: (url) => { 5 | return new Promise((resolve, reject) => { 6 | const script = document.createElement('script') 7 | script.type = 'text/javascript' 8 | script.src = url 9 | script.crossOrigin = 'anonymous' 10 | script.addEventListener('load', () => resolve(script), false) 11 | script.addEventListener('error', (err) => reject(err), false) 12 | document.body.appendChild(script) 13 | }) 14 | }, 15 | urls: (urls) => { 16 | return Promise.all(urls.map(importer.url)) 17 | } 18 | } 19 | 20 | // The functions above are required to wrap the fetcher and access/enrich the data returned by the GQL query 21 | // Except `fetcherWrapper`, they are copy/pasted directly from the `graphiql` codebase. 22 | 23 | function observableToPromise (observable) { 24 | return new Promise((resolve, reject) => { 25 | const subscription = observable.subscribe({ 26 | next: v => { 27 | resolve(v) 28 | subscription.unsubscribe() 29 | }, 30 | error: reject, 31 | complete: () => { 32 | reject(new Error('no value resolved')) 33 | } 34 | }) 35 | }) 36 | } 37 | 38 | function isObservable (value) { 39 | return ( 40 | typeof value === 'object' && 41 | value !== null && 42 | 'subscribe' in value && 43 | typeof value.subscribe === 'function' 44 | ) 45 | } 46 | 47 | function isAsyncIterable (input) { 48 | return ( 49 | typeof input === 'object' && 50 | input !== null && 51 | ((input)[Symbol.toStringTag] === 'AsyncGenerator' || 52 | Symbol.asyncIterator in input) 53 | ) 54 | } 55 | 56 | function asyncIterableToPromise ( 57 | input 58 | ) { 59 | return new Promise((resolve, reject) => { 60 | const iteratorReturn = ( 61 | 'return' in input ? input : input[Symbol.asyncIterator]() 62 | ).return?.bind(input) 63 | const iteratorNext = ( 64 | 'next' in input ? input : input[Symbol.asyncIterator]() 65 | ).next.bind(input) 66 | 67 | iteratorNext() 68 | .then(result => { 69 | resolve(result.value) 70 | // ensure cleanup 71 | iteratorReturn?.() 72 | }) 73 | .catch(err => { 74 | reject(err) 75 | }) 76 | }) 77 | } 78 | 79 | function fetcherReturnToPromise (fetcherResult) { 80 | return Promise.resolve(fetcherResult).then(result => { 81 | if (isAsyncIterable(result)) { 82 | return asyncIterableToPromise(result) 83 | } else if (isObservable(result)) { 84 | return observableToPromise(result) 85 | } 86 | return result 87 | }) 88 | } 89 | 90 | function fetcherWrapper (fetcher, cbs = []) { 91 | return async (gqlp, fetchOpt) => { 92 | const fetchResponse = await fetcher(gqlp, fetchOpt) 93 | const result = await fetcherReturnToPromise(fetchResponse) 94 | return cbs.reduce((acc, cb) => cb(acc), result) 95 | } 96 | } 97 | 98 | /** 99 | * Verify if the baseUrl is already present in the first part of GRAPHQL_ENDPOINT url 100 | * to avoid unexpected duplication of paths 101 | * @param {string} baseUrl [comes from {@link render} function] 102 | * @returns boolean 103 | */ 104 | function isDuplicatedUrlArg (baseUrl) { 105 | const checker = window.GRAPHQL_ENDPOINT.split('/') 106 | return (checker[1] === baseUrl) 107 | } 108 | 109 | function render () { 110 | const host = window.location.host 111 | const websocketProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' 112 | let url = '' 113 | let subscriptionUrl = '' 114 | let pathName = window.location.pathname 115 | if (pathName.startsWith('/')) { 116 | pathName = pathName.substring(1) 117 | } 118 | pathName = pathName.split('/') 119 | const baseUrl = pathName[0] 120 | if (baseUrl !== 'graphiql') { 121 | url = `${window.location.protocol}//${host}/${baseUrl}${window.GRAPHQL_ENDPOINT}` 122 | subscriptionUrl = `${websocketProtocol}//${host}/${baseUrl}${window.GRAPHQL_ENDPOINT}` 123 | if (isDuplicatedUrlArg(baseUrl)) { 124 | url = `${window.location.protocol}//${host}${window.GRAPHQL_ENDPOINT}` 125 | subscriptionUrl = `${websocketProtocol}//${host}${window.GRAPHQL_ENDPOINT}` 126 | } 127 | } else { 128 | url = `${window.location.protocol}//${host}${window.GRAPHQL_ENDPOINT}` 129 | subscriptionUrl = `${websocketProtocol}//${host}${window.GRAPHQL_ENDPOINT}` 130 | } 131 | 132 | const availablePlugins = window.GRAPHIQL_PLUGIN_LIST 133 | .map(plugin => window[`GRAPHIQL_PLUGIN_${plugin.toUpperCase()}`]) 134 | .filter(pluginData => pluginData && pluginData.umdUrl) 135 | 136 | const fetcherWrapperPlugins = availablePlugins 137 | .filter(plugin => plugin.fetcherWrapper) 138 | .map(pluginData => window[pluginData.name][window[`GRAPHIQL_PLUGIN_${pluginData.name.toUpperCase()}`].fetcherWrapper]) 139 | 140 | const fetcher = fetcherWrapper(GraphiQL.createFetcher({ 141 | url, 142 | subscriptionUrl 143 | }), fetcherWrapperPlugins) 144 | 145 | const plugins = availablePlugins.map(pluginData => window[pluginData.name].umdPlugin(window[`GRAPHIQL_PLUGIN_${pluginData.name.toUpperCase()}`].props)) 146 | 147 | ReactDOM.render( 148 | React.createElement(GraphiQL, { 149 | fetcher, 150 | headerEditorEnabled: true, 151 | shouldPersistHeaders: true, 152 | plugins 153 | }), 154 | document.getElementById('main') 155 | ) 156 | } 157 | 158 | function importDependencies () { 159 | const link = document.createElement('link') 160 | link.href = 'https://unpkg.com/graphiql@3.8.3/graphiql.min.css' 161 | link.type = 'text/css' 162 | link.rel = 'stylesheet' 163 | link.media = 'screen,print' 164 | link.crossOrigin = 'anonymous' 165 | document.getElementsByTagName('head')[0].appendChild(link) 166 | 167 | return importer.urls([ 168 | 'https://unpkg.com/react@18.3.1/umd/react.production.min.js', 169 | 'https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js', 170 | 'https://unpkg.com/graphiql@3.8.3/graphiql.min.js' 171 | ]).then(function () { 172 | const pluginUrls = window.GRAPHIQL_PLUGIN_LIST 173 | .map(plugin => window[`GRAPHIQL_PLUGIN_${plugin.toUpperCase()}`].umdUrl) 174 | .filter(url => !!url) 175 | 176 | if (pluginUrls.length) { 177 | return importer.urls(pluginUrls) 178 | } 179 | }) 180 | } 181 | 182 | if ('serviceWorker' in navigator) { 183 | navigator 184 | .serviceWorker 185 | .register('./graphiql/sw.js') 186 | .then(importDependencies).then(render) 187 | } else { 188 | importDependencies() 189 | .then(render) 190 | } 191 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { GraphQLError } = require('graphql') 4 | const createError = require('@fastify/error') 5 | 6 | class ErrorWithProps extends Error { 7 | constructor (message, extensions, statusCode) { 8 | super(message) 9 | this.extensions = extensions 10 | this.statusCode = statusCode || 200 11 | } 12 | } 13 | 14 | // converts an error to a `GraphQLError` compatible 15 | // allows to copy the `path` & `locations` properties 16 | // from the already serialized error 17 | function toGraphQLError (err) { 18 | if (err instanceof GraphQLError) { 19 | return err 20 | } 21 | 22 | const gqlError = new GraphQLError( 23 | err.message, 24 | err.nodes, 25 | err.source, 26 | err.positions, 27 | err.path, 28 | err, 29 | err.extensions 30 | ) 31 | 32 | gqlError.locations = err.locations 33 | 34 | return gqlError 35 | } 36 | 37 | function defaultErrorFormatter (execution, ctx) { 38 | // There is always app if there is a context 39 | const log = ctx.reply ? ctx.reply.log : ctx.app.log 40 | 41 | let statusCode = execution.data ? 200 : (execution.statusCode || 200) 42 | 43 | const errors = execution.errors.map((error) => { 44 | log.info({ err: error }, error.message) 45 | 46 | // it handles fastify errors MER_ERR_GQL_VALIDATION 47 | if (error.originalError?.errors && Array.isArray(error.originalError.errors)) { 48 | // not all errors are `GraphQLError` type, we need to convert them 49 | return error.originalError.errors.map(toGraphQLError) 50 | } 51 | 52 | return error 53 | // as the result of the outer map could potentially contain arrays with errors 54 | // the result needs to be flattened 55 | // and convert error into serializable format 56 | }).reduce((acc, val) => acc.concat(val), []).map((error) => error.toJSON()) 57 | 58 | // Override status code when there is no data or statusCode present 59 | if (!execution.data && typeof execution.statusCode === 'undefined' && execution.errors.length > 0) { 60 | if (errors.length === 1) { 61 | // If single error defined, use status code if present 62 | if (typeof execution.errors[0].originalError !== 'undefined' && typeof execution.errors[0].originalError.statusCode === 'number') { 63 | statusCode = execution.errors[0].originalError.statusCode 64 | // Otherwise, use 200 as per graphql-over-http spec 65 | } else { 66 | statusCode = 200 67 | } 68 | } 69 | } 70 | 71 | return { 72 | statusCode, 73 | response: { 74 | data: execution.data || null, 75 | errors 76 | } 77 | } 78 | } 79 | 80 | function addErrorsToExecutionResult (execution, errors) { 81 | if (errors) { 82 | let newErrors 83 | if (execution.errors) { 84 | newErrors = execution.errors.concat(errors) 85 | } else { 86 | newErrors = errors 87 | } 88 | execution.errors = newErrors.map((error) => toGraphQLError(error)) 89 | } 90 | return execution 91 | } 92 | 93 | function addErrorsToContext (context, errors) { 94 | let newErrors 95 | if (context.errors !== null) { 96 | newErrors = context.errors.concat(errors) 97 | } else { 98 | newErrors = errors 99 | } 100 | 101 | context.errors = newErrors 102 | } 103 | 104 | const errors = { 105 | /** 106 | * General errors 107 | */ 108 | MER_ERR_INVALID_OPTS: createError( 109 | 'MER_ERR_INVALID_OPTS', 110 | 'Invalid options: %s' 111 | ), 112 | MER_ERR_INVALID_METHOD: createError( 113 | 'MER_ERR_INVALID_METHOD', 114 | 'Invalid method: %s' 115 | ), 116 | MER_ERR_METHOD_NOT_ALLOWED: createError( 117 | 'MER_ERR_METHOD_NOT_ALLOWED', 118 | 'Method not allowed', 119 | 405 120 | ), 121 | /** 122 | * General graphql errors 123 | */ 124 | MER_ERR_GQL_INVALID_SCHEMA: createError( 125 | 'MER_ERR_GQL_INVALID_SCHEMA', 126 | 'Invalid schema: check out the .errors property on the Error' 127 | ), 128 | MER_ERR_GQL_VALIDATION: createError( 129 | 'MER_ERR_GQL_VALIDATION', 130 | 'Graphql validation error', 131 | 400 132 | ), 133 | MER_ERR_GQL_QUERY_DEPTH: createError( 134 | 'MER_ERR_GQL_QUERY_DEPTH', 135 | '`%s query depth (%s) exceeds the query depth limit of %s`' 136 | ), 137 | MER_ERR_GQL_CSRF_PREVENTION: createError( 138 | 'MER_ERR_GQL_CSRF_PREVENTION', 139 | '%s', 140 | 400 141 | ), 142 | /** 143 | * Persisted query errors 144 | */ 145 | MER_ERR_GQL_PERSISTED_QUERY_NOT_FOUND: createError( 146 | 'MER_ERR_GQL_PERSISTED_QUERY_NOT_FOUND', 147 | '%s', 148 | 400 149 | ), 150 | MER_ERR_GQL_PERSISTED_QUERY_NOT_SUPPORTED: createError( 151 | 'MER_ERR_GQL_PERSISTED_QUERY_NOT_SUPPORTED', 152 | '%s', 153 | 400 154 | ), 155 | MER_ERR_GQL_PERSISTED_QUERY_MISMATCH: createError( 156 | 'MER_ERR_GQL_PERSISTED_QUERY_MISMATCH', 157 | '%s', 158 | 400 159 | ), 160 | /** 161 | * Subscription errors 162 | */ 163 | MER_ERR_GQL_SUBSCRIPTION_CONNECTION_NOT_READY: createError( 164 | 'MER_ERR_GQL_SUBSCRIPTION_CONNECTION_NOT_READY', 165 | 'Connection is not ready' 166 | ), 167 | MER_ERR_GQL_SUBSCRIPTION_FORBIDDEN: createError( 168 | 'MER_ERR_GQL_SUBSCRIPTION_FORBIDDEN', 169 | 'Forbidden' 170 | ), 171 | MER_ERR_GQL_SUBSCRIPTION_UNKNOWN_EXTENSION: createError( 172 | 'MER_ERR_GQL_SUBSCRIPTION_UNKNOWN_EXTENSION', 173 | 'Unknown extension %s' 174 | ), 175 | MER_ERR_GQL_SUBSCRIPTION_MESSAGE_INVALID: createError( 176 | 'MER_ERR_GQL_SUBSCRIPTION_MESSAGE_INVALID', 177 | 'Invalid message received: %s' 178 | ), 179 | MER_ERR_GQL_SUBSCRIPTION_INVALID_OPERATION: createError( 180 | 'MER_ERR_GQL_SUBSCRIPTION_INVALID_OPERATION', 181 | 'Invalid operation: %s' 182 | ), 183 | /** 184 | * Hooks errors 185 | */ 186 | MER_ERR_HOOK_INVALID_TYPE: createError( 187 | 'MER_ERR_HOOK_INVALID_TYPE', 188 | 'The hook name must be a string', 189 | 500, 190 | TypeError 191 | ), 192 | MER_ERR_HOOK_INVALID_HANDLER: createError( 193 | 'MER_ERR_HOOK_INVALID_HANDLER', 194 | 'The hook callback must be a function', 195 | 500, 196 | TypeError 197 | ), 198 | MER_ERR_HOOK_UNSUPPORTED_HOOK: createError( 199 | 'MER_ERR_HOOK_UNSUPPORTED_HOOK', 200 | '%s hook not supported!', 201 | 500 202 | ), 203 | MER_ERR_SERVICE_RETRY_FAILED: createError( 204 | 'MER_ERR_SERVICE_RETRY_FAILED', 205 | 'Mandatory services retry failed - [%s]', 206 | 500 207 | ) 208 | } 209 | 210 | module.exports = errors 211 | module.exports.ErrorWithProps = ErrorWithProps 212 | module.exports.defaultErrorFormatter = defaultErrorFormatter 213 | module.exports.addErrorsToExecutionResult = addErrorsToExecutionResult 214 | module.exports.addErrorsToContext = addErrorsToContext 215 | module.exports.toGraphQLError = toGraphQLError 216 | -------------------------------------------------------------------------------- /docs/integrations/prisma.md: -------------------------------------------------------------------------------- 1 | # Integrating Prisma with Mercurius 2 | 3 | [Prisma](https://prisma.io) is an [open-source](https://github.com/prisma/prisma) ORM for Node.js and TypeScript. 4 | It can be used as an _alternative_ to writing plain SQL, or using another database access tool such as SQL query builders (e.g. [knex.js](https://knexjs.org/)) or ORMs (like [TypeORM](https://typeorm.io/) and [Sequelize](https://sequelize.org/)). 5 | Prisma currently supports PostgreSQL, MySQL, SQL Server, MongoDB, CockroachDB, and SQLite. 6 | 7 | You can easily combine Prisma and Mercurius to build your GraphQL server that connects to a database. Prisma is agnostic to the GraphQL tools you use when building your GraphQL server. Check out this [GitHub repo](https://github.com/2color/fastify-graphql-nexus-prisma) for a ready-to-run example project with a PosgreSQL database. 8 | 9 | Prisma can be used with plain JavaScript and it embraces TypeScript and provides a level to type-safety that goes beyond the guarantees other ORMs in the TypeScript ecosystem. You can find an in-depth comparison of Prisma against other ORMs [here](https://www.prisma.io/docs/concepts/more/comparisons) 10 | 11 | ## Installation 12 | 13 | Install [Prisma CLI](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-cli) as a development dependency in your project: 14 | 15 | ```bash 16 | npm install prisma --save-dev 17 | npm install @prisma/client 18 | ``` 19 | 20 | [Prisma Client](https://www.prisma.io/docs/reference/api-reference/prisma-client-reference) is an auto-generated database client that allows you to interact with your database in a type-safe way. 21 | 22 | Initialize Prisma in your project: 23 | ```bash 24 | npx prisma init 25 | ``` 26 | 27 | This command does the following: 28 | - Creates a new directory called `prisma` which contains a file called `schema.prisma`. This file defines your database connection and the Prisma Client generator. 29 | - Creates a `.env` file at the root of your project. This defines your environment variables (used for your database connection). 30 | 31 | ## Connect to your database 32 | 33 | To connect to your database, set the `url` field of the `datasource` block in your Prisma schema to your database connection URL. By default, it's set to `postgresql` but this guide will use SQLite database. Adjust your `datasource` block to `sqlite`: 34 | 35 | ```prisma 36 | /// prisma/schema.prisma 37 | datasource db { 38 | provider = "sqlite" 39 | url = env("DATABASE_URL") 40 | } 41 | 42 | generator client { 43 | provider = "prisma-client-js" 44 | } 45 | ``` 46 | 47 | Update the `DATABASE_URL` environment variable in the `.env` file: 48 | 49 | ``` 50 | # .env 51 | DATABASE_URL="file:./dev.db" 52 | ``` 53 | 54 | If you wish to use a different database, you can jump to [switching database providers](#switching-database-providers). 55 | 56 | ## Create database tables with Prisma Migrate 57 | 58 | Add the following model to your `prisma.schema` file: 59 | 60 | ```prisma 61 | model Post { 62 | id Int @id @default(autoincrement()) 63 | title String 64 | body String 65 | published Boolean 66 | } 67 | ``` 68 | 69 | To map your data model to the database schema, you need to use `prisma migrate` CLI commands: 70 | 71 | ```bash 72 | npx prisma migrate dev --name init 73 | ``` 74 | 75 | The above command does three things: 76 | 1. Creates a new SQL migration file for this migration 77 | 1. Creates the database if it does not exist 78 | 1. Runs the SQL migration against the database 79 | 1. Generates Prisma Client 80 | 81 | ## Set up your GraphQL server 82 | 83 | ```js 84 | // index.js 85 | 'use strict' 86 | const Fastify = require('fastify') 87 | const mercurius = require('mercurius') 88 | const { PrismaClient } = require('@prisma/client') 89 | 90 | const app = Fastify() 91 | const prisma = new PrismaClient() 92 | 93 | const schema = ` 94 | type Mutation { 95 | createDraft(body: String!, title: String!): Post 96 | publish(draftId: Int!): Post 97 | } 98 | 99 | type Post { 100 | body: String 101 | id: Int 102 | published: Boolean 103 | title: String 104 | } 105 | 106 | type Query { 107 | drafts: [Post] 108 | posts: [Post] 109 | } 110 | ` 111 | 112 | const resolvers = { 113 | Query: { 114 | posts: async (_parent, args, ctx) => { 115 | return ctx.prisma.post.findMany({ 116 | where: { 117 | published: true 118 | } 119 | }) 120 | }, 121 | drafts: async (_parent, args, ctx) => { 122 | return ctx.prisma.post.findMany({ 123 | where: { 124 | published: false 125 | } 126 | }) 127 | }, 128 | }, 129 | Mutation: { 130 | createDraft: async (_parent, args, ctx) => { 131 | return ctx.prisma.post.create({ 132 | data: { 133 | title: args.title, 134 | body: args.body, 135 | } 136 | }) 137 | }, 138 | publish: async (_parent, args, ctx) => { 139 | return ctx.prisma.post.update({ 140 | where: { id: args.draftId }, 141 | data: { published: true } 142 | }) 143 | }, 144 | } 145 | } 146 | 147 | app.register(mercurius, { 148 | schema, 149 | resolvers, 150 | context: (request, reply) => { 151 | return { prisma } 152 | }, 153 | graphiql: true 154 | }) 155 | 156 | app.listen({ port: 3000 }) 157 | .then(() => console.log(`🚀 Server ready at http://localhost:3000/graphiql`)) 158 | 159 | ``` 160 | 161 | Start your application: 162 | ```bash 163 | node index.js 164 | ``` 165 | 166 | ## Switching database providers 167 | 168 | If you want to switch to a different database other than SQLite, you can adjust the database connection in `prisma/prisma.schema` by reconfiguring the `datasource` block. 169 | 170 | Learn more about the different connection configurations in the [docs](https://www.prisma.io/docs/reference/database-reference/connection-urls). 171 | 172 | Here's an overview of an example configuration with different databases: 173 | 174 | ### PostgreSQL 175 | 176 | Here is an example connection string with a local PostgreSQL database: 177 | 178 | ```prisma 179 | datasource db { 180 | provider = "postgresql" 181 | url = env("DATABASE_URL") 182 | } 183 | ``` 184 | 185 | ### MySQL 186 | 187 | Here is an example connection string with a local MySQL database: 188 | 189 | ```prisma 190 | datasource db { 191 | provider = "mysql" 192 | url = env("DATABASE_URL") 193 | } 194 | ``` 195 | 196 | ### SQL Server 197 | 198 | Here is an example connection string with a local Microsoft SQL Server database: 199 | 200 | ```prisma 201 | datasource db { 202 | provider = "sqlserver" 203 | url = env("DATABASE_URL") 204 | } 205 | ``` 206 | 207 | ### CockroachDB 208 | 209 | Here is an example connection string with a local CockroachDB database: 210 | 211 | ```prisma 212 | datasource db { 213 | provider = "cockroachdb" 214 | url = env("DATABASE_URL") 215 | } 216 | ``` 217 | 218 | ### MongoDB 219 | 220 | Here is an example connection string with a local MongoDB database: 221 | 222 | ```prisma 223 | datasource db { 224 | provider = "mongodb" 225 | url = env("DATABASE_URL") 226 | } 227 | ``` 228 | --------------------------------------------------------------------------------