diff --git a/.changeset/@envelop_rate-limiter-2443-dependencies.md b/.changeset/@envelop_rate-limiter-2443-dependencies.md new file mode 100644 index 0000000000..90182f8a46 --- /dev/null +++ b/.changeset/@envelop_rate-limiter-2443-dependencies.md @@ -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`) diff --git a/packages/plugins/rate-limiter/package.json b/packages/plugins/rate-limiter/package.json index 96ff00b47d..62fd14fd95 100644 --- a/packages/plugins/rate-limiter/package.json +++ b/packages/plugins/rate-limiter/package.json @@ -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": { diff --git a/packages/plugins/rate-limiter/src/batch-request-cache.ts b/packages/plugins/rate-limiter/src/batch-request-cache.ts new file mode 100644 index 0000000000..1b9aca1ac0 --- /dev/null +++ b/packages/plugins/rate-limiter/src/batch-request-cache.ts @@ -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; + fieldIdentity: string; + newTimestamps: number[]; + }) => any; +} => { + const cache = new WeakMap(); + + return { + set: ({ + context, + fieldIdentity, + newTimestamps, + }: { + context: Record; + fieldIdentity: string; + newTimestamps: number[]; + }) => { + const currentCalls = cache.get(context) || {}; + + currentCalls[fieldIdentity] = [...(currentCalls[fieldIdentity] || []), ...newTimestamps]; + cache.set(context, currentCalls); + return currentCalls[fieldIdentity]; + }, + }; +}; diff --git a/packages/plugins/rate-limiter/src/get-graphql-rate-limiter.ts b/packages/plugins/rate-limiter/src/get-graphql-rate-limiter.ts new file mode 100644 index 0000000000..a87d014e31 --- /dev/null +++ b/packages/plugins/rate-limiter/src/get-graphql-rate-limiter.ts @@ -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; + context: any; + info: GraphQLResolveInfo; + }, + { + arrayLengthField, + identityArgs, + max, + window, + message, + uncountRejected, + }: GraphQLRateLimitDirectiveArgs, +) => Promise) => { + // 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; + context: any; + info: GraphQLResolveInfo; + }, + // Field level config (e.g. the directive parameters) + { + arrayLengthField, + identityArgs, + max, + window, + message, + readOnly, + uncountRejected, + }: GraphQLRateLimitDirectiveArgs, + ): Promise => { + // 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 }; diff --git a/packages/plugins/rate-limiter/src/in-memory-store.ts b/packages/plugins/rate-limiter/src/in-memory-store.ts new file mode 100644 index 0000000000..2f35104e1f --- /dev/null +++ b/packages/plugins/rate-limiter/src/in-memory-store.ts @@ -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 }; diff --git a/packages/plugins/rate-limiter/src/index.ts b/packages/plugins/rate-limiter/src/index.ts index 090b024ff5..3bd42e79a1 100644 --- a/packages/plugins/rate-limiter/src/index.ts +++ b/packages/plugins/rate-limiter/src/index.ts @@ -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, @@ -15,7 +26,7 @@ export { RateLimitError, RedisStore, Store, -} from 'graphql-rate-limit'; +}; export type IdentifyFn = (context: ContextType) => string; diff --git a/packages/plugins/rate-limiter/src/rate-limit-error.ts b/packages/plugins/rate-limiter/src/rate-limit-error.ts new file mode 100644 index 0000000000..b1d4cdd519 --- /dev/null +++ b/packages/plugins/rate-limiter/src/rate-limit-error.ts @@ -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 }; diff --git a/packages/plugins/rate-limiter/src/redis-store.ts b/packages/plugins/rate-limiter/src/redis-store.ts new file mode 100644 index 0000000000..388db3cfd2 --- /dev/null +++ b/packages/plugins/rate-limiter/src/redis-store.ts @@ -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 { + return new Promise((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 { + return new Promise((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 }; diff --git a/packages/plugins/rate-limiter/src/store.ts b/packages/plugins/rate-limiter/src/store.ts new file mode 100644 index 0000000000..f65c75bade --- /dev/null +++ b/packages/plugins/rate-limiter/src/store.ts @@ -0,0 +1,26 @@ +import type { Identity } from './types.js'; + +abstract class Store { + /** + * Sets an array of call timestamps in the store for a given identity + * + * @param identity + * @param timestamps + */ + public abstract setForIdentity( + identity: Identity, + timestamps: readonly number[], + windowMs?: number, + ): void | Promise; + + /** + * Gets an array of call timestamps for a given identity. + * + * @param identity + */ + public abstract getForIdentity( + identity: Identity, + ): readonly number[] | Promise; +} + +export { Store }; diff --git a/packages/plugins/rate-limiter/src/types.ts b/packages/plugins/rate-limiter/src/types.ts new file mode 100644 index 0000000000..2c73235165 --- /dev/null +++ b/packages/plugins/rate-limiter/src/types.ts @@ -0,0 +1,105 @@ +import { Store } from './store.js'; + +/** + * Two keys that define the identity for the call to a given + * field resolver with a given context. + */ +export interface Identity { + /** + * The return value from `identifyContext` + */ + readonly contextIdentity: string; + /** + * Returns value from `getFieldIdentity` + */ + readonly fieldIdentity: string; +} + +/** + * + */ +export interface Options { + readonly windowMs: number; + readonly max: number; + readonly callCount?: number; +} + +/** + * GraphQLRateLimitDirectiveArgs: The directive parameters. + * + * myField(id: String): Field @rateLimit(message: "Stop!", window: 100000, max: 10, identityArgs: ["id"]) + */ +export interface GraphQLRateLimitDirectiveArgs { + /** + * Error message used when/if the RateLimit error is thrown + */ + readonly message?: string; + /** + * Window duration in millis. + */ + readonly window?: string; + /** + * Max number of calls within the `window` duration. + */ + readonly max?: number; + /** + * Values to build into the key used to identify the resolve call. + */ + readonly identityArgs?: readonly string[]; + /** + * Limit by the length of an input array + */ + readonly arrayLengthField?: string; + /** + * Prevents registering the current request to the store. + * This can be useful for example when you only wanna rate limit on failed attempts. + */ + readonly readOnly?: boolean; + /** + * Prevents rejected requests (due to limit reach) from being counted. + */ + readonly uncountRejected?: boolean; +} + +/** + * Args passed to the format error callback. + */ +export interface FormatErrorInput { + readonly fieldName: string; + readonly contextIdentity: string; + readonly max: number; + readonly window: number; + readonly fieldIdentity?: string; +} + +/** + * Config object type passed to the directive factory. + */ +export interface GraphQLRateLimitConfig { + /** + * Provide a store to hold info on client requests. + * + * Defaults to an inmemory store if not provided. + */ + readonly store?: Store; + /** + * Return a string to identify the user or client. + * + * Example: + * identifyContext: (context) => context.user.id; + * identifyContext: (context) => context.req.ip; + */ + readonly identifyContext?: (context: any) => string; + /** + * Custom error messages. + */ + readonly formatError?: (input: FormatErrorInput) => string; + /** + * Return an error. + * + * Defaults to new RateLimitError. + */ + readonly createError?: (message: string) => Error; + + readonly enableBatchRequestCache?: boolean; +} diff --git a/packages/plugins/rate-limiter/tests/get-graphql-rate-limiter.spec.ts b/packages/plugins/rate-limiter/tests/get-graphql-rate-limiter.spec.ts new file mode 100644 index 0000000000..3948b029ff --- /dev/null +++ b/packages/plugins/rate-limiter/tests/get-graphql-rate-limiter.spec.ts @@ -0,0 +1,160 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { GraphQLResolveInfo } from 'graphql'; +import { getFieldIdentity, getGraphQLRateLimiter } from '../src/get-graphql-rate-limiter.js'; +import { InMemoryStore } from '../src/in-memory-store.js'; +import { GraphQLRateLimitDirectiveArgs } from '../src/types.js'; + +const sleep = (ms: number) => { + return new Promise(resolve => setTimeout(resolve, ms)); +}; + +test('getFieldIdentity with no identity args', () => { + expect(getFieldIdentity('myField', [], {})).toBe('myField'); + expect(getFieldIdentity('random', [], {})).toBe('random'); +}); + +test('getFieldIdentity with identity args', () => { + expect(getFieldIdentity('myField', ['id'], { id: 2 })).toBe('myField:2'); + expect(getFieldIdentity('myField', ['name', 'id'], { id: 2, name: 'Foo' })).toBe('myField:Foo:2'); + expect(getFieldIdentity('myField', ['name', 'bool'], { bool: true, name: 'Foo' })).toBe( + 'myField:Foo:true', + ); + expect(getFieldIdentity('myField', ['name', 'bool'], {})).toBe('myField::'); + expect(getFieldIdentity('myField', ['name', 'bool'], { name: null })).toBe('myField::'); +}); + +test('getFieldIdentity with nested identity args', () => { + expect(getFieldIdentity('myField', ['item.id'], { item: { id: 2 }, name: 'Foo' })).toBe( + 'myField:2', + ); + expect(getFieldIdentity('myField', ['item.foo'], { item: { id: 2 }, name: 'Foo' })).toBe( + 'myField:', + ); + + const obj = { item: { subItem: { id: 9 } }, name: 'Foo' }; + expect(getFieldIdentity('myField', ['item.subItem.id'], obj)).toBe('myField:9'); + + const objTwo = { item: { subItem: { id: 1 } }, name: 'Foo' }; + expect(getFieldIdentity('myField', ['name', 'item.subItem.id'], objTwo)).toBe('myField:Foo:1'); +}); + +test('getGraphQLRateLimiter with an empty store passes, but second time fails', async () => { + const rateLimit = getGraphQLRateLimiter({ + store: new InMemoryStore(), + identifyContext: context => context.id, + }); + const config = { max: 1, window: '1s' }; + const field = { + parent: {}, + args: {}, + context: { id: '1' }, + info: { fieldName: 'myField' } as any as GraphQLResolveInfo, + }; + expect(await rateLimit(field, config)).toBeFalsy(); + expect(await rateLimit(field, config)).toBe(`You are trying to access 'myField' too often`); +}); + +test('getGraphQLRateLimiter should block a batch of rate limited fields in a single query', async () => { + const rateLimit = getGraphQLRateLimiter({ + store: new InMemoryStore(), + identifyContext: context => context.id, + enableBatchRequestCache: true, + }); + const config = { max: 3, window: '1s' }; + const field = { + parent: {}, + args: {}, + context: { id: '1' }, + info: { fieldName: 'myField' } as any as GraphQLResolveInfo, + }; + const requests = Array.from({ length: 5 }) + .map(() => rateLimit(field, config)) + .map(p => p.catch(e => e)); + + (await Promise.all(requests)).forEach((result, idx) => { + if (idx < 3) expect(result).toBeFalsy(); + else expect(result).toBe(`You are trying to access 'myField' too often`); + }); +}); + +test('getGraphQLRateLimiter timestamps should expire', async () => { + const rateLimit = getGraphQLRateLimiter({ + store: new InMemoryStore(), + identifyContext: context => context.id, + }); + const config = { max: 1, window: '0.5s' }; + const field = { + parent: {}, + args: {}, + context: { id: '1' }, + info: { fieldName: 'myField' } as any as GraphQLResolveInfo, + }; + expect(await rateLimit(field, config)).toBeFalsy(); + expect(await rateLimit(field, config)).toBe(`You are trying to access 'myField' too often`); + await sleep(500); + expect(await rateLimit(field, config)).toBeFalsy(); +}); + +test('getGraphQLRateLimiter uncountRejected should ignore rejections', async () => { + const rateLimit = getGraphQLRateLimiter({ + store: new InMemoryStore(), + identifyContext: context => context.id, + }); + const config = { max: 1, window: '1s', uncountRejected: true }; + const field = { + parent: {}, + args: {}, + context: { id: '1' }, + info: { fieldName: 'myField' } as any as GraphQLResolveInfo, + }; + expect(await rateLimit(field, config)).toBeFalsy(); + await sleep(500); + expect(await rateLimit(field, config)).toBe(`You are trying to access 'myField' too often`); + await sleep(500); + expect(await rateLimit(field, config)).toBeFalsy(); +}); + +test('getGraphQLRateLimiter should limit by callCount if arrayLengthField is passed', async () => { + const rateLimit = getGraphQLRateLimiter({ + store: new InMemoryStore(), + identifyContext: context => context.id, + }); + const config: GraphQLRateLimitDirectiveArgs = { + max: 4, + window: '1s', + arrayLengthField: 'items', + }; + const field = { + parent: {}, + args: { + items: [1, 2, 3, 4, 5], + }, + context: { id: '1' }, + info: { fieldName: 'listOfItems' } as any as GraphQLResolveInfo, + }; + expect(await rateLimit(field, config)).toBe(`You are trying to access 'listOfItems' too often`); +}); + +test('getGraphQLRateLimiter should allow multiple calls to a field if the identityArgs change', async () => { + const rateLimit = getGraphQLRateLimiter({ + store: new InMemoryStore(), + identifyContext: context => context.id, + }); + const config: GraphQLRateLimitDirectiveArgs = { + max: 1, + window: '1s', + identityArgs: ['id'], + }; + const field = { + parent: {}, + args: { + id: '1', + }, + context: { id: '1' }, + info: { fieldName: 'listOfItems' } as any as GraphQLResolveInfo, + }; + expect(await rateLimit(field, config)).toBeFalsy(); + expect(await rateLimit(field, config)).toBe(`You are trying to access 'listOfItems' too often`); + expect(await rateLimit({ ...field, args: { id: '2' } }, config)).toBeFalsy(); + expect(await rateLimit(field, config)).toBe(`You are trying to access 'listOfItems' too often`); +}); diff --git a/packages/plugins/rate-limiter/tests/in-memory-store.spec.ts b/packages/plugins/rate-limiter/tests/in-memory-store.spec.ts new file mode 100644 index 0000000000..501075d691 --- /dev/null +++ b/packages/plugins/rate-limiter/tests/in-memory-store.spec.ts @@ -0,0 +1,29 @@ +import { InMemoryStore } from '../src/in-memory-store.js'; + +test('InMemoryStore sets correct timestamps', () => { + const store = new InMemoryStore(); + store.setForIdentity({ contextIdentity: 'foo', fieldIdentity: 'bar' }, [1, 2, 3]); + expect(store.state).toEqual({ foo: { bar: [1, 2, 3] } }); + + store.setForIdentity({ contextIdentity: 'foo', fieldIdentity: 'bar2' }, [4, 5]); + expect(store.state).toEqual({ + foo: { bar: [1, 2, 3], bar2: [4, 5] }, + }); + + store.setForIdentity({ contextIdentity: 'foo', fieldIdentity: 'bar' }, [10, 20]); + expect(store.state).toEqual({ + foo: { bar: [10, 20], bar2: [4, 5] }, + }); +}); + +test('InMemoryStore get correct timestamps', () => { + const store = new InMemoryStore(); + store.setForIdentity({ contextIdentity: 'foo', fieldIdentity: 'bar' }, [1, 2, 3]); + expect(store.getForIdentity({ contextIdentity: 'foo', fieldIdentity: 'bar' })).toEqual([1, 2, 3]); + + store.setForIdentity({ contextIdentity: 'foo', fieldIdentity: 'bar2' }, [4, 5]); + expect(store.getForIdentity({ contextIdentity: 'foo', fieldIdentity: 'bar2' })).toEqual([4, 5]); + + store.setForIdentity({ contextIdentity: 'foo', fieldIdentity: 'bar' }, [10, 20]); + expect(store.getForIdentity({ contextIdentity: 'foo', fieldIdentity: 'bar' })).toEqual([10, 20]); +}); diff --git a/packages/plugins/rate-limiter/tests/rate-limit-error.spec.ts b/packages/plugins/rate-limiter/tests/rate-limit-error.spec.ts new file mode 100644 index 0000000000..a3d8d3ff27 --- /dev/null +++ b/packages/plugins/rate-limiter/tests/rate-limit-error.spec.ts @@ -0,0 +1,9 @@ +import { RateLimitError } from '../src/rate-limit-error'; + +test('RateLimitError is an Error', () => { + expect(new RateLimitError('Some message')).toBeInstanceOf(Error); +}); + +test('RateLimitError.isRateLimitError is true', () => { + expect(new RateLimitError('Some message').isRateLimitError).toBe(true); +}); diff --git a/packages/plugins/rate-limiter/tests/redis-store.spec.ts b/packages/plugins/rate-limiter/tests/redis-store.spec.ts new file mode 100644 index 0000000000..6620aa57a5 --- /dev/null +++ b/packages/plugins/rate-limiter/tests/redis-store.spec.ts @@ -0,0 +1,30 @@ +import redis from 'redis-mock'; +import { RedisStore } from '../src/redis-store'; + +test('RedisStore sets and gets correct timestamps', async () => { + const storeInstance = new RedisStore(redis.createClient()); + + await storeInstance.setForIdentity({ contextIdentity: 'foo', fieldIdentity: 'bar' }, [1, 2, 3]); + expect( + await storeInstance.getForIdentity({ + contextIdentity: 'foo', + fieldIdentity: 'bar', + }), + ).toEqual([1, 2, 3]); + + await storeInstance.setForIdentity({ contextIdentity: 'foo', fieldIdentity: 'bar2' }, [4, 5]); + expect( + await storeInstance.getForIdentity({ + contextIdentity: 'foo', + fieldIdentity: 'bar2', + }), + ).toEqual([4, 5]); + + await storeInstance.setForIdentity({ contextIdentity: 'foo', fieldIdentity: 'bar' }, [10, 20]); + expect( + await storeInstance.getForIdentity({ + contextIdentity: 'foo', + fieldIdentity: 'bar', + }), + ).toEqual([10, 20]); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a094972ba9..55bfc74046 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1341,28 +1341,40 @@ importers: '@graphql-tools/utils': specifier: ^10.5.4 version: 10.8.1(graphql@16.8.1) - graphql-middleware: - specifier: ^6.1.35 - version: 6.1.35(graphql@16.8.1) - graphql-rate-limit: - specifier: ^3.3.0 - version: 3.3.0(graphql-middleware@6.1.35(graphql@16.8.1))(graphql@16.8.1) + lodash.get: + specifier: ^4.4.2 + version: 4.4.2 minimatch: specifier: ^10.0.1 version: 10.0.1 + ms: + specifier: ^2.1.3 + version: 2.1.3 tslib: specifier: ^2.5.0 version: 2.8.1 devDependencies: '@envelop/core': - specifier: workspace:^ + specifier: workspace:* version: link:../../core/dist '@graphql-tools/schema': specifier: 10.0.18 version: 10.0.18(graphql@16.8.1) + '@types/lodash.get': + specifier: 4.4.9 + version: 4.4.9 + '@types/ms': + specifier: 2.1.0 + version: 2.1.0 + '@types/redis-mock': + specifier: 0.17.3 + version: 0.17.3 graphql: specifier: 16.8.1 version: 16.8.1 + redis-mock: + specifier: 0.56.3 + version: 0.56.3 typescript: specifier: 5.7.3 version: 5.7.3 @@ -4832,6 +4844,9 @@ packages: '@types/katex@0.16.3': resolution: {integrity: sha512-CeVMX9EhVUW8MWnei05eIRks4D5Wscw/W9Byz1s3PA+yJvcdvq9SaDjiUKvRvEgjpdTyJMjQA43ae4KTwsvOPg==} + '@types/lodash.get@4.4.9': + resolution: {integrity: sha512-J5dvW98sxmGnamqf+/aLP87PYXyrha9xIgc2ZlHl6OHMFR2Ejdxep50QfU0abO1+CH6+ugx+8wEUN1toImAinA==} + '@types/lodash@4.14.191': resolution: {integrity: sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==} @@ -4856,6 +4871,9 @@ packages: '@types/ms@0.7.31': resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/mysql@2.15.26': resolution: {integrity: sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==} @@ -4904,6 +4922,12 @@ packages: '@types/react@18.3.18': resolution: {integrity: sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==} + '@types/redis-mock@0.17.3': + resolution: {integrity: sha512-1baXyGxRKEDog8p1ReiypODwiST2n3/0pBbgUKEuv9pBXnY6ttRzKATcW5Xz20ZOl9qkKtPIeq20tHgHSdQBAQ==} + + '@types/redis@2.8.32': + resolution: {integrity: sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==} + '@types/request@2.48.12': resolution: {integrity: sha512-G3sY+NpsA9jnwm0ixhAFQSJ3Q9JkpLZpJbI3GMv0mIAT0y3mRabYeINzal5WOChIiaTEGQYlHOKgkaM9EisWHw==} @@ -7405,12 +7429,6 @@ packages: peerDependencies: graphql: 16.8.1 - graphql-rate-limit@3.3.0: - resolution: {integrity: sha512-mbbEv5z3SjkDLvVVdHi0XrVLavw2Mwo93GIqgQB/fx8dhcNSEv3eYI1OGdp8mhsm/MsZm7hjrRlwQMVRKBVxhA==} - engines: {node: '>=12.0', pnpm: '>=6'} - peerDependencies: - graphql: 16.8.1 - graphql-scalars@1.24.1: resolution: {integrity: sha512-3L553TMPh3YpHQM4x9G4tXcyD+AX8QQOYA6tUn1nrNiWEXE0JfAnWdjoe3WGxRuGjnZrzvHDz62q8S+sSGXXwA==} engines: {node: '>=10'} @@ -10086,6 +10104,10 @@ packages: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} + redis-mock@0.56.3: + resolution: {integrity: sha512-ynaJhqk0Qf3Qajnwvy4aOjS4Mdf9IBkELWtjd+NYhpiqu4QCNq6Vf3Q7c++XRPGiKiwRj9HWr0crcwy7EiPjYQ==} + engines: {node: '>=6'} + redis-parser@3.0.0: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} @@ -15870,7 +15892,7 @@ snapshots: '@types/debug@4.1.7': dependencies: - '@types/ms': 0.7.31 + '@types/ms': 2.1.0 '@types/estree-jsx@1.0.0': dependencies: @@ -15955,6 +15977,10 @@ snapshots: '@types/katex@0.16.3': {} + '@types/lodash.get@4.4.9': + dependencies: + '@types/lodash': 4.14.191 + '@types/lodash@4.14.191': {} '@types/long@4.0.1': {} @@ -15975,6 +16001,8 @@ snapshots: '@types/ms@0.7.31': {} + '@types/ms@2.1.0': {} + '@types/mysql@2.15.26': dependencies: '@types/node': 22.13.4 @@ -16027,6 +16055,14 @@ snapshots: '@types/prop-types': 15.7.3 csstype: 3.0.9 + '@types/redis-mock@0.17.3': + dependencies: + '@types/redis': 2.8.32 + + '@types/redis@2.8.32': + dependencies: + '@types/node': 22.13.4 + '@types/request@2.48.12': dependencies: '@types/caseless': 0.12.5 @@ -19204,16 +19240,6 @@ snapshots: graphql: 16.8.1 lodash.get: 4.4.2 - graphql-rate-limit@3.3.0(graphql-middleware@6.1.35(graphql@16.8.1))(graphql@16.8.1): - dependencies: - '@graphql-tools/utils': 7.10.0(graphql@16.8.1) - graphql: 16.8.1 - graphql-shield: 7.6.5(graphql-middleware@6.1.35(graphql@16.8.1))(graphql@16.8.1) - lodash.get: 4.4.2 - ms: 2.1.3 - transitivePeerDependencies: - - graphql-middleware - graphql-scalars@1.24.1(graphql@16.8.1): dependencies: graphql: 16.8.1 @@ -22772,6 +22798,8 @@ snapshots: redis-errors@1.2.0: {} + redis-mock@0.56.3: {} + redis-parser@3.0.0: dependencies: redis-errors: 1.2.0