@envelop/resource-limitations 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/index.d.ts +45 -0
- package/index.js +255 -0
- package/index.mjs +247 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
## `@envelop/resource-limitations`
|
|
2
|
+
|
|
3
|
+
This plugins uses `extended-valiations` concept ([details here](https://github.com/dotansimha/envelop/tree/main/packages/plugins/extended-validation#envelopextended-validation)) for implemeting a resource-limitations rate-limit similar to GitHub GraphQL API (see https://docs.github.com/en/graphql/overview/resource-limitations for more details)
|
|
4
|
+
|
|
5
|
+
## Getting Started
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
yarn add @envelop/resource-limitations
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage Example
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { envelop } from '@envelop/core';
|
|
15
|
+
import { useResourceLimitations } from '@envelop/resource-limitations';
|
|
16
|
+
|
|
17
|
+
const getEnveloped = envelop({
|
|
18
|
+
plugins: [
|
|
19
|
+
// ... other plugins ...
|
|
20
|
+
useResourceLimitations({
|
|
21
|
+
nodeCostLimit: 500000, // optional, default to 500000
|
|
22
|
+
paginationArgumentMaximum: 100, // optional, default to 100
|
|
23
|
+
paginationArgumentMinimum: 1, // optional, default to 1
|
|
24
|
+
paginationArgumentScalars: ['ConnectionInt'], // optional, use if connections use a different scalar type as the argument instead of `Int`
|
|
25
|
+
extensions: false, // set this to `true` in order to add the calculated const to the response of queries
|
|
26
|
+
}),
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
```
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Plugin } from '@envelop/types';
|
|
2
|
+
import { ExtendedValidationRule } from '@envelop/extended-validation';
|
|
3
|
+
import { ExecutionArgs } from 'graphql';
|
|
4
|
+
export declare const defaultNodeCostLimit = 500000;
|
|
5
|
+
export declare const defaultPaginationArgumentMaximum = 100;
|
|
6
|
+
export declare const defaultPaginationArgumentMinimum = 1;
|
|
7
|
+
export declare type ResourceLimitationValidationRuleParams = {
|
|
8
|
+
nodeCostLimit: number;
|
|
9
|
+
paginationArgumentMaximum: number;
|
|
10
|
+
paginationArgumentMinimum: number;
|
|
11
|
+
paginationArgumentTypes?: string[];
|
|
12
|
+
reportNodeCost?: (cost: number, executionArgs: ExecutionArgs) => void;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Validate whether a user is allowed to execute a certain GraphQL operation.
|
|
16
|
+
*/
|
|
17
|
+
export declare const ResourceLimitationValidationRule: (params: ResourceLimitationValidationRuleParams) => ExtendedValidationRule;
|
|
18
|
+
declare type UseResourceLimitationsParams = {
|
|
19
|
+
/**
|
|
20
|
+
* The node cost limit for rejecting a operation.
|
|
21
|
+
* @default 500000
|
|
22
|
+
*/
|
|
23
|
+
nodeCostLimit?: number;
|
|
24
|
+
/**
|
|
25
|
+
* The custom scalar types accepted for connection arguments.
|
|
26
|
+
*/
|
|
27
|
+
paginationArgumentScalars?: string[];
|
|
28
|
+
/**
|
|
29
|
+
* The maximum value accepted for connection arguments.
|
|
30
|
+
* @default 100
|
|
31
|
+
*/
|
|
32
|
+
paginationArgumentMaximum?: number;
|
|
33
|
+
/**
|
|
34
|
+
* The minimum value accepted for connection arguments.
|
|
35
|
+
* @default 1
|
|
36
|
+
*/
|
|
37
|
+
paginationArgumentMinimum?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Whether the resourceLimitations.nodeCost field should be included within the execution result extensions map.
|
|
40
|
+
* @default false
|
|
41
|
+
*/
|
|
42
|
+
extensions?: boolean;
|
|
43
|
+
};
|
|
44
|
+
export declare const useResourceLimitations: (params?: UseResourceLimitationsParams | undefined) => Plugin;
|
|
45
|
+
export {};
|
package/index.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
const graphql = require('graphql');
|
|
6
|
+
const values_js = require('graphql/execution/values.js');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Returns true if the provided object implements the AsyncIterator protocol via
|
|
10
|
+
* implementing a `Symbol.asyncIterator` method.
|
|
11
|
+
*
|
|
12
|
+
* Source: https://github.com/graphql/graphql-js/blob/main/src/jsutils/isAsyncIterable.ts
|
|
13
|
+
*/
|
|
14
|
+
function isAsyncIterable(maybeAsyncIterable) {
|
|
15
|
+
return typeof (maybeAsyncIterable === null || maybeAsyncIterable === void 0 ? void 0 : maybeAsyncIterable[Symbol.asyncIterator]) === 'function';
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A utility function for hanlding `onExecuteDone` hook result, for simplifying the hanlding of AsyncIterable returned from `execute`.
|
|
19
|
+
*
|
|
20
|
+
* @param payload The payload send to `onExecuteDone` hook function
|
|
21
|
+
* @param fn The handler to be executed on each result
|
|
22
|
+
* @returns a subscription for streamed results, or undefined in case of an non-async
|
|
23
|
+
*/
|
|
24
|
+
function handleStreamOrSingleExecutionResult(payload, fn) {
|
|
25
|
+
if (isAsyncIterable(payload.result)) {
|
|
26
|
+
return { onNext: fn };
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
fn({
|
|
30
|
+
args: payload.args,
|
|
31
|
+
result: payload.result,
|
|
32
|
+
setResult: payload.setResult,
|
|
33
|
+
});
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SYMBOL_EXTENDED_VALIDATION_RULES = Symbol('SYMBOL_EXTENDED_VALIDATION_RULES');
|
|
39
|
+
const useExtendedValidation = (options) => {
|
|
40
|
+
let schemaTypeInfo;
|
|
41
|
+
return {
|
|
42
|
+
onSchemaChange({ schema }) {
|
|
43
|
+
schemaTypeInfo = new graphql.TypeInfo(schema);
|
|
44
|
+
},
|
|
45
|
+
onParse({ context, extendContext }) {
|
|
46
|
+
var _a;
|
|
47
|
+
const rules = (_a = context === null || context === void 0 ? void 0 : context[SYMBOL_EXTENDED_VALIDATION_RULES]) !== null && _a !== void 0 ? _a : [];
|
|
48
|
+
rules.push(...options.rules);
|
|
49
|
+
extendContext({
|
|
50
|
+
[SYMBOL_EXTENDED_VALIDATION_RULES]: rules,
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
onExecute({ args, setResultAndStopExecution }) {
|
|
54
|
+
const rules = args.contextValue[SYMBOL_EXTENDED_VALIDATION_RULES];
|
|
55
|
+
const errors = [];
|
|
56
|
+
const typeInfo = schemaTypeInfo || new graphql.TypeInfo(args.schema);
|
|
57
|
+
const validationContext = new graphql.ValidationContext(args.schema, args.document, typeInfo, e => {
|
|
58
|
+
errors.push(e);
|
|
59
|
+
});
|
|
60
|
+
const visitor = graphql.visitInParallel(rules.map(rule => rule(validationContext, args)));
|
|
61
|
+
graphql.visit(args.document, graphql.visitWithTypeInfo(typeInfo, visitor));
|
|
62
|
+
for (const rule of rules) {
|
|
63
|
+
rule(validationContext, args);
|
|
64
|
+
}
|
|
65
|
+
if (errors.length > 0) {
|
|
66
|
+
let result = {
|
|
67
|
+
data: null,
|
|
68
|
+
errors,
|
|
69
|
+
};
|
|
70
|
+
if (options.onValidationFailed) {
|
|
71
|
+
options.onValidationFailed({ args, result, setResult: newResult => (result = newResult) });
|
|
72
|
+
}
|
|
73
|
+
return setResultAndStopExecution(result);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const getWrappedType = (graphqlType) => {
|
|
80
|
+
if (graphqlType instanceof graphql.GraphQLList || graphqlType instanceof graphql.GraphQLNonNull) {
|
|
81
|
+
return getWrappedType(graphqlType.ofType);
|
|
82
|
+
}
|
|
83
|
+
return graphqlType;
|
|
84
|
+
};
|
|
85
|
+
const isValidArgType = (type, paginationArgumentTypes) => type === graphql.GraphQLInt || (graphql.isScalarType(type) && !!paginationArgumentTypes && paginationArgumentTypes.includes(type.name));
|
|
86
|
+
const hasFieldDefConnectionArgs = (field, argumentTypes) => {
|
|
87
|
+
let hasFirst = false;
|
|
88
|
+
let hasLast = false;
|
|
89
|
+
for (const arg of field.args) {
|
|
90
|
+
if (arg.name === 'first' && isValidArgType(arg.type, argumentTypes)) {
|
|
91
|
+
hasFirst = true;
|
|
92
|
+
}
|
|
93
|
+
else if (arg.name === 'last' && isValidArgType(arg.type, argumentTypes)) {
|
|
94
|
+
hasLast = true;
|
|
95
|
+
}
|
|
96
|
+
else if (hasLast && hasFirst) {
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { hasFirst, hasLast };
|
|
101
|
+
};
|
|
102
|
+
const buildMissingPaginationFieldErrorMessage = (params) => `Missing pagination argument for field '${params.fieldName}'. ` +
|
|
103
|
+
`Please provide ` +
|
|
104
|
+
(params.hasFirst && params.hasLast ? "either the 'first' or 'last'" : params.hasFirst ? "the 'first'" : "the 'last'") +
|
|
105
|
+
' field argument.';
|
|
106
|
+
const buildInvalidPaginationRangeErrorMessage = (params) => `Invalid pagination argument for field ${params.fieldName}. ` +
|
|
107
|
+
`The value for the '${params.argumentName}' argument must be an integer within ${params.paginationArgumentMinimum}-${params.paginationArgumentMaximum}.`;
|
|
108
|
+
const defaultNodeCostLimit = 500000;
|
|
109
|
+
const defaultPaginationArgumentMaximum = 100;
|
|
110
|
+
const defaultPaginationArgumentMinimum = 1;
|
|
111
|
+
/**
|
|
112
|
+
* Validate whether a user is allowed to execute a certain GraphQL operation.
|
|
113
|
+
*/
|
|
114
|
+
const ResourceLimitationValidationRule = (params) => (context, executionArgs) => {
|
|
115
|
+
const { paginationArgumentMaximum, paginationArgumentMinimum } = params;
|
|
116
|
+
const nodeCostStack = [];
|
|
117
|
+
let totalNodeCost = 0;
|
|
118
|
+
const connectionFieldMap = new WeakSet();
|
|
119
|
+
return {
|
|
120
|
+
Field: {
|
|
121
|
+
enter(fieldNode) {
|
|
122
|
+
const fieldDef = context.getFieldDef();
|
|
123
|
+
// if it is not found the query is invalid and graphql validation will complain
|
|
124
|
+
if (fieldDef != null) {
|
|
125
|
+
const argumentValues = values_js.getArgumentValues(fieldDef, fieldNode, executionArgs.variableValues);
|
|
126
|
+
const type = getWrappedType(fieldDef.type);
|
|
127
|
+
if (type instanceof graphql.GraphQLObjectType && type.name.endsWith('Connection')) {
|
|
128
|
+
let nodeCost = 1;
|
|
129
|
+
connectionFieldMap.add(fieldNode);
|
|
130
|
+
const { hasFirst, hasLast } = hasFieldDefConnectionArgs(fieldDef, params.paginationArgumentTypes);
|
|
131
|
+
if (hasFirst === false && hasLast === false) {
|
|
132
|
+
// eslint-disable-next-line no-console
|
|
133
|
+
console.warn('Encountered paginated field without pagination arguments.');
|
|
134
|
+
}
|
|
135
|
+
else if (hasFirst === true || hasLast === true) {
|
|
136
|
+
if ('first' in argumentValues === false && 'last' in argumentValues === false) {
|
|
137
|
+
context.reportError(new graphql.GraphQLError(buildMissingPaginationFieldErrorMessage({
|
|
138
|
+
fieldName: fieldDef.name,
|
|
139
|
+
hasFirst,
|
|
140
|
+
hasLast,
|
|
141
|
+
}), fieldNode));
|
|
142
|
+
}
|
|
143
|
+
else if ('first' in argumentValues === true && 'last' in argumentValues === false) {
|
|
144
|
+
if (argumentValues.first < paginationArgumentMinimum || argumentValues.first > paginationArgumentMaximum) {
|
|
145
|
+
context.reportError(new graphql.GraphQLError(buildInvalidPaginationRangeErrorMessage({
|
|
146
|
+
paginationArgumentMaximum,
|
|
147
|
+
paginationArgumentMinimum,
|
|
148
|
+
argumentName: 'first',
|
|
149
|
+
fieldName: fieldDef.name,
|
|
150
|
+
}), fieldNode));
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
// eslint-disable-next-line dot-notation
|
|
154
|
+
nodeCost = argumentValues['first'];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else if ('last' in argumentValues === true && 'false' in argumentValues === false) {
|
|
158
|
+
if (argumentValues.last < paginationArgumentMinimum || argumentValues.last > paginationArgumentMaximum) {
|
|
159
|
+
context.reportError(new graphql.GraphQLError(buildInvalidPaginationRangeErrorMessage({
|
|
160
|
+
paginationArgumentMaximum,
|
|
161
|
+
paginationArgumentMinimum,
|
|
162
|
+
argumentName: 'last',
|
|
163
|
+
fieldName: fieldDef.name,
|
|
164
|
+
}), fieldNode));
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
// eslint-disable-next-line dot-notation
|
|
168
|
+
nodeCost = argumentValues['last'];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
context.reportError(new graphql.GraphQLError(buildMissingPaginationFieldErrorMessage({
|
|
173
|
+
fieldName: fieldDef.name,
|
|
174
|
+
hasFirst,
|
|
175
|
+
hasLast,
|
|
176
|
+
}), fieldNode));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
nodeCostStack.push(nodeCost);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
leave(node) {
|
|
184
|
+
if (connectionFieldMap.delete(node)) {
|
|
185
|
+
totalNodeCost = totalNodeCost + nodeCostStack.reduce((a, b) => a * b, 1);
|
|
186
|
+
nodeCostStack.pop();
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
Document: {
|
|
191
|
+
leave(documentNode) {
|
|
192
|
+
var _a;
|
|
193
|
+
if (totalNodeCost === 0) {
|
|
194
|
+
totalNodeCost = 1;
|
|
195
|
+
}
|
|
196
|
+
if (totalNodeCost > params.nodeCostLimit) {
|
|
197
|
+
context.reportError(new graphql.GraphQLError(`Cannot request more than ${params.nodeCostLimit} nodes in a single document. Please split your operation into multiple sub operations or reduce the amount of requested nodes.`, documentNode));
|
|
198
|
+
}
|
|
199
|
+
(_a = params.reportNodeCost) === null || _a === void 0 ? void 0 : _a.call(params, totalNodeCost, executionArgs);
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
const useResourceLimitations = (params) => {
|
|
205
|
+
var _a, _b, _c, _d;
|
|
206
|
+
const paginationArgumentMaximum = (_a = params === null || params === void 0 ? void 0 : params.paginationArgumentMaximum) !== null && _a !== void 0 ? _a : defaultPaginationArgumentMaximum;
|
|
207
|
+
const paginationArgumentMinimum = (_b = params === null || params === void 0 ? void 0 : params.paginationArgumentMinimum) !== null && _b !== void 0 ? _b : defaultPaginationArgumentMinimum;
|
|
208
|
+
const nodeCostLimit = (_c = params === null || params === void 0 ? void 0 : params.nodeCostLimit) !== null && _c !== void 0 ? _c : defaultNodeCostLimit;
|
|
209
|
+
const extensions = (_d = params === null || params === void 0 ? void 0 : params.extensions) !== null && _d !== void 0 ? _d : false;
|
|
210
|
+
const nodeCostMap = new WeakMap();
|
|
211
|
+
const handleResult = ({ result, args }) => {
|
|
212
|
+
const nodeCost = nodeCostMap.get(args);
|
|
213
|
+
if (nodeCost != null) {
|
|
214
|
+
result.extensions = {
|
|
215
|
+
...result.extensions,
|
|
216
|
+
resourceLimitations: {
|
|
217
|
+
nodeCost,
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
return {
|
|
223
|
+
onPluginInit({ addPlugin }) {
|
|
224
|
+
addPlugin(useExtendedValidation({
|
|
225
|
+
rules: [
|
|
226
|
+
ResourceLimitationValidationRule({
|
|
227
|
+
nodeCostLimit,
|
|
228
|
+
paginationArgumentMaximum,
|
|
229
|
+
paginationArgumentMinimum,
|
|
230
|
+
paginationArgumentTypes: params === null || params === void 0 ? void 0 : params.paginationArgumentScalars,
|
|
231
|
+
reportNodeCost: extensions
|
|
232
|
+
? (nodeCost, ref) => {
|
|
233
|
+
nodeCostMap.set(ref, nodeCost);
|
|
234
|
+
}
|
|
235
|
+
: undefined,
|
|
236
|
+
}),
|
|
237
|
+
],
|
|
238
|
+
onValidationFailed: params => handleResult(params),
|
|
239
|
+
}));
|
|
240
|
+
},
|
|
241
|
+
onExecute({ args }) {
|
|
242
|
+
return {
|
|
243
|
+
onExecuteDone(payload) {
|
|
244
|
+
return handleStreamOrSingleExecutionResult(payload, ({ result }) => handleResult({ result, args }));
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
exports.ResourceLimitationValidationRule = ResourceLimitationValidationRule;
|
|
252
|
+
exports.defaultNodeCostLimit = defaultNodeCostLimit;
|
|
253
|
+
exports.defaultPaginationArgumentMaximum = defaultPaginationArgumentMaximum;
|
|
254
|
+
exports.defaultPaginationArgumentMinimum = defaultPaginationArgumentMinimum;
|
|
255
|
+
exports.useResourceLimitations = useResourceLimitations;
|
package/index.mjs
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { TypeInfo, ValidationContext, visitInParallel, visit, visitWithTypeInfo, GraphQLObjectType, GraphQLError, GraphQLList, GraphQLNonNull, GraphQLInt, isScalarType } from 'graphql';
|
|
2
|
+
import { getArgumentValues } from 'graphql/execution/values.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns true if the provided object implements the AsyncIterator protocol via
|
|
6
|
+
* implementing a `Symbol.asyncIterator` method.
|
|
7
|
+
*
|
|
8
|
+
* Source: https://github.com/graphql/graphql-js/blob/main/src/jsutils/isAsyncIterable.ts
|
|
9
|
+
*/
|
|
10
|
+
function isAsyncIterable(maybeAsyncIterable) {
|
|
11
|
+
return typeof (maybeAsyncIterable === null || maybeAsyncIterable === void 0 ? void 0 : maybeAsyncIterable[Symbol.asyncIterator]) === 'function';
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A utility function for hanlding `onExecuteDone` hook result, for simplifying the hanlding of AsyncIterable returned from `execute`.
|
|
15
|
+
*
|
|
16
|
+
* @param payload The payload send to `onExecuteDone` hook function
|
|
17
|
+
* @param fn The handler to be executed on each result
|
|
18
|
+
* @returns a subscription for streamed results, or undefined in case of an non-async
|
|
19
|
+
*/
|
|
20
|
+
function handleStreamOrSingleExecutionResult(payload, fn) {
|
|
21
|
+
if (isAsyncIterable(payload.result)) {
|
|
22
|
+
return { onNext: fn };
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
fn({
|
|
26
|
+
args: payload.args,
|
|
27
|
+
result: payload.result,
|
|
28
|
+
setResult: payload.setResult,
|
|
29
|
+
});
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SYMBOL_EXTENDED_VALIDATION_RULES = Symbol('SYMBOL_EXTENDED_VALIDATION_RULES');
|
|
35
|
+
const useExtendedValidation = (options) => {
|
|
36
|
+
let schemaTypeInfo;
|
|
37
|
+
return {
|
|
38
|
+
onSchemaChange({ schema }) {
|
|
39
|
+
schemaTypeInfo = new TypeInfo(schema);
|
|
40
|
+
},
|
|
41
|
+
onParse({ context, extendContext }) {
|
|
42
|
+
var _a;
|
|
43
|
+
const rules = (_a = context === null || context === void 0 ? void 0 : context[SYMBOL_EXTENDED_VALIDATION_RULES]) !== null && _a !== void 0 ? _a : [];
|
|
44
|
+
rules.push(...options.rules);
|
|
45
|
+
extendContext({
|
|
46
|
+
[SYMBOL_EXTENDED_VALIDATION_RULES]: rules,
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
onExecute({ args, setResultAndStopExecution }) {
|
|
50
|
+
const rules = args.contextValue[SYMBOL_EXTENDED_VALIDATION_RULES];
|
|
51
|
+
const errors = [];
|
|
52
|
+
const typeInfo = schemaTypeInfo || new TypeInfo(args.schema);
|
|
53
|
+
const validationContext = new ValidationContext(args.schema, args.document, typeInfo, e => {
|
|
54
|
+
errors.push(e);
|
|
55
|
+
});
|
|
56
|
+
const visitor = visitInParallel(rules.map(rule => rule(validationContext, args)));
|
|
57
|
+
visit(args.document, visitWithTypeInfo(typeInfo, visitor));
|
|
58
|
+
for (const rule of rules) {
|
|
59
|
+
rule(validationContext, args);
|
|
60
|
+
}
|
|
61
|
+
if (errors.length > 0) {
|
|
62
|
+
let result = {
|
|
63
|
+
data: null,
|
|
64
|
+
errors,
|
|
65
|
+
};
|
|
66
|
+
if (options.onValidationFailed) {
|
|
67
|
+
options.onValidationFailed({ args, result, setResult: newResult => (result = newResult) });
|
|
68
|
+
}
|
|
69
|
+
return setResultAndStopExecution(result);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const getWrappedType = (graphqlType) => {
|
|
76
|
+
if (graphqlType instanceof GraphQLList || graphqlType instanceof GraphQLNonNull) {
|
|
77
|
+
return getWrappedType(graphqlType.ofType);
|
|
78
|
+
}
|
|
79
|
+
return graphqlType;
|
|
80
|
+
};
|
|
81
|
+
const isValidArgType = (type, paginationArgumentTypes) => type === GraphQLInt || (isScalarType(type) && !!paginationArgumentTypes && paginationArgumentTypes.includes(type.name));
|
|
82
|
+
const hasFieldDefConnectionArgs = (field, argumentTypes) => {
|
|
83
|
+
let hasFirst = false;
|
|
84
|
+
let hasLast = false;
|
|
85
|
+
for (const arg of field.args) {
|
|
86
|
+
if (arg.name === 'first' && isValidArgType(arg.type, argumentTypes)) {
|
|
87
|
+
hasFirst = true;
|
|
88
|
+
}
|
|
89
|
+
else if (arg.name === 'last' && isValidArgType(arg.type, argumentTypes)) {
|
|
90
|
+
hasLast = true;
|
|
91
|
+
}
|
|
92
|
+
else if (hasLast && hasFirst) {
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { hasFirst, hasLast };
|
|
97
|
+
};
|
|
98
|
+
const buildMissingPaginationFieldErrorMessage = (params) => `Missing pagination argument for field '${params.fieldName}'. ` +
|
|
99
|
+
`Please provide ` +
|
|
100
|
+
(params.hasFirst && params.hasLast ? "either the 'first' or 'last'" : params.hasFirst ? "the 'first'" : "the 'last'") +
|
|
101
|
+
' field argument.';
|
|
102
|
+
const buildInvalidPaginationRangeErrorMessage = (params) => `Invalid pagination argument for field ${params.fieldName}. ` +
|
|
103
|
+
`The value for the '${params.argumentName}' argument must be an integer within ${params.paginationArgumentMinimum}-${params.paginationArgumentMaximum}.`;
|
|
104
|
+
const defaultNodeCostLimit = 500000;
|
|
105
|
+
const defaultPaginationArgumentMaximum = 100;
|
|
106
|
+
const defaultPaginationArgumentMinimum = 1;
|
|
107
|
+
/**
|
|
108
|
+
* Validate whether a user is allowed to execute a certain GraphQL operation.
|
|
109
|
+
*/
|
|
110
|
+
const ResourceLimitationValidationRule = (params) => (context, executionArgs) => {
|
|
111
|
+
const { paginationArgumentMaximum, paginationArgumentMinimum } = params;
|
|
112
|
+
const nodeCostStack = [];
|
|
113
|
+
let totalNodeCost = 0;
|
|
114
|
+
const connectionFieldMap = new WeakSet();
|
|
115
|
+
return {
|
|
116
|
+
Field: {
|
|
117
|
+
enter(fieldNode) {
|
|
118
|
+
const fieldDef = context.getFieldDef();
|
|
119
|
+
// if it is not found the query is invalid and graphql validation will complain
|
|
120
|
+
if (fieldDef != null) {
|
|
121
|
+
const argumentValues = getArgumentValues(fieldDef, fieldNode, executionArgs.variableValues);
|
|
122
|
+
const type = getWrappedType(fieldDef.type);
|
|
123
|
+
if (type instanceof GraphQLObjectType && type.name.endsWith('Connection')) {
|
|
124
|
+
let nodeCost = 1;
|
|
125
|
+
connectionFieldMap.add(fieldNode);
|
|
126
|
+
const { hasFirst, hasLast } = hasFieldDefConnectionArgs(fieldDef, params.paginationArgumentTypes);
|
|
127
|
+
if (hasFirst === false && hasLast === false) {
|
|
128
|
+
// eslint-disable-next-line no-console
|
|
129
|
+
console.warn('Encountered paginated field without pagination arguments.');
|
|
130
|
+
}
|
|
131
|
+
else if (hasFirst === true || hasLast === true) {
|
|
132
|
+
if ('first' in argumentValues === false && 'last' in argumentValues === false) {
|
|
133
|
+
context.reportError(new GraphQLError(buildMissingPaginationFieldErrorMessage({
|
|
134
|
+
fieldName: fieldDef.name,
|
|
135
|
+
hasFirst,
|
|
136
|
+
hasLast,
|
|
137
|
+
}), fieldNode));
|
|
138
|
+
}
|
|
139
|
+
else if ('first' in argumentValues === true && 'last' in argumentValues === false) {
|
|
140
|
+
if (argumentValues.first < paginationArgumentMinimum || argumentValues.first > paginationArgumentMaximum) {
|
|
141
|
+
context.reportError(new GraphQLError(buildInvalidPaginationRangeErrorMessage({
|
|
142
|
+
paginationArgumentMaximum,
|
|
143
|
+
paginationArgumentMinimum,
|
|
144
|
+
argumentName: 'first',
|
|
145
|
+
fieldName: fieldDef.name,
|
|
146
|
+
}), fieldNode));
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
// eslint-disable-next-line dot-notation
|
|
150
|
+
nodeCost = argumentValues['first'];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
else if ('last' in argumentValues === true && 'false' in argumentValues === false) {
|
|
154
|
+
if (argumentValues.last < paginationArgumentMinimum || argumentValues.last > paginationArgumentMaximum) {
|
|
155
|
+
context.reportError(new GraphQLError(buildInvalidPaginationRangeErrorMessage({
|
|
156
|
+
paginationArgumentMaximum,
|
|
157
|
+
paginationArgumentMinimum,
|
|
158
|
+
argumentName: 'last',
|
|
159
|
+
fieldName: fieldDef.name,
|
|
160
|
+
}), fieldNode));
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
// eslint-disable-next-line dot-notation
|
|
164
|
+
nodeCost = argumentValues['last'];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
context.reportError(new GraphQLError(buildMissingPaginationFieldErrorMessage({
|
|
169
|
+
fieldName: fieldDef.name,
|
|
170
|
+
hasFirst,
|
|
171
|
+
hasLast,
|
|
172
|
+
}), fieldNode));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
nodeCostStack.push(nodeCost);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
leave(node) {
|
|
180
|
+
if (connectionFieldMap.delete(node)) {
|
|
181
|
+
totalNodeCost = totalNodeCost + nodeCostStack.reduce((a, b) => a * b, 1);
|
|
182
|
+
nodeCostStack.pop();
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
Document: {
|
|
187
|
+
leave(documentNode) {
|
|
188
|
+
var _a;
|
|
189
|
+
if (totalNodeCost === 0) {
|
|
190
|
+
totalNodeCost = 1;
|
|
191
|
+
}
|
|
192
|
+
if (totalNodeCost > params.nodeCostLimit) {
|
|
193
|
+
context.reportError(new GraphQLError(`Cannot request more than ${params.nodeCostLimit} nodes in a single document. Please split your operation into multiple sub operations or reduce the amount of requested nodes.`, documentNode));
|
|
194
|
+
}
|
|
195
|
+
(_a = params.reportNodeCost) === null || _a === void 0 ? void 0 : _a.call(params, totalNodeCost, executionArgs);
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
const useResourceLimitations = (params) => {
|
|
201
|
+
var _a, _b, _c, _d;
|
|
202
|
+
const paginationArgumentMaximum = (_a = params === null || params === void 0 ? void 0 : params.paginationArgumentMaximum) !== null && _a !== void 0 ? _a : defaultPaginationArgumentMaximum;
|
|
203
|
+
const paginationArgumentMinimum = (_b = params === null || params === void 0 ? void 0 : params.paginationArgumentMinimum) !== null && _b !== void 0 ? _b : defaultPaginationArgumentMinimum;
|
|
204
|
+
const nodeCostLimit = (_c = params === null || params === void 0 ? void 0 : params.nodeCostLimit) !== null && _c !== void 0 ? _c : defaultNodeCostLimit;
|
|
205
|
+
const extensions = (_d = params === null || params === void 0 ? void 0 : params.extensions) !== null && _d !== void 0 ? _d : false;
|
|
206
|
+
const nodeCostMap = new WeakMap();
|
|
207
|
+
const handleResult = ({ result, args }) => {
|
|
208
|
+
const nodeCost = nodeCostMap.get(args);
|
|
209
|
+
if (nodeCost != null) {
|
|
210
|
+
result.extensions = {
|
|
211
|
+
...result.extensions,
|
|
212
|
+
resourceLimitations: {
|
|
213
|
+
nodeCost,
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
return {
|
|
219
|
+
onPluginInit({ addPlugin }) {
|
|
220
|
+
addPlugin(useExtendedValidation({
|
|
221
|
+
rules: [
|
|
222
|
+
ResourceLimitationValidationRule({
|
|
223
|
+
nodeCostLimit,
|
|
224
|
+
paginationArgumentMaximum,
|
|
225
|
+
paginationArgumentMinimum,
|
|
226
|
+
paginationArgumentTypes: params === null || params === void 0 ? void 0 : params.paginationArgumentScalars,
|
|
227
|
+
reportNodeCost: extensions
|
|
228
|
+
? (nodeCost, ref) => {
|
|
229
|
+
nodeCostMap.set(ref, nodeCost);
|
|
230
|
+
}
|
|
231
|
+
: undefined,
|
|
232
|
+
}),
|
|
233
|
+
],
|
|
234
|
+
onValidationFailed: params => handleResult(params),
|
|
235
|
+
}));
|
|
236
|
+
},
|
|
237
|
+
onExecute({ args }) {
|
|
238
|
+
return {
|
|
239
|
+
onExecuteDone(payload) {
|
|
240
|
+
return handleStreamOrSingleExecutionResult(payload, ({ result }) => handleResult({ result, args }));
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
export { ResourceLimitationValidationRule, defaultNodeCostLimit, defaultPaginationArgumentMaximum, defaultPaginationArgumentMinimum, useResourceLimitations };
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@envelop/resource-limitations",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A rate-limit implementation based on resource limitations and static calculation of the score (similar to GitHub GraphQL API)",
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"graphql": "^14.0.0 || ^15.0.0"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/dotansimha/envelop.git",
|
|
12
|
+
"directory": "packages/plugins/resource-limitations"
|
|
13
|
+
},
|
|
14
|
+
"author": "Laurin Quast <laurinquast@googlemail.com>",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"main": "index.js",
|
|
17
|
+
"module": "index.mjs",
|
|
18
|
+
"typings": "index.d.ts",
|
|
19
|
+
"typescript": {
|
|
20
|
+
"definition": "index.d.ts"
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"require": "./index.js",
|
|
25
|
+
"import": "./index.mjs"
|
|
26
|
+
},
|
|
27
|
+
"./*": {
|
|
28
|
+
"require": "./*.js",
|
|
29
|
+
"import": "./*.mjs"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|