Skip to content

Commit 149a719

Browse files
authored
Merge pull request #101 from oslabs-beta/em/finalDeploy
Exposed complexity analysis functionality and updated readme
2 parents f177595 + 1265709 commit 149a719

File tree

7 files changed

+150
-61
lines changed

7 files changed

+150
-61
lines changed

.travis.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,19 @@ node_js:
88
branches:
99
only:
1010
- dev
11-
# - main
11+
- main
1212
# scripts to run for each test
1313
script:
1414
- echo "Running tests against $(node -v) ..."
1515
- 'npm run lint'
1616
- 'npm run test'
1717
- 'npm run build'
18-
# specify a job to run
18+
19+
# specify deployment
20+
before_deploy:
21+
- 'npm run build'
22+
- 'npm run build:fix'
23+
1924
deploy:
2025
on:
2126
branch: main

README.md

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
</div>
88

99
&nbsp;
10+
## Summary
11+
12+
Developed under tech-accelerator [OSLabs](https://opensourcelabs.io/), GraphQLGate strives for a principled approach to complexity analysis and rate-limiting for GraphQL queries by accurately estimating an upper-bound of the response size of the query. Within a loosely opinionated framework with lots of configuration options, you can reliably throttle GraphQL queries by complexity and depth to protect your GraphQL API. Our solution is inspired by [this paper](https://github.com/Alan-Cha/fse20/blob/master/submissions/functional/FSE-24/graphql-paper.pdf) from IBM research teams.
1013

1114
## Table of Contents
1215

@@ -16,6 +19,7 @@
1619
- [How It Works](#how-it-works)
1720
- [Response](#response)
1821
- [Error Handling](#error-handling)
22+
- [Internals](#internals)
1923
- [Future Development](#future-development)
2024
- [Contributions](#contributions)
2125
- [Developers](#developers)
@@ -176,7 +180,7 @@ query {
176180

177181
```javascript
178182
{
179-
graphglGate: {
183+
graphqlGate: {
180184
success: boolean, // true when successful
181185
tokens: number, // tokens available after request
182186
compexity: number, // complexity of the query
@@ -191,6 +195,85 @@ query {
191195
- Incoming queries are validated against the GraphQL schema. If the query is invalid, a response with status code `400` is returned along with an array of GraphQL Errors that were found.
192196
- To avoid disrupting server activity, errors thrown during the analysis and rate-limiting of the query are logged and the request is passed onto the next piece of middleware in the chain.
193197

198+
## <a name="internals"></a> Internals
199+
200+
This package exposes 3 additional functionalities which comprise the internals of the package. This is a breif documentaion on them.
201+
202+
### Complexity Analysis
203+
204+
1. #### `typeWeightsFromSchema` | function to create the type weight object from the schema for complexity analysis
205+
206+
- `schema: GraphQLSchema` | GraphQL schema object
207+
- `typeWeightsConfig: TypeWeightConfig = defaultTypeWeightsConfig` | type weight configuration
208+
- `enforceBoundedLists = false`
209+
- returns: `TypeWeightObject`
210+
- usage:
211+
212+
```ts
213+
import { typeWeightsFromSchema } from 'graphql-limiter';
214+
import { GraphQLSchema } from 'graphql/type/schema';
215+
import { buildSchema } from 'graphql';
216+
217+
let schema: GraphQLSchema = buildSchema(`...`);
218+
219+
const typeWeights: TypeWeightObject = typeWeightsFromSchema(schema);
220+
```
221+
222+
2. #### `ComplexityAnalysis` | class to calculate the complexity of the query based on the type weights and variables
223+
224+
- `typeWeights: TypeWeightObject`
225+
- `variables: Variables` | variables on request
226+
- returns a class with method:
227+
228+
- `processQuery(queryAST: DocumentNode): number`
229+
- returns: complexity of the query and exposes `maxDepth` property for depth limiting
230+
231+
```ts
232+
import { typeWeightsFromSchema } from 'graphql-limiter';
233+
import { parse, validate } from 'graphql';
234+
235+
let queryAST: DocumentNode = parse(`...`);
236+
237+
const queryParser: ASTParser = new ComplexityAnalysis(typeWeights, variables);
238+
239+
// query must be validatied against the schema before processing the query
240+
const validationErrors = validate(schema, queryAST);
241+
242+
const complexity: number = queryParser.processQuery(queryAST);
243+
```
244+
245+
### Rate-limiting
246+
247+
3. #### `rateLimiter` | returns a rate limiting class instance based on selections
248+
249+
- `rateLimiter: RateLimiterConfig` | see "configuration" -> rateLimiter
250+
- `client: Redis` | an ioredis client
251+
- `keyExpiry: number` | time (ms) for key to persist in cache
252+
- returns a rate limiter class with method:
253+
254+
- `processRequest(uuid: string, timestamp: number, tokens = 1): Promise<RateLimiterResponse>`
255+
- returns: `{ success: boolean, tokens: number, retryAfter?: number }` | where `tokens` is tokens available, `retryAfter` is time to wait in seconds before the request would be successful and `success` is false if the request is blocked
256+
257+
```ts
258+
import { rateLimiter } from 'graphql-limiter';
259+
260+
const limiter: RateLimiter = rateLimiter(
261+
{
262+
type: 'TOKEN_BUCKET',
263+
refillRate: 1,
264+
capacity: 10,
265+
},
266+
redisClient,
267+
86400000 // 24 hours
268+
);
269+
270+
const response: RateLimiterResponse = limiter.processRequest(
271+
'user-1',
272+
new Date().valueOf(),
273+
5
274+
);
275+
```
276+
194277
## <a name="future-development"></a> Future Development
195278

196279
- Ability to use this package with other caching technologies or libraries

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "graphql-limiter",
3-
"version": "1.1.0",
3+
"version": "1.3.0",
44
"description": "A GraphQL rate limiting library using query complexity analysis.",
55
"main": "./dist/src/index.js",
66
"types": "./dist/src/index.d.ts",

src/analysis/ASTParser.ts

Lines changed: 38 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
Kind,
77
DirectiveNode,
88
SelectionNode,
9-
getArgumentValues,
109
} from 'graphql';
1110
import { FieldWeight, TypeWeightObject, Variables } from '../@types/buildTypeWeights';
1211
/**
@@ -19,26 +18,29 @@ import { FieldWeight, TypeWeightObject, Variables } from '../@types/buildTypeWei
1918
* |
2019
* Definiton Node
2120
* (operation and fragment definitons)
22-
* / \
23-
* |-----> Selection Set Node not done
21+
* / |
22+
* |-----> Selection Set Node <-------|
2423
* | /
2524
* | Selection Node
26-
* | (Field, Inline fragment and fragment spread)
27-
* | | \ \
28-
* |--Field Node not done not done
29-
*
25+
* | (Field, Inline fragment and fragment spread)
26+
* | | | \
27+
* | Field Node | fragmentCache
28+
* | | |
29+
* |---calculateCast |
30+
* | |
31+
* |-------------------|
3032
*/
3133

3234
class ASTParser {
33-
typeWeights: TypeWeightObject;
35+
private typeWeights: TypeWeightObject;
3436

35-
depth: number;
37+
private depth: number;
3638

37-
maxDepth: number;
39+
public maxDepth: number;
3840

39-
variables: Variables;
41+
private variables: Variables;
4042

41-
fragmentCache: { [index: string]: { complexity: number; depth: number } };
43+
private fragmentCache: { [index: string]: { complexity: number; depth: number } };
4244

4345
constructor(typeWeights: TypeWeightObject, variables: Variables) {
4446
this.typeWeights = typeWeights;
@@ -59,7 +61,6 @@ class ASTParser {
5961
let selectionsCost = 0;
6062
let calculatedWeight = 0;
6163

62-
// call the function to handle selection set node with selectionSet property if it is not undefined
6364
if (node.selectionSet) {
6465
selectionsCost += this.selectionSetNode(node.selectionSet, typeName);
6566
}
@@ -80,22 +81,26 @@ class ASTParser {
8081
private fieldNode(node: FieldNode, parentName: string): number {
8182
try {
8283
let complexity = 0;
84+
// the node must have a parent in typeweights or the analysis will fail. this should never happen
8385
const parentType = this.typeWeights[parentName];
8486
if (!parentType) {
8587
throw new Error(
8688
`ERROR: ASTParser Failed to obtain parentType for parent: ${parentName} and node: ${node.name.value}`
8789
);
8890
}
91+
8992
let typeName: string | undefined;
9093
let typeWeight: FieldWeight | undefined;
91-
if (node.name.value === '__typename') return complexity;
94+
95+
if (node.name.value === '__typename') return complexity; // this will be zero, ie. this field has no complexity
96+
9297
if (node.name.value in this.typeWeights) {
93-
// node is an object type n the typeWeight root
98+
// node is an object type in the typeWeight root
9499
typeName = node.name.value;
95100
typeWeight = this.typeWeights[typeName].weight;
96101
complexity += this.calculateCost(node, parentName, typeName, typeWeight);
97102
} else if (parentType.fields[node.name.value].resolveTo) {
98-
// field resolves to another type in type weights or a list
103+
// node is a field on a typeWeight root, field resolves to another type in type weights or a list
99104
typeName = parentType.fields[node.name.value].resolveTo;
100105
typeWeight = parentType.fields[node.name.value].weight;
101106
// if this is a list typeWeight is a weight function
@@ -147,7 +152,8 @@ class ASTParser {
147152
* 2. there is a directive named inlcude and the value is true
148153
* 3. there is a directive named skip and the value is false
149154
*/
150-
directiveCheck(directive: DirectiveNode): boolean {
155+
// THIS IS NOT CALLED ANYWEHERE. IN PROGRESS
156+
private directiveCheck(directive: DirectiveNode): boolean {
151157
if (directive?.arguments) {
152158
// get the first argument
153159
const argument = directive.arguments[0];
@@ -172,8 +178,9 @@ class ASTParser {
172178

173179
private selectionNode(node: SelectionNode, parentName: string): number {
174180
let complexity = 0;
181+
// TODO: complete implementation of directives include and skip
175182
/**
176-
* process this node if:
183+
* process this node only if:
177184
* 1. there is no directive
178185
* 2. there is a directive named inlcude and the value is true
179186
* 3. there is a directive named skip and the value is false
@@ -182,9 +189,8 @@ class ASTParser {
182189
// if (directive && this.directiveCheck(directive[0])) {
183190
this.depth += 1;
184191
if (this.depth > this.maxDepth) this.maxDepth = this.depth;
185-
// check the kind property against the set of selection nodes that are possible
192+
// the kind of a field node will either be field, fragment spread or inline fragment
186193
if (node.kind === Kind.FIELD) {
187-
// call the function that handle field nodes
188194
complexity += this.fieldNode(node, parentName.toLowerCase());
189195
} else if (node.kind === Kind.FRAGMENT_SPREAD) {
190196
// add complexity and depth from fragment cache
@@ -214,25 +220,23 @@ class ASTParser {
214220
}
215221

216222
this.depth -= 1;
217-
// }
223+
//* }
218224
return complexity;
219225
}
220226

221227
private selectionSetNode(node: SelectionSetNode, parentName: string): number {
222228
let complexity = 0;
223229
let maxFragmentComplexity = 0;
224-
// iterate shrough the 'selections' array on the seletion set node
225230
for (let i = 0; i < node.selections.length; i += 1) {
226-
// call the function to handle seletion nodes
227231
// pass the current parent through because selection sets act only as intermediaries
228232
const selectionNode = node.selections[i];
229-
const selectionCost = this.selectionNode(node.selections[i], parentName);
233+
const selectionCost = this.selectionNode(selectionNode, parentName);
230234

231235
// we need to get the largest possible complexity so we save the largest inline fragment
232-
// FIXME: Consider the case where 2 typed fragments are applicable
233236
// e.g. ...UnionType and ...PartofTheUnion
234237
// this case these complexities should be summed in order to be accurate
235238
// However an estimation suffice
239+
// FIXME: Consider the case where 2 typed fragments are applicable
236240
if (selectionNode.kind === Kind.INLINE_FRAGMENT) {
237241
if (!selectionNode.typeCondition) {
238242
// complexity is always applicable
@@ -248,22 +252,17 @@ class ASTParser {
248252

249253
private definitionNode(node: DefinitionNode): number {
250254
let complexity = 0;
251-
// check the kind property against the set of definiton nodes that are possible
255+
// Operation definition is either query, mutation or subscripiton
252256
if (node.kind === Kind.OPERATION_DEFINITION) {
253-
// check if the operation is in the type weights object.
254257
if (node.operation.toLocaleLowerCase() in this.typeWeights) {
255-
// if it is, it is an object type, add it's type weight to the total
256258
complexity += this.typeWeights[node.operation].weight;
257-
// console.log(`the weight of ${node.operation} is ${complexity}`);
258-
// call the function to handle selection set node with selectionSet property if it is not undefined
259259
if (node.selectionSet) {
260260
complexity += this.selectionSetNode(node.selectionSet, node.operation);
261261
}
262262
}
263263
} else if (node.kind === Kind.FRAGMENT_DEFINITION) {
264264
// Fragments can only be defined on the root type.
265-
// Parse the complexity of this fragment once and store it for use when analyzing other
266-
// nodes. The complexity of a fragment can be added to the selection cost for the query.
265+
// Parse the complexity of this fragment once and store it for use when analyzing other nodes
267266
const namedType = node.typeCondition.name.value;
268267
// Duplicate fragment names are not allowed by the GraphQL spec and an error is thrown if used.
269268
const fragmentName = node.name.value;
@@ -276,10 +275,12 @@ class ASTParser {
276275
// Don't count fragment complexity in the node's complexity. Only when fragment is used.
277276
this.fragmentCache[fragmentName] = {
278277
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
278+
depth: this.maxDepth - 1, // subtract one from the calculated depth of the fragment to correct for the additional depth the fragment adds to the query when used
280279
};
281-
} // else {
282-
// // TODO: Verify that are no other type definition nodes that need to be handled (see ast.d.ts in 'graphql')
280+
}
281+
// TODO: Verify that there are no other type definition nodes that need to be handled (see ast.d.ts in 'graphql')
282+
// else {
283+
//
283284
// // Other types include TypeSystemDefinitionNode (Schema, Type, Directvie) and
284285
// // TypeSystemExtensionNode(Schema, Type);
285286
// throw new Error(`ERROR: ASTParser.definitionNode: ${node.kind} type not supported`);
@@ -289,19 +290,18 @@ class ASTParser {
289290

290291
private documentNode(node: DocumentNode): number {
291292
let complexity = 0;
292-
// sort the definitions array by kind so that fragments are always parsed first.
293+
// Sort the definitions array by kind so that fragments are always parsed first.
293294
// Fragments must be parsed first so that their complexity is available to other nodes.
294295
const sortedDefinitions = [...node.definitions].sort((a, b) =>
295296
a.kind.localeCompare(b.kind)
296297
);
297298
for (let i = 0; i < sortedDefinitions.length; i += 1) {
298-
// call the function to handle the various types of definition nodes
299299
complexity += this.definitionNode(sortedDefinitions[i]);
300300
}
301301
return complexity;
302302
}
303303

304-
processQuery(queryAST: DocumentNode): number {
304+
public processQuery(queryAST: DocumentNode): number {
305305
return this.documentNode(queryAST);
306306
}
307307
}

0 commit comments

Comments
 (0)