From c29d63902f815ece96e80c968714b5d904255380 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 May 2021 13:03:13 +0200 Subject: [PATCH 01/14] wip: theoretical implementation --- packages/core/src/index.ts | 1 + packages/core/src/plugins/typings.d.ts | 4 + .../use-context-per-subscription-event.ts | 143 ++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 packages/core/src/plugins/typings.d.ts create mode 100644 packages/core/src/plugins/use-context-per-subscription-event.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2b832673ae..21563c434e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,3 +7,4 @@ export * from './plugins/use-error-handler'; export * from './plugins/use-extend-context'; export * from './plugins/use-payload-formatter'; export * from './plugins/use-masked-errors'; +export * from './plugins/use-context-per-subscription-event'; diff --git a/packages/core/src/plugins/typings.d.ts b/packages/core/src/plugins/typings.d.ts new file mode 100644 index 0000000000..474528ad13 --- /dev/null +++ b/packages/core/src/plugins/typings.d.ts @@ -0,0 +1,4 @@ +declare module 'graphql/jsutils/isAsyncIterable' { + function isAsyncIterable(input: unknown): input is AsyncIterable; + export default isAsyncIterable; +} diff --git a/packages/core/src/plugins/use-context-per-subscription-event.ts b/packages/core/src/plugins/use-context-per-subscription-event.ts new file mode 100644 index 0000000000..b75e70e9f1 --- /dev/null +++ b/packages/core/src/plugins/use-context-per-subscription-event.ts @@ -0,0 +1,143 @@ +import { Plugin } from '@envelop/types'; +import { + ExecutionResult, + execute as defaultExecute, + SubscriptionArgs, + createSourceEventStream, + GraphQLSchema, + DocumentNode, + GraphQLFieldResolver, + ExecutionArgs, +} from 'graphql'; +import mapAsyncIterator from 'graphql/subscription/mapAsyncIterator'; +import isAsyncIterable from 'graphql/jsutils/isAsyncIterable'; +import { Maybe } from 'graphql/jsutils/Maybe'; +import { PromiseOrValue } from 'graphql/jsutils/PromiseOrValue'; + +type ContextFactoryOptions = { + /** The arguments with which the subscription was set up. */ + args: SubscriptionArgs; +}; +type ContextFactoryHook = { + /** Context that will be used for the "ExecuteSubscriptionEvent" phase. */ + contextValue: TContextValue; + /** Optional callback that is invoked once the "ExecuteSubscriptionEvent" phase has ended. Useful for cleanup, such as tearing down database connections. */ + onEnd?: () => void; +}; +type ContextFactoryType = ( + options: ContextFactoryOptions +) => PromiseOrValue | void>; + +type PolyMorphicSubscribeArguments = + | [SubscriptionArgs] + | [ + GraphQLSchema, + DocumentNode, + any?, + any?, + Maybe<{ [key: string]: any }>?, + Maybe?, + Maybe>?, + Maybe>? + ]; + +function getArgs(args: PolyMorphicSubscribeArguments): SubscriptionArgs { + return args.length === 1 + ? args[0] + : { + schema: args[0], + document: args[1], + rootValue: args[2], + contextValue: args[3], + variableValues: args[4], + operationName: args[5], + fieldResolver: args[6], + subscribeFieldResolver: args[7], + }; +} + +/** + * This is a almost identical port from graphql-js subscribe. + * The difference is the polymorphic argument handling and + * the possibility for injecting a custom `execute` function. + */ +const createSubscribe = ( + makeExecute: (subscriptionArgs: SubscriptionArgs) => (args: ExecutionArgs) => PromiseOrValue +) => + async function subscribe( + ...polyArgs: PolyMorphicSubscribeArguments + ): Promise | ExecutionResult> { + const args = getArgs(polyArgs); + const { + schema, + document, + rootValue, + contextValue, + variableValues, + operationName, + fieldResolver, + subscribeFieldResolver, + } = args; + + const resultOrStream = await createSourceEventStream( + schema, + document, + rootValue, + contextValue, + variableValues, + operationName, + subscribeFieldResolver + ); + + if (!isAsyncIterable(resultOrStream)) { + return resultOrStream; + } + + const execute = makeExecute(args); + + // For each payload yielded from a subscription, map it over the normal + // GraphQL `execute` function, with `payload` as the rootValue. + // This implements the "MapSourceToResponseEvent" algorithm described in + // the GraphQL specification. The `execute` function provides the + // "ExecuteSubscriptionEvent" algorithm, as it is nearly identical to the + // "ExecuteQuery" algorithm, for which `execute` is also used. + const mapSourceToResponse = async (payload: object) => + execute({ + schema, + document, + rootValue: payload, + contextValue, + variableValues, + operationName, + fieldResolver, + }); + + // Map every source value to a ExecutionResult value as described above. + return mapAsyncIterator(resultOrStream, mapSourceToResponse); + }; + +const executeWithContextFactory = (createContext: ContextFactoryType) => ( + subscriptionArgs: SubscriptionArgs +) => async (args: ExecutionArgs): Promise => { + const result = await createContext({ args: subscriptionArgs }); + try { + return defaultExecute({ + ...args, + contextValue: result ? result.contextValue : args.contextValue, + }); + } finally { + if (result && result.onEnd) { + result.onEnd(); + } + } +}; + +export const useContextPerSubscriptionValue = ( + createContext: ContextFactoryType +): Plugin => { + return { + onSubscribe({ setSubscribeFn }) { + setSubscribeFn(createSubscribe(executeWithContextFactory(createContext))); + }, + }; +}; From df30ff64c8fa5302a7449c5069aa8ca756e51204 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 May 2021 14:12:35 +0200 Subject: [PATCH 02/14] refactor: use programmatic schema for subscription support --- packages/core/test/common.ts | 82 ++++++++++++++++++++++++++++-------- 1 file changed, 65 insertions(+), 17 deletions(-) diff --git a/packages/core/test/common.ts b/packages/core/test/common.ts index 261a7cc736..221de78b8d 100644 --- a/packages/core/test/common.ts +++ b/packages/core/test/common.ts @@ -1,28 +1,70 @@ -import { makeExecutableSchema } from '@graphql-tools/schema'; +import { EventEmitter, on } from 'events'; +import { GraphQLID, GraphQLNonNull, GraphQLObjectType, GraphQLSchema, GraphQLString } from 'graphql'; -export const schema = makeExecutableSchema({ - typeDefs: /* GraphQL */ ` - type Query { - me: User! - } - type User { - id: ID! - name: String! - } - `, - resolvers: { - Query: { - me: () => { +const createPubSub = (emitter: EventEmitter) => { + return { + publish: >(topic: TTopic, payload: TTopicPayload[TTopic]) => { + emitter.emit(topic as string, payload); + }, + subscribe: async function*>( + topic: TTopic + ): AsyncIterableIterator { + const asyncIterator = on(emitter, topic); + for await (const [value] of asyncIterator) { + yield value; + } + }, + }; +}; + +export const pubSub = createPubSub<{ + ping: string; +}>(new EventEmitter()); + +const GraphQLUser = new GraphQLObjectType({ + name: 'User', + fields: { + id: { + type: GraphQLNonNull(GraphQLID), + resolve: u => u._id, + }, + name: { + type: GraphQLNonNull(GraphQLString), + resolve: u => `${u.firstName} ${u.lastName}`, + }, + }, +}); + +const GraphQLQuery = new GraphQLObjectType({ + name: 'Query', + fields: { + me: { + type: GraphQLNonNull(GraphQLUser), + resolve: () => { return { _id: 1, firstName: 'Dotan', lastName: 'Simha' }; }, }, - User: { - id: u => u._id, - name: u => `${u.firstName} ${u.lastName}`, + }, +}); + +const GraphQLSubscription = new GraphQLObjectType({ + name: 'Subscription', + fields: { + ping: { + type: GraphQLString, + subscribe: async function*() { + const stream = pubSub.subscribe('ping'); + return yield* stream; + }, + resolve: (b, _, context) => { + return `${b} ${context}`; + }, }, }, }); +export const schema = new GraphQLSchema({ query: GraphQLQuery, subscription: GraphQLSubscription }); + export const query = /* GraphQL */ ` query me { me { @@ -31,3 +73,9 @@ export const query = /* GraphQL */ ` } } `; + +export const subscription = /* GraphQL */ ` + subscription ping { + ping + } +`; From 4e5af6fdc6298a58069fe8076bfd4afc3086a568 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 May 2021 14:12:56 +0200 Subject: [PATCH 03/14] feat: add subscribe function to test kit --- packages/testing/src/index.ts | 37 ++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/testing/src/index.ts b/packages/testing/src/index.ts index 57c293ef00..6603a1313c 100644 --- a/packages/testing/src/index.ts +++ b/packages/testing/src/index.ts @@ -1,5 +1,5 @@ import { DocumentNode, ExecutionResult, GraphQLSchema, print } from 'graphql'; -import { getGraphQLParameters, processRequest } from 'graphql-helix'; +import { getGraphQLParameters, processRequest, Push } from 'graphql-helix'; import { envelop, useSchema } from '@envelop/core'; import { Envelop, Plugin } from '@envelop/types'; @@ -54,6 +54,7 @@ export function createTestkit( variables?: Record, initialContext?: any ) => Promise>; + subscribe: (operation: DocumentNode | string, variables?: Record, initialContext?: any) => Promise>; replaceSchema: (schema: GraphQLSchema) => void; wait: (ms: number) => Promise; } { @@ -101,5 +102,39 @@ export function createTestkit( return (r as any).payload as ExecutionResult; }, + subscribe: async (operation, rawVariables = {}, initialContext = null) => { + const request = { + headers: {}, + method: 'POST', + query: '', + body: { + query: typeof operation === 'string' ? operation : print(operation), + variables: rawVariables, + }, + }; + const proxy = initRequest(); + const { operationName, query, variables } = getGraphQLParameters(request); + + const r = await processRequest({ + operationName, + query, + variables, + request, + execute: proxy.execute, + subscribe: proxy.subscribe, + parse: proxy.parse, + validate: proxy.validate, + contextFactory: initialContext ? () => proxy.contextFactory(initialContext) : proxy.contextFactory, + schema: proxy.schema, + }); + + if (r.type !== 'PUSH') { + throw new Error('Did not receive subscription operation.'); + } + + return r; + }, }; } + +export type SubscriptionInterface = Push; From 84368cbec6d987496819b1a59b21d512294787f3 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 May 2021 14:13:20 +0200 Subject: [PATCH 04/14] feat: add tests for useContextPErSubscriptionEvent plugin --- ...use-context-per-subscription-event.spec.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 packages/core/test/use-context-per-subscription-event.spec.ts diff --git a/packages/core/test/use-context-per-subscription-event.spec.ts b/packages/core/test/use-context-per-subscription-event.spec.ts new file mode 100644 index 0000000000..6adc40cfd3 --- /dev/null +++ b/packages/core/test/use-context-per-subscription-event.spec.ts @@ -0,0 +1,66 @@ +import { createSpiedPlugin, createTestkit, SubscriptionInterface } from '@envelop/testing'; +import { useContextPerSubscriptionValue } from '../src'; +import { schema, query, subscription, pubSub } from './common'; + +let result: SubscriptionInterface | null = null; + +afterEach(() => { + result?.unsubscribe(); +}); + +it('it can be used for injecting a context that is different from the subscription context', async done => { + const subscriptionContextValue = 'I am subscription context'; + + let counter = 0; + + const testInstance = createTestkit( + [ + useContextPerSubscriptionValue(() => ({ + contextValue: `=== ${counter}`, + })), + ], + schema + ); + + result = await testInstance.subscribe(subscription, undefined, subscriptionContextValue); + result.subscribe(result => { + expect(result.errors).toBeUndefined(); + if (counter === 0) { + expect(result.data!.ping).toEqual('0 === 0'); + pubSub.publish('ping', String(counter)); + done(); + return; + } + if (counter === 1) { + expect(result.data!.ping).toEqual('1 === 1'); + done(); + return; + } + }); + + pubSub.publish('ping', String(counter)); +}); + +it('invokes cleanup function after value is published', async done => { + let onEnd = jest.fn(); + const testInstance = createTestkit( + [ + useContextPerSubscriptionValue(() => ({ + contextValue: `hi`, + onEnd, + })), + ], + schema + ); + + result = await testInstance.subscribe(subscription, undefined); + + result.subscribe(result => { + expect(result.errors).toBeUndefined(); + expect(result.data!.ping).toEqual('foo hi'); + expect(onEnd.mock.calls).toHaveLength(1); + done(); + }); + + pubSub.publish('ping', 'foo'); +}); From 3532be3683c369c6000e5f2ff9e01d1c48ec6b32 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 May 2021 14:42:02 +0200 Subject: [PATCH 05/14] fix: typing --- packages/core/src/plugins/use-context-per-subscription-event.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/plugins/use-context-per-subscription-event.ts b/packages/core/src/plugins/use-context-per-subscription-event.ts index b75e70e9f1..212f07a52e 100644 --- a/packages/core/src/plugins/use-context-per-subscription-event.ts +++ b/packages/core/src/plugins/use-context-per-subscription-event.ts @@ -84,7 +84,7 @@ const createSubscribe = ( document, rootValue, contextValue, - variableValues, + variableValues ?? undefined, operationName, subscribeFieldResolver ); From 6284f23ffd3c539abdac23b4f3494a86e007cae2 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 14 May 2021 08:16:33 +0200 Subject: [PATCH 06/14] refactor: implement hooks for the execute function that is injected into subscribe --- packages/core/src/create.ts | 162 +++++++++++------- .../typings.d.ts => graphql-typings.d.ts} | 0 .../use-context-per-subscription-event.ts | 143 +++------------- packages/core/src/subscribe.ts | 60 +++++++ packages/core/src/util.ts | 49 ++++++ packages/types/src/index.ts | 66 ++++++- 6 files changed, 291 insertions(+), 189 deletions(-) rename packages/core/src/{plugins/typings.d.ts => graphql-typings.d.ts} (100%) create mode 100644 packages/core/src/subscribe.ts create mode 100644 packages/core/src/util.ts diff --git a/packages/core/src/create.ts b/packages/core/src/create.ts index 1543e4a8e9..1a75795cf3 100644 --- a/packages/core/src/create.ts +++ b/packages/core/src/create.ts @@ -2,12 +2,9 @@ import { defaultFieldResolver, DocumentNode, execute, - subscribe, - ExecutionArgs, GraphQLError, GraphQLFieldResolver, GraphQLSchema, - GraphQLTypeResolver, isIntrospectionType, isObjectType, parse, @@ -15,10 +12,19 @@ import { specifiedRules, ValidationRule, ExecutionResult, - SubscriptionArgs, } from 'graphql'; -import { AfterCallback, AfterResolverPayload, Envelop, OnResolverCalledHooks, Plugin } from '@envelop/types'; -import { Maybe } from 'graphql/jsutils/Maybe'; +import { + AfterCallback, + AfterResolverPayload, + Envelop, + ExecuteFunction, + OnExecuteSubscriptionEventHandler, + OnResolverCalledHooks, + Plugin, + SubscribeFunction, +} from '@envelop/types'; +import { subscribe } from './subscribe'; +import { makeSubscribe, makeExecute } from './util'; const trackedSchemaSymbol = Symbol('TRACKED_SCHEMA'); const resolversHooksSymbol = Symbol('RESOLVERS_HOOKS'); @@ -206,37 +212,15 @@ export function envelop({ plugins }: { plugins: Plugin[] }): Envelop { } : (ctx: any) => ctx; - const customSubscribe = async ( - argsOrSchema: SubscriptionArgs | GraphQLSchema, - document?: DocumentNode, - rootValue?: any, - contextValue?: any, - variableValues?: Maybe<{ [key: string]: any }>, - operationName?: Maybe, - fieldResolver?: Maybe>, - subscribeFieldResolver?: Maybe> - ) => { - const args: SubscriptionArgs = - argsOrSchema instanceof GraphQLSchema - ? { - schema: argsOrSchema, - document: document!, - rootValue, - contextValue, - variableValues, - operationName, - fieldResolver, - subscribeFieldResolver, - } - : argsOrSchema; - + const customSubscribe: SubscribeFunction = makeSubscribe(async args => { const onResolversHandlers: OnResolverCalledHooks[] = []; - let subscribeFn: typeof subscribe = subscribe; + let subscribeFn = subscribe as SubscribeFunction; const afterCalls: ((options: { result: AsyncIterableIterator | ExecutionResult; setResult: (newResult: AsyncIterableIterator | ExecutionResult) => void; }) => void)[] = []; + const beforeExecuteSubscriptionHandlers: OnExecuteSubscriptionEventHandler[] = []; let context = args.contextValue; for (const onSubscribe of onSubscribeCbs) { @@ -267,6 +251,9 @@ export function envelop({ plugins }: { plugins: Plugin[] }): Envelop { if (after.onResolverCalled) { onResolversHandlers.push(after.onResolverCalled); } + if (after.onExecuteSubscriptionEvent) { + beforeExecuteSubscriptionHandlers.push(after.onExecuteSubscriptionEvent); + } } } @@ -274,9 +261,87 @@ export function envelop({ plugins }: { plugins: Plugin[] }): Envelop { context[resolversHooksSymbol] = onResolversHandlers; } + const subscribeExecute = (beforeExecuteSubscriptionHandlers.length + ? makeExecute(async args => { + const onResolversHandlers: OnResolverCalledHooks[] = []; + let executeFn: ExecuteFunction = execute as ExecuteFunction; + let result: ExecutionResult; + + const afterCalls: ((options: { + result: ExecutionResult; + setResult: (newResult: ExecutionResult) => void; + }) => void)[] = []; + let context = args.contextValue; + + for (const onExecute of beforeExecuteSubscriptionHandlers) { + let stopCalled = false; + + const after = onExecute({ + executeFn, + setExecuteFn: newExecuteFn => { + executeFn = newExecuteFn; + }, + setResultAndStopExecution: stopResult => { + stopCalled = true; + result = stopResult; + }, + extendContext: extension => { + if (typeof extension === 'object') { + context = { + ...(context || {}), + ...extension, + }; + } else { + throw new Error( + `Invalid context extension provided! Expected "object", got: "${JSON.stringify( + extension + )}" (${typeof extension})` + ); + } + }, + args, + }); + + if (stopCalled) { + return result!; + } + + if (after) { + if (after.onExecuteDone) { + afterCalls.push(after.onExecuteDone); + } + if (after.onResolverCalled) { + onResolversHandlers.push(after.onResolverCalled); + } + } + } + + if (onResolversHandlers.length) { + context[resolversHooksSymbol] = onResolversHandlers; + } + + result = await executeFn({ + ...args, + contextValue: context, + }); + + for (const afterCb of afterCalls) { + afterCb({ + result, + setResult: newResult => { + result = newResult; + }, + }); + } + + return result; + }) + : args.execute ?? execute) as SubscribeFunction; + let result = await subscribeFn({ ...args, contextValue: context, + execute: subscribeExecute as ExecuteFunction, }); for (const afterCb of afterCalls) { @@ -289,35 +354,12 @@ export function envelop({ plugins }: { plugins: Plugin[] }): Envelop { } return result; - }; - - const customExecute = onExecuteCbs.length - ? async ( - argsOrSchema: ExecutionArgs | GraphQLSchema, - document?: DocumentNode, - rootValue?: any, - contextValue?: any, - variableValues?: Maybe<{ [key: string]: any }>, - operationName?: Maybe, - fieldResolver?: Maybe>, - typeResolver?: Maybe> - ) => { - const args: ExecutionArgs = - argsOrSchema instanceof GraphQLSchema - ? { - schema: argsOrSchema, - document: document!, - rootValue, - contextValue, - variableValues, - operationName, - fieldResolver, - typeResolver, - } - : argsOrSchema; + }); + const customExecute = (onExecuteCbs.length + ? makeExecute(async args => { const onResolversHandlers: OnResolverCalledHooks[] = []; - let executeFn: typeof execute = execute; + let executeFn: ExecuteFunction = execute as ExecuteFunction; let result: ExecutionResult; const afterCalls: ((options: { @@ -388,8 +430,8 @@ export function envelop({ plugins }: { plugins: Plugin[] }): Envelop { } return result; - } - : execute; + }) + : execute) as ExecuteFunction; function prepareSchema() { if (!schema || schema[trackedSchemaSymbol]) { diff --git a/packages/core/src/plugins/typings.d.ts b/packages/core/src/graphql-typings.d.ts similarity index 100% rename from packages/core/src/plugins/typings.d.ts rename to packages/core/src/graphql-typings.d.ts diff --git a/packages/core/src/plugins/use-context-per-subscription-event.ts b/packages/core/src/plugins/use-context-per-subscription-event.ts index 212f07a52e..1afa716296 100644 --- a/packages/core/src/plugins/use-context-per-subscription-event.ts +++ b/packages/core/src/plugins/use-context-per-subscription-event.ts @@ -1,18 +1,7 @@ -import { Plugin } from '@envelop/types'; -import { - ExecutionResult, - execute as defaultExecute, - SubscriptionArgs, - createSourceEventStream, - GraphQLSchema, - DocumentNode, - GraphQLFieldResolver, - ExecutionArgs, -} from 'graphql'; -import mapAsyncIterator from 'graphql/subscription/mapAsyncIterator'; -import isAsyncIterable from 'graphql/jsutils/isAsyncIterable'; -import { Maybe } from 'graphql/jsutils/Maybe'; +import { Plugin, SubscriptionArgs, PolymorphicExecuteArguments } from '@envelop/types'; + import { PromiseOrValue } from 'graphql/jsutils/PromiseOrValue'; +import { getExecuteArgs } from '../util'; type ContextFactoryOptions = { /** The arguments with which the subscription was set up. */ @@ -28,116 +17,30 @@ type ContextFactoryType = ( options: ContextFactoryOptions ) => PromiseOrValue | void>; -type PolyMorphicSubscribeArguments = - | [SubscriptionArgs] - | [ - GraphQLSchema, - DocumentNode, - any?, - any?, - Maybe<{ [key: string]: any }>?, - Maybe?, - Maybe>?, - Maybe>? - ]; - -function getArgs(args: PolyMorphicSubscribeArguments): SubscriptionArgs { - return args.length === 1 - ? args[0] - : { - schema: args[0], - document: args[1], - rootValue: args[2], - contextValue: args[3], - variableValues: args[4], - operationName: args[5], - fieldResolver: args[6], - subscribeFieldResolver: args[7], - }; -} - -/** - * This is a almost identical port from graphql-js subscribe. - * The difference is the polymorphic argument handling and - * the possibility for injecting a custom `execute` function. - */ -const createSubscribe = ( - makeExecute: (subscriptionArgs: SubscriptionArgs) => (args: ExecutionArgs) => PromiseOrValue -) => - async function subscribe( - ...polyArgs: PolyMorphicSubscribeArguments - ): Promise | ExecutionResult> { - const args = getArgs(polyArgs); - const { - schema, - document, - rootValue, - contextValue, - variableValues, - operationName, - fieldResolver, - subscribeFieldResolver, - } = args; - - const resultOrStream = await createSourceEventStream( - schema, - document, - rootValue, - contextValue, - variableValues ?? undefined, - operationName, - subscribeFieldResolver - ); - - if (!isAsyncIterable(resultOrStream)) { - return resultOrStream; - } - - const execute = makeExecute(args); - - // For each payload yielded from a subscription, map it over the normal - // GraphQL `execute` function, with `payload` as the rootValue. - // This implements the "MapSourceToResponseEvent" algorithm described in - // the GraphQL specification. The `execute` function provides the - // "ExecuteSubscriptionEvent" algorithm, as it is nearly identical to the - // "ExecuteQuery" algorithm, for which `execute` is also used. - const mapSourceToResponse = async (payload: object) => - execute({ - schema, - document, - rootValue: payload, - contextValue, - variableValues, - operationName, - fieldResolver, - }); - - // Map every source value to a ExecutionResult value as described above. - return mapAsyncIterator(resultOrStream, mapSourceToResponse); - }; - -const executeWithContextFactory = (createContext: ContextFactoryType) => ( - subscriptionArgs: SubscriptionArgs -) => async (args: ExecutionArgs): Promise => { - const result = await createContext({ args: subscriptionArgs }); - try { - return defaultExecute({ - ...args, - contextValue: result ? result.contextValue : args.contextValue, - }); - } finally { - if (result && result.onEnd) { - result.onEnd(); - } - } -}; - export const useContextPerSubscriptionValue = ( createContext: ContextFactoryType ): Plugin => { return { - onSubscribe({ setSubscribeFn }) { - setSubscribeFn(createSubscribe(executeWithContextFactory(createContext))); + onSubscribe({ args }) { + return { + onExecuteSubscriptionEvent({ executeFn, setExecuteFn }) { + const newExecute = async (..._executionArgs: PolymorphicExecuteArguments) => { + const executionArgs = getExecuteArgs(_executionArgs); + const context = await createContext({ args }); + try { + return executeFn({ + ...executionArgs, + contextValue: context ? context.contextValue : executionArgs.contextValue, + }); + } finally { + if (context && context.onEnd) { + context.onEnd(); + } + } + }; + setExecuteFn(newExecute); + }, + }; }, }; }; diff --git a/packages/core/src/subscribe.ts b/packages/core/src/subscribe.ts new file mode 100644 index 0000000000..20792f67f0 --- /dev/null +++ b/packages/core/src/subscribe.ts @@ -0,0 +1,60 @@ +import { ExecutionResult, execute as defaultExecute, createSourceEventStream } from 'graphql'; +import isAsyncIterable from 'graphql/jsutils/isAsyncIterable'; +import mapAsyncIterator from 'graphql/subscription/mapAsyncIterator'; +import { PolymorphicSubscribeArguments } from '@envelop/types'; +import { getSubscribeArgs } from './util'; + +/** + * This is a almost identical port from graphql-js subscribe. + * The only difference is that a custom `execute` function can be injected as an additional argument. + */ +export async function subscribe( + ...polyArgs: PolymorphicSubscribeArguments +): Promise | ExecutionResult> { + const args = getSubscribeArgs(polyArgs); + const { + schema, + document, + rootValue, + contextValue, + variableValues, + operationName, + fieldResolver, + subscribeFieldResolver, + execute = defaultExecute, + } = args; + + const resultOrStream = await createSourceEventStream( + schema, + document, + rootValue, + contextValue, + variableValues ?? undefined, + operationName, + subscribeFieldResolver + ); + + if (!isAsyncIterable(resultOrStream)) { + return resultOrStream; + } + + // For each payload yielded from a subscription, map it over the normal + // GraphQL `execute` function, with `payload` as the rootValue. + // This implements the "MapSourceToResponseEvent" algorithm described in + // the GraphQL specification. The `execute` function provides the + // "ExecuteSubscriptionEvent" algorithm, as it is nearly identical to the + // "ExecuteQuery" algorithm, for which `execute` is also used. + const mapSourceToResponse = async (payload: object) => + execute({ + schema, + document, + rootValue: payload, + contextValue, + variableValues, + operationName, + fieldResolver, + }); + + // Map every source value to a ExecutionResult value as described above. + return mapAsyncIterator(resultOrStream, mapSourceToResponse); +} diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts new file mode 100644 index 0000000000..3ba0157adb --- /dev/null +++ b/packages/core/src/util.ts @@ -0,0 +1,49 @@ +import { PolymorphicExecuteArguments, PolymorphicSubscribeArguments, SubscriptionArgs } from '@envelop/types'; +import { ExecutionArgs, ExecutionResult } from 'graphql'; +import { PromiseOrValue } from 'graphql/jsutils/PromiseOrValue'; + +export function getExecuteArgs(args: PolymorphicExecuteArguments): ExecutionArgs { + return args.length === 1 + ? args[0] + : { + schema: args[0], + document: args[1], + rootValue: args[2], + contextValue: args[3], + variableValues: args[4], + operationName: args[5], + fieldResolver: args[6], + typeResolver: args[7], + }; +} + +/** + * Utility function for making a execute function that handles polymorphic arguments. + */ +export const makeExecute = (subscribeFn: (args: ExecutionArgs) => PromiseOrValue) => ( + ...polyArgs: PolymorphicExecuteArguments +): PromiseOrValue => subscribeFn(getExecuteArgs(polyArgs)); + +export function getSubscribeArgs(args: PolymorphicSubscribeArguments): SubscriptionArgs { + return args.length === 1 + ? args[0] + : { + schema: args[0], + document: args[1], + rootValue: args[2], + contextValue: args[3], + variableValues: args[4], + operationName: args[5], + fieldResolver: args[6], + subscribeFieldResolver: args[7], + execute: args[8], + }; +} + +/** + * Utility function for making a subscribe function that handles polymorphic arguments. + */ +export const makeSubscribe = ( + subscribeFn: (args: SubscriptionArgs) => PromiseOrValue | ExecutionResult> +) => (...polyArgs: PolymorphicSubscribeArguments): PromiseOrValue | ExecutionResult> => + subscribeFn(getSubscribeArgs(polyArgs)); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index dba0a4fe6b..f52ac8d47d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -5,7 +5,6 @@ import { Source, ParseOptions, GraphQLError, - execute, parse, validate, GraphQLResolveInfo, @@ -13,9 +12,49 @@ import { ExecutionResult, ValidationRule, TypeInfo, - subscribe, - SubscriptionArgs, + SubscriptionArgs as OriginalSubscriptionArgs, + GraphQLFieldResolver, + GraphQLTypeResolver, } from 'graphql'; +import { Maybe } from 'graphql/jsutils/Maybe'; +import { PromiseOrValue } from 'graphql/jsutils/PromiseOrValue'; + +export type PolymorphicExecuteArguments = + | [ExecutionArgs] + | [ + GraphQLSchema, + DocumentNode, + any, + any, + Maybe<{ [key: string]: any }>, + Maybe, + Maybe>, + Maybe> + ]; + +export type ExecuteFunction = (...args: PolymorphicExecuteArguments) => PromiseOrValue; + +export type SubscriptionArgs = OriginalSubscriptionArgs & { + execute?: ExecuteFunction; +}; + +export type PolymorphicSubscribeArguments = + | [SubscriptionArgs] + | [ + GraphQLSchema, + DocumentNode, + any?, + any?, + Maybe<{ [key: string]: any }>?, + Maybe?, + Maybe>?, + Maybe>?, + ExecuteFunction? + ]; + +export type SubscribeFunction = ( + ...args: PolymorphicSubscribeArguments +) => PromiseOrValue | ExecutionResult>; type AfterFnOrVoid = void | ((afterOptions: Result) => void); @@ -44,12 +83,21 @@ export type OnExecuteHookResult = { onResolverCalled?: OnResolverCalledHooks; }; +export type OnExecuteSubscriptionEventHandler = (options: { + executeFn: ExecuteFunction; + args: ExecutionArgs; + setExecuteFn: (newExecute: ExecuteFunction) => void; + setResultAndStopExecution: (newResult: ExecutionResult) => void; + extendContext: (contextExtension: Partial) => void; +}) => OnExecuteHookResult | void; + export type OnSubscribeHookResult = { onSubscribeResult?: (options: { result: AsyncIterableIterator | ExecutionResult; setResult: (newResult: AsyncIterableIterator | ExecutionResult) => void; }) => void; onResolverCalled?: OnResolverCalledHooks; + onExecuteSubscriptionEvent?: OnExecuteSubscriptionEventHandler; }; export interface Plugin { @@ -60,16 +108,16 @@ export interface Plugin { setSchema: (newSchema: GraphQLSchema) => void; }) => void; onExecute?: (options: { - executeFn: typeof execute; + executeFn: ExecuteFunction; args: ExecutionArgs; - setExecuteFn: (newExecute: typeof execute) => void; + setExecuteFn: (newExecute: ExecuteFunction) => void; setResultAndStopExecution: (newResult: ExecutionResult) => void; extendContext: (contextExtension: Partial) => void; }) => void | OnExecuteHookResult; onSubscribe?: (options: { - subscribeFn: typeof subscribe; + subscribeFn: SubscribeFunction; args: SubscriptionArgs; - setSubscribeFn: (newSubscribe: typeof subscribe) => void; + setSubscribeFn: (newSubscribe: SubscribeFunction) => void; extendContext: (contextExtension: Partial) => void; }) => void | OnSubscribeHookResult; onParse?: BeforeAfterHook< @@ -126,9 +174,9 @@ export type AfterCallback> = NonNullable export type Envelop = { (): { - execute: typeof execute; + execute: ExecuteFunction; validate: typeof validate; - subscribe: typeof subscribe; + subscribe: SubscribeFunction; parse: typeof parse; contextFactory: (requestContext: RequestContext) => ExecutionContext | Promise; schema: GraphQLSchema; From ee70f93c1d6e29b76cd4e45af72dca57bc59e7dd Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 14 May 2021 08:34:45 +0200 Subject: [PATCH 07/14] refactor: rename hook --- .../core/src/plugins/use-context-per-subscription-event.ts | 2 +- .../core/test/use-context-per-subscription-event.spec.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/plugins/use-context-per-subscription-event.ts b/packages/core/src/plugins/use-context-per-subscription-event.ts index 1afa716296..9bd59c38a9 100644 --- a/packages/core/src/plugins/use-context-per-subscription-event.ts +++ b/packages/core/src/plugins/use-context-per-subscription-event.ts @@ -17,7 +17,7 @@ type ContextFactoryType = ( options: ContextFactoryOptions ) => PromiseOrValue | void>; -export const useContextPerSubscriptionValue = ( +export const useCreateContextPerSubscriptionEvent = ( createContext: ContextFactoryType ): Plugin => { return { diff --git a/packages/core/test/use-context-per-subscription-event.spec.ts b/packages/core/test/use-context-per-subscription-event.spec.ts index 6adc40cfd3..6dd20ce42f 100644 --- a/packages/core/test/use-context-per-subscription-event.spec.ts +++ b/packages/core/test/use-context-per-subscription-event.spec.ts @@ -1,5 +1,5 @@ import { createSpiedPlugin, createTestkit, SubscriptionInterface } from '@envelop/testing'; -import { useContextPerSubscriptionValue } from '../src'; +import { useCreateContextPerSubscriptionEvent } from '../src'; import { schema, query, subscription, pubSub } from './common'; let result: SubscriptionInterface | null = null; @@ -15,7 +15,7 @@ it('it can be used for injecting a context that is different from the subscripti const testInstance = createTestkit( [ - useContextPerSubscriptionValue(() => ({ + useCreateContextPerSubscriptionEvent(() => ({ contextValue: `=== ${counter}`, })), ], @@ -45,7 +45,7 @@ it('invokes cleanup function after value is published', async done => { let onEnd = jest.fn(); const testInstance = createTestkit( [ - useContextPerSubscriptionValue(() => ({ + useCreateContextPerSubscriptionEvent(() => ({ contextValue: `hi`, onEnd, })), From 51af526a4950286a9b091eb38c4064cef1758a67 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 14 May 2021 08:42:23 +0200 Subject: [PATCH 08/14] feat: export utility functions --- packages/core/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 21563c434e..d96c63286a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,6 @@ export * from '@envelop/types'; export * from './create'; +export * from './util'; export * from './plugins/use-logger'; export * from './plugins/use-timing'; export * from './plugins/use-schema'; From dcc3a0e857cc67a7478cb528356495eda87cd410 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 14 May 2021 08:42:30 +0200 Subject: [PATCH 09/14] feat: add changeset --- .changeset/grumpy-melons-deny.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/grumpy-melons-deny.md diff --git a/.changeset/grumpy-melons-deny.md b/.changeset/grumpy-melons-deny.md new file mode 100644 index 0000000000..64760f1a15 --- /dev/null +++ b/.changeset/grumpy-melons-deny.md @@ -0,0 +1,10 @@ +--- +'@envelop/core': patch +'@envelop/types': patch +--- + +Add custom `subscribe` function that behaves like `graphql-js` `subscribe` with an additional parameter for a custom `execute` function that is used for the `ExecuteSubscriptionEvent` phase. + +Expose utility functions `getExecuteArgs`, `getSubscribeArgs`, `makeExecute`, and `makeSubscribe` for easier handling of composition with the polymorphic arguments. + +Allow hooking into the `ExecuteSubscriptionEvent` phase with the `onExecuteSubscriptionEvent` hook. From a30e7176f28a8b64d2318aca8b571de34e5ee046 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 14 May 2021 09:35:59 +0200 Subject: [PATCH 10/14] refactor: use makeSubscribe utility function --- packages/core/src/subscribe.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/core/src/subscribe.ts b/packages/core/src/subscribe.ts index 20792f67f0..1ef70366df 100644 --- a/packages/core/src/subscribe.ts +++ b/packages/core/src/subscribe.ts @@ -1,17 +1,13 @@ -import { ExecutionResult, execute as defaultExecute, createSourceEventStream } from 'graphql'; +import { execute as defaultExecute, createSourceEventStream } from 'graphql'; import isAsyncIterable from 'graphql/jsutils/isAsyncIterable'; import mapAsyncIterator from 'graphql/subscription/mapAsyncIterator'; -import { PolymorphicSubscribeArguments } from '@envelop/types'; -import { getSubscribeArgs } from './util'; +import { makeSubscribe } from './util'; /** * This is a almost identical port from graphql-js subscribe. * The only difference is that a custom `execute` function can be injected as an additional argument. */ -export async function subscribe( - ...polyArgs: PolymorphicSubscribeArguments -): Promise | ExecutionResult> { - const args = getSubscribeArgs(polyArgs); +export const subscribe = makeSubscribe(async args => { const { schema, document, @@ -57,4 +53,4 @@ export async function subscribe( // Map every source value to a ExecutionResult value as described above. return mapAsyncIterator(resultOrStream, mapSourceToResponse); -} +}); From 2bd41bd9f94ed1b619f923c38349fb5e3cf2b8b7 Mon Sep 17 00:00:00 2001 From: n1ru4l Date: Tue, 18 May 2021 07:43:17 +0200 Subject: [PATCH 11/14] fix: type casting --- packages/core/src/create.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/create.ts b/packages/core/src/create.ts index 1a75795cf3..a5dcf44a22 100644 --- a/packages/core/src/create.ts +++ b/packages/core/src/create.ts @@ -261,7 +261,7 @@ export function envelop({ plugins }: { plugins: Plugin[] }): Envelop { context[resolversHooksSymbol] = onResolversHandlers; } - const subscribeExecute = (beforeExecuteSubscriptionHandlers.length + const subscribeExecute = beforeExecuteSubscriptionHandlers.length ? makeExecute(async args => { const onResolversHandlers: OnResolverCalledHooks[] = []; let executeFn: ExecuteFunction = execute as ExecuteFunction; @@ -336,12 +336,12 @@ export function envelop({ plugins }: { plugins: Plugin[] }): Envelop { return result; }) - : args.execute ?? execute) as SubscribeFunction; + : ((args.execute ?? execute) as ExecuteFunction); let result = await subscribeFn({ ...args, contextValue: context, - execute: subscribeExecute as ExecuteFunction, + execute: subscribeExecute, }); for (const afterCb of afterCalls) { From b667b3f06f3f3a041182eaa58068c727ead69b57 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 24 May 2021 20:28:03 +0200 Subject: [PATCH 12/14] refactor: move package out of core + overwrite subscribe function. --- packages/core/src/create.ts | 135 ++++-------------- packages/core/src/index.ts | 1 - .../use-context-per-subscription-event.ts | 46 ------ packages/core/src/subscribe.ts | 56 -------- packages/core/src/util.ts | 20 +-- ...use-context-per-subscription-event.spec.ts | 66 --------- .../execute-subscription-event/README.md | 23 +++ .../execute-subscription-event/package.json | 38 +++++ .../execute-subscription-event/src/index.ts | 1 + .../src/subscribe.ts | 49 +++++++ ...xt-value-per-execute-subscription-event.ts | 42 ++++++ ...use-context-per-subscription-event.spec.ts | 68 +++++++++ packages/types/src/index.ts | 15 +- 13 files changed, 258 insertions(+), 302 deletions(-) delete mode 100644 packages/core/src/plugins/use-context-per-subscription-event.ts delete mode 100644 packages/core/src/subscribe.ts delete mode 100644 packages/core/test/use-context-per-subscription-event.spec.ts create mode 100644 packages/plugins/execute-subscription-event/README.md create mode 100644 packages/plugins/execute-subscription-event/package.json create mode 100644 packages/plugins/execute-subscription-event/src/index.ts create mode 100644 packages/plugins/execute-subscription-event/src/subscribe.ts create mode 100644 packages/plugins/execute-subscription-event/src/use-context-value-per-execute-subscription-event.ts create mode 100644 packages/plugins/execute-subscription-event/test/use-context-per-subscription-event.spec.ts diff --git a/packages/core/src/create.ts b/packages/core/src/create.ts index a5dcf44a22..2d773f93df 100644 --- a/packages/core/src/create.ts +++ b/packages/core/src/create.ts @@ -2,6 +2,7 @@ import { defaultFieldResolver, DocumentNode, execute, + subscribe, GraphQLError, GraphQLFieldResolver, GraphQLSchema, @@ -18,16 +19,14 @@ import { AfterResolverPayload, Envelop, ExecuteFunction, - OnExecuteSubscriptionEventHandler, OnResolverCalledHooks, Plugin, SubscribeFunction, } from '@envelop/types'; -import { subscribe } from './subscribe'; import { makeSubscribe, makeExecute } from './util'; const trackedSchemaSymbol = Symbol('TRACKED_SCHEMA'); -const resolversHooksSymbol = Symbol('RESOLVERS_HOOKS'); +export const resolversHooksSymbol = Symbol('RESOLVERS_HOOKS'); export function envelop({ plugins }: { plugins: Plugin[] }): Envelop { let schema: GraphQLSchema | undefined | null = null; @@ -220,7 +219,6 @@ export function envelop({ plugins }: { plugins: Plugin[] }): Envelop { result: AsyncIterableIterator | ExecutionResult; setResult: (newResult: AsyncIterableIterator | ExecutionResult) => void; }) => void)[] = []; - const beforeExecuteSubscriptionHandlers: OnExecuteSubscriptionEventHandler[] = []; let context = args.contextValue; for (const onSubscribe of onSubscribeCbs) { @@ -251,9 +249,6 @@ export function envelop({ plugins }: { plugins: Plugin[] }): Envelop { if (after.onResolverCalled) { onResolversHandlers.push(after.onResolverCalled); } - if (after.onExecuteSubscriptionEvent) { - beforeExecuteSubscriptionHandlers.push(after.onExecuteSubscriptionEvent); - } } } @@ -261,19 +256,35 @@ export function envelop({ plugins }: { plugins: Plugin[] }): Envelop { context[resolversHooksSymbol] = onResolversHandlers; } - const subscribeExecute = beforeExecuteSubscriptionHandlers.length + let result = await subscribeFn({ + ...args, + contextValue: context, + }); + + for (const afterCb of afterCalls) { + afterCb({ + result, + setResult: newResult => { + result = newResult; + }, + }); + } + + return result; + }); + + const customExecute = ( + onExecuteCbs.length ? makeExecute(async args => { const onResolversHandlers: OnResolverCalledHooks[] = []; let executeFn: ExecuteFunction = execute as ExecuteFunction; let result: ExecutionResult; - const afterCalls: ((options: { - result: ExecutionResult; - setResult: (newResult: ExecutionResult) => void; - }) => void)[] = []; + const afterCalls: ((options: { result: ExecutionResult; setResult: (newResult: ExecutionResult) => void }) => void)[] = + []; let context = args.contextValue; - for (const onExecute of beforeExecuteSubscriptionHandlers) { + for (const onExecute of onExecuteCbs) { let stopCalled = false; const after = onExecute({ @@ -336,102 +347,8 @@ export function envelop({ plugins }: { plugins: Plugin[] }): Envelop { return result; }) - : ((args.execute ?? execute) as ExecuteFunction); - - let result = await subscribeFn({ - ...args, - contextValue: context, - execute: subscribeExecute, - }); - - for (const afterCb of afterCalls) { - afterCb({ - result, - setResult: newResult => { - result = newResult; - }, - }); - } - - return result; - }); - - const customExecute = (onExecuteCbs.length - ? makeExecute(async args => { - const onResolversHandlers: OnResolverCalledHooks[] = []; - let executeFn: ExecuteFunction = execute as ExecuteFunction; - let result: ExecutionResult; - - const afterCalls: ((options: { - result: ExecutionResult; - setResult: (newResult: ExecutionResult) => void; - }) => void)[] = []; - let context = args.contextValue; - - for (const onExecute of onExecuteCbs) { - let stopCalled = false; - - const after = onExecute({ - executeFn, - setExecuteFn: newExecuteFn => { - executeFn = newExecuteFn; - }, - setResultAndStopExecution: stopResult => { - stopCalled = true; - result = stopResult; - }, - extendContext: extension => { - if (typeof extension === 'object') { - context = { - ...(context || {}), - ...extension, - }; - } else { - throw new Error( - `Invalid context extension provided! Expected "object", got: "${JSON.stringify( - extension - )}" (${typeof extension})` - ); - } - }, - args, - }); - - if (stopCalled) { - return result!; - } - - if (after) { - if (after.onExecuteDone) { - afterCalls.push(after.onExecuteDone); - } - if (after.onResolverCalled) { - onResolversHandlers.push(after.onResolverCalled); - } - } - } - - if (onResolversHandlers.length) { - context[resolversHooksSymbol] = onResolversHandlers; - } - - result = await executeFn({ - ...args, - contextValue: context, - }); - - for (const afterCb of afterCalls) { - afterCb({ - result, - setResult: newResult => { - result = newResult; - }, - }); - } - - return result; - }) - : execute) as ExecuteFunction; + : execute + ) as ExecuteFunction; function prepareSchema() { if (!schema || schema[trackedSchemaSymbol]) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d96c63286a..92d82421dc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,4 +8,3 @@ export * from './plugins/use-error-handler'; export * from './plugins/use-extend-context'; export * from './plugins/use-payload-formatter'; export * from './plugins/use-masked-errors'; -export * from './plugins/use-context-per-subscription-event'; diff --git a/packages/core/src/plugins/use-context-per-subscription-event.ts b/packages/core/src/plugins/use-context-per-subscription-event.ts deleted file mode 100644 index 9bd59c38a9..0000000000 --- a/packages/core/src/plugins/use-context-per-subscription-event.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Plugin, SubscriptionArgs, PolymorphicExecuteArguments } from '@envelop/types'; - -import { PromiseOrValue } from 'graphql/jsutils/PromiseOrValue'; -import { getExecuteArgs } from '../util'; - -type ContextFactoryOptions = { - /** The arguments with which the subscription was set up. */ - args: SubscriptionArgs; -}; -type ContextFactoryHook = { - /** Context that will be used for the "ExecuteSubscriptionEvent" phase. */ - contextValue: TContextValue; - /** Optional callback that is invoked once the "ExecuteSubscriptionEvent" phase has ended. Useful for cleanup, such as tearing down database connections. */ - onEnd?: () => void; -}; -type ContextFactoryType = ( - options: ContextFactoryOptions -) => PromiseOrValue | void>; - -export const useCreateContextPerSubscriptionEvent = ( - createContext: ContextFactoryType -): Plugin => { - return { - onSubscribe({ args }) { - return { - onExecuteSubscriptionEvent({ executeFn, setExecuteFn }) { - const newExecute = async (..._executionArgs: PolymorphicExecuteArguments) => { - const executionArgs = getExecuteArgs(_executionArgs); - const context = await createContext({ args }); - try { - return executeFn({ - ...executionArgs, - contextValue: context ? context.contextValue : executionArgs.contextValue, - }); - } finally { - if (context && context.onEnd) { - context.onEnd(); - } - } - }; - setExecuteFn(newExecute); - }, - }; - }, - }; -}; diff --git a/packages/core/src/subscribe.ts b/packages/core/src/subscribe.ts deleted file mode 100644 index 1ef70366df..0000000000 --- a/packages/core/src/subscribe.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { execute as defaultExecute, createSourceEventStream } from 'graphql'; -import isAsyncIterable from 'graphql/jsutils/isAsyncIterable'; -import mapAsyncIterator from 'graphql/subscription/mapAsyncIterator'; -import { makeSubscribe } from './util'; - -/** - * This is a almost identical port from graphql-js subscribe. - * The only difference is that a custom `execute` function can be injected as an additional argument. - */ -export const subscribe = makeSubscribe(async args => { - const { - schema, - document, - rootValue, - contextValue, - variableValues, - operationName, - fieldResolver, - subscribeFieldResolver, - execute = defaultExecute, - } = args; - - const resultOrStream = await createSourceEventStream( - schema, - document, - rootValue, - contextValue, - variableValues ?? undefined, - operationName, - subscribeFieldResolver - ); - - if (!isAsyncIterable(resultOrStream)) { - return resultOrStream; - } - - // For each payload yielded from a subscription, map it over the normal - // GraphQL `execute` function, with `payload` as the rootValue. - // This implements the "MapSourceToResponseEvent" algorithm described in - // the GraphQL specification. The `execute` function provides the - // "ExecuteSubscriptionEvent" algorithm, as it is nearly identical to the - // "ExecuteQuery" algorithm, for which `execute` is also used. - const mapSourceToResponse = async (payload: object) => - execute({ - schema, - document, - rootValue: payload, - contextValue, - variableValues, - operationName, - fieldResolver, - }); - - // Map every source value to a ExecutionResult value as described above. - return mapAsyncIterator(resultOrStream, mapSourceToResponse); -}); diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts index 3ba0157adb..a582a7137e 100644 --- a/packages/core/src/util.ts +++ b/packages/core/src/util.ts @@ -1,5 +1,5 @@ -import { PolymorphicExecuteArguments, PolymorphicSubscribeArguments, SubscriptionArgs } from '@envelop/types'; -import { ExecutionArgs, ExecutionResult } from 'graphql'; +import { PolymorphicExecuteArguments, PolymorphicSubscribeArguments } from '@envelop/types'; +import { ExecutionArgs, ExecutionResult, SubscriptionArgs } from 'graphql'; import { PromiseOrValue } from 'graphql/jsutils/PromiseOrValue'; export function getExecuteArgs(args: PolymorphicExecuteArguments): ExecutionArgs { @@ -20,9 +20,10 @@ export function getExecuteArgs(args: PolymorphicExecuteArguments): ExecutionArgs /** * Utility function for making a execute function that handles polymorphic arguments. */ -export const makeExecute = (subscribeFn: (args: ExecutionArgs) => PromiseOrValue) => ( - ...polyArgs: PolymorphicExecuteArguments -): PromiseOrValue => subscribeFn(getExecuteArgs(polyArgs)); +export const makeExecute = + (executeFn: (args: ExecutionArgs) => PromiseOrValue) => + (...polyArgs: PolymorphicExecuteArguments): PromiseOrValue => + executeFn(getExecuteArgs(polyArgs)); export function getSubscribeArgs(args: PolymorphicSubscribeArguments): SubscriptionArgs { return args.length === 1 @@ -36,14 +37,13 @@ export function getSubscribeArgs(args: PolymorphicSubscribeArguments): Subscript operationName: args[5], fieldResolver: args[6], subscribeFieldResolver: args[7], - execute: args[8], }; } /** * Utility function for making a subscribe function that handles polymorphic arguments. */ -export const makeSubscribe = ( - subscribeFn: (args: SubscriptionArgs) => PromiseOrValue | ExecutionResult> -) => (...polyArgs: PolymorphicSubscribeArguments): PromiseOrValue | ExecutionResult> => - subscribeFn(getSubscribeArgs(polyArgs)); +export const makeSubscribe = + (subscribeFn: (args: SubscriptionArgs) => PromiseOrValue | ExecutionResult>) => + (...polyArgs: PolymorphicSubscribeArguments): PromiseOrValue | ExecutionResult> => + subscribeFn(getSubscribeArgs(polyArgs)); diff --git a/packages/core/test/use-context-per-subscription-event.spec.ts b/packages/core/test/use-context-per-subscription-event.spec.ts deleted file mode 100644 index 6dd20ce42f..0000000000 --- a/packages/core/test/use-context-per-subscription-event.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { createSpiedPlugin, createTestkit, SubscriptionInterface } from '@envelop/testing'; -import { useCreateContextPerSubscriptionEvent } from '../src'; -import { schema, query, subscription, pubSub } from './common'; - -let result: SubscriptionInterface | null = null; - -afterEach(() => { - result?.unsubscribe(); -}); - -it('it can be used for injecting a context that is different from the subscription context', async done => { - const subscriptionContextValue = 'I am subscription context'; - - let counter = 0; - - const testInstance = createTestkit( - [ - useCreateContextPerSubscriptionEvent(() => ({ - contextValue: `=== ${counter}`, - })), - ], - schema - ); - - result = await testInstance.subscribe(subscription, undefined, subscriptionContextValue); - result.subscribe(result => { - expect(result.errors).toBeUndefined(); - if (counter === 0) { - expect(result.data!.ping).toEqual('0 === 0'); - pubSub.publish('ping', String(counter)); - done(); - return; - } - if (counter === 1) { - expect(result.data!.ping).toEqual('1 === 1'); - done(); - return; - } - }); - - pubSub.publish('ping', String(counter)); -}); - -it('invokes cleanup function after value is published', async done => { - let onEnd = jest.fn(); - const testInstance = createTestkit( - [ - useCreateContextPerSubscriptionEvent(() => ({ - contextValue: `hi`, - onEnd, - })), - ], - schema - ); - - result = await testInstance.subscribe(subscription, undefined); - - result.subscribe(result => { - expect(result.errors).toBeUndefined(); - expect(result.data!.ping).toEqual('foo hi'); - expect(onEnd.mock.calls).toHaveLength(1); - done(); - }); - - pubSub.publish('ping', 'foo'); -}); diff --git a/packages/plugins/execute-subscription-event/README.md b/packages/plugins/execute-subscription-event/README.md new file mode 100644 index 0000000000..a302a9af5d --- /dev/null +++ b/packages/plugins/execute-subscription-event/README.md @@ -0,0 +1,23 @@ +## `@envelop/execute-subscription-event` + +Utilities for hooking into the [ExecuteSubscriptionEvent]() phase. + +### `useContextValuePerExecuteSubscriptionEvent` + +Create a new context object per `ExecuteSubscriptionEvent` phase, allowing to bypass common issues with context objects such as [`DataLoader` caching issues](https://github.com/dotansimha/envelop/issues/80). + +```ts +import { envelop } from '@envelop/core'; +import { useContextValuePerExecuteSubscriptionEvent } from '@envelop/execute-subscription-event'; + +const getEnveloped = envelop({ + plugins: [ + useContextValuePerExecuteSubscriptionEvent(() => ({ + contextValue: { + value: 'This context value is re-created every time the ExecuteSubscriptionEvent phase starts', + }, + })), + // ... other plugins ... + ], +}); +``` diff --git a/packages/plugins/execute-subscription-event/package.json b/packages/plugins/execute-subscription-event/package.json new file mode 100644 index 0000000000..5f7cfded75 --- /dev/null +++ b/packages/plugins/execute-subscription-event/package.json @@ -0,0 +1,38 @@ +{ + "name": "@envelop/execute-subscription-event", + "version": "0.1.0", + "author": "Dotan Simha ", + "license": "MIT", + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/dotansimha/envelop.git", + "directory": "packages/plugins/execute-subscription-event" + }, + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "typescript": { + "definition": "dist/index.d.ts" + }, + "scripts": { + "test": "jest", + "prepack": "bob prepack" + }, + "dependencies": {}, + "devDependencies": { + "bob-the-bundler": "1.2.0", + "graphql": "15.5.0", + "typescript": "4.2.4" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0" + }, + "buildOptions": { + "input": "./src/index.ts" + }, + "publishConfig": { + "directory": "dist", + "access": "public" + } +} diff --git a/packages/plugins/execute-subscription-event/src/index.ts b/packages/plugins/execute-subscription-event/src/index.ts new file mode 100644 index 0000000000..4cfa5d7c59 --- /dev/null +++ b/packages/plugins/execute-subscription-event/src/index.ts @@ -0,0 +1 @@ +export * from './use-context-value-per-execute-subscription-event'; diff --git a/packages/plugins/execute-subscription-event/src/subscribe.ts b/packages/plugins/execute-subscription-event/src/subscribe.ts new file mode 100644 index 0000000000..631edb6788 --- /dev/null +++ b/packages/plugins/execute-subscription-event/src/subscribe.ts @@ -0,0 +1,49 @@ +import { createSourceEventStream } from 'graphql'; +import isAsyncIterable from 'graphql/jsutils/isAsyncIterable'; +import mapAsyncIterator from 'graphql/subscription/mapAsyncIterator'; +import { makeSubscribe } from '@envelop/core'; +import { ExecuteFunction, SubscribeFunction } from '@envelop/types'; + +/** + * This is a almost identical port from graphql-js subscribe. + * The only difference is that a custom `execute` function can be injected for customizing the behavior. + */ +export const subscribe = (execute: ExecuteFunction): SubscribeFunction => + makeSubscribe(async args => { + const { schema, document, rootValue, contextValue, variableValues, operationName, fieldResolver, subscribeFieldResolver } = + args; + + const resultOrStream = await createSourceEventStream( + schema, + document, + rootValue, + contextValue, + variableValues ?? undefined, + operationName, + subscribeFieldResolver + ); + + if (!isAsyncIterable(resultOrStream)) { + return resultOrStream; + } + + // For each payload yielded from a subscription, map it over the normal + // GraphQL `execute` function, with `payload` as the rootValue. + // This implements the "MapSourceToResponseEvent" algorithm described in + // the GraphQL specification. The `execute` function provides the + // "ExecuteSubscriptionEvent" algorithm, as it is nearly identical to the + // "ExecuteQuery" algorithm, for which `execute` is also used. + const mapSourceToResponse = async (payload: object) => + execute({ + schema, + document, + rootValue: payload, + contextValue, + variableValues, + operationName, + fieldResolver, + }); + + // Map every source value to a ExecutionResult value as described above. + return mapAsyncIterator(resultOrStream, mapSourceToResponse); + }); diff --git a/packages/plugins/execute-subscription-event/src/use-context-value-per-execute-subscription-event.ts b/packages/plugins/execute-subscription-event/src/use-context-value-per-execute-subscription-event.ts new file mode 100644 index 0000000000..0d3fd3e724 --- /dev/null +++ b/packages/plugins/execute-subscription-event/src/use-context-value-per-execute-subscription-event.ts @@ -0,0 +1,42 @@ +import { SubscriptionArgs, execute } from 'graphql'; +import { Plugin } from '@envelop/types'; +import { makeExecute } from '@envelop/core'; +import { PromiseOrValue } from 'graphql/jsutils/PromiseOrValue'; +import { subscribe } from './subscribe'; + +export type ContextFactoryOptions = { + /** The arguments with which the subscription was set up. */ + args: SubscriptionArgs; +}; +export type ContextFactoryHook = { + /** Context that will be used for the "ExecuteSubscriptionEvent" phase. */ + contextValue: TContextValue; + /** Optional callback that is invoked once the "ExecuteSubscriptionEvent" phase has ended. Useful for cleanup, such as tearing down database connections. */ + onEnd?: () => void; +}; +export type ContextFactoryType = ( + options: ContextFactoryOptions +) => PromiseOrValue | void>; + +export const useContextValuePerExecuteSubscriptionEvent = ( + createContext: ContextFactoryType +): Plugin => { + return { + onSubscribe({ args, setSubscribeFn }) { + const executeNew = makeExecute(async executionArgs => { + const context = await createContext({ args }); + try { + return execute({ + ...executionArgs, + contextValue: context ? context.contextValue : executionArgs.contextValue, + }); + } finally { + if (context && context.onEnd) { + context.onEnd(); + } + } + }); + setSubscribeFn(subscribe(executeNew)); + }, + }; +}; diff --git a/packages/plugins/execute-subscription-event/test/use-context-per-subscription-event.spec.ts b/packages/plugins/execute-subscription-event/test/use-context-per-subscription-event.spec.ts new file mode 100644 index 0000000000..c0f4dfd87c --- /dev/null +++ b/packages/plugins/execute-subscription-event/test/use-context-per-subscription-event.spec.ts @@ -0,0 +1,68 @@ +import { createTestkit, SubscriptionInterface } from '@envelop/testing'; +import { schema, subscription, pubSub } from '../../../core/test/common'; +import { useContextValuePerExecuteSubscriptionEvent } from '../src'; + +let result: SubscriptionInterface | null = null; + +afterEach(() => { + result?.unsubscribe(); +}); + +describe('useContextValuePerExecuteSubscriptionEvent', () => { + it('it can be used for injecting a context that is different from the subscription context', async done => { + const subscriptionContextValue = 'I am subscription context'; + + let counter = 0; + + const testInstance = createTestkit( + [ + useContextValuePerExecuteSubscriptionEvent(() => ({ + contextValue: `=== ${counter}`, + })), + ], + schema + ); + + result = await testInstance.subscribe(subscription, undefined, subscriptionContextValue); + result.subscribe(result => { + expect(result.errors).toBeUndefined(); + if (counter === 0) { + expect(result.data!.ping).toEqual('0 === 0'); + pubSub.publish('ping', String(counter)); + done(); + return; + } + if (counter === 1) { + expect(result.data!.ping).toEqual('1 === 1'); + done(); + return; + } + }); + + pubSub.publish('ping', String(counter)); + }); + + it('invokes cleanup function after value is published', async done => { + let onEnd = jest.fn(); + const testInstance = createTestkit( + [ + useContextValuePerExecuteSubscriptionEvent(() => ({ + contextValue: `hi`, + onEnd, + })), + ], + schema + ); + + result = await testInstance.subscribe(subscription, undefined); + + result.subscribe(result => { + expect(result.errors).toBeUndefined(); + expect(result.data!.ping).toEqual('foo hi'); + expect(onEnd.mock.calls).toHaveLength(1); + done(); + }); + + pubSub.publish('ping', 'foo'); + }); +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index f52ac8d47d..308576b466 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -12,7 +12,7 @@ import { ExecutionResult, ValidationRule, TypeInfo, - SubscriptionArgs as OriginalSubscriptionArgs, + SubscriptionArgs, GraphQLFieldResolver, GraphQLTypeResolver, } from 'graphql'; @@ -34,10 +34,6 @@ export type PolymorphicExecuteArguments = export type ExecuteFunction = (...args: PolymorphicExecuteArguments) => PromiseOrValue; -export type SubscriptionArgs = OriginalSubscriptionArgs & { - execute?: ExecuteFunction; -}; - export type PolymorphicSubscribeArguments = | [SubscriptionArgs] | [ @@ -83,21 +79,12 @@ export type OnExecuteHookResult = { onResolverCalled?: OnResolverCalledHooks; }; -export type OnExecuteSubscriptionEventHandler = (options: { - executeFn: ExecuteFunction; - args: ExecutionArgs; - setExecuteFn: (newExecute: ExecuteFunction) => void; - setResultAndStopExecution: (newResult: ExecutionResult) => void; - extendContext: (contextExtension: Partial) => void; -}) => OnExecuteHookResult | void; - export type OnSubscribeHookResult = { onSubscribeResult?: (options: { result: AsyncIterableIterator | ExecutionResult; setResult: (newResult: AsyncIterableIterator | ExecutionResult) => void; }) => void; onResolverCalled?: OnResolverCalledHooks; - onExecuteSubscriptionEvent?: OnExecuteSubscriptionEventHandler; }; export interface Plugin { From a7a340765a210b6bdb9db418a80f5454331e9d54 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 24 May 2021 21:09:56 +0200 Subject: [PATCH 13/14] chore: update changeset --- .changeset/grumpy-melons-deny.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.changeset/grumpy-melons-deny.md b/.changeset/grumpy-melons-deny.md index 64760f1a15..f9d95551ca 100644 --- a/.changeset/grumpy-melons-deny.md +++ b/.changeset/grumpy-melons-deny.md @@ -3,8 +3,4 @@ '@envelop/types': patch --- -Add custom `subscribe` function that behaves like `graphql-js` `subscribe` with an additional parameter for a custom `execute` function that is used for the `ExecuteSubscriptionEvent` phase. - Expose utility functions `getExecuteArgs`, `getSubscribeArgs`, `makeExecute`, and `makeSubscribe` for easier handling of composition with the polymorphic arguments. - -Allow hooking into the `ExecuteSubscriptionEvent` phase with the `onExecuteSubscriptionEvent` hook. From 3bb8394b33d1e20b64157a4ab0dad7a745cf1153 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 24 May 2021 21:11:40 +0200 Subject: [PATCH 14/14] chore: add changeset for useContextValuePerExecuteSubscriptionEvent --- .changeset/many-bugs-peel.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/many-bugs-peel.md diff --git a/.changeset/many-bugs-peel.md b/.changeset/many-bugs-peel.md new file mode 100644 index 0000000000..f5a8594724 --- /dev/null +++ b/.changeset/many-bugs-peel.md @@ -0,0 +1,5 @@ +--- +'@envelop/execute-subscription-event': patch +--- + +Add `useContextValuePerExecuteSubscriptionEvent` for creating a new context for each `ExecuteSubscriptionEvent` phase.