The contract test
scripts/contract-test.mjs checks that the API does exactly what the spec says. It imports the function's handler and calls it with standard Request objects, so it needs no server, no deploy, and no network.
Quick reference
- Run it with
npm test. It prints one pass line, or lists every failed check and exits with code 1. - It validates each response's status code,
Content-Type, documented headers, and body against the spec, using Ajv with JSON Schema 2020-12 (the dialect OpenAPI 3.1 uses). - It compares every documented example with the real response to its request, so the docs can't drift from the API.
- It fails if a documented status code is never exercised, or if the data has a broken reference.
- CI runs it in
.github/workflows/ci.yml, afternpm run lint.
Why it exists
The spec and the function are written by hand, so nothing ties them together on its own. Without a check, they drift: a new field never gets documented, an example shows an old value, or an error type gets renamed in one place only. The test makes every one of those a CI failure.
What it checks
Each response
For each request, the test finds the matching operation in the spec and checks:
- The status code is documented for that operation.
405is the exception, because it applies to methods the spec doesn't describe, so the test checks for theAllowheader instead. - The
Content-Typematches the documented media type. - Every header the spec documents is present and matches its schema. For example,
Cache-Controlmust equalpublic, max-age=3600. - The body matches the schema. Response schemas use
additionalProperties: false, so an undocumented field fails. - Every error's
typeappears in the Errors table in the spec's introduction. - CORS and
X-Content-Type-Optionsheaders are present.
Behavior
The test makes requests that cover each rule in the docs:
- Filters (
category,place, and both together) return only matching items. - Invalid IDs (
abc,010,0,-1,1.5,Sognefjord,-oslo) return400. Unknown IDs return404. - Unknown, repeated, and out-of-range parameters return
400, and several invalid parameters produce one error each. - Unknown paths,
/v2/..., and the old/api/norway-data/...paths return404. Invalid percent-encoding returns400.POSTandDELETEreturn405. /norway-dataredirects (302) to/norway-data/v1.utm_*and_parameters are ignored and left out of pagination links, but a parameter that only looks similar, such asutmx, is still rejected.- For every cacheable endpoint, the
ETagis weak, sending it back inIf-None-Match(in weak or strong form) returns304, and a staleETagreturns200. /random-factsends noETag.HEADmatchesGETwithout a body, andOPTIONSanswers preflight requests.- Following
pagination.links.nextfrom the first page visits every item in each collection exactly once. - Links in responses use the origin of the request, whatever host was called (checked for a deploy preview's host and
localhost). - The
rate-limitedfunction's429matches the spec'sTooManyRequestsresponse: status, media type,Retry-After, body schema, and a documented errortype. - Security: a request that arrives as
http://still getshttpslinks; every kind of response (200,302,304,204,400,404,405,429) has the security headers; cacheable responses have theNetlify-Varycache key header; and long values echoed in errors are clipped.
Coverage
After the requests, the test checks that every status code documented for every operation came up at least once. 429 and 500 are excluded: 429 comes from the separate rate-limited function, which is checked on its own, and 500 only happens on an unexpected exception.
Examples
- Every
examplein the spec must match its schema. - Every media type example with an
x-example-request, whether a singleexampleor one of several namedexamples, must equal the real response to that request, including its status code. - Every
jsonblock in the introduction that follows abashblock with acurlcommand must equal the real response to that command. The test also requires at least four of these, so a formatting change can't make it quietly skip them all.
See Examples are real responses for how to write examples the test can find.
Data
- Every fact's
categoryexists, and every ID in itsplaceIdsis a real place. - Category and place IDs match the documented ID pattern.
- Fact and place IDs are unique and in order.
- The
categoryparameter'senumin the spec matches the categories indata.mjs.
How it validates schemas
OpenAPI 3.1 schemas are JSON Schema 2020-12, so the test uses Ajv's 2020 build (ajv/dist/2020.js) with ajv-formats for format: uri. Only components.schemas is registered with Ajv, as $defs in a schema with the ID spec, because the rest of an OpenAPI document isn't JSON Schema. Each $ref to #/components/schemas/X is rewritten to spec#/$defs/X.
Checking that it catches problems
A test that never fails proves nothing. When the test was written, it was checked by planting mistakes and confirming each one failed:
| Planted mistake | Failure reported |
|---|---|
| An extra field on one place | body doesn't match schema: must NOT have additional properties |
A wrong total in an introduction example | introduction example for .../facts?place=svalbard isn't the actual response |
A misspelled place ID in a fact's placeIds | fact 5 references unknown place ... |
Its first runs also caught three real bugs: a path matcher that treated /v2/facts as /v1/facts, a documented 404 on /random-fact that could never happen, and a missing test for one endpoint's 400.
CI
.github/workflows/ci.yml runs on every push and pull request:
npm run lint: the Redocly lint of the spec.npm test: this contract test.npm run check:bundle: bundles the functions with Netlify's own bundler, so a config error (such as a non-literalconfig.path) fails here instead of in a deploy.npm run check:version: if anything undernetlify/functions/or the spec changed, the spec'sinfo.versionmust be new, the changelog must have a row for it, andpackage.json's version must match.
CI installs with npm ci --ignore-scripts, and checkouts don't keep the GitHub token.
Netlify's build command also runs the lint and this test, so a commit that fails them never deploys, whatever CI reports.
The smoke test
The contract test calls the functions in memory, so it can't catch problems that only exist once deployed. scripts/smoke-test.mjs checks a deployed copy over the real network:
- The unversioned root redirects to
/v1. - The index and pagination links, the
Linkheader, and the spec link all point at the host that was called. - CORS headers,
Cache-Control, a weakETag, and a304on revalidation. - An unknown fact returns a problem details
404with arequestId. - The spec is served, with CORS.
- Security headers on a success, an error, and the spec, and
httpslinks even when the request sendsX-Forwarded-Proto: http(on a unique URL, so it never touches a cache entry real clients use). --rate-limit: a burst of uncached requests gets a problem details429withRetry-After, and a burst of direct requests to/_rate-limitedeventually gets Netlify's empty429(so it has its own limit).--redirects: the oldwww.marlasowards.comURLs and thenetlify.appdomain redirect (301) toapi.marlasowards.com.
.github/workflows/smoke.yml runs it:
- After each push to
main, against production, with--rate-limit. It first waits until production serves a spec with the pushed version, which means Netlify finished deploying it. CI requires a version bump for every API change, so this always tests the new deploy. (Netlify doesn't report production deploys to GitHub, so the workflow can't be triggered by the deploy itself.) - Every hour, against production, with
--redirects. This is the API's uptime check: when a scheduled workflow fails, GitHub emails the repo owner.
Two bugs that reached production before the smoke test existed are exactly what it now checks: pagination links that pointed at the netlify.app host, and a rate limit that proxied requests skipped.