diff --git a/.eslintrc.json b/.eslintrc.json index 3fa0b0c..42201ab 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -26,5 +26,5 @@ "error" ] }, - "ignorePatterns": ["jest.*"] + "ignorePatterns": ["jest.*", "build/*"] } diff --git a/.gitignore b/.gitignore index 6704566..cc1e49e 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,5 @@ dist # TernJS port file .tern-port + +build/* \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..81ae313 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,21 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [{ + "type": "node", + "request": "launch", + "name": "Jest Tests", + "program": "${workspaceRoot}/node_modules/jest/bin/jest.js", + "args": [ + "-i", "--verbose", "--no-cache" + ], + // "preLaunchTask": "build", + // "internalConsoleOptions": "openOnSessionStart", + // "outFiles": [ + // "${workspaceRoot}/dist/**/*" + // ], + // "envFile": "${workspaceRoot}/.env" + }] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index c754b38..6dade31 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,4 +5,19 @@ "source.fixAll.eslint": true }, "editor.formatOnSave": true, + "configurations": [{ + "type": "node", + "request": "launch", + "name": "Jest Tests", + "program": "${workspaceRoot}\\node_modules\\jest\\bin\\jest.js", + "args": [ + "-i" + ], + // "preLaunchTask": "build", + "internalConsoleOptions": "openOnSessionStart", + "outFiles": [ + "${workspaceRoot}/dist/**/*" + ], + "envFile": "${workspaceRoot}/.env" + }] } \ No newline at end of file diff --git a/src/@types/buildTypeWeights.d.ts b/src/@types/buildTypeWeights.d.ts index 871318f..bda7bca 100644 --- a/src/@types/buildTypeWeights.d.ts +++ b/src/@types/buildTypeWeights.d.ts @@ -1,17 +1,16 @@ -interface Fields { - [index: string]: number | ((args: ArgumentNode[]) => number); +export interface Fields { + [index: string]: FieldWeight; } - -interface Type { +export type WeightFunction = (args: ArgumentNode[]) => number; +export type FieldWeight = number | WeightFunction; +export interface Type { readonly weight: number; readonly fields: Fields; } - -interface TypeWeightObject { +export interface TypeWeightObject { [index: string]: Type; } - -interface TypeWeightConfig { +export interface TypeWeightConfig { mutation?: number; query?: number; object?: number; diff --git a/src/@types/rateLimit.d.ts b/src/@types/rateLimit.d.ts index 2eded16..d240ff1 100644 --- a/src/@types/rateLimit.d.ts +++ b/src/@types/rateLimit.d.ts @@ -1,4 +1,4 @@ -interface RateLimiter { +export interface RateLimiter { /** * Checks if a request is allowed under the given conditions and withdraws the specified number of tokens * @param uuid Unique identifier for the user associated with the request @@ -13,17 +13,17 @@ interface RateLimiter { ) => Promise; } -interface RateLimiterResponse { +export interface RateLimiterResponse { success: boolean; tokens: number; } -interface RedisBucket { +export interface RedisBucket { tokens: number; timestamp: number; } -type RateLimiterSelection = +export type RateLimiterSelection = | 'TOKEN_BUCKET' | 'LEAKY_BUCKET' | 'FIXED_WINDOW' @@ -34,7 +34,7 @@ type RateLimiterSelection = * @type {number} bucketSize - Size of the token bucket * @type {number} refillRate - Rate at which tokens are added to the bucket in seconds */ -interface TokenBucketOptions { +export interface TokenBucketOptions { bucketSize: number; refillRate: number; } @@ -42,4 +42,4 @@ interface TokenBucketOptions { // TODO: This will be a union type where we can specify Option types for other Rate Limiters // Record represents the empty object for alogorithms that don't require settings // and might be able to be removed in the future. -type RateLimiterOptions = TokenBucketOptions | Record; +export type RateLimiterOptions = TokenBucketOptions | Record; diff --git a/src/analysis/ASTnodefunctions.ts b/src/analysis/ASTnodefunctions.ts new file mode 100644 index 0000000..0caa817 --- /dev/null +++ b/src/analysis/ASTnodefunctions.ts @@ -0,0 +1,157 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { + DocumentNode, + FieldNode, + SelectionSetNode, + DefinitionNode, + Kind, + SelectionNode, + ArgumentNode, +} from 'graphql'; +import { FieldWeight, TypeWeightObject } from '../@types/buildTypeWeights'; + +// TODO: handle variables and arguments +// ! this is not functional +const getArgObj = (args: ArgumentNode[]): { [index: string]: any } => { + const argObj: { [index: string]: any } = {}; + for (let i = 0; i < args.length; i + 1) { + const node = args[i]; + if (args[i].value.kind !== Kind.VARIABLE) { + if (args[i].value.kind === Kind.INT) { + // FIXME: this does not work + argObj[args[i].name.value] = args[i].value; + } + } + } + return argObj; +}; +/** + * The AST node functions call each other following the nested structure below + * Each function handles a specific GraphQL AST node type + * + * AST nodes call each other in the following way + * + * Document Node + * | + * Definiton Node + * (operation and fragment definitons) + * / \ + * |-----> Selection Set Node not done + * | / + * | Selection Node + * | (Field, Inline fragment and fragment spread) + * | | \ \ + * |--Field Node not done not done + * + */ + +export function fieldNode( + node: FieldNode, + typeWeights: TypeWeightObject, + variables: any | undefined, + parentName: string +): number { + let complexity = 0; + // console.log('fieldNode', node, parentName); + // check if the field name is in the type weight object. + if (node.name.value.toLocaleLowerCase() in typeWeights) { + // if it is, than the field is an object type, add itss type weight to the total + complexity += typeWeights[node.name.value].weight; + // call the function to handle selection set node with selectionSet property if it is not undefined + if (node.selectionSet) { + complexity += selectionSetNode( + node.selectionSet, + typeWeights, + variables, + node.name.value + ); + } + } else { + // otherwise the field is a scalar or a list. + const fieldWeight: FieldWeight = typeWeights[parentName].fields[node.name.value]; + if (typeof fieldWeight === 'number') { + // if the feild weight is a number, add the number to the total complexity + complexity += fieldWeight; + } else if (node.arguments) { + // otherwise the the feild weight is a list, invoke the function with variables + // TODO: calculate the complexity for lists with arguments and varibales + // ! this is not functional + // iterate through the arguments to build the object to + complexity += fieldWeight([...node.arguments]); + } + } + return complexity; +} + +export function selectionNode( + node: SelectionNode, + typeWeights: TypeWeightObject, + variables: any | undefined, + parentName: string +): number { + let complexity = 0; + // console.log('selectionNode', node, parentName); + // check the kind property against the set of selection nodes that are possible + if (node.kind === Kind.FIELD) { + // call the function that handle field nodes + complexity += fieldNode(node, typeWeights, variables, parentName); + } + // TODO: add checks for Kind.FRAGMENT_SPREAD and Kind.INLINE_FRAGMENT here + return complexity; +} + +export function selectionSetNode( + node: SelectionSetNode, + typeWeights: TypeWeightObject, + variables: any | undefined, + parentName: string +): number { + let complexity = 0; + // iterate shrough the 'selections' array on the seletion set node + for (let i = 0; i < node.selections.length; i += 1) { + // call the function to handle seletion nodes + // pass the current parent through because selection sets act only as intermediaries + complexity += selectionNode(node.selections[i], typeWeights, variables, parentName); + } + return complexity; +} + +export function definitionNode( + node: DefinitionNode, + typeWeights: TypeWeightObject, + variables: any | undefined +): number { + let complexity = 0; + // check the kind property against the set of definiton nodes that are possible + if (node.kind === Kind.OPERATION_DEFINITION) { + // check if the operation is in the type weights object. + if (node.operation.toLocaleLowerCase() in typeWeights) { + // if it is, it is an object type, add it's type weight to the total + complexity += typeWeights[node.operation].weight; + // call the function to handle selection set node with selectionSet property if it is not undefined + if (node.selectionSet) + complexity += selectionSetNode( + node.selectionSet, + typeWeights, + variables, + node.operation + ); + } + } + // TODO: add checks for Kind.FRAGMENT_DEFINITION here (there are other type definition nodes that i think we can ignore. see ast.d.ts in 'graphql') + return complexity; +} + +export function documentNode( + node: DocumentNode, + typeWeights: TypeWeightObject, + variables: any | undefined +): number { + let complexity = 0; + // iterate through 'definitions' array on the document node + for (let i = 0; i < node.definitions.length; i += 1) { + // call the function to handle the various types of definition nodes + complexity += definitionNode(node.definitions[i], typeWeights, variables); + } + return complexity; +} diff --git a/src/analysis/buildTypeWeights.ts b/src/analysis/buildTypeWeights.ts index c5b3dd9..abb6bbd 100644 --- a/src/analysis/buildTypeWeights.ts +++ b/src/analysis/buildTypeWeights.ts @@ -19,6 +19,7 @@ import { import { Maybe } from 'graphql/jsutils/Maybe'; import { ObjMap } from 'graphql/jsutils/ObjMap'; import { GraphQLSchema } from 'graphql/type/schema'; +import { TypeWeightConfig, TypeWeightObject } from '../@types/buildTypeWeights'; export const KEYWORDS = ['first', 'last', 'limit']; @@ -81,6 +82,7 @@ function parseQuery( queryFields[field].args.forEach((arg: GraphQLArgument) => { // If query has an argument matching one of the limiting keywords and resolves to a list then the weight of the query // should be dependent on both the weight of the resolved type and the limiting argument. + // FIXME: Can nonnull wrap list types? if (KEYWORDS.includes(arg.name) && isListType(resolveType)) { // Get the type that comprises the list const listType = resolveType.ofType; @@ -125,7 +127,7 @@ function parseQuery( } }); - // if the field is a scalar or an enum set weight accordingly + // if the field is a scalar or an enum set weight accordingly. It is not a list in this case if (isScalarType(resolveType) || isEnumType(resolveType)) { result.query.fields[field] = typeWeights.scalar || DEFAULT_SCALAR_WEIGHT; } @@ -152,7 +154,7 @@ function parseTypes( // Handle Object, Interface, Enum and Union types Object.keys(typeMap).forEach((type) => { - const typeName = type.toLowerCase(); + const typeName: string = type.toLowerCase(); const currentType: GraphQLNamedType = typeMap[type]; // Get all types that aren't Query or Mutation or a built in type that starts with '__' @@ -219,7 +221,7 @@ function buildTypeWeightsFromSchema( ...typeWeightsConfig, }; - // Confirm that any custom weights are positive + // Confirm that any custom weights are non-negative Object.entries(typeWeights).forEach((value: [string, number]) => { if (value[1] < 0) { throw new Error(`Type weights cannot be negative. Received: ${value[0]}: ${value[1]} `); diff --git a/src/analysis/typeComplexityAnalysis.ts b/src/analysis/typeComplexityAnalysis.ts index cc603f3..43c063a 100644 --- a/src/analysis/typeComplexityAnalysis.ts +++ b/src/analysis/typeComplexityAnalysis.ts @@ -1,28 +1,25 @@ import { DocumentNode } from 'graphql'; +import { TypeWeightObject } from '../@types/buildTypeWeights'; +import { documentNode } from './ASTnodefunctions'; /** - * This function should - * 1. validate the query using graphql methods - * 2. parse the query string using the graphql parse method - * 3. itreate through the query AST and - * - cross reference the type weight object to check type weight - * - total all the eweights of all types in the query - * 4. return the total as the query complexity + * Calculate the complexity for the query by recursivly traversing through the query AST, + * checking the query fields against the type weight object and totaling the weights of every field. * - * TO DO: extend the functionality to work for mutations and subscriptions + * TO DO: extend the functionality to work for mutations and subscriptions and directives * - * @param {string} queryString - * @param {TypeWeightObject} typeWeights + * @param {string} queryAST * @param {any | undefined} varibales - * @param {string} complexityOption + * @param {TypeWeightObject} typeWeights */ -// TODO add queryVaribables parameter function getQueryTypeComplexity( - queryString: DocumentNode, - varibales: any | undefined, + queryAST: DocumentNode, + variables: any | undefined, typeWeights: TypeWeightObject ): number { - throw Error('getQueryComplexity is not implemented.'); + let complexity = 0; + complexity += documentNode(queryAST, typeWeights, variables); + return complexity; } export default getQueryTypeComplexity; diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 75b0ee3..3e4944a 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -6,6 +6,8 @@ import { Request, Response, NextFunction, RequestHandler } from 'express'; import buildTypeWeightsFromSchema, { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights'; import setupRateLimiter from './rateLimiterSetup'; import getQueryTypeComplexity from '../analysis/typeComplexityAnalysis'; +import { RateLimiterOptions, RateLimiterSelection } from '../@types/rateLimit'; +import { TypeWeightConfig } from '../@types/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 @@ -42,7 +44,11 @@ export function expressRateLimiter( const rateLimiter = setupRateLimiter(rateLimiterAlgo, rateLimiterOptions, redisClient); // return the rate limiting middleware - return async (req: Request, res: Response, next: NextFunction): Promise => { + return async ( + req: Request, + res: Response, + next: NextFunction + ): Promise>> => { const requestTimestamp = new Date().valueOf(); const { query, variables }: { query: string; variables: any } = req.body; if (!query) { @@ -50,7 +56,6 @@ export function expressRateLimiter( 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. @@ -68,10 +73,12 @@ export function expressRateLimiter( // 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(); + if (validationErrors.length > 0) { + // FIXME: Customize this error to throw the GraphQLError + return next(Error('invalid query')); + } 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 @@ -80,10 +87,10 @@ export function expressRateLimiter( requestTimestamp, queryComplexity ); - if (rateLimiterResponse.success === false) { + if (!rateLimiterResponse.success) { // 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(); + return res.status(429).json({ graphqlGate: rateLimiterResponse }); } res.locals.graphqlGate = { timestamp: requestTimestamp, diff --git a/src/middleware/rateLimiterSetup.ts b/src/middleware/rateLimiterSetup.ts index 4c5de39..734bc5f 100644 --- a/src/middleware/rateLimiterSetup.ts +++ b/src/middleware/rateLimiterSetup.ts @@ -1,4 +1,5 @@ import Redis from 'ioredis'; +import { RateLimiterOptions, RateLimiterSelection } from '../@types/rateLimit'; import TokenBucket from '../rateLimiters/tokenBucket'; /** diff --git a/src/rateLimiters/tokenBucket.ts b/src/rateLimiters/tokenBucket.ts index daea169..02cc429 100644 --- a/src/rateLimiters/tokenBucket.ts +++ b/src/rateLimiters/tokenBucket.ts @@ -1,4 +1,5 @@ import Redis from 'ioredis'; +import { RateLimiter, RateLimiterResponse, RedisBucket } from '../@types/rateLimit'; /** * The TokenBucket instance of a RateLimiter limits requests based on a unique user ID. @@ -98,7 +99,7 @@ class TokenBucket implements RateLimiter { timestamp: number ): number => { const timeSinceLastQueryInSeconds: number = Math.floor( - (timestamp - bucket.timestamp) / 1000 // 1000 ms in a second + (timestamp - bucket.timestamp) / 1000 // 1000 ms in a second FIXME: magic number if specifying custom timeframe ); const tokensToAdd = timeSinceLastQueryInSeconds * this.refillRate; const updatedTokenCount = bucket.tokens + tokensToAdd; diff --git a/test/analysis/typeComplexityAnalysis.test.ts b/test/analysis/typeComplexityAnalysis.test.ts index bf6f69f..3901601 100644 --- a/test/analysis/typeComplexityAnalysis.test.ts +++ b/test/analysis/typeComplexityAnalysis.test.ts @@ -1,6 +1,7 @@ import { ArgumentNode } from 'graphql/language'; import { parse } from 'graphql'; import getQueryTypeComplexity from '../../src/analysis/typeComplexityAnalysis'; +import { TypeWeightObject } from '../../src/@types/buildTypeWeights'; /** * Here is the schema that creates the followning 'typeWeightsObject' used for the tests @@ -168,54 +169,54 @@ const typeWeights: TypeWeightObject = { }, }; -xdescribe('Test getQueryTypeComplexity function', () => { +describe('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 } }`; + query = `query { scalars { num } }`; expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(2); // Query 1 + Scalars 1 }); test('with two or more fields', () => { - query = `Query { scalars { num } test { name } }`; + query = `query { scalars { num } test { name } }`; 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 } } }`; + query = `query { scalars { num, test { name } } }`; 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 } } } }`; + query = `query { scalars { num, test { name, scalars { id } } } }`; 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 }}`; + query = `query { foo: scalars { num } bar: scalars { id }}`; 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 } }`; + query = `query { scalars { id, num, float, bool, string } }`; expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(2); // Query 1 + scalar 1 }); - test('with arguments and variables', () => { - query = `Query { hero(episode: EMPIRE) { id, name } }`; + xtest('with arguments and variables', () => { + query = `query { hero(episode: EMPIRE) { id, name } }`; expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(2); // Query 1 + hero/character 1 - query = `Query { human(id: 1) { id, name, appearsIn } }`; + query = `query { human(id: 1) { id, name, appearsIn } }`; expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(3); // Query 1 + human/character 1 + appearsIn/episode // argument passed in as a variable variables = { ep: 'EMPIRE' }; - query = `Query varibaleQuery ($ep: Episode){ hero(episode: $ep) { id, name } }`; + 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', () => { + xtest('with fragments', () => { query = ` - Query { + query { leftComparison: hero(episode: EMPIRE) { ...comparisonFields } @@ -232,9 +233,9 @@ xdescribe('Test getQueryTypeComplexity function', () => { expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(5); // Query 1 + 2*(character 1 + appearsIn/episode 1) }); - test('with inline fragments', () => { + xtest('with inline fragments', () => { query = ` - Query { + query { hero(episode: EMPIRE) { name ... on Droid { @@ -253,7 +254,7 @@ xdescribe('Test getQueryTypeComplexity function', () => { */ xtest('with lists of unknown size', () => { query = ` - Query { + query { search(text: 'hi') { id name @@ -262,7 +263,7 @@ xdescribe('Test getQueryTypeComplexity function', () => { expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(false); // ? }); - test('with lists detrmined by arguments and variables', () => { + xtest('with lists detrmined by arguments and variables', () => { query = `Query {reviews(episode: EMPIRE, first: 3) { stars, commentary } }`; expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(4); // 1 Query + 3 reviews variables = { first: 3 }; @@ -270,7 +271,7 @@ xdescribe('Test getQueryTypeComplexity function', () => { expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(4); // 1 Query + 3 reviews }); - test('with nested lists', () => { + xtest('with nested lists', () => { query = ` query { human(id: 1) { @@ -286,7 +287,7 @@ xdescribe('Test getQueryTypeComplexity function', () => { 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', () => { + xtest('accounting for __typename feild', () => { query = ` query { search(text: "an", first: 4) { @@ -305,22 +306,6 @@ xdescribe('Test getQueryTypeComplexity function', () => { }); // 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(parse(query), variables, typeWeights)).toThrow( - 'Error' - ); - query = `Query { hero(episode: EMPIRE){ starship } }`; // field doesn't exist - expect(() => getQueryTypeComplexity(parse(query), variables, typeWeights)).toThrow( - 'Error' - ); - query = `Query { hero(episode: EMPIRE) { id, name }`; // missing a closing bracket - expect(() => getQueryTypeComplexity(parse(query), variables, typeWeights)).toThrow( - 'Error' - ); - }); }); xdescribe('Calculates the correct type complexity for mutations', () => {}); diff --git a/test/rateLimiters/tokenBucket.test.ts b/test/rateLimiters/tokenBucket.test.ts index a177943..834feb0 100644 --- a/test/rateLimiters/tokenBucket.test.ts +++ b/test/rateLimiters/tokenBucket.test.ts @@ -1,4 +1,5 @@ import * as ioredis from 'ioredis'; +import { RedisBucket } from '../../src/@types/rateLimit'; import TokenBucket from '../../src/rateLimiters/tokenBucket'; // eslint-disable-next-line @typescript-eslint/no-var-requires diff --git a/tsconfig.json b/tsconfig.json index f8c4bf7..8e6273b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,8 +17,9 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": false, - "typeRoots": ["./@types", "node_modules/@types"], - "types": ["node", "jest"] + "typeRoots": ["src/@types", "node_modules/@types"], + "types": ["node", "jest"], + "outDir": "build/" }, "include": ["src/**/*.ts", "src/**/*.js", "test/**/*.ts", "test/**/*.js"], "exclude": ["node_modules", "**/*.spec.ts"]