Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 6b1662b

Browse files
Merge pull request #79 from oslabs-beta/em/middleware-setup
Refactored how the middleware is configured, added depth calculation and throttling and added Rertry-After header on blocked requests, as well as error handling
2 parents e9dd058 + 5bb8195 commit 6b1662b

22 files changed

+1579
-789
lines changed

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
Install the package
2727

2828
```
29-
npm i graphqlgate
29+
npm i graphql-limiter
3030
```
3131

3232
Import the package and add the rate-limiting middleware to the Express middleware chain before the GraphQL server.
@@ -35,7 +35,7 @@ NOTE: a Redis server instance will need to be started in order for the limiter t
3535

3636
```javascript
3737
// import package
38-
import expressGraphQLRateLimiter from 'graphqlgate';
38+
import expressGraphQLRateLimiter from 'graphql-limiter';
3939

4040
/**
4141
* Import other dependencies

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
2-
"name": "graphqlgate",
2+
"name": "graphql-limiter",
33
"version": "1.0.0",
44
"description": "A GraphQL rate limiting library using query complexity analysis.",
5-
"main": "index.js",
5+
"main": "src/middleware/index.ts",
66
"type": "module",
77
"files": ["src"],
88
"scripts": {

‎src/@types/expressMiddleware.d.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { RedisOptions } from 'ioredis';
2+
import { TypeWeightConfig, TypeWeightSet } from './buildTypeWeights';
3+
import { RateLimiterConfig } from './rateLimit';
4+
5+
// extend ioredis configuration options to include an expiry prooperty for rate limiting cache
6+
interface RedisConfig {
7+
keyExpiry?: number;
8+
options?: RedisOptions;
9+
}
10+
// extend the redis config type to have keyExpiry set once configured in the middleware
11+
interface RedisConfigSet extends RedisConfig {
12+
keyExpiry: number;
13+
options: RedisOptions;
14+
}
15+
16+
export interface ExpressMiddlewareConfig {
17+
rateLimiter: RateLimiterConfig;
18+
redis?: RedisConfig;
19+
typeWeights?: TypeWeightConfig;
20+
dark?: boolean;
21+
enforceBoundedLists?: boolean;
22+
depthLimit?: number;
23+
}
24+
25+
export interface ExpressMiddlewareSet extends ExpressMiddlewareConfig {
26+
redis: RedisConfigSet;
27+
typeWeights: TypeWeightSet;
28+
dark: boolean;
29+
enforceBoundedLists: boolean;
30+
depthLimit: number;
31+
}

‎src/@types/rateLimit.d.ts‎

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,32 +34,20 @@ export interface RedisWindow extends FixedWindow {
3434

3535
export type RedisLog = RedisBucket[];
3636

37-
export type RateLimiterSelection =
38-
| 'TOKEN_BUCKET'
39-
| 'LEAKY_BUCKET'
40-
| 'FIXED_WINDOW'
41-
| 'SLIDING_WINDOW_LOG'
42-
| 'SLIDING_WINDOW_COUNTER';
37+
type BucketType = 'TOKEN_BUCKET' | 'LEAKY_BUCKET';
4338

44-
/**
45-
* @type {number} bucketSize - Size of the token bucket
46-
* @type {number} refillRate - Rate at which tokens are added to the bucket in seconds
47-
*/
48-
export interface TokenBucketOptions {
49-
bucketSize: number;
39+
type WindowType = 'FIXED_WINDOW' | 'SLIDING_WINDOW_LOG' | 'SLIDING_WINDOW_COUNTER';
40+
41+
type BucketRateLimiter = {
42+
type: BucketType;
5043
refillRate: number;
51-
}
44+
capacity: number;
45+
};
5246

53-
/**
54-
* @type {number} windowSize - size of the window in milliseconds
55-
* @type {number} capacity - max number of tokens that can be used in the bucket
56-
*/
57-
export interface WindowOptions {
47+
type WindowRateLimiter = {
48+
type: WindowType;
5849
windowSize: number;
5950
capacity: number;
60-
}
51+
};
6152

62-
// TODO: This will be a union type where we can specify Option types for other Rate Limiters
63-
// Record<string, never> represents the empty object for algorithms that don't require settings
64-
// and might be able to be removed in the future.
65-
export type RateLimiterOptions = TokenBucketOptions | Record<string, never>;
53+
export type RateLimiterConfig = WindowRateLimiter | BucketRateLimiter;

‎src/analysis/ASTParser.ts‎

Lines changed: 85 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import {
44
SelectionSetNode,
55
DefinitionNode,
66
Kind,
7+
DirectiveNode,
78
SelectionNode,
9+
getArgumentValues,
810
} from 'graphql';
911
import { FieldWeight, TypeWeightObject, Variables } from '../@types/buildTypeWeights';
1012
/**
@@ -30,14 +32,20 @@ import { FieldWeight, TypeWeightObject, Variables } from '../@types/buildTypeWei
3032
class ASTParser {
3133
typeWeights: TypeWeightObject;
3234

35+
depth: number;
36+
37+
maxDepth: number;
38+
3339
variables: Variables;
3440

35-
fragmentCache: { [index: string]: number };
41+
fragmentCache: { [index: string]: {complexity: number;depth: number} };
3642

3743
constructor(typeWeights: TypeWeightObject, variables: Variables) {
3844
this.typeWeights = typeWeights;
3945
this.variables = variables;
4046
this.fragmentCache = {};
47+
this.depth = 0;
48+
this.maxDepth = 0;
4149
}
4250

4351
private calculateCost(
@@ -59,6 +67,8 @@ class ASTParser {
5967
if (node.arguments && typeof typeWeight === 'function') {
6068
// FIXME: May never happen but what if weight is a function and arguments don't exist
6169
calculatedWeight += typeWeight([...node.arguments], this.variables, selectionsCost);
70+
} else if (typeof typeWeight === 'number') {
71+
calculatedWeight += typeWeight + selectionsCost;
6272
} else {
6373
calculatedWeight += this.typeWeights[typeName].weight + selectionsCost;
6474
}
@@ -67,7 +77,7 @@ class ASTParser {
6777
return complexity;
6878
}
6979

70-
fieldNode(node: FieldNode, parentName: string): number {
80+
privatefieldNode(node: FieldNode, parentName: string): number {
7181
try {
7282
let complexity = 0;
7383
const parentType = this.typeWeights[parentName];
@@ -78,7 +88,7 @@ class ASTParser {
7888
}
7989
let typeName: string | undefined;
8090
let typeWeight: FieldWeight | undefined;
81-
91+
if(node.name.value==='__typename')returncomplexity;
8292
if (node.name.value in this.typeWeights) {
8393
// node is an object type n the typeWeight root
8494
typeName = node.name.value;
@@ -131,14 +141,60 @@ class ASTParser {
131141
}
132142
}
133143

134-
selectionNode(node: SelectionNode, parentName: string): number {
144+
/**
145+
* Return true if:
146+
* 1. there is no directive
147+
* 2. there is a directive named inlcude and the value is true
148+
* 3. there is a directive named skip and the value is false
149+
*/
150+
directiveCheck(directive: DirectiveNode): boolean {
151+
if (directive?.arguments) {
152+
// get the first argument
153+
const argument = directive.arguments[0];
154+
// ensure the argument name is 'if'
155+
const argumentHasVariables =
156+
argument.value.kind === Kind.VARIABLE && argument.name.value === 'if';
157+
// access the value of the argument depending on whether it is passed as a variable or not
158+
let directiveArgumentValue;
159+
if (argument.value.kind === Kind.BOOLEAN) {
160+
directiveArgumentValue = Boolean(argument.value.value);
161+
} else if (argumentHasVariables) {
162+
directiveArgumentValue = Boolean(this.variables[argument.value.name.value]);
163+
}
164+
165+
return (
166+
(directive.name.value === 'include' && directiveArgumentValue === true) ||
167+
(directive.name.value === 'skip' && directiveArgumentValue === false)
168+
);
169+
}
170+
return true;
171+
}
172+
173+
private selectionNode(node: SelectionNode, parentName: string): number {
135174
let complexity = 0;
175+
/**
176+
* process this node if:
177+
* 1. there is no directive
178+
* 2. there is a directive named inlcude and the value is true
179+
* 3. there is a directive named skip and the value is false
180+
*/
181+
// const directive = node.directives;
182+
// if (directive && this.directiveCheck(directive[0])) {
183+
this.depth += 1;
184+
if (this.depth > this.maxDepth) this.maxDepth = this.depth;
136185
// check the kind property against the set of selection nodes that are possible
137186
if (node.kind === Kind.FIELD) {
138187
// call the function that handle field nodes
139188
complexity += this.fieldNode(node, parentName.toLowerCase());
140189
} else if (node.kind === Kind.FRAGMENT_SPREAD) {
141-
complexity += this.fragmentCache[node.name.value];
190+
// add complexity and depth from fragment cache
191+
const { complexity: fragComplexity, depth: fragDepth } =
192+
this.fragmentCache[node.name.value];
193+
complexity += fragComplexity;
194+
this.depth += fragDepth;
195+
if (this.depth > this.maxDepth) this.maxDepth = this.depth;
196+
this.depth -= fragDepth;
197+
142198
// This is a leaf
143199
// need to parse fragment definition at root and get the result here
144200
} else if (node.kind === Kind.INLINE_FRAGMENT) {
@@ -148,16 +204,21 @@ class ASTParser {
148204
// If the TypeCondition is omitted, an inline fragment is considered to be of the same type as the enclosing context
149205
const namedType = typeCondition ? typeCondition.name.value.toLowerCase() : parentName;
150206

151-
// TODO: Handle directives like @include
207+
// TODO: Handle directives like @include and @skip
208+
// subtract 1 before, and add one after, entering the fragment selection to negate the additional level of depth added
209+
this.depth -= 1;
152210
complexity += this.selectionSetNode(node.selectionSet, namedType);
211+
this.depth += 1;
153212
} else {
154-
// FIXME: Consider removing this check. SelectionNodes cannot have any other kind in the current spec.
155213
throw new Error(`ERROR: ASTParser.selectionNode: node type not supported`);
156214
}
215+
216+
this.depth -= 1;
217+
// }
157218
return complexity;
158219
}
159220

160-
selectionSetNode(node: SelectionSetNode, parentName: string): number {
221+
privateselectionSetNode(node: SelectionSetNode, parentName: string): number {
161222
let complexity = 0;
162223
let maxFragmentComplexity = 0;
163224
// iterate shrough the 'selections' array on the seletion set node
@@ -185,7 +246,7 @@ class ASTParser {
185246
return complexity + maxFragmentComplexity;
186247
}
187248

188-
definitionNode(node: DefinitionNode): number {
249+
privatedefinitionNode(node: DefinitionNode): number {
189250
let complexity = 0;
190251
// check the kind property against the set of definiton nodes that are possible
191252
if (node.kind === Kind.OPERATION_DEFINITION) {
@@ -207,25 +268,26 @@ class ASTParser {
207268
// Duplicate fragment names are not allowed by the GraphQL spec and an error is thrown if used.
208269
const fragmentName = node.name.value;
209270

210-
if (this.fragmentCache[fragmentName]) return this.fragmentCache[fragmentName];
211-
212271
const fragmentComplexity = this.selectionSetNode(
213272
node.selectionSet,
214273
namedType.toLowerCase()
215274
);
216275

217276
// Don't count fragment complexity in the node's complexity. Only when fragment is used.
218-
this.fragmentCache[fragmentName] = fragmentComplexity;
219-
} else {
220-
// TODO: Verify that are no other type definition nodes that need to be handled (see ast.d.ts in 'graphql')
221-
// Other types include TypeSystemDefinitionNode (Schema, Type, Directvie) and
222-
// TypeSystemExtensionNode(Schema, Type);
223-
throw new Error(`ERROR: ASTParser.definitionNode: ${node.kind} type not supported`);
224-
}
277+
this.fragmentCache[fragmentName] = {
278+
complexity: fragmentComplexity,
279+
depth: this.maxDepth - 1, // subtract one from the calculated depth of the fragment to correct for the additional depth the fragment ads to the query when used
280+
};
281+
} // else {
282+
// // TODO: Verify that are no other type definition nodes that need to be handled (see ast.d.ts in 'graphql')
283+
// // Other types include TypeSystemDefinitionNode (Schema, Type, Directvie) and
284+
// // TypeSystemExtensionNode(Schema, Type);
285+
// throw new Error(`ERROR: ASTParser.definitionNode: ${node.kind} type not supported`);
286+
// }
225287
return complexity;
226288
}
227289

228-
documentNode(node: DocumentNode): number {
290+
privatedocumentNode(node: DocumentNode): number {
229291
let complexity = 0;
230292
// sort the definitions array by kind so that fragments are always parsed first.
231293
// Fragments must be parsed first so that their complexity is available to other nodes.
@@ -238,6 +300,10 @@ class ASTParser {
238300
}
239301
return complexity;
240302
}
303+
304+
processQuery(queryAST: DocumentNode): number {
305+
return this.documentNode(queryAST);
306+
}
241307
}
242308

243309
export default ASTParser;

0 commit comments

Comments
(0)

AltStyle によって変換されたページ (->オリジナル) /