1025 lines
24 KiB
TypeScript
1025 lines
24 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(),
|
|
}),
|
|
);
|
|
|
|
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 }),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
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(),
|
|
}),
|
|
command: DeviceCommandSchema,
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
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(),
|
|
status: z.string(),
|
|
streamKey: z.string().nullable(),
|
|
mediaProvider: z.string(),
|
|
mediaSessionId: z.string().nullable(),
|
|
publishEndpoint: z.string().nullable(),
|
|
subscribeEndpoint: z.string().nullable(),
|
|
startedAt: z.string().datetime().nullable(),
|
|
}),
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
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/{streamSessionId}/publish-credentials',
|
|
summary: 'Get publish credentials for camera ingest to media provider',
|
|
tags: ['Streams'],
|
|
security: [{ bearerDeviceToken: [] }],
|
|
request: {
|
|
params: z.object({ streamSessionId: z.string().uuid() }),
|
|
},
|
|
responses: {
|
|
200: {
|
|
description: 'Publish credentials',
|
|
content: {
|
|
'application/json': {
|
|
schema: z.object({
|
|
provider: z.string(),
|
|
mediaSessionId: z.string(),
|
|
publishToken: z.string(),
|
|
publishUrl: z.string(),
|
|
expiresInSeconds: z.number().int(),
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
registry.registerPath({
|
|
method: 'get',
|
|
path: '/streams/{streamSessionId}/subscribe-credentials',
|
|
summary: 'Get subscribe credentials for viewing stream from media provider',
|
|
tags: ['Streams'],
|
|
security: [{ bearerDeviceToken: [] }],
|
|
request: {
|
|
params: z.object({ streamSessionId: z.string().uuid() }),
|
|
},
|
|
responses: {
|
|
200: {
|
|
description: 'Subscribe credentials',
|
|
content: {
|
|
'application/json': {
|
|
schema: z.object({
|
|
provider: z.string(),
|
|
mediaSessionId: z.string(),
|
|
subscribeToken: z.string(),
|
|
subscribeUrl: z.string(),
|
|
expiresInSeconds: z.number().int(),
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
registry.registerPath({
|
|
method: 'get',
|
|
path: '/streams/{streamSessionId}/playback-token',
|
|
summary: 'Get short-lived playback token for active stream session',
|
|
tags: ['Streams'],
|
|
security: [{ bearerDeviceToken: [] }],
|
|
request: {
|
|
params: z.object({ streamSessionId: z.string().uuid() }),
|
|
},
|
|
responses: {
|
|
200: {
|
|
description: 'Playback token response',
|
|
content: {
|
|
'application/json': {
|
|
schema: z.object({
|
|
streamSessionId: z.string().uuid(),
|
|
streamKey: z.string(),
|
|
status: z.string(),
|
|
playbackToken: z.string(),
|
|
subscribeUrl: z.string(),
|
|
mediaProvider: z.string(),
|
|
expiresInSeconds: z.number().int(),
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
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;
|
|
}
|