162 lines
5.6 KiB
TypeScript
162 lines
5.6 KiB
TypeScript
/**
|
||
* Detect when two subsystems' interface assumptions drift.
|
||
*
|
||
* Two design points:
|
||
*
|
||
* 1. **Framework-agnostic.** A helper that throws a framework-specific HTTP
|
||
* error ties itself to that framework. This throws a plain
|
||
* `ContractDriftError` the caller maps to its own transport.
|
||
* 2. **Request contracts, not just response shapes.** A shape validator
|
||
* checks what came back. It cannot catch a provider that SILENTLY IGNORES
|
||
* part of the request — see `expectFields` below.
|
||
*/
|
||
|
||
/** Anything with a zod-style `safeParse`. Avoids depending on zod itself. */
|
||
export interface Parser<T> {
|
||
safeParse: (data: unknown) => { success: true, data: T } | {
|
||
success: false
|
||
error: {
|
||
/**
|
||
* Zod 4 widened issue paths to `PropertyKey[]`, which includes symbol.
|
||
* Narrowing to string|number here made a Zod 4 schema unassignable to
|
||
* this interface — and where typecheck gates a pipeline, that failure
|
||
* can block a deploy without anyone reading the log.
|
||
*/
|
||
issues: Array<{ path: ReadonlyArray<PropertyKey>, message: string }>
|
||
}
|
||
}
|
||
}
|
||
|
||
export class ContractDriftError extends Error {
|
||
readonly context: string
|
||
readonly issues: string[]
|
||
constructor(context: string, issues: string[]) {
|
||
super(`Response shape drift in ${context} — ${issues.slice(0, 3).join('; ')}`)
|
||
this.name = 'ContractDriftError'
|
||
this.context = context
|
||
this.issues = issues
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Validate a payload against a schema, throwing on drift.
|
||
*
|
||
* Fails LOUDLY on purpose. The bug class this exists for is an endpoint
|
||
* silently dropping a column the UI needs: nothing errors, the page renders,
|
||
* and the number is just wrong. A 500 naming the offending path is strictly
|
||
* better than a plausible screen.
|
||
*/
|
||
export function assertShape<T>(schema: Parser<T>, data: unknown, context: string): T {
|
||
const result = schema.safeParse(data)
|
||
if (!result.success) {
|
||
// `.join()` throws on a symbol element, so map through String first —
|
||
// a symbol path renders as "Symbol(x)" rather than crashing the handler
|
||
// that was trying to report a validation error.
|
||
const issues = result.error.issues.map(i => `${i.path.map(String).join('.')}: ${i.message}`)
|
||
throw new ContractDriftError(context, issues)
|
||
}
|
||
return result.data
|
||
}
|
||
|
||
export interface FieldExpectation {
|
||
/** Dot path, e.g. `latest_invoice.confirmation_secret`. */
|
||
path: string
|
||
/** Human note on why this field was requested. */
|
||
why?: string
|
||
}
|
||
|
||
export interface FieldCheckResult {
|
||
ok: boolean
|
||
missing: string[]
|
||
present: string[]
|
||
}
|
||
|
||
function readPath(obj: unknown, path: string): unknown {
|
||
return path.split('.').reduce<unknown>(
|
||
(acc, k) => (acc == null || typeof acc !== 'object' ? undefined : (acc as Record<string, unknown>)[k]),
|
||
obj,
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Assert a response actually contains the fields the request asked for.
|
||
*
|
||
* THE FAILURE THIS EXISTS FOR: Stripe removed `invoice.payment_intent` in the
|
||
* 2025-03-31 (Basil) API version, and Stripe **silently ignores an unknown
|
||
* `expand` path** rather than rejecting it. So a stale expand keeps succeeding
|
||
* and simply returns an invoice without the field. No error, no warning — the
|
||
* client secret is absent and the payment form cannot mount, while typecheck,
|
||
* lint and a full test suite all pass.
|
||
*
|
||
* A response-shape validator cannot catch this: the response is a perfectly
|
||
* valid invoice. The only way to detect it is to assert that what you ASKED FOR
|
||
* actually came back.
|
||
*/
|
||
export function expectFields(
|
||
data: unknown,
|
||
expectations: Array<string | FieldExpectation>,
|
||
): FieldCheckResult {
|
||
const missing: string[] = []
|
||
const present: string[] = []
|
||
for (const e of expectations) {
|
||
const path = typeof e === 'string' ? e : e.path
|
||
if (readPath(data, path) === undefined) missing.push(path)
|
||
else present.push(path)
|
||
}
|
||
return { ok: missing.length === 0, missing, present }
|
||
}
|
||
|
||
/**
|
||
* Throwing form of `expectFields`.
|
||
*
|
||
* Use at an integration boundary where a silently-ignored request parameter
|
||
* would otherwise surface as a confusing downstream failure far from its cause.
|
||
*/
|
||
export function assertFields(
|
||
data: unknown,
|
||
expectations: Array<string | FieldExpectation>,
|
||
context: string,
|
||
): void {
|
||
const r = expectFields(data, expectations)
|
||
if (r.ok) return
|
||
const notes = expectations
|
||
.filter((e): e is FieldExpectation => typeof e !== 'string' && !!e.why)
|
||
.filter(e => r.missing.includes(e.path))
|
||
.map(e => `${e.path} (${e.why})`)
|
||
throw new ContractDriftError(
|
||
context,
|
||
notes.length ? notes : r.missing.map(m => `${m}: requested but absent from the response`),
|
||
)
|
||
}
|
||
|
||
export interface CoverageReport {
|
||
total: number
|
||
covered: number
|
||
/** 0–100, one decimal. */
|
||
pct: number
|
||
uncovered: string[]
|
||
/** True when below the floor — the caller decides whether that fails a build. */
|
||
belowFloor: boolean
|
||
}
|
||
|
||
/**
|
||
* Measure how much of a surface is contract-checked, against a ratchet floor.
|
||
*
|
||
* Raise the floor over time (a ratchet) so coverage cannot silently regress. Reporting `uncovered` rather than just a percentage is deliberate: a
|
||
* bare number tells you there is a gap, not where it is.
|
||
*/
|
||
export function coverage(
|
||
all: string[],
|
||
checked: Iterable<string>,
|
||
floorPct = 0,
|
||
): CoverageReport {
|
||
const set = new Set(checked)
|
||
const uncovered = all.filter(x => !set.has(x))
|
||
const covered = all.length - uncovered.length
|
||
const pct = all.length === 0 ? 0 : Math.round((covered / all.length) * 1000) / 10
|
||
return { total: all.length, covered, pct, uncovered, belowFloor: pct < floorPct }
|
||
}
|
||
|
||
export { fieldReport, coverageReport, formatFields, formatCoverage } from './report.js'
|
||
export type { FieldReportOptions } from './report.js'
|