# Extension Quick Reference — Hybrid Architecture ## Overview APOPHIS v2.x uses a **hybrid architecture**: - **First-class features**: Standard HTTP capabilities built into core (multipart, streaming, timeouts, redirects) - **Extensions**: Specialized protocols via the extension system (SSE, serializers, WebSockets, JWT, X.509, SPIFFE, etc.) Extensions integrate with APOSTL by registering custom predicates and operation headers that can be used in contract formulas. **When to implement first-class vs extension**: - **First-class**: Required by common HTTP request/response execution, schema-to-arbitrary integration, or request builder changes - **Extension**: Protocol-specific, dependency-heavy, or uncommon in the default HTTP path --- ## New in v2.2 ### Route Targeting Test only specific routes instead of all discovered routes: ```typescript await fastify.apophis.contract({ depth: 'quick', routes: ['GET /health', 'POST /billing/plans'] }) ``` ### Chaos Configuration Per-route chaos with include/exclude patterns: ```typescript await fastify.apophis.contract({ chaos: { probability: 0.3, include: ['/billing/*'], exclude: ['/billing/sensitive'], routes: { '/billing/plans': { dropout: { probability: 0 } } }, resilience: { enabled: true, maxRetries: 3 } } }) ``` ### wrapFetch for Outbound Interception ```typescript import { wrapFetch, createOutboundInterceptor } from 'apophis-fastify' const interceptor = createOutboundInterceptor([ { target: 'api.stripe.com', delay: { probability: 0.1, minMs: 1000, maxMs: 5000 }, error: { probability: 0.05, responses: [{ statusCode: 429, headers: { 'retry-after': '60' } }] } } ], 42) const interceptedFetch = wrapFetch(globalThis.fetch, interceptor) ``` ### Mutation Testing Measure contract strength by injecting synthetic bugs: ```typescript import { runMutationTesting } from 'apophis-fastify/quality/mutation' const report = await runMutationTesting(fastify) console.log(`Score: ${report.score}%`) // 0-100 console.log('Weak contracts:', report.weakContracts) ``` --- ## First-Class Features (Built-In) ### Multipart File Uploads **Always available. No registration needed.** ```typescript // Route definition fastify.post('/upload', { 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 } } }, 'x-ensures': [ 'request_files(this).avatar.count == 1', 'request_files(this).avatar.size <= 5242880', 'request_fields(this).description != null' ] } }, handler) ``` **APOSTL Expressions**: ```apostl request_files(this).avatar.count // number request_files(this).avatar.size // bytes request_files(this).avatar.mimetype // string request_fields(this).description // string ``` **Core Files**: - `src/infrastructure/multipart.ts` — FormData construction - `src/domain/multipart-generator.ts` — Fake file generation - `src/domain/schema-to-arbitrary.ts` — Detect `x-content-type: multipart/form-data` - `src/domain/request-builder.ts` — Build multipart payload - `src/infrastructure/http-executor.ts` — Inject multipart via Fastify --- ### Streaming / NDJSON **Always available. No registration needed.** ```typescript // Route definition fastify.get('/events', { schema: { response: { 200: { type: 'object', 'x-streaming': true, 'x-stream-format': 'ndjson', 'x-stream-max-chunks': 100, 'x-stream-timeout': 5000, 'x-ensures': [ 'stream_chunks(this).length <= 100', 'stream_duration(this) < 5000' ] } } } }, handler) ``` **APOSTL Expressions**: ```apostl stream_chunks(this) // array of parsed chunks (for NDJSON) stream_duration(this) // milliseconds ``` **Core Files**: - `src/infrastructure/stream-collector.ts` — Chunk collection & NDJSON parsing - `src/infrastructure/http-executor.ts` — Apply streaming config after inject - `src/domain/contract.ts` — Extract streaming annotations --- ### Timeouts & Redirects Implemented in the current core. ```apostl timeout_occurred(this) == false timeout_value(this) < 5000 redirect_count(this) == 1 redirect_url(this).0 == "https://example.com" redirect_status(this).0 == 301 ``` --- ## Extensions (Opt-In) Extensions register custom APOSTL predicates that can be used in `x-ensures` and `x-requires` formulas. ### SSE (Server-Sent Events) **Register via `extensions: [sseExtension]`** ```typescript import { sseExtension } from 'apophis-fastify/extensions/sse' await fastify.register(apophis, { extensions: [sseExtension] }) // Route definition fastify.get('/notifications', { schema: { response: { 200: { 'x-sse': true, 'x-sse-events': ['update', 'delete'], 'x-sse-max-events': 10, 'x-sse-timeout': 30000, 'x-ensures': [ 'sse_events(this).length <= 10', 'sse_events(this).0.event == "update"' ] } } } }, handler) ``` **APOSTL Expressions**: ```apostl sse_events(this) // array of events sse_events(this).0.event // string sse_events(this).0.data // unknown sse_events(this).0.retry // number (ms) ``` **Extension Files**: - `src/extensions/sse/types.ts` - `src/extensions/sse/predicates.ts` - `src/extensions/sse/extension.ts` - `src/extensions/sse/test.ts` --- ### Custom Serializers **Register via `extensions: [createSerializerExtension(registry)]`** ```typescript import { createSerializerExtension, createSerializerRegistry } from 'apophis-fastify/extensions/serializers' const registry = createSerializerRegistry() registry.register('protobuf', { encode: (data) => protobuf.encode(data), decode: (buffer) => protobuf.decode(buffer), }) await fastify.register(apophis, { extensions: [createSerializerExtension(registry)] }) // Route definition fastify.post('/users', { schema: { body: { 'x-serializer': 'protobuf', 'x-serializer-schema': './schemas/user.proto' } } }, handler) ``` **No new APOSTL expressions.** Use existing `response_body(this)`, `response_headers(this)`. **Extension Files**: - `src/extensions/serializers/types.ts` - `src/extensions/serializers/extension.ts` - `src/extensions/serializers/test.ts` --- ### WebSockets **Register via `extensions: [websocketExtension]`** ```typescript import { websocketExtension } from 'apophis-fastify/extensions/websocket' await fastify.register(apophis, { extensions: [websocketExtension] }) // Route definition fastify.get('/ws/events', { websocket: true, schema: { 'x-ws-messages': [ { type: 'auth', direction: 'outgoing', schema: { type: 'object', properties: { token: { type: 'string' } } } }, { type: 'ready', direction: 'incoming', schema: { type: 'object', properties: { status: { type: 'string', const: 'ready' } } } } ], 'x-ws-transitions': [ { from: 'open', to: 'authenticating', trigger: 'auth' }, { from: 'authenticating', to: 'ready', trigger: 'ready' } ], 'x-ensures': [ 'ws_state(this) == "ready"' ] } }, handler) ``` **APOSTL Expressions**: ```apostl ws_message(this).type // string ws_message(this).payload // unknown ws_state(this) // string ``` **Extension Files**: - `src/extensions/websocket/types.ts` - `src/extensions/websocket/predicates.ts` - `src/extensions/websocket/client.ts` - `src/extensions/websocket/runner.ts` - `src/extensions/websocket/extension.ts` - `src/extensions/websocket/test.ts` --- ### JWT **Register via `extensions: [jwtExtension(config)]`** ```typescript import { jwtExtension } from 'apophis-fastify/extensions' await fastify.register(apophis, { extensions: [ jwtExtension({ jwks: 'https://auth.example.com/.well-known/jwks.json', verify: true, }) ] }) ``` **APOSTL Expressions**: ```apostl jwt_claims(this).sub != null jwt_claims(this).exp > jwt_claims(this).iat jwt_header(this).alg == "RS256" jwt_valid(this) == true jwt_format(this) == "compact" ``` --- ### X.509 Certificates **Register via `extensions: [x509Extension(config)]`** ```typescript import { x509Extension } from 'apophis-fastify/extensions' await fastify.register(apophis, { extensions: [x509Extension()] }) ``` **APOSTL Expressions**: ```apostl x509_uri_sans(this).length == 1 x509_ca(this) == false x509_expired(this) == false x509_self_signed(this) == false ``` --- ### SPIFFE **Register via `extensions: [spiffeExtension(config)]`** ```typescript import { spiffeExtension } from 'apophis-fastify/extensions' await fastify.register(apophis, { extensions: [spiffeExtension()] }) ``` **APOSTL Expressions**: ```apostl spiffe_parse(this).trustDomain matches "^[a-z0-9.-]+$" spiffe_parse(this).path.length > 0 spiffe_validate(this) == true ``` --- ### Token Hash (WIMSE S2S) **Register via `extensions: [tokenHashExtension(config)]`** ```typescript import { tokenHashExtension } from 'apophis-fastify/extensions' await fastify.register(apophis, { extensions: [tokenHashExtension()] }) ``` **APOSTL Expressions**: ```apostl ath_valid(this) == true tth_valid(this) == true token_hash(this, "sha256") == jwt_claims(this).ath ``` --- ### HTTP Signature **Register via `extensions: [httpSignatureExtension(config)]`** ```typescript import { httpSignatureExtension } from 'apophis-fastify/extensions' await fastify.register(apophis, { extensions: [httpSignatureExtension()] }) ``` **APOSTL Expressions**: ```apostl signature_covers(this, "@method") == true signature_covers(this, "@request-target") == true signature_valid(this) == true ``` --- ### Time Control **Register via `extensions: [timeExtension(config)]`** ```typescript import { timeExtension } from 'apophis-fastify/extensions' await fastify.register(apophis, { extensions: [timeExtension()] }) ``` **APOSTL Expressions**: ```apostl jwt_claims(this).exp > now() jwt_claims(this).exp <= now() + 30000 ``` --- ### Stateful Cross-Request **Register via `extensions: [statefulExtension()]`** ```typescript import { statefulExtension } from 'apophis-fastify/extensions' await fastify.register(apophis, { extensions: [statefulExtension()] }) ``` **APOSTL Expressions**: ```apostl already_seen(this, jwt_claims(this).jti) == false is_consumed(this, jwt_claims(this).jti) == false previous(constructor).jwt_claims(this).refresh_token != null ``` --- ### Cross-Route Relationships **Always available. No registration needed.** Validate hypermedia links and parent-child relationships using APOSTL predicates: **APOSTL Expressions**: ```apostl // Verify hypermedia controls resolve to real routes route_exists(this).controls.self.href == true route_exists(this).controls.tenant.href == true // Verify parent-child consistency relationship_valid("parent", request_params(this).tenantId, response_body(this).tenantId) == true // Verify cascade after DELETE cascade_valid("tenant", request_params(this).id, ["application", "user"]) == true ``` **Example**: ```typescript fastify.get('/tenants/:id', { schema: { 'x-category': 'observer', 'x-ensures': [ 'route_exists(this).controls.self.href == true', 'route_exists(this).controls.applications.href == true', ], response: { 200: { type: 'object', properties: { id: { type: 'string' }, controls: { type: 'object', properties: { self: { type: 'object', properties: { href: { type: 'string' } } }, applications: { type: 'object', properties: { href: { type: 'string' } } }, }, }, }, }, }, }, }) ``` ### Request Context **Register via `extensions: [requestContextExtension(config)]`** ```typescript import { requestContextExtension } from 'apophis-fastify/extensions' await fastify.register(apophis, { extensions: [requestContextExtension()] }) ``` **APOSTL Expressions**: ```apostl jwt_claims(this).aud == request_url(this) request_url(this).path == "/api/users" request_body_hash(this, "sha256") == expected_hash ``` --- ## Chaos Quick Reference ### Basic Chaos ```typescript await fastify.apophis.contract({ chaos: { probability: 0.3, delay: { probability: 0.5, minMs: 50, maxMs: 200 }, error: { probability: 0.2, statusCode: 503 }, dropout: { probability: 0.1 }, corruption: { probability: 0.1 } } }) ``` ### Outbound Interception ```typescript import { wrapFetch, createOutboundInterceptor } from 'apophis-fastify' const interceptor = createOutboundInterceptor([{ target: 'api.stripe.com', error: { probability: 0.05, responses: [{ statusCode: 429, headers: { 'retry-after': '60' } }] } }], 42) const interceptedFetch = wrapFetch(globalThis.fetch, interceptor) ``` ### Per-Route Overrides ```typescript chaos: { probability: 0.3, exclude: ['/health'], include: ['/api/*'], routes: { '/billing/plans': { dropout: { probability: 0 } } } } ``` ### Blast Radius Cap ```typescript chaos: { probability: 0.5, delay: { probability: 1.0, minMs: 10, maxMs: 50 }, maxInjectionsPerSuite: 10 } ``` ### ChaosConfig Options | Field | Type | Description | |-------|------|-------------| | `probability` | `number` | Top-level injection probability (0.0–1.0) | | `delay` | `{ probability, minMs, maxMs }` | Delay injection | | `error` | `{ probability, statusCode, body? }` | Forced error responses | | `dropout` | `{ probability, statusCode? }` | Simulated network failure (default 504) | | `corruption` | `{ probability }` | Body truncation / malformed payloads | | `outbound` | `OutboundChaosConfig[]` | Intercept outbound HTTP requests | | `routes` | `Record>` | Per-route config overrides | | `include` | `string[]` | Whitelist routes (supports `*` suffix) | | `exclude` | `string[]` | Blacklist routes | | `resilience` | `{ enabled, maxRetries?, backoffMs? }` | Retry after chaos to confirm recovery | | `skipResilienceFor` | `OperationCategory[]` | Skip retries for non-idempotent categories | | `dropoutStatusCode` | `number` | Override dropout status (default 504) | | `maxInjectionsPerSuite` | `number` | Cap total injections per test suite | ### Body Corruption Strategies | Content Type | Strategy | Kind | |-------------|----------|------| | `application/json` | Truncate or null random field | `body-truncate` / `body-malformed` | | `application/x-ndjson` | Corrupt random chunk | `body-malformed` | | `text/event-stream` | Corrupt SSE event format | `body-malformed` | | `multipart/form-data` | Corrupt multipart field | `body-malformed` | | `text/plain` / `text/html` | Truncate text | `body-truncate` | --- ## Decision Matrix | Question | If YES → | If NO → | |----------|----------|---------| | Is this standard HTTP (RFC)? | **First-class** | Consider extension | | Does it need fast-check schema integration? | **First-class** | Extension | | Is it in >50% of APIs? | **First-class** | Extension | | Does it need heavy dependencies (>100KB)? | Extension | **First-class** | | Is it a different protocol (WS, gRPC)? | Extension | **First-class** | | Is it declining in popularity (<10% usage)? | Extension | **First-class** | --- ## Core Extension Points ### For First-Class Features Modify these core files: 1. **Types** (`src/types.ts`): - Add new fields to `EvalContext` if needed - Add new `OperationHeader` values 2. **HTTP Executor** (`src/infrastructure/http-executor.ts`): - Multipart: Build FormData - Streaming: Collect chunks 3. **Schema-to-Arbitrary** (`src/domain/schema-to-arbitrary.ts`): - Multipart: Generate fake files - Streaming: No changes (streaming is response-only) 4. **Evaluator** (`src/formula/evaluator.ts`): - Add new `resolveStandardOperation` cases ### For Extensions Implement these in your extension module: 1. **Extension Config** (`extension.ts`): ```typescript export const myExtension: ApophisExtension = { name: 'my-extension', headers: ['my_predicate'], predicates: { my_predicate: (ctx) => ({ value: 'test', success: true }) }, hooks: { onAfterRequest: async (ctx) => { // Transform response } } } ``` 2. **Registration**: ```typescript await fastify.register(apophis, { extensions: [myExtension] }) ``` --- ## Testing Strategy ### First-Class Features Test in `src/test/FEATURE.test.ts`: ```typescript import { test } from 'node:test' import assert from 'node:assert' import Fastify from 'fastify' test('multipart: upload with fake file', async () => { const fastify = Fastify() // ... setup route with multipart schema ... const result = await fastify.apophis.contract() assert.strictEqual(result.summary.failed, 0) }) ``` ### Extensions Test in `src/extensions/NAME/test.ts`: ```typescript import { test } from 'node:test' import assert from 'node:assert' import { myExtension } from './extension.js' test('extension: predicate resolves', () => { const resolver = myExtension.predicates!.my_predicate const result = resolver(mockContext) assert.strictEqual(result.value, expected) }) ``` --- ## Getting Started ### Adding a First-Class Feature 1. Identify if feature needs schema-to-arbitrary integration 2. If yes → implement in core 3. Add types to `src/types.ts` 4. Add evaluator cases to `src/formula/evaluator.ts` 5. Add HTTP executor support 6. Add tests to `src/test/FEATURE.test.ts` ### Adding an Extension 1. Create module: `src/extensions/my-feature/` 2. Implement `extension.ts` with `ApophisExtension` config 3. Add tests to `src/extensions/my-feature/test.ts` 4. Export from `src/extensions/my-feature/index.ts` 5. Register via `extensions: [myExtension]` --- ## Questions? **Q: Can I make a first-class feature into an extension later?** A: Yes, but it's a breaking change. Better to start as first-class if unsure. **Q: Can extensions depend on first-class features?** A: Yes. Extensions can use any core capability. **Q: How do I test without the extension loaded?** A: Extensions are self-contained. Each module is testable in isolation. **Q: What if two extensions define the same predicate?** A: Duplicate predicate names should fail registration unless an explicit override policy is enabled. Use namespacing: `sse_events` not `events`.