Files
apophis-fastify/docs/extensions/QUICK-REFERENCE.md
T

759 lines
18 KiB
Markdown
Raw Permalink Normal View History

# 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.01.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`.