Skip to content

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
wants to merge 6 commits into from
Closed
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
65 changes: 65 additions & 0 deletions packages/plugins/persisted-queries/README.md
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
47 changes: 47 additions & 0 deletions packages/plugins/persisted-queries/package.json
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"
}
}
2 changes: 2 additions & 0 deletions packages/plugins/persisted-queries/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './jsonFilesStore';
export * from './plugin';
51 changes: 51 additions & 0 deletions packages/plugins/persisted-queries/src/jsonFilesStore.ts
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);
}
}
96 changes: 96 additions & 0 deletions packages/plugins/persisted-queries/src/plugin.ts
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 = {
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);
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}"`);
}