diff --git a/package-lock.json b/package-lock.json index 1941a3c..05bd366 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@babel/core": "^7.17.12", "@babel/preset-env": "^7.17.12", "@babel/preset-typescript": "^7.17.12", + "@types/express": "^4.17.13", "@types/jest": "^27.5.1", "@types/redis-mock": "^0.17.1", "@typescript-eslint/eslint-plugin": "^5.24.0", @@ -2467,6 +2468,48 @@ "@babel/types": "^7.3.0" } }, + "node_modules/@types/body-parser": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", + "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", + "dev": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.35", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", + "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", + "integrity": "sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==", + "dev": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.18", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.17.28", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz", + "integrity": "sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", @@ -2602,6 +2645,12 @@ "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", "dev": true }, + "node_modules/@types/mime": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", + "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", + "dev": true + }, "node_modules/@types/node": { "version": "17.0.34", "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.34.tgz", @@ -2614,6 +2663,18 @@ "integrity": "sha512-XFjFHmaLVifrAKaZ+EKghFHtHSUonyw8P2Qmy2/+osBnrKbH9UYtlK10zg8/kCt47MFilll/DEDKy3DHfJ0URw==", "dev": true }, + "node_modules/@types/qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", + "dev": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", + "dev": true + }, "node_modules/@types/redis": { "version": "2.8.32", "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz", @@ -2632,6 +2693,16 @@ "@types/redis": "^2.8.0" } }, + "node_modules/@types/serve-static": { + "version": "1.13.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz", + "integrity": "sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==", + "dev": true, + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -9391,6 +9462,48 @@ "@babel/types": "^7.3.0" } }, + "@types/body-parser": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", + "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", + "dev": true, + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/connect": { + "version": "3.4.35", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", + "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/express": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", + "integrity": "sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.18", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.28", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz", + "integrity": "sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, "@types/graceful-fs": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", @@ -9507,6 +9620,12 @@ "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", "dev": true }, + "@types/mime": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", + "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", + "dev": true + }, "@types/node": { "version": "17.0.34", "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.34.tgz", @@ -9519,6 +9638,18 @@ "integrity": "sha512-XFjFHmaLVifrAKaZ+EKghFHtHSUonyw8P2Qmy2/+osBnrKbH9UYtlK10zg8/kCt47MFilll/DEDKy3DHfJ0URw==", "dev": true }, + "@types/qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", + "dev": true + }, + "@types/range-parser": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", + "dev": true + }, "@types/redis": { "version": "2.8.32", "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz", @@ -9537,6 +9668,16 @@ "@types/redis": "^2.8.0" } }, + "@types/serve-static": { + "version": "1.13.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz", + "integrity": "sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==", + "dev": true, + "requires": { + "@types/mime": "^1", + "@types/node": "*" + } + }, "@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", diff --git a/package.json b/package.json index 91caf46..a154acd 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@babel/core": "^7.17.12", "@babel/preset-env": "^7.17.12", "@babel/preset-typescript": "^7.17.12", + "@types/express": "^4.17.13", "@types/jest": "^27.5.1", "@types/redis-mock": "^0.17.1", "@typescript-eslint/eslint-plugin": "^5.24.0", @@ -49,7 +50,7 @@ "*.{js,ts,css,md}": "prettier --write --ignore-unknown" }, "dependencies": { - "redis": "^4.1.0", - "graphql": "^16.5.0" + "graphql": "^16.5.0", + "redis": "^4.1.0" } } diff --git a/src/@types/rateLimit.d.ts b/src/@types/rateLimit.d.ts index 34de442..91ec955 100644 --- a/src/@types/rateLimit.d.ts +++ b/src/@types/rateLimit.d.ts @@ -22,3 +22,22 @@ interface RedisBucket { tokens: number; timestamp: number; } + +type RateLimiterSelection = + | 'TOKEN_BUCKET' + | 'LEAKY_BUCKET' + | 'FIXED_WINDOW' + | 'SLIDING_WINDOW_LOG' + | 'SLIDING_WINDOW_COUNTER'; + +/** + * @type {number} bucketSize - Size of the token bucket + * @type {number} refillRate - Rate at which tokens are added to the bucket in seconds + */ +interface TokenBucketOptions { + bucketSize: number; + refillRate: number; +} + +// TODO: This will be a union type where we can specify Option types for other Rate Limiters +type RateLimiterOptions = TokenBucketOptions; diff --git a/src/analysis/buildTypeWeights.ts b/src/analysis/buildTypeWeights.ts index 5b8beb3..41a5a93 100644 --- a/src/analysis/buildTypeWeights.ts +++ b/src/analysis/buildTypeWeights.ts @@ -1,5 +1,19 @@ import { GraphQLSchema } from 'graphql/type/schema'; +/** + * Default TypeWeight Configuration: + * mutation: 10 + * object: 1 + * scalar: 0 + * connection: 2 + */ +export const defaultTypeWeightsConfig: TypeWeightConfig = { + mutation: 10, + object: 1, + scalar: 0, + connection: 2, +}; + /** * The default typeWeightsConfig object is based off of Shopifys implementation of query * cost analysis. Our function should input a users configuration of type weights or fall @@ -10,16 +24,11 @@ import { GraphQLSchema } from 'graphql/type/schema'; * - validate that the typeWeightsConfig parameter has no negative values (throw an error if it does) * * @param schema - * @param typeWeightsConfig + * @param typeWeightsConfig Defaults to {mutation: 10, object: 1, field: 0, connection: 2} */ function buildTypeWeightsFromSchema( schema: GraphQLSchema, - typeWeightsConfig: TypeWeightConfig = { - mutation: 10, - object: 1, - scalar: 0, - connection: 2, - } + typeWeightsConfig: TypeWeightConfig = defaultTypeWeightsConfig ): TypeWeightObject { throw Error(`getTypeWeightsFromSchema is not implemented.`); } diff --git a/src/middleware/index.ts b/src/middleware/index.ts new file mode 100644 index 0000000..57fc4d6 --- /dev/null +++ b/src/middleware/index.ts @@ -0,0 +1,43 @@ +import { RedisClientOptions } from 'redis'; +import { Request, Response, NextFunction, RequestHandler } from 'express'; +import { GraphQLSchema } from 'graphql/type/schema'; +import { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights'; + +// 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 +// FIXME: Should a 429 status be sent by default or do we allow the user to handle blocked requests? + +/** + * Primary entry point for adding GraphQL Rate Limiting middleware to an Express Server + * @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 {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 + * if the query is allowed or sends a 429 status if the request is blocked + * @throws ValidationError if GraphQL Schema is invalid + */ +export function expressRateLimiter( + rateLimiter: RateLimiterSelection, + rateLimiterOptions: RateLimiterOptions, + schema: GraphQLSchema, + redisClientOptions: RedisClientOptions, + 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')); + }; + return middleware; +} + +export default expressRateLimiter;