460 lines
12 KiB
Markdown
460 lines
12 KiB
Markdown
# Structuring Your Fastify App for APOPHIS
|
|
|
|
APOPHIS requires that you register its plugin **before** defining routes, and it needs to access your route schemas at test time. If your application is a single file that creates the server, connects to databases, registers routes, and starts listening, you cannot test it with APOPHIS.
|
|
|
|
This guide shows how to restructure a monolithic Fastify application into a testable plugin architecture.
|
|
|
|
---
|
|
|
|
## The Problem
|
|
|
|
Here is what Arbiter's setup looked like — a single file doing everything:
|
|
|
|
```typescript
|
|
// server.ts — THE WRONG WAY
|
|
import Fastify from 'fastify'
|
|
import database from './database'
|
|
import routes from './routes'
|
|
|
|
const fastify = Fastify()
|
|
|
|
// Database setup
|
|
await database.connect(process.env.DATABASE_URL)
|
|
|
|
// Register plugins
|
|
await fastify.register(require('@fastify/swagger'))
|
|
await fastify.register(require('@fastify/cors'))
|
|
|
|
// Register routes
|
|
fastify.register(routes)
|
|
|
|
// Add decorators
|
|
fastify.decorate('db', database)
|
|
|
|
// Start server
|
|
await fastify.listen({ port: 3000 })
|
|
```
|
|
|
|
**Why this breaks APOPHIS:**
|
|
|
|
1. **Routes are registered before APOPHIS** — APOPHIS must hook into the registration process, so it must be registered first.
|
|
2. **No way to create a test instance** — The database connection and server start are unconditional. You cannot create a second Fastify instance for testing without starting another server.
|
|
3. **No cleanup hook** — File system state (WAL logs, uploaded files) accumulates between runs.
|
|
4. **Side effects at import time** — Importing the file has side effects. You cannot import routes without importing the database connection.
|
|
|
|
---
|
|
|
|
## The Solution: App Factory Pattern
|
|
|
|
Separate **application creation** from **server startup**. Export a function that creates a configured Fastify instance without starting it.
|
|
|
|
### Recommended Directory Structure
|
|
|
|
```
|
|
src/
|
|
app.ts # App factory: creates Fastify instance, registers plugins
|
|
server.ts # Entry point: creates app, connects DB, starts server
|
|
plugins/
|
|
database.ts # Database connection plugin
|
|
auth.ts # Auth decorator plugin
|
|
routes/
|
|
users.ts # Route definitions with schema annotations
|
|
health.ts # Health check route
|
|
test/
|
|
setup.ts # Test bootstrap: creates app, registers APOPHIS
|
|
contracts.test.ts # Contract test entry point
|
|
```
|
|
|
|
### 1. App Factory (`src/app.ts`)
|
|
|
|
This file exports a function that creates a Fastify instance and registers all plugins **except** APOPHIS and the database connection. It should have no side effects.
|
|
|
|
```typescript
|
|
import Fastify from 'fastify'
|
|
import type { FastifyInstance } from 'fastify'
|
|
|
|
// Your application plugins
|
|
import databasePlugin from './plugins/database'
|
|
import authPlugin from './plugins/auth'
|
|
import userRoutes from './routes/users'
|
|
import healthRoutes from './routes/health'
|
|
|
|
export interface AppOptions {
|
|
// Pass configuration explicitly instead of reading env vars
|
|
databaseUrl?: string
|
|
jwtSecret?: string
|
|
enableLogging?: boolean
|
|
}
|
|
|
|
export async function buildApp(opts: AppOptions = {}): Promise<FastifyInstance> {
|
|
const fastify = Fastify({
|
|
logger: opts.enableLogging ?? true,
|
|
// Disable request logging in test mode to reduce noise
|
|
disableRequestLogging: process.env.NODE_ENV === 'test',
|
|
})
|
|
|
|
// Register infrastructure plugins
|
|
await fastify.register(databasePlugin, { url: opts.databaseUrl })
|
|
await fastify.register(authPlugin, { secret: opts.jwtSecret })
|
|
|
|
// Register route plugins
|
|
await fastify.register(userRoutes, { prefix: '/api/users' })
|
|
await fastify.register(healthRoutes, { prefix: '/health' })
|
|
|
|
return fastify
|
|
}
|
|
```
|
|
|
|
### 2. Database Plugin (`src/plugins/database.ts`)
|
|
|
|
Encapsulate database setup in a Fastify plugin. This makes it composable and testable.
|
|
|
|
```typescript
|
|
import fp from 'fastify-plugin'
|
|
import type { FastifyInstance } from 'fastify'
|
|
import { createConnection } from './db-client'
|
|
|
|
export interface DatabasePluginOptions {
|
|
url?: string
|
|
}
|
|
|
|
export default fp(async (fastify: FastifyInstance, opts: DatabasePluginOptions) => {
|
|
const db = await createConnection(opts.url ?? process.env.DATABASE_URL)
|
|
|
|
// Decorate fastify with db access
|
|
fastify.decorate('db', db)
|
|
|
|
// Clean up on close
|
|
fastify.addHook('onClose', async () => {
|
|
await db.disconnect()
|
|
})
|
|
})
|
|
```
|
|
|
|
### 3. Route Files with Contracts (`src/routes/users.ts`)
|
|
|
|
Define routes in separate files. Each route file is a Fastify plugin that receives the parent instance.
|
|
|
|
```typescript
|
|
import type { FastifyInstance } from 'fastify'
|
|
|
|
export default async function userRoutes(fastify: FastifyInstance) {
|
|
fastify.post('/', {
|
|
schema: {
|
|
'x-category': 'constructor',
|
|
'x-ensures': [
|
|
'status:201',
|
|
'response_body(this).id != null',
|
|
'response_body(this).email matches "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"',
|
|
],
|
|
body: {
|
|
type: 'object',
|
|
properties: {
|
|
name: { type: 'string', minLength: 1 },
|
|
email: { type: 'string', format: 'email' },
|
|
},
|
|
required: ['name', 'email'],
|
|
},
|
|
response: {
|
|
201: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
name: { type: 'string' },
|
|
email: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}, async (req, reply) => {
|
|
const user = await fastify.db.users.create(req.body)
|
|
reply.status(201)
|
|
return user
|
|
})
|
|
|
|
fastify.get('/:id', {
|
|
schema: {
|
|
'x-category': 'observer',
|
|
'x-ensures': [
|
|
'if status:200 then response_body(this).id != null',
|
|
],
|
|
params: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
}, async (req, reply) => {
|
|
const user = await fastify.db.users.findById(req.params.id)
|
|
if (!user) {
|
|
reply.status(404)
|
|
return { error: 'Not found' }
|
|
}
|
|
return user
|
|
})
|
|
}
|
|
```
|
|
|
|
### 4. Production Entry Point (`src/server.ts`)
|
|
|
|
The production entry point imports the app factory, adds APOPHIS, connects to services, and starts the server.
|
|
|
|
```typescript
|
|
import { buildApp } from './app'
|
|
import apophis from 'apophis-fastify'
|
|
|
|
async function start() {
|
|
const fastify = await buildApp({
|
|
databaseUrl: process.env.DATABASE_URL,
|
|
jwtSecret: process.env.JWT_SECRET,
|
|
})
|
|
|
|
// Register APOPHIS before ready() but after all routes
|
|
await fastify.register(apophis, {
|
|
runtime: process.env.NODE_ENV === 'production' ? 'error' : 'warn',
|
|
timeout: 5000,
|
|
})
|
|
|
|
await fastify.ready()
|
|
|
|
// Start server
|
|
await fastify.listen({ port: Number(process.env.PORT) || 3000 })
|
|
|
|
console.log(`Server listening on ${fastify.server.address()}`)
|
|
}
|
|
|
|
start().catch((err) => {
|
|
console.error(err)
|
|
process.exit(1)
|
|
})
|
|
```
|
|
|
|
### 5. Test Bootstrap (`src/test/setup.ts`)
|
|
|
|
The test file creates a fresh app instance, registers APOPHIS, and runs contract tests against it.
|
|
|
|
```typescript
|
|
import { buildApp } from '../app'
|
|
import apophis from 'apophis-fastify'
|
|
import type { FastifyInstance } from 'fastify'
|
|
|
|
export async function createTestApp(): Promise<FastifyInstance> {
|
|
// Use test database or in-memory store
|
|
const fastify = await buildApp({
|
|
databaseUrl: process.env.TEST_DATABASE_URL ?? ':memory:',
|
|
jwtSecret: 'test-secret',
|
|
enableLogging: false,
|
|
})
|
|
|
|
// Register APOPHIS for testing
|
|
await fastify.register(apophis, {
|
|
timeout: 2000, // Faster timeouts in tests
|
|
cleanup: true, // Auto-cleanup resources
|
|
})
|
|
|
|
await fastify.ready()
|
|
return fastify
|
|
}
|
|
```
|
|
|
|
### 6. Contract Test Entry Point (`src/test/contracts.test.ts`)
|
|
|
|
```typescript
|
|
import { test } from 'node:test'
|
|
import assert from 'node:assert'
|
|
import { createTestApp } from './setup'
|
|
|
|
test('contract tests', async () => {
|
|
const fastify = await createTestApp()
|
|
|
|
try {
|
|
const result = await fastify.apophis.contract({
|
|
depth: 'standard',
|
|
seed: 42, // Deterministic
|
|
})
|
|
|
|
console.log(result.summary)
|
|
|
|
// Fail the test suite if any contract fails
|
|
assert.strictEqual(
|
|
result.summary.failed,
|
|
0,
|
|
`Contract failures: ${result.tests
|
|
.filter((t) => !t.ok)
|
|
.map((t) => t.name)
|
|
.join(', ')}`
|
|
)
|
|
} finally {
|
|
// Always clean up
|
|
await fastify.apophis.cleanup()
|
|
await fastify.close()
|
|
}
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## Key Principles
|
|
|
|
### 1. No Side Effects at Import Time
|
|
|
|
**Wrong:**
|
|
```typescript
|
|
// db.ts
|
|
export const db = await createConnection(process.env.DATABASE_URL) // Side effect!
|
|
```
|
|
|
|
**Right:**
|
|
```typescript
|
|
// db.ts
|
|
export async function createDb(url: string) {
|
|
return createConnection(url)
|
|
}
|
|
```
|
|
|
|
### 2. Separate App Creation from Server Start
|
|
|
|
**Wrong:**
|
|
```typescript
|
|
// server.ts
|
|
const app = Fastify()
|
|
// ... setup ...
|
|
await app.listen({ port: 3000 }) // Cannot test without starting server
|
|
export default app
|
|
```
|
|
|
|
**Right:**
|
|
```typescript
|
|
// app.ts
|
|
export async function buildApp() {
|
|
const app = Fastify()
|
|
// ... setup without listen() ...
|
|
return app
|
|
}
|
|
|
|
// server.ts
|
|
const app = await buildApp()
|
|
await app.listen({ port: 3000 })
|
|
```
|
|
|
|
### 3. Use Fastify Plugins for Everything
|
|
|
|
Routes, database connections, auth, decorators — everything should be a Fastify plugin. This makes composition explicit and testable.
|
|
|
|
### 4. APOPHIS Registration Order
|
|
|
|
```typescript
|
|
// 1. Create app (registers routes)
|
|
const app = await buildApp()
|
|
|
|
// 2. Register APOPHIS (hooks into existing routes)
|
|
await app.register(apophis, opts)
|
|
|
|
// 3. Ready (compiles schemas)
|
|
await app.ready()
|
|
|
|
// 4. Test or serve
|
|
await app.apophis.contract({...})
|
|
// OR
|
|
await app.listen({...})
|
|
```
|
|
|
|
---
|
|
|
|
## Handling Arbiter-Specific Issues
|
|
|
|
### File System State (WAL Logs)
|
|
|
|
If your server writes to files or WAL logs:
|
|
|
|
```typescript
|
|
// test/setup.ts
|
|
import { mkdirSync, rmSync } from 'fs'
|
|
import { tmpdir } from 'os'
|
|
import { join } from 'path'
|
|
|
|
let testCounter = 0
|
|
|
|
export function createTestWorkspace() {
|
|
const dir = join(tmpdir(), `apophis-test-${++testCounter}`)
|
|
mkdirSync(dir, { recursive: true })
|
|
|
|
return {
|
|
path: dir,
|
|
cleanup() {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
},
|
|
}
|
|
}
|
|
|
|
// In your test:
|
|
const workspace = createTestWorkspace()
|
|
const app = await buildApp({
|
|
dataDir: workspace.path, // Server writes here instead of production path
|
|
})
|
|
```
|
|
|
|
### Database Seeding
|
|
|
|
```typescript
|
|
// test/setup.ts
|
|
export async function seedTestDatabase(db: Database) {
|
|
await db.migrate.latest()
|
|
await db.seed.run()
|
|
}
|
|
|
|
// In your contract test:
|
|
const app = await createTestApp()
|
|
await seedTestDatabase(app.db)
|
|
```
|
|
|
|
### Complex Dependency Injection
|
|
|
|
If routes depend on external services (ledger, graph store):
|
|
|
|
```typescript
|
|
// Use test doubles via plugin options
|
|
export async function buildApp(opts: AppOptions) {
|
|
const app = Fastify()
|
|
|
|
// Production: real ledger
|
|
// Test: mock ledger
|
|
await app.register(ledgerPlugin, {
|
|
client: opts.ledgerClient ?? new RealLedgerClient(),
|
|
})
|
|
|
|
return app
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Migration Checklist
|
|
|
|
If you have a monolithic `server.ts` like Arbiter:
|
|
|
|
- [x] Extract route definitions into `src/routes/*.ts` files
|
|
- [x] Extract database/auth setup into `src/plugins/*.ts` files
|
|
- [x] Create `src/app.ts` with a `buildApp()` factory function
|
|
- [x] Move `fastify.listen()` from `app.ts` to `src/server.ts`
|
|
- [x] Create `src/test/setup.ts` that calls `buildApp()` + `apophis.register()`
|
|
- [x] Ensure no side effects at import time in any `src/` file
|
|
- [x] Run `npx tsc --noEmit` to verify no circular dependencies
|
|
- [x] Run contract tests: `npm run test:contracts`
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
| Monolithic | Plugin Architecture |
|
|
|-----------|-------------------|
|
|
| Single file with everything | Factory function + plugin files |
|
|
| Side effects at import | Pure functions, explicit initialization |
|
|
| Cannot create test instance | Create unlimited instances |
|
|
| APOPHIS must be first (impossible) | APOPHIS registered after routes, before ready() |
|
|
| Manual cleanup | Hooks for automatic cleanup |
|
|
| Database URL hardcoded | Injected via options |
|
|
|
|
The plugin architecture takes 30 minutes to set up and saves hours of debugging when APOPHIS cannot access your routes.
|