Files

18 KiB
Raw Permalink Blame 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:

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 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.

// 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 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.

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.ts
  • src/extensions/sse/predicates.ts
  • src/extensions/sse/extension.ts
  • src/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.ts
  • src/extensions/serializers/extension.ts
  • src/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.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)]

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.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):

    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:

    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

  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.