Repository: mercurius-js/mercurius
Pull request #367 (2020-12-22 15:28:11)

feat: GraphQL playground headers
Adds a `playgroundHeaders` option which works with GraphQL playground and allows adding HTTP headers such as authorization.

It supports objects or, probably more useful, functions. The function version relies on the ability to serialize the function definition to a string, which is injected in the client side JS and invoked with the window object, basically allowing to extract headers from browser's storage or any place accessible to the client.

PabloSzx: there is missing the types, it could be something like 

```ts
playgroundHeaders?: ((window: Window) => object) | object
```

in line 314 of index.d.ts
simoneb: > there is missing the types, it could be something like
> 
> ```ts
> playgroundHeaders?: ((window: Window) => object) | object
> ```
> 
> in line 314 of index.d.ts

good catch. done (had to change tsconfig to allow it to recognize the Window object)

Patch:
diff --git a/docs/api/options.md b/docs/api/options.md
index b8e1a8e..cb023d1 100644
--- a/docs/api/options.md
+++ b/docs/api/options.md
@@ -40,6 +40,7 @@
 
 - `playgroundSettings` Object. that allow you to configure GraphQL Playground with [playground
   options](https://github.com/prisma-labs/graphql-playground#usage). it works if the graphiql is set to `'playground'`.
+- `playgroundHeaders` Object | Function. It provides HTTP headers to GraphQL Playground. If it is an object, it is provided as-is. If it is a function, it is serialized, injected in the generated HTML and invoked with the `window` object as the argument. Useful to read authorization token from browser's storage. See [examples/playground.js](https://github.com/mercurius-js/mercurius/blob/master/examples/playground.js).
 - `jit`: Integer. The minimum number of execution a query needs to be
   executed before being jit'ed.
 - `routes`: boolean. Serves the Default: `true`. A graphql endpoint is
@@ -499,4 +500,3 @@ app.register(mercurius, {
 
 app.listen(3000)
 ```
-
diff --git a/examples/playground.js b/examples/playground.js
new file mode 100644
index 0000000..97c026f
--- /dev/null
+++ b/examples/playground.js
@@ -0,0 +1,39 @@
+'use strict'
+
+const Fastify = require('fastify')
+const mercurius = require('..')
+
+const app = Fastify()
+
+const schema = `
+  type Query {
+    add(x: Int, y: Int): Int
+  }
+`
+
+const resolvers = {
+  Query: {
+    add: async (_, obj) => {
+      const { x, y } = obj
+      return x + y
+    }
+  }
+}
+
+app.register(mercurius, {
+  schema,
+  resolvers,
+  graphiql: 'playground',
+  playgroundHeaders (window) {
+    return {
+      authorization: `bearer ${window.sessionStorage.getItem('token')}`
+    }
+  }
+})
+
+app.get('/', async function (req, reply) {
+  const query = '{ add(x: 2, y: 2) }'
+  return reply.graphql(query)
+})
+
+app.listen(3000)
diff --git a/index.d.ts b/index.d.ts
index f844f7e..94fe1fc 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -311,6 +311,15 @@ export interface MercuriusCommonOptions {
     ['tracing.hideTracingResponse']: boolean;
     ['tracing.tracingSupported']: boolean;
   };
+
+  /**
+   * It provides HTTP headers to GraphQL Playground. If it is an object, 
+   * it is provided as-is. If it is a function, it is serialized, injected 
+   * in the generated HTML and invoked with the `window` object as the argument. 
+   * Useful to read authorization token from browser's storage. 
+   * See [examples/playground.js](https://github.com/mercurius-js/mercurius/blob/master/examples/playground.js).
+   */
+  playgroundHeaders?: ((window: Window) => object) | object;
 }
 
 export type MercuriusOptions = MercuriusCommonOptions & (MercuriusGatewayOptions | MercuriusSchemaOptions)
diff --git a/index.js b/index.js
index e646a1c..ff2e172 100644
--- a/index.js
+++ b/index.js
@@ -217,6 +217,7 @@ const plugin = fp(async function (app, opts) {
       errorFormatter: opts.errorFormatter,
       ide: optsIde,
       ideSettings: opts.playgroundSettings,
+      playgroundHeaders: opts.playgroundHeaders,
       prefix: opts.prefix,
       path: opts.path,
       context: opts.context,
diff --git a/lib/routes.js b/lib/routes.js
index 81dc328..18a2c8b 100644
--- a/lib/routes.js
+++ b/lib/routes.js
@@ -313,6 +313,16 @@ module.exports = async function (app, opts) {
       })
     }
     if (opts.ide === 'playground') {
+      const headers = () => {
+        const h = opts.playgroundHeaders
+
+        const invoke = `(${/^function|=>/.test(h) ? '' : 'function '}${h})(window);`
+
+        return `const headers = ${typeof h === 'function'
+          ? invoke
+          : JSON.stringify(h)}`
+      }
+
       app.get('/playground', (req, reply) => {
         reply.sendFile('playground.html')
       })
@@ -320,12 +330,15 @@ module.exports = async function (app, opts) {
         reply
           .header('Content-Type', 'application/javascript')
           .send(`window.addEventListener('load', function(event) {
-          GraphQLPlayground.init(document.getElementById('root'), {
-            subscriptionEndpoint: '${app.prefix}${graphqlPath}',
-            endpoint: '${app.prefix}${graphqlPath}',
-            settings: ${JSON.stringify(opts.ideSettings)},
-          });
-        });`)
+  ${headers()}
+
+  GraphQLPlayground.init(document.getElementById('root'), {
+    subscriptionEndpoint: '${app.prefix}${graphqlPath}',
+    endpoint: '${app.prefix}${graphqlPath}',
+    settings: ${JSON.stringify(opts.ideSettings)},
+    headers: headers
+  });
+});`)
       })
     }
   }
