Files
apophis-fastify/docs/attic/API_REDESIGN_V1.md
T

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

  1. Split what is separate: Runtime validation and test generation are different concerns. Do not force them into one plugin.
  2. Do not export internals: The public API should fit on a postcard.
  3. Fail loud: A silent empty result is worse than a thrown error.
  4. One way to do things: No duplicate syntaxes, no overlapping annotations.
  5. 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. Default false because 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. No mergeTestSuites. 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() into contract() and stateful() methods
  • Remove mode and mergeTestSuites
  • Add check(method, path) per-route validation
  • Add routes disposition metadata to TestSuite
  • Make empty discovery throw with diagnostic message
  • Curate exports: remove FormulaNode, EvalContext, ModelState, ApiCommand, CacheEntry, FastifyInjectInstance, ResourceHierarchy from public API
  • Remove export * from './types' from index.ts

Phase 2: Plugin Options (Week 1)

  • Rename validateRuntimeruntime: 'off' | 'warn' | 'error'
  • Change default from true to 'off'
  • Add cleanup: boolean option (default false)
  • Move scope config from env discovery to plugin option scopes
  • Add invariants: string[] plugin option (replacing per-route x-invariants)
  • Remove x-validate-runtime schema annotation

Phase 3: APOSTL Simplification (Week 2)

  • Add type: 'status' to FormulaNode AST (make status:201 real)
  • Remove if/then/else from parser
  • Remove for/exists quantifiers from parser
  • Remove previous() from parser
  • Remove => implication from parser
  • Remove T/F shorthand from parser
  • Update all tests to use simplified syntax
  • Update documentation

Phase 4: Schema Annotations (Week 2)

  • Remove x-invariants support (migrated to plugin option)
  • Remove x-regex support (use JSON Schema pattern)
  • Add destructor to OperationCategory type (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() returns CheckResult with violations array
  • All test calls fail loudly on empty discovery

Phase 6: Types (Week 3)

  • Type spec() return as ApophisSpec extends OpenAPI.Document
  • Make cacheHits/cacheMisses required (or move to sub-object)
  • Use seed?: number instead of seed: number | undefined
  • Brand validated types: ValidatedFormula, HttpMethod
  • Fix ContractViolation.formulaType to distinguish pre/post/invariant/regex
  • Add ContractViolation.kind field

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 mergeTestSuites reindexing 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)