@unito/integration-sdk
Version:
Integration SDK
210 lines (209 loc) • 7.69 kB
JavaScript
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;