diff --git a/package-lock.json b/package-lock.json index 83faedcadf..1a7bfea8b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "graphql", - "version": "16.5.0", + "version": "16.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "graphql", - "version": "16.5.0", + "version": "16.6.0", "license": "MIT", "devDependencies": { "@babel/core": "7.17.9", diff --git a/package.json b/package.json index 5457b6e2ec..8ac82541b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql", - "version": "16.5.0", + "version": "16.6.0", "description": "A Query Language and Runtime which can target any service.", "license": "MIT", "private": true, diff --git a/src/execution/__tests__/subscribe-test.ts b/src/execution/__tests__/subscribe-test.ts index 54c5019ab0..e9ea0d0ace 100644 --- a/src/execution/__tests__/subscribe-test.ts +++ b/src/execution/__tests__/subscribe-test.ts @@ -1,4 +1,4 @@ -import { expect } from 'chai'; +import { assert, expect } from 'chai'; import { describe, it } from 'mocha'; import { expectJSON } from '../../__testUtils__/expectJSON'; @@ -377,6 +377,65 @@ describe('Subscription Initialization Phase', () => { ); }); + it('Deprecated: allows positional arguments to createSourceEventStream', async () => { + async function* fooGenerator() { + /* c8 ignore next 2 */ + yield { foo: 'FooValue' }; + } + + const schema = new GraphQLSchema({ + query: DummyQueryType, + subscription: new GraphQLObjectType({ + name: 'Subscription', + fields: { + foo: { type: GraphQLString, subscribe: fooGenerator }, + }, + }), + }); + const document = parse('subscription { foo }'); + + const eventStream = await createSourceEventStream(schema, document); + assert(isAsyncIterable(eventStream)); + }); + + it('Deprecated: throws an error if document is missing when using positional arguments', async () => { + const document = parse('subscription { foo }'); + const schema = new GraphQLSchema({ + query: DummyQueryType, + subscription: new GraphQLObjectType({ + name: 'Subscription', + fields: { + foo: { type: GraphQLString }, + }, + }), + }); + + // @ts-expect-error (schema must not be null) + (await expectPromise(createSourceEventStream(null, document))).toRejectWith( + 'Expected null to be a GraphQL schema.', + ); + + ( + await expectPromise( + createSourceEventStream( + // @ts-expect-error + undefined, + document, + ), + ) + ).toRejectWith('Expected undefined to be a GraphQL schema.'); + + // @ts-expect-error (document must not be null) + (await expectPromise(createSourceEventStream(schema, null))).toRejectWith( + 'Must provide document.', + ); + + // @ts-expect-error + (await expectPromise(createSourceEventStream(schema))).toRejectWith( + 'Must provide document.', + ); + }); + it('resolves to an error if schema does not support subscriptions', async () => { const schema = new GraphQLSchema({ query: DummyQueryType }); const document = parse('subscription { unknownField }'); diff --git a/src/execution/subscribe.ts b/src/execution/subscribe.ts index 91a8231538..8b20ec3374 100644 --- a/src/execution/subscribe.ts +++ b/src/execution/subscribe.ts @@ -58,26 +58,7 @@ export async function subscribe( 'graphql@16 dropped long-deprecated support for positional arguments, please pass an object instead.', ); - const { - schema, - document, - rootValue, - contextValue, - variableValues, - operationName, - fieldResolver, - subscribeFieldResolver, - } = args; - - const resultOrStream = await createSourceEventStream( - schema, - document, - rootValue, - contextValue, - variableValues, - operationName, - subscribeFieldResolver, - ); + const resultOrStream = await createSourceEventStream(args); if (!isAsyncIterable(resultOrStream)) { return resultOrStream; @@ -91,19 +72,44 @@ export async function subscribe( // "ExecuteQuery" algorithm, for which `execute` is also used. const mapSourceToResponse = (payload: unknown) => execute({ - schema, - document, + ...args, rootValue: payload, - contextValue, - variableValues, - operationName, - fieldResolver, }); // Map every source value to a ExecutionResult value as described above. return mapAsyncIterator(resultOrStream, mapSourceToResponse); } +type BackwardsCompatibleArgs = + | [options: ExecutionArgs] + | [ + schema: ExecutionArgs['schema'], + document: ExecutionArgs['document'], + rootValue?: ExecutionArgs['rootValue'], + contextValue?: ExecutionArgs['contextValue'], + variableValues?: ExecutionArgs['variableValues'], + operationName?: ExecutionArgs['operationName'], + subscribeFieldResolver?: ExecutionArgs['subscribeFieldResolver'], + ]; + +function toNormalizedArgs(args: BackwardsCompatibleArgs): ExecutionArgs { + const firstArg = args[0]; + if (firstArg && 'document' in firstArg) { + return firstArg; + } + + return { + schema: firstArg, + // FIXME: when underlying TS bug fixed, see https://github.com/microsoft/TypeScript/issues/31613 + document: args[1] as DocumentNode, + rootValue: args[2], + contextValue: args[3], + variableValues: args[4], + operationName: args[5], + subscribeFieldResolver: args[6], + }; +} + /** * Implements the "CreateSourceEventStream" algorithm described in the * GraphQL specification, resolving the subscription source event stream. @@ -132,6 +138,10 @@ export async function subscribe( * or otherwise separating these two steps. For more on this, see the * "Supporting Subscriptions at Scale" information in the GraphQL specification. */ +export async function createSourceEventStream( + args: ExecutionArgs, +): Promise | ExecutionResult>; +/** @deprecated will be removed in next major version in favor of named arguments */ export async function createSourceEventStream( schema: GraphQLSchema, document: DocumentNode, @@ -140,22 +150,21 @@ export async function createSourceEventStream( variableValues?: Maybe<{ readonly [variable: string]: unknown }>, operationName?: Maybe, subscribeFieldResolver?: Maybe>, -): Promise | ExecutionResult> { +): Promise | ExecutionResult>; +export async function createSourceEventStream( + ...rawArgs: BackwardsCompatibleArgs +) { + const args = toNormalizedArgs(rawArgs); + + const { schema, document, variableValues } = args; + // If arguments are missing or incorrectly typed, this is an internal // developer mistake which should throw an early error. assertValidExecutionArguments(schema, document, variableValues); // If a valid execution context cannot be created due to incorrect arguments, // a "Response" with only errors is returned. - const exeContext = buildExecutionContext({ - schema, - document, - rootValue, - contextValue, - variableValues, - operationName, - subscribeFieldResolver, - }); + const exeContext = buildExecutionContext(args); // Return early errors if execution context failed. if (!('schema' in exeContext)) { diff --git a/src/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index 3571b75700..87e7b92c34 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -84,6 +84,19 @@ describe('Parser', () => { `); }); + it('limit maximum number of tokens', () => { + expect(() => parse('{ foo }', { maxTokens: 3 })).to.not.throw(); + expect(() => parse('{ foo }', { maxTokens: 2 })).to.throw( + 'Syntax Error: Document contains more that 2 tokens. Parsing aborted.', + ); + + expect(() => parse('{ foo(bar: "baz") }', { maxTokens: 8 })).to.not.throw(); + + expect(() => parse('{ foo(bar: "baz") }', { maxTokens: 7 })).to.throw( + 'Syntax Error: Document contains more that 7 tokens. Parsing aborted.', + ); + }); + it('parses variable inline values', () => { expect(() => parse('{ field(complex: { a: { b: [ $var ] } }) }'), diff --git a/src/language/ast.ts b/src/language/ast.ts index 0b30366df0..29029342a1 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -323,11 +323,12 @@ export interface OperationDefinitionNode { readonly selectionSet: SelectionSetNode; } -export enum OperationTypeNode { +enum OperationTypeNode { QUERY = 'query', MUTATION = 'mutation', SUBSCRIPTION = 'subscription', } +export { OperationTypeNode }; export interface VariableDefinitionNode { readonly kind: Kind.VARIABLE_DEFINITION; diff --git a/src/language/directiveLocation.ts b/src/language/directiveLocation.ts index e98ddf6d75..5c8aeb7240 100644 --- a/src/language/directiveLocation.ts +++ b/src/language/directiveLocation.ts @@ -1,7 +1,7 @@ /** * The set of allowed directive location values. */ -export enum DirectiveLocation { +enum DirectiveLocation { /** Request Definitions */ QUERY = 'QUERY', MUTATION = 'MUTATION', @@ -24,6 +24,7 @@ export enum DirectiveLocation { INPUT_OBJECT = 'INPUT_OBJECT', INPUT_FIELD_DEFINITION = 'INPUT_FIELD_DEFINITION', } +export { DirectiveLocation }; /** * The enum type representing the directive location values. diff --git a/src/language/kinds.ts b/src/language/kinds.ts index 39b2a8e675..cd05f66a3b 100644 --- a/src/language/kinds.ts +++ b/src/language/kinds.ts @@ -1,7 +1,7 @@ /** * The set of allowed kind values for AST nodes. */ -export enum Kind { +enum Kind { /** Name */ NAME = 'Name', @@ -67,6 +67,7 @@ export enum Kind { ENUM_TYPE_EXTENSION = 'EnumTypeExtension', INPUT_OBJECT_TYPE_EXTENSION = 'InputObjectTypeExtension', } +export { Kind }; /** * The enum type representing the possible kind values of AST nodes. diff --git a/src/language/parser.ts b/src/language/parser.ts index 282ee16859..eb54a0376b 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -78,6 +78,15 @@ export interface ParseOptions { */ noLocation?: boolean; + /** + * Parser CPU and memory usage is linear to the number of tokens in a document + * however in extreme cases it becomes quadratic due to memory exhaustion. + * Parsing happens before validation so even invalid queries can burn lots of + * CPU time and memory. + * To prevent this you can set a maximum number of tokens allowed within a document. + */ + maxTokens?: number | undefined; + /** * @deprecated will be removed in the v17.0.0 * @@ -102,7 +111,7 @@ export interface ParseOptions { */ export function parse( source: string | Source, - options?: ParseOptions, + options?: ParseOptions | undefined, ): DocumentNode { const parser = new Parser(source, options); return parser.parseDocument(); @@ -120,7 +129,7 @@ export function parse( */ export function parseValue( source: string | Source, - options?: ParseOptions, + options?: ParseOptions | undefined, ): ValueNode { const parser = new Parser(source, options); parser.expectToken(TokenKind.SOF); @@ -135,7 +144,7 @@ export function parseValue( */ export function parseConstValue( source: string | Source, - options?: ParseOptions, + options?: ParseOptions | undefined, ): ConstValueNode { const parser = new Parser(source, options); parser.expectToken(TokenKind.SOF); @@ -156,7 +165,7 @@ export function parseConstValue( */ export function parseType( source: string | Source, - options?: ParseOptions, + options?: ParseOptions | undefined, ): TypeNode { const parser = new Parser(source, options); parser.expectToken(TokenKind.SOF); @@ -177,14 +186,16 @@ export function parseType( * @internal */ export class Parser { - protected _options: Maybe; + protected _options: ParseOptions; protected _lexer: Lexer; + protected _tokenCounter: number; - constructor(source: string | Source, options?: ParseOptions) { + constructor(source: string | Source, options: ParseOptions = {}) { const sourceObj = isSource(source) ? source : new Source(source); this._lexer = new Lexer(sourceObj); this._options = options; + this._tokenCounter = 0; } /** @@ -510,7 +521,7 @@ export class Parser { // Legacy support for defining variables within fragments changes // the grammar of FragmentDefinition: // - fragment FragmentName VariableDefinitions? on TypeCondition Directives? SelectionSet - if (this._options?.allowLegacyFragmentVariables === true) { + if (this._options.allowLegacyFragmentVariables === true) { return this.node(start, { kind: Kind.FRAGMENT_DEFINITION, name: this.parseFragmentName(), @@ -569,13 +580,13 @@ export class Parser { case TokenKind.BRACE_L: return this.parseObject(isConst); case TokenKind.INT: - this._lexer.advance(); + this.advanceLexer(); return this.node(token, { kind: Kind.INT, value: token.value, }); case TokenKind.FLOAT: - this._lexer.advance(); + this.advanceLexer(); return this.node(token, { kind: Kind.FLOAT, value: token.value, @@ -584,7 +595,7 @@ export class Parser { case TokenKind.BLOCK_STRING: return this.parseStringLiteral(); case TokenKind.NAME: - this._lexer.advance(); + this.advanceLexer(); switch (token.value) { case 'true': return this.node(token, { @@ -630,7 +641,7 @@ export class Parser { parseStringLiteral(): StringValueNode { const token = this._lexer.token; - this._lexer.advance(); + this.advanceLexer(); return this.node(token, { kind: Kind.STRING, value: token.value, @@ -1387,7 +1398,7 @@ export class Parser { * given parsed object. */ node(startToken: Token, node: T): T { - if (this._options?.noLocation !== true) { + if (this._options.noLocation !== true) { node.loc = new Location( startToken, this._lexer.lastToken, @@ -1411,7 +1422,7 @@ export class Parser { expectToken(kind: TokenKind): Token { const token = this._lexer.token; if (token.kind === kind) { - this._lexer.advance(); + this.advanceLexer(); return token; } @@ -1429,7 +1440,7 @@ export class Parser { expectOptionalToken(kind: TokenKind): boolean { const token = this._lexer.token; if (token.kind === kind) { - this._lexer.advance(); + this.advanceLexer(); return true; } return false; @@ -1442,7 +1453,7 @@ export class Parser { expectKeyword(value: string): void { const token = this._lexer.token; if (token.kind === TokenKind.NAME && token.value === value) { - this._lexer.advance(); + this.advanceLexer(); } else { throw syntaxError( this._lexer.source, @@ -1459,7 +1470,7 @@ export class Parser { expectOptionalKeyword(value: string): boolean { const token = this._lexer.token; if (token.kind === TokenKind.NAME && token.value === value) { - this._lexer.advance(); + this.advanceLexer(); return true; } return false; @@ -1548,6 +1559,22 @@ export class Parser { } while (this.expectOptionalToken(delimiterKind)); return nodes; } + + advanceLexer(): void { + const { maxTokens } = this._options; + const token = this._lexer.advance(); + + if (maxTokens !== undefined && token.kind !== TokenKind.EOF) { + ++this._tokenCounter; + if (this._tokenCounter > maxTokens) { + throw syntaxError( + this._lexer.source, + token.start, + `Document contains more that ${maxTokens} tokens. Parsing aborted.`, + ); + } + } + } } /** diff --git a/src/language/tokenKind.ts b/src/language/tokenKind.ts index 4878d697b0..0c260df99e 100644 --- a/src/language/tokenKind.ts +++ b/src/language/tokenKind.ts @@ -2,7 +2,7 @@ * An exported enum describing the different kinds of tokens that the * lexer emits. */ -export enum TokenKind { +enum TokenKind { SOF = '', EOF = '', BANG = '!', @@ -26,6 +26,7 @@ export enum TokenKind { BLOCK_STRING = 'BlockString', COMMENT = 'Comment', } +export { TokenKind }; /** * The enum type representing the token kinds values. diff --git a/src/type/introspection.ts b/src/type/introspection.ts index e5fce6f241..f5e4b07ea7 100644 --- a/src/type/introspection.ts +++ b/src/type/introspection.ts @@ -435,7 +435,7 @@ export const __EnumValue: GraphQLObjectType = new GraphQLObjectType({ } as GraphQLFieldConfigMap), }); -export enum TypeKind { +enum TypeKind { SCALAR = 'SCALAR', OBJECT = 'OBJECT', INTERFACE = 'INTERFACE', @@ -445,6 +445,7 @@ export enum TypeKind { LIST = 'LIST', NON_NULL = 'NON_NULL', } +export { TypeKind }; export const __TypeKind: GraphQLEnumType = new GraphQLEnumType({ name: '__TypeKind', diff --git a/src/utilities/findBreakingChanges.ts b/src/utilities/findBreakingChanges.ts index 0bf0d453b4..2489af9d62 100644 --- a/src/utilities/findBreakingChanges.ts +++ b/src/utilities/findBreakingChanges.ts @@ -34,7 +34,7 @@ import type { GraphQLSchema } from '../type/schema'; import { astFromValue } from './astFromValue'; import { sortValueNode } from './sortValueNode'; -export enum BreakingChangeType { +enum BreakingChangeType { TYPE_REMOVED = 'TYPE_REMOVED', TYPE_CHANGED_KIND = 'TYPE_CHANGED_KIND', TYPE_REMOVED_FROM_UNION = 'TYPE_REMOVED_FROM_UNION', @@ -52,8 +52,9 @@ export enum BreakingChangeType { DIRECTIVE_REPEATABLE_REMOVED = 'DIRECTIVE_REPEATABLE_REMOVED', DIRECTIVE_LOCATION_REMOVED = 'DIRECTIVE_LOCATION_REMOVED', } +export { BreakingChangeType }; -export enum DangerousChangeType { +enum DangerousChangeType { VALUE_ADDED_TO_ENUM = 'VALUE_ADDED_TO_ENUM', TYPE_ADDED_TO_UNION = 'TYPE_ADDED_TO_UNION', OPTIONAL_INPUT_FIELD_ADDED = 'OPTIONAL_INPUT_FIELD_ADDED', @@ -61,6 +62,7 @@ export enum DangerousChangeType { IMPLEMENTED_INTERFACE_ADDED = 'IMPLEMENTED_INTERFACE_ADDED', ARG_DEFAULT_VALUE_CHANGE = 'ARG_DEFAULT_VALUE_CHANGE', } +export { DangerousChangeType }; export interface BreakingChange { type: BreakingChangeType; diff --git a/src/version.ts b/src/version.ts index 2ccaf094d9..d5662a10f5 100644 --- a/src/version.ts +++ b/src/version.ts @@ -4,14 +4,14 @@ /** * A string containing the version of the GraphQL.js library */ -export const version = '16.5.0' as string; +export const version = '16.6.0' as string; /** * An object containing the components of the GraphQL.js version string */ export const versionInfo = Object.freeze({ major: 16 as number, - minor: 5 as number, + minor: 6 as number, patch: 0 as number, preReleaseTag: null as string | null, });