-
Notifications
You must be signed in to change notification settings - Fork 131
Initial implementation of persisted queries plugin #324
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
0dead21
Initial implementation of persisted queries plugin
santino 3d565ab
Add request setup to README draft
santino dd15b30
updating types and adding more details to the README
santino 863920e
Do not cache store, to allow refreshing it on the fly
santino 3b3ba2f
return Promise from load() method, to allow custom error handling
santino 0e80b10
implement whitelist functionality
santino File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
## `@envelop/persisted-queries` | ||
|
||
TODO | ||
|
||
## Getting Started | ||
|
||
``` | ||
yarn add @envelop/persisted-queries | ||
``` | ||
|
||
## Basic Usage | ||
|
||
For convenience the plugin expose a Class which allow you to setup a basic store from one or more JSON files. | ||
You can use this or build your own store that implements `PersistedQueriesStore` typescript interface exported by the plugin. | ||
|
||
```ts | ||
import { resolve } from 'path'; | ||
import { envelop } from '@envelop/core'; | ||
import { usePersistedQueries, JsonFilesStore } from '@envelop/persisted-queries'; | ||
|
||
const persistedQueriesStore = new JsonFilesStore([ | ||
resolve(process.cwd(), 'assets/clientOne_persistedQueries.json'), | ||
resolve(process.cwd(), 'assets/clientTwo_persistedQueries.json'), | ||
]); | ||
|
||
const getEnveloped = envelop({ | ||
plugins: [ | ||
usePersistedQueries({ | ||
store: persistedQueriesStore, | ||
onlyPersisted: true, // default `false`. When set to true, will reject requests that don't have a valid query id | ||
}), | ||
// ... other plugins ... | ||
], | ||
}); | ||
|
||
----- | ||
|
||
await persistedQueriesStore.load(); // get store ready, in this case by loading persisted-quries files | ||
|
||
server.listen() // once queries are loaded you can safely start the server | ||
``` | ||
|
||
TODO: explain default behaviour which identifies query id from standard "query" param (GraphQL source) | ||
|
||
## Advanced usage | ||
|
||
In order to build custom logic you probably want to pass the request object (or part of it) to `getEnveloped`, so that this will be available as the initial context | ||
|
||
```ts | ||
httpServer.on('request', (request, response) => { | ||
const { parse, validate, contextFactory, execute, schema } = getEnveloped({ request }); | ||
// ... | ||
} | ||
``` | ||
|
||
TODO, describe the following: | ||
|
||
- when using `JsonFilesStore`, lists are named after file name (without extension) | ||
- build custom logic to set query id: `setQueryId: (context) => context.request.body.queryId // set custom logic to get queryId` | ||
- how to match ids from a single queries list: `` pickSingleList: (context) => `${context.request.headers.applicationName}_persistedQueries` `` | ||
- custom handling of file read errors `.then(lists => { listsResults.forEach(listResult => { if (listResult instanceof Error) { console.log('error', listResult.message); } }) });` | ||
- `setQueryId` and `pickSingleList` can return undefined, explain what happens | ||
- `documentId` property set in context with query id; to be retrieved for other usage (e.g. logging) | ||
- building your own store | ||
- update the store during runtime with updated or new lists, without restarting the server |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
{ | ||
"name": "@envelop/persisted-queries", | ||
"version": "0.0.1", | ||
"author": "Santino Puleio <[email protected]>", | ||
"license": "MIT", | ||
"repository": { | ||
"type": "git", | ||
"url": "https://github.com/dotansimha/envelop.git", | ||
"directory": "packages/plugins/persisted-queries" | ||
}, | ||
"sideEffects": false, | ||
"main": "dist/index.js", | ||
"module": "dist/index.mjs", | ||
"exports": { | ||
".": { | ||
"require": "./dist/index.js", | ||
"import": "./dist/index.mjs" | ||
}, | ||
"./*": { | ||
"require": "./dist/*.js", | ||
"import": "./dist/*.mjs" | ||
} | ||
}, | ||
"typings": "dist/index.d.ts", | ||
"typescript": { | ||
"definition": "dist/index.d.ts" | ||
}, | ||
"scripts": { | ||
"test": "jest", | ||
"prepack": "bob prepack" | ||
}, | ||
"devDependencies": { | ||
"bob-the-bundler": "1.4.1", | ||
"graphql": "15.5.1", | ||
"typescript": "4.3.4" | ||
}, | ||
"peerDependencies": { | ||
"graphql": "^14.0.0 || ^15.0.0" | ||
}, | ||
"buildOptions": { | ||
"input": "./src/index.ts" | ||
}, | ||
"publishConfig": { | ||
"directory": "dist", | ||
"access": "public" | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export * from './jsonFilesStore'; | ||
export * from './plugin'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
/* eslint-disable no-console */ | ||
import { basename, extname, isAbsolute, resolve } from 'path'; | ||
import { readFile } from 'fs/promises'; | ||
import { PersistedQueriesStore, PersistedQueriesStoreList } from './plugin'; | ||
|
||
export class JsonFilesStore implements PersistedQueriesStore { | ||
private store: PersistedQueriesStoreList = new Map(); | ||
private paths: string[]; | ||
|
||
constructor(jsonFilePaths: string[]) { | ||
this.paths = jsonFilePaths; | ||
} | ||
|
||
get(): PersistedQueriesStoreList { | ||
return this.store; | ||
} | ||
|
||
public load(whitelist: string[] = []): Promise<string[]> { | ||
const readListPromises = []; | ||
|
||
for (const filePath of this.paths) { | ||
const extension = extname(filePath); | ||
const listName = basename(filePath, extension); | ||
|
||
if (extension !== '.json') { | ||
console.error(`Persisted query file must be JSON format, received: ${filePath}`); | ||
continue; | ||
} else if (whitelist.length && !whitelist.includes(listName)) { | ||
// in case of whitelist (most likely to update existing store), only process lists whose name is whitelisted | ||
continue; | ||
} | ||
|
||
const queriesFile = filePath && (isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath)); | ||
|
||
readListPromises.push( | ||
readFile(queriesFile, 'utf8') | ||
.then(content => { | ||
console.info(`Successfully loaded persisted queries from "${listName}"`); | ||
this.store.set(listName, JSON.parse(content)); | ||
return filePath; | ||
}) | ||
.catch(error => { | ||
console.error(`Could not load persisted queries from: ${filePath}`); | ||
return error; | ||
}) | ||
); | ||
} | ||
|
||
return Promise.all(readListPromises); | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
/* eslint-disable no-console */ | ||
import { Plugin, DefaultContext } from '@envelop/core'; | ||
import { DocumentNode, parse, Source, GraphQLError } from 'graphql'; | ||
|
||
const contextProperty = 'documentId'; | ||
|
||
export type PersistedQueriesStoreList = Map<string, { [key: string]: string }>; | ||
|
||
export interface PersistedQueriesStore { | ||
get(): PersistedQueriesStoreList; | ||
} | ||
|
||
interface PluginContext { | ||
[contextProperty]?: string; | ||
} | ||
|
||
export type UsePersistedQueriesOptions = { | ||
dotansimha marked this conversation as resolved.
Show resolved
Hide resolved
|
||
store: PersistedQueriesStore; | ||
onlyPersisted?: boolean; | ||
setQueryId?: (context: Readonly<DefaultContext>) => string | undefined; | ||
pickSingleList?: (context: Readonly<DefaultContext>) => string | undefined; | ||
}; | ||
|
||
const DEFAULT_OPTIONS: Omit<UsePersistedQueriesOptions, 'store'> = { | ||
onlyPersisted: false, | ||
}; | ||
|
||
export const usePersistedQueries = (rawOptions: UsePersistedQueriesOptions): Plugin<PluginContext> => { | ||
const options: UsePersistedQueriesOptions = { | ||
...DEFAULT_OPTIONS, | ||
...(rawOptions || {}), | ||
}; | ||
|
||
return { | ||
onParse({ context, params, extendContext, setParsedDocument }) { | ||
const store = options.store.get(); // retrieve fresh instance of store Map | ||
const queryId = options.setQueryId ? options.setQueryId(context) : queryIdFromSource(params.source); | ||
dotansimha marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const pickedListName = options.pickSingleList && options.pickSingleList(context); | ||
const pickedList = pickedListName ? store.get(pickedListName) : undefined; | ||
const hasPickedList = Boolean(pickedList); | ||
|
||
// if a list has been picked, check for persisted query within that list | ||
if (queryId && pickedListName && hasPickedList && pickedList![queryId]) { | ||
return handleSuccess(setParsedDocument, extendContext, pickedList![queryId], queryId, pickedListName); | ||
} | ||
|
||
// if no list picked, search throughout all persisted queries lists available | ||
if (queryId && !pickedListName) { | ||
// eslint-disable-next-line no-restricted-syntax | ||
for (const [listName, queries] of store) { | ||
if (queries[queryId]) { | ||
return handleSuccess(setParsedDocument, extendContext, queries[queryId], queryId, listName); | ||
} | ||
} | ||
} | ||
|
||
// no match found, if onlyPersisted throw error; otherwise oepration flow can progress | ||
if (options.onlyPersisted) return handleFailure(queryId, pickedListName, hasPickedList); | ||
}, | ||
}; | ||
}; | ||
|
||
function queryIdFromSource(source: string | Source): string | undefined { | ||
return typeof source === 'string' && source.length && source.indexOf('{') === -1 ? source : undefined; | ||
} | ||
|
||
function handleSuccess( | ||
setParsedDocument: (doc: DocumentNode) => void, | ||
extendContext: (contextExtension: PluginContext) => void, | ||
rawDocument: string | Source, | ||
queryId: string, | ||
listName: string | ||
) { | ||
console.info(`Persisted query matched in "${listName}", with id "${queryId}"`); | ||
|
||
extendContext({ [contextProperty]: queryId }); | ||
setParsedDocument(parse(rawDocument)); | ||
} | ||
|
||
function handleFailure(queryId?: string, listName?: string, isListAvailable?: boolean) { | ||
if (!queryId) { | ||
console.error('Query id not sent when "onlyPersisted" is set to true, unable to process request'); | ||
|
||
throw new GraphQLError('Must provide query id'); | ||
} | ||
|
||
console.error( | ||
// eslint-disable-next-line no-nested-ternary | ||
listName | ||
? isListAvailable | ||
? `Unable to match query with id "${queryId}", within requested list "${listName}"` | ||
: `Requested persisted queries list not available. List: "${listName}", query id "${queryId}"` | ||
: `Unable to match query with id "${queryId}", across all provided lists` | ||
); | ||
throw new GraphQLError(listName ? 'Unable to match query within requested list' : `Unable to match query with id "${queryId}"`); | ||
} |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.