Skip to content

Commit bc9e2d0

Browse files
ideeeveeenullswan
andauthored
Feat/flatten fragments cost plugin (#724)
* Update cost-limit.md Signed-off-by: ideeeveee <[email protected]> * add flattenFragments flag to cost-limit plugin Introduce config to control if depthCostFactor should be applied to fragments when calculating the cost Signed-off-by: ideeeveee <[email protected]> * Add tests Signed-off-by: ideeeveee <[email protected]> * feat: changeset --------- Signed-off-by: ideeeveee <[email protected]> Co-authored-by: nullswan <[email protected]>
1 parent 9c39455 commit bc9e2d0

File tree

4 files changed

+96
-8
lines changed

4 files changed

+96
-8
lines changed

.changeset/bright-kings-think.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@escape.tech/graphql-armor-cost-limit': minor
3+
'@escape.tech/graphql-armor': patch
4+
---
5+
6+
cost limit: flatten fragment option support

packages/plugins/cost-limit/src/index.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type CostLimitOptions = {
1616
objectCost?: number;
1717
scalarCost?: number;
1818
depthCostFactor?: number;
19+
flattenFragments?: boolean;
1920
ignoreIntrospection?: boolean;
2021
fragmentRecursionCost?: number;
2122
exposeLimits?: boolean;
@@ -27,6 +28,7 @@ const costLimitDefaultOptions: Required<CostLimitOptions> = {
2728
objectCost: 2,
2829
scalarCost: 1,
2930
depthCostFactor: 1.5,
31+
flattenFragments: false,
3032
fragmentRecursionCost: 1000,
3133
ignoreIntrospection: true,
3234
exposeLimits: true,
@@ -95,11 +97,16 @@ class CostLimitVisitor {
9597
if ('selectionSet' in node && node.selectionSet) {
9698
cost = this.config.objectCost;
9799
for (const child of node.selectionSet.selections) {
98-
cost += this.config.depthCostFactor * this.computeComplexity(child, depth + 1);
100+
if (
101+
this.config.flattenFragments &&
102+
(child.kind === Kind.INLINE_FRAGMENT || child.kind === Kind.FRAGMENT_SPREAD)
103+
) {
104+
cost += this.computeComplexity(child, depth);
105+
} else {
106+
cost += this.config.depthCostFactor * this.computeComplexity(child, depth + 1);
107+
}
99108
}
100-
}
101-
102-
if (node.kind === Kind.FRAGMENT_SPREAD) {
109+
} else if (node.kind === Kind.FRAGMENT_SPREAD) {
103110
if (this.visitedFragments.has(node.name.value)) {
104111
const visitCost = this.visitedFragments.get(node.name.value) ?? 0;
105112
return cost + this.config.depthCostFactor * visitCost;
@@ -108,14 +115,19 @@ class CostLimitVisitor {
108115
}
109116
const fragment = this.context.getFragment(node.name.value);
110117
if (fragment) {
111-
const additionalCost = this.computeComplexity(fragment, depth + 1);
118+
let fragmentCost;
119+
if (this.config.flattenFragments) {
120+
fragmentCost = this.computeComplexity(fragment, depth);
121+
cost += fragmentCost;
122+
} else {
123+
fragmentCost = this.computeComplexity(fragment, depth + 1);
124+
cost += this.config.depthCostFactor * fragmentCost;
125+
}
112126
if (this.visitedFragments.get(node.name.value) === -1) {
113-
this.visitedFragments.set(node.name.value, additionalCost);
127+
this.visitedFragments.set(node.name.value, fragmentCost);
114128
}
115-
cost += this.config.depthCostFactor * additionalCost;
116129
}
117130
}
118-
119131
return cost;
120132
}
121133
}

packages/plugins/cost-limit/test/index.spec.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,73 @@ describe('costLimitPlugin', () => {
141141
]);
142142
});
143143

144+
it('should flatten fragments', async () => {
145+
const maxCost = 57;
146+
const testkit = createTestkit(
147+
[
148+
costLimitPlugin({
149+
maxCost: maxCost,
150+
objectCost: 4,
151+
scalarCost: 2,
152+
depthCostFactor: 2,
153+
flattenFragments: true,
154+
ignoreIntrospection: true,
155+
}),
156+
],
157+
schema,
158+
);
159+
const result = await testkit.execute(`
160+
query {
161+
...BookFragment
162+
}
163+
164+
fragment BookFragment on Query {
165+
books {
166+
title
167+
author
168+
}
169+
}
170+
`);
171+
172+
assertSingleExecutionValue(result);
173+
expect(result.errors).toBeUndefined();
174+
});
175+
176+
it('should reject flattened fragments if the maxCost limit is exceeded', async () => {
177+
const maxCost = 5;
178+
const testkit = createTestkit(
179+
[
180+
costLimitPlugin({
181+
maxCost: maxCost,
182+
objectCost: 4,
183+
scalarCost: 2,
184+
depthCostFactor: 2,
185+
flattenFragments: true,
186+
ignoreIntrospection: true,
187+
}),
188+
],
189+
schema,
190+
);
191+
const result = await testkit.execute(`
192+
query {
193+
...BookFragment
194+
}
195+
196+
fragment BookFragment on Query {
197+
books {
198+
title
199+
author
200+
}
201+
}
202+
`);
203+
204+
assertSingleExecutionValue(result);
205+
expect(result.errors).toBeDefined();
206+
expect(result.errors?.map((error) => error.message)).toEqual([
207+
`Syntax Error: Query Cost limit of ${maxCost} exceeded, found 30.`,
208+
]);
209+
});
210+
144211
it('should not crash on recursive fragment', async () => {
145212
const testkit = createTestkit(
146213
[

services/docs/docs/plugins/cost-limit.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ GraphQLArmorConfig({
2929
// Factorial applied to nested operator | default: 1.5
3030
depthCostFactor?: int,
3131

32+
// Flatten frament spreads and inline framents for the cost calculation | default: false
33+
flattenFragments?: boolean,
34+
3235
// Ignore the cost of introspection queries | default: true
3336
ignoreIntrospection?: boolean,
3437

0 commit comments

Comments
 (0)