Files
apophis-fastify/src/domain/contract-validation.ts
T

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)
}