UNPKG

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
"use strict"; 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 }; }