18 KiB
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:
await fastify.apophis.contract({
depth: 'quick',
routes: ['GET /health', 'POST /billing/plans']
})
Chaos Configuration
Per-route chaos with include/exclude patterns:
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
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:
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.
// 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:
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 constructionsrc/domain/multipart-generator.ts— Fake file generationsrc/domain/schema-to-arbitrary.ts— Detectx-content-type: multipart/form-datasrc/domain/request-builder.ts— Build multipart payloadsrc/infrastructure/http-executor.ts— Inject multipart via Fastify
Streaming / NDJSON
Always available. No registration needed.
// 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:
stream_chunks(this) // array of parsed chunks (for NDJSON)
stream_duration(this) // milliseconds
Core Files:
src/infrastructure/stream-collector.ts— Chunk collection & NDJSON parsingsrc/infrastructure/http-executor.ts— Apply streaming config after injectsrc/domain/contract.ts— Extract streaming annotations
Timeouts & Redirects
Implemented in the current core.
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]
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:
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.tssrc/extensions/sse/predicates.tssrc/extensions/sse/extension.tssrc/extensions/sse/test.ts
Custom Serializers
Register via extensions: [createSerializerExtension(registry)]
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.tssrc/extensions/serializers/extension.tssrc/extensions/serializers/test.ts
WebSockets
Register via extensions: [websocketExtension]
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:
ws_message(this).type // string
ws_message(this).payload // unknown
ws_state(this) // string
Extension Files:
src/extensions/websocket/types.tssrc/extensions/websocket/predicates.tssrc/extensions/websocket/client.tssrc/extensions/websocket/runner.tssrc/extensions/websocket/extension.tssrc/extensions/websocket/test.ts
JWT
Register via extensions: [jwtExtension(config)]
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:
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)]
import { x509Extension } from 'apophis-fastify/extensions'
await fastify.register(apophis, {
extensions: [x509Extension()]
})
APOSTL Expressions:
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)]
import { spiffeExtension } from 'apophis-fastify/extensions'
await fastify.register(apophis, {
extensions: [spiffeExtension()]
})
APOSTL Expressions:
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)]
import { tokenHashExtension } from 'apophis-fastify/extensions'
await fastify.register(apophis, {
extensions: [tokenHashExtension()]
})
APOSTL Expressions:
ath_valid(this) == true
tth_valid(this) == true
token_hash(this, "sha256") == jwt_claims(this).ath
HTTP Signature
Register via extensions: [httpSignatureExtension(config)]
import { httpSignatureExtension } from 'apophis-fastify/extensions'
await fastify.register(apophis, {
extensions: [httpSignatureExtension()]
})
APOSTL Expressions:
signature_covers(this, "@method") == true
signature_covers(this, "@request-target") == true
signature_valid(this) == true
Time Control
Register via extensions: [timeExtension(config)]
import { timeExtension } from 'apophis-fastify/extensions'
await fastify.register(apophis, {
extensions: [timeExtension()]
})
APOSTL Expressions:
jwt_claims(this).exp > now()
jwt_claims(this).exp <= now() + 30000
Stateful Cross-Request
Register via extensions: [statefulExtension()]
import { statefulExtension } from 'apophis-fastify/extensions'
await fastify.register(apophis, {
extensions: [statefulExtension()]
})
APOSTL Expressions:
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:
// 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:
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)]
import { requestContextExtension } from 'apophis-fastify/extensions'
await fastify.register(apophis, {
extensions: [requestContextExtension()]
})
APOSTL Expressions:
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
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
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
chaos: {
probability: 0.3,
exclude: ['/health'],
include: ['/api/*'],
routes: {
'/billing/plans': { dropout: { probability: 0 } }
}
}
Blast Radius Cap
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<string, Partial<ChaosConfig>> |
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:
-
Types (
src/types.ts):- Add new fields to
EvalContextif needed - Add new
OperationHeadervalues
- Add new fields to
-
HTTP Executor (
src/infrastructure/http-executor.ts):- Multipart: Build FormData
- Streaming: Collect chunks
-
Schema-to-Arbitrary (
src/domain/schema-to-arbitrary.ts):- Multipart: Generate fake files
- Streaming: No changes (streaming is response-only)
-
Evaluator (
src/formula/evaluator.ts):- Add new
resolveStandardOperationcases
- Add new
For Extensions
Implement these in your extension module:
-
Extension Config (
extension.ts):export const myExtension: ApophisExtension = { name: 'my-extension', headers: ['my_predicate'], predicates: { my_predicate: (ctx) => ({ value: 'test', success: true }) }, hooks: { onAfterRequest: async (ctx) => { // Transform response } } } -
Registration:
await fastify.register(apophis, { extensions: [myExtension] })
Testing Strategy
First-Class Features
Test in src/test/FEATURE.test.ts:
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:
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
- Identify if feature needs schema-to-arbitrary integration
- If yes → implement in core
- Add types to
src/types.ts - Add evaluator cases to
src/formula/evaluator.ts - Add HTTP executor support
- Add tests to
src/test/FEATURE.test.ts
Adding an Extension
- Create module:
src/extensions/my-feature/ - Implement
extension.tswithApophisExtensionconfig - Add tests to
src/extensions/my-feature/test.ts - Export from
src/extensions/my-feature/index.ts - 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.