graphql-query-complexity
Version:
Validation rule for GraphQL query complexity analysis
344 lines (343 loc) • 16.9 kB
JavaScript
"use strict";
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-use-before-define */
/**
* Created by Ivo Meißner on 28.07.17.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.getComplexity = void 0;
const graphql_1 = require("graphql");
function queryComplexityMessage(max, actual) {
return (`The query exceeds the maximum complexity of ${max}. ` +
`Actual complexity is ${actual}`);
}
function getComplexity(options) {
const typeInfo = new graphql_1.TypeInfo(options.schema);
const errors = [];
const context = new graphql_1.ValidationContext(options.schema, options.query, typeInfo, (error) => errors.push(error));
const visitor = new QueryComplexity(context, {
// Maximum complexity does not matter since we're only interested in the calculated complexity.
maximumComplexity: Infinity,
estimators: options.estimators,
variables: options.variables,
operationName: options.operationName,
context: options.context,
maxQueryNodes: options.maxQueryNodes,
});
(0, graphql_1.visit)(options.query, (0, graphql_1.visitWithTypeInfo)(typeInfo, visitor));
// Throw first error if any
if (errors.length) {
throw errors.pop();
}
return visitor.complexity;
}
exports.getComplexity = getComplexity;
class QueryComplexity {
constructor(context, options) {
var _a;
if (!(typeof options.maximumComplexity === 'number' &&
options.maximumComplexity > 0)) {
throw new Error('Maximum query complexity must be a positive number');
}
this.context = context;
this.complexity = 0;
this.options = options;
this.evaluatedNodes = 0;
this.maxQueryNodes = (_a = options.maxQueryNodes) !== null && _a !== void 0 ? _a : 10000;
this.includeDirectiveDef = this.context.getSchema().getDirective('include');
this.skipDirectiveDef = this.context.getSchema().getDirective('skip');
this.estimators = options.estimators;
this.variableValues = {};
this.requestContext = options.context;
this.OperationDefinition = {
enter: this.onOperationDefinitionEnter,
leave: this.onOperationDefinitionLeave,
};
}
onOperationDefinitionEnter(operation) {
var _a;
if (typeof this.options.operationName === 'string' &&
this.options.operationName !== operation.name.value) {
return;
}
// Get variable values from variables that are passed from options, merged
// with default values defined in the operation
const { variableValues, errors } = getOperationVariableValues(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] : [], (_a = this.options.variables) !== null && _a !== void 0 ? _a : {});
if (errors && errors.length) {
// We have input validation errors, report errors and abort
errors.forEach((error) => this.context.reportError(error));
return;
}
this.variableValues = variableValues;
switch (operation.operation) {
case 'query':
this.complexity += this.nodeComplexity(operation, this.context.getSchema().getQueryType());
break;
case 'mutation':
this.complexity += this.nodeComplexity(operation, this.context.getSchema().getMutationType());
break;
case 'subscription':
this.complexity += this.nodeComplexity(operation, this.context.getSchema().getSubscriptionType());
break;
default:
throw new Error(`Query complexity could not be calculated for operation of type ${operation.operation}`);
}
}
onOperationDefinitionLeave(operation) {
if (typeof this.options.operationName === 'string' &&
this.options.operationName !== operation.name.value) {
return;
}
if (this.options.onComplete) {
this.options.onComplete(this.complexity);
}
if (this.complexity > this.options.maximumComplexity) {
return this.context.reportError(this.createError());
}
}
nodeComplexity(node, typeDef, activeFragments = new Set()) {
if (node.selectionSet && typeDef) {
let fields = {};
if (typeDef instanceof graphql_1.GraphQLObjectType ||
typeDef instanceof graphql_1.GraphQLInterfaceType) {
fields = typeDef.getFields();
}
// Determine all possible types of the current node
let possibleTypeNames;
if ((0, graphql_1.isAbstractType)(typeDef)) {
possibleTypeNames = this.context
.getSchema()
.getPossibleTypes(typeDef)
.map((t) => t.name);
}
else {
possibleTypeNames = [typeDef.name];
}
// Collect complexities for all possible types individually
const selectionSetComplexities = node.selectionSet.selections.reduce((complexities, childNode) => {
var _a;
this.evaluatedNodes++;
if (this.evaluatedNodes >= this.maxQueryNodes) {
throw new graphql_1.GraphQLError('Query exceeds the maximum allowed number of nodes.');
}
let innerComplexities = complexities;
let includeNode = true;
let skipNode = false;
for (const directive of (_a = childNode.directives) !== null && _a !== void 0 ? _a : []) {
const directiveName = directive.name.value;
switch (directiveName) {
case 'include': {
const values = (0, graphql_1.getDirectiveValues)(this.includeDirectiveDef, childNode, getExecutionVariableValues(this.variableValues));
if (typeof values.if === 'boolean') {
includeNode = values.if;
}
break;
}
case 'skip': {
const values = (0, graphql_1.getDirectiveValues)(this.skipDirectiveDef, childNode, getExecutionVariableValues(this.variableValues));
if (typeof values.if === 'boolean') {
skipNode = values.if;
}
break;
}
}
}
if (!includeNode || skipNode) {
return complexities;
}
switch (childNode.kind) {
case 'Field': {
let field = null;
switch (childNode.name.value) {
case graphql_1.SchemaMetaFieldDef.name:
field = graphql_1.SchemaMetaFieldDef;
break;
case graphql_1.TypeMetaFieldDef.name:
field = graphql_1.TypeMetaFieldDef;
break;
case graphql_1.TypeNameMetaFieldDef.name:
field = graphql_1.TypeNameMetaFieldDef;
break;
default:
field = fields[childNode.name.value];
break;
}
// Invalid field, should be caught by other validation rules
if (!field) {
break;
}
const fieldType = (0, graphql_1.getNamedType)(field.type);
// Get arguments
let args;
try {
args = (0, graphql_1.getArgumentValues)(field, childNode, getExecutionVariableValues(this.variableValues));
}
catch (e) {
this.context.reportError(e);
return complexities;
}
// Check if we have child complexity
let childComplexity = 0;
if ((0, graphql_1.isCompositeType)(fieldType)) {
childComplexity = this.nodeComplexity(childNode, fieldType, activeFragments);
}
// Run estimators one after another and return first valid complexity
// score
const estimatorArgs = {
childComplexity,
args,
field,
node: childNode,
type: typeDef,
context: this.requestContext,
};
const validScore = this.estimators.find((estimator) => {
const tmpComplexity = estimator(estimatorArgs);
if (typeof tmpComplexity === 'number' &&
!isNaN(tmpComplexity)) {
innerComplexities = addComplexities(tmpComplexity, complexities, possibleTypeNames);
return true;
}
return false;
});
if (!validScore) {
this.context.reportError(new graphql_1.GraphQLError(`No complexity could be calculated for field ${typeDef.name}.${field.name}. ` +
'At least one complexity estimator has to return a complexity score.'));
return complexities;
}
break;
}
case 'FragmentSpread': {
const fragmentName = childNode.name.value;
const fragment = this.context.getFragment(fragmentName);
// Unknown fragment, should be caught by other validation rules
if (!fragment) {
break;
}
// Circular fragment reference — skip to avoid infinite recursion
if (activeFragments.has(fragmentName)) {
break;
}
const fragmentType = this.context
.getSchema()
.getType(fragment.typeCondition.name.value);
// Invalid fragment type, ignore. Should be caught by other validation rules
if (!(0, graphql_1.isCompositeType)(fragmentType)) {
break;
}
// Track this fragment on the active path so deeper spreads can
// detect cycles, then remove it on the way back up (backtracking)
// to avoid copying the set on every descent.
activeFragments.add(fragmentName);
const nodeComplexity = this.nodeComplexity(fragment, fragmentType, activeFragments);
activeFragments.delete(fragmentName);
if ((0, graphql_1.isAbstractType)(fragmentType)) {
// Add fragment complexity for all possible types
innerComplexities = addComplexities(nodeComplexity, complexities, this.context
.getSchema()
.getPossibleTypes(fragmentType)
.map((t) => t.name));
}
else {
// Add complexity for object type
innerComplexities = addComplexities(nodeComplexity, complexities, [fragmentType.name]);
}
break;
}
case 'InlineFragment': {
let inlineFragmentType = typeDef;
if (childNode.typeCondition && childNode.typeCondition.name) {
inlineFragmentType = this.context
.getSchema()
.getType(childNode.typeCondition.name.value);
if (!(0, graphql_1.isCompositeType)(inlineFragmentType)) {
break;
}
}
const nodeComplexity = this.nodeComplexity(childNode, inlineFragmentType, activeFragments);
if ((0, graphql_1.isAbstractType)(inlineFragmentType)) {
// Add fragment complexity for all possible types
innerComplexities = addComplexities(nodeComplexity, complexities, this.context
.getSchema()
.getPossibleTypes(inlineFragmentType)
.map((t) => t.name));
}
else {
// Add complexity for object type
innerComplexities = addComplexities(nodeComplexity, complexities, [inlineFragmentType.name]);
}
break;
}
default: {
// Unreachable: all selection kinds (Field, FragmentSpread,
// InlineFragment) are handled above. The cast keeps this
// compatible across graphql versions whose AST `kind` typings
// differ (enum vs string literal), which affect how the switch
// narrows the node type in this branch.
innerComplexities = addComplexities(this.nodeComplexity(childNode, typeDef, activeFragments), complexities, possibleTypeNames);
break;
}
}
return innerComplexities;
}, {});
// Only return max complexity of all possible types
if (!selectionSetComplexities) {
return NaN;
}
return Math.max(...Object.values(selectionSetComplexities), 0);
}
return 0;
}
createError() {
if (typeof this.options.createError === 'function') {
return this.options.createError(this.options.maximumComplexity, this.complexity);
}
return new graphql_1.GraphQLError(queryComplexityMessage(this.options.maximumComplexity, this.complexity));
}
}
exports.default = QueryComplexity;
/**
* GraphQL v17 changed getVariableValues() to return { variableValues }
* (an object with a `coerced` map) instead of a `{ coerced }` map directly.
* This helper normalizes both shapes to the container the running graphql
* version expects, without referencing any version-specific graphql types
* (which would leak into this package's published type definitions).
*/
function getOperationVariableValues(schema, variableDefinitions, inputs) {
var _a, _b;
const result = (0, graphql_1.getVariableValues)(schema, variableDefinitions, inputs);
return {
variableValues: (_b = (_a = result.variableValues) !== null && _a !== void 0 ? _a : result.coerced) !== null && _b !== void 0 ? _b : {},
errors: result.errors,
};
}
/**
* Forwards the version-correct variable values (already shaped by
* getOperationVariableValues) to getArgumentValues / getDirectiveValues
* unchanged, mapping only the empty case to `undefined`.
*/
function getExecutionVariableValues(variableValues) {
if (!variableValues || Object.keys(variableValues).length === 0) {
return undefined;
}
return variableValues;
}
/**
* Adds a complexity to the complexity map for all possible types
* @param complexity
* @param complexityMap
* @param possibleTypes
*/
function addComplexities(complexity, complexityMap, possibleTypes) {
for (const type of possibleTypes) {
if (Object.prototype.hasOwnProperty.call(complexityMap, type)) {
complexityMap[type] += complexity;
}
else {
complexityMap[type] = complexity;
}
}
return complexityMap;
}