diff --git a/.changeset/neat-pumpkins-attack.md b/.changeset/neat-pumpkins-attack.md new file mode 100644 index 0000000000..b2f53f8c75 --- /dev/null +++ b/.changeset/neat-pumpkins-attack.md @@ -0,0 +1,5 @@ +--- +'@graphql-yoga/common': minor +--- + +New Yoga-specific hooks for plugins: onRequestParse & onRequestParseDone diff --git a/package.json b/package.json index ec0c9b9242..2ee2be69b3 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ }, "workspaces": [ "packages/*", + "packages/plugins/*", "examples/**/*", "benchmark/*", "website", diff --git a/packages/common/src/getGraphQLParameters.ts b/packages/common/src/getGraphQLParameters.ts deleted file mode 100644 index adef9ce493..0000000000 --- a/packages/common/src/getGraphQLParameters.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { dset } from 'dset' -import { GraphQLParams } from './types' - -type RequestParser = { - is: (request: Request) => boolean - parse: (request: Request) => Promise -} - -export const GETRequestParser: RequestParser = { - is: (request) => request.method === 'GET', - parse: async (request) => { - const [, searchParamsStr] = request.url.split('?') - const searchParams = new URLSearchParams(searchParamsStr) - const operationName = searchParams.get('operationName') || undefined - const query = searchParams.get('query') || undefined - const variables = searchParams.get('variables') || undefined - const extensions = searchParams.get('extensions') || undefined - return { - operationName, - query, - variables: variables ? JSON.parse(variables) : undefined, - extensions: extensions ? JSON.parse(extensions) : undefined, - } - }, -} - -export const POSTRequestParser: RequestParser = { - is: (request) => request.method === 'POST', - parse: async (request) => { - const requestBody = await request.json() - return { - operationName: requestBody.operationName, - query: requestBody.query, - variables: requestBody.variables, - extensions: requestBody.extensions, - } - }, -} - -export const POSTMultipartFormDataRequestParser: RequestParser = { - is: (request) => - request.method === 'POST' && - !!request.headers.get('content-type')?.startsWith('multipart/form-data'), - parse: async (request) => { - const requestBody = await request.formData() - const operationsStr = requestBody.get('operations')?.toString() || '{}' - const operations = JSON.parse(operationsStr) - - const mapStr = requestBody.get('map')?.toString() || '{}' - const map = JSON.parse(mapStr) - for (const fileIndex in map) { - const file = requestBody.get(fileIndex) - const [path] = map[fileIndex] - dset(operations, path, file) - } - - return { - operationName: operations.operationName, - query: operations.query, - variables: operations.variables, - extensions: operations.extensions, - } - }, -} - -export function buildGetGraphQLParameters(parsers: Array) { - return async function getGraphQLParameters( - request: Request, - ): Promise { - for (const parser of parsers) { - if (parser.is(request)) { - return parser.parse(request) - } - } - return { - operationName: undefined, - query: undefined, - variables: undefined, - extensions: undefined, - } - } -} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 56aa7af66f..82afa1ed33 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -5,3 +5,6 @@ export * from './server' export * from './graphiql' export * from '@envelop/core' export * from '@graphql-yoga/subscription' +export * from './plugins/types' +export * from './plugins/useRequestParser' +export { Plugin } from './plugins/types' diff --git a/packages/common/src/plugins/requestParser/GET.ts b/packages/common/src/plugins/requestParser/GET.ts new file mode 100644 index 0000000000..8ab3f53a72 --- /dev/null +++ b/packages/common/src/plugins/requestParser/GET.ts @@ -0,0 +1,31 @@ +import { GraphQLParams } from '../../types' +import { Plugin } from '../types' + +export function isGETRequest(request: Request) { + return request.method === 'GET' +} + +export function parseGETRequest(request: Request): GraphQLParams { + const [, searchParamsStr] = request.url.split('?') + const searchParams = new URLSearchParams(searchParamsStr) + const operationName = searchParams.get('operationName') || undefined + const query = searchParams.get('query') || undefined + const variables = searchParams.get('variables') || undefined + const extensions = searchParams.get('extensions') || undefined + return { + operationName, + query, + variables: variables ? JSON.parse(variables) : undefined, + extensions: extensions ? JSON.parse(extensions) : undefined, + } +} + +export function useGETRequestParser(): Plugin { + return { + onRequestParse: async ({ request, setRequestParser }) => { + if (isGETRequest(request)) { + setRequestParser(parseGETRequest) + } + }, + } +} diff --git a/packages/common/src/plugins/requestParser/POST.ts b/packages/common/src/plugins/requestParser/POST.ts new file mode 100644 index 0000000000..e48fdf080b --- /dev/null +++ b/packages/common/src/plugins/requestParser/POST.ts @@ -0,0 +1,26 @@ +import { GraphQLParams } from '../../types' +import { Plugin } from '../types' +import { useRequestParser } from '../useRequestParser' + +export function isPOSTRequest(request: Request) { + return request.method === 'POST' +} + +export async function parsePOSTRequest( + request: Request, +): Promise { + const requestBody = await request.json() + return { + operationName: requestBody.operationName, + query: requestBody.query, + variables: requestBody.variables, + extensions: requestBody.extensions, + } +} + +export function usePOSTRequestParser(): Plugin { + return useRequestParser({ + match: isPOSTRequest, + parse: parsePOSTRequest, + }) +} diff --git a/packages/common/src/plugins/requestParser/POSTMultipart.ts b/packages/common/src/plugins/requestParser/POSTMultipart.ts new file mode 100644 index 0000000000..d18ebe8231 --- /dev/null +++ b/packages/common/src/plugins/requestParser/POSTMultipart.ts @@ -0,0 +1,42 @@ +import { dset } from 'dset' +import { GraphQLParams } from '../../types' +import { Plugin } from '../types' +import { useRequestParser } from '../useRequestParser' +import { isPOSTRequest } from './POST' + +export function isPOSTMultipartRequest(request: Request): boolean { + return ( + isPOSTRequest(request) && + !!request.headers.get('content-type')?.startsWith('multipart/form-data') + ) +} + +export async function parsePOSTMultipartRequest( + request: Request, +): Promise { + const requestBody = await request.formData() + const operationsStr = requestBody.get('operations')?.toString() || '{}' + const operations = JSON.parse(operationsStr) + + const mapStr = requestBody.get('map')?.toString() || '{}' + const map = JSON.parse(mapStr) + for (const fileIndex in map) { + const file = requestBody.get(fileIndex) + const [path] = map[fileIndex] + dset(operations, path, file) + } + + return { + operationName: operations.operationName, + query: operations.query, + variables: operations.variables, + extensions: operations.extensions, + } +} + +export function usePOSTMultipartRequestParser(): Plugin { + return useRequestParser({ + match: isPOSTMultipartRequest, + parse: parsePOSTMultipartRequest, + }) +} diff --git a/packages/common/src/plugins/types.ts b/packages/common/src/plugins/types.ts new file mode 100644 index 0000000000..fa0d336055 --- /dev/null +++ b/packages/common/src/plugins/types.ts @@ -0,0 +1,35 @@ +import { Plugin as EnvelopPlugin, PromiseOrValue } from '@envelop/core' +import { GraphQLParams } from '../types' + +export type Plugin< + PluginContext extends Record = {}, + TServerContext = {}, +> = EnvelopPlugin & { + onRequestParse?: OnRequestParseHook +} + +export type OnRequestParseHook = ( + payload: OnRequestParseEventPayload, +) => PromiseOrValue + +export type RequestParser = (request: Request) => PromiseOrValue + +export interface OnRequestParseEventPayload { + serverContext: TServerContext | undefined + request: Request + requestParser: RequestParser + setRequestParser: (parser: RequestParser) => void +} + +export type OnRequestParseHookResult = { + onRequestParseDone?: OnRequestParseDoneHook +} + +export type OnRequestParseDoneHook = ( + payload: OnRequestParseDoneEventPayload, +) => PromiseOrValue + +export interface OnRequestParseDoneEventPayload { + params: GraphQLParams + setParams: (params: GraphQLParams) => void +} diff --git a/packages/common/src/plugins/useRequestParser.ts b/packages/common/src/plugins/useRequestParser.ts new file mode 100644 index 0000000000..984b7cd0bc --- /dev/null +++ b/packages/common/src/plugins/useRequestParser.ts @@ -0,0 +1,23 @@ +import { Plugin } from './types' +import { PromiseOrValue } from '@envelop/core' +import { GraphQLParams } from '../types' + +interface RequestParserPluginOptions { + match?(request: Request): boolean + parse(request: Request): PromiseOrValue +} + +const DEFAULT_MATCHER = () => true + +export function useRequestParser(options: RequestParserPluginOptions): Plugin { + const matchFn = options.match || DEFAULT_MATCHER + return { + onRequestParse({ request, setRequestParser }) { + if (matchFn(request)) { + setRequestParser(function useRequestParserFn(request: Request) { + return options.parse(request) + }) + } + }, + } +} diff --git a/packages/common/src/server.ts b/packages/common/src/server.ts index 38b9fef75a..d51c9f56e3 100644 --- a/packages/common/src/server.ts +++ b/packages/common/src/server.ts @@ -1,6 +1,5 @@ import { GraphQLError, GraphQLSchema, isSchema, print } from 'graphql' import { - Plugin, GetEnvelopedFn, envelop, useMaskedErrors, @@ -14,11 +13,7 @@ import { import { useValidationCache, ValidationCache } from '@envelop/validation-cache' import { ParserCacheOptions, useParserCache } from '@envelop/parser-cache' import { makeExecutableSchema } from '@graphql-tools/schema' -import type { - ExecutionResult, - IResolvers, - TypeSource, -} from '@graphql-tools/utils' +import { ExecutionResult, IResolvers, TypeSource } from '@graphql-tools/utils' import { CORSOptions, GraphQLServerInject, @@ -27,20 +22,23 @@ import { FetchAPI, GraphQLParams, } from './types' +import { + OnRequestParseDoneHook, + OnRequestParseHook, + Plugin, + RequestParser, +} from './plugins/types' import { GraphiQLOptions, renderGraphiQL, shouldRenderGraphiQL, } from './graphiql' import * as crossUndiciFetch from 'cross-undici-fetch' -import { - buildGetGraphQLParameters, - GETRequestParser, - POSTMultipartFormDataRequestParser, - POSTRequestParser, -} from './getGraphQLParameters' import { processRequest } from './processRequest' import { defaultYogaLogger, titleBold, YogaLogger } from './logger' +import { useGETRequestParser } from './plugins/requestParser/GET' +import { usePOSTRequestParser } from './plugins/requestParser/POST' +import { usePOSTMultipartRequestParser } from './plugins/requestParser/POSTMultipart' import { getCORSHeadersByRequestAndOptions } from './cors' interface OptionsWithPlugins { @@ -210,10 +208,10 @@ export class YogaServer< fetch: typeof fetch ReadableStream: typeof ReadableStream } - - private getGraphQLParameters: ( - request: Request, - ) => PromiseOrValue + protected plugins: Array< + Plugin + > + private onRequestParseHooks: OnRequestParseHook[] renderGraphiQL: (options?: GraphiQLOptions) => PromiseOrValue @@ -251,89 +249,104 @@ export class YogaServer< const maskedErrors = options?.maskedErrors ?? true - this.getEnveloped = envelop({ - plugins: [ - // Use the schema provided by the user - enableIf(schema != null, useSchema(schema!)), - // Performance things - enableIf(options?.parserCache !== false, () => - useParserCache( - typeof options?.parserCache === 'object' - ? options?.parserCache - : undefined, - ), - ), - enableIf(options?.validationCache !== false, () => - useValidationCache({ - cache: - typeof options?.validationCache === 'object' - ? options?.validationCache - : undefined, - }), + this.plugins = [ + // Use the schema provided by the user + enableIf(schema != null, useSchema(schema!)), + // Performance things + enableIf(options?.parserCache !== false, () => + useParserCache( + typeof options?.parserCache === 'object' + ? options?.parserCache + : undefined, ), - // Log events - useful for debugging purposes - enableIf( - logger !== false, - useLogger({ - skipIntrospection: true, - logFn: (eventName, events) => { - switch (eventName) { - case 'execute-start': - case 'subscribe-start': - this.logger.debug(titleBold('Execution start')) - const { + ), + enableIf(options?.validationCache !== false, () => + useValidationCache({ + cache: + typeof options?.validationCache === 'object' + ? options?.validationCache + : undefined, + }), + ), + // Log events - useful for debugging purposes + enableIf( + logger !== false, + useLogger({ + skipIntrospection: true, + logFn: (eventName, events) => { + switch (eventName) { + case 'execute-start': + case 'subscribe-start': + this.logger.debug(titleBold('Execution start')) + const { + query, + operationName, + variables, + extensions, + }: YogaInitialContext = events.args.contextValue + if (query) { + this.logger.debug( + '\n' + titleBold('Received GraphQL operation:') + '\n', query, - operationName, - variables, - extensions, - }: YogaInitialContext = events.args.contextValue - if (query) { - this.logger.debug( - '\n' + titleBold('Received GraphQL operation:') + '\n', - query, - ) - } - if (operationName) { - this.logger.debug('\t operationName:', operationName) - } - if (variables) { - this.logger.debug('\t variables:', variables) - } - if (extensions) { - this.logger.debug('\t extensions:', extensions) - } - break - case 'execute-end': - case 'subscribe-end': - this.logger.debug(titleBold('Execution end')) - this.logger.debug('\t result:', events.result) - break - } - }, - }), - ), - enableIf( - options?.context != null, - useExtendContext(async (initialContext) => { - if (options?.context) { - if (typeof options.context === 'function') { - return (options.context as Function)(initialContext) - } else { - return options.context - } + ) + } + if (operationName) { + this.logger.debug('\t operationName:', operationName) + } + if (variables) { + this.logger.debug('\t variables:', variables) + } + if (extensions) { + this.logger.debug('\t extensions:', extensions) + } + break + case 'execute-end': + case 'subscribe-end': + this.logger.debug(titleBold('Execution end')) + this.logger.debug('\t result:', events.result) + break } - }), - ), - ...(options?.plugins ?? []), - enableIf( - !!maskedErrors, - useMaskedErrors( - typeof maskedErrors === 'object' ? maskedErrors : undefined, - ), + }, + }), + ), + enableIf( + options?.context != null, + useExtendContext(async (initialContext) => { + if (options?.context) { + if (typeof options.context === 'function') { + return (options.context as Function)(initialContext) + } else { + return options.context + } + } + }), + ), + useGETRequestParser(), + usePOSTRequestParser(), + enableIf(options?.multipart !== false, () => + usePOSTMultipartRequestParser(), + ), + usePOSTMultipartRequestParser(), + ...(options?.plugins ?? []), + enableIf( + !!maskedErrors, + useMaskedErrors( + typeof maskedErrors === 'object' ? maskedErrors : undefined, ), - ], + ), + ] + + this.getEnveloped = envelop({ + plugins: this.plugins, }) as GetEnvelopedFn + this.onRequestParseHooks = [] + for (const plugin of this.plugins) { + if (plugin && plugin.onRequestParse != null) { + this.onRequestParseHooks.push(plugin.onRequestParse.bind(plugin)) + } + } + if (options?.cors != null) { if (typeof options.cors === 'function') { this.corsOptionsFactory = options.cors @@ -360,13 +373,6 @@ export class YogaServer< this.renderGraphiQL = options?.renderGraphiQL || renderGraphiQL this.endpoint = options?.endpoint - - const requestParsers = [GETRequestParser] - if (options?.multipart !== false) { - requestParsers.push(POSTMultipartFormDataRequestParser) - } - requestParsers.push(POSTRequestParser) - this.getGraphQLParameters = buildGetGraphQLParameters(requestParsers) } getCORSResponseHeaders( @@ -472,19 +478,41 @@ export class YogaServer< } } + let requestParser: RequestParser = () => ({}) + let onRequestParseDoneList: OnRequestParseDoneHook[] = [] + + for (const onRequestParse of this.onRequestParseHooks) { + const onRequestParseResult = await onRequestParse({ + serverContext, + request, + requestParser, + setRequestParser(parser: RequestParser) { + requestParser = parser + }, + }) + if (onRequestParseResult?.onRequestParseDone != null) { + onRequestParseDoneList.push(onRequestParseResult.onRequestParseDone) + } + } + this.logger.debug(`Extracting GraphQL Parameters`) + let params = await requestParser(request) - const { query, variables, operationName, extensions } = - await this.getGraphQLParameters(request) + for (const onRequestParseDone of onRequestParseDoneList) { + await onRequestParseDone({ + params, + setParams(newParams: GraphQLParams) { + params = newParams + }, + }) + } const initialContext = { request, - query, - variables, - operationName, - extensions, + ...params, ...serverContext, } as YogaInitialContext & TServerContext + const { execute, validate, subscribe, parse, contextFactory, schema } = this.getEnveloped(initialContext) @@ -493,9 +521,9 @@ export class YogaServer< const corsHeaders = this.getCORSResponseHeaders(request, initialContext) const response = await processRequest({ request, - query, - variables, - operationName, + query: initialContext.query, + variables: initialContext.variables, + operationName: initialContext.operationName, execute, validate, subscribe, diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index 7367c0c6dc..c8a18570d3 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -22,11 +22,11 @@ export interface ExecutionPatchResult< export interface GraphQLParams< TVariables = Record, - TExtensions = Record, + TExtensions = Record, > { operationName?: string query?: string - variables?: string | TVariables + variables?: TVariables extensions?: TExtensions } diff --git a/tsconfig.json b/tsconfig.json index ba9e8481af..263b126b6b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,7 +29,8 @@ "@graphql-yoga/render-graphiql": [ "packages/render-graphiql/src/index.ts" ], - "graphql-yoga": ["packages/graphql-yoga/src/index.ts"] + "graphql-yoga": ["packages/graphql-yoga/src/index.ts"], + "@graphql-yoga/plugin-*": ["packages/plugins/*/src/index.ts"] }, "jsx": "preserve" },