Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/@envelop_rate-limiter-2676-dependencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@envelop/rate-limiter": patch
---
dependencies updates:
- Added dependency [`@types/picomatch@^4.0.2` ↗︎](https://www.npmjs.com/package/@types/picomatch/v/4.0.2) (to `dependencies`)
- Added dependency [`picomatch@^4.0.3` ↗︎](https://www.npmjs.com/package/picomatch/v/4.0.3) (to `dependencies`)
- Removed dependency [`minimatch@^10.0.1` ↗︎](https://www.npmjs.com/package/minimatch/v/10.0.1) (from `dependencies`)
6 changes: 6 additions & 0 deletions .changeset/moody-dodos-post.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@envelop/rate-limiter': patch
---

Massive reduction of performance impact on GraphQL execution. Overhead has been minimalized on
rate-limited fields, and entirely suppressed on other fields.
9 changes: 9 additions & 0 deletions .changeset/open-geckos-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@envelop/rate-limiter': minor
---

Validate the configuration at schema loading time. The plugin now throws an error on invalid
configuration, such as:

- Multiple field configuration matching the same field
- A field configuration matching a field already having a directive
5 changes: 5 additions & 0 deletions .changeset/silver-poems-stay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@envelop/rate-limiter': patch
---

Fixed `rateLimitDirectiveName` option being ignored.
3 changes: 2 additions & 1 deletion packages/plugins/rate-limiter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,11 @@
"dependencies": {
"@envelop/on-resolve": "workspace:^",
"@graphql-tools/utils": "^10.5.4",
"@types/picomatch": "^4.0.2",
"@whatwg-node/promise-helpers": "^1.2.4",
"lodash.get": "^4.4.2",
"minimatch": "^10.0.1",
"ms": "^2.1.3",
"picomatch": "^4.0.3",
"tslib": "^2.5.0"
},
"devDependencies": {
Expand Down
198 changes: 109 additions & 89 deletions packages/plugins/rate-limiter/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { GraphQLResolveInfo, responsePathAsArray } from 'graphql';
import { minimatch } from 'minimatch';
import {
defaultFieldResolver,
GraphQLResolveInfo,
GraphQLSchema,
isObjectType,
responsePathAsArray,
} from 'graphql';
import picomatch from 'picomatch';
import type { Plugin } from '@envelop/core';
import { useOnResolve } from '@envelop/on-resolve';
import { createGraphQLError, getDirectiveExtensions } from '@graphql-tools/utils';
import { handleMaybePromise } from '@whatwg-node/promise-helpers';
import { getGraphQLRateLimiter } from './get-graphql-rate-limiter.js';
Expand Down Expand Up @@ -99,104 +104,119 @@ export const useRateLimiter = (options: RateLimiterPluginOptions): Plugin<RateLi

const interpolateMessage = options.interpolateMessage || defaultInterpolateMessageFn;

const configByField = options.configByField?.map(config => ({
...config,
isMatch: {
type: picomatch(config.type),
field: picomatch(config.field),
},
}));

const directiveName = options.rateLimitDirectiveName ?? 'rateLimit';

return {
onPluginInit({ addPlugin }) {
addPlugin(
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 (!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;
}
}
onSchemaChange({ schema: _schema }) {
if (!_schema) {
return;
}
const schema = _schema as GraphQLSchema;

for (const type of Object.values(schema.getTypeMap())) {
if (!isObjectType(type)) {
continue;
}

for (const field of Object.values(type.getFields())) {
const fieldConfigs = configByField?.filter(
({ isMatch }) => isMatch.type(type.name) && isMatch.field(field.name),
);
if (fieldConfigs && fieldConfigs.length > 1) {
throw new Error(
`Config error: field '${type.name}.${field.name}' has multiple matching configuration`,
);
}
const fieldConfig = fieldConfigs?.[0];

const rateLimitDirective = getDirectiveExtensions(field, schema)[
directiveName
]?.[0] as RateLimitDirectiveArgs;

if (rateLimitDirective && fieldConfig) {
throw new Error(
`Config error: field '${type.name}.${field.name}' has both a configuration and a directive`,
);
}

const rateLimitConfig = { ...(rateLimitDirective ?? fieldConfig) };

if (rateLimitConfig) {
rateLimitConfig.max = rateLimitConfig.max && Number(rateLimitConfig.max);

if (fieldConfig?.identifyFn) {
rateLimitConfig.identityArgs = [
'identifier',
...(rateLimitConfig.identityArgs ?? []),
];
}

if (rateLimitDef) {
const message = rateLimitDef.message;
const max = rateLimitDef.max && Number(rateLimitDef.max);
const window = rateLimitDef.window;
const identifier = identifyFn(context);
const originalResolver = field.resolve ?? defaultFieldResolver;
field.resolve = (parent, args, context, info) => {
const resolverRateLimitConfig = { ...rateLimitConfig };
const executionArgs = { parent, args, context, info };
const identifier = (fieldConfig?.identifyFn ?? options.identifyFn)(context);

if (fieldConfig?.identifyFn) {
executionArgs.args = { identifier, ...args };
}

if (resolverRateLimitConfig.message && identifier) {
const messageArgs = { root: parent, args, context, info };
resolverRateLimitConfig.message = interpolateMessage(
resolverRateLimitConfig.message,
identifier,
messageArgs,
);
}

return handleMaybePromise(
() =>
rateLimiterFn(
{
parent: root,
args: fieldIdentity ? { ...args, identifier } : args,
() => rateLimiterFn(executionArgs, resolverRateLimitConfig),
rateLimitError => {
if (!rateLimitError) {
return originalResolver(parent, args, context, info);
}

if (options.onRateLimitError) {
options.onRateLimitError({
error: rateLimitError,
identifier,
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 createGraphQLError(errorMessage, {
extensions: {
http: {
statusCode: 429,
headers: {
'Retry-After': window,
},
},
},
path: responsePathAsArray(info.path),
nodes: info.fieldNodes,
});
}

if (options.transformError) {
throw options.transformError(rateLimitError);
}

const errorOptions: Parameters<typeof createGraphQLError>[1] = {
extensions: { http: { statusCode: 429 } },
path: responsePathAsArray(info.path),
nodes: info.fieldNodes,
};

if (resolverRateLimitConfig.window) {
errorOptions.extensions.http.headers = {
'Retry-After': resolverRateLimitConfig.window,
};
}

throw createGraphQLError(rateLimitError, errorOptions);
},
);
}
};
}
}),
);
}
}
},
onContextBuilding({ extendContext }) {
extendContext({
Expand Down
100 changes: 99 additions & 1 deletion packages/plugins/rate-limiter/tests/use-rate-limiter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,50 @@ describe('Rate-Limiter', () => {
},
},
});

it('should allow to use a custom directive name', async () => {
const testInstance = createTestkit(
[
useRateLimiter({
identifyFn,
rateLimitDirectiveName: 'customDirective',
}),
],
makeExecutableSchema({
typeDefs: `
directive @customDirective(
max: Int
window: String
message: String
identityArgs: [String]
arrayLengthField: String
readOnly: Boolean
uncountRejected: Boolean
) on FIELD_DEFINITION

type Query {
limited: String @customDirective(
max: 1,
window: "0.1s",
message: "too many calls"
),
unlimited: String
}
`,
resolvers: {
Query: {
limited: (root, args, context) => 'limited',
unlimited: (root, args, context) => 'unlimited',
},
},
}),
);
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 unlimited calls', async () => {
const testInstance = createTestkit(
[
Expand Down Expand Up @@ -349,5 +392,60 @@ describe('Rate-Limiter', () => {
expect(result.data?.bar).toBe('BAR');
expect(result.errors?.[0]?.message).toBe(`Rate limit of "Query.foo" exceeded for "MYUSER"`);
});
it('should throw an error if multiple configuration matches the same field', () => {
const schema = makeExecutableSchema({
typeDefs: /* GraphQL */ `
type Query {
foo: String
}
`,
resolvers: {
Query: {
foo: () => 'bar',
},
},
});
expect(() =>
createTestkit(
[
useRateLimiter({
identifyFn: (ctx: any) => ctx.userId,
configByField: [
{ type: 'Query', field: 'foo' },
{ type: 'Query', field: 'foo' },
],
}),
],
schema,
),
).toThrow(`Config error: field 'Query.foo' has multiple matching configuration`);
});
});
it('should throw an error if a configuration matches a field with a directive', () => {
const schema = makeExecutableSchema({
typeDefs: /* GraphQL */ `
${DIRECTIVE_SDL}

type Query {
foo: String @rateLimit
}
`,
resolvers: {
Query: {
foo: () => 'bar',
},
},
});
expect(() =>
createTestkit(
[
useRateLimiter({
identifyFn: (ctx: any) => ctx.userId,
configByField: [{ type: 'Query', field: 'foo' }],
}),
],
schema,
),
).toThrow(`Config error: field 'Query.foo' has both a configuration and a directive`);
});
});
Loading