Skip to content

Commit d926d63

Browse files
yaacovCRn1ru4l
andcommitted
add graphql "harness" abstraction along with async parse/validate support (#4562)
This PR extends the functionality of the graphql function by allowing users to pass in a custom harness with user-supplied parse/validate/execute/subscribe functions. This allows users to pass custom versions of those functions, enabling a simple API for adding pre/post hooks, a very simplified version of the pattern introduced by [Envelop](https://the-guild.dev/graphql/envelop). Although this extends what is possible with the graphql function, which is neat, the underlying purpose is not to compete with Envelop and other frameworks, but rather to facilitate them, background at #3421. The introduction of the `GraphQLParseFn`, `GraphQLValidateFn`, `GraphQLExecuteFn` and `GraphQLSubscribeFn` types for the functions which make up the harness include purposeful maybe-async return types, even though our internal `parse` and `validate` functions are always sync, to encourage servers and other tooling to expect that user-supplied versions of these functions may have async pre/post hooks. This is a softened response to the request by Envelop maintainers in #3421 to wrap the `parse` result in a promise. This PR nudges servers and other tooling in that direction by exposing types that return maybe-async results, but is a non-breaking change. Co-authored-by: Laurin Quast <laurinquast@googlemail.com>
1 parent 2e2a6d7 commit d926d63

File tree

4 files changed

+205
-8
lines changed

4 files changed

+205
-8
lines changed

src/__tests__/graphql-test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import { GraphQLSchema } from '../type/schema.js';
1111

1212
import type { ValidationRule } from '../validation/ValidationContext.js';
1313

14+
import { execute } from '../execution/execute.js';
15+
1416
import { graphql, graphqlSync } from '../graphql.js';
17+
import { defaultHarness } from '../harness.js';
1518

1619
const schema = new GraphQLSchema({
1720
query: new GraphQLObjectType({
@@ -139,6 +142,109 @@ describe('graphql', () => {
139142
'Query root type must be provided.',
140143
);
141144
});
145+
146+
it('works when a custom harness is provided', async () => {
147+
const result = await graphql({
148+
schema,
149+
source: '{ syncField }',
150+
rootValue: 'rootValue',
151+
harness: {
152+
...defaultHarness,
153+
execute: (args) =>
154+
execute({ ...args, rootValue: `**${args.rootValue}**` }),
155+
},
156+
});
157+
158+
expect(result).to.deep.equal({ data: { syncField: '**rootValue**' } });
159+
});
160+
161+
it('returns parse errors thrown synchronously by a custom harness', async () => {
162+
const parseError = new GraphQLError('sync parse error');
163+
const result = await graphql({
164+
schema,
165+
source: '{ syncField }',
166+
harness: {
167+
...defaultHarness,
168+
parse: () => {
169+
throw parseError;
170+
},
171+
},
172+
});
173+
174+
expect(result).to.deep.equal({ errors: [parseError] });
175+
});
176+
177+
it('works with asynchronous parse from a custom harness', async () => {
178+
const result = await graphql({
179+
schema,
180+
source: '{ syncField }',
181+
rootValue: 'rootValue',
182+
harness: {
183+
...defaultHarness,
184+
parse: (source, options) =>
185+
Promise.resolve(defaultHarness.parse(source, options)),
186+
},
187+
});
188+
189+
expect(result).to.deep.equal({ data: { syncField: 'rootValue' } });
190+
});
191+
192+
it('handles errors from an asynchronous parse from a custom harness', async () => {
193+
const parseError = new GraphQLError('async parse error');
194+
const result = await graphql({
195+
schema,
196+
source: '{ syncField }',
197+
harness: {
198+
...defaultHarness,
199+
parse: () => Promise.reject(parseError),
200+
},
201+
});
202+
203+
expect(result).to.deep.equal({ errors: [parseError] });
204+
});
205+
206+
it('works with asynchronous validation from a custom harness', async () => {
207+
const result = await graphql({
208+
schema,
209+
source: '{ syncField }',
210+
rootValue: 'rootValue',
211+
harness: {
212+
...defaultHarness,
213+
validate: (s, document) =>
214+
Promise.resolve(defaultHarness.validate(s, document)),
215+
},
216+
});
217+
218+
expect(result).to.deep.equal({ data: { syncField: 'rootValue' } });
219+
});
220+
221+
it('returns validation errors from synchronous validation from a custom harness', async () => {
222+
const validationError = new GraphQLError('async validation error');
223+
const result = await graphql({
224+
schema,
225+
source: '{ syncField }',
226+
harness: {
227+
...defaultHarness,
228+
validate: () => [validationError],
229+
},
230+
});
231+
232+
expect(result).to.deep.equal({ errors: [validationError] });
233+
});
234+
235+
it('returns validation errors from asynchronous validation from a custom harness', async () => {
236+
const validationError = new GraphQLError('async validation error');
237+
const result = await graphql({
238+
schema,
239+
source: '{ syncField }',
240+
harness: {
241+
...defaultHarness,
242+
validate: () => Promise.resolve([validationError]),
243+
},
244+
});
245+
246+
expect(result).to.deep.equal({ errors: [validationError] });
247+
});
142248
});
143249

144250
describe('graphqlSync', () => {

src/graphql.ts

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
import { isPromise } from './jsutils/isPromise.js';
22
import type { PromiseOrValue } from './jsutils/PromiseOrValue.js';
33

4+
import type { GraphQLError } from './error/GraphQLError.js';
5+
6+
import type { DocumentNode } from './language/ast.js';
47
import type { ParseOptions } from './language/parser.js';
5-
import { parse } from './language/parser.js';
68
import type { Source } from './language/source.js';
79

10+
import type { GraphQLSchema } from './type/schema.js';
811
import { validateSchema } from './type/validate.js';
912

1013
import type { ValidationOptions } from './validation/validate.js';
11-
import { validate } from './validation/validate.js';
1214
import type { ValidationRule } from './validation/ValidationContext.js';
1315

1416
import type { ExecutionArgs } from './execution/execute.js';
15-
import { execute } from './execution/execute.js';
1617
import type { ExecutionResult } from './execution/Executor.js';
1718

19+
import type { GraphQLHarness } from './harness.js';
20+
import { defaultHarness } from './harness.js';
21+
1822
/**
1923
* This is the primary entry point function for fulfilling GraphQL operations
2024
* by parsing, validating, and executing a GraphQL document along side a
@@ -58,6 +62,7 @@ export interface GraphQLArgs
5862
extends ParseOptions,
5963
ValidationOptions,
6064
Omit<ExecutionArgs, 'document'> {
65+
harness?: GraphQLHarness | undefined;
6166
source: string | Source;
6267
rules?: ReadonlyArray<ValidationRule> | undefined;
6368
}
@@ -85,6 +90,7 @@ export function graphqlSync(args: GraphQLArgs): ExecutionResult {
8590
}
8691

8792
function graphqlImpl(args: GraphQLArgs): PromiseOrValue<ExecutionResult> {
93+
const harness = args.harness ?? defaultHarness;
8894
const { schema, source } = args;
8995

9096
// Validate Schema
@@ -96,17 +102,55 @@ function graphqlImpl(args: GraphQLArgs): PromiseOrValue<ExecutionResult> {
96102
// Parse
97103
let document;
98104
try {
99-
document = parse(source, args);
105+
document = harness.parse(source, args);
100106
} catch (syntaxError) {
101107
return { errors: [syntaxError] };
102108
}
103109

110+
if (isPromise(document)) {
111+
return document.then(
112+
(resolvedDocument) =>
113+
validateAndExecute(harness, args, schema, resolvedDocument),
114+
(syntaxError: unknown) => ({ errors: [syntaxError as GraphQLError] }),
115+
);
116+
}
117+
118+
return validateAndExecute(harness, args, schema, document);
119+
}
120+
121+
function validateAndExecute(
122+
harness: GraphQLHarness,
123+
args: GraphQLArgs,
124+
schema: GraphQLSchema,
125+
document: DocumentNode,
126+
): PromiseOrValue<ExecutionResult> {
104127
// Validate
105-
const validationErrors = validate(schema, document, args.rules, args);
106-
if (validationErrors.length > 0) {
107-
return { errors: validationErrors };
128+
const validationResult = harness.validate(schema, document, args.rules, args);
129+
130+
if (isPromise(validationResult)) {
131+
return validationResult.then((resolvedValidationResult) =>
132+
checkValidationAndExecute(
133+
harness,
134+
args,
135+
resolvedValidationResult,
136+
document,
137+
),
138+
);
139+
}
140+
141+
return checkValidationAndExecute(harness, args, validationResult, document);
142+
}
143+
144+
function checkValidationAndExecute(
145+
harness: GraphQLHarness,
146+
args: GraphQLArgs,
147+
validationResult: ReadonlyArray<GraphQLError>,
148+
document: DocumentNode,
149+
): PromiseOrValue<ExecutionResult> {
150+
if (validationResult.length > 0) {
151+
return { errors: validationResult };
108152
}
109153

110154
// Execute
111-
return execute({ ...args, document });
155+
return harness.execute({ ...args, document });
112156
}

src/harness.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { PromiseOrValue } from './jsutils/PromiseOrValue.js';
2+
3+
import { parse } from './language/parser.js';
4+
5+
import { validate } from './validation/validate.js';
6+
7+
import { execute, subscribe } from './execution/execute.js';
8+
9+
export type GraphQLParseFn = (
10+
...args: Parameters<typeof parse>
11+
) => PromiseOrValue<ReturnType<typeof parse>>;
12+
13+
export type GraphQLValidateFn = (
14+
...args: Parameters<typeof validate>
15+
) => PromiseOrValue<ReturnType<typeof validate>>;
16+
17+
export type GraphQLExecuteFn = (
18+
...args: Parameters<typeof execute>
19+
) => ReturnType<typeof execute>;
20+
21+
export type GraphQLSubscribeFn = (
22+
...args: Parameters<typeof subscribe>
23+
) => ReturnType<typeof subscribe>;
24+
25+
export interface GraphQLHarness {
26+
parse: GraphQLParseFn;
27+
validate: GraphQLValidateFn;
28+
execute: GraphQLExecuteFn;
29+
subscribe: GraphQLSubscribeFn;
30+
}
31+
32+
export const defaultHarness: GraphQLHarness = {
33+
parse,
34+
validate,
35+
execute,
36+
subscribe,
37+
};

src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ export { enableDevMode, isDevModeEnabled } from './devMode.js';
3636
export type { GraphQLArgs } from './graphql.js';
3737
export { graphql, graphqlSync } from './graphql.js';
3838

39+
// The default versions of the parse/validate/execute/subscribe harness used by `graphql` and `graphqlSync`.
40+
export { defaultHarness } from './harness.js';
41+
export type {
42+
GraphQLHarness,
43+
GraphQLParseFn,
44+
GraphQLValidateFn,
45+
GraphQLExecuteFn,
46+
GraphQLSubscribeFn,
47+
} from './harness.js';
48+
3949
// Create and operate on GraphQL type definitions and schema.
4050
export type {
4151
GraphQLField,

0 commit comments

Comments
 (0)