import { OpenAPIRegistry, OpenApiGeneratorV3, extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; import { z } from 'zod'; extendZodWithOpenApi(z); const registry = new OpenAPIRegistry(); const ErrorResponseSchema = registry.register( 'ErrorResponse', z.object({ message: z.string(), errors: z.unknown().optional(), }), ); const VideoRecordSchema = registry.register( 'VideoRecord', z.object({ id: z.string().uuid(), objectKey: z.string(), bucket: z.string(), status: z.string(), createdAt: z.string().datetime(), expiresAt: z.string().datetime().nullable(), }), ); const VideoUploadUrlRequestSchema = registry.register( 'VideoUploadUrlRequest', z.object({ fileName: z.string().min(1).max(255), deviceId: z.string().uuid(), prefix: z.string().optional(), }), ); const AdminUploadUrlRequestSchema = registry.register( 'AdminUploadUrlRequest', z.object({ fileName: z.string().min(1).max(255), prefix: z.string().optional(), }), ); const UploadUrlResponseSchema = registry.register( 'UploadUrlResponse', z.object({ message: z.string(), bucket: z.string(), objectKey: z.string(), uploadUrl: z.string().url(), expiresInSeconds: z.number().int().positive(), video: VideoRecordSchema, }), ); const DownloadUrlQuerySchema = registry.register( 'DownloadUrlQuery', z.object({ objectKey: z.string().min(1), }), ); const DownloadUrlResponseSchema = registry.register( 'DownloadUrlResponse', z.object({ message: z.string(), bucket: z.string(), objectKey: z.string(), downloadUrl: z.string().url(), expiresInSeconds: z.number().int().positive(), }), ); const ListVideosQuerySchema = registry.register( 'ListVideosQuery', z.object({ prefix: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).default(20), }), ); const ListedObjectSchema = registry.register( 'ListedObject', z.object({ objectKey: z.string().nullable(), size: z.number(), etag: z.string().nullable(), lastModified: z.string().datetime().nullable(), }), ); const ListVideosResponseSchema = registry.register( 'ListVideosResponse', z.object({ bucket: z.string(), count: z.number().int().nonnegative(), objects: z.array(ListedObjectSchema), }), ); const DeleteVideoResponseSchema = registry.register( 'DeleteVideoResponse', z.object({ message: z.string(), bucket: z.string(), objectKey: z.string(), }), ); const DeviceSchema = registry.register( 'Device', z.object({ id: z.string().uuid(), userId: z.string().uuid(), name: z.string().nullable(), role: z.enum(['camera', 'client']), platform: z.string().nullable(), appVersion: z.string().nullable(), status: z.string(), isCamera: z.boolean(), lastSeenAt: z.string().datetime().nullable(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }), ); const DeviceLinkSchema = registry.register( 'DeviceLink', z.object({ id: z.string().uuid(), ownerUserId: z.string().uuid(), cameraDeviceId: z.string().uuid(), clientDeviceId: z.string().uuid(), status: z.string(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }), ); const DeviceCommandSchema = registry.register( 'DeviceCommand', z.object({ id: z.string().uuid(), ownerUserId: z.string().uuid(), sourceDeviceId: z.string().uuid(), targetDeviceId: z.string().uuid(), commandType: z.string(), payload: z.record(z.string(), z.unknown()).nullable(), status: z.string(), retryCount: z.number().int(), lastDispatchedAt: z.string().datetime().nullable(), acknowledgedAt: z.string().datetime().nullable(), error: z.string().nullable(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }), ); registry.registerPath({ method: 'get', path: '/', summary: 'Health check', tags: ['System'], responses: { 200: { description: 'Service health text', content: { 'text/plain': { schema: z.string(), }, }, }, }, }); registry.registerPath({ method: 'post', path: '/videos/upload-url', summary: 'Create a presigned upload URL', tags: ['Videos'], security: [{ cookieAuth: [] }], request: { body: { content: { 'application/json': { schema: VideoUploadUrlRequestSchema, }, }, }, }, responses: { 201: { description: 'Presigned upload URL generated', content: { 'application/json': { schema: UploadUrlResponseSchema, }, }, }, 400: { description: 'Invalid request body', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, 401: { description: 'Unauthorized', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, }, }); registry.registerPath({ method: 'get', path: '/videos/download-url', summary: 'Create a signed download URL', tags: ['Videos'], security: [{ cookieAuth: [] }], request: { query: DownloadUrlQuerySchema, }, responses: { 200: { description: 'Signed download URL generated', content: { 'application/json': { schema: DownloadUrlResponseSchema, }, }, }, 400: { description: 'Invalid query params', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, 401: { description: 'Unauthorized', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, }, }); registry.registerPath({ method: 'get', path: '/videos', summary: 'List objects in the configured bucket', tags: ['Videos'], security: [{ cookieAuth: [] }], request: { query: ListVideosQuerySchema, }, responses: { 200: { description: 'Object listing', content: { 'application/json': { schema: ListVideosResponseSchema, }, }, }, 400: { description: 'Invalid query params', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, 401: { description: 'Unauthorized', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, }, }); registry.registerPath({ method: 'delete', path: '/videos', summary: 'Delete object by object key', tags: ['Videos'], security: [{ cookieAuth: [] }], request: { query: DownloadUrlQuerySchema, }, responses: { 200: { description: 'Object deleted', content: { 'application/json': { schema: DeleteVideoResponseSchema, }, }, }, 400: { description: 'Invalid query params', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, 401: { description: 'Unauthorized', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, }, }); registry.registerPath({ method: 'post', path: '/admin/upload-url', summary: 'Admin: create presigned upload URL', tags: ['Admin'], security: [{ basicAuth: [] }], request: { body: { content: { 'application/json': { schema: AdminUploadUrlRequestSchema, }, }, }, }, responses: { 201: { description: 'Admin upload URL generated', content: { 'application/json': { schema: z.object({ message: z.string(), bucket: z.string(), objectKey: z.string(), uploadUrl: z.string().url(), expiresAt: z.string().datetime(), expiresInSeconds: z.number().int().positive(), }), }, }, }, 400: { description: 'Invalid request body', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, 401: { description: 'Unauthorized', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, }, }); registry.registerPath({ method: 'get', path: '/admin/objects', summary: 'Admin: list objects in bucket', tags: ['Admin'], security: [{ basicAuth: [] }], request: { query: ListVideosQuerySchema, }, responses: { 200: { description: 'Admin object listing', content: { 'application/json': { schema: ListVideosResponseSchema, }, }, }, 400: { description: 'Invalid query params', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, 401: { description: 'Unauthorized', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, }, }); registry.registerPath({ method: 'delete', path: '/admin/object', summary: 'Admin: delete object by key', tags: ['Admin'], security: [{ basicAuth: [] }], request: { query: DownloadUrlQuerySchema, }, responses: { 200: { description: 'Object deleted', content: { 'application/json': { schema: DeleteVideoResponseSchema, }, }, }, 400: { description: 'Invalid query params', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, 401: { description: 'Unauthorized', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, }, }); registry.registerPath({ method: 'post', path: '/devices/register', summary: 'Register a mobile device and issue a device token', tags: ['Devices'], security: [{ cookieAuth: [] }], request: { body: { content: { 'application/json': { schema: z.object({ name: z.string().optional(), role: z.enum(['camera', 'client']), platform: z.string().optional(), appVersion: z.string().optional(), pushToken: z.string().optional(), }), }, }, }, }, responses: { 201: { description: 'Device registered', content: { 'application/json': { schema: z.object({ message: z.string(), device: DeviceSchema, deviceToken: z.string(), }), }, }, }, }, }); registry.registerPath({ method: 'get', path: '/devices', summary: 'List devices for the authenticated user', tags: ['Devices'], security: [{ cookieAuth: [] }], responses: { 200: { description: 'User devices', content: { 'application/json': { schema: z.object({ count: z.number().int(), devices: z.array(DeviceSchema), }), }, }, }, }, }); registry.registerPath({ method: 'post', path: '/devices/{deviceId}/heartbeat', summary: 'Record heartbeat for a device using bearer device token', tags: ['Devices'], security: [{ bearerDeviceToken: [] }], request: { params: z.object({ deviceId: z.string().uuid() }), body: { content: { 'application/json': { schema: z.object({ status: z.enum(['online', 'offline']).optional() }), }, }, }, }, responses: { 200: { description: 'Heartbeat recorded', content: { 'application/json': { schema: z.object({ message: z.string(), device: z.object({ id: z.string().uuid(), status: z.string(), lastSeenAt: z.string().datetime().nullable(), updatedAt: z.string().datetime(), }), }), }, }, }, }, }); registry.registerPath({ method: 'post', path: '/device-links', summary: 'Link a client device to a camera device', tags: ['Device Links'], security: [{ cookieAuth: [] }], request: { body: { content: { 'application/json': { schema: z.object({ cameraDeviceId: z.string().uuid(), clientDeviceId: z.string().uuid(), }), }, }, }, }, responses: { 201: { description: 'Link created', content: { 'application/json': { schema: z.object({ message: z.string(), link: DeviceLinkSchema }), }, }, }, }, }); registry.registerPath({ method: 'post', path: '/commands', summary: 'Create and dispatch a realtime command from client to camera', tags: ['Commands'], security: [{ cookieAuth: [] }], request: { body: { content: { 'application/json': { schema: z.object({ sourceDeviceId: z.string().uuid(), targetDeviceId: z.string().uuid(), commandType: z.enum(['start_stream', 'stop_stream', 'ping', 'update_settings']), payload: z.record(z.string(), z.unknown()).optional(), }), }, }, }, }, responses: { 201: { description: 'Command queued', content: { 'application/json': { schema: z.object({ message: z.string(), command: DeviceCommandSchema }), }, }, }, }, }); export function buildOpenApiDocument() { const generator = new OpenApiGeneratorV3(registry.definitions); const document = generator.generateDocument({ openapi: '3.0.3', info: { title: 'Backend API', version: '1.0.0', description: 'Auto-generated API documentation from Zod schemas.', }, servers: [{ url: process.env.BETTER_AUTH_URL ?? 'http://localhost:3000' }], tags: [ { name: 'System', description: 'Service endpoints' }, { name: 'Videos', description: 'Authenticated video object operations' }, { name: 'Admin', description: 'Basic-auth protected admin operations' }, { name: 'Devices', description: 'Device registration and heartbeat endpoints' }, { name: 'Device Links', description: 'Client-camera authorization links' }, { name: 'Commands', description: 'Realtime command dispatch and status' }, ], }); document.components = { ...(document.components ?? {}), securitySchemes: { cookieAuth: { type: 'apiKey', in: 'cookie', name: 'better-auth.session_token', description: 'Better Auth session cookie', }, basicAuth: { type: 'http', scheme: 'basic', }, bearerDeviceToken: { type: 'http', scheme: 'bearer', }, }, }; return document; }