# 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**: ```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 /** Lifecycle hooks. */ hooks?: { onBuildRequest?: Hook onBeforeRequest?: Hook onAfterRequest?: Hook 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 } ``` ### 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 // 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 |