backend: implement X-Fields header for recording responses, enhancing data retrieval flexibility
This commit is contained in:
parent
6ca1ace61e
commit
9dc4834edd
@ -0,0 +1,15 @@
|
||||
name: X-Fields
|
||||
in: header
|
||||
description: >
|
||||
Comma-separated list of **Recording** fields to include in the response.
|
||||
Use this header to request only the data you need, reducing payload size and improving performance.
|
||||
|
||||
When combined with the `fields` query parameter, values are merged (union of unique fields).
|
||||
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
examples:
|
||||
basic:
|
||||
value: 'recordingId,roomId,status'
|
||||
summary: Only return basic recording information
|
||||
@ -17,6 +17,8 @@
|
||||
security:
|
||||
- apiKeyHeader: []
|
||||
- roomMemberTokenHeader: []
|
||||
parameters:
|
||||
- $ref: '../components/parameters/recording-x-fields-header.yaml'
|
||||
requestBody:
|
||||
$ref: '../components/requestBodies/start-recording-request.yaml'
|
||||
responses:
|
||||
@ -56,6 +58,7 @@
|
||||
- accessTokenHeader: []
|
||||
- roomMemberTokenHeader: []
|
||||
parameters:
|
||||
- $ref: '../components/parameters/recording-x-fields-header.yaml'
|
||||
- $ref: '../components/parameters/room-id-query.yaml'
|
||||
- $ref: '../components/parameters/room-name.yaml'
|
||||
- $ref: '../components/parameters/recording-status.yaml'
|
||||
@ -168,6 +171,8 @@
|
||||
parameters:
|
||||
- $ref: '../components/parameters/recording-id.yaml'
|
||||
- $ref: '../components/parameters/recording-secret.yaml'
|
||||
- $ref: '../components/parameters/recording-x-fields-header.yaml'
|
||||
- $ref: '../components/parameters/recording-fields.yaml'
|
||||
responses:
|
||||
'200':
|
||||
$ref: '../components/responses/success-get-recording.yaml'
|
||||
@ -303,6 +308,7 @@
|
||||
- roomMemberTokenHeader: []
|
||||
parameters:
|
||||
- $ref: '../components/parameters/recording-id.yaml'
|
||||
- $ref: '../components/parameters/recording-x-fields-header.yaml'
|
||||
responses:
|
||||
'202':
|
||||
$ref: '../components/responses/success-stop-recording.yaml'
|
||||
|
||||
@ -4,6 +4,7 @@ import { Request, Response } from 'express';
|
||||
import { Readable } from 'stream';
|
||||
import { container } from '../config/dependency-injector.config.js';
|
||||
import { INTERNAL_CONFIG } from '../config/internal-config.js';
|
||||
import { RecordingHelper } from '../helpers/recording.helper.js';
|
||||
import {
|
||||
errorRecordingsZipEmpty,
|
||||
handleError,
|
||||
@ -18,15 +19,17 @@ export const startRecording = async (req: Request, res: Response) => {
|
||||
const logger = container.get(LoggerService);
|
||||
const recordingService = container.get(RecordingService);
|
||||
const { roomId, config } = req.body;
|
||||
const { fields } = req.query as { fields?: MeetRecordingField[] };
|
||||
logger.info(`Starting recording in room '${roomId}'`);
|
||||
|
||||
try {
|
||||
const recordingInfo = await recordingService.startRecording(roomId, config);
|
||||
let recordingInfo = await recordingService.startRecording(roomId, config);
|
||||
res.setHeader(
|
||||
'Location',
|
||||
`${getBaseUrl()}${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings/${recordingInfo.recordingId}`
|
||||
);
|
||||
|
||||
recordingInfo = RecordingHelper.applyFieldFilters(recordingInfo, fields);
|
||||
return res.status(201).json(recordingInfo);
|
||||
} catch (error) {
|
||||
handleError(res, error, `starting recording in room '${roomId}'`);
|
||||
@ -36,13 +39,16 @@ export const startRecording = async (req: Request, res: Response) => {
|
||||
export const stopRecording = async (req: Request, res: Response) => {
|
||||
const logger = container.get(LoggerService);
|
||||
const recordingId = req.params.recordingId;
|
||||
const { fields } = req.query as { fields?: MeetRecordingField[] };
|
||||
|
||||
try {
|
||||
logger.info(`Stopping recording '${recordingId}'`);
|
||||
const recordingService = container.get(RecordingService);
|
||||
|
||||
const recordingInfo = await recordingService.stopRecording(recordingId);
|
||||
let recordingInfo = await recordingService.stopRecording(recordingId);
|
||||
res.setHeader('Location', `${getBaseUrl()}${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings/${recordingId}`);
|
||||
|
||||
recordingInfo = RecordingHelper.applyFieldFilters(recordingInfo, fields);
|
||||
return res.status(202).json(recordingInfo);
|
||||
} catch (error) {
|
||||
handleError(res, error, `stopping recording '${recordingId}'`);
|
||||
|
||||
@ -2,6 +2,7 @@ import { EgressStatus } from '@livekit/protocol';
|
||||
import {
|
||||
MeetRecordingEncodingOptions,
|
||||
MeetRecordingEncodingPreset,
|
||||
MeetRecordingField,
|
||||
MeetRecordingInfo,
|
||||
MeetRecordingLayout,
|
||||
MeetRecordingStatus
|
||||
@ -10,12 +11,27 @@ import { EgressInfo } from 'livekit-server-sdk';
|
||||
import { container } from '../config/dependency-injector.config.js';
|
||||
import { RoomService } from '../services/room.service.js';
|
||||
import { EncodingConverter } from './encoding-converter.helper.js';
|
||||
import { applyHttpFieldFiltering } from './field-filter.helper.js';
|
||||
|
||||
export class RecordingHelper {
|
||||
private constructor() {
|
||||
// Prevent instantiation of this utility class
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies HTTP-level field filtering to a MeetRecordingInfo object.
|
||||
* Since recordings have no extra fields, this simply filters to the requested fields.
|
||||
* When no fields are specified, the full recording object is returned unmodified.
|
||||
*/
|
||||
static applyFieldFilters(recording: MeetRecordingInfo, fields?: MeetRecordingField[]): MeetRecordingInfo {
|
||||
if (!fields || fields.length === 0) {
|
||||
return recording;
|
||||
}
|
||||
|
||||
// Recordings have no extra fields concept, so we pass empty arrays for extra fields params
|
||||
return applyHttpFieldFiltering(recording, fields, undefined, []);
|
||||
}
|
||||
|
||||
static async toRecordingInfo(egressInfo: EgressInfo): Promise<MeetRecordingInfo> {
|
||||
const status = RecordingHelper.extractOpenViduStatus(egressInfo.status);
|
||||
const size = RecordingHelper.extractSize(egressInfo);
|
||||
|
||||
@ -1,27 +1,44 @@
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { rejectUnprocessableRequest } from '../../models/error.model.js';
|
||||
import {
|
||||
BulkDeleteRecordingsReqSchema,
|
||||
GetRecordingMediaReqSchema,
|
||||
GetRecordingReqSchema,
|
||||
RecordingFiltersSchema,
|
||||
GetRecordingUrlReqSchema,
|
||||
BulkDeleteRecordingsReqSchema,
|
||||
mergeRecordingHeaderFieldsIntoQuery,
|
||||
nonEmptySanitizedRecordingId,
|
||||
StartRecordingReqSchema
|
||||
RecordingFiltersSchema,
|
||||
RecordingQueryFieldsSchema,
|
||||
StartRecordingReqSchema,
|
||||
StopRecordingReqSchema
|
||||
} from '../../models/zod-schemas/recording.schema.js';
|
||||
|
||||
export const validateStartRecordingReq = (req: Request, res: Response, next: NextFunction) => {
|
||||
const { success, error, data } = StartRecordingReqSchema.safeParse(req.body);
|
||||
// Merge X-Fields header into query params before validation
|
||||
mergeRecordingHeaderFieldsIntoQuery(req.headers, req.query);
|
||||
|
||||
if (!success) {
|
||||
return rejectUnprocessableRequest(res, error);
|
||||
const bodyResult = StartRecordingReqSchema.safeParse(req.body);
|
||||
|
||||
if (!bodyResult.success) {
|
||||
return rejectUnprocessableRequest(res, bodyResult.error);
|
||||
}
|
||||
|
||||
req.body = data;
|
||||
req.body = bodyResult.data;
|
||||
|
||||
const queryResult = RecordingQueryFieldsSchema.safeParse(req.query);
|
||||
|
||||
if (!queryResult.success) {
|
||||
return rejectUnprocessableRequest(res, queryResult.error);
|
||||
}
|
||||
|
||||
req.query = queryResult.data;
|
||||
next();
|
||||
};
|
||||
|
||||
export const validateGetRecordingsReq = (req: Request, res: Response, next: NextFunction) => {
|
||||
// Merge X-Fields header into query params before validation
|
||||
mergeRecordingHeaderFieldsIntoQuery(req.headers, req.query);
|
||||
|
||||
const { success, error, data } = RecordingFiltersSchema.safeParse(req.query);
|
||||
|
||||
if (!success) {
|
||||
@ -59,6 +76,9 @@ export const withValidRecordingId = (req: Request, res: Response, next: NextFunc
|
||||
};
|
||||
|
||||
export const validateGetRecordingReq = (req: Request, res: Response, next: NextFunction) => {
|
||||
// Merge X-Fields header into query params before validation
|
||||
mergeRecordingHeaderFieldsIntoQuery(req.headers, req.query);
|
||||
|
||||
const { success, error, data } = GetRecordingReqSchema.safeParse({
|
||||
params: req.params,
|
||||
query: req.query
|
||||
@ -69,6 +89,25 @@ export const validateGetRecordingReq = (req: Request, res: Response, next: NextF
|
||||
}
|
||||
|
||||
req.params.recordingId = data.params.recordingId;
|
||||
req.query = data.query;
|
||||
next();
|
||||
};
|
||||
|
||||
export const validateStopRecordingReq = (req: Request, res: Response, next: NextFunction) => {
|
||||
// Merge X-Fields header into query params before validation
|
||||
mergeRecordingHeaderFieldsIntoQuery(req.headers, req.query);
|
||||
|
||||
const { success, error, data } = StopRecordingReqSchema.safeParse({
|
||||
params: req.params,
|
||||
query: req.query
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
return rejectUnprocessableRequest(res, error);
|
||||
}
|
||||
|
||||
req.params.recordingId = data.params.recordingId;
|
||||
req.query = data.query;
|
||||
next();
|
||||
};
|
||||
|
||||
|
||||
@ -93,7 +93,7 @@ export const RecordingFiltersSchema = z.object({
|
||||
roomId: nonEmptySanitizedRoomId('roomId').optional(),
|
||||
roomName: z.string().optional(),
|
||||
status: z.nativeEnum(MeetRecordingStatus).optional(),
|
||||
fields: fieldsSchema.optional(),
|
||||
fields: fieldsSchema,
|
||||
maxItems: z.coerce
|
||||
.number()
|
||||
.positive('maxItems must be a positive number')
|
||||
@ -128,15 +128,63 @@ export const BulkDeleteRecordingsReqSchema = z.object({
|
||||
)
|
||||
});
|
||||
|
||||
export const RecordingQueryFieldsSchema = z.object({
|
||||
fields: fieldsSchema
|
||||
});
|
||||
|
||||
export const RecordingHeaderFieldsSchema = z.object({
|
||||
'x-fields': fieldsSchema
|
||||
});
|
||||
|
||||
/**
|
||||
* Merges X-Fields header values into query.fields for recordings.
|
||||
* When both header and query param provide fields, values are merged (union of unique fields).
|
||||
* This allows API consumers to use either mechanism or both simultaneously.
|
||||
*/
|
||||
export function mergeRecordingHeaderFieldsIntoQuery(
|
||||
headers: Record<string, unknown>,
|
||||
query: Record<string, unknown>
|
||||
): void {
|
||||
const headerResult = RecordingHeaderFieldsSchema.safeParse(headers);
|
||||
|
||||
if (!headerResult.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const headerFields = headerResult.data['x-fields'];
|
||||
|
||||
if (headerFields) {
|
||||
const existingFields =
|
||||
typeof query.fields === 'string'
|
||||
? query.fields
|
||||
.split(',')
|
||||
.map((f: string) => f.trim())
|
||||
.filter((f: string) => f !== '')
|
||||
: [];
|
||||
const merged = Array.from(new Set([...existingFields, ...headerFields]));
|
||||
query.fields = merged.join(',');
|
||||
}
|
||||
}
|
||||
|
||||
export const GetRecordingReqSchema = z.object({
|
||||
params: z.object({
|
||||
recordingId: nonEmptySanitizedRecordingId('recordingId')
|
||||
}),
|
||||
query: z.object({
|
||||
fields: fieldsSchema,
|
||||
secret: z.string().optional()
|
||||
})
|
||||
});
|
||||
|
||||
export const StopRecordingReqSchema = z.object({
|
||||
params: z.object({
|
||||
recordingId: nonEmptySanitizedRecordingId('recordingId')
|
||||
}),
|
||||
query: z.object({
|
||||
fields: fieldsSchema
|
||||
})
|
||||
});
|
||||
|
||||
export const GetRecordingMediaReqSchema = z.object({
|
||||
params: z.object({
|
||||
recordingId: nonEmptySanitizedRecordingId('recordingId')
|
||||
|
||||
@ -21,6 +21,7 @@ import {
|
||||
validateGetRecordingsReq,
|
||||
validateGetRecordingUrlReq,
|
||||
validateStartRecordingReq,
|
||||
validateStopRecordingReq,
|
||||
withValidRecordingId
|
||||
} from '../middlewares/request-validators/recording-validator.middleware.js';
|
||||
|
||||
@ -88,7 +89,7 @@ recordingRouter.delete(
|
||||
recordingRouter.post(
|
||||
'/:recordingId/stop',
|
||||
withAuth(apiKeyValidator, roomMemberTokenValidator),
|
||||
withValidRecordingId,
|
||||
validateStopRecordingReq,
|
||||
authorizeRecordingControl,
|
||||
recordingCtrl.stopRecording
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user