diff --git a/.github/workflows/codesee-arch-diagram.yml b/.github/workflows/codesee-arch-diagram.yml new file mode 100644 index 000000000..b288da3f4 --- /dev/null +++ b/.github/workflows/codesee-arch-diagram.yml @@ -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 }} diff --git a/PLUGINS.md b/PLUGINS.md new file mode 100644 index 000000000..7c20b108b --- /dev/null +++ b/PLUGINS.md @@ -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 + diff --git a/packages/graphql-playground-html/package.json b/packages/graphql-playground-html/package.json index 89112c755..b1e701685 100644 --- a/packages/graphql-playground-html/package.json +++ b/packages/graphql-playground-html/package.json @@ -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" } } diff --git a/packages/graphql-playground-html/src/ApolloPlaygroundPlugin.ts b/packages/graphql-playground-html/src/ApolloPlaygroundPlugin.ts new file mode 100644 index 000000000..55074a0be --- /dev/null +++ b/packages/graphql-playground-html/src/ApolloPlaygroundPlugin.ts @@ -0,0 +1,48 @@ +import stringify from 'fast-json-stable-stringify' +import * as esbuild from 'esbuild' + +interface ApolloPlaygroundPluginFunctionMode { + init?: () => Promise | void + preRequest?: (request, linkProperties) => Promise | void +} + +type PluginBuildOptions = Exclude, '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 +} diff --git a/packages/graphql-playground-html/src/index.ts b/packages/graphql-playground-html/src/index.ts index 3cf31e7de..66ce0fff6 100644 --- a/packages/graphql-playground-html/src/index.ts +++ b/packages/graphql-playground-html/src/index.ts @@ -3,3 +3,5 @@ export { MiddlewareOptions, RenderPageOptions, } from './render-playground-page' + +export { ApolloPlaygroundPlugin } from './ApolloPlaygroundPlugin' \ No newline at end of file diff --git a/packages/graphql-playground-html/src/render-playground-page.ts b/packages/graphql-playground-html/src/render-playground-page.ts index de2ff3693..7013526ac 100644 --- a/packages/graphql-playground-html/src/render-playground-page.ts +++ b/packages/graphql-playground-html/src/render-playground-page.ts @@ -1,6 +1,7 @@ import { filterXSS } from 'xss'; import getLoadingMarkup from './get-loading-markup' +import { processPluginFile, ApolloPlaygroundPlugin } from './ApolloPlaygroundPlugin' export interface MiddlewareOptions { endpoint?: string @@ -12,6 +13,7 @@ export interface MiddlewareOptions { schema?: IntrospectionResult tabs?: Tab[] codeTheme?: EditorColours + plugins?: ApolloPlaygroundPlugin[] } export type CursorShape = 'line' | 'block' | 'underline' @@ -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") + } + } + }) }) diff --git a/packages/graphql-playground-html/tsconfig.json b/packages/graphql-playground-html/tsconfig.json index 78ba7da75..bd122f796 100644 --- a/packages/graphql-playground-html/tsconfig.json +++ b/packages/graphql-playground-html/tsconfig.json @@ -12,6 +12,7 @@ "strictNullChecks": true, "suppressImplicitAnyIndexErrors": true, "noUnusedLocals": true, - "declaration": true + "declaration": true, + "esModuleInterop": true } } diff --git a/packages/graphql-playground-react/plugins.md b/packages/graphql-playground-react/plugins.md new file mode 100644 index 000000000..75aa8f8f3 --- /dev/null +++ b/packages/graphql-playground-react/plugins.md @@ -0,0 +1,107 @@ +## 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 +- As part of apollo server's `playground` configuration + ```js + const server = new ApolloServer({ + typeDefs, + resolvers, + playground: { + 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) + }) + } + } + ] + } + }); + ``` + We can see that the above code passes an array of plugin objects with + hooks for init and preRequest events +- If you're using the `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 + diff --git a/packages/graphql-playground-react/src/components/Playground.tsx b/packages/graphql-playground-react/src/components/Playground.tsx index 78ad216ae..f7f79c58e 100644 --- a/packages/graphql-playground-react/src/components/Playground.tsx +++ b/packages/graphql-playground-react/src/components/Playground.tsx @@ -44,6 +44,7 @@ import { setLinkCreator, schemaFetcher, setSubscriptionEndpoint, + setPlugins } from '../state/sessions/fetchingSagas' import { Session } from '../state/sessions/reducers' import { getWorkspaceId } from './Playground/util/getWorkspaceId' @@ -51,6 +52,7 @@ import { getSettings, getSettingsString } from '../state/workspace/reducers' import { Backoff } from './Playground/util/fibonacci-backoff' import { debounce } from 'lodash' import { cachedPrintSchema } from './util' +import { ApolloPlaygroundPlugin } from "../plugins/ApolloPlaygroundPlugin"; export interface Response { resultID: string @@ -92,6 +94,7 @@ export interface Props { ) => ApolloLink workspaceName?: string schema?: GraphQLSchema + plugins?: ApolloPlaygroundPlugin[] } export interface ReduxProps { @@ -195,6 +198,7 @@ export class Playground extends React.PureComponent { setLinkCreator(props.createApolloLink) this.getSchema() setSubscriptionEndpoint(props.subscriptionEndpoint) + setPlugins(props.plugins) } UNSAFE_componentWillMount() { @@ -204,12 +208,25 @@ export class Playground extends React.PureComponent { this.props.injectHeaders(this.props.headers, this.props.endpoint) } - componentDidMount() { + async componentDidMount() { if (this.initialIndex > -1) { this.setState({ selectedSessionIndex: this.initialIndex, } as State) } + + if (this.props.plugins) { + try { + await Promise.all( + this.props.plugins.map( + (plugin) => plugin.init && plugin.init() + ), + ) + } catch (err) { + console.log('Error in executing plugins init method', err) + } + } + this.mounted = true } diff --git a/packages/graphql-playground-react/src/components/PlaygroundWrapper.tsx b/packages/graphql-playground-react/src/components/PlaygroundWrapper.tsx index 9695494f2..0fc0cd7f9 100644 --- a/packages/graphql-playground-react/src/components/PlaygroundWrapper.tsx +++ b/packages/graphql-playground-react/src/components/PlaygroundWrapper.tsx @@ -26,6 +26,7 @@ import { Session, Tab } from '../state/sessions/reducers' import { ApolloLink } from 'apollo-link' import { injectTabs } from '../state/workspace/actions' import { buildSchema, buildClientSchema, GraphQLSchema } from 'graphql' +import { ApolloPlaygroundPlugin } from "../plugins/ApolloPlaygroundPlugin"; function getParameterByName(name: string, uri?: string): string | null { const url = uri || window.location.href @@ -68,6 +69,7 @@ export interface PlaygroundWrapperProps { codeTheme?: EditorColours workspaceName?: string headers?: any + plugins?: ApolloPlaygroundPlugin[] } export interface ReduxProps { @@ -411,6 +413,7 @@ class PlaygroundWrapper extends React.Component< } createApolloLink={this.props.createApolloLink} schema={this.state.schema} + plugins={this.props.plugins} /> diff --git a/packages/graphql-playground-react/src/plugins/ApolloPlaygroundPlugin.ts b/packages/graphql-playground-react/src/plugins/ApolloPlaygroundPlugin.ts new file mode 100644 index 000000000..d6510a23d --- /dev/null +++ b/packages/graphql-playground-react/src/plugins/ApolloPlaygroundPlugin.ts @@ -0,0 +1,7 @@ +import {GraphQLRequestData} from "../components/Playground/util/makeOperation"; +import {LinkCreatorProps} from "../state/sessions/fetchingSagas"; + +export interface ApolloPlaygroundPlugin { + init?: () => Promise | void + preRequest?: (request: GraphQLRequestData, linkProperties: LinkCreatorProps) => Promise | void +} diff --git a/packages/graphql-playground-react/src/state/sessions/fetchingSagas.ts b/packages/graphql-playground-react/src/state/sessions/fetchingSagas.ts index 19e027bd4..22cc99675 100644 --- a/packages/graphql-playground-react/src/state/sessions/fetchingSagas.ts +++ b/packages/graphql-playground-react/src/state/sessions/fetchingSagas.ts @@ -38,14 +38,20 @@ import { Session, ResponseRecord } from './reducers' import { addHistoryItem } from '../history/actions' import { safely } from '../../utils' import { set } from 'immutable' +import { ApolloPlaygroundPlugin } from '../../plugins/ApolloPlaygroundPlugin'; // tslint:disable let subscriptionEndpoint +let plugins: ApolloPlaygroundPlugin[] | null export function setSubscriptionEndpoint(endpoint) { subscriptionEndpoint = endpoint } +export function setPlugins(apolloPlugins: ApolloPlaygroundPlugin[]) { + plugins = apolloPlugins +} + export interface LinkCreatorProps { endpoint: string headers?: Headers @@ -140,6 +146,18 @@ function* runQuerySaga(action) { credentials: settings['request.credentials'], } + if (plugins) { + try { + yield Promise.all( + plugins.map( + (plugin) => plugin.preRequest && plugin.preRequest(request, lol) + ), + ) + } catch (err) { + console.log('Error in executing plugins preRequest method', err) + } + } + const { link, subscriptionClient } = linkCreator(lol, subscriptionEndpoint) yield put(setCurrentQueryStartTime(new Date())) diff --git a/yarn.lock b/yarn.lock index 40bc614d2..bf2b9e1f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3979,6 +3979,11 @@ es6-promise@^4.0.5: resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w== +esbuild@^0.12.15: + version "0.12.15" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.12.15.tgz#9d99cf39aeb2188265c5983e983e236829f08af0" + integrity sha512-72V4JNd2+48eOVCXx49xoSWHgC3/cCy96e7mbXKY+WOWghN00cCmlGnwVLRhRHorvv0dgCyuMYBZlM2xDM5OQw== + escape-html@^1.0.3, escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" @@ -4309,7 +4314,7 @@ fast-glob@^2.0.2: merge2 "^1.2.3" micromatch "^3.1.10" -fast-json-stable-stringify@^2.0.0: +fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==