@envelop/resource-limitations
Version:
A rate-limit implementation based on resource limitations and static calculation of the score (similar to GitHub GraphQL API)
130 lines (128 loc) • 6.37 kB
JavaScript
import { GraphQLError, GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLObjectType, isScalarType } from "graphql";
import { handleStreamOrSingleExecutionResult } from "@envelop/core";
import { useExtendedValidation } from "@envelop/extended-validation";
import { getArgumentValues } from "@graphql-tools/utils";
//#region src/index.ts
const getWrappedType = (graphqlType) => {
if (graphqlType instanceof GraphQLList || graphqlType instanceof GraphQLNonNull) return getWrappedType(graphqlType.ofType);
return graphqlType;
};
const isValidArgType = (type, paginationArgumentTypes) => type === GraphQLInt || isScalarType(type) && !!paginationArgumentTypes && paginationArgumentTypes.includes(type.name);
const hasFieldDefConnectionArgs = (field, argumentTypes) => {
let hasFirst = false;
let hasLast = false;
for (const arg of field.args) if (arg.name === "first" && isValidArgType(arg.type, argumentTypes)) hasFirst = true;
else if (arg.name === "last" && isValidArgType(arg.type, argumentTypes)) hasLast = true;
else if (hasLast && hasFirst) break;
return {
hasFirst,
hasLast
};
};
const buildMissingPaginationFieldErrorMessage = (params) => `Missing pagination argument for field '${params.fieldName}'. Please provide ` + (params.hasFirst && params.hasLast ? "either the 'first' or 'last'" : params.hasFirst ? "the 'first'" : "the 'last'") + " field argument.";
const buildInvalidPaginationRangeErrorMessage = (params) => `Invalid pagination argument for field '${params.fieldName}'. The value for the '${params.argumentName}' argument must be an integer within ${params.paginationArgumentMinimum}-${params.paginationArgumentMaximum}.`;
const defaultNodeCostLimit = 5e5;
const defaultPaginationArgumentMaximum = 100;
const defaultPaginationArgumentMinimum = 1;
/**
* Validate whether a user is allowed to execute a certain GraphQL operation.
*/
const ResourceLimitationValidationRule = (params) => (context, executionArgs) => {
const { paginationArgumentMaximum, paginationArgumentMinimum } = params;
const nodeCostStack = [];
let totalNodeCost = 0;
const connectionFieldMap = /* @__PURE__ */ new WeakSet();
return {
Field: {
enter(fieldNode) {
const fieldDef = context.getFieldDef();
if (fieldDef != null) {
const argumentValues = getArgumentValues(fieldDef, fieldNode, executionArgs.variableValues || void 0);
const type = getWrappedType(fieldDef.type);
if (type instanceof GraphQLObjectType && type.name.endsWith("Connection")) {
let nodeCost = 1;
connectionFieldMap.add(fieldNode);
const { hasFirst, hasLast } = hasFieldDefConnectionArgs(fieldDef, params.paginationArgumentTypes);
if (hasFirst === false && hasLast === false) console.warn("Encountered paginated field without pagination arguments.");
else if (hasFirst === true || hasLast === true) if ("first" in argumentValues === false && "last" in argumentValues === false || argumentValues["first"] === null && argumentValues["last"] === null) context.reportError(new GraphQLError(buildMissingPaginationFieldErrorMessage({
fieldName: fieldDef.name,
hasFirst,
hasLast
}), fieldNode));
else if ("first" in argumentValues && !argumentValues["last"]) if (argumentValues["first"] < paginationArgumentMinimum || argumentValues["first"] > paginationArgumentMaximum) context.reportError(new GraphQLError(buildInvalidPaginationRangeErrorMessage({
paginationArgumentMaximum,
paginationArgumentMinimum,
argumentName: "first",
fieldName: fieldDef.name
}), fieldNode));
else nodeCost = argumentValues["first"];
else if (!argumentValues["first"] && "last" in argumentValues) if (argumentValues["last"] < paginationArgumentMinimum || argumentValues["last"] > paginationArgumentMaximum) context.reportError(new GraphQLError(buildInvalidPaginationRangeErrorMessage({
paginationArgumentMaximum,
paginationArgumentMinimum,
argumentName: "last",
fieldName: fieldDef.name
}), fieldNode));
else nodeCost = argumentValues["last"];
else context.reportError(new GraphQLError(buildMissingPaginationFieldErrorMessage({
fieldName: fieldDef.name,
hasFirst,
hasLast
}), fieldNode));
nodeCostStack.push(nodeCost);
}
}
},
leave(node) {
if (connectionFieldMap.delete(node)) {
totalNodeCost = totalNodeCost + nodeCostStack.reduce((a, b) => a * b, 1);
nodeCostStack.pop();
}
}
},
Document: { leave(documentNode) {
if (totalNodeCost === 0) totalNodeCost = 1;
if (totalNodeCost > params.nodeCostLimit) 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));
params.reportNodeCost?.(totalNodeCost, executionArgs);
} }
};
};
const useResourceLimitations = (params) => {
const paginationArgumentMaximum = params?.paginationArgumentMaximum ?? defaultPaginationArgumentMaximum;
const paginationArgumentMinimum = params?.paginationArgumentMinimum ?? defaultPaginationArgumentMinimum;
const nodeCostLimit = params?.nodeCostLimit ?? defaultNodeCostLimit;
const extensions = params?.extensions ?? false;
const nodeCostMap = /* @__PURE__ */ new WeakMap();
const handleResult = ({ result, args }) => {
const nodeCost = nodeCostMap.get(args);
if (nodeCost != null) result.extensions = {
...result.extensions,
resourceLimitations: { nodeCost }
};
};
return {
onPluginInit({ addPlugin }) {
addPlugin(useExtendedValidation({
rules: [ResourceLimitationValidationRule({
nodeCostLimit,
paginationArgumentMaximum,
paginationArgumentMinimum,
paginationArgumentTypes: params?.paginationArgumentScalars,
reportNodeCost: extensions ? (nodeCost, ref) => {
nodeCostMap.set(ref, nodeCost);
} : void 0
})],
onValidationFailed: (params) => handleResult(params)
}));
},
onExecute({ args }) {
return { onExecuteDone(payload) {
return handleStreamOrSingleExecutionResult(payload, ({ result }) => handleResult({
result,
args
}));
} };
}
};
};
//#endregion
export { ResourceLimitationValidationRule, defaultNodeCostLimit, defaultPaginationArgumentMaximum, defaultPaginationArgumentMinimum, useResourceLimitations };