Skip to content
This repository has been archived by the owner on May 26, 2023. It is now read-only.

Add Plugin Feature to the graphiql IDE #22

Open
wants to merge 4 commits into
base: apollo
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/workflows/codesee-arch-diagram.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
on:
push:
branches:
- apollo
pull_request_target:
types: [opened, synchronize, reopened]

name: CodeSee Map

jobs:
test_map_action:
runs-on: ubuntu-latest
continue-on-error: true
name: Run CodeSee Map Analysis
steps:
- name: checkout
id: checkout
uses: actions/checkout@v2
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0

# codesee-detect-languages has an output with id languages.
- name: Detect Languages
id: detect-languages
uses: Codesee-io/codesee-detect-languages-action@latest

- name: Configure JDK 16
uses: actions/setup-java@v2
if: ${{ fromJSON(steps.detect-languages.outputs.languages).java }}
with:
java-version: '16'
distribution: 'zulu'

# CodeSee Maps Go support uses a static binary so there's no setup step required.

- name: Configure Node.js 14
uses: actions/setup-node@v2
if: ${{ fromJSON(steps.detect-languages.outputs.languages).javascript }}
with:
node-version: '14'

- name: Configure Python 3.x
uses: actions/setup-python@v2
if: ${{ fromJSON(steps.detect-languages.outputs.languages).python }}
with:
python-version: '3.x'
architecture: 'x64'

- name: Configure Ruby '3.x'
uses: ruby/setup-ruby@v1
if: ${{ fromJSON(steps.detect-languages.outputs.languages).ruby }}
with:
ruby-version: '3.0'

# CodeSee Maps Rust support uses a static binary so there's no setup step required.

- name: Generate Map
id: generate-map
uses: Codesee-io/codesee-map-action@latest
with:
step: map
github_ref: ${{ github.ref }}
languages: ${{ steps.detect-languages.outputs.languages }}

- name: Upload Map
id: upload-map
uses: Codesee-io/codesee-map-action@latest
with:
step: mapUpload
api_token: ${{ secrets.CODESEE_ARCH_DIAG_API_TOKEN }}
github_ref: ${{ github.ref }}

- name: Insights
id: insights
uses: Codesee-io/codesee-map-action@latest
with:
step: insights
api_token: ${{ secrets.CODESEE_ARCH_DIAG_API_TOKEN }}
github_ref: ${{ github.ref }}
134 changes: 134 additions & 0 deletions PLUGINS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
## Plugin architecture

React Playground plugins are modelled based on Plugin Architecture of `apollo-server` package

Plugins are javascript objects with keys as names of events and values as functions that
can return a `Promise` or `void`

- `init`
- `preRequest`

Note: Both of the keys are optional

Ex:

```js
const plugin = {
init: function() {
console.log('init called');
},

preRequest: async() => {
console.log('preRequest called');
}
}
```

Plugins can be passed in the following ways

- Using the `renderPlayground` function from `@apollographql/graphql-playground-html` to serve the playground's HTML. This allows specifying the file path to JS or TS module which will be bundled and run on the client's browser. The module must export an object with a `preRequest` function and/or an `init` function.

```ts
// Plugin.ts
export default {
init: () => console.log('Init'),
preRequest: () => console.log('Pre Request'),
}
```

```ts
// Server.ts
import { ApolloServer } from 'apollo-server-express';
import express, { Request, Response } from 'express';
import * as graphqlPlayground from '@apollographql/graphql-playground-html';

const server = new ApolloServer({ /** Apollo Init Options */ });
const app = express();

let playground;
app.get('/graphql', (req: Request, res: Response) => {
if (!playground) {
playground = graphqlPlayground.renderPlaygroundPage({
/** Other playground options */
plugins: [
{
filePath: path.resolve('./Plugin.ts'),
buildOptions: { /** es-build options */ }
},
{
init: () => console.log('Init1'),
preRequest: () => console.log('PreRequest1')
}
],
});
}
res.setHeader('Content-Type', 'text/html');
res.write(playground);
res.end();
return res;
});

await server.start();
server.applyMiddleware({ app });
app.listen({ port: 8080 }
```

This method allows using external npm dependencies, so long as the dependencies are compatible with the browser runtime.

`@apollographql/graphql-playground-html` uses `es-build` to bundle the plugin files. The `buildOptions` property is used to pass any build config on to `es-build`. It accepts all properties that es-build accepts, with the exception of the `format` property, which must be set to `esm`.

The plugin may also be an inline object. In this case, the values are stringified and sent to the browser for rendering/evaluating as part of the HTML.

- If you're using `graphql-playground-html` or `graphql-playground-react` directly, then pass the `plugins`
key in the `options` object in the call to *GraphQLPlayground.init()* as shown below.
```js
GraphQLPlayground.init(root, {
"env": "react",
"canSaveConfig": false,
"headers": {
"test": "test",
},
"plugins": [{
init: async () => {
await new Promise(resolve => {
setTimeout(resolve, 10000)
})
},
preRequest: async (request, linkProps) => {
console.log(`request ${JSON.stringify(request)}`)
if (linkProps) {
linkProps.headers['Apollo-Query-Plan-Experimental'] = 10
}
await new Promise(resolve => {
setTimeout(resolve, 500)
})
}
}]
})
```
Note: `GraphQLPlayground` is exposed in `window` object when you include this package

