Skip to content

Commit 39473b5

Browse files
committed
Merge branch 'dev' into sh/build-type-weights
2 parents c3f5e1e + 3cfbef4 commit 39473b5

File tree

7 files changed

+158
-44
lines changed

7 files changed

+158
-44
lines changed

package-lock.json

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

src/@types/rateLimit.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ interface RateLimiter {
1515

1616
interface RateLimiterResponse {
1717
success: boolean;
18-
tokens?: number;
18+
tokens: number;
1919
}
2020

2121
interface RedisBucket {

src/analysis/typeComplexityAnalysis.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { parse } from 'graphql';
1+
import { DocumentNode } from 'graphql';
22

33
/**
44
* This function should
@@ -13,9 +13,15 @@ import { parse } from 'graphql';
1313
*
1414
* @param {string} queryString
1515
* @param {TypeWeightObject} typeWeights
16+
* @param {any | undefined} varibales
1617
* @param {string} complexityOption
1718
*/
18-
function getQueryTypeComplexity(queryString: string, typeWeights: TypeWeightObject): number {
19+
// TODO add queryVaribables parameter
20+
function getQueryTypeComplexity(
21+
queryString: DocumentNode,
22+
varibales: any | undefined,
23+
typeWeights: TypeWeightObject
24+
): number {
1925
throw Error('getQueryComplexity is not implemented.');
2026
}
2127

src/middleware/index.ts

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import Redis, { RedisOptions } from 'ioredis';
2-
import { Request, Response, NextFunction, RequestHandler } from 'express';
2+
import { parse, validate } from 'graphql';
33
import { GraphQLSchema } from 'graphql/type/schema';
4-
import { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights';
4+
import { Request, Response, NextFunction, RequestHandler } from 'express';
5+
6+
import buildTypeWeightsFromSchema, { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights';
7+
import setupRateLimiter from './rateLimiterSetup';
8+
import getQueryTypeComplexity from '../analysis/typeComplexityAnalysis';
59

610
// FIXME: Will the developer be responsible for first parsing the schema from a file?
711
// Can consider accepting a string representing a the filepath to a schema
@@ -12,7 +16,7 @@ import { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights';
1216
* @param {RateLimiterSelection} rateLimiter Specify rate limiting algorithm to be used
1317
* @param {RateLimiterOptions} options Specify the appropriate options for the selected rateLimiter
1418
* @param {GraphQLSchema} schema GraphQLSchema object
15-
* @param {RedisClientOptions} redisClientOptions valid node-redis connection options. See https://github.com/redis/node-redis/blob/HEAD/docs/client-configuration.md
19+
* @param {RedisClientOptions} RedisOptions ioredis connection options https://ioredis.readthedocs.io/en/stable/API/#new_Redis
1620
* @param {TypeWeightConfig} typeWeightConfig Optional type weight configuration for the GraphQL Schema.
1721
* Defaults to {mutation: 10, object: 1, field: 0, connection: 2}
1822
* @returns {RequestHandler} express middleware that computes the complexity of req.query and calls the next middleware
@@ -21,23 +25,76 @@ import { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights';
2125
* @throws ValidationError if GraphQL Schema is invalid.
2226
*/
2327
export function expressRateLimiter(
24-
rateLimiter: RateLimiterSelection,
28+
rateLimiterAlgo: RateLimiterSelection,
2529
rateLimiterOptions: RateLimiterOptions,
2630
schema: GraphQLSchema,
2731
redisClientOptions: RedisOptions,
2832
typeWeightConfig: TypeWeightConfig = defaultTypeWeightsConfig
2933
): RequestHandler {
30-
// TODO: Set 'timestamp' on res.locals to record when the request is received in UNIX format. HTTP does not inlude this.
31-
// TODO: Parse the schema to create a TypeWeightObject. Throw ValidationError if schema is invalid
32-
// TODO: Connect to Redis store using provided options. Default to localhost:6379
33-
// TODO: Configure the selected RateLimtier
34-
// TODO: Configure the complexity analysis algorithm to run for incoming requests
35-
const middleware: RequestHandler = (req: Request, res: Response, next: NextFunction) => {
36-
// TODO: Parse query from req.query, compute complexity and pass necessary info to rate limiter
37-
// TODO: Call next if query is successful, send 429 status if query blocked, call next(err) with any thrown errors
38-
next(Error('Express rate limiting middleware not implemented'));
34+
/**
35+
* build the type weight object, create the redis client and instantiate the ratelimiter
36+
* before returning the express middleware that calculates query complexity and throttles the requests
37+
*/
38+
// TODO: Throw ValidationError if schema is invalid
39+
const typeWeightObject = buildTypeWeightsFromSchema(schema, typeWeightConfig);
40+
// TODO: Throw error if connection is unsuccessful
41+
const redisClient = new Redis(redisClientOptions); // Default port is 6379 automatically
42+
const rateLimiter = setupRateLimiter(rateLimiterAlgo, rateLimiterOptions, redisClient);
43+
44+
// return the rate limiting middleware
45+
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
46+
const requestTimestamp = new Date().valueOf();
47+
const { query, variables }: { query: string; variables: any } = req.body;
48+
if (!query) {
49+
// FIXME: Throw an error here? Code currently passes this on to whatever is next
50+
console.log('There is no query on the request');
51+
return next();
52+
}
53+
54+
/**
55+
* There are numorous ways to get the ip address off of the request object.
56+
* - the header 'x-forward-for' will hold the originating ip address if a proxy is placed infront of the server. This would be commen for a production build.
57+
* - req.ips wwill hold an array of ip addresses in'x-forward-for' header. client is likely at index zero
58+
* - req.ip will have the ip address
59+
* - req.socket.remoteAddress is an insatnce of net.socket which is used as another method of getting the ip address
60+
*
61+
* req.ip and req.ips will worx in express but not with other frameworks
62+
*/
63+
// check for a proxied ip address before using the ip address on request
64+
const ip: string = req.ips[0] || req.ip;
65+
66+
// FIXME: this will only work with type complexity
67+
const queryAST = parse(query);
68+
// validate the query against the schema. The GraphQL validation function returns an array of errors.
69+
const validationErrors = validate(schema, queryAST);
70+
// check if the length of the returned GraphQL Errors array is greater than zero. If it is, there were errors. Call next so that the GraphQL server can handle those.
71+
if (validationErrors.length > 0) return next();
72+
73+
const queryComplexity = getQueryTypeComplexity(queryAST, variables, typeWeightObject);
74+
75+
try {
76+
// process the request and conditinoally respond to client with status code 429 o
77+
// r pass the request onto the next middleware function
78+
const rateLimiterResponse = await rateLimiter.processRequest(
79+
ip,
80+
requestTimestamp,
81+
queryComplexity
82+
);
83+
if (rateLimiterResponse.success === false) {
84+
// TODO: add a header 'Retry-After' with the time to wait untill next query will succeed
85+
// FIXME: send information about query complexity, tokens, etc, to the client on rejected query
86+
res.status(429).send();
87+
}
88+
res.locals.graphqlGate = {
89+
timestamp: requestTimestamp,
90+
complexity: queryComplexity,
91+
tokens: rateLimiterResponse.tokens,
92+
};
93+
return next();
94+
} catch (err) {
95+
return next(err);
96+
}
3997
};
40-
return middleware;
4198
}
4299

43100
export default expressRateLimiter;

src/middleware/rateLimiterSetup.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import Redis from 'ioredis';
2+
import TokenBucket from '../rateLimiters/tokenBucket';
3+
4+
/**
5+
* Instatieate the rateLimiting algorithm class based on the developer selection and options
6+
*
7+
* @export
8+
* @param {RateLimiterSelection} selection
9+
* @param {RateLimiterOptions} options
10+
* @param {Redis} client
11+
* @return {*}
12+
*/
13+
export default function setupRateLimiter(
14+
selection: RateLimiterSelection,
15+
options: RateLimiterOptions,
16+
client: Redis
17+
) {
18+
switch (selection) {
19+
case 'TOKEN_BUCKET':
20+
// todo validate options
21+
return new TokenBucket(options.bucketSize, options.refillRate, client);
22+
break;
23+
case 'LEAKY_BUCKET':
24+
throw new Error('Leaky Bucket algonithm has not be implemented.');
25+
break;
26+
case 'FIXED_WINDOW':
27+
throw new Error('Fixed Window algonithm has not be implemented.');
28+
break;
29+
case 'SLIDING_WINDOW_LOG':
30+
throw new Error('Sliding Window Log has not be implemented.');
31+
break;
32+
case 'SLIDING_WINDOW_COUNTER':
33+
throw new Error('Sliding Window Counter algonithm has not be implemented.');
34+
break;
35+
default:
36+
// typescript should never let us invoke this function with anything other than the options above
37+
throw new Error('Selected rate limiting algorithm is not suppported');
38+
break;
39+
}
40+
}

test/analysis/typeComplexityAnalysis.test.ts

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { ArgumentNode, IntValueNode, ValueNode } from 'graphql/language';
2-
import { KEYWORDS } from '../../src/analysis/buildTypeWeights';
1+
import { ArgumentNode } from 'graphql/language';
2+
import { parse } from 'graphql';
33
import getQueryTypeComplexity from '../../src/analysis/typeComplexityAnalysis';
44

55
/**
@@ -170,45 +170,47 @@ const typeWeights: TypeWeightObject = {
170170

171171
xdescribe('Test getQueryTypeComplexity function', () => {
172172
let query = '';
173+
let variables: any | undefined;
173174
describe('Calculates the correct type complexity for queries', () => {
174175
test('with one feild', () => {
175176
query = `Query { scalars { num } }`;
176-
expect(getQueryTypeComplexity(query, typeWeights)).toBe(2); // Query 1 + Scalars 1
177+
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(2); // Query 1 + Scalars 1
177178
});
178179

179180
test('with two or more fields', () => {
180181
query = `Query { scalars { num } test { name } }`;
181-
expect(getQueryTypeComplexity(query, typeWeights)).toBe(3); // Query 1 + scalars 1 + test 1
182+
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(3); // Query 1 + scalars 1 + test 1
182183
});
183184

184185
test('with one level of nested fields', () => {
185186
query = `Query { scalars { num, test { name } } }`;
186-
expect(getQueryTypeComplexity(query, typeWeights)).toBe(3); // Query 1 + scalars 1 + test 1
187+
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(3); // Query 1 + scalars 1 + test 1
187188
});
188189

189190
test('with multiple levels of nesting', () => {
190191
query = `Query { scalars { num, test { name, scalars { id } } } }`;
191-
expect(getQueryTypeComplexity(query, typeWeights)).toBe(4); // Query 1 + scalars 1 + test 1 + scalars 1
192+
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(4); // Query 1 + scalars 1 + test 1 + scalars 1
192193
});
193194

194195
test('with aliases', () => {
195196
query = `Query { foo: scalar { num } bar: scalar { id }}`;
196-
expect(getQueryTypeComplexity(query, typeWeights)).toBe(3); // Query 1 + scalar 1 + scalar 1
197+
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(3); // Query 1 + scalar 1 + scalar 1
197198
});
198199

199200
test('with all scalar fields', () => {
200201
query = `Query { scalars { id, num, float, bool, string } }`;
201-
expect(getQueryTypeComplexity(query, typeWeights)).toBe(2); // Query 1 + scalar 1
202+
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(2); // Query 1 + scalar 1
202203
});
203204

204205
test('with arguments and variables', () => {
205206
query = `Query { hero(episode: EMPIRE) { id, name } }`;
206-
expect(getQueryTypeComplexity(query, typeWeights)).toBe(2); // Query 1 + hero/character 1
207+
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(2); // Query 1 + hero/character 1
207208
query = `Query { human(id: 1) { id, name, appearsIn } }`;
208-
expect(getQueryTypeComplexity(query, typeWeights)).toBe(3); // Query 1 + human/character 1 + appearsIn/episode
209+
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(3); // Query 1 + human/character 1 + appearsIn/episode
209210
// argument passed in as a variable
210-
query = `Query { hero(episode: $ep) { id, name } }`;
211-
expect(getQueryTypeComplexity(query, typeWeights)).toBe(2); // Query 1 + hero/character 1
211+
variables = { ep: 'EMPIRE' };
212+
query = `Query varibaleQuery ($ep: Episode){ hero(episode: $ep) { id, name } }`;
213+
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(2); // Query 1 + hero/character 1
212214
});
213215

214216
test('with fragments', () => {
@@ -227,7 +229,7 @@ xdescribe('Test getQueryTypeComplexity function', () => {
227229
appearsIn
228230
}
229231
}`;
230-
expect(getQueryTypeComplexity(query, typeWeights)).toBe(5); // Query 1 + 2*(character 1 + appearsIn/episode 1)
232+
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(5); // Query 1 + 2*(character 1 + appearsIn/episode 1)
231233
});
232234

233235
test('with inline fragments', () => {
@@ -243,7 +245,7 @@ xdescribe('Test getQueryTypeComplexity function', () => {
243245
}
244246
}
245247
}`;
246-
expect(getQueryTypeComplexity(query, typeWeights)).toBe(2); // Query 1 + hero/character 1)
248+
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(2); // Query 1 + hero/character 1)
247249
});
248250

249251
/**
@@ -257,12 +259,15 @@ xdescribe('Test getQueryTypeComplexity function', () => {
257259
name
258260
}
259261
}`;
260-
expect(getQueryTypeComplexity(query, typeWeights)).toBe(false); // ?
262+
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(false); // ?
261263
});
262264

263-
test('with lists detrmined by arguments', () => {
265+
test('with lists detrmined by arguments and variables', () => {
264266
query = `Query {reviews(episode: EMPIRE, first: 3) { stars, commentary } }`;
265-
expect(getQueryTypeComplexity(query, typeWeights)).toBe(4); // 1 Query + 3 reviews
267+
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(4); // 1 Query + 3 reviews
268+
variables = { first: 3 };
269+
query = `Query queryVaribales($first: Int) {reviews(episode: EMPIRE, first: $first) { stars, commentary } }`;
270+
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(4); // 1 Query + 3 reviews
266271
});
267272

268273
test('with nested lists', () => {
@@ -278,7 +283,7 @@ xdescribe('Test getQueryTypeComplexity function', () => {
278283
}
279284
}
280285
}`;
281-
expect(getQueryTypeComplexity(query, typeWeights)).toBe(17); // 1 Query + 1 human/character + (5 friends/character X 3 friends/characters)
286+
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(17); // 1 Query + 1 human/character + (5 friends/character X 3 friends/characters)
282287
});
283288

284289
test('accounting for __typename feild', () => {
@@ -296,19 +301,25 @@ xdescribe('Test getQueryTypeComplexity function', () => {
296301
}
297302
}
298303
}`;
299-
expect(getQueryTypeComplexity(query, typeWeights)).toBe(5); // 1 Query + 4 search results
304+
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(5); // 1 Query + 4 search results
300305
});
301306

302307
// todo: directives @skip, @include and custom directives
303308

304309
// todo: expand on error handling
305310
test('Throws an error if for a bad query', () => {
306311
query = `Query { hello { hi } }`; // type doesn't exist
307-
expect(() => getQueryTypeComplexity(query, typeWeights)).toThrow('Error');
312+
expect(() => getQueryTypeComplexity(parse(query), variables, typeWeights)).toThrow(
313+
'Error'
314+
);
308315
query = `Query { hero(episode: EMPIRE){ starship } }`; // field doesn't exist
309-
expect(() => getQueryTypeComplexity(query, typeWeights)).toThrow('Error');
316+
expect(() => getQueryTypeComplexity(parse(query), variables, typeWeights)).toThrow(
317+
'Error'
318+
);
310319
query = `Query { hero(episode: EMPIRE) { id, name }`; // missing a closing bracket
311-
expect(() => getQueryTypeComplexity(query, typeWeights)).toThrow('Error');
320+
expect(() => getQueryTypeComplexity(parse(query), variables, typeWeights)).toThrow(
321+
'Error'
322+
);
312323
});
313324
});
314325

test/middleware/express.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Request, Response, NextFunction, RequestHandler } from 'express';
22
import { GraphQLSchema, buildSchema } from 'graphql';
33
import * as ioredis from 'ioredis';
44

5-
import expressRateLimitMiddleware from '../../src/middleware/index';
5+
import { expressRateLimiter as expressRateLimitMiddleware } from '../../src/middleware/index';
66

77
// eslint-disable-next-line @typescript-eslint/no-var-requires
88
const RedisMock = require('ioredis-mock');

0 commit comments

Comments
 (0)