432 lines
14 KiB
Markdown
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).
|