UNPKG

@unito/integration-sdk

Version:

Integration SDK

210 lines (209 loc) 7.69 kB
import { isHttpErrorsCallee } from '../utils.js'; const SKIP_FILE_PATTERNS = [/\/provider\.ts$/, /\/credentials\.ts$/, /\/helpers\/credentials\.ts$/]; const INPUT_VALIDATION_MESSAGE_PATTERNS = [/^Missing/i, /^Invalid/i, /required/i, /must be/i]; const PROVIDER_AWAIT_LOOKBACK = 3; const BAD_REQUEST_ERROR = 'BadRequestError'; const PROVIDER_IDENTIFIER_RE = /^provider$/i; const FUNCTION_BOUNDARY_TYPES = new Set([ 'FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression', ]); function isFileSkipped(filename) { return SKIP_FILE_PATTERNS.some(re => re.test(filename)); } function getLiteralMessageText(arg, sourceCode) { if (!arg) { return null; } if (arg.type === 'Literal' && typeof arg.value === 'string') { return arg.value; } if (arg.type === 'TemplateLiteral') { const raw = sourceCode.getText(arg); return raw.replace(/^`|`$/g, '').replace(/\$\{[^}]*\}/g, ''); } return null; } function isInputValidationMessage(arg, sourceCode) { const text = getLiteralMessageText(arg, sourceCode); if (text === null) { return false; } return INPUT_VALIDATION_MESSAGE_PATTERNS.some(re => re.test(text)); } function isAwaitOnProvider(awaitExpression) { if (!awaitExpression || awaitExpression.type !== 'AwaitExpression') { return false; } const inner = awaitExpression.argument; if (!inner || inner.type !== 'CallExpression') { return false; } let callee = inner.callee; while (callee && callee.type === 'MemberExpression') { callee = callee.object; } if (!callee || callee.type !== 'Identifier') { return false; } return PROVIDER_IDENTIFIER_RE.test(callee.name); } function findProviderAwait(statement) { if (!statement) { return null; } if (statement.type === 'ExpressionStatement') { return isAwaitOnProvider(statement.expression) ? statement.expression : null; } if (statement.type === 'VariableDeclaration') { for (const decl of statement.declarations) { if (decl.init && isAwaitOnProvider(decl.init)) { return decl.init; } } return null; } if (statement.type === 'ReturnStatement' && statement.argument) { return isAwaitOnProvider(statement.argument) ? statement.argument : null; } return null; } // Walks parents up to a function boundary. At each block-level ancestor, scans // up to PROVIDER_AWAIT_LOOKBACK preceding siblings for a provider await. Lets // the rule fire on `await provider.x(); if (y) { throw ... }` — the throw's // direct parent has no awaits but the enclosing block does. function isWithinProviderAwaitWindow(throwStatement) { let current = throwStatement; let parent = current.parent; while (parent) { if (FUNCTION_BOUNDARY_TYPES.has(parent.type)) { return false; } const parentBody = parent.body; if (Array.isArray(parentBody)) { const idx = parentBody.indexOf(current); if (idx > 0) { const start = Math.max(0, idx - PROVIDER_AWAIT_LOOKBACK); for (let i = idx - 1; i >= start; i--) { if (findProviderAwait(parentBody[i])) { return true; } } } } current = parent; parent = parent.parent; } return false; } // Walk subtree looking for ANY AwaitExpression. Skips nested function bodies — // an async function defined inside the try doesn't count as awaiting at the // try-block scope. function subtreeHasAwait(rootNode) { let found = false; const walk = (node) => { if (found || !node || typeof node !== 'object') { return; } const n = node; if (n.type === 'AwaitExpression') { found = true; return; } if (FUNCTION_BOUNDARY_TYPES.has(n.type)) { return; } for (const key in n) { if (key === 'parent' || key === 'loc' || key === 'range') { continue; } const child = n[key]; if (Array.isArray(child)) { for (const c of child) { walk(c); } } else if (child && typeof child === 'object' && 'type' in child) { walk(child); } } }; walk(rootNode); return found; } // Returns true when `node` is inside a CatchClause whose corresponding // TryStatement.block contains any `await`. Catches wrapping pure sync // operations have no await and are skipped — those are legitimate // input-validation throw points. function isInsideAsyncCatch(node) { let parent = node.parent; while (parent) { if (parent.type === 'CatchClause') { const tryStatement = parent.parent; if (tryStatement && tryStatement.type === 'TryStatement') { const ts = tryStatement; if (ts.block) { return subtreeHasAwait(ts.block); } } return false; } if (FUNCTION_BOUNDARY_TYPES.has(parent.type)) { return false; } parent = parent.parent; } return false; } const rule = { meta: { type: 'problem', docs: { description: 'Reserve `HttpErrors.BadRequestError` (HTTP 400) for input validation. Throwing it inside a `catch` or after a `await provider.*` call mislabels a provider rejection as a caller-input error — use `HttpErrors.UnprocessableEntityError` (422) instead.', recommended: true, }, schema: [], messages: { badRequestNotInputValidation: '`HttpErrors.BadRequestError` (HTTP 400) is reserved for input validation. Throwing it {{context}} treats a provider rejection as a caller-input error — the upstream caller did not send bad input. Throw `HttpErrors.UnprocessableEntityError` (422) instead, or, if the message truly describes a missing/invalid input field, rephrase it (e.g. `Missing X`, `Invalid Y`, `X is required`, `X must be ...`).', }, }, create(context) { const filename = context.filename ?? ''; if (isFileSkipped(filename)) { return {}; } const sourceCode = context.sourceCode; return { ThrowStatement(node) { const argument = node.argument; if (!argument || argument.type !== 'NewExpression') { return; } if (!isHttpErrorsCallee(argument.callee, BAD_REQUEST_ERROR)) { return; } const firstArg = argument.arguments[0]; if (isInputValidationMessage(firstArg, sourceCode)) { return; } if (isInsideAsyncCatch(node)) { context.report({ node, messageId: 'badRequestNotInputValidation', data: { context: 'inside a `catch` that wraps an `await` (async I/O)' }, }); return; } if (isWithinProviderAwaitWindow(node)) { context.report({ node, messageId: 'badRequestNotInputValidation', data: { context: 'after a `provider.*` API call' }, }); } }, }; }, }; export default rule;