2026-03-10 00:00:00 -07:00
# APOPHIS v1.1 Architecture — Hybrid Core + Extensions
2026-05-22 17:07:12 -07:00
> **HISTORICAL**: This is a v1.1 architecture design specification. For current extension documentation, see [EXTENSION-PLUGIN-SYSTEM.md](EXTENSION-PLUGIN-SYSTEM.md) and [QUICK-REFERENCE.md](QUICK-REFERENCE.md).
2026-03-10 00:00:00 -07:00
## 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 ** :
``` typescript
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 ** :
``` typescript
// 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 ** :
1. **Schema-to-arbitrary ** : Detect `x-content-type: multipart/form-data` , generate `{ fields: {...}, files: [...] }`
2. **Request builder ** : Convert generated data to `multipart` payload on `RequestStructure`
3. **HTTP executor ** : Build `FormData` from `request.multipart` , inject via Fastify
4. **Parser ** : Add `request_files` , `request_fields` to `VALID_HEADERS`
5. **Evaluator ** : Add multipart operations to `resolveOperation`
### 2.2 Streaming / NDJSON
**Module ** : Core — `src/infrastructure/stream-collector.ts`
**Schema Annotations ** :
``` typescript
schema : {
response : {
200 : {
type : 'object' ,
'x-streaming' : true ,
'x-stream-format' : 'ndjson' ,
'x-stream-max-chunks' : 100 ,
'x-stream-timeout' : 5000
}
}
}
```
**APOSTL Operations ** :
``` typescript
// 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 ** :
1. **Contract extraction ** : Extract `x-streaming` , `x-stream-format` , `x-stream-max-chunks` , `x-stream-timeout`
2. **HTTP executor ** : After inject, check if route has streaming config. If so:
- Read response payload as string
- Split by `\n`
- `JSON.parse` each line (for NDJSON)
- Respect `maxChunks` and `timeoutMs`
- Store result in `EvalContext.response.body` and `EvalContext.response.chunks`
3. **Parser ** : Add `stream_chunks` , `stream_duration` to `VALID_HEADERS`
4. **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
``` typescript
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
``` typescript
await fastify . register ( apophis , {
extensions : [
sseExtension ,
createSerializerExtension ( mySerializerRegistry ) ,
websocketExtension ,
]
} )
```
### 3.3 Extensions Available
#### SSE Extension
**Module**: `src/extensions/sse/`
``` typescript
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/`
``` typescript
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.
``` typescript
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:
``` typescript
// 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.
``` typescript
// 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.
``` typescript
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.
``` typescript
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)
1. Make parser accept registered headers (CORE_HEADERS + extension headers)
2. Make evaluator check extension predicates before core operations
3. Add response transform hook point to HTTP executor
4. **Test ** : Core operations still work; extension predicates resolve
### Phase 2A: Multipart (First-Class, 2-3 days)
1. Add `MultipartFile` , `MultipartPayload` types
2. Add multipart schema-to-arbitrary handler
3. Add multipart request builder support
4. Add multipart HTTP executor support (FormData construction)
5. Add `request_files` , `request_fields` to parser/evaluator
6. Extract multipart config from schema in contract.ts
7. **Test ** : `src/test/multipart.test.ts` (10+ tests)
### Phase 2B: Streaming (First-Class, 2-3 days)
1. Add `chunks` , `streamDurationMs` to `EvalContext.response`
2. Add streaming config extraction from schema
3. Add stream collection to HTTP executor (NDJSON parsing)
4. Add `stream_chunks` , `stream_duration` to parser/evaluator
5. **Test ** : `src/test/streaming.test.ts` (8+ tests)
### Phase 2C: Extension System Polish (1 day)
1. Document extension registration API
2. Add `extensions: ApophisExtension[]` to `ApophisOptions`
3. Wire extension headers into parser
4. 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)
1. Run full test suite
2. Update README
3. 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
``` typescript
// 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` :
``` typescript
// 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 ** :
``` typescript
// 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 |