Files
apophis-fastify/docs/plugin-contracts.md
T

432 lines
14 KiB
Markdown

# 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](#when-to-use-it)
- [Config Format](#config-format)
- [PluginContractSpec Reference](#plugincontractspec-reference)
- [Route Pattern Matching](#route-pattern-matching)
- [Precondition → Skip Semantics](#precondition--skip-semantics)
- [Auto-Injected Headers](#auto-injected-headers)
- [Failure Attribution](#failure-attribution)
- [Extension Dependencies](#extension-dependencies)
- [Production Safety](#production-safety)
- [Complete Example](#complete-example)
- [Limitations](#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
```javascript
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)
```javascript
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](#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 runs
- `preHandler` — evaluated before the handler, after shared hooks
- `preSerialization` — evaluated before the response body is serialized
- `onSend` — evaluated before the response is sent
- `onResponse` — 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
```javascript
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:
```javascript
{
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:
1. Require failure is **not** counted as a contract failure
2. All `ensures` formulas for that route+run are **skipped**
3. 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
```javascript
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 `requires` check fails → the `ensures` (`status != 500`) is **skipped**
- `skippedCount` increments; `failedCount` does not
For a route with the `x-tenant-id` header:
- The `requires` check passes → the `ensures` is 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
```javascript
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:
```javascript
// 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:
```javascript
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:
```text
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:
```json
{
"summary": {
"pluginContractsApplied": 12,
"pluginContractsFailed": 2,
"failed": 1,
"passed": 10
}
}
```
- `pluginContractsApplied` — total plugin contract formulas evaluated across all routes
- `pluginContractsFailed` — plugin contract formulas that failed
- `failed` — 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
```javascript
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 name
- `required` (optional, default `true`) — if `false`, 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()`:
```text
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:
```javascript
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:
```text
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`)
```javascript
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`)
```javascript
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`)
1. Sends a request to `GET /api/users`
2. `auth-required` plugin: `onRequest.requires``request_headers(this).authorization != null` → APOPHIS auto-injects `authorization: test-value` → passes
3. Route-level ensures: `status:200` → passes; `response_body(this) is Array` → passes
4. `request-id` plugin: `onSend.ensures``response_headers(this).x-request-id != null` → if the handler doesn't set `x-request-id`, this **fails** as a `plugin:request-id` contract violation
```text
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
- **`appliesTo` matches by route path only** by default. Use method-prefixed syntax (`"POST /api/**"`) for HTTP-method-specific rules.
- **Header auto-injection only handles simple patterns** (`!= null` and `== "value"`). Complex preconditions like `>= 5` are 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 `appliesTo` patterns 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 `requires` or `ensures` must be present per phase.** A phase with neither is silently ignored.
- **Runtime hook ordering follows Fastify's lifecycle.** Plugin contracts in `onResponse` cannot inspect the response body (it has already been sent to the client).