From d13b8a4a25f665e8484d64214b566e207de73514 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Thu, 6 Feb 2025 17:25:49 +0300 Subject: [PATCH] feat: replace/wrap the logic `params` handled - `ParamsHandler` (#3736) * feat(onParams-handler) * Move error handling inside * Format * Unit tests for params handler * Make linter happy * Update packages/graphql-yoga/src/server.ts --- .changeset/friendly-actors-brake.md | 20 +++ .../__tests__/plugin-hooks.spec.ts | 37 +++++ packages/graphql-yoga/src/plugins/types.ts | 18 ++- packages/graphql-yoga/src/server.ts | 141 ++++++++++-------- 4 files changed, 150 insertions(+), 66 deletions(-) create mode 100644 .changeset/friendly-actors-brake.md diff --git a/.changeset/friendly-actors-brake.md b/.changeset/friendly-actors-brake.md new file mode 100644 index 0000000000..e3ef9a0c73 --- /dev/null +++ b/.changeset/friendly-actors-brake.md @@ -0,0 +1,20 @@ +--- +'graphql-yoga': minor +--- + +Now it is possible to replace or wrap the logic how `GraphQLParams` handled; + +By default Yoga calls Envelop to handle the parameters, but now you can replace it with your own logic. + +Example: Wrap the GraphQL handling pipeline in an `AsyncLocalStorage` + +```ts +function myPlugin(): Plugin { + const context = new AsyncLocalStorage(); + return { + onParams({ paramsHandler, setParamsHandler }) { + const store = { foo: 'bar' } + setParamsHandler(payload => context.run(store, paramsHandler, payload)) + } +} +``` \ No newline at end of file diff --git a/packages/graphql-yoga/__tests__/plugin-hooks.spec.ts b/packages/graphql-yoga/__tests__/plugin-hooks.spec.ts index fb5564105c..015c8feffc 100644 --- a/packages/graphql-yoga/__tests__/plugin-hooks.spec.ts +++ b/packages/graphql-yoga/__tests__/plugin-hooks.spec.ts @@ -1,4 +1,5 @@ import { createSchema, createYoga, type Plugin } from '../src'; +import type { ParamsHandlerPayload } from '../src/plugins/types'; import { eventStream } from './utilities'; test('onParams -> setResult to single execution result', async () => { @@ -60,6 +61,42 @@ test('onParams -> setResult to event stream execution result', async () => { expect(counter).toBe(2); }); +test('onParams -> replaces the params handler correctly', async () => { + const paramsHandler = jest.fn((_payload: ParamsHandlerPayload<{}>) => ({ + data: { hello: 'world' }, + })); + const plugin: Plugin = { + async onParams({ setParamsHandler }) { + setParamsHandler(paramsHandler); + }, + }; + + const yoga = createYoga({ plugins: [plugin] }); + + const params = { + query: '{ hello }', + }; + const request = new yoga.fetchAPI.Request('http://yoga/graphql', { + method: 'POST', + body: JSON.stringify(params), + headers: { + 'Content-Type': 'application/json', + }, + }); + + const serverContext = {}; + + const result = await yoga.fetch(request, serverContext); + + expect(result.status).toBe(200); + const body = await result.json(); + expect(body).toEqual({ data: { hello: 'world' } }); + expect(paramsHandler).toHaveBeenCalledTimes(1); + expect(paramsHandler).toHaveBeenCalledWith( + expect.objectContaining({ params, request, context: expect.objectContaining(serverContext) }), + ); +}); + test('context value identity stays the same in all hooks', async () => { const contextValues = [] as Array; const yoga = createYoga({ diff --git a/packages/graphql-yoga/src/plugins/types.ts b/packages/graphql-yoga/src/plugins/types.ts index e29dd75399..2e957e8469 100644 --- a/packages/graphql-yoga/src/plugins/types.ts +++ b/packages/graphql-yoga/src/plugins/types.ts @@ -123,14 +123,30 @@ export type OnParamsHook = ( ) => PromiseOrValue; export interface OnParamsEventPayload> { - params: GraphQLParams; request: Request; + + params: GraphQLParams; setParams: (params: GraphQLParams) => void; + paramsHandler: ParamsHandler; + + setParamsHandler: (handler: ParamsHandler) => void; + setResult: (result: ExecutionResult | AsyncIterable) => void; + fetchAPI: FetchAPI; context: TServerContext; } +export interface ParamsHandlerPayload { + request: Request; + params: GraphQLParams; + context: TServerContext & ServerAdapterInitialContext & YogaInitialContext; +} + +export type ParamsHandler = ( + payload: ParamsHandlerPayload, +) => PromiseOrValue>; + export type OnResultProcess = ( payload: OnResultProcessEventPayload, ) => PromiseOrValue; diff --git a/packages/graphql-yoga/src/server.ts b/packages/graphql-yoga/src/server.ts index f216d849f4..e586c85c9f 100644 --- a/packages/graphql-yoga/src/server.ts +++ b/packages/graphql-yoga/src/server.ts @@ -49,6 +49,7 @@ import { OnRequestParseDoneHook, OnRequestParseHook, OnResultProcess, + ParamsHandler, Plugin, RequestParser, ResultProcessorInput, @@ -462,6 +463,56 @@ export class YogaServer< } } + handleParams: ParamsHandler = async ({ request, context, params }) => { + let result: ExecutionResult | AsyncIterable; + try { + const additionalContext = + context['request'] === request + ? { + params, + } + : { + request, + params, + }; + + Object.assign(context, additionalContext); + + const enveloped = this.getEnveloped(context); + + this.logger.debug(`Processing GraphQL Parameters`); + result = await processGraphQLParams({ + params, + enveloped, + }); + this.logger.debug(`Processing GraphQL Parameters done.`); + } catch (error) { + const errors = handleError(error, this.maskedErrorsOpts, this.logger); + + result = { + errors, + }; + } + if (isAsyncIterable(result)) { + result = mapAsyncIterator( + result, + v => v, + (error: Error) => { + if (error.name === 'AbortError') { + this.logger.debug(`Request aborted`); + throw error; + } + + const errors = handleError(error, this.maskedErrorsOpts, this.logger); + return { + errors, + }; + }, + ); + } + return result; + }; + async getResultForParams( { params, @@ -473,74 +524,33 @@ export class YogaServer< context: TServerContext, ) { let result: ExecutionResult | AsyncIterable | undefined; + let paramsHandler = this.handleParams; - try { - for (const onParamsHook of this.onParamsHooks) { - await onParamsHook({ - params, - request, - setParams(newParams) { - params = newParams; - }, - setResult(newResult) { - result = newResult; - }, - fetchAPI: this.fetchAPI, - context, - }); - } - - if (result == null) { - const additionalContext = - context['request'] === request - ? { - params, - } - : { - request, - params, - }; - - Object.assign(context, additionalContext); - - const enveloped = this.getEnveloped(context); - - this.logger.debug(`Processing GraphQL Parameters`); - result = await processGraphQLParams({ - params, - enveloped, - }); - - this.logger.debug(`Processing GraphQL Parameters done.`); - } - - /** Ensure that error thrown from subscribe is sent to client */ - // TODO: this should probably be something people can customize via a hook? - if (isAsyncIterable(result)) { - result = mapAsyncIterator( - result, - v => v, - (err: Error) => { - if (err.name === 'AbortError') { - this.logger.debug(`Request aborted`); - throw err; - } - - const errors = handleError(err, this.maskedErrorsOpts, this.logger); - return { - errors, - }; - }, - ); - } - } catch (error) { - const errors = handleError(error, this.maskedErrorsOpts, this.logger); - - result = { - errors, - }; + for (const onParamsHook of this.onParamsHooks) { + await onParamsHook({ + params, + request, + setParams(newParams) { + params = newParams; + }, + paramsHandler, + setParamsHandler(newHandler) { + paramsHandler = newHandler; + }, + setResult(newResult) { + result = newResult; + }, + fetchAPI: this.fetchAPI, + context, + }); } + result ??= await paramsHandler({ + request, + params, + context: context as TServerContext & YogaInitialContext, + }); + for (const onExecutionResult of this.onExecutionResultHooks) { await onExecutionResult({ result, @@ -551,6 +561,7 @@ export class YogaServer< context: context as TServerContext & YogaInitialContext, }); } + return result; }