17 KiB
APOPHIS v1.1 Architecture — Hybrid Core + Extensions
Status: Architecture Specification
Date: 2026-04-24
Scope: v1.1 First-Class Features & Extension Ecosystem
1. Philosophy: Core HTTP vs Extensions
First-class: Standard HTTP features that require deep integration with APOPHIS core:
- Schema-to-arbitrary integration (teaching fast-check to generate custom data)
- Request builder integration (constructing specialized payloads)
- HTTP executor integration (handling specialized responses)
- APOSTL parser/evaluator integration (new operations)
Extensions: Specialized protocols or features with heavy dependencies that should be opt-in:
- Different protocols (WebSockets, not HTTP)
- Heavy dependencies (Protobuf, MessagePack)
- Protocol-specific features such as SSE
This split keeps common HTTP testing in core while moving specialized protocols out of the default path.
2. First-Class Features (v1.1 Core)
2.1 Multipart File Uploads
Module: Core — src/infrastructure/multipart.ts, src/domain/multipart-generator.ts
Schema Annotations:
schema: {
body: {
type: 'object',
'x-content-type': 'multipart/form-data',
'x-multipart-fields': {
description: { type: 'string', maxLength: 500 }
},
'x-multipart-files': {
avatar: {
maxSize: 5 * 1024 * 1024,
mimeTypes: ['image/jpeg', 'image/png'],
maxCount: 1
}
}
}
}
APOSTL Operations:
// request_files(this).avatar.count == 1
// request_files(this).avatar.size <= 5242880
// request_files(this).avatar.mimetype matches "image/(jpeg|png)"
// request_fields(this).description != null
Core Integration Points:
- Schema-to-arbitrary: Detect
x-content-type: multipart/form-data, generate{ fields: {...}, files: [...] } - Request builder: Convert generated data to
multipartpayload onRequestStructure - HTTP executor: Build
FormDatafromrequest.multipart, inject via Fastify - Parser: Add
request_files,request_fieldstoVALID_HEADERS - Evaluator: Add multipart operations to
resolveOperation
2.2 Streaming / NDJSON
Module: Core — src/infrastructure/stream-collector.ts
Schema Annotations:
schema: {
response: {
200: {
type: 'object',
'x-streaming': true,
'x-stream-format': 'ndjson',
'x-stream-max-chunks': 100,
'x-stream-timeout': 5000
}
}
}
APOSTL Operations:
// response_body(this) — array of parsed chunks
// stream_chunks(this) — alias for response_body(this)
// stream_duration(this) — total stream time in ms
Core Integration Points:
- Contract extraction: Extract
x-streaming,x-stream-format,x-stream-max-chunks,x-stream-timeout - HTTP executor: After inject, check if route has streaming config. If so:
- Read response payload as string
- Split by
\n JSON.parseeach line (for NDJSON)- Respect
maxChunksandtimeoutMs - Store result in
EvalContext.response.bodyandEvalContext.response.chunks
- Parser: Add
stream_chunks,stream_durationtoVALID_HEADERS - Evaluator: Add streaming operations to
resolveOperation
3. Extension System (v1.1+ Ecosystem)
The extension system handles features that don't require core HTTP integration.
3.1 Extension Interface
export interface ApophisExtension {
/** Unique name. Used for state isolation and error attribution. */
name: string
/** APOSTL headers this extension adds. Used for parser validation. */
headers?: string[]
/** APOSTL predicates exposed by this extension. */
predicates?: Record<string, PredicateResolver>
/** Lifecycle hooks. */
hooks?: {
onBuildRequest?: Hook<RequestBuildContext, void>
onBeforeRequest?: Hook<ExecutionContext, void>
onAfterRequest?: Hook<ExecutionContext, void>
onSuiteStart?: Hook<{ routes: RouteContract[] }, void>
onSuiteEnd?: Hook<{ summary: TestSummary }, void>
onViolation?: Hook<{ violation: ContractViolation }, void>
}
/** Severity: 'fatal' (block test), 'warn' (log, don't block). Default: 'fatal'. */
severity?: 'fatal' | 'warn'
/** Redaction: fields to mask in violation output. */
redactFields?: string[]
/** Initial state for this extension. Passed to hooks/predicates. */
state?: Record<string, unknown>
}
3.2 Extension Registration
await fastify.register(apophis, {
extensions: [
sseExtension,
createSerializerExtension(mySerializerRegistry),
websocketExtension,
]
})
3.3 Extensions Available
SSE Extension
Module: src/extensions/sse/
export const sseExtension: ApophisExtension = {
name: 'sse',
headers: ['sse_events'],
predicates: {
sse_events: (ctx) => {
const events = ctx.evalContext.response.sseEvents ?? []
if (ctx.accessor.length === 0) return { value: events, success: true }
const idx = parseInt(ctx.accessor[0], 10)
const event = events[idx]
if (!event) return { value: null, success: true }
if (ctx.accessor[1] === 'event') return { value: event.event, success: true }
if (ctx.accessor[1] === 'data') return { value: event.data, success: true }
if (ctx.accessor[1] === 'id') return { value: event.id, success: true }
if (ctx.accessor[1] === 'retry') return { value: event.retry, success: true }
return { value: event, success: true }
}
}
}
Serializers Extension
Module: src/extensions/serializers/
export interface Serializer {
readonly name: string
encode(data: unknown): Buffer
decode(buffer: Buffer): unknown
}
export interface SerializerRegistry {
get(name: string): Serializer | undefined
register(name: string, serializer: Serializer): void
}
export const createSerializerExtension = (registry: SerializerRegistry): ApophisExtension => ({
name: 'serializers',
hooks: {
onBuildRequest: async (ctx) => {
const serializerName = ctx.route.serializer?.name
if (!serializerName) return
const serializer = registry.get(serializerName)
if (!serializer) return
// Modify request: encode body, set content-type
ctx.request.body = serializer.encode(ctx.request.body)
ctx.request.headers = {
...ctx.request.headers,
'content-type': `application/x-${serializerName}`,
}
},
onAfterRequest: async (ctx) => {
const serializerName = ctx.route.serializer?.name
if (!serializerName) return
const serializer = registry.get(serializerName)
if (!serializer) return
// Modify response: decode body
const rawBody = Buffer.from(JSON.stringify(ctx.evalContext.response.body))
ctx.evalContext.response.body = serializer.decode(rawBody)
}
}
})
WebSockets Extension
Module: src/extensions/websocket/
Note: WebSockets are fundamentally different from HTTP. They require a dedicated runner, not just hooks.
export const websocketExtension: ApophisExtension = {
name: 'websocket',
headers: ['ws_message', 'ws_state'],
predicates: {
ws_message: (ctx) => {
const msg = ctx.evalContext.ws?.message ?? null
if (ctx.accessor.length === 0) return { value: msg, success: true }
if (!msg) return { value: null, success: true }
if (ctx.accessor[0] === 'type') return { value: msg.type, success: true }
if (ctx.accessor[0] === 'payload') return { value: msg.payload, success: true }
if (ctx.accessor[0] === 'direction') return { value: msg.direction, success: true }
return { value: msg, success: true }
},
ws_state: (ctx) => {
return { value: ctx.evalContext.ws?.state ?? null, success: true }
}
},
hooks: {
onSuiteStart: async ({ routes }) => {
// Pre-validate all WS contracts
const wsRoutes = routes.filter(r => r.ws !== undefined)
for (const route of wsRoutes) {
validateWebSocketContract(route.ws!)
}
}
}
}
WebSocket runner: Invoked by plugin separately from HTTP runners:
// In plugin/index.ts
const buildContract = (fastify, scope) => async (opts) => {
const httpSuite = await runPetitTests(fastify, opts, scope)
const wsSuite = await runWebSocketTests(fastify, opts, scope) // From extension
return mergeSuites(httpSuite, wsSuite)
}
4. Core Changes (Phase 1)
4.1 Parser Extensibility
Current: VALID_HEADERS is hardcoded. Extensions can't add headers.
Solution: Extensions register headers. Parser validates against registered + core headers.
// src/formula/parser.ts
const CORE_HEADERS: OperationHeader[] = [
'request_body', 'response_body', 'response_code',
'request_headers', 'response_headers', 'query_params', 'cookies', 'response_time',
'redirect_count', 'redirect_url', 'redirect_status',
'timeout_occurred', 'timeout_value',
// v1.1 first-class
'request_files', 'request_fields', 'stream_chunks', 'stream_duration',
]
// ExtensionRegistry provides additional headers
function getValidHeaders(registry?: ExtensionRegistry): string[] {
const extensionHeaders = registry
? registry.extensions.flatMap(e => e.headers ?? [])
: []
return [...CORE_HEADERS, ...extensionHeaders]
}
// In parseOperation, validate against getValidHeaders()
4.2 Evaluator Extensibility
Current: resolveOperation checks core operations only.
Solution: Check extension predicates BEFORE core operations.
function resolveOperation(node, ctx, extensionRegistry, route) {
const { header, accessor } = node
// 1. Check extension predicates FIRST
if (extensionRegistry) {
const resolver = extensionRegistry.resolvePredicate(header)
if (resolver) {
const ownerName = extensionRegistry.getPredicateOwner(header)
const extState = ownerName ? (extensionRegistry.getState(ownerName) ?? {}) : {}
const result = resolver({ route, evalContext: ctx, accessor: accessor ?? [], extensionState: extState })
if (result && typeof result.then !== 'function') {
return (result as PredicateResult).value
}
}
}
// 2. Fall back to core operations
switch (header) {
// ... core cases ...
}
}
4.3 HTTP Executor Hooks
Current: executeHttp is a monolithic function.
Solution: Add onTransformResponse hook point for extensions that need to modify responses.
export interface ResponseTransformContext {
responseBody: unknown
evalContext: EvalContext
route: RouteContract
}
export type ResponseTransformHook = (ctx: ResponseTransformContext) => EvalContext | Promise<EvalContext>
// In executeHttp:
let ctx = buildEvalContext(request, response, route)
// Apply extension response transforms
for (const ext of (extensionRegistry?.extensions ?? [])) {
if (ext.hooks?.onAfterRequest) {
await ext.hooks.onAfterRequest({
route,
request,
evalContext: ctx,
extensionState: extensionRegistry?.getState(ext.name) ?? {},
})
}
}
5. Implementation Order
Phase 1: Core Extension Points (1-2 days)
- Make parser accept registered headers (CORE_HEADERS + extension headers)
- Make evaluator check extension predicates before core operations
- Add response transform hook point to HTTP executor
- Test: Core operations still work; extension predicates resolve
Phase 2A: Multipart (First-Class, 2-3 days)
- Add
MultipartFile,MultipartPayloadtypes - Add multipart schema-to-arbitrary handler
- Add multipart request builder support
- Add multipart HTTP executor support (FormData construction)
- Add
request_files,request_fieldsto parser/evaluator - Extract multipart config from schema in contract.ts
- Test:
src/test/multipart.test.ts(10+ tests)
Phase 2B: Streaming (First-Class, 2-3 days)
- Add
chunks,streamDurationMstoEvalContext.response - Add streaming config extraction from schema
- Add stream collection to HTTP executor (NDJSON parsing)
- Add
stream_chunks,stream_durationto parser/evaluator - Test:
src/test/streaming.test.ts(8+ tests)
Phase 2C: Extension System Polish (1 day)
- Document extension registration API
- Add
extensions: ApophisExtension[]toApophisOptions - Wire extension headers into parser
- Wire extension predicates into evaluator
Phase 3: Extensions (Parallel, after Phase 2C)
- SSE Extension (2-3 days)
- Serializers Extension (2-3 days)
- WebSockets Extension (1-2 weeks)
Phase 4: Integration (2-3 days)
- Run full test suite
- Update README
- Verify benchmarks
6. File Layout
src/
# Core v1.1 First-Class Features
infrastructure/
http-executor.ts # ADD: multipart FormData, stream collection
multipart.ts # NEW: FormData construction
stream-collector.ts # NEW: NDJSON chunk parsing
domain/
schema-to-arbitrary.ts # ADD: multipart schema handler
request-builder.ts # ADD: multipart payload construction
contract.ts # ADD: multipart/streaming config extraction
formula/
parser.ts # MODIFY: extensible VALID_HEADERS
evaluator.ts # MODIFY: extension predicate check
types.ts # ADD: MultipartFile, MultipartPayload, stream fields
# Extension System
extension/
types.ts # ADD: headers, onTransformResponse to interface
registry.ts # ADD: collect extension headers
# Extensions (opt-in)
extensions/
sse/ # SSE extension module
serializers/ # Serializer extension module
websocket/ # WebSocket extension module
7. Test Strategy
First-Class Features: Red-Green-Refactor
// Example: Multipart
// 1. Test: Parser accepts request_files(this).avatar.size
// 2. Implement: Add request_files to VALID_HEADERS
// 3. Test: Evaluator resolves request_files
// 4. Implement: Add multipart operations to resolveOperation
// 5. Test: Schema-to-arbitrary generates fake files
// 6. Implement: Add multipart handler to convertSchemaInternal
// 7. Test: Request builder constructs multipart payload
// 8. Implement: Add multipart support to buildRequest
// 9. Test: HTTP executor sends multipart request
// 10. Implement: Build FormData in executeHttp
// 11. Test: Integration — upload route works end-to-end
// 12. Implement: Full flow
Extensions: Self-Contained Tests
Each extension module has its own test.ts:
// src/extensions/sse/test.ts
import { test } from 'node:test'
import assert from 'node:assert'
import { sseExtension } from './extension.js'
test('sse: predicate returns events', () => {
const resolver = sseExtension.predicates!.sse_events
const result = resolver({
route: mockRoute,
evalContext: { response: { sseEvents: [{ event: 'update', data: {} }] } },
accessor: [],
extensionState: {},
})
assert.strictEqual((result.value as any[]).length, 1)
})
8. Backward Compatibility
All v1.1 changes are additive:
- Routes without multipart/streaming annotations work unchanged
- Extensions are opt-in via
extensions: [...]option - Existing APOSTL formulas work unchanged
- No breaking changes to public API
Migration path:
// v1.0
await fastify.register(apophis)
// v1.1 (no changes required for existing code)
await fastify.register(apophis)
// v1.1 with extensions
await fastify.register(apophis, {
extensions: [sseExtension, serializerExtension, websocketExtension]
})
9. Risk Assessment
| Risk | Mitigation |
|---|---|
| Parser changes break existing formulas | Comprehensive regression tests before parser modification |
| Multipart adds heavy deps | Only use native FormData/Blob (no external deps) |
| Streaming tests are flaky | Mock streams for unit tests; integration tests with deterministic timeouts |
| Extension conflicts | Namespacing by extension name; ExtensionRegistry.getState(name) isolates state |
| WebSocket extension too large | Split into sub-workstreams: client, runner, stateful, validation |
10. Success Criteria
| Criterion | Verification |
|---|---|
| Multipart upload routes tested | multipart.test.ts passes |
| Streaming routes tested | streaming.test.ts passes |
| Extension predicates work | Extension test.ts files pass |
| No regression | Full source and CLI test suites pass |
| Benchmark targets met | benchmark.test.ts passes |
| Documentation updated | README covers multipart and streaming |
11. Quick Reference: First-Class vs Extension
| Feature | Type | Core Files | Tests | Effort |
|---|---|---|---|---|
| Multipart | First-class | multipart.ts, schema-to-arbitrary.ts, request-builder.ts, http-executor.ts, parser.ts, evaluator.ts |
multipart.test.ts |
2-3 days |
| Streaming | First-class | stream-collector.ts, http-executor.ts, parser.ts, evaluator.ts, contract.ts |
streaming.test.ts |
2-3 days |
| SSE | Extension | src/extensions/sse/* |
src/extensions/sse/test.ts |
2-3 days |
| Serializers | Extension | src/extensions/serializers/* |
src/extensions/serializers/test.ts |
2-3 days |
| WebSockets | Extension | src/extensions/websocket/* |
src/extensions/websocket/test.ts |
1-2 weeks |