diff --git a/tsconfig.json b/tsconfig.json
index c8879b2..dc85ffb 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,6 +2,7 @@
   "compilerOptions": {
     "target": "es2018",
     "module": "commonjs",
+    "lib": ["DOM"],
     "noEmit": true,
     "strict": true,
     "esModuleInterop": true
@@ -10,4 +11,4 @@
   "files": [
     "./test/types/index.ts"
   ]
-}
\ No newline at end of file
+}


Test patch:
diff --git a/tap-snapshots/test-routes.js-TAP.test.js b/tap-snapshots/test-routes.js-TAP.test.js
index 9c18269..82e4768 100644
--- a/tap-snapshots/test-routes.js-TAP.test.js
+++ b/tap-snapshots/test-routes.js-TAP.test.js
@@ -70,24 +70,111 @@ exports['test/routes.js TAP if ide is graphiql, serve config.js with the correct
 window.GRAPHQL_ENDPOINT = '/app/graphql'
 `
 
+exports['test/routes.js TAP if ide is playground, and playgroundHeaders is a method, serve init.js with playground headers as iife > must match snapshot 1'] = `
+window.addEventListener('load', function(event) {
+  const headers = (function playgroundHeaders (window) {
+      return {
+        authorization: \`bearer \${window.localStorage.getItem('token')}\`
+      }
+    })(window);
+
+  GraphQLPlayground.init(document.getElementById('root'), {
+    subscriptionEndpoint: '/graphql',
+    endpoint: '/graphql',
+    settings: undefined,
+    headers: headers
+  });
+});
+`
+
+exports['test/routes.js TAP if ide is playground, and playgroundHeaders is a named function, serve init.js with playground headers as iife > must match snapshot 1'] = `
+window.addEventListener('load', function(event) {
+  const headers = (function headers (window) {
+      return {
+        authorization: \`bearer \${window.localStorage.getItem('token')}\`
+      }
+    })(window);
+
+  GraphQLPlayground.init(document.getElementById('root'), {
+    subscriptionEndpoint: '/graphql',
+    endpoint: '/graphql',
+    settings: undefined,
+    headers: headers
+  });
+});
+`
+
+exports['test/routes.js TAP if ide is playground, and playgroundHeaders is an anonymous function, serve init.js with playground headers as iife > must match snapshot 1'] = `
+window.addEventListener('load', function(event) {
+  const headers = (function (window) {
+      return {
+        authorization: \`bearer \${window.localStorage.getItem('token')}\`
+      }
+    })(window);
+
+  GraphQLPlayground.init(document.getElementById('root'), {
+    subscriptionEndpoint: '/graphql',
+    endpoint: '/graphql',
+    settings: undefined,
+    headers: headers
+  });
+});
+`
+
+exports['test/routes.js TAP if ide is playground, and playgroundHeaders is an arrow function, serve init.js with playground headers as iife > must match snapshot 1'] = `
+window.addEventListener('load', function(event) {
+  const headers = (window => {
+      return {
+        authorization: \`bearer \${window.localStorage.getItem('token')}\`
+      }
+    })(window);
+
+  GraphQLPlayground.init(document.getElementById('root'), {
+    subscriptionEndpoint: '/graphql',
+    endpoint: '/graphql',
+    settings: undefined,
+    headers: headers
+  });
+});
+`
+
+exports['test/routes.js TAP if ide is playground, and playgroundHeaders is an object, serve init.js with playground headers options > must match snapshot 1'] = `
+window.addEventListener('load', function(event) {
+  const headers = {"authorization":"bearer token"}
+
+  GraphQLPlayground.init(document.getElementById('root'), {
+    subscriptionEndpoint: '/graphql',
+    endpoint: '/graphql',
+    settings: undefined,
+    headers: headers
+  });
+});
+`
+
 exports['test/routes.js TAP if ide is playground, and playgroundSettings is set, serve init.js with playground editor options > must match snapshot 1'] = `
 window.addEventListener('load', function(event) {
-          GraphQLPlayground.init(document.getElementById('root'), {
-            subscriptionEndpoint: '/graphql',
-            endpoint: '/graphql',
-            settings: {"editor.theme":"light","editor.fontSize":17},
-          });
-        });
+  const headers = undefined
+
+  GraphQLPlayground.init(document.getElementById('root'), {
+    subscriptionEndpoint: '/graphql',
+    endpoint: '/graphql',
+    settings: {"editor.theme":"light","editor.fontSize":17},
+    headers: headers
+  });
+});
 `
 
 exports['test/routes.js TAP if ide is playground, serve init.js with the correct endpoint > must match snapshot 1'] = `
 window.addEventListener('load', function(event) {
-          GraphQLPlayground.init(document.getElementById('root'), {
-            subscriptionEndpoint: '/app/graphql',
-            endpoint: '/app/graphql',
-            settings: undefined,
-          });
-        });
+  const headers = undefined
+
+  GraphQLPlayground.init(document.getElementById('root'), {
+    subscriptionEndpoint: '/app/graphql',
+    endpoint: '/app/graphql',
+    settings: undefined,
+    headers: headers
+  });
+});
 `
 
 exports['test/routes.js TAP mutation with GET errors > must match snapshot 1'] = `
diff --git a/test/routes.js b/test/routes.js
index 9c325b5..b6021dd 100644
--- a/test/routes.js
+++ b/test/routes.js
@@ -1870,6 +1870,134 @@ test('if ide is playground, and playgroundSettings is set, serve init.js with pl
   t.matchSnapshot(res.body)
 })
 
+test('if ide is playground, and playgroundHeaders is an object, serve init.js with playground headers options', async (t) => {
+  const app = Fastify()
+  const schema = `
+    type Query {
+      add(x: Int, y: Int): Int
+    }
+  `
+  app.register(GQL, {
+    ide: 'playground',
+    playgroundHeaders: {
+      authorization: 'bearer token'
+    },
+    schema
+  })
+
+  const res = await app.inject({
+    method: 'GET',
+    url: '/playground/init.js'
+  })
+  t.strictEqual(res.statusCode, 200)
+  t.strictEqual(res.headers['content-type'], 'application/javascript')
+  t.matchSnapshot(res.body)
+})
+
+test('if ide is playground, and playgroundHeaders is a method, serve init.js with playground headers as iife', async (t) => {
+  const app = Fastify()
+  const schema = `
+    type Query {
+      add(x: Int, y: Int): Int
+    }
+  `
+  app.register(GQL, {
+    ide: 'playground',
+    playgroundHeaders (window) {
+      return {
+        authorization: `bearer ${window.localStorage.getItem('token')}`
+      }
+    },
+    schema
+  })
+
+  const res = await app.inject({
+    method: 'GET',
+    url: '/playground/init.js'
+  })
+  t.strictEqual(res.statusCode, 200)
+  t.strictEqual(res.headers['content-type'], 'application/javascript')
+  t.matchSnapshot(res.body)
+})
+
+test('if ide is playground, and playgroundHeaders is a named function, serve init.js with playground headers as iife', async (t) => {
+  const app = Fastify()
+  const schema = `
+    type Query {
+      add(x: Int, y: Int): Int
+    }
+  `
+  app.register(GQL, {
+    ide: 'playground',
+    playgroundHeaders: function headers (window) {
+      return {
+        authorization: `bearer ${window.localStorage.getItem('token')}`
+      }
+    },
+    schema
+  })
+
+  const res = await app.inject({
+    method: 'GET',
+    url: '/playground/init.js'
+  })
+  t.strictEqual(res.statusCode, 200)
+  t.strictEqual(res.headers['content-type'], 'application/javascript')
+  t.matchSnapshot(res.body)
+})
+
+test('if ide is playground, and playgroundHeaders is an anonymous function, serve init.js with playground headers as iife', async (t) => {
+  const app = Fastify()
+  const schema = `
+    type Query {
+      add(x: Int, y: Int): Int
+    }
+  `
+  app.register(GQL, {
+    ide: 'playground',
+    playgroundHeaders: function (window) {
+      return {
+        authorization: `bearer ${window.localStorage.getItem('token')}`
+      }
+    },
+    schema
+  })
+
+  const res = await app.inject({
+    method: 'GET',
+    url: '/playground/init.js'
+  })
+  t.strictEqual(res.statusCode, 200)
+  t.strictEqual(res.headers['content-type'], 'application/javascript')
+  t.matchSnapshot(res.body)
+})
+
+test('if ide is playground, and playgroundHeaders is an arrow function, serve init.js with playground headers as iife', async (t) => {
+  const app = Fastify()
+  const schema = `
+    type Query {
+      add(x: Int, y: Int): Int
+    }
+  `
+  app.register(GQL, {
+    ide: 'playground',
+    playgroundHeaders: window => {
+      return {
+        authorization: `bearer ${window.localStorage.getItem('token')}`
+      }
+    },
+    schema
+  })
+
+  const res = await app.inject({
+    method: 'GET',
+    url: '/playground/init.js'
+  })
+  t.strictEqual(res.statusCode, 200)
+  t.strictEqual(res.headers['content-type'], 'application/javascript')
+  t.matchSnapshot(res.body)
+})
+
 test('if operationName is null, it should work fine', async (t) => {
   const app = Fastify()
   const schema = `