14 KiB
Plugin Contracts
Define behavioral rules that apply across all matching routes and are automatically injected into every matching route's contract evaluation.
Plugin contracts let teams codify cross-cutting expectations — "all API routes must carry an auth header," "all POST handlers must close the database connection," "all responses must include a request ID." They're defined once in config and evaluated alongside route-level contracts without per-route boilerplate.
Table of Contents
- When to Use It
- Config Format
- PluginContractSpec Reference
- Route Pattern Matching
- Precondition → Skip Semantics
- Auto-Injected Headers
- Failure Attribution
- Extension Dependencies
- Production Safety
- Complete Example
- Limitations
When to Use It
Plugin contracts are ideal for rules that span many routes and don't belong on a single handler:
- Auth header presence across all API routes
- Response metadata (request IDs, timing headers, version stamps)
- Tenant isolation checks that apply to every route in a prefix
- Database connection lifecycle guards on write endpoints
- Content-type enforcement on all response paths
Don't use plugin contracts for route-specific behavioral rules — those belong on the route schema via x-requires and x-ensures.
Config Format
apophis.config.js
export default {
pluginContracts: {
"auth-presence": {
appliesTo: "/api/**",
hooks: {
onRequest: {
requires: ["request_headers(this).authorization != null"]
}
}
},
"rate-limit-respected": {
appliesTo: "POST /api/**",
hooks: {
onResponse: {
ensures: ["status != 429"]
}
},
meta: { name: "rate-limit-respected", description: "Rate limit should not be hit under normal CI load" }
}
}
}
Programmatic (Fastify plugin registration)
await app.register(apophisPlugin, {
pluginContracts: {
"auth-presence": {
appliesTo: "/api/**",
hooks: {
onRequest: { requires: ["request_headers(this).authorization != null"] }
}
}
}
})
Contracts registered programmatically take the same path through the plugin contract registry as config-defined contracts. Both sources are merged into the composed contract at evaluation time.
PluginContractSpec Reference
| Field | Required | Type | Description |
|---|---|---|---|
appliesTo |
yes | string |
Route pattern (see Route Pattern Matching) |
hooks |
yes | object |
Hook-phase contracts keyed by Fastify lifecycle hook name |
hooks.<phase>.requires |
no | string[] |
APOSTL formulas that must be true before this phase executes |
hooks.<phase>.ensures |
no | string[] |
APOSTL formulas that must be true after this phase executes |
meta |
no | object |
Display metadata |
meta.name |
no | string |
Human-readable plugin name |
meta.version |
no | string |
Plugin version |
meta.description |
no | string |
Description of what the plugin enforces |
extensions |
no | array |
APOPHIS extensions this plugin depends on |
Supported hook phases
Keys in hooks correspond to Fastify lifecycle hook names:
onRequest— evaluated before the route handler runspreHandler— evaluated before the handler, after shared hookspreSerialization— evaluated before the response body is serializedonSend— evaluated before the response is sentonResponse— evaluated after the response is sent (body already consumed)
At least one of requires or ensures must be present per phase. Empty phases are ignored.
Extension dependencies
pluginContracts: {
"jwt-auth": {
appliesTo: "/api/**",
extensions: [{ name: "jwt-decoder", required: true }],
hooks: {
onRequest: { requires: ["decode_jwt(this).valid == true"] }
}
}
}
If a required extension is missing at registration time, the plugin's contracts are skipped with a warning. Extensions are resolved from the APOPHIS extension registry at evaluation time, not at route discovery time.
Route Pattern Matching
The appliesTo field controls which routes a plugin contract covers.
Pattern syntax
| Pattern | Matches | Does NOT match |
|---|---|---|
"/api/users" |
/api/users (exact) |
/api/users/42, /api/v2/users |
"/api/**" |
/api/users, /api/users/42, /api/users/:id/posts |
/v2/api/users |
"/api/*" |
/api/users, /api/status |
/api/users/42, /api/users/:id/posts |
"**" |
Everything | — |
"POST /api/**" |
POST /api/users, POST /api/orders/42 |
GET /api/users, POST /v2/api/users |
The /** suffix matches any path at any depth below the prefix. The /* suffix matches exactly one segment below the prefix.
Method-prefixed patterns
Prepend the HTTP method to restrict a pattern to a specific verb:
{
appliesTo: "POST /api/**",
hooks: {
preHandler: { requires: ["request_body(this) != null"] }
}
}
When a method is specified, the pattern only matches routes registered with that HTTP method. The method must be a valid HTTP method in uppercase.
Pattern matching is evaluated against the route's path string only (the raw argument passed to fastify.get(), fastify.post(), etc.), not against the dereferenced URL with prefix applied. Fastify prefix application happens before pattern matching, so a route registered as fastify.get("/users") under a prefix: "/api" is matched against /api/users.
Precondition → Skip Semantics
When a plugin contract defines requires formulas, the runner evaluates them before ensures. If any require formula fails:
- Require failure is not counted as a contract failure
- All
ensuresformulas for that route+run are skipped - The skip is counted separately from failures in the artifact summary
This distinguishes "the plugin's prerequisites aren't met" from "the route's behavior violated the contract."
Example
pluginContracts: {
"tenant-check": {
appliesTo: "/api/**",
hooks: {
onRequest: {
requires: ["request_headers(this).x-tenant-id != null"],
ensures: ["status != 500"]
}
}
}
}
For a route without the x-tenant-id header:
- The
requirescheck fails → theensures(status != 500) is skipped skippedCountincrements;failedCountdoes not
For a route with the x-tenant-id header:
- The
requirescheck passes → theensuresis evaluated normally - If
status == 500, it's counted as a plugin contract failure
Auto-Injected Headers
To avoid every test being skipped when a plugin contract checks for header presence, APOPHIS automatically injects headers into test requests when the precondition pattern is simple enough.
Supported patterns
| Formula pattern | Injected header |
|---|---|
request_headers(this).<name> != null |
<name>: test-value |
request_headers(this).<name> == "value" |
<name>: value |
Example
pluginContracts: {
"auth-presence": {
appliesTo: "/api/**",
hooks: {
onRequest: {
requires: ["request_headers(this).authorization != null"]
}
}
}
}
APOPHIS detects the != null pattern on authorization and injects authorization: test-value into every test request. The precondition passes automatically without user intervention.
Auto-injection works in all runners: verify, qualify (scenario, stateful, chaos), and replay.
Unsupported patterns
Complex preconditions are not auto-injected. For these, the requires check will fail and the route's ensures will be skipped:
// NOT auto-injected — the route's ensures will be skipped
requires: ["request_headers(this).x-count >= 5"]
For these cases, provide the header explicitly via route variants:
await fastify.apophis.contract({
runs: 5,
variants: [{ name: "with-count", headers: { "x-count": "10" } }]
})
Failure Attribution
When a plugin contract formula fails, the violation carries metadata that distinguishes it from route-level failures.
ContractViolation fields
| Field | Value | Meaning |
|---|---|---|
source |
"plugin:auth-presence" |
Which plugin added the formula |
phase |
"onRequest" |
Which hook phase the formula belongs to |
In human-readable output, plugin failures appear separately from route contract failures:
Plugin contract violation (plugin:auth-presence)
GET /api/users
Phase: onRequest
Expected
response_headers(this).x-request-id != null
Observed
response_headers(this).x-request-id was null
The artifact summary tracks plugin contract outcomes independently:
{
"summary": {
"pluginContractsApplied": 12,
"pluginContractsFailed": 2,
"failed": 1,
"passed": 10
}
}
pluginContractsApplied— total plugin contract formulas evaluated across all routespluginContractsFailed— plugin contract formulas that failedfailed— total failures (route + plugin)passed— route-level contract passes (plugin-only formulas not counted here)
A route can pass its own contracts and still have plugin contract failures. The CLI exit code reflects the aggregate: any failure (route or plugin) produces exit code 1.
Extension Dependencies
Plugin contracts can declare dependencies on APOPHIS extensions. Extension resolution happens at evaluation time via the extension registry.
Declaring dependencies
pluginContracts: {
"jwt-auth": {
appliesTo: "/api/**",
extensions: [{ name: "jwt-decoder", required: true }],
hooks: {
onRequest: { requires: ["decode_jwt(this).valid == true"] }
}
}
}
Each extension reference has:
name(required) — matches the extension's registered namerequired(optional, defaulttrue) — iffalse, missing extension emits a warning but does not skip the plugin
Missing extension behavior
If a required extension is not registered, the plugin's contracts are skipped for all matching routes. Warnings are collected in the registry and surfaced via drainWarnings():
Plugin 'jwt-auth' requires extensions [jwt-decoder] which are not registered. Skipping its contracts.
Warnings appear in the CLI output and the artifact's warnings array.
Extension registration
Extensions are registered alongside plugin contracts:
await app.register(apophisPlugin, {
extensions: [
{
name: "jwt-decoder",
headers: ["decode_jwt"],
predicates: {
decode_jwt: (ctx) => ({ value: true, success: true })
}
}
],
pluginContracts: {
"jwt-auth": {
appliesTo: "/api/**",
extensions: [{ name: "jwt-decoder", required: true }],
hooks: {
onRequest: { requires: ["decode_jwt(this).valid == true"] }
}
}
}
})
Production Safety
pluginContracts is a test-only feature. If pluginContracts is non-empty in a production environment (NODE_ENV=production), APOPHIS throws at plugin registration:
APOPHIS: Unsafe options detected in production: pluginContracts.
These features are test-only and must not be enabled in production.
Remove them from ApophisOptions or set NODE_ENV=test.
This is enforced by validateProductionSafety() before any contract evaluation runs. There is no escape hatch.
Complete Example
Config (apophis.config.js)
export default {
profiles: {
quick: { preset: "safe-ci" },
},
presets: {
"safe-ci": { runs: 10 },
},
pluginContracts: {
"auth-required": {
appliesTo: "/api/**",
hooks: {
onRequest: {
requires: ["request_headers(this).authorization != null"],
},
},
meta: { name: "auth-required", description: "All API routes require an Authorization header" },
},
"request-id": {
appliesTo: "/api/**",
hooks: {
onSend: {
ensures: ["response_headers(this).x-request-id != null"],
},
},
meta: { name: "request-id", description: "All API responses must include x-request-id" },
},
},
}
Route (routes/users.js)
app.get("/api/users", {
schema: {
"x-category": "observer",
"x-ensures": ["status:200", "response_body(this) is Array"],
response: {
200: {
type: "array",
items: { type: "object", properties: { id: { type: "number" } } },
},
},
},
}, async (request, reply) => {
return [{ id: 1 }, { id: 2 }]
})
Execution (apophis verify)
- Sends a request to
GET /api/users auth-requiredplugin:onRequest.requires→request_headers(this).authorization != null→ APOPHIS auto-injectsauthorization: test-value→ passes- Route-level ensures:
status:200→ passes;response_body(this) is Array→ passes request-idplugin:onSend.ensures→response_headers(this).x-request-id != null→ if the handler doesn't setx-request-id, this fails as aplugin:request-idcontract violation
Plugin contract violation (plugin:request-id)
GET /api/users
Phase: onSend
Expected
response_headers(this).x-request-id != null
Observed
response_headers(this).x-request-id was null
Limitations
appliesTomatches by route path only by default. Use method-prefixed syntax ("POST /api/**") for HTTP-method-specific rules.- Header auto-injection only handles simple patterns (
!= nulland== "value"). Complex preconditions like>= 5are not auto-injected; the route's ensures will be skipped. - No per-route opt-out. If a plugin contract matches a route, it always applies. Use narrower
appliesTopatterns or separate route prefixes to exclude routes. - Extension resolution is lazy. Extensions are checked at route evaluation time, not at registration time. A missing extension is only discovered when a matching route is evaluated.
- At least one of
requiresorensuresmust be present per phase. A phase with neither is silently ignored. - Runtime hook ordering follows Fastify's lifecycle. Plugin contracts in
onResponsecannot inspect the response body (it has already been sent to the client).