Files
Autonomous-Bug-Explorer/src/api/openapi.ts
debian 30f293fbf8 fase(21): openapi documentation with scalar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 06:06:44 -04:00

720 lines
20 KiB
TypeScript

/**
* OpenAPI 3.1 specification for ABE API.
* Uses @asteasolutions/zod-to-openapi to generate from Zod schemas.
*/
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import { extendZodWithOpenApi, OpenApiGeneratorV31, OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import { apiReference } from '@scalar/express-api-reference';
// Extend Zod with OpenAPI metadata support
extendZodWithOpenApi(z);
// ─── Registry ─────────────────────────────────────────────────────────────────
const registry = new OpenAPIRegistry();
// ─── Reusable schemas ─────────────────────────────────────────────────────────
const ErrorSchema = registry.register(
'Error',
z.object({ error: z.string() }).openapi('Error')
);
// Auth schemas
const RegisterRequestSchema = registry.register(
'RegisterRequest',
z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().optional(),
}).openapi('RegisterRequest')
);
const LoginRequestSchema = registry.register(
'LoginRequest',
z.object({
email: z.string().email(),
password: z.string(),
}).openapi('LoginRequest')
);
const UserSchema = registry.register(
'User',
z.object({
id: z.string(),
email: z.string(),
name: z.string().nullable(),
role: z.enum(['owner', 'admin', 'member', 'viewer']),
createdAt: z.number(),
}).openapi('User')
);
// Session schemas
const SessionStatusSchema = z.enum(['running', 'completed', 'failed', 'stopped']);
const CrawlSessionSchema = registry.register(
'CrawlSession',
z.object({
id: z.string(),
url: z.string().url(),
status: SessionStatusSchema,
seed: z.number(),
maxStates: z.number(),
statesVisited: z.number(),
createdAt: z.number(),
completedAt: z.number().nullable(),
}).openapi('CrawlSession')
);
const StartSessionRequestSchema = registry.register(
'StartSessionRequest',
z.object({
url: z.string().url(),
seed: z.number().optional(),
maxStates: z.number().optional(),
maxDepth: z.number().optional(),
allowedDomains: z.array(z.string()).optional(),
excludedPaths: z.array(z.string()).optional(),
}).openapi('StartSessionRequest')
);
// Finding schemas
const SeveritySchema = z.enum(['low', 'medium', 'high', 'critical']);
const FindingStatusSchema = z.enum(['open', 'investigating', 'resolved', 'closed']);
const FindingSchema = registry.register(
'Finding',
z.object({
id: z.string(),
sessionId: z.string(),
severity: SeveritySchema,
type: z.string(),
description: z.string(),
status: FindingStatusSchema,
createdAt: z.number(),
resolvedAt: z.number().nullable(),
}).openapi('Finding')
);
// Report schemas
const ReportFormatSchema = z.enum(['pdf', 'html', 'json']);
const ReportSchema = registry.register(
'Report',
z.object({
id: z.string(),
format: ReportFormatSchema,
status: z.enum(['pending', 'completed', 'failed']),
createdAt: z.number(),
completedAt: z.number().nullable(),
}).openapi('Report')
);
// Schedule schemas
const ScheduleSchema = registry.register(
'Schedule',
z.object({
id: z.string(),
name: z.string(),
url: z.string(),
cronExpression: z.string(),
enabled: z.boolean(),
lastRunAt: z.number().nullable(),
nextRunAt: z.number().nullable(),
createdAt: z.number(),
}).openapi('Schedule')
);
// Integration schemas
const IntegrationTypeSchema = z.enum(['slack', 'github', 'jira', 'webhook']);
const IntegrationSchema = registry.register(
'Integration',
z.object({
id: z.string(),
type: IntegrationTypeSchema,
name: z.string(),
enabled: z.boolean(),
createdAt: z.number(),
}).openapi('Integration')
);
// Visual comparison schemas
const ComparisonStatusSchema = z.enum(['passed', 'failed', 'new_state', 'pending']);
const VisualComparisonSchema = registry.register(
'VisualComparison',
z.object({
id: z.string(),
session_id: z.string(),
state_id: z.string(),
baseline_id: z.string().nullable(),
current_screenshot_path: z.string(),
diff_screenshot_path: z.string().nullable(),
diff_pixels: z.number().nullable(),
diff_percent: z.number().nullable(),
status: ComparisonStatusSchema,
created_at: z.number(),
}).openapi('VisualComparison')
);
// License schema
const LicensePlanSchema = z.enum(['free', 'pro', 'enterprise']);
const LicenseStatusSchema = registry.register(
'LicenseStatus',
z.object({
plan: LicensePlanSchema,
valid: z.boolean(),
expiresAt: z.string().nullable(),
features: z.array(z.string()),
}).openapi('LicenseStatus')
);
// ─── Route registrations ───────────────────────────────────────────────────────
const bearerAuth = registry.registerComponent('securitySchemes', 'BearerAuth', {
type: 'http',
scheme: 'bearer',
});
// Auth endpoints
registry.registerPath({
method: 'post',
path: '/api/auth/register',
summary: 'Register a new user',
tags: ['Auth'],
request: { body: { content: { 'application/json': { schema: RegisterRequestSchema } } } },
responses: {
201: { description: 'User registered', content: { 'application/json': { schema: UserSchema } } },
400: { description: 'Validation error', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/auth/login',
summary: 'Login',
tags: ['Auth'],
request: { body: { content: { 'application/json': { schema: LoginRequestSchema } } } },
responses: {
200: { description: 'Login successful', content: { 'application/json': { schema: UserSchema } } },
401: { description: 'Invalid credentials', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/auth/logout',
summary: 'Logout',
tags: ['Auth'],
security: [{ [bearerAuth.name]: [] }],
responses: { 200: { description: 'Logged out' } },
});
registry.registerPath({
method: 'get',
path: '/api/auth/me',
summary: 'Get current user',
tags: ['Auth'],
security: [{ [bearerAuth.name]: [] }],
responses: {
200: { description: 'Current user', content: { 'application/json': { schema: UserSchema } } },
401: { description: 'Not authenticated', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'get',
path: '/api/auth/setup-required',
summary: 'Check if first-run setup is required',
tags: ['Auth'],
responses: {
200: {
description: 'Setup status',
content: {
'application/json': {
schema: z.object({ required: z.boolean() }),
},
},
},
},
});
// Sessions endpoints
registry.registerPath({
method: 'get',
path: '/api/sessions',
summary: 'List all crawl sessions',
tags: ['Sessions'],
security: [{ [bearerAuth.name]: [] }],
responses: {
200: {
description: 'List of sessions',
content: { 'application/json': { schema: z.array(CrawlSessionSchema) } },
},
},
});
registry.registerPath({
method: 'post',
path: '/api/sessions',
summary: 'Start a new crawl session',
tags: ['Sessions'],
security: [{ [bearerAuth.name]: [] }],
request: { body: { content: { 'application/json': { schema: StartSessionRequestSchema } } } },
responses: {
201: { description: 'Session started', content: { 'application/json': { schema: CrawlSessionSchema } } },
400: { description: 'Validation error', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'get',
path: '/api/sessions/{id}',
summary: 'Get session by ID',
tags: ['Sessions'],
security: [{ [bearerAuth.name]: [] }],
request: { params: z.object({ id: z.string() }) },
responses: {
200: { description: 'Session details', content: { 'application/json': { schema: CrawlSessionSchema } } },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'delete',
path: '/api/sessions/{id}',
summary: 'Stop a crawl session',
tags: ['Sessions'],
security: [{ [bearerAuth.name]: [] }],
request: { params: z.object({ id: z.string() }) },
responses: {
200: { description: 'Session stopped' },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
// Findings endpoints
registry.registerPath({
method: 'get',
path: '/api/findings',
summary: 'List findings',
tags: ['Findings'],
security: [{ [bearerAuth.name]: [] }],
request: {
query: z.object({
severity: z.string().optional(),
type: z.string().optional(),
status: z.string().optional(),
sessionId: z.string().optional(),
search: z.string().optional(),
}),
},
responses: {
200: {
description: 'List of findings',
content: { 'application/json': { schema: z.array(FindingSchema) } },
},
},
});
registry.registerPath({
method: 'get',
path: '/api/findings/{id}',
summary: 'Get finding by ID',
tags: ['Findings'],
security: [{ [bearerAuth.name]: [] }],
request: { params: z.object({ id: z.string() }) },
responses: {
200: { description: 'Finding details', content: { 'application/json': { schema: FindingSchema } } },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/findings/{id}/resolve',
summary: 'Resolve a finding',
tags: ['Findings'],
security: [{ [bearerAuth.name]: [] }],
request: { params: z.object({ id: z.string() }) },
responses: {
200: { description: 'Finding resolved', content: { 'application/json': { schema: FindingSchema } } },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'get',
path: '/api/findings/stats',
summary: 'Get finding statistics',
tags: ['Findings'],
security: [{ [bearerAuth.name]: [] }],
responses: {
200: {
description: 'Statistics',
content: {
'application/json': {
schema: z.object({
total: z.number(),
bySeverity: z.record(z.string(), z.number()),
byType: z.record(z.string(), z.number()),
byStatus: z.record(z.string(), z.number()),
}),
},
},
},
},
});
// Reports endpoints
registry.registerPath({
method: 'post',
path: '/api/reports',
summary: 'Generate a report',
tags: ['Reports'],
security: [{ [bearerAuth.name]: [] }],
request: {
body: {
content: {
'application/json': {
schema: z.object({
sessionId: z.string().optional(),
format: ReportFormatSchema,
}),
},
},
},
},
responses: {
202: { description: 'Report generation started', content: { 'application/json': { schema: ReportSchema } } },
},
});
registry.registerPath({
method: 'get',
path: '/api/reports',
summary: 'List reports',
tags: ['Reports'],
security: [{ [bearerAuth.name]: [] }],
responses: {
200: { description: 'Reports list', content: { 'application/json': { schema: z.array(ReportSchema) } } },
},
});
registry.registerPath({
method: 'get',
path: '/api/reports/{id}/download',
summary: 'Download report file',
tags: ['Reports'],
security: [{ [bearerAuth.name]: [] }],
request: { params: z.object({ id: z.string() }) },
responses: {
200: { description: 'Report file (PDF, HTML or JSON)' },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
// Schedules endpoints
registry.registerPath({
method: 'get',
path: '/api/schedules',
summary: 'List schedules',
tags: ['Scheduling'],
security: [{ [bearerAuth.name]: [] }],
responses: {
200: { description: 'Schedules list', content: { 'application/json': { schema: z.array(ScheduleSchema) } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/schedules',
summary: 'Create a schedule',
tags: ['Scheduling'],
security: [{ [bearerAuth.name]: [] }],
request: {
body: {
content: {
'application/json': {
schema: z.object({
name: z.string(),
url: z.string().url(),
cronExpression: z.string(),
config: z.record(z.string(), z.unknown()).optional(),
}),
},
},
},
},
responses: {
201: { description: 'Schedule created', content: { 'application/json': { schema: ScheduleSchema } } },
400: { description: 'Invalid cron expression', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'patch',
path: '/api/schedules/{id}/toggle',
summary: 'Enable or disable a schedule',
tags: ['Scheduling'],
security: [{ [bearerAuth.name]: [] }],
request: { params: z.object({ id: z.string() }) },
responses: {
200: { description: 'Toggled', content: { 'application/json': { schema: ScheduleSchema } } },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'delete',
path: '/api/schedules/{id}',
summary: 'Delete a schedule',
tags: ['Scheduling'],
security: [{ [bearerAuth.name]: [] }],
request: { params: z.object({ id: z.string() }) },
responses: {
200: { description: 'Deleted' },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
// Integrations endpoints
registry.registerPath({
method: 'get',
path: '/api/integrations',
summary: 'List integrations',
tags: ['Integrations'],
security: [{ [bearerAuth.name]: [] }],
responses: {
200: { description: 'Integrations list', content: { 'application/json': { schema: z.array(IntegrationSchema) } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/integrations',
summary: 'Create an integration',
tags: ['Integrations'],
security: [{ [bearerAuth.name]: [] }],
request: {
body: {
content: {
'application/json': {
schema: z.object({
type: IntegrationTypeSchema,
name: z.string(),
config: z.record(z.string(), z.unknown()),
}),
},
},
},
},
responses: {
201: { description: 'Integration created', content: { 'application/json': { schema: IntegrationSchema } } },
},
});
// Visual regression endpoints
registry.registerPath({
method: 'get',
path: '/api/visual/comparisons',
summary: 'List visual comparisons',
tags: ['Visual Regression'],
security: [{ [bearerAuth.name]: [] }],
request: {
query: z.object({
sessionId: z.string().optional(),
status: ComparisonStatusSchema.optional(),
}),
},
responses: {
200: {
description: 'Comparisons list',
content: { 'application/json': { schema: z.array(VisualComparisonSchema) } },
},
},
});
registry.registerPath({
method: 'post',
path: '/api/visual/baselines/{comparisonId}/approve',
summary: 'Approve a comparison as baseline',
tags: ['Visual Regression'],
security: [{ [bearerAuth.name]: [] }],
request: { params: z.object({ comparisonId: z.string() }) },
responses: {
200: {
description: 'Approved',
content: {
'application/json': {
schema: z.object({ baselineId: z.string(), status: z.literal('approved') }),
},
},
},
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/visual/baselines/{comparisonId}/reject',
summary: 'Reject a comparison',
tags: ['Visual Regression'],
security: [{ [bearerAuth.name]: [] }],
request: { params: z.object({ comparisonId: z.string() }) },
responses: {
200: {
description: 'Rejected',
content: {
'application/json': {
schema: z.object({ status: z.literal('rejected') }),
},
},
},
404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/visual/baselines/approve-all',
summary: 'Approve all new-state comparisons as baselines',
tags: ['Visual Regression'],
security: [{ [bearerAuth.name]: [] }],
request: {
body: {
content: {
'application/json': {
schema: z.object({ sessionId: z.string().optional() }),
},
},
},
},
responses: {
200: {
description: 'Bulk approved',
content: {
'application/json': {
schema: z.object({ approved: z.number() }),
},
},
},
},
});
// License endpoints
registry.registerPath({
method: 'get',
path: '/api/license/status',
summary: 'Get license status',
tags: ['License'],
security: [{ [bearerAuth.name]: [] }],
responses: {
200: { description: 'License status', content: { 'application/json': { schema: LicenseStatusSchema } } },
},
});
registry.registerPath({
method: 'post',
path: '/api/license/activate',
summary: 'Activate a license key',
tags: ['License'],
security: [{ [bearerAuth.name]: [] }],
request: {
body: {
content: {
'application/json': {
schema: z.object({ key: z.string() }),
},
},
},
},
responses: {
200: { description: 'License activated', content: { 'application/json': { schema: LicenseStatusSchema } } },
400: { description: 'Invalid key', content: { 'application/json': { schema: ErrorSchema } } },
},
});
// Health endpoints
registry.registerPath({
method: 'get',
path: '/health/live',
summary: 'Liveness probe',
tags: ['Health'],
responses: {
200: {
description: 'Process alive',
content: {
'application/json': {
schema: z.object({ status: z.literal('ok'), uptime: z.number() }),
},
},
},
},
});
registry.registerPath({
method: 'get',
path: '/health/ready',
summary: 'Readiness probe',
tags: ['Health'],
responses: {
200: {
description: 'Ready',
content: {
'application/json': {
schema: z.object({ status: z.literal('ready'), db: z.string() }),
},
},
},
503: {
description: 'Not ready',
content: {
'application/json': {
schema: z.object({ status: z.literal('not_ready'), db: z.string(), error: z.string() }),
},
},
},
},
});
// ─── Generate spec ─────────────────────────────────────────────────────────────
const generator = new OpenApiGeneratorV31(registry.definitions);
export const openApiSpec = generator.generateDocument({
openapi: '3.1.0',
info: {
title: 'ABE — Autonomous Bug Explorer API',
version: '1.0.0',
description:
'ABE is an enterprise self-hosted platform for autonomous web application bug discovery. ' +
'This API allows you to manage crawl sessions, review findings, generate reports, and configure integrations.',
},
servers: [{ url: 'http://localhost:3001', description: 'Local development' }],
});
// ─── Express Router ────────────────────────────────────────────────────────────
export function createApiDocsRouter(): Router {
const router = Router();
// Serve the raw OpenAPI JSON spec
router.get('/openapi.json', (_req: Request, res: Response) => {
res.json(openApiSpec);
});
// Serve Scalar UI
router.use(
'/',
apiReference({
spec: { content: openApiSpec },
theme: 'purple',
})
);
return router;
}