Files

550 lines
17 KiB
Markdown
Raw Permalink Normal View History

# 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<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 |