Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/@types/rateLimit.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface RateLimiter {

interface RateLimiterResponse {
success: boolean;
tokens?: number;
tokens: number;
}

interface RedisBucket {
Expand Down
10 changes: 8 additions & 2 deletions src/analysis/typeComplexityAnalysis.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { parse } from 'graphql';
import { DocumentNode } from 'graphql';

/**
* This function should
Expand All @@ -13,9 +13,15 @@ import { parse } from 'graphql';
*
* @param {string} queryString
* @param {TypeWeightObject} typeWeights
* @param {any | undefined} varibales
* @param {string} complexityOption
*/
function getQueryTypeComplexity(queryString: string, typeWeights: TypeWeightObject): number {
// TODO add queryVaribables parameter
function getQueryTypeComplexity(
queryString: DocumentNode,
varibales: any | undefined,
typeWeights: TypeWeightObject
): number {
throw Error('getQueryComplexity is not implemented.');
}

Expand Down
85 changes: 71 additions & 14 deletions src/middleware/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import Redis, { RedisOptions } from 'ioredis';
import { Request, Response, NextFunction, RequestHandler } from 'express';
import { parse, validate } from 'graphql';
import { GraphQLSchema } from 'graphql/type/schema';
import { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights';
import { Request, Response, NextFunction, RequestHandler } from 'express';

import buildTypeWeightsFromSchema, { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights';
import setupRateLimiter from './rateLimiterSetup';
import getQueryTypeComplexity from '../analysis/typeComplexityAnalysis';

// FIXME: Will the developer be responsible for first parsing the schema from a file?
// Can consider accepting a string representing a the filepath to a schema
Expand All @@ -12,7 +16,7 @@ import { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights';
* @param {RateLimiterSelection} rateLimiter Specify rate limiting algorithm to be used
* @param {RateLimiterOptions} options Specify the appropriate options for the selected rateLimiter
* @param {GraphQLSchema} schema GraphQLSchema object
* @param {RedisClientOptions} redisClientOptions valid node-redis connection options. See https://github.com/redis/node-redis/blob/HEAD/docs/client-configuration.md
* @param {RedisClientOptions} RedisOptions ioredis connection options https://ioredis.readthedocs.io/en/stable/API/#new_Redis
* @param {TypeWeightConfig} typeWeightConfig Optional type weight configuration for the GraphQL Schema.
* Defaults to {mutation: 10, object: 1, field: 0, connection: 2}
* @returns {RequestHandler} express middleware that computes the complexity of req.query and calls the next middleware
Expand All @@ -21,23 +25,76 @@ import { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights';
* @throws ValidationError if GraphQL Schema is invalid.
*/
export function expressRateLimiter(
rateLimiter: RateLimiterSelection,
rateLimiterAlgo: RateLimiterSelection,
rateLimiterOptions: RateLimiterOptions,
schema: GraphQLSchema,
redisClientOptions: RedisOptions,
typeWeightConfig: TypeWeightConfig = defaultTypeWeightsConfig
): RequestHandler {
// TODO: Set 'timestamp' on res.locals to record when the request is received in UNIX format. HTTP does not inlude this.
// TODO: Parse the schema to create a TypeWeightObject. Throw ValidationError if schema is invalid
// TODO: Connect to Redis store using provided options. Default to localhost:6379
// TODO: Configure the selected RateLimtier
// TODO: Configure the complexity analysis algorithm to run for incoming requests
const middleware: RequestHandler = (req: Request, res: Response, next: NextFunction) => {
// TODO: Parse query from req.query, compute complexity and pass necessary info to rate limiter
// TODO: Call next if query is successful, send 429 status if query blocked, call next(err) with any thrown errors
next(Error('Express rate limiting middleware not implemented'));
/**
* build the type weight object, create the redis client and instantiate the ratelimiter
* before returning the express middleware that calculates query complexity and throttles the requests
*/
// TODO: Throw ValidationError if schema is invalid
const typeWeightObject = buildTypeWeightsFromSchema(schema, typeWeightConfig);
// TODO: Throw error if connection is unsuccessful
const redisClient = new Redis(redisClientOptions); // Default port is 6379 automatically
const rateLimiter = setupRateLimiter(rateLimiterAlgo, rateLimiterOptions, redisClient);

// return the rate limiting middleware
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const requestTimestamp = new Date().valueOf();
const { query, variables }: { query: string; variables: any } = req.body;
if (!query) {
// FIXME: Throw an error here? Code currently passes this on to whatever is next
console.log('There is no query on the request');
return next();
}

/**
* There are numorous ways to get the ip address off of the request object.
* - 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.
* - req.ips wwill hold an array of ip addresses in'x-forward-for' header. client is likely at index zero
* - req.ip will have the ip address
* - req.socket.remoteAddress is an insatnce of net.socket which is used as another method of getting the ip address
*
* req.ip and req.ips will worx in express but not with other frameworks
*/
// check for a proxied ip address before using the ip address on request
const ip: string = req.ips[0] || req.ip;

// FIXME: this will only work with type complexity
const queryAST = parse(query);
// validate the query against the schema. The GraphQL validation function returns an array of errors.
const validationErrors = validate(schema, queryAST);
// 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.
if (validationErrors.length > 0) return next();

const queryComplexity = getQueryTypeComplexity(queryAST, variables, typeWeightObject);

try {
// process the request and conditinoally respond to client with status code 429 o
// r pass the request onto the next middleware function
const rateLimiterResponse = await rateLimiter.processRequest(
ip,
requestTimestamp,
queryComplexity
);
if (rateLimiterResponse.success === false) {
// TODO: add a header 'Retry-After' with the time to wait untill next query will succeed
// FIXME: send information about query complexity, tokens, etc, to the client on rejected query
res.status(429).send();
}
res.locals.graphqlGate = {
timestamp: requestTimestamp,
complexity: queryComplexity,
tokens: rateLimiterResponse.tokens,
};
return next();
} catch (err) {
return next(err);
}
};
return middleware;
}

export default expressRateLimiter;
40 changes: 40 additions & 0 deletions src/middleware/rateLimiterSetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Redis from 'ioredis';
import TokenBucket from '../rateLimiters/tokenBucket';

/**
* Instatieate the rateLimiting algorithm class based on the developer selection and options
*
* @export
* @param {RateLimiterSelection} selection
* @param {RateLimiterOptions} options
* @param {Redis} client
* @return {*}
*/
export default function setupRateLimiter(
selection: RateLimiterSelection,
options: RateLimiterOptions,
client: Redis
) {
switch (selection) {
case 'TOKEN_BUCKET':
// todo validate options
return new TokenBucket(options.bucketSize, options.refillRate, client);
break;
case 'LEAKY_BUCKET':
throw new Error('Leaky Bucket algonithm has not be implemented.');
break;
case 'FIXED_WINDOW':
throw new Error('Fixed Window algonithm has not be implemented.');
break;
case 'SLIDING_WINDOW_LOG':
throw new Error('Sliding Window Log has not be implemented.');
break;
case 'SLIDING_WINDOW_COUNTER':
throw new Error('Sliding Window Counter algonithm has not be implemented.');
break;
default:
// typescript should never let us invoke this function with anything other than the options above
throw new Error('Selected rate limiting algorithm is not suppported');
break;
}
}
52 changes: 32 additions & 20 deletions test/analysis/typeComplexityAnalysis.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { parse } from 'graphql';
import getQueryTypeComplexity from '../../src/analysis/typeComplexityAnalysis';

/**
Expand Down Expand Up @@ -168,45 +169,47 @@ const typeWeights: TypeWeightObject = {

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

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

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

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

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

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

test('with arguments and variables', () => {
query = `Query { hero(episode: EMPIRE) { id, name } }`;
expect(getQueryTypeComplexity(query, typeWeights)).toBe(2); // Query 1 + hero/character 1
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(2); // Query 1 + hero/character 1
query = `Query { human(id: 1) { id, name, appearsIn } }`;
expect(getQueryTypeComplexity(query, typeWeights)).toBe(3); // Query 1 + human/character 1 + appearsIn/episode
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(3); // Query 1 + human/character 1 + appearsIn/episode
// argument passed in as a variable
query = `Query { hero(episode: $ep) { id, name } }`;
expect(getQueryTypeComplexity(query, typeWeights)).toBe(2); // Query 1 + hero/character 1
variables = { ep: 'EMPIRE' };
query = `Query varibaleQuery ($ep: Episode){ hero(episode: $ep) { id, name } }`;
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(2); // Query 1 + hero/character 1
});

test('with fragments', () => {
Expand All @@ -225,7 +228,7 @@ xdescribe('Test getQueryTypeComplexity function', () => {
appearsIn
}
}`;
expect(getQueryTypeComplexity(query, typeWeights)).toBe(5); // Query 1 + 2*(character 1 + appearsIn/episode 1)
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(5); // Query 1 + 2*(character 1 + appearsIn/episode 1)
});

test('with inline fragments', () => {
Expand All @@ -241,7 +244,7 @@ xdescribe('Test getQueryTypeComplexity function', () => {
}
}
}`;
expect(getQueryTypeComplexity(query, typeWeights)).toBe(2); // Query 1 + hero/character 1)
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(2); // Query 1 + hero/character 1)
});

/**
Expand All @@ -255,12 +258,15 @@ xdescribe('Test getQueryTypeComplexity function', () => {
name
}
}`;
expect(getQueryTypeComplexity(query, typeWeights)).toBe(false); // ?
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(false); // ?
});

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

test('with nested lists', () => {
Expand All @@ -276,7 +282,7 @@ xdescribe('Test getQueryTypeComplexity function', () => {
}
}
}`;
expect(getQueryTypeComplexity(query, typeWeights)).toBe(17); // 1 Query + 1 human/character + (5 friends/character X 3 friends/characters)
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(17); // 1 Query + 1 human/character + (5 friends/character X 3 friends/characters)
});

test('accounting for __typename feild', () => {
Expand All @@ -294,19 +300,25 @@ xdescribe('Test getQueryTypeComplexity function', () => {
}
}
}`;
expect(getQueryTypeComplexity(query, typeWeights)).toBe(5); // 1 Query + 4 search results
expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(5); // 1 Query + 4 search results
});

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

// todo: expand on error handling
test('Throws an error if for a bad query', () => {
query = `Query { hello { hi } }`; // type doesn't exist
expect(() => getQueryTypeComplexity(query, typeWeights)).toThrow('Error');
expect(() => getQueryTypeComplexity(parse(query), variables, typeWeights)).toThrow(
'Error'
);
query = `Query { hero(episode: EMPIRE){ starship } }`; // field doesn't exist
expect(() => getQueryTypeComplexity(query, typeWeights)).toThrow('Error');
expect(() => getQueryTypeComplexity(parse(query), variables, typeWeights)).toThrow(
'Error'
);
query = `Query { hero(episode: EMPIRE) { id, name }`; // missing a closing bracket
expect(() => getQueryTypeComplexity(query, typeWeights)).toThrow('Error');
expect(() => getQueryTypeComplexity(parse(query), variables, typeWeights)).toThrow(
'Error'
);
});
});

Expand Down
2 changes: 1 addition & 1 deletion test/middleware/express.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Request, Response, NextFunction, RequestHandler } from 'express';
import { GraphQLSchema, buildSchema } from 'graphql';
import * as ioredis from 'ioredis';

import expressRateLimitMiddleware from '../../src/middleware/index';
import { expressRateLimiter as expressRateLimitMiddleware } from '../../src/middleware/index';

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