Skip to content

Commit 1229cd0

Browse files
authored
Merge pull request #99 from oslabs-beta/dev
Deploy v1.0.0 of graphqlgate
2 parents 19ac884 + 6b1662b commit 1229cd0

23 files changed

+1534
-587
lines changed

README.md

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

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

32-
Import the package and add the rate-limiting middlleware to the Express middleware chain before the GraphQL server.
32+
Import the package and add the rate-limiting middleware to the Express middleware chain before the GraphQL server.
3333

3434
NOTE: a Redis server instance will need to be started in order for the limiter to cache data.
3535

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

4040
/**
4141
* Import other dependencies

package-lock.json

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 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": {
@@ -64,6 +64,7 @@
6464
"*.{js,ts,css,md}": "prettier --write --ignore-unknown"
6565
},
6666
"dependencies": {
67+
"@graphql-tools/utils": "^8.8.0",
6768
"graphql": "^16.5.0",
6869
"ioredis": "^5.0.5"
6970
}

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+
private fieldNode(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') return complexity;
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+
private selectionSetNode(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+
private definitionNode(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+
private documentNode(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)