diff --git a/.babelrc b/.babelrc index ab7bedb..8fae5fa 100644 --- a/.babelrc +++ b/.babelrc @@ -1,5 +1,8 @@ { "presets": [ ["env", { "loose": true }] + ], + "plugins": [ + "transform-object-rest-spread" ] } diff --git a/README.md b/README.md index 64b6a1d..f701ce9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ const ComplexityLimitRule = createComplexityLimitRule(1000); // Then use this rule with validate(). ``` -You can also provide custom costs for scalars and objects, and a custom cost factor for lists. +You can provide a configuration object with custom costs for scalars and objects as `scalarCost` and `objectCost` respectively, and a custom cost factor for lists as `listFactor`. ```js const ComplexityLimitRule = createComplexityLimitRule(1000, { @@ -23,6 +23,19 @@ const ComplexityLimitRule = createComplexityLimitRule(1000, { }); ``` +The configuration object also supports an `onCost` callback for logging query costs and a `formatErrorMessage` callback for customizing error messages. `onCost` will be called for every query with its cost. `formatErrorMessage` will be called with the cost whenever a query exceeds the complexity limit, and should return a string containing the error message. + +```js +const ComplexityLimitRule = createComplexityLimitRule(1000, { + onCost: (cost) => { + console.log('query cost:', cost); + }, + formatErrorMessage: cost => ( + `query with cost ${cost} exceeds complexity limit` + ), +}); +``` + [build-badge]: https://img.shields.io/travis/4Catalyzer/graphql-validation-complexity/master.svg [build]: https://travis-ci.org/4Catalyzer/graphql-validation-complexity diff --git a/package-lock.json b/package-lock.json index adb0c5b..8ff9184 100644 --- a/package-lock.json +++ b/package-lock.json @@ -338,6 +338,12 @@ "integrity": "sha1-nufoM3KQ2pUoggGmpX9BcDF4MN4=", "dev": true }, + "babel-plugin-syntax-object-rest-spread": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz", + "integrity": "sha1-/WU28rzhODb/o6VFjEkDpZe7O/U=", + "dev": true + }, "babel-plugin-syntax-trailing-function-commas": { "version": "6.22.0", "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz", @@ -488,6 +494,12 @@ "integrity": "sha1-KrDJx/MJj6SJB3cruBP+QejeOg4=", "dev": true }, + "babel-plugin-transform-object-rest-spread": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-object-rest-spread/-/babel-plugin-transform-object-rest-spread-6.23.0.tgz", + "integrity": "sha1-h11ryb52HFiirj/u5dxIldjH+SE=", + "dev": true + }, "babel-plugin-transform-regenerator": { "version": "6.24.1", "resolved": "https://registry.npmjs.org/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.24.1.tgz", diff --git a/package.json b/package.json index 5959e00..eada68a 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "babel-cli": "^6.24.1", "babel-eslint": "^7.2.3", "babel-jest": "^20.0.3", + "babel-plugin-transform-object-rest-spread": "^6.23.0", "babel-preset-env": "^1.5.2", "codecov": "^2.2.0", "eslint": "^3.19.0", diff --git a/src/index.js b/src/index.js index d5327d6..c26aef4 100644 --- a/src/index.js +++ b/src/index.js @@ -130,7 +130,20 @@ export class ComplexityVisitor { } } -export function createComplexityLimitRule(maxCost, options = {}) { +function complexityLimitExceededErrorMessage() { + // By default, don't respond with the cost to avoid leaking information about + // the cost scheme to a potentially malicious client. + return 'query exceeds complexity limit'; +} + +export function createComplexityLimitRule( + maxCost, + { + onCost, + formatErrorMessage = complexityLimitExceededErrorMessage, + ...options + } = {}, +) { return function ComplexityLimit(context) { const visitor = new ComplexityVisitor(context, options); @@ -149,9 +162,15 @@ export function createComplexityLimitRule(maxCost, options = {}) { } if (node.kind === 'Document') { - if (visitor.getCost() > maxCost) { + const cost = visitor.getCost(); + + if (onCost) { + onCost(cost); + } + + if (cost > maxCost) { context.reportError(new GraphQLError( - 'query exceeds complexity limit', [node], + formatErrorMessage(cost), [node], )); } } diff --git a/test/createComplexityLimitRule.test.js b/test/createComplexityLimitRule.test.js index 1c52f21..0835d32 100644 --- a/test/createComplexityLimitRule.test.js +++ b/test/createComplexityLimitRule.test.js @@ -15,10 +15,11 @@ describe('createComplexityLimitRule', () => { `); const errors = validate(schema, ast, [createComplexityLimitRule(9)]); + expect(errors).toHaveLength(0); }); - it('should not report error on an invalid query', () => { + it('should report error on an invalid query', () => { const ast = parse(` query { list { @@ -28,9 +29,69 @@ describe('createComplexityLimitRule', () => { `); const errors = validate(schema, ast, [createComplexityLimitRule(9)]); + expect(errors).toHaveLength(1); expect(errors[0]).toMatchObject({ message: 'query exceeds complexity limit', }); }); + + it('should call onCost with complexity score', () => { + const ast = parse(` + query { + item { + name + } + } + `); + + const onCostSpy = jest.fn(); + + const errors = validate(schema, ast, [ + createComplexityLimitRule(9, { onCost: onCostSpy }), + ]); + + expect(errors).toHaveLength(0); + expect(onCostSpy).toHaveBeenCalledWith(1); + }); + + it('should call onCost with cost when there are errors', () => { + const ast = parse(` + query { + list { + name + } + } + `); + + const onCostSpy = jest.fn(); + + const errors = validate(schema, ast, [ + createComplexityLimitRule(9, { onCost: onCostSpy }), + ]); + + expect(errors).toHaveLength(1); + expect(onCostSpy).toHaveBeenCalledWith(10); + }); + + it('should call formatErrorMessage with cost', () => { + const ast = parse(` + query { + list { + name + } + } + `); + + const errors = validate(schema, ast, [ + createComplexityLimitRule(9, { + formatErrorMessage: cost => `custom error, cost ${cost}`, + }), + ]); + + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + message: 'custom error, cost 10', + }); + }); });