`init` function is called for all registered plugins after app initialization is complete

`preRequest` function is called for all plugins before each GraphQL request is sent to the backend server
- It takes two arguments `request` and `linkProperties`
- `request` corresponds to the following type
```ts
export interface GraphQLRequestData {
query: string
variables?: any
operationName?: string
extensions?: any
}
```
The query and other params will be sent to the GraphQL server
- `linkProperties` contains the following type exported in `fetchingSagas.ts`
```ts
export interface LinkCreatorProps {
endpoint: string
headers?: Headers
credentials?: string
}
```
The headers will be sent in the GraphQL request and can be modified in any of the plugins

2 changes: 2 additions & 0 deletions packages/graphql-playground-html/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
"definition": "dist/index.d.ts"
},
"dependencies": {
"esbuild": "^0.12.15",
"fast-json-stable-stringify": "^2.1.0",
"xss": "^1.0.8"
}
}
48 changes: 48 additions & 0 deletions packages/graphql-playground-html/src/ApolloPlaygroundPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import stringify from 'fast-json-stable-stringify'
import * as esbuild from 'esbuild'

interface ApolloPlaygroundPluginFunctionMode {
init?: () => Promise<any> | void
preRequest?: (request, linkProperties) => Promise<any> | void
}

type PluginBuildOptions = Exclude<Partial<esbuild.BuildOptions>, 'write' | 'outdir' | 'format'>;

interface ApolloPlaygroundPluginFileMode {
filePath: string
buildOptions?: PluginBuildOptions
}

const cache = {}

export type ApolloPlaygroundPlugin = ApolloPlaygroundPluginFunctionMode | ApolloPlaygroundPluginFileMode

export function processPluginFile (
pluginFilePath: string,
buildOptions: PluginBuildOptions = {}
) {
const cacheString = `${pluginFilePath}${stringify(buildOptions)}`
if (cache[cacheString]) {
return cache[cacheString]
}
const build = esbuild.buildSync({
entryPoints: [pluginFilePath],
target: 'es2015',
bundle: true,
write: false,
outdir: 'out',
format: 'esm',
...buildOptions
})
if (build && build.errors && build.errors.length > 0) {
build.errors.forEach(console.error);
throw new Error('Compilation failed.')
}
const outputFile = build?.outputFiles?.[0]?.text
if (!outputFile || outputFile.length === 0) {
throw new Error('No output file found or output file is empty.')
}
const encodedJs = `data:text/javascript;charset=utf-8,${encodeURIComponent(outputFile)}`
cache[cacheString] = encodedJs
return encodedJs
}
2 changes: 2 additions & 0 deletions packages/graphql-playground-html/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ export {
MiddlewareOptions,
RenderPageOptions,
} from './render-playground-page'

export { ApolloPlaygroundPlugin } from './ApolloPlaygroundPlugin'
51 changes: 45 additions & 6 deletions packages/graphql-playground-html/src/render-playground-page.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { filterXSS } from 'xss';

import getLoadingMarkup from './get-loading-markup'
import { processPluginFile, ApolloPlaygroundPlugin } from './ApolloPlaygroundPlugin'

export interface MiddlewareOptions {
endpoint?: string
Expand All @@ -12,6 +13,7 @@ export interface MiddlewareOptions {
schema?: IntrospectionResult
tabs?: Tab[]
codeTheme?: EditorColours
plugins?: ApolloPlaygroundPlugin[]
}

export type CursorShape = 'line' | 'block' | 'underline'
Expand Down Expand Up @@ -199,14 +201,51 @@ export function renderPlaygroundPage(options: RenderPageOptions) {
const root = document.getElementById('root');
root.classList.add('playgroundIn');
const configText = document.getElementById('playground-config').innerText
if(configText && configText.length) {
try {
GraphQLPlayground.init(root, JSON.parse(configText))

const config = JSON.parse(configText)
// stringify the plugins
config.plugins = [ ${((() => {
if (!extendedOptions.plugins) {
return ''
}
catch(err) {
console.error("could not find config")
const pluginObjects = extendedOptions.plugins.map(
(plugin): string => {
if (plugin.filePath) {
return JSON.stringify({
module: processPluginFile(plugin.filePath, plugin.buildOptions)
})
}
let pluginText = '{'
const keys = Object.keys(plugin)
keys.forEach(function (key, idx) {
pluginText += `${key}: ${plugin[key]}`
if (idx < keys.length - 1)
pluginText += ','
})
pluginText += '}'
return pluginText
})
return pluginObjects.join(",")
})())}]

const pluginsPendingResolution = Promise.all(config.plugins.map(plugin => {
if (plugin.module) {
return import(plugin.module).then(obj => obj.default)
}
}
return plugin
}))

pluginsPendingResolution.then(plugins => {
config.plugins = plugins;
if(configText && configText.length) {
try {
GraphQLPlayground.init(root, config)
}
catch(err) {
console.error("could not find config")
}
}
})
})
</script>
</body>
Expand Down
3 changes: 2 additions & 1 deletion packages/graphql-playground-html/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"strictNullChecks": true,
"suppressImplicitAnyIndexErrors": true,
"noUnusedLocals": true,
"declaration": true
"declaration": true,
"esModuleInterop": true
}
}
Loading