14 KiB
APOPHIS API Redesign — Unified Interface Document
Rationale
Five independent interface reviews (Substack/minimalist, Jared Hanson/DX, WebReflections/performance, XP theorist, FRP/DDD theorist) were conducted. All five agreed on the core value proposition (schemas as contracts) but identified a shared set of problems: overgrown surface area, leaky abstractions, silent failures, and an over-engineered formula language. This document unifies their feedback into a single coherent redesign.
Guiding Principles
- Split what is separate: Runtime validation and test generation are different concerns. Do not force them into one plugin.
- Do not export internals: The public API should fit on a postcard.
- Fail loud: A silent empty result is worse than a thrown error.
- One way to do things: No duplicate syntaxes, no overlapping annotations.
- Types are documentation: Every public type should prevent misuse at compile time.
The New Public API
Package Entry Point
import apophis from 'apophis-fastify'
The package exports one default: the Fastify plugin. No export * from './types'.
Plugin Registration
await fastify.register(apophis, {
runtime: 'warn', // 'off' | 'warn' | 'error' — default: 'off'
cleanup: false, // auto-cleanup on SIGINT/SIGTERM — default: false
})
runtime: How to enforce contracts at runtime.'off'disables hooks.'warn'logs violations without failing the request.'error'throws (500). Default is'off'because runtime validation is a development aid, not a production default.cleanup: Whether to register process signal handlers. Defaultfalsebecause serverless and CLI tools should not have their signals hijacked.
Test Execution
// Contract tests (fast, deterministic)
const contract = await fastify.apophis.contract({
depth: 'quick', // 'quick' | 'standard' | 'thorough' | { runs: 75 }
scope: 'admin', // optional scope filter
seed: 12345, // optional reproducibility seed
})
// Stateful tests (slower, property-based with fast-check)
const stateful = await fastify.apophis.stateful({
depth: 'standard',
scope: 'admin',
seed: 12345,
})
// Both (if you really want)
const [contract, stateful] = await Promise.all([
fastify.apophis.contract({ depth: 'quick' }),
fastify.apophis.stateful({ depth: 'standard' }),
])
contract(): Validates postconditions against generated requests. Does not mutate state. Safe to run against production.stateful(): Generates command sequences that create, mutate, and delete resources. Requires cleanup. Not safe for production databases.- No
mode: 'all'merging. NomergeTestSuites. The user composes explicitly.
Per-Route Validation (New)
// Validate a single route in <100ms
const result = await fastify.apophis.check('POST', '/users')
// => { ok: boolean, violations: ContractViolation[] }
Spec Extraction
const spec = fastify.apophis.spec()
// => OpenAPISpec & { 'x-apophis-contracts': ContractSummary[] }
Cleanup
// Manual cleanup (always available)
const results = await fastify.apophis.cleanup()
// => Array<{ resource: TrackedResource; deleted: boolean; error?: string }>
Scope Configuration
// Scopes are passed at plugin registration, not auto-discovered from env
await fastify.register(apophis, {
scopes: {
prod: {
headers: { 'x-api-key': 'secret' },
metadata: { tenantId: 'prod-tenant' }
}
}
})
// Access headers for a scope
const headers = fastify.apophis.scope('prod')
// => Record<string, string>
No ScopeRegistry class exposed. No deriveFromRequest. No env var auto-discovery. Scopes are configuration, not global state.
Schema Annotations
Required (Core Value)
| Annotation | Type | Description |
|---|---|---|
x-category |
'constructor' | 'mutator' | 'observer' | 'destructor' | 'utility' |
Route classification |
x-requires |
RequiresClause[] |
Preconditions |
x-ensures |
EnsuresClause[] |
Postconditions |
Removed
| Annotation | Reason |
|---|---|
x-invariants |
Move to plugin-level option: invariants: ['response_body(this).id != null'] |
x-regex |
JSON Schema pattern already exists. No duplication. |
x-validate-runtime |
Replaced by plugin-level runtime option |
Scope Filtering
fastify.get('/admin', {
schema: {
'x-scope': 'admin', // Still valid: restricts route to admin scope tests
'x-category': 'observer',
'x-ensures': ['status:200'],
}
})
APOSTL Formula Language
APOSTL remains the full-featured contract language. All features are preserved for complex protocol contracts (OAuth 2.1, etc.):
// Comparisons
response_body(this).id != null
response_body(this).email == request_body(this).email
response_code(this) == 201
request_headers(this).authorization != null
response_body(this).items matches "^test"
// Boolean combinations
status:200 && response_body(this).id != null
status:200 || status:201
// Conditionals
if response_code(this) == 200 then response_body(this).id != null else true
// Quantified expressions
for item in response_body(this).items: item.status == "active"
exists item in response_body(this).items: item.id != null
// Temporal references
previous(response_body(this).id) != null
// Implication
status:200 => response_body(this).id != null
// Literals
true, false, null, 42, "string", T, F
New: status: Is Real APOSTL
// Parser now understands this natively
status:201
Adds type: 'status' to FormulaNode. No more special-case string prefix check in contract validation.
Types (Curated Public API)
// Only these types are exported
export interface ApophisOptions {
readonly runtime?: 'off' | 'warn' | 'error'
readonly cleanup?: boolean
readonly scopes?: Record<string, ScopeConfig>
readonly invariants?: string[]
}
export interface ScopeConfig {
readonly headers: Record<string, string>
readonly metadata?: Record<string, unknown>
}
export interface TestConfig {
readonly depth?: 'quick' | 'standard' | 'thorough' | { runs: number }
readonly scope?: string
readonly seed?: number
}
export interface TestSuite {
readonly tests: TestResult[]
readonly summary: TestSummary
readonly routes: RouteDisposition[] // NEW: every route discovered and its status
}
export interface TestResult {
readonly ok: boolean
readonly name: string
readonly id: number
readonly directive?: string
readonly diagnostics?: TestDiagnostics
}
export interface TestSummary {
readonly passed: number
readonly failed: number
readonly skipped: number
readonly timeMs: number
}
export interface RouteDisposition {
readonly path: string
readonly method: string
readonly status: 'tested' | 'skipped' | 'no-contract' | 'scope-filtered'
readonly reason?: string
}
export interface ContractViolation {
readonly type: 'contract-violation'
readonly kind: 'precondition' | 'postcondition' | 'invariant' | 'regex'
readonly route: { readonly method: string; readonly path: string }
readonly formula: string
readonly request: {
readonly body: unknown
readonly headers: Record<string, string>
readonly query: Record<string, unknown>
readonly params: Record<string, unknown>
}
readonly response: {
readonly statusCode: number
readonly headers: Record<string, string>
readonly body: unknown
}
readonly context: {
readonly expected: string
readonly actual: string
readonly diff?: string | null
}
readonly suggestion: string
}
export interface CheckResult {
readonly ok: boolean
readonly violations: ContractViolation[]
}
// Internal types are NOT exported:
// FormulaNode, EvalContext, ModelState, ApiCommand, CacheEntry, etc.
Error Handling
Loud Failures (No Silent Empty Results)
// If no routes are discovered, THROW
const result = await fastify.apophis.contract()
// => throws: No routes discovered. Did you register APOPHIS before defining routes?
// If scope filter excludes all routes, THROW
await fastify.apophis.contract({ scope: 'nonexistent' })
// => throws: Scope 'nonexistent' not found. Available scopes: ['admin', 'user']
// If formula parse fails, THROW with route context
// => ParseError: POST /users, x-ensures[1]: "response_body(this).id != nul"
// Parse error at position 28: Expected identifier
// response_body(this).id != nul
// ^
Diagnostics in TestSuite
const result = await fastify.apophis.contract()
// Every route is accounted for
for (const route of result.routes) {
console.log(`${route.method} ${route.path}: ${route.status}`)
// GET /health: tested
// POST /users: tested
// GET /admin: scope-filtered (scope: 'admin' not in test config)
// DELETE /items/:id: no-contract (no x-ensures or x-requires)
}
Migration from v0.x to v1.0
Plugin Registration
// Before
await fastify.register(apophis, { validateRuntime: true })
// After
await fastify.register(apophis, { runtime: 'error' })
Test Execution
// Before
await fastify.apophis.test({ mode: 'all', depth: 'quick' })
// After
const contract = await fastify.apophis.contract({ depth: 'quick' })
const stateful = await fastify.apophis.stateful({ depth: 'quick' })
Scope Configuration
// Before (env vars)
// APOPHIS_SCOPE_PROD='{"headers":{"x-api-key":"secret"}}'
await fastify.register(apophis)
fastify.apophis.scope.getHeaders('prod')
// After (explicit config)
await fastify.register(apophis, {
scopes: {
prod: { headers: { 'x-api-key': 'secret' } }
}
})
fastify.apophis.scope('prod')
Removed Annotations
// Before
schema: {
'x-invariants': ['response_body(this).id != null'],
'x-regex': { email: '^[^@]+@[^@]+$' },
'x-validate-runtime': false,
}
// After
schema: {
// x-invariants moved to plugin option
// x-regex replaced by JSON Schema pattern
// x-validate-runtime replaced by plugin runtime option
}
Formula Language
// Before (still works)
'if response_code(this) == 200 then response_body(this).id != null else T'
'for item in response_body(this): item.status == "active"'
'previous(response_body(this).id) != null'
// After (removed)
// Use boolean operators instead
'response_code(this) == 200 && response_body(this).id != null'
// Use array element access (if supported in evaluator)
'response_body(this).items.0.status == "active"'
// Temporal contracts removed until bounded
Success Metrics
| Metric | Target | How Verified |
|---|---|---|
| New user: npm install → passing test | < 5 minutes | examples.test.ts |
| Error messages include request/response context | 100% | success-metrics.test.ts |
| Suggestions for violations | 100% | success-metrics.test.ts |
| Silent empty results | 0% | All test calls throw on empty discovery |
| Public API surface | < 10 exported types | types.ts audit |
| Formula parse errors with position | 100% | formula.test.ts |
| Per-route validation latency | < 100ms | benchmark.test.ts |
Remaining Work
Phase 1: API Surface (Week 1)
- Split
test()intocontract()andstateful()methods - Remove
modeandmergeTestSuites - Add
check(method, path)per-route validation - Add
routesdisposition metadata toTestSuite - Make empty discovery throw with diagnostic message
- Curate exports: remove
FormulaNode,EvalContext,ModelState,ApiCommand,CacheEntry,FastifyInjectInstance,ResourceHierarchyfrom public API - Remove
export * from './types'fromindex.ts
Phase 2: Plugin Options (Week 1)
- Rename
validateRuntime→runtime: 'off' | 'warn' | 'error' - Change default from
trueto'off' - Add
cleanup: booleanoption (defaultfalse) - Move scope config from env discovery to plugin option
scopes - Add
invariants: string[]plugin option (replacing per-routex-invariants) - Remove
x-validate-runtimeschema annotation
Phase 3: APOSTL Simplification (Week 2)
- Add
type: 'status'toFormulaNodeAST (makestatus:201real) - Remove
if/then/elsefrom parser - Remove
for/existsquantifiers from parser - Remove
previous()from parser - Remove
=>implication from parser - Remove
T/Fshorthand from parser - Update all tests to use simplified syntax
- Update documentation
Phase 4: Schema Annotations (Week 2)
- Remove
x-invariantssupport (migrated to plugin option) - Remove
x-regexsupport (use JSON Schemapattern) - Add
destructortoOperationCategorytype (or remove from docs) - Document annotation precedence rules
Phase 5: Error Handling (Week 2)
- Parse errors include route path, method, annotation index
- Scope mismatch throws with available scopes list
check()returnsCheckResultwith violations array- All test calls fail loudly on empty discovery
Phase 6: Types (Week 3)
- Type
spec()return asApophisSpec extends OpenAPI.Document - Make
cacheHits/cacheMissesrequired (or move to sub-object) - Use
seed?: numberinstead ofseed: number | undefined - Brand validated types:
ValidatedFormula,HttpMethod - Fix
ContractViolation.formulaTypeto distinguish pre/post/invariant/regex - Add
ContractViolation.kindfield
Phase 7: Performance (Week 3)
- Eager-import test runners (remove lazy imports)
- Static export for
spec()extraction - Cache parsed formulas at route registration time
- Remove
mergeTestSuitesreindexing overhead
Phase 8: Documentation (Week 4)
- Rewrite getting-started.md with new API
- Document simplified APOSTL grammar
- Update all examples
- Migration guide from v0.x
- API reference (typedoc)
Principles Checklist
- Runtime validation and test generation are separate concerns
- Public API fits on a postcard (< 10 exported types)
- Silent empty results are eliminated (throw instead)
- One way to do things (no duplicate syntaxes)
- Types prevent misuse at compile time
- Signal handlers are opt-in
- Scope configuration is explicit, not magic
- Formula language is simplified to core use cases
- Every test call accounts for every route
- Error messages include full context (route, formula, position)