101 lines
4.3 KiB
Markdown
101 lines
4.3 KiB
Markdown
# ContractCheck
|
|
|
|
Catches interface drift between two systems: a response whose shape changed,
|
|
a requested field that never came back, and endpoints with no contract check.
|
|
It is used in Hitt Hosting products.
|
|
|
|
## What it does and why
|
|
|
|
**A response can be perfectly valid while the field you asked for is
|
|
missing.** Stripe removed `invoice.payment_intent` in its 2025-03-31 (Basil)
|
|
API version, and Stripe ignores an unknown `expand` path instead of rejecting
|
|
it. A stale expand keeps succeeding and returns a well-formed invoice without
|
|
the field. There is no error and no warning; the client secret is absent and
|
|
the payment form cannot mount, while typecheck, lint and the test suite all
|
|
pass. A schema validator cannot catch this, because the invoice is valid. The
|
|
only way to detect it is to check that what you requested actually came back.
|
|
|
|
ContractCheck has three parts.
|
|
|
|
- `assertShape()` validates a payload against any schema with a zod-style
|
|
`safeParse` and throws a `ContractDriftError` naming the context and the
|
|
offending paths. It fails loudly because the bug it targets is an endpoint
|
|
dropping a field the UI needs: nothing errors, the page renders, and the
|
|
number is just wrong. A 500 naming the path beats a plausible screen. The
|
|
error is a plain `Error`, not tied to a web framework, so you map it to your
|
|
own transport.
|
|
- `expectFields()` and `assertFields()` check that each requested dot path is
|
|
present in the response. An explicit `null` counts as present: `null` means
|
|
the provider answered "no value", while `undefined` means it never
|
|
acknowledged the request. A `why` note on each expectation is quoted in the
|
|
failure, so it says what breaks and not only what is missing.
|
|
- `coverage()` measures how much of an API surface is contract-checked,
|
|
against a floor you can raise over time so coverage cannot slip back. It
|
|
lists the uncovered endpoints, because a bare percentage tells you there is
|
|
a gap, not where it is.
|
|
|
|
The `Parser` type accepts Zod 4 schemas, whose issue paths may include
|
|
symbols, and symbol path segments are rendered without crashing.
|
|
|
|
## Reports
|
|
|
|
`fieldReport()` and `formatFields()` make each requested path its own
|
|
finding, so "the response is a valid object" and "the field you asked for is
|
|
absent" never collapse into one verdict. If no fields were asserted, the
|
|
report is not assessed.
|
|
|
|
`coverageReport()` and `formatCoverage()` report an empty surface as not
|
|
assessed, not 0%. "0% covered" for a surface with no endpoints describes a
|
|
failure that did not happen. Uncovered endpoints are listed one by one as not
|
|
assessed. Reports use the `@extant2000/evidence-record` format.
|
|
|
|
## Usage
|
|
|
|
```ts
|
|
import { assertShape, expectFields, assertFields, formatFields, coverage, formatCoverage } from '@extant2000/contract-check'
|
|
import { z } from 'zod' // or any parser with a zod-style safeParse
|
|
|
|
// 1. Response shape
|
|
const payload: unknown = { id: 'm1', name: 'Survey' } // e.g. a parsed response body
|
|
const Mission = z.object({ id: z.string(), name: z.string() })
|
|
const mission = assertShape(Mission, payload, 'GET /missions/:id')
|
|
|
|
// 2. Requested fields actually returned
|
|
const subscription = { id: 'sub_1', latest_invoice: { id: 'in_1' } } // response from the provider
|
|
const expectations = [
|
|
{ path: 'latest_invoice.payment_intent', why: 'the client secret is needed to mount the payment form' },
|
|
'id',
|
|
]
|
|
const result = expectFields(subscription, expectations)
|
|
console.log(formatFields(result, { context: 'stripe.subscriptions.create', expectations }))
|
|
assertFields(subscription, expectations, 'stripe.subscriptions.create') // or throw
|
|
|
|
// 3. Coverage against a floor
|
|
const c = coverage(['GET /a', 'GET /b', 'POST /c', 'GET /d'], ['GET /a', 'GET /b', 'POST /c'], 80)
|
|
console.log(formatCoverage(c, 80))
|
|
```
|
|
|
|
For a subscription whose invoice lacks `payment_intent`, the field report
|
|
fails at critical severity on `latest_invoice.payment_intent`, quotes the
|
|
`why` note, and passes `id`. The coverage example prints:
|
|
|
|
```
|
|
✗ HIGH 3 of 4 endpoints contract-checked (75%) [NOT MET]
|
|
Below the ratchet floor of 80%.
|
|
evidence: Contract coverage = 75% (4 samples)
|
|
· MEDIUM GET /d has no contract check [NOT ASSESSED]
|
|
```
|
|
|
|
ContractCheck does not depend on zod. Any object with a compatible
|
|
`safeParse` works.
|
|
|
|
## Install
|
|
|
|
```
|
|
npm install @extant2000/contract-check
|
|
```
|
|
|
|
## License
|
|
|
|
MIT. See [LICENSE](LICENSE).
|