From d3ea2d4ef137519d073185dea778e39e89a301c2 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Tue, 27 Sep 2022 13:38:17 -0700 Subject: [PATCH] Add generics for data and variables to executeOperation (#6960) This lets you manually specify the these types when calling the method; typically you generate these types with a codegen tool like graphql-code-generator. This matches a common pattern used in client development. This doesn't let you specify the types for incremental delivery responses; we could potentially add that later. This also lets you use TypedQueryDocumentNode (from graphql-js) as the `query`; if you do so, we can infer the two generic types automatically. (This type is less popular than the earlier TypedDocumentNode from `@graphql-typed-document-node/core` and we should probably support the latter as well; in the interest of reducing dependencies we don't yet, but we can extend to support that later.) Adapted from an original PR #6384 by @jacksenior. --- .changeset/witty-toes-grin.md | 5 ++ docs/source/api/apollo-server.mdx | 2 +- packages/server/src/ApolloServer.ts | 56 ++++++++++++++----- .../server/src/__tests__/ApolloServer.test.ts | 56 ++++++++++++++++++- packages/server/src/externalTypes/graphql.ts | 19 ++++--- 5 files changed, 114 insertions(+), 24 deletions(-) create mode 100644 .changeset/witty-toes-grin.md diff --git a/.changeset/witty-toes-grin.md b/.changeset/witty-toes-grin.md new file mode 100644 index 00000000000..b2361fdc400 --- /dev/null +++ b/.changeset/witty-toes-grin.md @@ -0,0 +1,5 @@ +--- +'@apollo/server': patch +--- + +Add generics for response data and variables to server.executeOperation; allow inference from TypedQueryDocumentNode. diff --git a/docs/source/api/apollo-server.mdx b/docs/source/api/apollo-server.mdx index 183c95676a5..5e6d606c06e 100644 --- a/docs/source/api/apollo-server.mdx +++ b/docs/source/api/apollo-server.mdx @@ -631,7 +631,7 @@ Below are the available fields for the first argument of `executeOperation`: -**Required**. The GraphQL operation to run. Note that you must use the `query` field even if the operation is a mutation. +**Required**. The GraphQL operation to run. Note that you must use the `query` field even if the operation is a mutation. This may also be a [`TypedQueryDocumentNode`](https://github.com/graphql/graphql-js/blob/main/src/utilities/typedQueryDocumentNode.ts), in which case TypeScript types for the `variables` option and `response.body.singleResult.data` will be automatically inferred. diff --git a/packages/server/src/ApolloServer.ts b/packages/server/src/ApolloServer.ts index 9ba05ff80eb..6fc4330f0b2 100644 --- a/packages/server/src/ApolloServer.ts +++ b/packages/server/src/ApolloServer.ts @@ -11,6 +11,7 @@ import { GraphQLSchema, ParseOptions, print, + TypedQueryDocumentNode, ValidationContext, ValidationRule, } from 'graphql'; @@ -64,7 +65,10 @@ import type { ApolloServerOptionsWithStaticSchema } from './externalTypes/constr import type { GatewayExecutor } from '@apollo/server-gateway-interface'; import type { GraphQLExperimentalIncrementalExecutionResults } from './incrementalDeliveryPolyfill.js'; import { HeaderMap } from './utils/HeaderMap.js'; -import type { ExecuteOperationOptions } from './externalTypes/graphql.js'; +import type { + ExecuteOperationOptions, + VariableValues, +} from './externalTypes/graphql.js'; const NoIntrospection: ValidationRule = (context: ValidationContext) => ({ Field(node) { @@ -1080,26 +1084,44 @@ export class ApolloServer { * * The second object is an optional options object which includes the optional * `contextValue` object available in resolvers. + * + * You may specify the TData and TVariables generic types when calling this + * method; Apollo Server does not validate that the returned data actually + * matches the structure of TData. (Typically these types are created by a + * code generation tool.) Note that this does not enforce that `variables` is + * provided at all, just that it has the right type if provided. */ - public async executeOperation( + public async executeOperation< + TData = Record, + TVariables extends VariableValues = VariableValues, + >( this: ApolloServer, - request: Omit & { - query?: string | DocumentNode; + request: Omit, 'query'> & { + query?: string | DocumentNode | TypedQueryDocumentNode; }, - ): Promise; - public async executeOperation( - request: Omit & { - query?: string | DocumentNode; + ): Promise>; + public async executeOperation< + TData = Record, + TVariables extends VariableValues = VariableValues, + >( + request: Omit, 'query'> & { + query?: string | DocumentNode | TypedQueryDocumentNode; }, options?: ExecuteOperationOptions, - ): Promise; - - async executeOperation( - request: Omit & { - query?: string | DocumentNode; + ): Promise>; + + async executeOperation< + TData = Record, + TVariables extends VariableValues = VariableValues, + >( + request: Omit, 'query'> & { + // We should consider supporting TypedDocumentNode from + // `@graphql-typed-document-node/core` as well, as it is more popular than + // the newer built-in type. + query?: string | DocumentNode | TypedQueryDocumentNode; }, options: ExecuteOperationOptions = {}, - ): Promise { + ): Promise> { // Since this function is mostly for testing, you don't need to explicitly // start your server before calling it. (That also means you can use it with // `apollo-server` which doesn't support `start()`.) @@ -1121,7 +1143,7 @@ export class ApolloServer { : request.query, }; - return await internalExecuteOperation( + const response: GraphQLResponse = await internalExecuteOperation( { server: this, graphQLRequest, @@ -1130,6 +1152,10 @@ export class ApolloServer { }, options, ); + + // It's your job to set an appropriate TData (perhaps using codegen); we + // don't validate it. + return response as GraphQLResponse; } } diff --git a/packages/server/src/__tests__/ApolloServer.test.ts b/packages/server/src/__tests__/ApolloServer.test.ts index 4bdc1b30c58..dd113979e77 100644 --- a/packages/server/src/__tests__/ApolloServer.test.ts +++ b/packages/server/src/__tests__/ApolloServer.test.ts @@ -1,6 +1,12 @@ import { ApolloServer, HeaderMap } from '..'; import type { ApolloServerOptions } from '..'; -import { FormattedExecutionResult, GraphQLError, GraphQLSchema } from 'graphql'; +import { + FormattedExecutionResult, + GraphQLError, + GraphQLSchema, + parse, + TypedQueryDocumentNode, +} from 'graphql'; import type { ApolloServerPlugin, BaseContext } from '../externalTypes'; import { ApolloServerPluginCacheControlDisabled } from '../plugin/disabled/index.js'; import { ApolloServerPluginUsageReporting } from '../plugin/usageReporting/index.js'; @@ -567,3 +573,51 @@ describe('ApolloServer addPlugin', () => { await server.stop(); }); }); + +it('TypedQueryDocumentNode', async () => { + const server = new ApolloServer({ + typeDefs: `type Query { + foo(arg: Int!): String + }`, + resolvers: { + Query: { + foo(_, { arg }) { + return `yay ${arg}`; + }, + }, + }, + }); + await server.start(); + + const query = parse( + 'query Q($a: Int!) {foo(arg: $a)}', + ) as TypedQueryDocumentNode<{ foo: string | null }, { a: number }>; + + const response = await server.executeOperation({ + query, + variables: { a: 1 }, + }); + assert(response.body.kind === 'single'); + expect(response.body.singleResult.data?.foo).toBe('yay 1'); + // bar is not part of the data type. + // @ts-expect-error + expect(response.body.singleResult.data?.bar).toBe(undefined); + + if (1 + 1 === 3) { + // We just want to type-check this part, not run it. + await server.executeOperation({ + query, + // @ts-expect-error + variables: { a: 'asdf' }, + }); + await server.executeOperation({ + query, + // @ts-expect-error + variables: {}, + }); + + // Note that we don't detect providing extra variables or not providing + // variables at all when some are required, though it would be nice to fix + // that. + } +}); diff --git a/packages/server/src/externalTypes/graphql.ts b/packages/server/src/externalTypes/graphql.ts index d7408da3555..534aa470887 100644 --- a/packages/server/src/externalTypes/graphql.ts +++ b/packages/server/src/externalTypes/graphql.ts @@ -17,10 +17,12 @@ import type { GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult, } from './incrementalDeliveryPolyfill.js'; -export interface GraphQLRequest { +export interface GraphQLRequest< + TVariables extends VariableValues = VariableValues, +> { query?: string; operationName?: string; - variables?: VariableValues; + variables?: TVariables; extensions?: Record; http?: HTTPGraphQLRequest; } @@ -32,10 +34,10 @@ export type VariableValues = { [name: string]: any }; // GraphQL operation uses incremental delivery directives such as `@defer` or // `@stream`. Note that incremental delivery currently requires using a // pre-release of graphql-js v17. -export type GraphQLResponseBody = +export type GraphQLResponseBody> = | { kind: 'single'; - singleResult: FormattedExecutionResult; + singleResult: FormattedExecutionResult; } | { kind: 'incremental'; @@ -43,12 +45,15 @@ export type GraphQLResponseBody = subsequentResults: AsyncIterable; }; -export type GraphQLInProgressResponse = { +export type GraphQLInProgressResponse> = { http: HTTPGraphQLHead; - body?: GraphQLResponseBody; + body?: GraphQLResponseBody; }; -export type GraphQLResponse = WithRequired; +export type GraphQLResponse> = WithRequired< + GraphQLInProgressResponse, + 'body' +>; export interface GraphQLRequestMetrics { // It would be more accurate to call this fieldLevelInstrumentation (it is