UNPKG

@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
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 };