chore: crush git history - reborn from consolidation on 2026-03-10
This commit is contained in:
@@ -0,0 +1,758 @@
|
||||
# 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`.
|
||||
Reference in New Issue
Block a user