backend: implement X-Fields header for recording responses, enhancing data retrieval flexibility

This commit is contained in:
CSantosM 2026-02-16 14:09:14 +01:00
parent 6ca1ace61e
commit 9dc4834edd
7 changed files with 142 additions and 11 deletions

View File

@ -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

View File

@ -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'

View File

@ -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}'`);

View File

@ -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);

View File

@ -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();
};

View File

@ -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')

View File

@ -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
);