759 lines
18 KiB
Markdown
759 lines
18 KiB
Markdown
|
|
# 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<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:
|
|||
|
|
|
|||
|
|
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`.
|