diff --git a/docs/source/integrations/plugins.md b/docs/source/integrations/plugins.md index df4003d8544..578e62f6a3e 100644 --- a/docs/source/integrations/plugins.md +++ b/docs/source/integrations/plugins.md @@ -121,17 +121,18 @@ The following diagram illustrates the sequence of events that fire for each requ ```mermaid graph TB; - request(requestDidStart) --> parsing(parsingDidStart*); + request(requestDidStart) --> resolveSource(didResolveSource); + resolveSource --"Success"--> parsing(parsingDidStart*); parsing --"Success"--> validation(validationDidStart*); - validation --"Success"--> resolve(didResolveOperation); - resolve --"Success"--> response(responseForOperation); + validation --"Success"--> resolveOperation(didResolveOperation); + resolveOperation --"Success"--> response(responseForOperation); execution(executionDidStart*); errors(didEncounterErrors); response --"Response provided"--> send; response --"No response provided"--> execution; execution --"Success"--> send(willSendResponse); - execution & resolve & parsing & validation --"Failure"--> errors; + execution & resolveSource & resolveOperation & parsing & validation --"Failure"--> errors; errors --> send; class server,request secondary; ``` @@ -313,6 +314,23 @@ should not return a value. > If you're using TypeScript to create your plugin, implement the [ `GraphQLRequestListener` interface](https://github.com/apollographql/apollo-server/blob/master/packages/apollo-server-plugin-base/src/index.ts) from the `apollo-server-plugin-base` module to define functions for request lifecycle events. +### `didResolveSource` + +The `didResolveSource` event is invoked after Apollo Server has determined the +`String`-representation of the incoming operation that it will act upon. In the +event that this `String` was not directly passed in from the client, this +may be retrieved from a cache store (e.g., Automated Persisted Queries). + +At this stage, there is not a guarantee that the operation is not malformed. + +```typescript +didResolveSource?( + requestContext: WithRequired< + GraphQLRequestContext, 'source' | 'logger'>, + >, +): ValueOrPromise; +``` + ### `parsingDidStart` The `parsingDidStart` event fires whenever Apollo Server will parse a GraphQL diff --git a/packages/apollo-engine-reporting/src/agent.ts b/packages/apollo-engine-reporting/src/agent.ts index 9e360846e8b..efb84a4d1a8 100644 --- a/packages/apollo-engine-reporting/src/agent.ts +++ b/packages/apollo-engine-reporting/src/agent.ts @@ -12,7 +12,7 @@ import { fetch, RequestAgent, Response } from 'apollo-server-env'; import retry from 'async-retry'; import { EngineReportingExtension } from './extension'; -import { GraphQLRequestContext, Logger } from 'apollo-server-types'; +import { GraphQLRequestContext, Logger, SchemaHash } from 'apollo-server-types'; import { InMemoryLRUCache } from 'apollo-server-caching'; import { defaultEngineReportingSignature } from 'apollo-graphql'; @@ -250,7 +250,7 @@ export interface AddTraceArgs { trace: Trace; operationName: string; queryHash: string; - schemaHash: string; + schemaHash: SchemaHash; queryString?: string; documentAST?: DocumentNode; } @@ -328,7 +328,7 @@ export class EngineReportingAgent { handleLegacyOptions(this.options); } - public newExtension(schemaHash: string): EngineReportingExtension { + public newExtension(schemaHash: SchemaHash): EngineReportingExtension { return new EngineReportingExtension( this.options, this.addTrace.bind(this), diff --git a/packages/apollo-engine-reporting/src/extension.ts b/packages/apollo-engine-reporting/src/extension.ts index 6c90f6bf384..1b88527d5ad 100644 --- a/packages/apollo-engine-reporting/src/extension.ts +++ b/packages/apollo-engine-reporting/src/extension.ts @@ -1,4 +1,9 @@ -import { GraphQLRequestContext, WithRequired, Logger } from 'apollo-server-types'; +import { + GraphQLRequestContext, + WithRequired, + Logger, + SchemaHash, +} from 'apollo-server-types'; import { Request, Headers } from 'apollo-server-env'; import { GraphQLResolveInfo, @@ -42,7 +47,7 @@ export class EngineReportingExtension public constructor( options: EngineReportingOptions, addTrace: (args: AddTraceArgs) => Promise, - private schemaHash: string, + private schemaHash: SchemaHash, ) { this.options = { ...options, diff --git a/packages/apollo-gateway/src/executeQueryPlan.ts b/packages/apollo-gateway/src/executeQueryPlan.ts index 902c6552902..b92aec7d61e 100644 --- a/packages/apollo-gateway/src/executeQueryPlan.ts +++ b/packages/apollo-gateway/src/executeQueryPlan.ts @@ -89,11 +89,9 @@ export async function executeQueryPlan( }, rootValue: data, variableValues: requestContext.request.variables, - // FIXME: GraphQL extensions currently wraps every field and creates - // a field resolver. Because of this, when using with ApolloServer - // the defaultFieldResolver isn't called. We keep this here - // because it is the correct solution and when ApolloServer removes - // GraphQLExtensions this will be how alias support is maintained + // We have a special field resolver which ensures we support aliases. + // FIXME: It's _possible_ this will change after `graphql-extensions` is + // deprecated, though not certain. See here, also: https://git.io/Jf8cS. fieldResolver: defaultFieldResolverWithAliasSupport, })); } catch (error) { diff --git a/packages/apollo-gateway/src/index.ts b/packages/apollo-gateway/src/index.ts index 1daf6097d69..d74269f0299 100644 --- a/packages/apollo-gateway/src/index.ts +++ b/packages/apollo-gateway/src/index.ts @@ -476,6 +476,10 @@ export class ApolloGateway implements GraphQLService { this.logger.debug('Schema loaded and ready for execution'); + // FIXME: The comment below may change when `graphql-extensions` is + // removed, as it will be soon. It's not clear if this will be temporary, + // as is suggested, after that time, because we still very much need to + // do this special alias resolving. Original comment: // this is a temporary workaround for GraphQLFieldExtensions automatic // wrapping of all fields when using ApolloServer. Here we wrap all fields // with support for resolving aliases as part of the root value which diff --git a/packages/apollo-server-core/src/ApolloServer.ts b/packages/apollo-server-core/src/ApolloServer.ts index f5de66b3c20..724dd382c35 100644 --- a/packages/apollo-server-core/src/ApolloServer.ts +++ b/packages/apollo-server-core/src/ApolloServer.ts @@ -71,8 +71,8 @@ import { import { Headers } from 'apollo-server-env'; import { buildServiceDefinition } from '@apollographql/apollo-tools'; +import { Logger, SchemaHash } from "apollo-server-types"; import { getEngineApiKey, getEngineGraphVariant } from "apollo-engine-reporting/dist/agent"; -import { Logger } from "apollo-server-types"; const NoIntrospection = (context: ValidationContext) => ({ Field(node: FieldDefinitionNode) { @@ -109,7 +109,7 @@ type SchemaDerivedData = { // on the same operation to be executed immediately. documentStore?: InMemoryLRUCache; schema: GraphQLSchema; - schemaHash: string; + schemaHash: SchemaHash; extensions: Array<() => GraphQLExtension>; }; @@ -759,12 +759,16 @@ export class ApolloServerBase { return sdlFieldType.name == 'String'; } - private ensurePluginInstantiation(plugins?: PluginDefinition[]): void { - if (!plugins || !plugins.length) { - return; - } + private ensurePluginInstantiation(plugins: PluginDefinition[] = []): void { + const pluginsToInit: PluginDefinition[] = []; + + // Internal plugins should be added to `pluginsToInit` here. + // User's plugins, provided as an argument to this method, will be added + // at the end of that list so they take precedence. + // A follow-up commit will actually introduce this. - this.plugins = plugins.map(plugin => { + pluginsToInit.push(...plugins); + this.plugins = pluginsToInit.map(plugin => { if (typeof plugin === 'function') { return plugin(); } diff --git a/packages/apollo-server-core/src/__tests__/runQuery.test.ts b/packages/apollo-server-core/src/__tests__/runQuery.test.ts index d4f4640ca2c..e6c4ec5124a 100644 --- a/packages/apollo-server-core/src/__tests__/runQuery.test.ts +++ b/packages/apollo-server-core/src/__tests__/runQuery.test.ts @@ -19,7 +19,14 @@ import { import { processGraphQLRequest, GraphQLRequest } from '../requestPipeline'; import { Request } from 'apollo-server-env'; import { GraphQLOptions, Context as GraphQLContext } from 'apollo-server-core'; -import { ApolloServerPlugin } from 'apollo-server-plugin-base'; +import { + ApolloServerPlugin, + GraphQLRequestExecutionListener, + GraphQLRequestListenerDidResolveField, + GraphQLRequestListenerExecutionDidEnd, + GraphQLRequestListenerParsingDidEnd, + GraphQLRequestListenerValidationDidEnd, +} from 'apollo-server-plugin-base'; import { GraphQLRequestListener } from 'apollo-server-plugin-base'; import { InMemoryLRUCache } from 'apollo-server-caching'; @@ -39,6 +46,7 @@ function runQuery(options: QueryOptions): Promise { return processGraphQLRequest(options, { request, + logger: console, context: options.context || {}, debug: options.debug, cache: {} as any, @@ -471,6 +479,39 @@ describe('runQuery', () => { }); }); + /** + * This tests the simple invocation of the "didResolveSource" hook, but + * doesn't test one of the primary reasons why "source" isn't guaranteed + * sooner in the request life-cycle: when "source" is populated via an APQ + * cache HIT. + * + * That functionality is tested in `apollo-server-integration-testsuite`, + * within the "Persisted Queries" tests. (Search for "didResolveSource"). + */ + describe('didResolveSource', () => { + const didResolveSource = jest.fn(); + it('called with the source', async () => { + await runQuery({ + schema, + queryString: '{ testString }', + plugins: [ + { + requestDidStart() { + return { + didResolveSource, + }; + }, + }, + ], + request: new MockReq(), + }); + + expect(didResolveSource).toHaveBeenCalled(); + expect(didResolveSource.mock.calls[0][0]) + .toHaveProperty('source', '{ testString }'); + }); + }); + describe('parsingDidStart', () => { const parsingDidStart = jest.fn(); it('called when parsing will result in an error', async () => { @@ -512,6 +553,407 @@ describe('runQuery', () => { }); }); + describe('executionDidStart', () => { + it('called when execution starts', async () => { + const executionDidStart = jest.fn(); + await runQuery({ + schema, + queryString: '{ testString }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart, + }; + }, + }, + ], + request: new MockReq(), + }); + + expect(executionDidStart).toHaveBeenCalledTimes(1); + }); + + describe('executionDidEnd', () => { + it('works as a function returned from "executionDidStart"', async () => { + const executionDidEnd = jest.fn(); + const executionDidStart = jest.fn( + (): GraphQLRequestListenerExecutionDidEnd => executionDidEnd); + + await runQuery({ + schema, + queryString: '{ testString }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart, + }; + }, + }, + ], + request: new MockReq(), + }); + + expect(executionDidStart).toHaveBeenCalledTimes(1); + expect(executionDidEnd).toHaveBeenCalledTimes(1); + }); + + it('works as a listener on an object returned from "executionDidStart"', + async () => { + const executionDidEnd = jest.fn(); + const executionDidStart = jest.fn( + (): GraphQLRequestExecutionListener => ({ + executionDidEnd, + }), + ); + + await runQuery({ + schema, + queryString: '{ testString }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart, + }; + }, + }, + ], + request: new MockReq(), + } + ); + + expect(executionDidStart).toHaveBeenCalledTimes(1); + expect(executionDidEnd).toHaveBeenCalledTimes(1); + }); + + }); + + describe('willResolveField', () => { + it('called when resolving a field starts', async () => { + const willResolveField = jest.fn(); + const executionDidEnd = jest.fn(); + const executionDidStart = jest.fn( + (): GraphQLRequestExecutionListener => ({ + willResolveField, + executionDidEnd, + }), + ); + + await runQuery({ + schema, + queryString: '{ testString }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart, + }; + }, + }, + ], + request: new MockReq(), + }); + + expect(executionDidStart).toHaveBeenCalledTimes(1); + expect(willResolveField).toHaveBeenCalledTimes(1); + expect(executionDidEnd).toHaveBeenCalledTimes(1); + }); + + it('called once for each field being resolved', async () => { + const willResolveField = jest.fn(); + const executionDidEnd = jest.fn(); + const executionDidStart = jest.fn( + (): GraphQLRequestExecutionListener => ({ + willResolveField, + executionDidEnd, + }), + ); + + await runQuery({ + schema, + queryString: '{ testString again:testString }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart, + }; + }, + }, + ], + request: new MockReq(), + }); + + expect(executionDidStart).toHaveBeenCalledTimes(1); + expect(willResolveField).toHaveBeenCalledTimes(2); + expect(executionDidEnd).toHaveBeenCalledTimes(1); + }); + + describe('receives correct resolver parameter object', () => { + it('receives undefined parent when there is no parent', async () => { + const willResolveField = jest.fn(); + + await runQuery({ + schema, + queryString: '{ testString }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart: () => ({ + willResolveField, + }), + }; + }, + }, + ], + request: new MockReq(), + }); + + // It is called only once. + expect(willResolveField).toHaveBeenCalledTimes(1); + const call = willResolveField.mock.calls[0]; + expect(call[0]).toHaveProperty("source", undefined); + expect(call[0]).toHaveProperty("info.path.key", "testString"); + expect(call[0]).toHaveProperty("info.path.prev", undefined); + }); + + it('receives the parent when there is one', async () => { + const willResolveField = jest.fn(); + + await runQuery({ + schema, + queryString: '{ testObject { testString } }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart: () => ({ + willResolveField, + }), + }; + }, + }, + ], + request: new MockReq(), + }); + + // It is called 1st for `testObject` and then 2nd for `testString`. + expect(willResolveField).toHaveBeenCalledTimes(2); + const [firstCall, secondCall] = willResolveField.mock.calls; + expect(firstCall[0]).toHaveProperty("source", undefined); + expect(firstCall[0]).toHaveProperty("info.path.key", "testObject"); + expect(firstCall[0]).toHaveProperty("info.path.prev", undefined); + + expect(secondCall[0]).toHaveProperty('source', { + testString: 'a very test string', + }); + expect(secondCall[0]).toHaveProperty("info.path.key", "testString"); + expect(secondCall[0]).toHaveProperty('info.path.prev', { + key: 'testObject', + prev: undefined, + }); + }); + + it('receives context', async () => { + const willResolveField = jest.fn(); + + await runQuery({ + schema, + context: { ourSpecialContext: true }, + queryString: '{ testString }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart: () => ({ + willResolveField, + }), + }; + }, + }, + ], + request: new MockReq(), + }); + + expect(willResolveField).toHaveBeenCalledTimes(1); + expect(willResolveField.mock.calls[0][0]).toHaveProperty("context", + expect.objectContaining({ ourSpecialContext: true }), + ); + }); + + it('receives arguments', async () => { + const willResolveField = jest.fn(); + + await runQuery({ + schema, + queryString: '{ testArgumentValue(base: 99) }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart: () => ({ + willResolveField, + }), + }; + }, + }, + ], + request: new MockReq(), + }); + + expect(willResolveField).toHaveBeenCalledTimes(1); + expect(willResolveField.mock.calls[0][0]) + .toHaveProperty("args.base", 99); + }); + }); + + it('calls the end handler', async () => { + const didResolveField: GraphQLRequestListenerDidResolveField = + jest.fn(); + const willResolveField = jest.fn(() => didResolveField); + const executionDidEnd = jest.fn(); + const executionDidStart = jest.fn( + (): GraphQLRequestExecutionListener => ({ + willResolveField, + executionDidEnd, + }), + ); + + await runQuery({ + schema, + queryString: '{ testString }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart, + }; + }, + }, + ], + request: new MockReq(), + }); + + expect(executionDidStart).toHaveBeenCalledTimes(1); + expect(willResolveField).toHaveBeenCalledTimes(1); + expect(didResolveField).toHaveBeenCalledTimes(1); + expect(executionDidEnd).toHaveBeenCalledTimes(1); + }); + + it('calls the end handler for each field being resolved', async () => { + const didResolveField: GraphQLRequestListenerDidResolveField = + jest.fn(); + const willResolveField = jest.fn(() => didResolveField); + const executionDidEnd = jest.fn(); + const executionDidStart = jest.fn( + (): GraphQLRequestExecutionListener => ({ + willResolveField, + executionDidEnd, + }), + ); + + await runQuery({ + schema, + queryString: '{ testString again: testString }', + plugins: [ + { + requestDidStart() { + return { + executionDidStart, + }; + }, + }, + ], + request: new MockReq(), + }); + + expect(executionDidStart).toHaveBeenCalledTimes(1); + expect(willResolveField).toHaveBeenCalledTimes(2); + expect(didResolveField).toHaveBeenCalledTimes(2); + expect(executionDidEnd).toHaveBeenCalledTimes(1); + }); + + it('uses the custom "fieldResolver" when defined', async () => { + const schemaWithResolver = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'QueryType', + fields: { + testString: { + type: GraphQLString, + resolve() { + return "using schema-defined resolver"; + }, + }, + } + }) + }); + + const schemaWithoutResolver = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'QueryType', + fields: { + testString: { + type: GraphQLString, + }, + } + }) + }); + + const differentFieldResolver = () => "I'm diffrnt, ya, I'm diffrnt."; + + const queryString = `{ testString } `; + + const didResolveField: GraphQLRequestListenerDidResolveField = + jest.fn(); + const willResolveField = jest.fn(() => didResolveField); + + const plugins: ApolloServerPlugin[] = [ + { + requestDidStart: () => ({ + executionDidStart: () => ({ + willResolveField, + }), + }) + }, + ]; + + const resultFromSchemaWithResolver = await runQuery({ + schema: schemaWithResolver, + queryString, + plugins, + request: new MockReq(), + fieldResolver: differentFieldResolver, + }); + + expect(willResolveField).toHaveBeenCalledTimes(1); + expect(didResolveField).toHaveBeenCalledTimes(1); + + expect(resultFromSchemaWithResolver.data).toEqual({ + testString: "using schema-defined resolver" + }); + + const resultFromSchemaWithoutResolver = await runQuery({ + schema: schemaWithoutResolver, + queryString, + plugins, + request: new MockReq(), + fieldResolver: differentFieldResolver, + }); + + expect(willResolveField).toHaveBeenCalledTimes(2); + expect(didResolveField).toHaveBeenCalledTimes(2); + + expect(resultFromSchemaWithoutResolver.data).toEqual({ + testString: "I'm diffrnt, ya, I'm diffrnt." + }); + }); + }); + }); + + describe('didEncounterErrors', () => { const didEncounterErrors = jest.fn(); const plugins: ApolloServerPlugin[] = [ @@ -569,6 +1011,109 @@ describe('runQuery', () => { expect(didEncounterErrors).not.toBeCalled(); }); }); + + describe("ordering", () => { + it('calls hooks in the expected order', async () => { + const callOrder: string[] = []; + let stopAwaiting: Function; + const toBeAwaited = new Promise(resolve => stopAwaiting = resolve); + + const parsingDidEnd: GraphQLRequestListenerParsingDidEnd = + jest.fn(() => callOrder.push('parsingDidEnd')); + const parsingDidStart: GraphQLRequestListener['parsingDidStart'] = + jest.fn(() => { + callOrder.push('parsingDidStart'); + return parsingDidEnd; + }); + + const validationDidEnd: GraphQLRequestListenerValidationDidEnd = + jest.fn(() => callOrder.push('validationDidEnd')); + const validationDidStart: GraphQLRequestListener['validationDidStart'] = + jest.fn(() => { + callOrder.push('validationDidStart'); + return validationDidEnd; + }); + + const didResolveSource: GraphQLRequestListener['didResolveSource'] = + jest.fn(() => { callOrder.push('didResolveSource') }); + + const didResolveField: GraphQLRequestListenerDidResolveField = + jest.fn(() => callOrder.push("didResolveField")); + + const willResolveField = jest.fn(() => { + callOrder.push("willResolveField"); + return didResolveField; + }); + + const executionDidEnd: GraphQLRequestListenerExecutionDidEnd = + jest.fn(() => callOrder.push('executionDidEnd')); + + const executionDidStart = jest.fn( + (): GraphQLRequestExecutionListener => { + callOrder.push("executionDidStart"); + return { willResolveField, executionDidEnd }; + }, + ); + + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'QueryType', + fields: { + testString: { + type: GraphQLString, + async resolve() { + callOrder.push("beforeAwaiting"); + await toBeAwaited; + callOrder.push("afterAwaiting"); + return "it works"; + }, + }, + } + }) + }); + + Promise.resolve().then(() => stopAwaiting()); + + await runQuery({ + schema, + queryString: '{ testString }', + plugins: [ + { + requestDidStart() { + return { + parsingDidStart, + validationDidStart, + didResolveSource, + executionDidStart, + }; + }, + }, + ], + request: new MockReq(), + }); + + expect(parsingDidStart).toHaveBeenCalledTimes(1); + expect(parsingDidEnd).toHaveBeenCalledTimes(1); + expect(validationDidStart).toHaveBeenCalledTimes(1); + expect(validationDidEnd).toHaveBeenCalledTimes(1); + expect(executionDidStart).toHaveBeenCalledTimes(1); + expect(willResolveField).toHaveBeenCalledTimes(1); + expect(didResolveField).toHaveBeenCalledTimes(1); + expect(callOrder).toStrictEqual([ + "didResolveSource", + "parsingDidStart", + "parsingDidEnd", + "validationDidStart", + "validationDidEnd", + "executionDidStart", + "willResolveField", + "beforeAwaiting", + "afterAwaiting", + "didResolveField", + "executionDidEnd", + ]); + }); + }) }); describe('parsing and validation cache', () => { diff --git a/packages/apollo-server-core/src/requestPipeline.ts b/packages/apollo-server-core/src/requestPipeline.ts index 07a640c1924..eb287cb9516 100644 --- a/packages/apollo-server-core/src/requestPipeline.ts +++ b/packages/apollo-server-core/src/requestPipeline.ts @@ -17,7 +17,12 @@ import { enableGraphQLExtensions, } from 'graphql-extensions'; import { DataSource } from 'apollo-datasource'; -import { PersistedQueryOptions } from '.'; +import { PersistedQueryOptions } from './graphqlOptions'; +import { + symbolExecutionDispatcherWillResolveField, + enablePluginsForSchemaResolvers, + symbolUserFieldResolver, +} from "./utils/schemaInstrumentation" import { CacheControlExtension, CacheControlExtensionOptions, @@ -45,6 +50,7 @@ import { import { ApolloServerPlugin, GraphQLRequestListener, + GraphQLRequestContextDidResolveSource, GraphQLRequestContextExecutionDidStart, GraphQLRequestContextResponseForOperation, GraphQLRequestContextDidResolveOperation, @@ -52,6 +58,7 @@ import { GraphQLRequestContextValidationDidStart, GraphQLRequestContextWillSendResponse, GraphQLRequestContextDidEncounterErrors, + GraphQLRequestExecutionListener, } from 'apollo-server-plugin-base'; import { Dispatcher } from './utils/dispatcher'; @@ -127,7 +134,6 @@ export async function processGraphQLRequest( (requestContext.context as any)._extensionStack = extensionStack; const dispatcher = initializeRequestListenerDispatcher(); - await initializeDataSources(); const metrics = requestContext.metrics || Object.create(null); @@ -205,6 +211,16 @@ export async function processGraphQLRequest( requestContext.queryHash = queryHash; requestContext.source = query; + // Let the plugins know that we now have a STRING of what we hope will + // parse and validate into a document we can execute on. Unless we have + // retrieved this from our APQ cache, there's no guarantee that it is + // syntactically correct, so this string should not be trusted as a valid + // document until after it's parsed and validated. + await dispatcher.invokeHookAsync( + 'didResolveSource', + requestContext as GraphQLRequestContextDidResolveSource, + ); + const requestDidEnd = extensionStack.requestDidStart({ request: request.http!, queryString: request.query, @@ -357,11 +373,55 @@ export async function processGraphQLRequest( requestContext as GraphQLRequestContextResponseForOperation, ); if (response == null) { - const executionDidEnd = await dispatcher.invokeDidStartHook( + // This execution dispatcher code is duplicated in `pluginTestHarness` + // right now. + + const executionListeners: GraphQLRequestExecutionListener[] = []; + dispatcher.invokeHookSync( 'executionDidStart', requestContext as GraphQLRequestContextExecutionDidStart, + ).forEach(executionListener => { + if (typeof executionListener === 'function') { + executionListeners.push({ + executionDidEnd: executionListener, + }); + } else if (typeof executionListener === 'object') { + executionListeners.push(executionListener); + } + }); + + const executionDispatcher = new Dispatcher(executionListeners); + + // Create a callback that will trigger the execution dispatcher's + // `willResolveField` hook. We will attach this to the context on a + // symbol so it can be invoked by our `wrapField` method during execution. + const invokeWillResolveField: GraphQLRequestExecutionListener< + TContext + >['willResolveField'] = (...args) => + executionDispatcher.invokeDidStartHook('willResolveField', ...args); + + Object.defineProperty( + requestContext.context, + symbolExecutionDispatcherWillResolveField, + { value: invokeWillResolveField } ); + // If the user has provided a custom field resolver, we will attach + // it to the context so we can still invoke it after we've wrapped the + // fields with `wrapField` within `enablePluginsForSchemaResolvers` of + // the `schemaInstrumentation` module. + if (config.fieldResolver) { + Object.defineProperty( + requestContext.context, + symbolUserFieldResolver, + { value: config.fieldResolver } + ); + } + + // If the schema is already enabled, this is a no-op. Otherwise, the + // schema will be augmented so it is able to invoke willResolveField. + enablePluginsForSchemaResolvers(config.schema); + try { const result = await execute( requestContext as GraphQLRequestContextExecutionDidStart, @@ -376,9 +436,9 @@ export async function processGraphQLRequest( errors: result.errors ? formatErrors(result.errors) : undefined, }; - executionDidEnd(); + executionDispatcher.reverseInvokeHookSync("executionDidEnd"); } catch (executionError) { - executionDidEnd(executionError); + executionDispatcher.reverseInvokeHookSync("executionDidEnd", executionError); return await sendErrorResponse(executionError); } } @@ -569,7 +629,7 @@ export async function processGraphQLRequest( } function initializeRequestListenerDispatcher(): Dispatcher< - GraphQLRequestListener + GraphQLRequestListener > { const requestListeners: GraphQLRequestListener[] = []; if (config.plugins) { diff --git a/packages/apollo-server-core/src/utils/dispatcher.ts b/packages/apollo-server-core/src/utils/dispatcher.ts index d311eaf055a..122fa9aa408 100644 --- a/packages/apollo-server-core/src/utils/dispatcher.ts +++ b/packages/apollo-server-core/src/utils/dispatcher.ts @@ -1,27 +1,47 @@ -import { GraphQLRequestListener } from "apollo-server-plugin-base"; +import { AnyFunction, AnyFunctionMap } from "apollo-server-types"; -type AnyFunction = (...args: any[]) => any; type Args = F extends (...args: infer A) => any ? A : never; type AsFunction = F extends AnyFunction ? F : never; type UnwrapPromise = T extends Promise ? U : T; type DidEndHook = (...args: TArgs) => void; -export class Dispatcher { +export class Dispatcher { constructor(protected targets: T[]) {} + private callTargets( + targets: T[], + methodName: TMethodName, + ...args: Args + ): ReturnType>[] { + return targets.map(target => { + const method = target[methodName]; + if (method && typeof method === 'function') { + return method.apply(target, args); + } + }); + } + public async invokeHookAsync( methodName: TMethodName, ...args: Args - ): Promise>>[]> { + ): Promise>[]> { return await Promise.all( - this.targets.map(target => { - const method = target[methodName]; - if (method && typeof method === 'function') { - return method.apply(target, args); - } - }), - ); + this.callTargets(this.targets, methodName, ...args)); + } + + public invokeHookSync( + methodName: TMethodName, + ...args: Args + ): ReturnType>[] { + return this.callTargets(this.targets, methodName, ...args); + } + + public reverseInvokeHookSync( + methodName: TMethodName, + ...args: Args + ): ReturnType>[] { + return this.callTargets(this.targets.reverse(), methodName, ...args); } public async invokeHooksUntilNonNull( diff --git a/packages/apollo-server-core/src/utils/pluginTestHarness.ts b/packages/apollo-server-core/src/utils/pluginTestHarness.ts new file mode 100644 index 00000000000..d94c3c5d642 --- /dev/null +++ b/packages/apollo-server-core/src/utils/pluginTestHarness.ts @@ -0,0 +1,179 @@ +import { + WithRequired, + GraphQLRequest, + GraphQLRequestContextExecutionDidStart, + GraphQLResponse, + ValueOrPromise, + GraphQLRequestContextWillSendResponse, + GraphQLRequestContext, + Logger, +} from 'apollo-server-types'; +import { GraphQLSchema, GraphQLObjectType, GraphQLString } from 'graphql/type'; +import { CacheHint } from 'apollo-cache-control'; +import { + enablePluginsForSchemaResolvers, + symbolExecutionDispatcherWillResolveField, +} from './schemaInstrumentation'; +import { + ApolloServerPlugin, + GraphQLRequestExecutionListener, +} from 'apollo-server-plugin-base'; +import { InMemoryLRUCache } from 'apollo-server-caching'; +import { Dispatcher } from './dispatcher'; + +// This test harness guarantees the presence of `query`. +type IPluginTestHarnessGraphqlRequest = WithRequired; +type IPluginTestHarnessExecutionDidStart = + GraphQLRequestContextExecutionDidStart & { + request: IPluginTestHarnessGraphqlRequest, + }; + +export default async function pluginTestHarness({ + pluginInstance, + schema, + logger, + graphqlRequest, + overallCachePolicy, + executor, + context = Object.create(null) +}: { + /** + * An instance of the plugin to test. + */ + pluginInstance: ApolloServerPlugin, + + /** + * The optional schema that will be received by the executor. If not + * specified, a simple default schema will be created. In either case, + * the schema will be mutated by wrapping the resolvers with the + * `willResolveField` instrumentation that will allow it to respond to + * that lifecycle hook's implementations plugins. + */ + schema?: GraphQLSchema; + + /** + * An optional logger (Defaults to `console`) + */ + logger?: Logger; + + /** + * The `GraphQLRequest` which will be received by the `executor`. The + * `query` is required, and this doesn't support anything more exotic, + * like automated persisted queries (APQ). + */ + graphqlRequest: IPluginTestHarnessGraphqlRequest; + + /** + * Overall cache control policy. + */ + overallCachePolicy?: Required; + + /** + * This method will be executed to retrieve the response. + */ + executor: ( + requestContext: IPluginTestHarnessExecutionDidStart, + ) => ValueOrPromise; + + /** + * (optional) To provide a user context, if necessary. + */ + context?: TContext; +}): Promise> { + + if (!schema) { + schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'RootQueryType', + fields: { + hello: { + type: GraphQLString, + resolve() { + return 'hello world'; + } + } + } + }) + }); + } + + const requestContext: GraphQLRequestContext = { + logger: logger || console, + request: graphqlRequest, + metrics: Object.create(null), + source: graphqlRequest.query, + cache: new InMemoryLRUCache(), + context, + }; + + requestContext.overallCachePolicy = overallCachePolicy; + + if (typeof pluginInstance.requestDidStart !== "function") { + throw new Error("Should be impossible as the plugin is defined."); + } + + const listener = pluginInstance.requestDidStart(requestContext); + + if (!listener) { + throw new Error("Should be impossible to not have a listener."); + } + + const dispatcher = new Dispatcher([listener]); + + const executionListeners: GraphQLRequestExecutionListener[] = []; + + if (typeof listener.executionDidStart === 'function') { + // This execution dispatcher logic is duplicated in the request pipeline + // right now. + dispatcher.invokeHookSync( + 'executionDidStart', + requestContext as GraphQLRequestContextExecutionDidStart, + ).forEach(executionListener => { + if (typeof executionListener === 'function') { + executionListeners.push({ + executionDidEnd: executionListener, + }); + } else if (typeof executionListener === 'object') { + executionListeners.push(executionListener); + } + }); + } + + const executionDispatcher = new Dispatcher(executionListeners); + + // Create a callback that will trigger the execution dispatcher's + // `willResolveField` hook. We will attach this to the context on a + // symbol so it can be invoked by our `wrapField` method during execution. + const invokeWillResolveField: GraphQLRequestExecutionListener< + TContext + >['willResolveField'] = (...args) => + executionDispatcher.invokeDidStartHook('willResolveField', ...args); + + Object.defineProperty( + requestContext.context, + symbolExecutionDispatcherWillResolveField, + { value: invokeWillResolveField } + ); + + // If the schema is already enabled, this is a no-op. Otherwise, the + // schema will be augmented so it is able to invoke willResolveField. + enablePluginsForSchemaResolvers(schema); + + try { + // `response` is readonly, so we'll cast to `any` to assign to it. + (requestContext.response as any) = await executor( + requestContext as IPluginTestHarnessExecutionDidStart, + ); + executionDispatcher.reverseInvokeHookSync("executionDidEnd"); + + } catch (executionErr) { + executionDispatcher.reverseInvokeHookSync("executionDidEnd", executionErr); + } + + await dispatcher.invokeHookAsync( + "willSendResponse", + requestContext as GraphQLRequestContextWillSendResponse, + ); + + return requestContext as GraphQLRequestContextWillSendResponse; +} diff --git a/packages/apollo-server-core/src/utils/schemaHash.ts b/packages/apollo-server-core/src/utils/schemaHash.ts index 7c30c6e751d..7137b9dac6c 100644 --- a/packages/apollo-server-core/src/utils/schemaHash.ts +++ b/packages/apollo-server-core/src/utils/schemaHash.ts @@ -4,8 +4,9 @@ import { getIntrospectionQuery, IntrospectionSchema } from 'graphql/utilities'; import stableStringify from 'fast-json-stable-stringify'; import { GraphQLSchema } from 'graphql/type'; import createSHA from './createSHA'; +import { SchemaHash } from "apollo-server-types"; -export function generateSchemaHash(schema: GraphQLSchema): string { +export function generateSchemaHash(schema: GraphQLSchema): SchemaHash { const introspectionQuery = getIntrospectionQuery(); const documentAST = parse(introspectionQuery); const result = execute(schema, documentAST) as ExecutionResult; @@ -40,5 +41,5 @@ export function generateSchemaHash(schema: GraphQLSchema): string { return createSHA('sha512') .update(stringifiedSchema) - .digest('hex'); + .digest('hex') as SchemaHash; } diff --git a/packages/apollo-server-core/src/utils/schemaInstrumentation.ts b/packages/apollo-server-core/src/utils/schemaInstrumentation.ts new file mode 100644 index 00000000000..e20f94a9311 --- /dev/null +++ b/packages/apollo-server-core/src/utils/schemaInstrumentation.ts @@ -0,0 +1,172 @@ +import { + GraphQLSchema, + GraphQLField, + ResponsePath, + getNamedType, + GraphQLObjectType, + GraphQLFieldResolver, +} from 'graphql/type'; +import { defaultFieldResolver } from "graphql/execution"; +import { FieldNode } from "graphql/language"; +import { GraphQLRequestExecutionListener } from "apollo-server-plugin-base"; +import { GraphQLObjectResolver } from "@apollographql/apollo-tools"; + +export const symbolExecutionDispatcherWillResolveField = + Symbol("apolloServerExecutionDispatcherWillResolveField"); +export const symbolUserFieldResolver = + Symbol("apolloServerUserFieldResolver"); +export const symbolPluginsEnabled = Symbol("apolloServerPluginsEnabled"); + +export function enablePluginsForSchemaResolvers( + schema: GraphQLSchema & { [symbolPluginsEnabled]?: boolean }, +) { + if (schema[symbolPluginsEnabled]) { + return schema; + } + Object.defineProperty(schema, symbolPluginsEnabled, { + value: true, + }); + + forEachField(schema, wrapField); + + return schema; +} + +function wrapField(field: GraphQLField): void { + const originalFieldResolve = field.resolve; + + field.resolve = (source, args, context, info) => { + // This is a bit of a hack, but since `ResponsePath` is a linked list, + // a new object gets created every time a path segment is added. + // So we can use that to share our `whenObjectResolved` promise across + // all field resolvers for the same object. + const parentPath = info.path.prev as ResponsePath & { + __fields?: Record>; + __whenObjectResolved?: Promise; + }; + + const willResolveField = + context?.[symbolExecutionDispatcherWillResolveField] as + | GraphQLRequestExecutionListener['willResolveField'] + | undefined; + + const userFieldResolver = + context?.[symbolUserFieldResolver] as + | GraphQLFieldResolver + | undefined; + + // The technique for implementing a "did resolve field" is accomplished by + // returning a function from the `willResolveField` handler. While there + // may be several callbacks, depending on the number of plugins which have + // implemented a `willResolveField` hook, this hook will call them all + // as dictated by the dispatcher. We will call this when object + // resolution is complete. + const didResolveField = + typeof willResolveField === 'function' && + willResolveField({ source, args, context, info }); + + const resolveObject: GraphQLObjectResolver< + any, + any + > = (info.parentType as any).resolveObject; + + let whenObjectResolved: Promise | undefined; + + if (parentPath && resolveObject) { + if (!parentPath.__fields) { + parentPath.__fields = {}; + } + + parentPath.__fields[info.fieldName] = info.fieldNodes; + + whenObjectResolved = parentPath.__whenObjectResolved; + if (!whenObjectResolved) { + // Use `Promise.resolve().then()` to delay executing + // `resolveObject()` so we can collect all the fields first. + whenObjectResolved = Promise.resolve().then(() => { + return resolveObject(source, parentPath.__fields!, context, info); + }); + parentPath.__whenObjectResolved = whenObjectResolved; + } + } + + const fieldResolver = + originalFieldResolve || userFieldResolver || defaultFieldResolver; + + try { + let result: any; + if (whenObjectResolved) { + result = whenObjectResolved.then((resolvedObject: any) => { + return fieldResolver(resolvedObject, args, context, info); + }); + } else { + result = fieldResolver(source, args, context, info); + } + + // Call the stack's handlers either immediately (if result is not a + // Promise) or once the Promise is done. Then return that same + // maybe-Promise value. + if (typeof didResolveField === "function") { + whenResultIsFinished(result, didResolveField); + } + return result; + } catch (error) { + // Normally it's a bad sign to see an error both handled and + // re-thrown. But it is useful to allow extensions to track errors while + // still handling them in the normal GraphQL way. + if (typeof didResolveField === "function") { + didResolveField(error); + } + throw error; + } + };; +} + +function isPromise(x: any): boolean { + return x && typeof x.then === 'function'; +} + +// Given result (which may be a Promise or an array some of whose elements are +// promises) Promises, set up 'callback' to be invoked when result is fully +// resolved. +export function whenResultIsFinished( + result: any, + callback: (err: Error | null, result?: any) => void, +) { + if (isPromise(result)) { + result.then((r: any) => callback(null, r), (err: Error) => callback(err)); + } else if (Array.isArray(result)) { + if (result.some(isPromise)) { + Promise.all(result).then( + (r: any) => callback(null, r), + (err: Error) => callback(err), + ); + } else { + callback(null, result); + } + } else { + callback(null, result); + } +} + +function forEachField(schema: GraphQLSchema, fn: FieldIteratorFn): void { + const typeMap = schema.getTypeMap(); + Object.entries(typeMap).forEach(([typeName, type]) => { + + if ( + !getNamedType(type).name.startsWith('__') && + type instanceof GraphQLObjectType + ) { + const fields = type.getFields(); + Object.entries(fields).forEach(([fieldName, field]) => { + fn(field, typeName, fieldName); + }); + } + }); +} + +type FieldIteratorFn = ( + fieldDef: GraphQLField, + typeName: string, + fieldName: string, +) => void; diff --git a/packages/apollo-server-integration-testsuite/src/index.ts b/packages/apollo-server-integration-testsuite/src/index.ts index a5dd026108f..e39cb84d036 100644 --- a/packages/apollo-server-integration-testsuite/src/index.ts +++ b/packages/apollo-server-integration-testsuite/src/index.ts @@ -18,7 +18,12 @@ import { import request from 'supertest'; -import { GraphQLOptions, Config } from 'apollo-server-core'; +import { + GraphQLOptions, + Config, + PersistedQueryOptions, + KeyValueCache, +} from 'apollo-server-core'; import gql from 'graphql-tag'; import { ValueOrPromise } from 'apollo-server-types'; import { GraphQLRequestListener } from "apollo-server-plugin-base"; @@ -1221,12 +1226,7 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }, }; - let didEncounterErrors: jest.Mock< - ReturnType, - Parameters - >; - - function createMockCache() { + function createMockCache(): KeyValueCache { const map = new Map(); return { set: jest.fn(async (key, val) => { @@ -1237,37 +1237,48 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }; } - beforeEach(async () => { - didEncounterErrors = jest.fn(); - const cache = createMockCache(); - app = await createApp({ + let didEncounterErrors: jest.Mock< + ReturnType, + Parameters + >; + + let didResolveSource: jest.Mock< + ReturnType, + Parameters + >; + + function createApqApp(apqOptions: PersistedQueryOptions = {}) { + return createApp({ graphqlOptions: { schema, plugins: [ { requestDidStart() { - return { didEncounterErrors }; + return { + didResolveSource, + didEncounterErrors, + }; } } ], persistedQueries: { cache, + ...apqOptions, }, }, }); + } + + let cache: KeyValueCache; + + beforeEach(async () => { + cache = createMockCache(); + didResolveSource = jest.fn(); + didEncounterErrors = jest.fn(); }); it('when ttlSeconds is set, passes ttl to the apq cache set call', async () => { - const cache = createMockCache(); - app = await createApp({ - graphqlOptions: { - schema, - persistedQueries: { - cache: cache, - ttl: 900, - }, - }, - }); + app = await createApqApp({ ttl: 900 }); await request(app) .post('/graphql') @@ -1278,24 +1289,18 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { expect(cache.set).toHaveBeenCalledWith( expect.stringMatching(/^apq:/), - '{testString}', + query, expect.objectContaining({ ttl: 900, }), ); + expect(didResolveSource.mock.calls[0][0]) + .toHaveProperty('source', query); }); it('when ttlSeconds is unset, ttl is not passed to apq cache', async () => { - const cache = createMockCache(); - app = await createApp({ - graphqlOptions: { - schema, - persistedQueries: { - cache: cache, - }, - }, - }); + app = await createApqApp(); await request(app) .post('/graphql') @@ -1311,10 +1316,14 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { ttl: 900, }), ); + expect(didResolveSource.mock.calls[0][0]) + .toHaveProperty('source', query); } ); it('errors when version is not specified', async () => { + app = await createApqApp(); + const result = await request(app) .get('/graphql') .query({ @@ -1346,6 +1355,8 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }); it('errors when version is unsupported', async () => { + app = await createApqApp(); + const result = await request(app) .get('/graphql') .query({ @@ -1378,6 +1389,8 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }); it('errors when hash is mismatched', async () => { + app = await createApqApp(); + const result = await request(app) .get('/graphql') .query({ @@ -1407,9 +1420,13 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { })]), }), ); + + expect(didResolveSource).not.toHaveBeenCalled(); }); it('returns PersistedQueryNotFound on the first try', async () => { + app = await createApqApp(); + const result = await request(app) .post('/graphql') .send({ @@ -1430,8 +1447,12 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { ]), }), ); + + expect(didResolveSource).not.toHaveBeenCalled(); }); it('returns result on the second try', async () => { + app = await createApqApp(); + await request(app) .post('/graphql') .send({ @@ -1448,6 +1469,8 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }), ); + expect(didResolveSource).not.toHaveBeenCalled(); + const result = await request(app) .post('/graphql') .send({ @@ -1460,11 +1483,16 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { // asserted above. expect(didEncounterErrors).toHaveBeenCalledTimes(1); + expect(didResolveSource.mock.calls[0][0]) + .toHaveProperty('source', query); + expect(result.body.data).toEqual({ testString: 'it works' }); expect(result.body.errors).toBeUndefined(); }); it('returns with batched persisted queries', async () => { + app = await createApqApp(); + const errors = await request(app) .post('/graphql') .send([ @@ -1510,11 +1538,16 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }); it('returns result on the persisted query', async () => { + app = await createApqApp(); + await request(app) .post('/graphql') .send({ extensions, }); + + expect(didResolveSource).not.toHaveBeenCalled(); + await request(app) .post('/graphql') .send({ @@ -1527,11 +1560,16 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { extensions, }); + expect(didResolveSource.mock.calls[0][0]) + .toHaveProperty('source', query); + expect(result.body.data).toEqual({ testString: 'it works' }); expect(result.body.errors).toBeUndefined(); }); it('returns error when hash does not match', async () => { + app = await createApqApp(); + const response = await request(app) .post('/graphql') .send({ @@ -1546,9 +1584,12 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }); expect(response.status).toEqual(400); expect(response.error.text).toMatch(/does not match query/); + expect(didResolveSource).not.toHaveBeenCalled(); }); it('returns correct result using get request', async () => { + app = await createApqApp(); + await request(app) .post('/graphql') .send({ @@ -1561,6 +1602,9 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { extensions: JSON.stringify(extensions), }); expect(result.body.data).toEqual({ testString: 'it works' }); + expect(didResolveSource.mock.calls[0][0]) + .toHaveProperty('source', query); + }); }); }); diff --git a/packages/apollo-server-plugin-base/src/index.ts b/packages/apollo-server-plugin-base/src/index.ts index 6d89ea815cd..a77d6556c16 100644 --- a/packages/apollo-server-plugin-base/src/index.ts +++ b/packages/apollo-server-plugin-base/src/index.ts @@ -1,10 +1,14 @@ import { + AnyFunctionMap, + BaseContext, GraphQLServiceContext, GraphQLRequestContext, GraphQLRequest, GraphQLResponse, ValueOrPromise, WithRequired, + GraphQLFieldResolverParams, + GraphQLRequestContextDidResolveSource, GraphQLRequestContextParsingDidStart, GraphQLRequestContextValidationDidStart, GraphQLRequestContextDidResolveOperation, @@ -23,12 +27,15 @@ import { // In the future, `apollo-server-types` and `apollo-server-plugin-base` will // probably roll into the same "types" package, but that is not today! export { + BaseContext, GraphQLServiceContext, GraphQLRequestContext, GraphQLRequest, GraphQLResponse, ValueOrPromise, WithRequired, + GraphQLFieldResolverParams, + GraphQLRequestContextDidResolveSource, GraphQLRequestContextParsingDidStart, GraphQLRequestContextValidationDidStart, GraphQLRequestContextDidResolveOperation, @@ -38,27 +45,42 @@ export { GraphQLRequestContextWillSendResponse, }; -export interface ApolloServerPlugin = Record> { +// Typings Note! (Fix in AS3?) +// +// There are a number of types in this module which are specifying `void` as +// their return type, despite the fact that we _are_ observing the value. +// It's possible those should instead be `undefined`. For more details, see +// the issue that was logged as a result of this discovery during (unrelated) PR +// review: https://github.com/apollographql/apollo-server/issues/4103 + +export interface ApolloServerPlugin< + TContext extends BaseContext = BaseContext +> { serverWillStart?(service: GraphQLServiceContext): ValueOrPromise; requestDidStart?( requestContext: GraphQLRequestContext, ): GraphQLRequestListener | void; } -export type GraphQLRequestListenerParsingDidEnd = - ((err?: Error) => void) | void; +export type GraphQLRequestListenerParsingDidEnd = (err?: Error) => void; export type GraphQLRequestListenerValidationDidEnd = - ((err?: ReadonlyArray) => void) | void; -export type GraphQLRequestListenerExecutionDidEnd = - ((err?: Error) => void) | void; + ((err?: ReadonlyArray) => void); +export type GraphQLRequestListenerExecutionDidEnd = ((err?: Error) => void); +export type GraphQLRequestListenerDidResolveField = + ((error: Error | null, result?: any) => void); -export interface GraphQLRequestListener> { +export interface GraphQLRequestListener< + TContext extends BaseContext = BaseContext +> extends AnyFunctionMap { + didResolveSource?( + requestContext: GraphQLRequestContextDidResolveSource, + ): ValueOrPromise; parsingDidStart?( requestContext: GraphQLRequestContextParsingDidStart, - ): GraphQLRequestListenerParsingDidEnd; + ): GraphQLRequestListenerParsingDidEnd | void; validationDidStart?( requestContext: GraphQLRequestContextValidationDidStart, - ): GraphQLRequestListenerValidationDidEnd; + ): GraphQLRequestListenerValidationDidEnd | void; didResolveOperation?( requestContext: GraphQLRequestContextDidResolveOperation, ): ValueOrPromise; @@ -75,8 +97,20 @@ export interface GraphQLRequestListener> { ): ValueOrPromise; executionDidStart?( requestContext: GraphQLRequestContextExecutionDidStart, - ): GraphQLRequestListenerExecutionDidEnd; + ): + | GraphQLRequestExecutionListener + | GraphQLRequestListenerExecutionDidEnd + | void; willSendResponse?( requestContext: GraphQLRequestContextWillSendResponse, ): ValueOrPromise; } + +export interface GraphQLRequestExecutionListener< + TContext extends BaseContext = BaseContext +> extends AnyFunctionMap { + executionDidEnd?: GraphQLRequestListenerExecutionDidEnd; + willResolveField?( + fieldResolverParams: GraphQLFieldResolverParams + ): GraphQLRequestListenerDidResolveField | void; +} diff --git a/packages/apollo-server-types/src/index.ts b/packages/apollo-server-types/src/index.ts index 40d2639e4ff..9dc59c5ffac 100644 --- a/packages/apollo-server-types/src/index.ts +++ b/packages/apollo-server-types/src/index.ts @@ -7,21 +7,54 @@ import { OperationDefinitionNode, DocumentNode, GraphQLError, + GraphQLResolveInfo, } from 'graphql'; // This seems like it could live in this package too. import { KeyValueCache } from 'apollo-server-caching'; import { Trace } from 'apollo-engine-reporting-protobuf'; +export type BaseContext = Record; + export type ValueOrPromise = T | Promise; export type WithRequired = T & Required>; +/** + * It is not recommended to use this `AnyFunction` type further. + * + * This is a legacy type which aims to do what its name suggests (be the type + * for _any_ function) but it should be replaced with something from the + * TypeScript standard lib. It doesn't truly cover "any" function right now, + * and in particular doesn't consider `this`. For now, it has been brought + * here from the Apollo Server `Dispatcher`, where it was first utilized. + */ +export type AnyFunction = (...args: any[]) => any; + +/** + * A map of `AnyFunction`s which are the interface for our plugin API's + * request listeners. (e.g. `GraphQLRequestListener`s). + */ +export type AnyFunctionMap = { [key: string]: AnyFunction | undefined }; + type Mutable = { -readonly [P in keyof T]: T[P] }; + // By default, TypeScript uses structural typing (as opposed to nominal typing) + // Put another way, if it looks like the type and walks like that type, then + // TypeScript lets it be a type. + // + // That's often okay, but it leaves a lot to be desired since a `string` of one + // type can just be passed in as `string` for that type and TypeScript won't + // complain. Flow offers opaque types which solve this, but TypeScript doesn't + // offer this (yet?). This Faux-paque type can be used to gain nominal-esque + // typing, which is incredibly beneficial during re-factors! + type Fauxpaque = K & { __fauxpaque: T }; + + export type SchemaHash = Fauxpaque; + export interface GraphQLServiceContext { logger: Logger; schema: GraphQLSchema; - schemaHash: string; + schemaHash: SchemaHash; engine: { serviceID?: string; apiKeyHash?: string; @@ -115,12 +148,35 @@ export type Logger = { error(message?: any): void; } -export type GraphQLRequestContextParsingDidStart = +/** + * This is an object form of the parameters received by typical + * `graphql-js` resolvers. The function type is `GraphQLFieldResolver` + * and normally uses positional parameters. In order to facilitate better + * ergonomics in the Apollo Server plugin API, these have been converted to + * named properties on the object using their names from the upstream + * `GraphQLFieldResolver` type signature. Ergonomic wins, in this case, + * include not needing to have three unused variables in scope just because + * there was a need to access the `info` property in a wrapped plugin. + */ +export type GraphQLFieldResolverParams< + TSource, + TContext, + TArgs = { [argName: string]: any } +> = { + source: TSource; + args: TArgs; + context: TContext; + info: GraphQLResolveInfo; +}; + +export type GraphQLRequestContextDidResolveSource = WithRequired, | 'metrics' | 'source' | 'queryHash' >; +export type GraphQLRequestContextParsingDidStart = + GraphQLRequestContextDidResolveSource; export type GraphQLRequestContextValidationDidStart = GraphQLRequestContextParsingDidStart & WithRequired,