diff --git a/.changeset/long-ears-brake.md b/.changeset/long-ears-brake.md new file mode 100644 index 0000000000..c482d5dac2 --- /dev/null +++ b/.changeset/long-ears-brake.md @@ -0,0 +1,5 @@ +--- +'@graphql-yoga/plugin-persisted-queries': major +--- + +New Persisted Queries Plugin diff --git a/packages/common/src/processRequest.ts b/packages/common/src/processRequest.ts index ad93f3dbd4..056eeb9966 100644 --- a/packages/common/src/processRequest.ts +++ b/packages/common/src/processRequest.ts @@ -50,7 +50,7 @@ export async function processRequest({ ReadableStream, }: RequestProcessContext): Promise { function getErrorResponse({ - status = 500, + status = 500, // default status code headers, errors, isEventStream, diff --git a/packages/common/src/server.ts b/packages/common/src/server.ts index 89113bd038..b18ef3a278 100644 --- a/packages/common/src/server.ts +++ b/packages/common/src/server.ts @@ -21,6 +21,7 @@ import { FetchEvent, FetchAPI, GraphQLParams, + GraphQLYogaError, } from './types' import { OnRequestParseDoneHook, @@ -60,87 +61,87 @@ export type YogaServerOptions< TServerContext extends Record, TUserContext extends Record, TRootValue, -> = { - /** - * Enable/disable logging or provide a custom logger. - * @default true - */ - logging?: boolean | YogaLogger - /** - * Prevent leaking unexpected errors to the client. We highly recommend enabling this in production. - * If you throw `GraphQLYogaError`/`EnvelopError` within your GraphQL resolvers then that error will be sent back to the client. - * - * You can lean more about this here: - * @see https://graphql-yoga.vercel.app/docs/features/error-masking - * - * Default: `true` - */ - maskedErrors?: boolean | UseMaskedErrorsOpts - /** - * Context - */ - context?: + > = { + /** + * Enable/disable logging or provide a custom logger. + * @default true + */ + logging?: boolean | YogaLogger + /** + * Prevent leaking unexpected errors to the client. We highly recommend enabling this in production. + * If you throw `GraphQLYogaError`/`EnvelopError` within your GraphQL resolvers then that error will be sent back to the client. + * + * You can lean more about this here: + * @see https://graphql-yoga.vercel.app/docs/features/error-masking + * + * Default: `true` + */ + maskedErrors?: boolean | UseMaskedErrorsOpts + /** + * Context + */ + context?: | (( - initialContext: YogaInitialContext & TServerContext, - ) => Promise | TUserContext) + initialContext: YogaInitialContext & TServerContext, + ) => Promise | TUserContext) | Promise | TUserContext - cors?: + cors?: | (( - request: Request, - ...args: {} extends TServerContext - ? [serverContext?: TServerContext | undefined] - : [serverContext: TServerContext] - ) => CORSOptions) + request: Request, + ...args: {} extends TServerContext + ? [serverContext?: TServerContext | undefined] + : [serverContext: TServerContext] + ) => CORSOptions) | CORSOptions | boolean - /** - * GraphQL endpoint - */ - endpoint?: string - - /** - * GraphiQL options - * - * Default: `true` - */ - graphiql?: + /** + * GraphQL endpoint + */ + endpoint?: string + + /** + * GraphiQL options + * + * Default: `true` + */ + graphiql?: | GraphiQLOptions | (( - request: Request, - ...args: {} extends TServerContext - ? [serverContext?: TServerContext | undefined] - : [serverContext: TServerContext] - ) => GraphiQLOptions | boolean) + request: Request, + ...args: {} extends TServerContext + ? [serverContext?: TServerContext | undefined] + : [serverContext: TServerContext] + ) => GraphiQLOptions | boolean) | boolean - renderGraphiQL?: (options?: GraphiQLOptions) => PromiseOrValue + renderGraphiQL?: (options?: GraphiQLOptions) => PromiseOrValue - schema?: + schema?: | GraphQLSchema | { - typeDefs: TypeSource - resolvers?: - | IResolvers< - TRootValue, - TUserContext & TServerContext & YogaInitialContext - > - | Array< - IResolvers< - TRootValue, - TUserContext & TServerContext & YogaInitialContext - > - > - } + typeDefs: TypeSource + resolvers?: + | IResolvers< + TRootValue, + TUserContext & TServerContext & YogaInitialContext + > + | Array< + IResolvers< + TRootValue, + TUserContext & TServerContext & YogaInitialContext + > + > + } - parserCache?: boolean | ParserCacheOptions - validationCache?: boolean | ValidationCache - fetchAPI?: FetchAPI - multipart?: boolean -} & Partial< - OptionsWithPlugins -> + parserCache?: boolean | ParserCacheOptions + validationCache?: boolean | ValidationCache + fetchAPI?: FetchAPI + multipart?: boolean + } & Partial< + OptionsWithPlugins + > export function getDefaultSchema() { return makeExecutableSchema({ @@ -185,7 +186,7 @@ export class YogaServer< TServerContext extends Record, TUserContext extends Record, TRootValue, -> { + > { /** * Instance of envelop */ @@ -233,9 +234,9 @@ export class YogaServer< ? isSchema(options.schema) ? options.schema : makeExecutableSchema({ - typeDefs: options.schema.typeDefs, - resolvers: options.schema.resolvers, - }) + typeDefs: options.schema.typeDefs, + resolvers: options.schema.resolvers, + }) : getDefaultSchema() const logger = options?.logging != null ? options.logging : true @@ -244,11 +245,11 @@ export class YogaServer< ? logger === true ? defaultYogaLogger : { - debug: () => {}, - error: () => {}, - warn: () => {}, - info: () => {}, - } + debug: () => { }, + error: () => { }, + warn: () => { }, + info: () => { }, + } : logger const maskedErrors = options?.maskedErrors ?? true @@ -554,8 +555,8 @@ export class YogaServer< error instanceof GraphQLError ? error : { - message: error.message, - }, + message: error.message, + }, ], }), { @@ -643,17 +644,17 @@ export class YogaServer< export type YogaServerInstance = YogaServer & - ( - | WindowOrWorkerGlobalScope['fetch'] - | ((context: { request: Request }) => Promise) - ) + ( + | WindowOrWorkerGlobalScope['fetch'] + | ((context: { request: Request }) => Promise) + ) export function createServer< TServerContext extends Record = {}, TUserContext extends Record = {}, TRootValue = {}, ->( - options?: YogaServerOptions, + >( + options?: YogaServerOptions, ): YogaServerInstance { const server = new YogaServer( options, diff --git a/packages/plugins/persisted-queries/package.json b/packages/plugins/persisted-queries/package.json new file mode 100644 index 0000000000..7f778097ef --- /dev/null +++ b/packages/plugins/persisted-queries/package.json @@ -0,0 +1,49 @@ +{ + "name": "@graphql-yoga/plugin-persisted-queries", + "version": "0.0.0", + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/dotansimha/graphql-yoga.git", + "directory": "packages/plugins/persisted-queries" + }, + "main": "dist/index.js", + "module": "dist/index.mjs", + "scripts": { + "prepack": "bob prepack", + "check": "tsc --pretty --noEmit" + }, + "author": "Arda TANRIKULU ", + "license": "MIT", + "buildOptions": { + "input": "./src/index.ts" + }, + "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" + }, + "publishConfig": { + "directory": "dist", + "access": "public" + }, + "dependencies": { + "tiny-lru": "^8.0.2", + "tslib": "^2.3.1" + }, + "peerDependencies": { + "@graphql-yoga/common": "^2.3.0" + }, + "devDependencies": { + "bob-the-bundler": "^1.5.1" + } +} diff --git a/packages/plugins/persisted-queries/src/index.ts b/packages/plugins/persisted-queries/src/index.ts new file mode 100644 index 0000000000..ba7df1bc7e --- /dev/null +++ b/packages/plugins/persisted-queries/src/index.ts @@ -0,0 +1,93 @@ +import { GraphQLYogaError, Plugin, PromiseOrValue } from '@graphql-yoga/common' +import lru from 'tiny-lru' + +export interface PersistedQueriesStoreOptions { + max?: number + ttl?: number +} + +export function createInMemoryPersistedQueriesStore( + options: PersistedQueriesStoreOptions = {}, +) { + return lru(options.max ?? 1000, options.ttl ?? 36000) +} + +export interface PersistedQueriesOptions { + store?: PersistedQueriesStore + mode?: PersistedQueriesMode + hash?: (str: string) => PromiseOrValue +} + +export enum PersistedQueriesMode { + AUTOMATIC = 'AUTOMATIC', + MANUAL = 'MANUAL', + PERSISTED_ONLY = 'PERSISTED_ONLY', +} + +export interface PersistedQueriesStore { + get(key: string): PromiseOrValue + set?(key: string, query: string): PromiseOrValue +} + +export interface PersistedQueryExtension { + version: 1 + sha256Hash: string +} + +export function usePersistedQueries( + options: PersistedQueriesOptions = {}, +): Plugin { + const { + mode = PersistedQueriesMode.AUTOMATIC, + store = createInMemoryPersistedQueriesStore(), + hash, + } = options + if (mode === PersistedQueriesMode.AUTOMATIC && store.set == null) { + throw new Error( + `Automatic Persisted Queries require "set" method to be implemented`, + ) + } + return { + onRequestParse() { + return { + onRequestParseDone: async function persistedQueriesOnRequestParseDone({ + params, + setParams, + }) { + const persistedQueryData: PersistedQueryExtension = + params.extensions?.persistedQuery + if ( + mode === PersistedQueriesMode.PERSISTED_ONLY && + persistedQueryData == null + ) { + throw new GraphQLYogaError('PersistedQueryOnly') + } + if (persistedQueryData?.version === 1) { + if (params.query == null) { + const persistedQuery = await store.get( + persistedQueryData.sha256Hash, + ) + if (persistedQuery == null) { + throw new GraphQLYogaError('PersistedQueryNotFound') + } + setParams({ + ...params, + query: persistedQuery, + }) + } else { + if (hash != null) { + const expectedHash = await hash(params.query) + if (persistedQueryData.sha256Hash !== expectedHash) { + throw new GraphQLYogaError('PersistedQueryMismatch') + } + } + if (mode === PersistedQueriesMode.AUTOMATIC) { + await store.set!(persistedQueryData.sha256Hash, params.query) + } + } + } + }, + } + }, + } +} diff --git a/packages/plugins/persisted-queries/tests/persisted-queries.test.ts b/packages/plugins/persisted-queries/tests/persisted-queries.test.ts new file mode 100644 index 0000000000..72dfb0dc30 --- /dev/null +++ b/packages/plugins/persisted-queries/tests/persisted-queries.test.ts @@ -0,0 +1,191 @@ +import { YogaServerInstance } from '@graphql-yoga/common' +import { createServer } from 'graphql-yoga' +import request from 'supertest' +import { + createInMemoryPersistedQueriesStore, + PersistedQueriesMode, + PersistedQueriesStore, + usePersistedQueries, +} from '../src' + +describe('Persisted Queries', () => { + let yoga: ReturnType + let store: ReturnType + beforeAll(async () => { + store = createInMemoryPersistedQueriesStore() + yoga = createServer({ + plugins: [ + usePersistedQueries({ + store, + }), + ], + }) + }) + afterAll(() => { + store.clear() + }) + it('should return not found error if persisted query is missing', async () => { + const response = await request(yoga) + .post('/graphql') + .send({ + extensions: { + persistedQuery: { + version: 1, + sha256Hash: + 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38', + }, + }, + }) + + const body = JSON.parse(response.text) + expect(body.errors).toBeDefined() + expect(body.errors[0].message).toBe('PersistedQueryNotFound') + }) + it('should load the persisted query when stored', async () => { + const persistedQueryEntry = { + version: 1, + sha256Hash: + 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38', + } + store.set(persistedQueryEntry.sha256Hash, '{__typename}') + const response = await request(yoga) + .post('/graphql') + .send({ + extensions: { + persistedQuery: persistedQueryEntry, + }, + }) + + const body = JSON.parse(response.text) + expect(body.errors).toBeUndefined() + expect(body.data.__typename).toBe('Query') + }) + describe('Automatic', () => { + beforeAll(async () => { + store = createInMemoryPersistedQueriesStore() + yoga = createServer({ + plugins: [ + usePersistedQueries({ + store, + mode: PersistedQueriesMode.AUTOMATIC, + }), + ], + }) + }) + it('should save the persisted query', async () => { + const persistedQueryEntry = { + version: 1, + sha256Hash: + 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38', + } + const query = `{__typename}` + const response = await request(yoga) + .post('/graphql') + .send({ + query, + extensions: { + persistedQuery: persistedQueryEntry, + }, + }) + + const entry = store.get(persistedQueryEntry.sha256Hash) + expect(entry).toBe(query) + + const body = JSON.parse(response.text) + expect(body.errors).toBeUndefined() + expect(body.data.__typename).toBe('Query') + }) + }) + describe('Persisted Only', () => { + beforeAll(async () => { + store = createInMemoryPersistedQueriesStore() + yoga = createServer({ + plugins: [ + usePersistedQueries({ + store, + mode: PersistedQueriesMode.PERSISTED_ONLY, + }), + ], + }) + }) + it('should not allow regular queries', async () => { + const query = `{__typename}` + const response = await request(yoga).post('/graphql').send({ + query, + }) + + const body = JSON.parse(response.text) + expect(body.errors).toBeDefined() + expect(body.errors[0].message).toBe('PersistedQueryOnly') + }) + it('should not save the persisted query', async () => { + const persistedQueryEntry = { + version: 1, + sha256Hash: + 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38', + } + const query = `{__typename}` + const response = await request(yoga) + .post('/graphql') + .send({ + query, + extensions: { + persistedQuery: persistedQueryEntry, + }, + }) + + const entry = store.get(persistedQueryEntry.sha256Hash) + expect(entry).toBeFalsy() + + const body = JSON.parse(response.text) + expect(body.errors).toBeUndefined() + expect(body.data.__typename).toBe('Query') + }) + }) + describe('Manual', () => { + beforeAll(async () => { + store = createInMemoryPersistedQueriesStore() + yoga = createServer({ + plugins: [ + usePersistedQueries({ + store, + mode: PersistedQueriesMode.MANUAL, + }), + ], + }) + }) + it('should allow regular queries', async () => { + const query = `{__typename}` + const response = await request(yoga).post('/graphql').send({ + query, + }) + + const body = JSON.parse(response.text) + expect(body.errors).toBeUndefined() + expect(body.data.__typename).toBe('Query') + }) + it('should not save the persisted query', async () => { + const persistedQueryEntry = { + version: 1, + sha256Hash: + 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38', + } + const query = `{__typename}` + const response = await request(yoga) + .post('/graphql') + .send({ + query, + extensions: { + persistedQuery: persistedQueryEntry, + }, + }) + + const entry = store.get(persistedQueryEntry.sha256Hash) + expect(entry).toBeFalsy() + + const body = JSON.parse(response.text) + expect(body.errors).toBeUndefined() + expect(body.data.__typename).toBe('Query') + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index 979aef0de6..10d7146735 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21481,7 +21481,7 @@ tiny-lru@7.0.6, tiny-lru@^7.0.0: resolved "https://registry.yarnpkg.com/tiny-lru/-/tiny-lru-7.0.6.tgz#b0c3cdede1e5882aa2d1ae21cb2ceccf2a331f24" integrity sha512-zNYO0Kvgn5rXzWpL0y3RS09sMK67eGaQj9805jlK9G6pSadfriTczzLHFXa/xcW4mIRfmlB9HyQ/+SgL0V1uow== -tiny-lru@8.0.2: +tiny-lru@8.0.2, tiny-lru@^8.0.2: version "8.0.2" resolved "https://registry.yarnpkg.com/tiny-lru/-/tiny-lru-8.0.2.tgz#812fccbe6e622ded552e3ff8a4c3b5ff34a85e4c" integrity sha512-ApGvZ6vVvTNdsmt676grvCkUCGwzG9IqXma5Z07xJgiC5L7akUMof5U8G2JTI9Rz/ovtVhJBlY6mNhEvtjzOIg==