gqlcheck
Version:
Performs additional checks on your GraphQL documents and operations to ensure they conform to your rules, whilst allow-listing existing operations and their constituent parts (and allowing overrides on a per-field basis). Rules include max selection set d
408 lines (407 loc) • 17.6 kB
JavaScript
Object.defineProperty(exports, "__esModule", { value: true });
exports.DepthVisitor = DepthVisitor;
const tslib_1 = require("tslib");
const assert = tslib_1.__importStar(require("node:assert"));
const graphql_1 = require("graphql");
const ruleError_js_1 = require("./ruleError.js");
function newDepthInfo() {
return {
current: 0,
max: 0,
detailsByDepth: new Map(),
};
}
function newDepths() {
return {
fields: newDepthInfo(),
lists: newDepthInfo(),
introspectionFields: newDepthInfo(),
introspectionLists: newDepthInfo(),
};
}
function newRoot(node) {
if (node.kind === graphql_1.Kind.OPERATION_DEFINITION) {
return {
type: "operation",
name: node.name?.value,
depths: newDepths(),
fragmentReferences: Object.create(null),
};
}
else {
return {
type: "fragment",
name: node.name.value,
depths: newDepths(),
fragmentReferences: Object.create(null),
};
}
}
function newState() {
return {
roots: [],
complete: false,
};
}
function resolveFragment(fragmentRootByName, depths, operationPath, fragmentName, visitedFragments) {
const fragmentRoot = fragmentRootByName[fragmentName];
if (!fragmentRoot)
return;
if (visitedFragments.includes(fragmentName)) {
return;
}
visitedFragments.push(fragmentName);
try {
// Step 1: add all the fragments own depths
for (const key of Object.keys(fragmentRoot.depths)) {
const { max: fragMax, detailsByDepth: fragDetailsByDepth } = fragmentRoot.depths[key];
if (!depths[key]) {
depths[key] = newDepthInfo();
}
const adjustedMax = depths[key].current + fragMax;
if (adjustedMax > depths[key].max) {
depths[key].max = adjustedMax;
}
for (const [fragDepth, fragDetails] of fragDetailsByDepth) {
const transformedCoords = fragDetails.operationCoords.map((c) => `${operationPath}${c}`);
const depth = depths[key].current + fragDepth;
const details = depths[key].detailsByDepth.get(depth);
if (details) {
// More performant than details.operationCoords.push(...transformedCoords)
transformedCoords.forEach((c) => details.operationCoords.push(c));
}
else {
depths[key].detailsByDepth.set(depth, {
operationCoords: transformedCoords,
nodes: [...fragDetails.nodes],
});
}
}
}
// Step 2: traverse to the next fragment
traverseFragmentReferences(fragmentRootByName, depths, operationPath, fragmentRoot.fragmentReferences, visitedFragments);
}
finally {
const popped = visitedFragments.pop();
assert.equal(popped, fragmentName, "Something went wrong when popping fragment name?");
}
}
function traverseFragmentReferences(fragmentRootByName, depths, basePath, fragmentReferences, visitedFragments) {
for (const [operationPath, depthsByFragmentReference] of Object.entries(fragmentReferences)) {
for (const [fragmentName, spec] of Object.entries(depthsByFragmentReference)) {
for (const key of Object.keys(depths)) {
if (spec.depthValues[key]) {
depths[key].current += spec.depthValues[key];
}
}
resolveFragment(fragmentRootByName, depths, basePath + operationPath + ">", fragmentName, visitedFragments);
for (const key of Object.keys(depths)) {
if (spec.depthValues[key]) {
depths[key].current -= spec.depthValues[key];
}
}
}
}
}
function resolveOperationRoot(fragmentRootByName, operationRoot) {
const depths = newDepths();
for (const key of Object.keys(operationRoot.depths)) {
if (!depths[key]) {
depths[key] = newDepthInfo();
}
depths[key].max = operationRoot.depths[key].max;
depths[key].detailsByDepth = new Map(operationRoot.depths[key].detailsByDepth);
}
traverseFragmentReferences(fragmentRootByName, depths, "", operationRoot.fragmentReferences, []);
return {
name: operationRoot.name,
depths,
};
}
function resolveRoots(state) {
const operationRoots = [];
const fragmentRootByName = Object.create(null);
for (const root of state.roots) {
if (root.type === "operation") {
operationRoots.push(root);
}
else {
fragmentRootByName[root.name] = root;
}
}
return operationRoots.map((root) => resolveOperationRoot(fragmentRootByName, root));
}
function DepthVisitor(context) {
let state = newState();
state.complete = true;
let currentRoot = null;
function incDepth(key, node) {
if (!currentRoot) {
throw new Error(`DepthVisitor attempted to increment depth, but there's no currentRoot!`);
}
if (!currentRoot.depths[key]) {
// assert(key.includes('.'));
currentRoot.depths[key] = newDepthInfo();
}
currentRoot.depths[key].current++;
const { current, max } = currentRoot.depths[key];
if (current > max) {
currentRoot.depths[key].max = current;
currentRoot.depths[key].detailsByDepth.set(current, {
operationCoords: [context.getOperationPath()],
nodes: [node],
});
}
else if (current === max) {
const details = currentRoot.depths[key].detailsByDepth.get(current);
details.operationCoords.push(context.getOperationPath());
details.nodes.push(node);
}
}
function decDepth(key) {
if (!currentRoot) {
throw new Error(`DepthVisitor attempted to decrement depth, but there's no currentRoot!`);
}
if (!currentRoot.depths[key]) {
throw new Error(`DepthVisitor attempted to decrement depth, but the matching key doesn't exist!`);
}
currentRoot.depths[key].current--;
}
return {
Document: {
enter(_node) {
if (!state.complete) {
console.warn("Previous DepthVisitor didn't complete cleanly");
}
state = newState();
},
leave(_node) {
// Finalize depths by applying all the fragment depths to the operations
const resolvedOperations = resolveRoots(state);
const resolvedPreset = context.getResolvedPreset();
// Report the errors
for (const resolvedOperation of resolvedOperations) {
const operationName = resolvedOperation.name;
const config = {
// Global configuration
...resolvedPreset.gqlcheck?.config,
// Override for this operation
...(operationName
? resolvedPreset.gqlcheck?.operationOverrides?.[operationName]
: null),
};
const { maxDepth = 12, maxListDepth = 4, maxSelfReferentialDepth = 2, maxIntrospectionDepth = 14, maxIntrospectionListDepth = 3, maxIntrospectionSelfReferentialDepth = 2, } = config;
const maxDepthByFieldCoordinates = {
// Defaults
"Query.__schema": 1,
"Query.__type": 1,
"__Type.fields": 1,
"__Type.inputFields": 1,
"__Type.interfaces": 1,
"__Type.ofType": 9,
"__Type.possibleTypes": 1,
"__Field.args": 1,
"__Field.type": 1,
// Global config
...resolvedPreset.gqlcheck?.config?.maxDepthByFieldCoordinates,
// Override for this operation
...(operationName
? resolvedPreset.gqlcheck?.operationOverrides?.[operationName]
?.maxDepthByFieldCoordinates
: null),
};
// Now see if we've exceeded the limits
for (const key of Object.keys(resolvedOperation.depths)) {
const { max, detailsByDepth } = resolvedOperation.depths[key];
const selfReferential = key.includes(".");
const [limit, override, infraction, label] = (() => {
if (selfReferential) {
// Schema coordinate
const isIntrospection = key.startsWith("__") || key.includes(".__");
const limit = maxDepthByFieldCoordinates[key] ??
(isIntrospection
? maxIntrospectionSelfReferentialDepth
: maxSelfReferentialDepth);
return [
limit,
{ maxDepthByFieldCoordinates: { [key]: max } },
`maxDepthByFieldCoordinates['${key}']`,
`Self-reference limit for field '${key}'`,
];
}
else {
switch (key) {
case "fields":
return [
maxDepth,
{ maxDepth: max },
"maxDepth",
"Maximum selection depth limit",
];
case "lists":
return [
maxListDepth,
{ maxListDepth: max },
"maxListDepth",
"Maximum list nesting depth limit",
];
case "introspectionFields":
return [
maxIntrospectionDepth,
{ maxIntrospectionDepth: max },
"maxIntrospectionDepth",
"Maximum introspection selection depth limit",
];
case "introspectionLists":
return [
maxIntrospectionListDepth,
{ maxIntrospectionListDepth: max },
"maxIntrospectionListDepth",
"Maximum introspection list nesting depth limit",
];
default: {
throw new Error(`Key '${key}' has no associated setting?`);
}
}
}
})();
if (max > limit) {
const nodes = [];
const operationCoordinates = [];
for (let i = limit + 1; i <= max; i++) {
const details = detailsByDepth.get(i);
details.nodes.forEach((c) => nodes.push(c));
details.operationCoords.forEach((c) => operationCoordinates.push(c));
}
const error = new ruleError_js_1.RuleError(`${label} exceeded: ${max} > ${limit}`, {
infraction,
nodes,
errorOperationLocations: [
{ operationName, operationCoordinates },
],
override,
});
context.reportError(error);
}
}
// console.dir(resolvedOperation, { depth: 4 });
}
// Clean up
state.complete = true;
},
},
OperationDefinition: {
enter(node) {
if (currentRoot) {
throw new Error(`There should be no root when we visit an OperationDefinition`);
}
currentRoot = newRoot(node);
state.roots.push(currentRoot);
},
leave(_node) {
currentRoot = null;
},
},
FragmentDefinition: {
enter(node) {
if (currentRoot) {
throw new Error(`There should be no root when we visit a FragmentDefinition`);
}
currentRoot = newRoot(node);
state.roots.push(currentRoot);
},
leave(_node) {
currentRoot = null;
},
},
FragmentSpread: {
enter(node) {
if (!currentRoot) {
throw new Error(`There should be a root when we visit a FragmentSpread`);
}
const operationPath = context.getOperationPath();
const fragmentName = node.name.value;
const depthValues = {
fields: 0,
lists: 0,
introspectionFields: 0,
introspectionLists: 0,
};
for (const key of Object.keys(currentRoot.depths)) {
depthValues[key] = currentRoot.depths[key].current;
}
// Need to flag that all the depths should be extended by this
if (!currentRoot.fragmentReferences[operationPath]) {
currentRoot.fragmentReferences[operationPath] = {
[fragmentName]: {
depthValues,
},
};
}
else if (!currentRoot.fragmentReferences[operationPath][fragmentName]) {
currentRoot.fragmentReferences[operationPath][fragmentName] = {
depthValues,
};
}
else {
// Visiting same fragment again; ignore
}
},
leave(_node) {
// No specific action required
},
},
Field: {
enter(node) {
const returnType = context.getType();
if (!returnType)
return;
const parentType = context.getParentType();
if (!parentType)
return;
const { namedType, listDepth } = processType(returnType);
if ((0, graphql_1.isCompositeType)(namedType)) {
incDepth(`${parentType.name}.${node.name.value}`, node);
incDepth(context.isIntrospection() ? "introspectionFields" : "fields", node);
for (let i = 0; i < listDepth; i++) {
incDepth(context.isIntrospection() ? "introspectionLists" : "lists", node);
}
}
},
leave(node) {
const returnType = context.getType();
if (!returnType)
return;
const parentType = context.getParentType();
if (!parentType)
return;
const { namedType, listDepth } = processType(returnType);
if ((0, graphql_1.isCompositeType)(namedType)) {
decDepth(`${parentType.name}.${node.name.value}`);
decDepth(context.isIntrospection() ? "introspectionFields" : "fields");
for (let i = 0; i < listDepth; i++) {
decDepth(context.isIntrospection() ? "introspectionLists" : "lists");
}
}
},
},
};
}
function processType(inputType) {
let type = inputType;
let listDepth = 0;
while (!(0, graphql_1.isNamedType)(type)) {
if (type instanceof graphql_1.GraphQLNonNull) {
type = type.ofType;
}
else if (type instanceof graphql_1.GraphQLList) {
type = type.ofType;
listDepth++;
}
else {
throw new Error(`Unexpected type ${type}`);
}
}
return { namedType: type, listDepth };
}
;