From a822992fd6684dcc3fd379dd5607d8085d40c167 Mon Sep 17 00:00:00 2001 From: Michael Hayes Date: Sat, 27 Aug 2022 05:54:50 -0700 Subject: [PATCH 01/12] Add a context option for passing the request context to estimators (#77) * Add a context option for passing the request context to estimators * Fix tests --- README.md | 6 +++++ src/QueryComplexity.ts | 9 +++++++ src/__tests__/QueryComplexity-test.ts | 34 +++++++++++++++++++++--- src/__tests__/utils/compatResolveType.ts | 8 +++--- 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index dce7d3e..e94943a 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,9 @@ const rule = createComplexityRule({ // in the visitor of the graphql-js library variables: {}, + // The context object for the request (optional) + context: {} + // specify operation name only when pass multi-operation documents operationName?: string, @@ -110,6 +113,9 @@ type ComplexityEstimatorArgs = { // The complexity of all child selections for that field childComplexity: number; + + // The context object for the request if it was provided + context?: Record; }; type ComplexityEstimator = (options: ComplexityEstimatorArgs) => number | void; diff --git a/src/QueryComplexity.ts b/src/QueryComplexity.ts index 348c4f5..091f45c 100644 --- a/src/QueryComplexity.ts +++ b/src/QueryComplexity.ts @@ -43,6 +43,7 @@ export type ComplexityEstimatorArgs = { node: FieldNode; args: { [key: string]: any }; childComplexity: number; + context?: Record; }; export type ComplexityEstimator = ( @@ -78,6 +79,9 @@ export interface QueryComplexityOptions { // An array of complexity estimators to use for estimating the complexity estimators: Array; + + // Pass request context to the estimators via estimationContext + context?: Record; } function queryComplexityMessage(max: number, actual: number): string { @@ -93,6 +97,7 @@ export function getComplexity(options: { query: DocumentNode; variables?: Record; operationName?: string; + context?: Record; }): number { const typeInfo = new TypeInfo(options.schema); @@ -109,6 +114,7 @@ export function getComplexity(options: { estimators: options.estimators, variables: options.variables, operationName: options.operationName, + context: options.context, }); visit(options.query, visitWithTypeInfo(typeInfo, visitor)); @@ -130,6 +136,7 @@ export default class QueryComplexity { includeDirectiveDef: GraphQLDirective; skipDirectiveDef: GraphQLDirective; variableValues: Record; + requestContext?: Record; constructor(context: ValidationContext, options: QueryComplexityOptions) { if ( @@ -149,6 +156,7 @@ export default class QueryComplexity { this.skipDirectiveDef = this.context.getSchema().getDirective('skip'); this.estimators = options.estimators; this.variableValues = {}; + this.requestContext = options.context; this.OperationDefinition = { enter: this.onOperationDefinitionEnter, @@ -327,6 +335,7 @@ export default class QueryComplexity { field, node: childNode, type: typeDef, + context: this.requestContext, }; const validScore = this.estimators.find((estimator) => { const tmpComplexity = estimator(estimatorArgs); diff --git a/src/__tests__/QueryComplexity-test.ts b/src/__tests__/QueryComplexity-test.ts index a0979db..6c22ae3 100644 --- a/src/__tests__/QueryComplexity-test.ts +++ b/src/__tests__/QueryComplexity-test.ts @@ -639,7 +639,7 @@ describe('QueryComplexity analysis', () => { ...on Union { ...on Item { complexScalar1: complexScalar - } + } } ...on SecondItem { scalar @@ -678,7 +678,7 @@ describe('QueryComplexity analysis', () => { fragment F on Union { ...on Item { complexScalar1: complexScalar - } + } } `); @@ -759,7 +759,7 @@ describe('QueryComplexity analysis', () => { } } } - + fragment F on Query { complexScalar ...on Query { @@ -832,4 +832,32 @@ describe('QueryComplexity analysis', () => { }), ]); }); + + it('passed context to estimators', () => { + const ast = parse(` + query { + scalar + requiredArgs(count: 10) { + scalar + } + } + `); + + const contextEstimator: ComplexityEstimator = ({ + context, + childComplexity, + }) => { + return context['complexityMultiplier'] * (childComplexity || 1); + }; + + const complexity = getComplexity({ + estimators: [contextEstimator], + schema, + query: ast, + context: { complexityMultiplier: 5 }, + }); + + // query.scalar(5) + query.requiredArgs(5) * requiredArgs.scalar(5) + expect(complexity).to.equal(30); + }); }); diff --git a/src/__tests__/utils/compatResolveType.ts b/src/__tests__/utils/compatResolveType.ts index 26d229c..c96c813 100644 --- a/src/__tests__/utils/compatResolveType.ts +++ b/src/__tests__/utils/compatResolveType.ts @@ -1,6 +1,4 @@ -import { GraphQLType } from 'graphql'; - -import graphqlPackage from 'graphql/package.json'; +import * as graphql from 'graphql'; import semver from 'semver'; /** @@ -10,8 +8,8 @@ import semver from 'semver'; * @param type * @returns */ -export function compatResolveType(type: GraphQLType): any { - if (semver.gte(graphqlPackage.version, '16.0.0')) { +export function compatResolveType(type: graphql.GraphQLType): any { + if (graphql.version && semver.gte(graphql.version, '16.0.0')) { return () => type.toString(); } else { return () => type; From 4bcde6237b3ef6cc829feec05cb4a7c0686d072c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivo=20Mei=C3=9Fner?= Date: Sat, 27 Aug 2022 08:59:20 -0500 Subject: [PATCH 02/12] Report variable coercion errors (#78) * Report variable coercion errors * Ignore empty error list --- src/QueryComplexity.ts | 10 ++++-- src/__tests__/QueryComplexity-test.ts | 49 ++++++++++++++++++++++----- src/__tests__/fixtures/schema.ts | 8 +++++ 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/QueryComplexity.ts b/src/QueryComplexity.ts index 091f45c..41dee1e 100644 --- a/src/QueryComplexity.ts +++ b/src/QueryComplexity.ts @@ -174,12 +174,18 @@ export default class QueryComplexity { // Get variable values from variables that are passed from options, merged // with default values defined in the operation - this.variableValues = getVariableValues( + const { coerced, errors } = getVariableValues( this.context.getSchema(), // We have to create a new array here because input argument is not readonly in graphql ~14.6.0 operation.variableDefinitions ? [...operation.variableDefinitions] : [], this.options.variables ?? {} - ).coerced; + ); + if (errors && errors.length) { + // We have input validation errors, report errors and abort + errors.forEach((error) => this.context.reportError(error)); + return; + } + this.variableValues = coerced; switch (operation.operation) { case 'query': diff --git a/src/__tests__/QueryComplexity-test.ts b/src/__tests__/QueryComplexity-test.ts index 6c22ae3..92269dd 100644 --- a/src/__tests__/QueryComplexity-test.ts +++ b/src/__tests__/QueryComplexity-test.ts @@ -254,21 +254,28 @@ describe('QueryComplexity analysis', () => { expect(visitor.complexity).to.equal(0); }); - it('should ignore unused variables', () => { + it('should report errors for unused variables', () => { const ast = parse(` query ($unusedVar: ID!) { variableScalar(count: 100) } `); - const context = new CompatibleValidationContext(schema, ast, typeInfo); - const visitor = new ComplexityVisitor(context, { - maximumComplexity: 100, - estimators: [simpleEstimator({ defaultComplexity: 10 })], - }); - - visit(ast, visitWithTypeInfo(typeInfo, visitor)); - expect(visitor.complexity).to.equal(10); + const errors = validate(schema, ast, [ + createComplexityRule({ + maximumComplexity: 1000, + estimators: [ + simpleEstimator({ + defaultComplexity: 1, + }), + ], + variables: { + unusedVar: 'someID', + }, + }), + ]); + expect(errors).to.have.length(1); + expect(errors[0].message).to.contain('$unusedVar'); }); it('should ignore unknown field', () => { @@ -860,4 +867,28 @@ describe('QueryComplexity analysis', () => { // query.scalar(5) + query.requiredArgs(5) * requiredArgs.scalar(5) expect(complexity).to.equal(30); }); + + it('reports variable coercion errors', () => { + const ast = parse(` + query ($input: RGB!){ + enumInputArg(enum: $input) + } + `); + + const errors = validate(schema, ast, [ + createComplexityRule({ + maximumComplexity: 1000, + estimators: [ + simpleEstimator({ + defaultComplexity: 1, + }), + ], + variables: { + input: 'INVALIDVALUE', + }, + }), + ]); + expect(errors).to.have.length(1); + expect(errors[0].message).to.contain('INVALIDVALUE'); + }); }); diff --git a/src/__tests__/fixtures/schema.ts b/src/__tests__/fixtures/schema.ts index 5a2d30b..415994a 100644 --- a/src/__tests__/fixtures/schema.ts +++ b/src/__tests__/fixtures/schema.ts @@ -164,6 +164,14 @@ const Query = new GraphQLObjectType({ }, }, }, + enumInputArg: { + type: GraphQLString, + args: { + enum: { + type: EnumType, + }, + }, + }, _service: { type: SDLInterface }, }), interfaces: () => [NameInterface, UnionInterface], From fa1f9d54841593fad1481101adc1025fa8bbd1dd Mon Sep 17 00:00:00 2001 From: Marco Sansoni <45231575+marcosansoni@users.noreply.github.com> Date: Mon, 29 Aug 2022 14:06:02 +0200 Subject: [PATCH 03/12] Bump version to v0.12.0 (#79) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d5b0130..d108fb7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql-query-complexity", - "version": "0.11.0", + "version": "0.12.0", "description": "Validation rule for GraphQL query complexity analysis", "main": "dist/cjs/index.js", "types": "dist/cjs/index.d.ts", From c9e062ae60abcd72a5e398d420f07685717842d3 Mon Sep 17 00:00:00 2001 From: Uladzislau Bahdanouski <42293261+Squarix@users.noreply.github.com> Date: Tue, 28 May 2024 19:18:36 +0400 Subject: [PATCH 04/12] Complexity for meta fields + test (#92) --- src/QueryComplexity.ts | 21 ++++++++++++++++++++- src/__tests__/QueryComplexity-test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/QueryComplexity.ts b/src/QueryComplexity.ts index 41dee1e..8f9e5b3 100644 --- a/src/QueryComplexity.ts +++ b/src/QueryComplexity.ts @@ -35,6 +35,9 @@ import { Kind, getNamedType, GraphQLError, + SchemaMetaFieldDef, + TypeMetaFieldDef, + TypeNameMetaFieldDef, } from 'graphql'; export type ComplexityEstimatorArgs = { @@ -307,7 +310,23 @@ export default class QueryComplexity { switch (childNode.kind) { case Kind.FIELD: { - const field = fields[childNode.name.value]; + let field = null; + + switch (childNode.name.value) { + case SchemaMetaFieldDef.name: + field = SchemaMetaFieldDef; + break; + case TypeMetaFieldDef.name: + field = TypeMetaFieldDef; + break; + case TypeNameMetaFieldDef.name: + field = TypeNameMetaFieldDef; + break; + default: + field = fields[childNode.name.value]; + break; + } + // Invalid field, should be caught by other validation rules if (!field) { break; diff --git a/src/__tests__/QueryComplexity-test.ts b/src/__tests__/QueryComplexity-test.ts index 92269dd..6c2ec43 100644 --- a/src/__tests__/QueryComplexity-test.ts +++ b/src/__tests__/QueryComplexity-test.ts @@ -611,6 +611,33 @@ describe('QueryComplexity analysis', () => { expect(complexity2).to.equal(20); }); + it('should calculate complexity for meta fields', () => { + const query = parse(` + query Primary { + __typename + __type(name: "Primary") { + name + } + __schema { + types { + name + } + } + } + `); + + const complexity = getComplexity({ + estimators: [ + fieldExtensionsEstimator(), + simpleEstimator({ defaultComplexity: 1 }), + ], + schema, + query, + }); + + expect(complexity).to.equal(6); + }); + it('should calculate max complexity for fragment on union type', () => { const query = parse(` query Primary { From 9cadc5a543de76ee5ab5846e81aca35abb5ed07b Mon Sep 17 00:00:00 2001 From: Bas Kiers Date: Tue, 28 May 2024 18:03:06 +0200 Subject: [PATCH 05/12] fix: default non-defined graphql operations to have 0 complexity (#89) --- src/QueryComplexity.ts | 8 ++++++-- src/__tests__/QueryComplexity-test.ts | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/QueryComplexity.ts b/src/QueryComplexity.ts index 8f9e5b3..90eed82 100644 --- a/src/QueryComplexity.ts +++ b/src/QueryComplexity.ts @@ -241,9 +241,13 @@ export default class QueryComplexity { | FragmentDefinitionNode | InlineFragmentNode | OperationDefinitionNode, - typeDef: GraphQLObjectType | GraphQLInterfaceType | GraphQLUnionType + typeDef: + | GraphQLObjectType + | GraphQLInterfaceType + | GraphQLUnionType + | undefined ): number { - if (node.selectionSet) { + if (node.selectionSet && typeDef) { let fields: GraphQLFieldMap = {}; if ( typeDef instanceof GraphQLObjectType || diff --git a/src/__tests__/QueryComplexity-test.ts b/src/__tests__/QueryComplexity-test.ts index 6c2ec43..4e52a11 100644 --- a/src/__tests__/QueryComplexity-test.ts +++ b/src/__tests__/QueryComplexity-test.ts @@ -918,4 +918,25 @@ describe('QueryComplexity analysis', () => { expect(errors).to.have.length(1); expect(errors[0].message).to.contain('INVALIDVALUE'); }); + + it('falls back to 0 complexity for GraphQL operations not supported by the schema', () => { + const ast = parse(` + subscription { + foo + } + `); + + const errors = validate(schema, ast, [ + createComplexityRule({ + maximumComplexity: 1000, + estimators: [ + simpleEstimator({ + defaultComplexity: 1, + }), + ], + }), + ]); + + expect(errors).to.have.length(0); + }); }); From 439a3a9125626ba21a5d002ebf8f4d65b656b2ae Mon Sep 17 00:00:00 2001 From: Toan Quoc Ho Date: Mon, 10 Jun 2024 20:45:00 +0700 Subject: [PATCH 06/12] fix(ESM package): QueryComplexity import CJS module from GraphQL (#91) --- .mocharc.json | 3 +++ src/QueryComplexity.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 .mocharc.json diff --git a/.mocharc.json b/.mocharc.json new file mode 100644 index 0000000..efafb6e --- /dev/null +++ b/.mocharc.json @@ -0,0 +1,3 @@ +{ + "node-option": ["experimental-specifier-resolution=node"] +} diff --git a/src/QueryComplexity.ts b/src/QueryComplexity.ts index 90eed82..e491b64 100644 --- a/src/QueryComplexity.ts +++ b/src/QueryComplexity.ts @@ -8,7 +8,7 @@ import { getArgumentValues, getDirectiveValues, getVariableValues, -} from 'graphql/execution/values.js'; +} from 'graphql/execution/values'; import { ValidationContext, From 455978d4ed2934d7160a457965ab104998a3c515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivo=20Mei=C3=9Fner?= Date: Mon, 10 Jun 2024 08:54:01 -0500 Subject: [PATCH 07/12] v1.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d108fb7..20a1fdf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql-query-complexity", - "version": "0.12.0", + "version": "1.0.0", "description": "Validation rule for GraphQL query complexity analysis", "main": "dist/cjs/index.js", "types": "dist/cjs/index.d.ts", From ea0eba71a8732c0b9279408a62091c7d8f35246a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivo=20Mei=C3=9Fner?= Date: Sun, 6 Apr 2025 07:56:25 -0500 Subject: [PATCH 08/12] Fix NodeJS runtime compatibility (#94) --- .circleci/config.yml | 59 ------------------------ .github/workflows/ci.yaml | 84 +++++++++++++++++++++++++++++++++++ README.md | 1 - fix-hybrid-module.sh | 29 ++++++++++++ fix-hybrid-module.test.cjs.sh | 12 ++++- fix-hybrid-module.test.esm.sh | 14 ++++++ package.json | 8 ++-- 7 files changed, 143 insertions(+), 64 deletions(-) delete mode 100644 .circleci/config.yml create mode 100644 .github/workflows/ci.yaml diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 159d1de..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,59 +0,0 @@ -# Javascript Node CircleCI 2.0 configuration file -# -# Check https://circleci.com/docs/2.0/language-javascript/ for more details -# -version: 2.1 - -workflows: - all-tests: - jobs: - - test-and-build: - # Override graphql-version to test against specific versions. Type checking is disabled due missing - # definitions for field extensions in older @types/graphql versions - matrix: - parameters: - graphql-version: ['~14.6', '~14.7', '~15.0', '~16.0'] - - test-and-build: - # Leave graphql-version unspecified to respect the lockfile and also run tsc - name: test-and-build-with-typecheck - -jobs: - test-and-build: - parameters: - graphql-version: - type: string - default: '' - - docker: - # specify the version you desire here - - image: circleci/node:latest - - working_directory: ~/repo - - steps: - - checkout - - # Download and cache dependencies - - restore_cache: - keys: - - v1-dependencies-{{ checksum "package.json" }}-<< parameters.graphql-version >> - # fallback to using the latest cache if no exact match is found - - v1-dependencies- - - - when: - condition: << parameters.graphql-version >> - steps: - - run: yarn install --ignore-scripts - - run: yarn --ignore-scripts add --dev graphql@<< parameters.graphql-version >> - - unless: - condition: << parameters.graphql-version >> - steps: - - run: yarn install --frozen-lockfile - - - save_cache: - paths: - - node_modules - key: v1-dependencies-{{ checksum "package.json" }}-<< parameters.graphql-version >> - - # run tests! - - run: yarn test diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..a633b2e --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,84 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + test-and-build: + runs-on: ubuntu-latest + strategy: + matrix: + graphql-version: ['~15.0', '~16.0'] + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 'latest' + + - name: Restore cache + uses: actions/cache@v2 + with: + path: node_modules + key: v1-dependencies-${{ hashFiles('package.json') }}-${{ matrix.graphql-version }} + restore-keys: | + v1-dependencies- + + - name: Install dependencies + if: matrix.graphql-version != '' + run: yarn install --ignore-scripts + + - name: Add specific graphql version + if: matrix.graphql-version != '' + run: yarn --ignore-scripts add --dev graphql@${{ matrix.graphql-version }} + + - name: Install dependencies with frozen lockfile + if: matrix.graphql-version == '' + run: yarn install --frozen-lockfile + + - name: Save cache + uses: actions/cache@v2 + with: + path: node_modules + key: v1-dependencies-${{ hashFiles('package.json') }}-${{ matrix.graphql-version }} + + - name: Run tests + run: yarn test + + test-and-build-with-typecheck: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 'latest' + + - name: Restore cache + uses: actions/cache@v2 + with: + path: node_modules + key: v1-dependencies-${{ hashFiles('package.json') }} + restore-keys: | + v1-dependencies- + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Save cache + uses: actions/cache@v2 + with: + path: node_modules + key: v1-dependencies-${{ hashFiles('package.json') }} + + - name: Run tests + run: yarn test diff --git a/README.md b/README.md index e94943a..935ce89 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ [![npm](https://img.shields.io/npm/dm/graphql-query-complexity)](https://www.npmjs.com/package/graphql-query-complexity) [![npm version](https://badge.fury.io/js/graphql-query-complexity.svg)](https://badge.fury.io/js/graphql-query-complexity) -[![CircleCI](https://circleci.com/gh/slicknode/graphql-query-complexity.svg?style=shield)](https://circleci.com/gh/slicknode/graphql-query-complexity) [![Twitter Follow](https://img.shields.io/twitter/follow/slicknode?style=social)](https://twitter.com/slicknode) This library provides GraphQL query analysis to reject complex queries to your GraphQL server. diff --git a/fix-hybrid-module.sh b/fix-hybrid-module.sh index 4792b5f..3c46b4e 100755 --- a/fix-hybrid-module.sh +++ b/fix-hybrid-module.sh @@ -1,11 +1,40 @@ +#!/bin/bash + +# Create package.json for CommonJS cat >dist/cjs/package.json <dist/esm/package.json <dist/test/cjs/package.json <dist/test/esm/package.json < Date: Sun, 6 Apr 2025 08:04:25 -0500 Subject: [PATCH 09/12] Update github actions/cache version (#97) --- .github/workflows/ci.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a633b2e..c02e088 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,7 +24,7 @@ jobs: node-version: 'latest' - name: Restore cache - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: node_modules key: v1-dependencies-${{ hashFiles('package.json') }}-${{ matrix.graphql-version }} @@ -44,7 +44,7 @@ jobs: run: yarn install --frozen-lockfile - name: Save cache - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: node_modules key: v1-dependencies-${{ hashFiles('package.json') }}-${{ matrix.graphql-version }} @@ -64,7 +64,7 @@ jobs: node-version: 'latest' - name: Restore cache - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: node_modules key: v1-dependencies-${{ hashFiles('package.json') }} @@ -75,7 +75,7 @@ jobs: run: yarn install --frozen-lockfile - name: Save cache - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: node_modules key: v1-dependencies-${{ hashFiles('package.json') }} From 46706d4f90dc167b486eed1d70a2b633fe9c53b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivo=20Mei=C3=9Fner?= Date: Mon, 7 Apr 2025 20:35:36 -0500 Subject: [PATCH 10/12] Add maxQueryNodes limit (#98) --- README.md | 11 +++- src/QueryComplexity.ts | 19 +++++- src/__tests__/QueryComplexity-test.ts | 83 +++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 935ce89..34d3274 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ import { const rule = createComplexityRule({ // The maximum allowed query complexity, queries above this threshold will be rejected - maximumComplexity: 1000, + maximumComplexity: 1_000, // The query variables. This is needed because the variables are not available // in the visitor of the graphql-js library @@ -40,9 +40,16 @@ const rule = createComplexityRule({ // The context object for the request (optional) context: {} - // specify operation name only when pass multi-operation documents + // Specify operation name when evaluating multi-operation documents operationName?: string, + // The maximum number of query nodes to evaluate (fields, fragments, composite types). + // If a query contains more than the specified number of nodes, the complexity rule will + // throw an error, regardless of the complexity of the query. + // + // Default: 10_000 + maxQueryNodes?: 10_000, + // Optional callback function to retrieve the determined query complexity // Will be invoked whether the query is rejected or not // This can be used for logging or to implement rate limiting diff --git a/src/QueryComplexity.ts b/src/QueryComplexity.ts index e491b64..5295a93 100644 --- a/src/QueryComplexity.ts +++ b/src/QueryComplexity.ts @@ -85,6 +85,11 @@ export interface QueryComplexityOptions { // Pass request context to the estimators via estimationContext context?: Record; + + // The maximum number of nodes to evaluate. If this is set, the query will be + // rejected if it exceeds this number. (Includes fields, fragments, inline fragments, etc.) + // Defaults to 10_000. + maxQueryNodes?: number; } function queryComplexityMessage(max: number, actual: number): string { @@ -101,6 +106,7 @@ export function getComplexity(options: { variables?: Record; operationName?: string; context?: Record; + maxQueryNodes?: number; }): number { const typeInfo = new TypeInfo(options.schema); @@ -118,6 +124,7 @@ export function getComplexity(options: { variables: options.variables, operationName: options.operationName, context: options.context, + maxQueryNodes: options.maxQueryNodes, }); visit(options.query, visitWithTypeInfo(typeInfo, visitor)); @@ -140,6 +147,8 @@ export default class QueryComplexity { skipDirectiveDef: GraphQLDirective; variableValues: Record; requestContext?: Record; + evaluatedNodes: number; + maxQueryNodes: number; constructor(context: ValidationContext, options: QueryComplexityOptions) { if ( @@ -154,7 +163,8 @@ export default class QueryComplexity { this.context = context; this.complexity = 0; this.options = options; - + this.evaluatedNodes = 0; + this.maxQueryNodes = options.maxQueryNodes ?? 10_000; this.includeDirectiveDef = this.context.getSchema().getDirective('include'); this.skipDirectiveDef = this.context.getSchema().getDirective('skip'); this.estimators = options.estimators; @@ -274,7 +284,12 @@ export default class QueryComplexity { complexities: ComplexityMap, childNode: FieldNode | FragmentSpreadNode | InlineFragmentNode ): ComplexityMap => { - // let nodeComplexity = 0; + this.evaluatedNodes++; + if (this.evaluatedNodes >= this.maxQueryNodes) { + throw new GraphQLError( + 'Query exceeds the maximum allowed number of nodes.' + ); + } let innerComplexities = complexities; let includeNode = true; diff --git a/src/__tests__/QueryComplexity-test.ts b/src/__tests__/QueryComplexity-test.ts index 4e52a11..50e493d 100644 --- a/src/__tests__/QueryComplexity-test.ts +++ b/src/__tests__/QueryComplexity-test.ts @@ -939,4 +939,87 @@ describe('QueryComplexity analysis', () => { expect(errors).to.have.length(0); }); + + it('should reject queries that exceed the maximum number of fragment nodes', () => { + const query = parse(` + query { + ...F + ...F + } + fragment F on Query { + scalar + } + `); + + expect(() => + getComplexity({ + estimators: [simpleEstimator({ defaultComplexity: 1 })], + schema, + query, + maxQueryNodes: 1, + variables: {}, + }) + ).to.throw('Query exceeds the maximum allowed number of nodes.'); + }); + + it('should reject queries that exceed the maximum number of field nodes', () => { + const query = parse(` + query { + scalar + scalar1: scalar + scalar2: scalar + scalar3: scalar + scalar4: scalar + scalar5: scalar + scalar6: scalar + scalar7: scalar + } + `); + + expect(() => + getComplexity({ + estimators: [simpleEstimator({ defaultComplexity: 1 })], + schema, + query, + maxQueryNodes: 1, + variables: {}, + }) + ).to.throw('Query exceeds the maximum allowed number of nodes.'); + }); + + it('should limit the number of query nodes to 10_000 by default', () => { + const failingQuery = parse(` + query { + ${Array.from({ length: 10_000 }, (_, i) => `scalar${i}: scalar`).join( + '\n' + )} + } + `); + + expect(() => + getComplexity({ + estimators: [simpleEstimator({ defaultComplexity: 1 })], + schema, + query: failingQuery, + variables: {}, + }) + ).to.throw('Query exceeds the maximum allowed number of nodes.'); + + const passingQuery = parse(` + query { + ${Array.from({ length: 9999 }, (_, i) => `scalar${i}: scalar`).join( + '\n' + )} + } + `); + + expect(() => + getComplexity({ + estimators: [simpleEstimator({ defaultComplexity: 1 })], + schema, + query: passingQuery, + variables: {}, + }) + ).not.to.throw(); + }); }); From f84a9fa4a44950a6cbed1654ca72110d9a483834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivo=20Mei=C3=9Fner?= Date: Mon, 7 Apr 2025 20:38:16 -0500 Subject: [PATCH 11/12] 1.1.0-beta.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 13a62c7..ad2bbff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql-query-complexity", - "version": "1.0.0", + "version": "1.1.0-beta.0", "description": "Validation rule for GraphQL query complexity analysis", "main": "dist/cjs/index.js", "types": "dist/cjs/index.d.ts", From fc70c1b6e1e787154a3e5099fe70edc6a1a3b71f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivo=20Mei=C3=9Fner?= Date: Mon, 7 Apr 2025 20:56:26 -0500 Subject: [PATCH 12/12] 1.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ad2bbff..ec522db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql-query-complexity", - "version": "1.1.0-beta.0", + "version": "1.1.0", "description": "Validation rule for GraphQL query complexity analysis", "main": "dist/cjs/index.js", "types": "dist/cjs/index.d.ts",