Files

951 lines
23 KiB
TypeScript

import { OpenAPIRegistry, OpenApiGeneratorV3, extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
import { z } from 'zod';
import { getBetterAuthBaseUrl } from '../utils/env';
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(),
recordingId: z.string().uuid().optional(),
eventId: z.string().uuid().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 and attach it to a recording',
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 }),
},
},
},
},
});
registry.registerPath({
method: 'post',
path: '/events/motion/start',
summary: 'Camera device starts a motion event and notifies linked clients',
tags: ['Events'],
security: [{ bearerDeviceToken: [] }],
request: {
body: {
content: {
'application/json': {
schema: z.object({
title: z.string().optional(),
triggeredBy: z.string().optional(),
videoUrl: z.string().url().optional(),
}),
},
},
},
},
responses: {
201: {
description: 'Motion event started',
content: {
'application/json': {
schema: z.object({
message: z.string(),
event: z.object({
id: z.string().uuid(),
userId: z.string().uuid(),
deviceId: z.string().uuid().nullable(),
title: z.string().nullable(),
triggeredBy: z.string().nullable(),
status: z.string(),
startedAt: z.string().datetime(),
endedAt: z.string().datetime().nullable(),
videoUrl: z.string().nullable(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
notifiedAt: z.string().datetime().nullable(),
}),
notifiedClients: z.number().int(),
}),
},
},
},
},
});
registry.registerPath({
method: 'post',
path: '/events/{eventId}/motion/end',
summary: 'Camera device ends a motion event and notifies linked clients',
tags: ['Events'],
security: [{ bearerDeviceToken: [] }],
request: {
params: z.object({ eventId: z.string().uuid() }),
body: {
content: {
'application/json': {
schema: z.object({
status: z.enum(['completed', 'cancelled', 'failed']).optional(),
videoUrl: z.string().url().optional(),
}),
},
},
},
},
responses: {
200: {
description: 'Motion event ended',
content: {
'application/json': {
schema: z.object({
message: z.string(),
event: z.object({
id: z.string().uuid(),
status: z.string(),
endedAt: z.string().datetime().nullable(),
updatedAt: z.string().datetime(),
}),
notifiedClients: z.number().int(),
}),
},
},
},
},
});
registry.registerPath({
method: 'get',
path: '/events',
summary: 'List events for authenticated user',
tags: ['Events'],
security: [{ cookieAuth: [] }],
request: {
query: z.object({
status: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(25),
}),
},
responses: {
200: {
description: 'User event list',
content: {
'application/json': {
schema: z.object({
count: z.number().int(),
events: z.array(
z.object({
id: z.string().uuid(),
status: z.string(),
startedAt: z.string().datetime(),
endedAt: z.string().datetime().nullable(),
deviceId: z.string().uuid().nullable(),
title: z.string().nullable(),
triggeredBy: z.string().nullable(),
videoUrl: z.string().nullable(),
}),
),
}),
},
},
},
},
});
registry.registerPath({
method: 'post',
path: '/streams/request',
summary: 'Client device requests an on-demand stream from a linked camera',
tags: ['Streams'],
security: [{ bearerDeviceToken: [] }],
request: {
body: {
content: {
'application/json': {
schema: z.object({
cameraDeviceId: z.string().uuid(),
reason: z.enum(['on_demand', 'motion_follow_up']).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
},
},
},
},
responses: {
201: {
description: 'Stream request created',
content: {
'application/json': {
schema: z.object({
message: z.string(),
streamSession: z.object({
id: z.string().uuid(),
ownerUserId: z.string().uuid(),
cameraDeviceId: z.string().uuid(),
requesterDeviceId: z.string().uuid(),
status: z.string(),
reason: z.string(),
metadata: z.record(z.string(), z.unknown()).nullable().optional(),
startedAt: z.string().datetime().nullable().optional(),
endedAt: z.string().datetime().nullable().optional(),
createdAt: z.string().datetime().optional(),
updatedAt: z.string().datetime().optional(),
}),
}),
},
},
},
},
});
registry.registerPath({
method: 'post',
path: '/streams/{streamSessionId}/accept',
summary: 'Camera device accepts a pending on-demand stream request',
tags: ['Streams'],
security: [{ bearerDeviceToken: [] }],
request: {
params: z.object({ streamSessionId: z.string().uuid() }),
body: {
content: {
'application/json': {
schema: z.object({
streamKey: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
},
},
},
},
responses: {
200: {
description: 'Stream session accepted',
content: {
'application/json': {
schema: z.object({
message: z.string(),
streamSession: z.object({
id: z.string().uuid(),
ownerUserId: z.string().uuid(),
cameraDeviceId: z.string().uuid(),
requesterDeviceId: z.string().uuid(),
status: z.string(),
reason: z.string(),
metadata: z.record(z.string(), z.unknown()).nullable().optional(),
startedAt: z.string().datetime().nullable(),
endedAt: z.string().datetime().nullable().optional(),
createdAt: z.string().datetime().optional(),
updatedAt: z.string().datetime().optional(),
}),
}),
},
},
},
},
});
registry.registerPath({
method: 'post',
path: '/streams/{streamSessionId}/end',
summary: 'Requester or camera ends an active stream session',
tags: ['Streams'],
security: [{ bearerDeviceToken: [] }],
request: {
params: z.object({ streamSessionId: z.string().uuid() }),
body: {
content: {
'application/json': {
schema: z.object({
reason: z.enum(['completed', 'cancelled', 'failed']).optional(),
}),
},
},
},
},
responses: {
200: {
description: 'Stream session ended',
content: {
'application/json': {
schema: z.object({
message: z.string(),
streamSession: z.object({
id: z.string().uuid(),
status: z.string(),
endedAt: z.string().datetime().nullable(),
}),
}),
},
},
},
},
});
registry.registerPath({
method: 'get',
path: '/streams/me/list',
summary: 'List stream sessions for current device',
tags: ['Streams'],
security: [{ bearerDeviceToken: [] }],
request: {
query: z.object({
status: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(25),
}),
},
responses: {
200: {
description: 'Stream sessions list',
content: {
'application/json': {
schema: z.object({
count: z.number().int(),
streamSessions: z.array(
z.object({
id: z.string().uuid(),
cameraDeviceId: z.string().uuid(),
requesterDeviceId: z.string().uuid(),
status: z.string(),
reason: z.string(),
streamKey: z.string().nullable(),
mediaProvider: z.string(),
mediaSessionId: z.string().nullable(),
}),
),
}),
},
},
},
},
});
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: getBetterAuthBaseUrl() }],
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' },
{ name: 'Events', description: 'Motion event lifecycle and user event history' },
{ name: 'Streams', description: 'On-demand live stream control lifecycle' },
],
});
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;
}