Skip to content

Commit 75ae40e

Browse files
enhance(rate-limit): dependency cleanup (#2443)
* 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>
1 parent 15f022d commit 75ae40e

15 files changed

+757
-29
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@envelop/rate-limiter": patch
3+
---
4+
dependencies updates:
5+
- Added dependency [`lodash.get@^4.4.2` ↗︎](https://www.npmjs.com/package/lodash.get/v/4.4.2) (to `dependencies`)
6+
- Added dependency [`ms@^2.1.3` ↗︎](https://www.npmjs.com/package/ms/v/2.1.3) (to `dependencies`)
7+
- Removed dependency [`graphql-middleware@^6.1.35` ↗︎](https://www.npmjs.com/package/graphql-middleware/v/6.1.35) (from `dependencies`)
8+
- Removed dependency [`graphql-rate-limit@^3.3.0` ↗︎](https://www.npmjs.com/package/graphql-rate-limit/v/3.3.0) (from `dependencies`)

packages/plugins/rate-limiter/package.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,19 @@
5353
"dependencies": {
5454
"@envelop/on-resolve": "workspace:^",
5555
"@graphql-tools/utils": "^10.5.4",
56-
"graphql-middleware": "^6.1.35",
57-
"graphql-rate-limit": "^3.3.0",
56+
"lodash.get": "^4.4.2",
5857
"minimatch": "^10.0.1",
58+
"ms": "^2.1.3",
5959
"tslib": "^2.5.0"
6060
},
6161
"devDependencies": {
62-
"@envelop/core": "workspace:^",
62+
"@envelop/core": "workspace:*",
6363
"@graphql-tools/schema": "10.0.18",
64+
"@types/lodash.get": "4.4.9",
65+
"@types/ms": "2.1.0",
66+
"@types/redis-mock": "0.17.3",
6467
"graphql": "16.8.1",
68+
"redis-mock": "0.56.3",
6569
"typescript": "5.7.3"
6670
},
6771
"publishConfig": {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export const getNoOpCache = (): {
2+
set: ({ newTimestamps }: { newTimestamps: number[] }) => number[];
3+
} => ({
4+
set: ({ newTimestamps }: { newTimestamps: number[] }) => newTimestamps,
5+
});
6+
7+
export const getWeakMapCache = (): {
8+
set: ({
9+
context,
10+
fieldIdentity,
11+
newTimestamps,
12+
}: {
13+
context: Record<any, any>;
14+
fieldIdentity: string;
15+
newTimestamps: number[];
16+
}) => any;
17+
} => {
18+
const cache = new WeakMap();
19+
20+
return {
21+
set: ({
22+
context,
23+
fieldIdentity,
24+
newTimestamps,
25+
}: {
26+
context: Record<any, any>;
27+
fieldIdentity: string;
28+
newTimestamps: number[];
29+
}) => {
30+
const currentCalls = cache.get(context) || {};
31+
32+
currentCalls[fieldIdentity] = [...(currentCalls[fieldIdentity] || []), ...newTimestamps];
33+
cache.set(context, currentCalls);
34+
return currentCalls[fieldIdentity];
35+
},
36+
};
37+
};
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import type { GraphQLResolveInfo } from 'graphql';
2+
import get from 'lodash.get';
3+
import ms from 'ms';
4+
import { getNoOpCache, getWeakMapCache } from './batch-request-cache.js';
5+
import { InMemoryStore } from './in-memory-store.js';
6+
import type {
7+
FormatErrorInput,
8+
GraphQLRateLimitConfig,
9+
GraphQLRateLimitDirectiveArgs,
10+
Identity,
11+
} from './types.js';
12+
13+
// Default field options
14+
const DEFAULT_WINDOW = 60 * 1000;
15+
const DEFAULT_MAX = 5;
16+
const DEFAULT_FIELD_IDENTITY_ARGS: readonly string[] = [];
17+
18+
/**
19+
* Returns a string key for the given field + args. With no identityArgs are provided, just the fieldName
20+
* will be used for the key. If an array of resolveArgs are provided, the values of those will be built
21+
* into the key.
22+
*
23+
* Example:
24+
* (fieldName = 'books', identityArgs: ['id', 'title'], resolveArgs: { id: 1, title: 'Foo', subTitle: 'Bar' })
25+
* => books:1:Foo
26+
*
27+
* @param fieldName
28+
* @param identityArgs
29+
* @param resolveArgs
30+
*/
31+
const getFieldIdentity = (
32+
fieldName: string,
33+
identityArgs: readonly string[],
34+
resolveArgs: unknown,
35+
): string => {
36+
const argsKey = identityArgs.map(arg => get(resolveArgs, arg));
37+
return [fieldName, ...argsKey].join(':');
38+
};
39+
40+
/**
41+
* This is the core rate limiting logic function, APIs (directive, sheild etc.)
42+
* can wrap this or it can be used directly in resolvers.
43+
* @param userConfig - global (usually app-wide) rate limiting config
44+
*/
45+
const getGraphQLRateLimiter = (
46+
// Main config (e.g. the config passed to the createRateLimitDirective func)
47+
userConfig: GraphQLRateLimitConfig,
48+
): ((
49+
{
50+
args,
51+
context,
52+
info,
53+
}: {
54+
parent: any;
55+
args: Record<string, any>;
56+
context: any;
57+
info: GraphQLResolveInfo;
58+
},
59+
{
60+
arrayLengthField,
61+
identityArgs,
62+
max,
63+
window,
64+
message,
65+
uncountRejected,
66+
}: GraphQLRateLimitDirectiveArgs,
67+
) => Promise<string | undefined>) => {
68+
// Default directive config
69+
const defaultConfig = {
70+
enableBatchRequestCache: false,
71+
formatError: ({ fieldName }: FormatErrorInput) => {
72+
return `You are trying to access '${fieldName}' too often`;
73+
},
74+
// Required
75+
identifyContext: () => {
76+
throw new Error('You must implement a createRateLimitDirective.config.identifyContext');
77+
},
78+
store: new InMemoryStore(),
79+
};
80+
81+
const { enableBatchRequestCache, identifyContext, formatError, store } = {
82+
...defaultConfig,
83+
...userConfig,
84+
};
85+
86+
const batchRequestCache = enableBatchRequestCache ? getWeakMapCache() : getNoOpCache();
87+
88+
/**
89+
* Field level rate limiter function that returns the error message or undefined
90+
* @param args - pass the resolver args as an object
91+
* @param config - field level config
92+
*/
93+
const rateLimiter = async (
94+
// Resolver args
95+
{
96+
args,
97+
context,
98+
info,
99+
}: {
100+
parent: any;
101+
args: Record<string, any>;
102+
context: any;
103+
info: GraphQLResolveInfo;
104+
},
105+
// Field level config (e.g. the directive parameters)
106+
{
107+
arrayLengthField,
108+
identityArgs,
109+
max,
110+
window,
111+
message,
112+
readOnly,
113+
uncountRejected,
114+
}: GraphQLRateLimitDirectiveArgs,
115+
): Promise<string | undefined> => {
116+
// Identify the user or client on the context
117+
const contextIdentity = identifyContext(context);
118+
// User defined window in ms that this field can be accessed for before the call is expired
119+
const windowMs = (window ? ms(window as ms.StringValue) : DEFAULT_WINDOW) as number;
120+
// String key for this field
121+
const fieldIdentity = getFieldIdentity(
122+
info.fieldName,
123+
identityArgs || DEFAULT_FIELD_IDENTITY_ARGS,
124+
args,
125+
);
126+
127+
// User configured maximum calls to this field
128+
const maxCalls = max || DEFAULT_MAX;
129+
// Call count could be determined by the lenght of the array value, but most commonly 1
130+
const callCount = (arrayLengthField && get(args, [arrayLengthField, 'length'])) || 1;
131+
// Allinclusive 'identity' for this resolver call
132+
const identity: Identity = { contextIdentity, fieldIdentity };
133+
// Timestamp of this call to be save for future ref
134+
const timestamp = Date.now();
135+
// Create an array of callCount length, filled with the current timestamp
136+
const newTimestamps = [...new Array(callCount || 1)].map(() => timestamp);
137+
138+
// We set these new timestamps in a temporary memory cache so we can enforce
139+
// ratelimits across queries batched in a single request.
140+
const batchedTimestamps = batchRequestCache.set({
141+
context,
142+
fieldIdentity,
143+
newTimestamps,
144+
});
145+
146+
// Fetch timestamps from previous requests out of the store
147+
// and get all the timestamps that haven't expired
148+
const filteredAccessTimestamps = (await store.getForIdentity(identity)).filter(t => {
149+
return t + windowMs > Date.now();
150+
});
151+
152+
// Flag indicating requests limit reached
153+
const limitReached = filteredAccessTimestamps.length + batchedTimestamps.length > maxCalls;
154+
155+
// Confogure access timestamps to save according to uncountRejected setting
156+
const timestampsToStore: readonly any[] = [
157+
...filteredAccessTimestamps,
158+
...(!uncountRejected || !limitReached ? batchedTimestamps : []),
159+
];
160+
161+
// Save these access timestamps for future requests.
162+
if (!readOnly) {
163+
await store.setForIdentity(identity, timestampsToStore, windowMs);
164+
}
165+
166+
// Field level custom message or a global formatting function
167+
const errorMessage =
168+
message ||
169+
formatError({
170+
contextIdentity,
171+
fieldIdentity,
172+
fieldName: info.fieldName,
173+
max: maxCalls,
174+
window: windowMs,
175+
});
176+
177+
// Returns an error message or undefined if no error
178+
return limitReached ? errorMessage : undefined;
179+
};
180+
181+
return rateLimiter;
182+
};
183+
184+
export { getGraphQLRateLimiter, getFieldIdentity };
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Store } from './store';
2+
import { Identity } from './types';
3+
4+
interface StoreData {
5+
// Object of fields identified by the field name + potentially args.
6+
readonly [identity: string]: {
7+
// Array of calls for a given field identity
8+
readonly [fieldIdentity: string]: readonly number[];
9+
};
10+
}
11+
12+
class InMemoryStore implements Store {
13+
// The store is mutable.
14+
// tslint:disable-next-line readonly-keyword
15+
public state: StoreData = {};
16+
17+
public setForIdentity(identity: Identity, timestamps: readonly number[]): void {
18+
// tslint:disable-next-line no-object-mutation
19+
this.state = {
20+
...this.state,
21+
[identity.contextIdentity]: {
22+
...this.state[identity.contextIdentity],
23+
[identity.fieldIdentity]: [...timestamps],
24+
},
25+
};
26+
}
27+
28+
public getForIdentity(identity: Identity): readonly number[] {
29+
const ctxState = this.state[identity.contextIdentity];
30+
return (ctxState && ctxState[identity.fieldIdentity]) || [];
31+
}
32+
}
33+
34+
export { InMemoryStore };

packages/plugins/rate-limiter/src/index.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
import { GraphQLResolveInfo, responsePathAsArray } from 'graphql';
2-
import { getGraphQLRateLimiter, GraphQLRateLimitConfig } from 'graphql-rate-limit';
32
import { minimatch } from 'minimatch';
43
import { mapMaybePromise, Plugin } from '@envelop/core';
54
import { useOnResolve } from '@envelop/on-resolve';
65
import { createGraphQLError, getDirectiveExtensions } from '@graphql-tools/utils';
6+
import { getGraphQLRateLimiter } from './get-graphql-rate-limiter.js';
7+
import { InMemoryStore } from './in-memory-store.js';
8+
import { RateLimitError } from './rate-limit-error.js';
9+
import { RedisStore } from './redis-store.js';
10+
import { Store } from './store.js';
11+
import {
12+
FormatErrorInput,
13+
GraphQLRateLimitConfig,
14+
GraphQLRateLimitDirectiveArgs,
15+
Identity,
16+
Options,
17+
} from './types.js';
718

819
export {
920
FormatErrorInput,
@@ -15,7 +26,7 @@ export {
1526
RateLimitError,
1627
RedisStore,
1728
Store,
18-
} from 'graphql-rate-limit';
29+
};
1930

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
class RateLimitError extends Error {
2+
public readonly isRateLimitError = true;
3+
4+
public constructor(message: string) {
5+
super(message);
6+
Object.setPrototypeOf(this, RateLimitError.prototype);
7+
}
8+
}
9+
10+
export { RateLimitError };
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/* eslint-disable promise/param-names */
2+
import type { Store } from './store.js';
3+
import type { Identity } from './types.js';
4+
5+
class RedisStore implements Store {
6+
public store: any;
7+
8+
private readonly nameSpacedKeyPrefix: string = 'redis-store-id::';
9+
10+
public constructor(redisStoreInstance: unknown) {
11+
this.store = redisStoreInstance;
12+
}
13+
14+
public setForIdentity(
15+
identity: Identity,
16+
timestamps: readonly number[],
17+
windowMs?: number,
18+
): Promise<void> {
19+
return new Promise<void>((resolve, reject): void => {
20+
const expiry = windowMs
21+
? ['EX', Math.ceil((Date.now() + windowMs - Math.max(...timestamps)) / 1000)]
22+
: [];
23+
this.store.set(
24+
[this.generateNamedSpacedKey(identity), JSON.stringify([...timestamps]), ...expiry],
25+
(err: Error | null): void => {
26+
if (err) return reject(err);
27+
return resolve();
28+
},
29+
);
30+
});
31+
}
32+
33+
public async getForIdentity(identity: Identity): Promise<readonly number[]> {
34+
return new Promise<readonly number[]>((res, rej): void => {
35+
this.store.get(
36+
this.generateNamedSpacedKey(identity),
37+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
38+
(err: Error | null, obj: any): void => {
39+
if (err) {
40+
return rej(err);
41+
}
42+
return res(obj ? JSON.parse(obj) : []);
43+
},
44+
);
45+
});
46+
}
47+
48+
private readonly generateNamedSpacedKey = (identity: Identity): string => {
49+
return `${this.nameSpacedKeyPrefix}${identity.contextIdentity}:${identity.fieldIdentity}`;
50+
};
51+
}
52+
53+
export { RedisStore };

0 commit comments

Comments
 (0)