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

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

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 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

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:

  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

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

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 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

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():

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)

  1. Sends a request to GET /api/users
  2. auth-required plugin: onRequest.requiresrequest_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.ensuresresponse_headers(this).x-request-id != null → if the handler doesn't set x-request-id, this fails as a plugin:request-id contract 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

  • 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).