Skip to content

Commit

Permalink
Add generics for data and variables to executeOperation (#6960)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
glasser committed Sep 27, 2022
1 parent a782c79 commit d3ea2d4
Show file tree
Hide file tree
Showing 5 changed files with 114 additions and 24 deletions.
5 changes: 5 additions & 0 deletions .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.
2 changes: 1 addition & 1 deletion docs/source/api/apollo-server.mdx
Expand Up @@ -631,7 +631,7 @@ Below are the available fields for the first argument of `executeOperation`:

<td>

**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.

</td>
</tr>
Expand Down
56 changes: 41 additions & 15 deletions packages/server/src/ApolloServer.ts
Expand Up @@ -11,6 +11,7 @@ import {
GraphQLSchema,
ParseOptions,
print,
TypedQueryDocumentNode,
ValidationContext,
ValidationRule,
} from 'graphql';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1080,26 +1084,44 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
*
* 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<string, unknown>,
TVariables extends VariableValues = VariableValues,
>(
this: ApolloServer<BaseContext>,
request: Omit<GraphQLRequest, 'query'> & {
query?: string | DocumentNode;
request: Omit<GraphQLRequest<TVariables>, 'query'> & {
query?: string | DocumentNode | TypedQueryDocumentNode<TData, TVariables>;
},
): Promise<GraphQLResponse>;
public async executeOperation(
request: Omit<GraphQLRequest, 'query'> & {
query?: string | DocumentNode;
): Promise<GraphQLResponse<TData>>;
public async executeOperation<
TData = Record<string, unknown>,
TVariables extends VariableValues = VariableValues,
>(
request: Omit<GraphQLRequest<TVariables>, 'query'> & {
query?: string | DocumentNode | TypedQueryDocumentNode<TData, TVariables>;
},
options?: ExecuteOperationOptions<TContext>,
): Promise<GraphQLResponse>;

async executeOperation(
request: Omit<GraphQLRequest, 'query'> & {
query?: string | DocumentNode;
): Promise<GraphQLResponse<TData>>;

async executeOperation<
TData = Record<string, unknown>,
TVariables extends VariableValues = VariableValues,
>(
request: Omit<GraphQLRequest<TVariables>, '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<TData, TVariables>;
},
options: ExecuteOperationOptions<TContext> = {},
): Promise<GraphQLResponse> {
): Promise<GraphQLResponse<TData>> {
// 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()`.)
Expand All @@ -1121,7 +1143,7 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
: request.query,
};

return await internalExecuteOperation(
const response: GraphQLResponse = await internalExecuteOperation(
{
server: this,
graphQLRequest,
Expand All @@ -1130,6 +1152,10 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
},
options,
);

// It's your job to set an appropriate TData (perhaps using codegen); we
// don't validate it.
return response as GraphQLResponse<TData>;
}
}

Expand Down
56 changes: 55 additions & 1 deletion 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';
Expand Down Expand Up @@ -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.
}
});
19 changes: 12 additions & 7 deletions packages/server/src/externalTypes/graphql.ts
Expand Up @@ -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<string, any>;
http?: HTTPGraphQLRequest;
}
Expand All @@ -32,23 +34,26 @@ 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<TData = Record<string, unknown>> =
| {
kind: 'single';
singleResult: FormattedExecutionResult;
singleResult: FormattedExecutionResult<TData>;
}
| {
kind: 'incremental';
initialResult: GraphQLExperimentalFormattedInitialIncrementalExecutionResult;
subsequentResults: AsyncIterable<GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult>;
};

export type GraphQLInProgressResponse = {
export type GraphQLInProgressResponse<TData = Record<string, unknown>> = {
http: HTTPGraphQLHead;
body?: GraphQLResponseBody;
body?: GraphQLResponseBody<TData>;
};

export type GraphQLResponse = WithRequired<GraphQLInProgressResponse, 'body'>;
export type GraphQLResponse<TData = Record<string, unknown>> = WithRequired<
GraphQLInProgressResponse<TData>,
'body'
>;

export interface GraphQLRequestMetrics {
// It would be more accurate to call this fieldLevelInstrumentation (it is
Expand Down

0 comments on commit d3ea2d4

Please sign in to comment.