@unito/integration-sdk
Version:
Integration SDK
261 lines (238 loc) • 8.76 kB
text/typescript
/**
* `HttpErrors.BadRequestError` (HTTP 400) is reserved for genuine input
* validation. Throwing it where the upstream caller's input was already
* accepted — i.e. after the provider rejected the request — mislabels the
* failure. Per repo policy: prefer `HttpErrors.UnprocessableEntityError`
* (422); the frontend absorbs 400s and would mask the real cause.
*
* Fires only on high-confidence misuse:
* - Inside a `catch` whose `try.block` provably contains an `await` (async
* I/O — provider, service helper, db query, fetch). Catches wrapping pure
* sync operations (JSON.parse, schema.validate, parseInt) are NOT flagged
* — those are legitimate input-validation throw points.
* - Within 3 statements after an `await provider.*` / `await Provider.*` in
* any enclosing block (function-boundary stop).
*
* Allowlisted message substrings (`/^Missing/i`, `/^Invalid/i`, `/required/i`,
* `/must be/i`) skip the rule — those phrases describe input validation,
* `BadRequestError` is correct there.
*/
import type { Rule } from 'eslint';
import type * as ESTree from 'estree';
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<string>([
'FunctionDeclaration',
'FunctionExpression',
'ArrowFunctionExpression',
]);
type NodeWithParent = ESTree.Node & { parent?: NodeWithParent };
function isFileSkipped(filename: string): boolean {
return SKIP_FILE_PATTERNS.some(re => re.test(filename));
}
function getLiteralMessageText(
arg: ESTree.Node | null | undefined,
sourceCode: Rule.RuleContext['sourceCode'],
): string | null {
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: ESTree.Node | null | undefined,
sourceCode: Rule.RuleContext['sourceCode'],
): boolean {
const text = getLiteralMessageText(arg, sourceCode);
if (text === null) {
return false;
}
return INPUT_VALIDATION_MESSAGE_PATTERNS.some(re => re.test(text));
}
function isAwaitOnProvider(awaitExpression: ESTree.Node | null | undefined): boolean {
if (!awaitExpression || awaitExpression.type !== 'AwaitExpression') {
return false;
}
const inner = awaitExpression.argument;
if (!inner || inner.type !== 'CallExpression') {
return false;
}
let callee: ESTree.Node | null | undefined = 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: ESTree.Node | undefined): ESTree.Node | null {
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: Rule.Node): boolean {
let current: NodeWithParent = throwStatement as unknown as NodeWithParent;
let parent: NodeWithParent | undefined = current.parent;
while (parent) {
if (FUNCTION_BOUNDARY_TYPES.has(parent.type)) {
return false;
}
const parentBody = (parent as { body?: unknown }).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] as ESTree.Node)) {
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: ESTree.Node): boolean {
let found = false;
const walk = (node: unknown): void => {
if (found || !node || typeof node !== 'object') {
return;
}
const n = node as ESTree.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 as unknown as Record<string, unknown>)[key];
if (Array.isArray(child)) {
for (const c of child) {
walk(c);
}
} else if (child && typeof child === 'object' && 'type' in (child as object)) {
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: Rule.Node): boolean {
let parent: NodeWithParent | undefined = (node as unknown as NodeWithParent).parent;
while (parent) {
if (parent.type === 'CatchClause') {
const tryStatement = parent.parent;
if (tryStatement && tryStatement.type === 'TryStatement') {
const ts = tryStatement as unknown as ESTree.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: Rule.RuleModule = {
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] as ESTree.Node | undefined;
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;