From c3dd2c3525b42fcab773e0ae8a637caea5c33558 Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Tue, 20 Aug 2024 19:59:56 +0300 Subject: [PATCH] Refactor Rate Limit plugin (#2292) * Refactor Rate Limit plugin * chore(dependencies): updated changesets for modified dependencies * Ignore changesets * .. * Fix field identity support --------- Co-authored-by: github-actions[bot] --- ...@envelop_rate-limiter-2292-dependencies.md | 7 + .changeset/chilled-shirts-develop.md | 26 ++ .changeset/early-deers-roll.md | 5 + .changeset/fuzzy-donkeys-walk.md | 17 + .changeset/olive-rings-enjoy.md | 18 + .changeset/sharp-spoons-jam.md | 5 + .prettierignore | 1 + packages/core/src/utils.ts | 29 ++ packages/plugins/on-resolve/src/index.ts | 89 ++-- packages/plugins/rate-limiter/package.json | 4 +- packages/plugins/rate-limiter/src/index.ts | 191 ++++++--- packages/plugins/rate-limiter/src/utils.ts | 11 - .../tests/use-rate-limiter.spec.ts | 405 ++++++++++++++---- pnpm-lock.yaml | 18 +- 14 files changed, 633 insertions(+), 193 deletions(-) create mode 100644 .changeset/@envelop_rate-limiter-2292-dependencies.md create mode 100644 .changeset/chilled-shirts-develop.md create mode 100644 .changeset/early-deers-roll.md create mode 100644 .changeset/fuzzy-donkeys-walk.md create mode 100644 .changeset/olive-rings-enjoy.md create mode 100644 .changeset/sharp-spoons-jam.md delete mode 100644 packages/plugins/rate-limiter/src/utils.ts diff --git a/.changeset/@envelop_rate-limiter-2292-dependencies.md b/.changeset/@envelop_rate-limiter-2292-dependencies.md new file mode 100644 index 0000000000000..445f579882c79 --- /dev/null +++ b/.changeset/@envelop_rate-limiter-2292-dependencies.md @@ -0,0 +1,7 @@ +--- +"@envelop/rate-limiter": patch +--- +dependencies updates: + - Updated dependency [`graphql-rate-limit@^3.3.0` ↗︎](https://www.npmjs.com/package/graphql-rate-limit/v/3.3.0) (from `3.3.0`, in `dependencies`) + - Added dependency [`@graphql-tools/utils@^10.5.4` ↗︎](https://www.npmjs.com/package/@graphql-tools/utils/v/10.5.4) (to `dependencies`) + - Added dependency [`minimatch@^10.0.1` ↗︎](https://www.npmjs.com/package/minimatch/v/10.0.1) (to `dependencies`) diff --git a/.changeset/chilled-shirts-develop.md b/.changeset/chilled-shirts-develop.md new file mode 100644 index 0000000000000..c4b76532507d6 --- /dev/null +++ b/.changeset/chilled-shirts-develop.md @@ -0,0 +1,26 @@ +--- +'@envelop/rate-limiter': minor +--- + +Now you can define a custom string interpolation function to be used in the rate limit message. This +is useful when you want to include dynamic values in the message. + +```ts +useRateLimiter({ + configByField: [ + { + type: 'Query', + field: 'search', // You can also use glob patterns + max: 10, + window: '1m', + message: + 'My custom message with interpolated values: ${args.searchTerm} and ${context.user.id}' + } + ], + interpolateMessage: (message, args, context) => { + return message.replace(/\${(.*?)}/g, (_, key) => { + return key.split('.').reduce((acc, part) => acc[part], { args, context }) + }) + } +}) +``` diff --git a/.changeset/early-deers-roll.md b/.changeset/early-deers-roll.md new file mode 100644 index 0000000000000..d2c1bef308782 --- /dev/null +++ b/.changeset/early-deers-roll.md @@ -0,0 +1,5 @@ +--- +'@envelop/on-resolve': patch +--- + +Refactor the plugin to avoid extra promises with \`mapMaybePromise\` diff --git a/.changeset/fuzzy-donkeys-walk.md b/.changeset/fuzzy-donkeys-walk.md new file mode 100644 index 0000000000000..7480e1f867e2b --- /dev/null +++ b/.changeset/fuzzy-donkeys-walk.md @@ -0,0 +1,17 @@ +--- +'@envelop/rate-limiter': minor +--- + +New directive SDL; + +```graphql +directive @rateLimit( + max: Int + window: String + message: String + identityArgs: [String] + arrayLengthField: String + readOnly: Boolean + uncountRejected: Boolean +) on FIELD_DEFINITION +``` diff --git a/.changeset/olive-rings-enjoy.md b/.changeset/olive-rings-enjoy.md new file mode 100644 index 0000000000000..31f6fda8582fc --- /dev/null +++ b/.changeset/olive-rings-enjoy.md @@ -0,0 +1,18 @@ +--- +'@envelop/rate-limiter': minor +--- + +Programmatic API to define rate limit configuration in addition to directives + +```ts +useRateLimiter({ + configByField: [ + { + type: 'Query', + field: 'search', // You can also use glob patterns + max: 10, + window: '1m' + } + ] +}) +``` diff --git a/.changeset/sharp-spoons-jam.md b/.changeset/sharp-spoons-jam.md new file mode 100644 index 0000000000000..88f9f03ecc3de --- /dev/null +++ b/.changeset/sharp-spoons-jam.md @@ -0,0 +1,5 @@ +--- +'@envelop/core': patch +--- + +Export `mapMaybePromise` and `isPromise` diff --git a/.prettierignore b/.prettierignore index e6e27024323d8..bdfce19bcf4e9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,3 +9,4 @@ examples/sveltekit .next pnpm-lock.yaml .husky +.changeset/ diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 0bcb53c3a1898..9623917836233 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -224,3 +224,32 @@ export function errorAsyncIterator( return stream; } + +export function isPromise(value: any): value is Promise { + return value?.then !== undefined; +} + +export function mapMaybePromise( + value: PromiseOrValue, + mapper: (v: T) => PromiseOrValue, + errorMapper?: (e: any) => PromiseOrValue, +): PromiseOrValue { + if (isPromise(value)) { + if (errorMapper) { + try { + return value.then(mapper, errorMapper); + } catch (e) { + return errorMapper(e); + } + } + return value.then(mapper); + } + if (errorMapper) { + try { + return mapper(value); + } catch (e) { + return errorMapper(e); + } + } + return mapper(value); +} diff --git a/packages/plugins/on-resolve/src/index.ts b/packages/plugins/on-resolve/src/index.ts index 1487b7ce1d2a7..a3c964a04d061 100644 --- a/packages/plugins/on-resolve/src/index.ts +++ b/packages/plugins/on-resolve/src/index.ts @@ -5,7 +5,7 @@ import { isIntrospectionType, isObjectType, } from 'graphql'; -import { Plugin, PromiseOrValue } from '@envelop/core'; +import { mapMaybePromise, Plugin, PromiseOrValue } from '@envelop/core'; export type Resolver = ( root: unknown, @@ -63,39 +63,62 @@ export function useOnResolve = {}>( let resolver = (field.resolve || defaultFieldResolver) as Resolver; - field.resolve = async (root, args, context, info) => { - const afterResolve = await onResolve({ - root, - args, - context, - info, - resolver, - replaceResolver: newResolver => { - resolver = newResolver; - }, - }); - - let result; - try { - result = await resolver(root, args, context, info); - } catch (err) { - result = err as Error; - } - - if (typeof afterResolve === 'function') { - await afterResolve({ - result, - setResult: newResult => { - result = newResult; + field.resolve = (root, args, context, info) => + mapMaybePromise( + onResolve({ + root, + args, + context, + info, + resolver, + replaceResolver: newResolver => { + resolver = newResolver; }, - }); - } - - if (result instanceof Error) { - throw result; - } - return result; - }; + }), + afterResolve => { + if (typeof afterResolve === 'function') { + try { + return mapMaybePromise( + resolver(root, args, context, info), + result => + mapMaybePromise( + afterResolve({ + result, + setResult: newResult => { + result = newResult; + }, + }), + () => result, + ), + errorResult => + mapMaybePromise( + afterResolve({ + result: errorResult, + setResult: newResult => { + errorResult = newResult; + }, + }), + () => { + throw errorResult; + }, + ), + ); + } catch (err) { + let errorResult = err; + return mapMaybePromise( + afterResolve({ + result: errorResult, + setResult: newResult => { + errorResult = newResult; + }, + }), + () => errorResult, + ); + } + } + return resolver(root, args, context, info); + }, + ); field[hasWrappedResolveSymbol] = true; } diff --git a/packages/plugins/rate-limiter/package.json b/packages/plugins/rate-limiter/package.json index a7b8d5e7d3a64..b26bcf71cebf8 100644 --- a/packages/plugins/rate-limiter/package.json +++ b/packages/plugins/rate-limiter/package.json @@ -52,7 +52,9 @@ }, "dependencies": { "@envelop/on-resolve": "^4.1.0", - "graphql-rate-limit": "3.3.0", + "@graphql-tools/utils": "^10.5.4", + "graphql-rate-limit": "^3.3.0", + "minimatch": "^10.0.1", "tslib": "^2.5.0" }, "devDependencies": { diff --git a/packages/plugins/rate-limiter/src/index.ts b/packages/plugins/rate-limiter/src/index.ts index 586c1dd4c57bb..090b024ff5b0b 100644 --- a/packages/plugins/rate-limiter/src/index.ts +++ b/packages/plugins/rate-limiter/src/index.ts @@ -1,10 +1,9 @@ -import { GraphQLResolveInfo, IntValueNode, StringValueNode } from 'graphql'; +import { GraphQLResolveInfo, responsePathAsArray } from 'graphql'; import { getGraphQLRateLimiter, GraphQLRateLimitConfig } from 'graphql-rate-limit'; -import { Plugin } from '@envelop/core'; +import { minimatch } from 'minimatch'; +import { mapMaybePromise, Plugin } from '@envelop/core'; import { useOnResolve } from '@envelop/on-resolve'; -import { getDirective } from './utils.js'; - -export * from './utils.js'; +import { createGraphQLError, getDirectiveExtensions } from '@graphql-tools/utils'; export { FormatErrorInput, @@ -18,14 +17,41 @@ export { Store, } from 'graphql-rate-limit'; -export class UnauthenticatedError extends Error {} - export type IdentifyFn = (context: ContextType) => string; +export type MessageInterpolator = ( + message: string, + identifier: string, + params: { + root: unknown; + args: Record; + context: ContextType; + info: GraphQLResolveInfo; + }, +) => string; + export const DIRECTIVE_SDL = /* GraphQL */ ` - directive @rateLimit(max: Int, window: String, message: String) on FIELD_DEFINITION + directive @rateLimit( + max: Int + window: String + message: String + identityArgs: [String] + arrayLengthField: String + readOnly: Boolean + uncountRejected: Boolean + ) on FIELD_DEFINITION `; +export type RateLimitDirectiveArgs = { + max?: number; + window?: string; + message?: string; + identityArgs?: string[]; + arrayLengthField?: string; + readOnly?: boolean; + uncountRejected?: boolean; +}; + export type RateLimiterPluginOptions = { identifyFn: IdentifyFn; rateLimitDirectiveName?: 'rateLimit' | string; @@ -36,73 +62,130 @@ export type RateLimiterPluginOptions = { context: unknown; info: GraphQLResolveInfo; }) => void; + interpolateMessage?: MessageInterpolator; + configByField?: ConfigByField[]; } & Omit; +export interface ConfigByField extends RateLimitDirectiveArgs { + type: string; + field: string; + identifyFn?: IdentifyFn; +} + +export const defaultInterpolateMessageFn: MessageInterpolator = (message, identifier) => + interpolateByArgs(message, { id: identifier }); + interface RateLimiterContext { rateLimiterFn: ReturnType; } export const useRateLimiter = (options: RateLimiterPluginOptions): Plugin => { const rateLimiterFn = getGraphQLRateLimiter({ - ...options, // Pass through all available options + ...options, identifyContext: options.identifyFn, }); + const interpolateMessage = options.interpolateMessage || defaultInterpolateMessageFn; + return { onPluginInit({ addPlugin }) { addPlugin( - useOnResolve(async ({ args, root, context, info }) => { - const rateLimitDirectiveNode = getDirective( - info, - options.rateLimitDirectiveName || 'rateLimit', - ); - - if (rateLimitDirectiveNode && rateLimitDirectiveNode.arguments) { - const maxNode = rateLimitDirectiveNode.arguments.find(arg => arg.name.value === 'max') - ?.value as IntValueNode; - const windowNode = rateLimitDirectiveNode.arguments.find( - arg => arg.name.value === 'window', - )?.value as StringValueNode; - const messageNode = rateLimitDirectiveNode.arguments.find( - arg => arg.name.value === 'message', - )?.value as IntValueNode; - - const message = messageNode.value; - const max = parseInt(maxNode.value); - const window = windowNode.value; - const id = options.identifyFn(context); - - const errorMessage = await context.rateLimiterFn( - { parent: root, args, context, info }, - { - max, - window, - message: interpolate(message, { - id, - }), - }, - ); - if (errorMessage) { - if (options.onRateLimitError) { - options.onRateLimitError({ - error: errorMessage, - identifier: id, - context, - info, - }); - } + useOnResolve(({ root, args, context, info }) => { + const field = info.parentType.getFields()[info.fieldName]; + if (field) { + const directives = getDirectiveExtensions<{ + rateLimit?: RateLimitDirectiveArgs; + }>(field); + const rateLimitDefs = directives?.rateLimit; + + let rateLimitDef = rateLimitDefs?.[0]; + let identifyFn = options.identifyFn; + let fieldIdentity = false; - if (options.transformError) { - throw options.transformError(errorMessage); + if (!rateLimitDef) { + const foundConfig = options.configByField?.find( + ({ type, field }) => + minimatch(info.parentType.name, type) && minimatch(info.fieldName, field), + ); + if (foundConfig) { + rateLimitDef = foundConfig; + if (foundConfig.identifyFn) { + identifyFn = foundConfig.identifyFn; + fieldIdentity = true; + } } + } + + if (rateLimitDef) { + const message = rateLimitDef.message; + const max = rateLimitDef.max && Number(rateLimitDef.max); + const window = rateLimitDef.window; + const identifier = identifyFn(context); + + return mapMaybePromise( + rateLimiterFn( + { + parent: root, + args: fieldIdentity ? { ...args, identifier } : args, + context, + info, + }, + { + max, + window, + identityArgs: fieldIdentity + ? ['identifier', ...(rateLimitDef.identityArgs || [])] + : rateLimitDef.identityArgs, + arrayLengthField: rateLimitDef.arrayLengthField, + uncountRejected: rateLimitDef.uncountRejected, + readOnly: rateLimitDef.readOnly, + message: + message && identifier + ? interpolateMessage(message, identifier, { + root, + args, + context, + info, + }) + : undefined, + }, + ), + errorMessage => { + if (errorMessage) { + if (options.onRateLimitError) { + options.onRateLimitError({ + error: errorMessage, + identifier, + context, + info, + }); + } + + if (options.transformError) { + throw options.transformError(errorMessage); + } - throw new Error(errorMessage); + throw createGraphQLError(errorMessage, { + extensions: { + http: { + statusCode: 429, + headers: { + 'Retry-After': window, + }, + }, + }, + path: responsePathAsArray(info.path), + nodes: info.fieldNodes, + }); + } + }, + ); } } }), ); }, - async onContextBuilding({ extendContext }) { + onContextBuilding({ extendContext }) { extendContext({ rateLimiterFn, }); @@ -110,6 +193,6 @@ export const useRateLimiter = (options: RateLimiterPluginOptions): Plugin args[key.trim()]); } diff --git a/packages/plugins/rate-limiter/src/utils.ts b/packages/plugins/rate-limiter/src/utils.ts deleted file mode 100644 index 88a5f3f3072ca..0000000000000 --- a/packages/plugins/rate-limiter/src/utils.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { DirectiveNode, GraphQLObjectType, GraphQLResolveInfo } from 'graphql'; - -export function getDirective(info: GraphQLResolveInfo, name: string): null | DirectiveNode { - const { parentType, fieldName, schema } = info; - const schemaType = schema.getType(parentType.name) as GraphQLObjectType; - const field = schemaType.getFields()[fieldName]; - const astNode = field.astNode; - const rateLimitDirective = astNode?.directives?.find(d => d.name.value === name); - - return rateLimitDirective || null; -} diff --git a/packages/plugins/rate-limiter/tests/use-rate-limiter.spec.ts b/packages/plugins/rate-limiter/tests/use-rate-limiter.spec.ts index 961967775627b..b0f4d4be11e70 100644 --- a/packages/plugins/rate-limiter/tests/use-rate-limiter.spec.ts +++ b/packages/plugins/rate-limiter/tests/use-rate-limiter.spec.ts @@ -2,14 +2,15 @@ import { assertSingleExecutionValue, createTestkit } from '@envelop/testing'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { DIRECTIVE_SDL, IdentifyFn, useRateLimiter } from '../src/index.js'; -describe('useRateLimiter', () => { - const delay = (ms: number) => { - return new Promise(resolve => setTimeout(resolve, ms)); - }; - const identifyFn: IdentifyFn = () => '0.0.0.0'; - - const schemaWithDirective = makeExecutableSchema({ - typeDefs: ` +describe('Rate-Limiter', () => { + describe('Directive', () => { + const delay = (ms: number) => { + return new Promise(resolve => setTimeout(resolve, ms)); + }; + const identifyFn: IdentifyFn = () => '0.0.0.0'; + + const schemaWithDirective = makeExecutableSchema({ + typeDefs: ` ${DIRECTIVE_SDL} type Query { @@ -21,70 +22,70 @@ describe('useRateLimiter', () => { unlimited: String } `, - resolvers: { - Query: { - limited: (root, args, context) => 'limited', - unlimited: (root, args, context) => 'unlimited', + resolvers: { + Query: { + limited: (root, args, context) => 'limited', + unlimited: (root, args, context) => 'unlimited', + }, }, - }, - }); + }); - it('Should allow unlimited calls', async () => { - const testInstance = createTestkit( - [ - useRateLimiter({ - identifyFn, - }), - ], - schemaWithDirective, - ); - - testInstance.execute(`query { unlimited }`); - await testInstance.execute(`query { unlimited }`); - const result = await testInstance.execute(`query { unlimited }`); - assertSingleExecutionValue(result); - expect(result.errors).toBeUndefined(); - expect(result.data?.unlimited).toBe('unlimited'); - }); + it('Should allow unlimited calls', async () => { + const testInstance = createTestkit( + [ + useRateLimiter({ + identifyFn, + }), + ], + schemaWithDirective, + ); - it('Should allow calls with enough delay', async () => { - const testInstance = createTestkit( - [ - useRateLimiter({ - identifyFn, - }), - ], - schemaWithDirective, - ); - - await testInstance.execute(`query { limited }`); - await delay(300); - const result = await testInstance.execute(`query { limited }`); - assertSingleExecutionValue(result); - expect(result.errors).toBeUndefined(); - expect(result.data?.limited).toBe('limited'); - }); + testInstance.execute(`query { unlimited }`); + await testInstance.execute(`query { unlimited }`); + const result = await testInstance.execute(`query { unlimited }`); + assertSingleExecutionValue(result); + expect(result.errors).toBeUndefined(); + expect(result.data?.unlimited).toBe('unlimited'); + }); - it('Should limit calls', async () => { - const testInstance = createTestkit( - [ - useRateLimiter({ - identifyFn, - }), - ], - schemaWithDirective, - ); - await testInstance.execute(`query { limited }`); - const result = await testInstance.execute(`query { limited }`); - assertSingleExecutionValue(result); - expect(result.errors!.length).toBe(1); - expect(result.errors![0].message).toBe('too many calls'); - expect(result.errors![0].path).toEqual(['limited']); - }); + it('Should allow calls with enough delay', async () => { + const testInstance = createTestkit( + [ + useRateLimiter({ + identifyFn, + }), + ], + schemaWithDirective, + ); - it('Should interpolate {{ id }}', async () => { - const schema = makeExecutableSchema({ - typeDefs: ` + await testInstance.execute(`query { limited }`); + await delay(300); + const result = await testInstance.execute(`query { limited }`); + assertSingleExecutionValue(result); + expect(result.errors).toBeUndefined(); + expect(result.data?.limited).toBe('limited'); + }); + + it('Should limit calls', async () => { + const testInstance = createTestkit( + [ + useRateLimiter({ + identifyFn, + }), + ], + schemaWithDirective, + ); + await testInstance.execute(`query { limited }`); + const result = await testInstance.execute(`query { limited }`); + assertSingleExecutionValue(result); + expect(result.errors!.length).toBe(1); + expect(result.errors![0].message).toBe('too many calls'); + expect(result.errors![0].path).toEqual(['limited']); + }); + + it('Should interpolate {{ id }}', async () => { + const schema = makeExecutableSchema({ + typeDefs: ` ${DIRECTIVE_SDL} type Query { @@ -96,29 +97,257 @@ describe('useRateLimiter', () => { unlimited: String } `, - resolvers: { - Query: { - limited: (root, args, context) => 'limited', - unlimited: (root, args, context) => 'unlimited', + resolvers: { + Query: { + limited: (root, args, context) => 'limited', + unlimited: (root, args, context) => 'unlimited', + }, }, - }, + }); + + const testInstance = createTestkit( + [ + useRateLimiter({ + identifyFn, + }), + ], + schema, + ); + await testInstance.execute(`query { limited }`); + const result = await testInstance.execute(`query { limited }`); + + assertSingleExecutionValue(result); + + expect(result.errors!.length).toBe(1); + expect(result.errors![0].message).toBe(`too many calls for ${identifyFn({})}`); + expect(result.errors![0].path).toEqual(['limited']); + }); + }); + + describe('Programmatic', () => { + it('should throw an error if the rate limit is exceeded', async () => { + let numberOfCalls = 0; + const schema = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + type Query { + foo: String + } + `, + resolvers: { + Query: { + foo: () => { + numberOfCalls++; + return 'bar'; + }, + }, + }, + }); + const testkit = createTestkit( + [ + useRateLimiter({ + identifyFn: (ctx: any) => ctx.userId, + configByField: [ + { + type: 'Query', + field: 'foo', + max: 5, + window: '5s', + }, + ], + }), + ], + schema, + ); + const query = /* GraphQL */ ` + { + foo + } + `; + const executeQuery = () => + testkit.execute( + query, + {}, + { + userId: '1', + }, + ); + for (let i = 0; i < 5; i++) { + const result = await executeQuery(); + + expect(result).toEqual({ + data: { + foo: 'bar', + }, + }); + } + const result = await executeQuery(); + + assertSingleExecutionValue(result); + + // Resolver shouldn't be called + expect(numberOfCalls).toBe(5); + expect(result.data?.foo).toBeFalsy(); + const firstError = result.errors?.[0]; + expect(firstError?.message).toBe("You are trying to access 'foo' too often"); + expect(firstError?.path).toEqual(['foo']); }); + it('should reset tokens when the ttl is expired', async () => { + const schema = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + type Query { + foo: String + } + `, + resolvers: { + Query: { + foo: () => 'bar', + }, + }, + }); + const testkit = createTestkit( + [ + useRateLimiter({ + identifyFn: (ctx: any) => ctx.userId, + configByField: [ + { + type: 'Query', + field: 'foo', + max: 5, + window: '1s', + }, + ], + }), + ], + schema, + ); + const query = /* GraphQL */ ` + { + foo + } + `; + const executeQuery = () => + testkit.execute( + query, + {}, + { + userId: '1', + }, + ); + for (let i = 0; i < 5; i++) { + const result = await executeQuery(); - const testInstance = createTestkit( - [ - useRateLimiter({ - identifyFn, - }), - ], - schema, - ); - await testInstance.execute(`query { limited }`); - const result = await testInstance.execute(`query { limited }`); - - assertSingleExecutionValue(result); - - expect(result.errors!.length).toBe(1); - expect(result.errors![0].message).toBe(`too many calls for ${identifyFn({})}`); - expect(result.errors![0].path).toEqual(['limited']); + expect(result).toEqual({ + data: { + foo: 'bar', + }, + }); + } + await new Promise(resolve => setTimeout(resolve, 1000)); + const result = await executeQuery(); + + assertSingleExecutionValue(result); + + expect(result.errors?.length).toBeFalsy(); + expect(result.data?.foo).toBe('bar'); + }); + it('should provide different tokens for different identifiers', async () => { + const schema = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + type Query { + foo: String + } + `, + resolvers: { + Query: { + foo: () => 'bar', + }, + }, + }); + const testkit = createTestkit( + [ + useRateLimiter({ + identifyFn: (ctx: any) => ctx.userId, + configByField: [ + { + type: 'Query', + field: 'foo', + max: 1, + message: 'Rate limit of "Query.foo" exceeded for "{{ id }}"', + window: '1s', + }, + ], + }), + ], + schema, + ); + const query = /* GraphQL */ ` + { + foo + } + `; + for (let i = 0; i < 2; i++) { + const executeQuery = () => testkit.execute(query, {}, { userId: `User${i}` }); + const resultSuccessful = await executeQuery(); + + expect(resultSuccessful).toEqual({ + data: { + foo: 'bar', + }, + }); + + const resultFails = await executeQuery(); + assertSingleExecutionValue(resultFails); + expect(resultFails.data?.foo).toBeFalsy(); + const firstError = resultFails.errors?.[0]; + expect(firstError?.message).toBe(`Rate limit of "Query.foo" exceeded for "User${i}"`); + expect(firstError?.path).toEqual(['foo']); + } + expect.assertions(8); + }); + it('should return other fields even if one of them fails', async () => { + const schema = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + type Query { + foo: String + bar: String + } + `, + resolvers: { + Query: { + foo: () => 'FOO', + bar: () => 'BAR', + }, + }, + }); + const testkit = createTestkit( + [ + useRateLimiter({ + identifyFn: (ctx: any) => ctx.userId, + configByField: [ + { + type: 'Query', + field: 'foo', + max: 1, + message: 'Rate limit of "Query.foo" exceeded for "{{ id }}"', + window: '1s', + }, + ], + }), + ], + schema, + ); + const query = /* GraphQL */ ` + { + foo + bar + } + `; + const executeQuery = () => testkit.execute(query, {}, { userId: 'MYUSER' }); + await executeQuery(); + const result = await executeQuery(); + assertSingleExecutionValue(result); + expect(result.data?.bar).toBe('BAR'); + expect(result.errors?.[0]?.message).toBe(`Rate limit of "Query.foo" exceeded for "MYUSER"`); + }); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1430d25340c56..f20e4e0202505 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1306,9 +1306,15 @@ importers: '@envelop/on-resolve': specifier: ^4.1.0 version: 4.1.0(@envelop/core@packages+core+dist)(graphql@16.6.0) + '@graphql-tools/utils': + specifier: ^10.5.4 + version: 10.5.4(graphql@16.6.0) graphql-rate-limit: - specifier: 3.3.0 + specifier: ^3.3.0 version: 3.3.0(graphql-middleware@6.1.35(graphql@16.6.0))(graphql@16.6.0) + minimatch: + specifier: ^10.0.1 + version: 10.0.1 tslib: specifier: ^2.5.0 version: 2.5.0 @@ -6058,8 +6064,8 @@ packages: elkjs@0.8.2: resolution: {integrity: sha512-L6uRgvZTH+4OF5NE/MBbzQx/WYpru1xCBE9respNj6qznEewGUIfhzmm7horWWxbNO2M0WckQypGctR8lH79xQ==} - elliptic@6.5.6: - resolution: {integrity: sha512-mpzdtpeCLuS3BmE3pO3Cpp5bbjlOPY2Q0PgoF+Od1XZrHLYI28Xe3ossCmYCQt11FQKEYd9+PF8jymTvtWJSHQ==} + elliptic@6.5.7: + resolution: {integrity: sha512-ESVCtTwiA+XhY3wyh24QqRGBoP3rEdDUl3EDUUo9tft074fi19IrdpH7hLCMMP3CIj7jb3W96rn8lt/BqIlt5Q==} emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} @@ -15914,7 +15920,7 @@ snapshots: browserify-rsa: 4.1.0 create-hash: 1.2.0 create-hmac: 1.1.7 - elliptic: 6.5.6 + elliptic: 6.5.7 hash-base: 3.0.4 inherits: 2.0.4 parse-asn1: 5.1.7 @@ -16393,7 +16399,7 @@ snapshots: create-ecdh@4.0.4: dependencies: bn.js: 4.12.0 - elliptic: 6.5.6 + elliptic: 6.5.7 create-hash@1.2.0: dependencies: @@ -16917,7 +16923,7 @@ snapshots: elkjs@0.8.2: {} - elliptic@6.5.6: + elliptic@6.5.7: dependencies: bn.js: 4.12.0 brorand: 1.1.0