API Documentation Generation
This document explains how to generate and use the OpenAPI specification for the DeployStack Backend API.
Overview
The DeployStack Backend uses Fastify with Swagger plugins to automatically generate OpenAPI 3.0 specifications. Route schemas are defined using Zod for type safety and expressiveness, and then converted to JSON Schema using the zod-openapi library. This provides:
- Interactive Documentation: Swagger UI interface for testing APIs
- Postman Integration: JSON/YAML specs that can be imported into Postman
- Automated Generation: Specifications are generated from actual route code
🔒 Security First
IMPORTANT: Before developing any protected API endpoints, read the API Security Best Practices documentation. It covers critical security patterns including:
- Authorization Before Validation: Why
preValidationmust be used instead ofpreHandlerfor authorization - Proper Error Responses: Ensuring unauthorized users get 403 Forbidden, not validation errors
- Security Testing: How to test authorization properly
- Common Pitfalls: Security anti-patterns to avoid
Key Rule: Always use preValidation for authorization checks to prevent information disclosure to unauthorized users.
🔐 Dual Authentication Support
The DeployStack Backend supports dual authentication for API endpoints, allowing both web users (cookie-based) and CLI users (OAuth2 Bearer tokens) to access the same endpoints seamlessly.
Authentication Methods
-
Cookie Authentication (Web Users)
- Session-based authentication using HTTP cookies
- Automatic for web browser requests
- Uses
sessioncookie set during login
-
OAuth2 Bearer Token Authentication (CLI Users)
- RFC 6749 compliant OAuth2 implementation with PKCE
- Uses
Authorization: Bearer <token>header - Scope-based access control
Dual Authentication Middleware
Use these middleware functions to enable dual authentication on endpoints:
import { requireAuthenticationAny, requireOAuthScope } from '../../middleware/oauthMiddleware';
fastify.get('/your-endpoint', {
schema: {
security: [
{ cookieAuth: [] }, // Cookie authentication
{ bearerAuth: [] } // OAuth2 Bearer token
]
},
preValidation: [
requireAuthenticationAny(), // Accept either auth method
requireOAuthScope('your:scope') // Enforce OAuth2 scope
]
}, async (request, reply) => {
// Endpoint accessible via both authentication methods
const authType = request.tokenPayload ? 'oauth2' : 'cookie';
const userId = request.user!.id;
});OAuth2 Scopes
Available OAuth2 scopes for fine-grained access control:
mcp:read- Read MCP server installations and configurationsaccount:read- Read account informationuser:read- Read user profile informationteams:read- Read team memberships and informationoffline_access- Maintain access when not actively using the application
OAuth2 Flow Endpoints
GET /api/oauth2/auth- Authorization endpoint (PKCE required)GET /api/oauth2/consent- User consent pagePOST /api/oauth2/consent- Process consent decisionPOST /api/oauth2/token- Token exchange endpoint
Client Configuration
- Client ID:
deploystack-gateway-cli - Redirect URIs:
http://localhost:8976/oauth/callback,http://127.0.0.1:8976/oauth/callback - PKCE: Required (SHA256 method)
- Token Lifetime: 1 hour access tokens, 30 day refresh tokens
Usage Examples
Web Users (Cookie Authentication):
curl -b cookies.txt "http://localhost:3000/api/teams/me/default"CLI Users (OAuth2 Bearer Token):
curl -H "Authorization: Bearer <access_token>" \
"http://localhost:3000/api/teams/me/default"Available Commands
1. Generate Complete API Specification
npm run api:specThis command:
- Starts a temporary server
- Generates both JSON and YAML specifications
- Saves files to
api-spec.jsonandapi-spec.yaml - Provides URLs for interactive documentation
- Automatically shuts down the server
Output:
api-spec.json- OpenAPI JSON specification (for Postman import)api-spec.yaml- OpenAPI YAML specification
2. Generate JSON Specification (requires running server)
npm run api:spec:jsonRequires the development server to be running (npm run dev).
3. Generate YAML Specification (requires running server)
npm run api:spec:yamlRequires the development server to be running (npm run dev).
Usage Examples
Complete Generation (Recommended)
cd services/backend
npm run api:specManual Generation with Running Server
# Terminal 1: Start the server
cd services/backend
npm run dev
# Terminal 2: Generate specifications
npm run api:spec:json
npm run api:spec:yamlAccessing Documentation
When the server is running (npm run dev), you can access:
- Interactive Docs: http://localhost:3000/documentation
- JSON Spec: http://localhost:3000/documentation/json
- YAML Spec: http://localhost:3000/documentation/yaml
Importing into Postman
- Run
npm run api:specto generate the specification - Open Postman
- Click "Import"
- Select the generated
api-spec.jsonfile - All API endpoints will be imported with proper documentation
Route File Structure Rules
IMPORTANT: Every new API endpoint must be created in a separate file following the established directory structure pattern. Do not add route definitions directly to src/routes/index.ts.
File Structure Requirements
- Separate Files: Each route or group of related routes must be in its own file
- Directory Organization: Group related routes in directories (e.g.,
/auth/,/users/,/health/) - Import Pattern: Routes are imported and registered in
src/routes/index.ts - Consistent Naming: Use descriptive names that match the route purpose
- Modular Approach: Keep route files small and focused - aim for 1-3 related methods per file maximum
- Maintainability: Avoid large monolithic route files that become difficult to maintain
Correct File Structure
services/backend/src/routes/
├── index.ts # Main routes registration (imports only)
├── health/
│ └── index.ts # Health check endpoints
├── auth/
│ ├── loginEmail.ts # Email login endpoint
│ ├── registerEmail.ts # Email registration endpoint
│ └── logout.ts # Logout endpoint
├── db/
│ ├── status.ts # Database status endpoint
│ └── setup.ts # Database setup endpoint
├── users/
│ └── index.ts # User management endpoints
└── teams/
└── index.ts # Team management endpointsModular Route Organization (Recommended)
For complex feature areas, break down routes into smaller, focused files:
services/backend/src/routes/mcp/
├── index.ts # Route registration only
├── categories/
│ ├── create.ts # POST /api/mcp/categories (1 method)
│ ├── update.ts # PUT /api/mcp/categories/{id} (1 method)
│ └── delete.ts # DELETE /api/mcp/categories/{id} (1 method)
├── servers/
│ ├── list.ts # GET /api/mcp/servers (1 method)
│ ├── get.ts # GET /api/mcp/servers/{id} (1 method)
│ ├── search.ts # GET /api/mcp/servers/search (1 method)
│ ├── create-global.ts # POST /api/mcp/servers/global (1 method)
│ ├── update-global.ts # PUT /api/mcp/servers/global/{id} (1 method)
│ └── delete-global.ts # DELETE /api/mcp/servers/global/{id} (1 method)
└── versions/
├── list.ts # GET /api/mcp/servers/{id}/versions (1 method)
├── create.ts # POST /api/mcp/servers/{id}/versions (1 method)
└── update.ts # PUT /api/mcp/servers/{id}/versions/{versionId} (1 method)Benefits of Modular Approach:
- Easier Maintenance: Small files are easier to understand and modify
- Better Testing: Individual route files can be tested in isolation
- Team Collaboration: Multiple developers can work on different routes without conflicts
- Clear Responsibility: Each file has a single, clear purpose
- Reduced Complexity: Avoid hundreds of lines in single files
Route File Template
Each route file should follow this pattern:
import { type FastifyInstance } from 'fastify'
import { z } from 'zod'
import { createSchema } from 'zod-openapi'
// Define your schemas
const responseSchema = z.object({
// Your response structure
});
export default async function yourRoute(server: FastifyInstance) {
server.get('/your-endpoint', {
schema: {
tags: ['Your Category'],
summary: 'Brief description',
description: 'Detailed description',
response: {
200: createSchema(responseSchema)
}
}
}, async () => {
// Your route logic
return { /* your response */ }
});
}Registration in index.ts
Import and register your route in src/routes/index.ts:
// Import your route
import yourRoute from './your-directory'
export const registerRoutes = (server: FastifyInstance): void => {
server.register(async (apiInstance) => {
// Register your route
await apiInstance.register(yourRoute);
// Other route registrations...
}, { prefix: '/api' });
}❌ What NOT to Do
// DON'T: Add routes directly to index.ts
export const registerRoutes = (server: FastifyInstance): void => {
server.register(async (apiInstance) => {
// ❌ BAD: Inline route definition
apiInstance.get('/my-endpoint', {
schema: { /* ... */ }
}, async () => {
return { message: 'This should be in a separate file!' }
});
}, { prefix: '/api' });
}✅ What TO Do
// ✅ GOOD: Import and register separate route files
import myEndpointRoute from './my-endpoint'
export const registerRoutes = (server: FastifyInstance): void => {
server.register(async (apiInstance) => {
// ✅ GOOD: Register imported route
await apiInstance.register(myEndpointRoute);
}, { prefix: '/api' });
}Benefits of This Structure
- Maintainability: Each endpoint is self-contained and easy to find
- Scalability: Adding new endpoints doesn't clutter the main routes file
- Testing: Individual route files can be tested in isolation
- Code Organization: Related functionality is grouped together
- Team Collaboration: Multiple developers can work on different routes without conflicts
Content-Type Header Requirements
When to Include Content-Type Headers
IMPORTANT: The Content-Type: application/json header is required for specific HTTP methods when sending request body data.
✅ ALWAYS Include Content-Type for:
- POST requests with request body data
- PUT requests with request body data
- PATCH requests with request body data
❌ NEVER Include Content-Type for:
- GET requests (no request body)
- DELETE requests (typically no request body)
- HEAD requests (no request body)
Correct Client Implementation Pattern
function makeRequest(method, path, data = null, cookies = null) {
const options = {
method,
headers: { 'Accept': 'application/json' }
};
// Set Content-Type for methods that send request body data
if (['POST', 'PUT', 'PATCH'].includes(method.toUpperCase()) && data !== null) {
options.headers['Content-Type'] = 'application/json';
}
// Rest of implementation...
}❌ Problematic Pattern (Avoid This)
// UNCLEAR: This doesn't indicate WHICH methods need Content-Type
if (data) {
options.headers['Content-Type'] = 'application/json';
}API Specification Content-Type Documentation
When defining route schemas, explicitly document Content-Type requirements for POST/PUT/PATCH endpoints:
// For endpoints that require Content-Type
const routeSchema = {
tags: ['Category'],
summary: 'Create new item',
description: 'Creates a new item. Requires Content-Type: application/json header when sending request body.',
requestBody: {
required: true,
content: {
'application/json': {
schema: createSchema(requestSchema)
}
}
},
// ... rest of schema
};Adding Documentation to Routes
To add OpenAPI documentation to your routes, define your request body and response schemas using Zod. Then, use the createSchema utility to convert these Zod schemas into the JSON Schema format expected by Fastify.
Make sure you have zod and zod-openapi installed in your backend service.
Recommended Approach: Dual-Schema Pattern for Validation + Documentation
IMPORTANT: After the Zod v4 migration, we use a dual-schema approach to ensure both proper Fastify validation and accurate OpenAPI documentation.
import { z } from 'zod';
import { createSchema } from 'zod-openapi';
// 1. Define your Zod schemas for request body, responses, etc.
const myRequestBodySchema = z.object({
name: z.string().min(3).describe("The name of the item (min 3 chars)"),
count: z.number().positive().describe("How many items (must be positive)"),
type: z.enum(['mysql', 'sqlite']).describe("Database engine type")
});
const mySuccessResponseSchema = z.object({
success: z.boolean().describe("Indicates if the operation was successful"),
itemId: z.string().uuid().describe("The UUID of the created/affected item"),
message: z.string().optional().describe("Optional success message")
});
const myErrorResponseSchema = z.object({
success: z.boolean().default(false).describe("Indicates failure"),
error: z.string().describe("Error message detailing what went wrong")
});
// 2. Construct the Fastify route schema using DUAL-SCHEMA PATTERN
const routeSchema = {
tags: ['Category'], // Your API category
summary: 'Brief description of your endpoint',
description: 'Detailed description of what this endpoint does, its parameters, and expected outcomes. Requires Content-Type: application/json header when sending request body.',
security: [{ cookieAuth: [] }], // Include if authentication is required
// ✅ CRITICAL: Use plain JSON Schema for Fastify validation
body: {
type: 'object',
properties: {
name: { type: 'string', minLength: 3 },
count: { type: 'number', minimum: 1 },
type: { type: 'string', enum: ['mysql', 'sqlite'] }
},
required: ['name', 'count', 'type'],
additionalProperties: false
},
// ✅ Use createSchema() for OpenAPI documentation
requestBody: {
required: true,
content: {
'application/json': {
schema: createSchema(myRequestBodySchema)
}
}
},
response: {
200: createSchema(mySuccessResponseSchema.describe("Successful operation")),
400: createSchema(myErrorResponseSchema.describe("Bad Request - Invalid input")),
// Define other responses (e.g., 401, 403, 404, 500) similarly
}
};
// 3. Use the schema in your Fastify route definition with proper TypeScript typing
interface RequestBody {
name: string;
count: number;
type: 'mysql' | 'sqlite';
}
fastify.post<{ Body: RequestBody }>(
'/your-route',
{ schema: routeSchema },
async (request, reply) => {
// ✅ Fastify has already validated request.body using the JSON schema
// ✅ If we reach here, request.body is guaranteed to be valid
// ✅ No manual validation needed!
const { name, count, type } = request.body; // Fully typed and validated
// Your route handler logic here
const successResponse = {
success: true,
itemId: 'some-uuid-v4-here',
message: `Item ${name} processed successfully with ${count} items using ${type}.`
};
const jsonString = JSON.stringify(successResponse);
return reply.status(200).type('application/json').send(jsonString);
}
);Key Benefits of This Approach
- Single Source of Truth: Zod schemas define both validation AND documentation
- Automatic Validation: Fastify automatically validates requests before your handler runs
- No Manual Validation: Remove all manual
zod.parse()calls and field checks - Better Error Messages: Fastify provides detailed validation errors automatically
- Type Safety: Handlers receive properly typed, validated data
- Cleaner Code: No redundant validation logic in handlers
JSON Response Serialization Pattern
CRITICAL: After the Zod v4 migration, all API responses must use manual JSON serialization to prevent "[object Object]" serialization issues.
Required Response Pattern
// ✅ CORRECT: Manual JSON serialization
const successResponse = {
success: true,
message: 'Operation completed successfully',
data: { /* your data */ }
};
const jsonString = JSON.stringify(successResponse);
return reply.status(200).type('application/json').send(jsonString);What NOT to Do
// ❌ WRONG: Direct object response (causes serialization issues)
return reply.status(200).send({
success: true,
message: 'This will become "[object Object]"'
});
// ❌ WRONG: Using reply.send() without JSON.stringify()
const response = { success: true, message: 'Test' };
return reply.status(200).send(response);Error Response Pattern
All error responses must also use manual JSON serialization:
// ✅ CORRECT: Error response with manual serialization
const errorResponse = {
success: false,
error: 'Detailed error message'
};
const jsonString = JSON.stringify(errorResponse);
return reply.status(400).type('application/json').send(jsonString);Authentication Middleware Pattern
Authentication middleware and hooks must also use this pattern:
// ✅ CORRECT: Authentication error with manual serialization
const errorResponse = {
success: false,
error: 'Unauthorized: Authentication required.'
};
const jsonString = JSON.stringify(errorResponse);
return reply.status(401).type('application/json').send(jsonString);Why This Pattern is Required
After the Zod v4 migration, Fastify's automatic JSON serialization can fail with complex objects, resulting in:
- Response bodies showing
"[object Object]"instead of actual data - Client applications receiving unparseable responses
- Test failures due to missing
successanderrorproperties
The manual JSON serialization pattern ensures:
- ✅ Consistent, parseable JSON responses
- ✅ Proper
success/errorproperties in all responses - ✅ Reliable client-server communication
- ✅ Passing e2e tests
Why Both body and requestBody Properties?
Important: You need BOTH properties for complete functionality:
body: Enables Fastify's automatic request validation using the Zod schemarequestBody: Ensures proper OpenAPI specification generation with Content-Type documentation
Without body, validation won't work. Without requestBody, your API specification won't properly document the application/json Content-Type requirement.
What NOT to Do (Anti-patterns)
❌ Don't do manual validation in handlers:
// BAD: Manual validation (redundant)
const parsedBody = myRequestBodySchema.safeParse(request.body);
if (!parsedBody.success) {
return reply.status(400).send({ error: 'Invalid request body' });
}
// BAD: Manual field checks (redundant)
if (!request.body.name || !request.body.count) {
return reply.status(400).send({ error: 'Required fields missing' });
}
// BAD: Manual enum validation (redundant)
if (request.body.type !== 'mysql' && request.body.type !== 'sqlite') {
return reply.status(400).send({ error: 'Invalid database type' });
}✅ Do trust Fastify's automatic validation:
// GOOD: Trust the validation - if handler runs, data is valid
const { name, count, type } = request.body; // Already validated by FastifyValidation Flow
The validation chain works as follows:
Zod Schema → JSON Schema → Fastify Validation → Handler
- Zod Schema: Define validation rules using Zod
- JSON Schema: Convert to OpenAPI format using
createSchema() - Fastify Validation: Fastify automatically validates incoming requests
- Handler: Receives validated, typed data
If validation fails, Fastify automatically returns a 400 error before your handler runs.
Real-World Examples
See these files for complete examples of proper Zod validation:
src/routes/db/setup.ts- Database setup with enum validationsrc/routes/db/status.ts- Simple GET endpoint with response schemassrc/routes/auth/loginEmail.ts- Login with required string fieldssrc/routes/auth/registerEmail.ts- Registration with complex validation rules
Note: Older examples in this document (like the "Logout Route Documentation" below) might still show manually crafted JSON schemas. The recommended approach is now to use Zod with automatic Fastify validation as shown above.
Example: Logout Route Documentation
The logout route (/api/auth/logout) demonstrates proper documentation:
const logoutSchema = {
tags: ['Authentication'],
summary: 'User logout',
description: 'Invalidates the current user session and clears authentication cookies',
security: [{ cookieAuth: [] }],
response: {
200: {
type: 'object',
properties: {
success: {
type: 'boolean',
description: 'Indicates if the logout operation was successful'
},
message: {
type: 'string',
description: 'Human-readable message about the logout result'
}
},
required: ['success', 'message'],
examples: [
{
success: true,
message: 'Logged out successfully.'
}
]
}
}
};Configuration
Fastify Server Configuration
The Fastify server is configured with custom AJV options to ensure compatibility with zod-openapi schema generation. This configuration is in src/server.ts:
const server = fastify({
logger: loggerConfig,
disableRequestLogging: true,
ajv: {
customOptions: {
strict: false, // Allows unknown keywords in schemas
strictTypes: false, // Disables strict type checking
strictTuples: false // Disables strict tuple checking
}
}
})Why these AJV options are required:
strict: false: AJV v8+ runs in strict mode by default, which rejects schemas containing unknown keywords. Thezod-openapilibrary generates schemas that may include keywords AJV doesn't recognize in strict mode.strictTypes: false: Prevents strict type validation errors that can occur with complex Zod schemas.strictTuples: false: Allows more flexible tuple handling for array schemas.
Important: These settings don't affect validation behavior - they only allow the schema compilation to succeed. All validation rules defined in your Zod schemas still work exactly as expected.
Swagger Configuration
The Swagger documentation configuration is also in src/server.ts:
await server.register(fastifySwagger, {
openapi: {
openapi: '3.0.0',
info: {
title: 'DeployStack Backend API',
description: 'API documentation for DeployStack Backend',
version: '0.20.5'
},
servers: [
{
url: 'http://localhost:3000',
description: 'Development server'
}
],
components: {
securitySchemes: {
cookieAuth: {
type: 'apiKey',
in: 'cookie',
name: 'auth_session'
}
}
}
}
});Troubleshooting
"Route already declared" Error
This happens when trying to manually add routes that Swagger UI already provides. The /documentation/json and /documentation/yaml endpoints are automatically created.
"Failed to fetch API spec" Error
Ensure the server is fully started before trying to fetch the specification. The generation script includes a 2-second delay to allow for complete initialization.
Missing Route Documentation
Routes without schema definitions will appear in the specification but with minimal documentation. Add schema objects to routes for complete documentation.
Next Steps
To extend API documentation:
- Add schema definitions to more routes
- Define reusable components in the OpenAPI configuration
- Add request body schemas for POST/PUT endpoints
- Include error response schemas (400, 401, 500, etc.)
- Add parameter validation schemas
Plugin API Routes
Plugin Route Structure
All plugin routes are automatically namespaced under /api/plugin/<plugin-name>/ for security and isolation:
- Core Routes:
/api/auth/*,/api/users/*,/api/settings/*(protected from plugins) - Plugin Routes:
/api/plugin/<plugin-name>/*(isolated per plugin)
Example Plugin Routes
For a plugin with ID example-plugin:
GET /api/plugin/example-plugin/examples
GET /api/plugin/example-plugin/examples/:id
POST /api/plugin/example-plugin/examples
PUT /api/plugin/example-plugin/examples/:id
DELETE /api/plugin/example-plugin/examples/:idSecurity Benefits
- Route Isolation: Plugins cannot interfere with core routes or each other
- Predictable Structure: All plugin APIs follow the same pattern
- Easy Identification: Plugin ownership is clear from the URL
- Automatic Namespacing: No manual prefix management required
Plugin Route Registration
Plugins register routes using the PluginRouteManager:
// In plugin's routes.ts file
export async function registerRoutes(routeManager: PluginRouteManager, db: AnyDatabase | null) {
// This becomes /api/plugin/my-plugin/data
routeManager.get('/data', async () => {
return { message: 'Hello from plugin!' };
});
}Files Generated
api-spec.json- Complete OpenAPI 3.0 specification in JSON formatapi-spec.yaml- Complete OpenAPI 3.0 specification in YAML format- Interactive documentation available at
/documentationwhen server is running