Skip to content

Commit

Permalink
enhance(rate-limit): dependency cleanup (#2443)
Browse files Browse the repository at this point in the history
* enhance(rate-limit): dependency cleanup

* chore(dependencies): updated changesets for modified dependencies

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
ardatan and github-actions[bot] authored Feb 20, 2025
1 parent 15f022d commit 75ae40e
Show file tree
Hide file tree
Showing 15 changed files with 757 additions and 29 deletions.
8 changes: 8 additions & 0 deletions .changeset/@envelop_rate-limiter-2443-dependencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@envelop/rate-limiter": patch
---
dependencies updates:
- Added dependency [`lodash.get@^4.4.2` ↗︎](https://www.npmjs.com/package/lodash.get/v/4.4.2) (to `dependencies`)
- Added dependency [`ms@^2.1.3` ↗︎](https://www.npmjs.com/package/ms/v/2.1.3) (to `dependencies`)
- Removed dependency [`graphql-middleware@^6.1.35` ↗︎](https://www.npmjs.com/package/graphql-middleware/v/6.1.35) (from `dependencies`)
- Removed dependency [`graphql-rate-limit@^3.3.0` ↗︎](https://www.npmjs.com/package/graphql-rate-limit/v/3.3.0) (from `dependencies`)
10 changes: 7 additions & 3 deletions packages/plugins/rate-limiter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,19 @@
"dependencies": {
"@envelop/on-resolve": "workspace:^",
"@graphql-tools/utils": "^10.5.4",
"graphql-middleware": "^6.1.35",
"graphql-rate-limit": "^3.3.0",
"lodash.get": "^4.4.2",
"minimatch": "^10.0.1",
"ms": "^2.1.3",
"tslib": "^2.5.0"
},
"devDependencies": {
"@envelop/core": "workspace:^",
"@envelop/core": "workspace:*",
"@graphql-tools/schema": "10.0.18",
"@types/lodash.get": "4.4.9",
"@types/ms": "2.1.0",
"@types/redis-mock": "0.17.3",
"graphql": "16.8.1",
"redis-mock": "0.56.3",
"typescript": "5.7.3"
},
"publishConfig": {
Expand Down
37 changes: 37 additions & 0 deletions packages/plugins/rate-limiter/src/batch-request-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export const getNoOpCache = (): {
set: ({ newTimestamps }: { newTimestamps: number[] }) => number[];
} => ({
set: ({ newTimestamps }: { newTimestamps: number[] }) => newTimestamps,
});

export const getWeakMapCache = (): {
set: ({
context,
fieldIdentity,
newTimestamps,
}: {
context: Record<any, any>;
fieldIdentity: string;
newTimestamps: number[];
}) => any;
} => {
const cache = new WeakMap();

return {
set: ({
context,
fieldIdentity,
newTimestamps,
}: {
context: Record<any, any>;
fieldIdentity: string;
newTimestamps: number[];
}) => {
const currentCalls = cache.get(context) || {};

currentCalls[fieldIdentity] = [...(currentCalls[fieldIdentity] || []), ...newTimestamps];
cache.set(context, currentCalls);
return currentCalls[fieldIdentity];
},
};
};
184 changes: 184 additions & 0 deletions packages/plugins/rate-limiter/src/get-graphql-rate-limiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import type { GraphQLResolveInfo } from 'graphql';
import get from 'lodash.get';
import ms from 'ms';
import { getNoOpCache, getWeakMapCache } from './batch-request-cache.js';
import { InMemoryStore } from './in-memory-store.js';
import type {
FormatErrorInput,
GraphQLRateLimitConfig,
GraphQLRateLimitDirectiveArgs,
Identity,
} from './types.js';

// Default field options
const DEFAULT_WINDOW = 60 * 1000;
const DEFAULT_MAX = 5;
const DEFAULT_FIELD_IDENTITY_ARGS: readonly string[] = [];

/**
* Returns a string key for the given field + args. With no identityArgs are provided, just the fieldName
* will be used for the key. If an array of resolveArgs are provided, the values of those will be built
* into the key.
*
* Example:
* (fieldName = 'books', identityArgs: ['id', 'title'], resolveArgs: { id: 1, title: 'Foo', subTitle: 'Bar' })
* => books:1:Foo
*
* @param fieldName
* @param identityArgs
* @param resolveArgs
*/
const getFieldIdentity = (
fieldName: string,
identityArgs: readonly string[],
resolveArgs: unknown,
): string => {
const argsKey = identityArgs.map(arg => get(resolveArgs, arg));
return [fieldName, ...argsKey].join(':');
};

/**
* This is the core rate limiting logic function, APIs (directive, sheild etc.)
* can wrap this or it can be used directly in resolvers.
* @param userConfig - global (usually app-wide) rate limiting config
*/
const getGraphQLRateLimiter = (
// Main config (e.g. the config passed to the createRateLimitDirective func)
userConfig: GraphQLRateLimitConfig,
): ((
{
args,
context,
info,
}: {
parent: any;
args: Record<string, any>;
context: any;
info: GraphQLResolveInfo;
},
{
arrayLengthField,
identityArgs,
max,
window,
message,
uncountRejected,
}: GraphQLRateLimitDirectiveArgs,
) => Promise<string | undefined>) => {
// Default directive config
const defaultConfig = {
enableBatchRequestCache: false,
formatError: ({ fieldName }: FormatErrorInput) => {
return `You are trying to access '${fieldName}' too often`;
},
// Required
identifyContext: () => {
throw new Error('You must implement a createRateLimitDirective.config.identifyContext');
},
store: new InMemoryStore(),
};

const { enableBatchRequestCache, identifyContext, formatError, store } = {
...defaultConfig,
...userConfig,
};

const batchRequestCache = enableBatchRequestCache ? getWeakMapCache() : getNoOpCache();

/**
* Field level rate limiter function that returns the error message or undefined
* @param args - pass the resolver args as an object
* @param config - field level config
*/
const rateLimiter = async (
// Resolver args
{
args,
context,
info,
}: {
parent: any;
args: Record<string, any>;
context: any;
info: GraphQLResolveInfo;
},
// Field level config (e.g. the directive parameters)
{
arrayLengthField,
identityArgs,
max,
window,
message,
readOnly,
uncountRejected,
}: GraphQLRateLimitDirectiveArgs,
): Promise<string | undefined> => {
// Identify the user or client on the context
const contextIdentity = identifyContext(context);
// User defined window in ms that this field can be accessed for before the call is expired
const windowMs = (window ? ms(window as ms.StringValue) : DEFAULT_WINDOW) as number;
// String key for this field
const fieldIdentity = getFieldIdentity(
info.fieldName,
identityArgs || DEFAULT_FIELD_IDENTITY_ARGS,
args,
);

// User configured maximum calls to this field
const maxCalls = max || DEFAULT_MAX;
// Call count could be determined by the lenght of the array value, but most commonly 1
const callCount = (arrayLengthField && get(args, [arrayLengthField, 'length'])) || 1;
// Allinclusive 'identity' for this resolver call
const identity: Identity = { contextIdentity, fieldIdentity };
// Timestamp of this call to be save for future ref
const timestamp = Date.now();
// Create an array of callCount length, filled with the current timestamp
const newTimestamps = [...new Array(callCount || 1)].map(() => timestamp);

// We set these new timestamps in a temporary memory cache so we can enforce
// ratelimits across queries batched in a single request.
const batchedTimestamps = batchRequestCache.set({
context,
fieldIdentity,
newTimestamps,
});

// Fetch timestamps from previous requests out of the store
// and get all the timestamps that haven't expired
const filteredAccessTimestamps = (await store.getForIdentity(identity)).filter(t => {
return t + windowMs > Date.now();
});

// Flag indicating requests limit reached
const limitReached = filteredAccessTimestamps.length + batchedTimestamps.length > maxCalls;

// Confogure access timestamps to save according to uncountRejected setting
const timestampsToStore: readonly any[] = [
...filteredAccessTimestamps,
...(!uncountRejected || !limitReached ? batchedTimestamps : []),
];

// Save these access timestamps for future requests.
if (!readOnly) {
await store.setForIdentity(identity, timestampsToStore, windowMs);
}

// Field level custom message or a global formatting function
const errorMessage =
message ||
formatError({
contextIdentity,
fieldIdentity,
fieldName: info.fieldName,
max: maxCalls,
window: windowMs,
});

// Returns an error message or undefined if no error
return limitReached ? errorMessage : undefined;
};

return rateLimiter;
};

export { getGraphQLRateLimiter, getFieldIdentity };
34 changes: 34 additions & 0 deletions packages/plugins/rate-limiter/src/in-memory-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Store } from './store';
import { Identity } from './types';

interface StoreData {
// Object of fields identified by the field name + potentially args.
readonly [identity: string]: {
// Array of calls for a given field identity
readonly [fieldIdentity: string]: readonly number[];
};
}

class InMemoryStore implements Store {
// The store is mutable.
// tslint:disable-next-line readonly-keyword
public state: StoreData = {};

public setForIdentity(identity: Identity, timestamps: readonly number[]): void {
// tslint:disable-next-line no-object-mutation
this.state = {
...this.state,
[identity.contextIdentity]: {
...this.state[identity.contextIdentity],
[identity.fieldIdentity]: [...timestamps],
},
};
}

public getForIdentity(identity: Identity): readonly number[] {
const ctxState = this.state[identity.contextIdentity];
return (ctxState && ctxState[identity.fieldIdentity]) || [];
}
}

export { InMemoryStore };
15 changes: 13 additions & 2 deletions packages/plugins/rate-limiter/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import { GraphQLResolveInfo, responsePathAsArray } from 'graphql';
import { getGraphQLRateLimiter, GraphQLRateLimitConfig } from 'graphql-rate-limit';
import { minimatch } from 'minimatch';
import { mapMaybePromise, Plugin } from '@envelop/core';
import { useOnResolve } from '@envelop/on-resolve';
import { createGraphQLError, getDirectiveExtensions } from '@graphql-tools/utils';
import { getGraphQLRateLimiter } from './get-graphql-rate-limiter.js';
import { InMemoryStore } from './in-memory-store.js';
import { RateLimitError } from './rate-limit-error.js';
import { RedisStore } from './redis-store.js';
import { Store } from './store.js';
import {
FormatErrorInput,
GraphQLRateLimitConfig,
GraphQLRateLimitDirectiveArgs,
Identity,
Options,
} from './types.js';

export {
FormatErrorInput,
Expand All @@ -15,7 +26,7 @@ export {
RateLimitError,
RedisStore,
Store,
} from 'graphql-rate-limit';
};

export type IdentifyFn<ContextType = unknown> = (context: ContextType) => string;

Expand Down
10 changes: 10 additions & 0 deletions packages/plugins/rate-limiter/src/rate-limit-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class RateLimitError extends Error {
public readonly isRateLimitError = true;

public constructor(message: string) {
super(message);
Object.setPrototypeOf(this, RateLimitError.prototype);
}
}

export { RateLimitError };
53 changes: 53 additions & 0 deletions packages/plugins/rate-limiter/src/redis-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/* eslint-disable promise/param-names */
import type { Store } from './store.js';
import type { Identity } from './types.js';

class RedisStore implements Store {
public store: any;

private readonly nameSpacedKeyPrefix: string = 'redis-store-id::';

public constructor(redisStoreInstance: unknown) {
this.store = redisStoreInstance;
}

public setForIdentity(
identity: Identity,
timestamps: readonly number[],
windowMs?: number,
): Promise<void> {
return new Promise<void>((resolve, reject): void => {
const expiry = windowMs
? ['EX', Math.ceil((Date.now() + windowMs - Math.max(...timestamps)) / 1000)]
: [];
this.store.set(
[this.generateNamedSpacedKey(identity), JSON.stringify([...timestamps]), ...expiry],
(err: Error | null): void => {
if (err) return reject(err);
return resolve();
},
);
});
}

public async getForIdentity(identity: Identity): Promise<readonly number[]> {
return new Promise<readonly number[]>((res, rej): void => {
this.store.get(
this.generateNamedSpacedKey(identity),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(err: Error | null, obj: any): void => {
if (err) {
return rej(err);
}
return res(obj ? JSON.parse(obj) : []);
},
);
});
}

private readonly generateNamedSpacedKey = (identity: Identity): string => {
return `${this.nameSpacedKeyPrefix}${identity.contextIdentity}:${identity.fieldIdentity}`;
};
}

export { RedisStore };
Loading

0 comments on commit 75ae40e

Please sign in to comment.