383 lines
13 KiB
TypeScript
383 lines
13 KiB
TypeScript
/**
|
|
* Contract Validation with Rich Error Context
|
|
* Validates postconditions and returns structured errors.
|
|
* Backward compatible: error is always a string, violation is optional.
|
|
*/
|
|
import { parse } from '../formula/parser.js'
|
|
import { evaluateAsync, evaluateBooleanResult, evaluateWithExtensions } from '../formula/evaluator.js'
|
|
import { getSuggestion, formatDiff } from './error-suggestions.js'
|
|
import { getErrorMessage } from '../infrastructure/http-executor.js'
|
|
import type { ExtensionRegistry } from '../extension/types.js'
|
|
import type { FormulaNode } from './formula.js'
|
|
import type { ContractViolation, EvalContext, EvalResult, RouteContract } from '../types.js'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Core helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const makeViolation = (
|
|
partial: Omit<ContractViolation, 'type' | 'kind' | 'suggestion'> & { kind?: ContractViolation['kind']; suggestion?: string }
|
|
): ContractViolation => ({
|
|
type: 'contract-violation',
|
|
kind: partial.kind ?? 'postcondition',
|
|
suggestion: partial.suggestion ?? 'Contract violation detected. Review the formula and response.',
|
|
...partial,
|
|
})
|
|
|
|
const getFieldValue = (obj: unknown, path: string): unknown => {
|
|
const parts = path.split('.')
|
|
let current: unknown = obj
|
|
for (const part of parts) {
|
|
if (current === null || current === undefined || typeof current !== 'object') {
|
|
return undefined
|
|
}
|
|
current = (current as Record<string, unknown>)[part]
|
|
}
|
|
return current
|
|
}
|
|
|
|
const extractExpectedFromEquality = (formula: string): string | undefined => {
|
|
const match = formula.match(/==\s*["']?([^"']+)["']?/)
|
|
return match?.[1]
|
|
}
|
|
|
|
const extractFieldPath = (formula: string): string | undefined => {
|
|
const match = formula.match(/(?:response_body\(this\)|response_payload\(this\)|request_body\(this\)|request_headers\(this\)|response_headers\(this\)|query_params\(this\)|request_params\(this\)|cookies\(this\)|response_time\(this\))(?:\.?([\w.\[\]]+))?/)
|
|
return match?.[1]
|
|
}
|
|
|
|
const isLegacyPreconditionSyntax = (formula: string): boolean =>
|
|
/^[a-zA-Z][a-zA-Z0-9_-]*:[^\s()]+$/.test(formula) &&
|
|
!formula.startsWith('status:')
|
|
|
|
const clauseLabelForKind = (kind: ContractViolation['kind']): 'x-requires' | 'x-ensures' =>
|
|
kind === 'precondition' ? 'x-requires' : 'x-ensures'
|
|
|
|
const formatRouteClauseContext = (
|
|
kind: ContractViolation['kind'],
|
|
route: RouteContract | { method: string; path: string } | undefined,
|
|
index: number,
|
|
formula: string
|
|
): string => {
|
|
const routeCtx = route ? `${route.method} ${route.path}` : 'unknown route'
|
|
return `${routeCtx} ${clauseLabelForKind(kind)}[${index}] "${formula}"`
|
|
}
|
|
|
|
const buildLegacyPreconditionMessage = (formula: string): string =>
|
|
`Legacy precondition syntax is no longer supported: "${formula}". ` +
|
|
'Use APOSTL formulas in x-requires, for example request_params(this).id != null or response_code(GET /users/{request_params(this).id}) == 200.'
|
|
|
|
const parseFormula = (formula: string, extensionRegistry?: ExtensionRegistry): FormulaNode => {
|
|
const extensionHeaders = extensionRegistry?.getExtensionHeaders() ?? []
|
|
return parse(formula, extensionHeaders).ast
|
|
}
|
|
|
|
const evaluateParsedFormula = (
|
|
ast: FormulaNode,
|
|
ctx: EvalContext,
|
|
route?: RouteContract | { method: string; path: string },
|
|
extensionRegistry?: ExtensionRegistry
|
|
): boolean => {
|
|
if (extensionRegistry && route && 'category' in route) {
|
|
const evalResult = evaluateWithExtensions(ast, ctx, route as RouteContract, extensionRegistry)
|
|
if (!evalResult.success) {
|
|
throw new Error(evalResult.error)
|
|
}
|
|
return Boolean(evalResult.value)
|
|
}
|
|
return evaluateBooleanResult(ast, ctx)
|
|
}
|
|
|
|
const evaluateParsedFormulaAsync = async (
|
|
ast: FormulaNode,
|
|
ctx: EvalContext,
|
|
route?: RouteContract | { method: string; path: string },
|
|
extensionRegistry?: ExtensionRegistry
|
|
): Promise<boolean> => {
|
|
const evalResult = await evaluateAsync(
|
|
ast,
|
|
ctx,
|
|
route && 'category' in route ? route as RouteContract : undefined,
|
|
extensionRegistry
|
|
)
|
|
if (!evalResult.success) {
|
|
throw new Error(evalResult.error)
|
|
}
|
|
return Boolean(evalResult.value)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Violation builders — extracted from makeConditionFailure to reduce nesting
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const resolveStatusExpectation = (
|
|
formula: string,
|
|
ast: FormulaNode | undefined,
|
|
statusCode: number
|
|
): { expected: string; actual: string } => {
|
|
if (ast?.type === 'status') {
|
|
return { expected: String(ast.code), actual: String(statusCode) }
|
|
}
|
|
const statusMatch = formula.match(/status:(\d+)/)
|
|
if (statusMatch) {
|
|
return { expected: statusMatch[1] ?? 'true', actual: String(statusCode) }
|
|
}
|
|
return { expected: 'true', actual: 'false' }
|
|
}
|
|
|
|
const resolveFieldNullExpectation = (
|
|
formula: string,
|
|
body: unknown
|
|
): { expected: string; actual: string } | null => {
|
|
const fieldMatch = formula.match(/(?:response_body\(this\)|response_payload\(this\)|request_body\(this\)|request_headers\(this\)|response_headers\(this\)|query_params\(this\)|request_params\(this\))\.(\w[\w.\[\]]*)/)
|
|
if (!fieldMatch || !formula.includes('!= null')) return null
|
|
|
|
const fieldPath = fieldMatch[1]
|
|
if (!fieldPath) return null
|
|
|
|
const parts = fieldPath.split('.')
|
|
let current: unknown = body
|
|
let exists = true
|
|
for (const part of parts) {
|
|
if (current === null || current === undefined || typeof current !== 'object') {
|
|
exists = false
|
|
break
|
|
}
|
|
current = (current as Record<string, unknown>)[part]
|
|
}
|
|
|
|
if (!exists || current === undefined) {
|
|
return { expected: 'non-null value', actual: 'undefined (field missing)' }
|
|
}
|
|
if (current === null) {
|
|
return { expected: 'non-null value', actual: 'null' }
|
|
}
|
|
return null
|
|
}
|
|
|
|
const buildDiff = (formula: string, body: unknown): string | null => {
|
|
if (!formula.includes('==') || formula.includes('!=')) return null
|
|
|
|
const fieldPath = extractFieldPath(formula)
|
|
const expectedValue = extractExpectedFromEquality(formula)
|
|
if (!fieldPath || !expectedValue) return null
|
|
|
|
const actualValue = getFieldValue(body, fieldPath)
|
|
return formatDiff(expectedValue, String(actualValue ?? 'undefined'))
|
|
}
|
|
|
|
const makeConditionFailure = (
|
|
kind: ContractViolation['kind'],
|
|
formula: string,
|
|
ctx: EvalContext,
|
|
route: RouteContract | { method: string; path: string } | undefined,
|
|
ast?: FormulaNode
|
|
): ContractViolation => {
|
|
const statusExpectation = resolveStatusExpectation(formula, ast, ctx.response.statusCode)
|
|
const fieldExpectation = resolveFieldNullExpectation(formula, ctx.response.body)
|
|
|
|
const expected = fieldExpectation?.expected ?? statusExpectation.expected
|
|
const actual = fieldExpectation?.actual ?? statusExpectation.actual
|
|
const diff = buildDiff(formula, ctx.response.body)
|
|
|
|
return makeViolation({
|
|
route: route ?? { method: '', path: '' },
|
|
formula,
|
|
kind,
|
|
request: {
|
|
body: ctx.request.body,
|
|
headers: ctx.request.headers,
|
|
query: ctx.request.query,
|
|
params: ctx.request.params,
|
|
},
|
|
response: {
|
|
statusCode: ctx.response.statusCode,
|
|
headers: ctx.response.headers,
|
|
body: ctx.response.body,
|
|
},
|
|
context: { expected, actual, diff },
|
|
})
|
|
}
|
|
|
|
const makeFormulaError = (
|
|
kind: ContractViolation['kind'],
|
|
formula: string,
|
|
ctx: EvalContext,
|
|
route: RouteContract | { method: string; path: string } | undefined,
|
|
message: string
|
|
): ContractViolation => {
|
|
return makeViolation({
|
|
route: route ?? { method: '', path: '' },
|
|
formula,
|
|
kind,
|
|
request: {
|
|
body: ctx.request.body,
|
|
headers: ctx.request.headers,
|
|
query: ctx.request.query,
|
|
params: ctx.request.params,
|
|
},
|
|
response: {
|
|
statusCode: ctx.response.statusCode,
|
|
headers: ctx.response.headers,
|
|
body: ctx.response.body,
|
|
},
|
|
context: { expected: 'valid formula', actual: `parse error: ${message}`, diff: null },
|
|
suggestion: `Formula evaluation failed: ${message}. Check your contract syntax.`,
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared validation body — extracted to avoid duplication between sync/async
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const runValidationBody = (
|
|
kind: ContractViolation['kind'],
|
|
formula: string,
|
|
ctx: EvalContext,
|
|
route: RouteContract | { method: string; path: string } | undefined,
|
|
extensionRegistry: ExtensionRegistry | undefined
|
|
): EvalResult | null => {
|
|
if (kind === 'precondition' && isLegacyPreconditionSyntax(formula)) {
|
|
throw new Error(buildLegacyPreconditionMessage(formula))
|
|
}
|
|
|
|
const ast = parseFormula(formula, extensionRegistry)
|
|
const result = evaluateParsedFormula(ast, ctx, route, extensionRegistry)
|
|
|
|
if (!result) {
|
|
const violation = makeConditionFailure(kind, formula, ctx, route, ast)
|
|
if (extensionRegistry) {
|
|
extensionRegistry.runViolationHooks(violation).catch((err: unknown) => {
|
|
console.warn(`Extension violation hook failed: ${getErrorMessage(err)}`)
|
|
})
|
|
}
|
|
return {
|
|
success: false,
|
|
error: `Contract violation: ${formula}`,
|
|
violation: { ...violation, suggestion: getSuggestion(violation) },
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
const runValidationBodyAsync = async (
|
|
kind: ContractViolation['kind'],
|
|
formula: string,
|
|
ctx: EvalContext,
|
|
route: RouteContract | { method: string; path: string } | undefined,
|
|
extensionRegistry: ExtensionRegistry | undefined
|
|
): Promise<EvalResult | null> => {
|
|
if (kind === 'precondition' && isLegacyPreconditionSyntax(formula)) {
|
|
throw new Error(buildLegacyPreconditionMessage(formula))
|
|
}
|
|
|
|
const ast = parseFormula(formula, extensionRegistry)
|
|
const result = await evaluateParsedFormulaAsync(ast, ctx, route, extensionRegistry)
|
|
|
|
if (!result) {
|
|
const violation = makeConditionFailure(kind, formula, ctx, route, ast)
|
|
if (extensionRegistry) {
|
|
extensionRegistry.runViolationHooks(violation).catch((err: unknown) => {
|
|
console.warn(`Extension violation hook failed: ${getErrorMessage(err)}`)
|
|
})
|
|
}
|
|
return {
|
|
success: false,
|
|
error: `Contract violation: ${formula}`,
|
|
violation: { ...violation, suggestion: getSuggestion(violation) },
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sync / Async wrappers — thin loops over the shared body
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const runValidationSync = (
|
|
kind: ContractViolation['kind'],
|
|
formulas: string[],
|
|
ctx: EvalContext,
|
|
route?: RouteContract | { method: string; path: string },
|
|
extensionRegistry?: ExtensionRegistry
|
|
): EvalResult => {
|
|
for (let index = 0; index < formulas.length; index++) {
|
|
const formula = formulas[index] ?? ''
|
|
try {
|
|
const stepResult = runValidationBody(kind, formula, ctx, route, extensionRegistry)
|
|
if (stepResult) {
|
|
return stepResult
|
|
}
|
|
} catch (err) {
|
|
const msg = getErrorMessage(err)
|
|
const violation = makeFormulaError(kind, formula, ctx, route, msg)
|
|
const routeCtx = formatRouteClauseContext(kind, route, index, formula)
|
|
return {
|
|
success: false,
|
|
error: `Formula error in ${routeCtx}: ${msg}`,
|
|
violation,
|
|
}
|
|
}
|
|
}
|
|
return { success: true, value: ctx.response.statusCode }
|
|
}
|
|
|
|
const runValidationAsync = async (
|
|
kind: ContractViolation['kind'],
|
|
formulas: string[],
|
|
ctx: EvalContext,
|
|
route?: RouteContract | { method: string; path: string },
|
|
extensionRegistry?: ExtensionRegistry
|
|
): Promise<EvalResult> => {
|
|
for (let index = 0; index < formulas.length; index++) {
|
|
const formula = formulas[index] ?? ''
|
|
try {
|
|
const stepResult = await runValidationBodyAsync(kind, formula, ctx, route, extensionRegistry)
|
|
if (stepResult) {
|
|
return stepResult
|
|
}
|
|
} catch (err) {
|
|
const msg = getErrorMessage(err)
|
|
const violation = makeFormulaError(kind, formula, ctx, route, msg)
|
|
const routeCtx = formatRouteClauseContext(kind, route, index, formula)
|
|
return {
|
|
success: false,
|
|
error: `Formula error in ${routeCtx}: ${msg}`,
|
|
violation,
|
|
}
|
|
}
|
|
}
|
|
return { success: true, value: ctx.response.statusCode }
|
|
}
|
|
|
|
/**
|
|
* Validate a set of postcondition formulas against an evaluation context.
|
|
* Returns string error for backward compatibility, with optional rich violation.
|
|
*/
|
|
export const validatePostconditions = (
|
|
ensures: string[],
|
|
ctx: EvalContext,
|
|
route?: RouteContract | { method: string; path: string },
|
|
extensionRegistry?: ExtensionRegistry
|
|
): EvalResult => {
|
|
return runValidationSync('postcondition', ensures, ctx, route, extensionRegistry)
|
|
}
|
|
export const validatePostconditionsAsync = (
|
|
ensures: string[],
|
|
ctx: EvalContext,
|
|
route?: RouteContract | { method: string; path: string },
|
|
extensionRegistry?: ExtensionRegistry
|
|
): Promise<EvalResult> => {
|
|
return runValidationAsync('postcondition', ensures, ctx, route, extensionRegistry)
|
|
}
|
|
export const validatePreconditionsAsync = (
|
|
requires: string[],
|
|
ctx: EvalContext,
|
|
route?: RouteContract | { method: string; path: string },
|
|
extensionRegistry?: ExtensionRegistry
|
|
): Promise<EvalResult> => {
|
|
return runValidationAsync('precondition', requires, ctx, route, extensionRegistry)
|
|
}
|