diff --git a/meet-ce/backend/openapi/components/parameters/recording-x-fields-header.yaml b/meet-ce/backend/openapi/components/parameters/recording-x-fields-header.yaml new file mode 100644 index 00000000..9db181bc --- /dev/null +++ b/meet-ce/backend/openapi/components/parameters/recording-x-fields-header.yaml @@ -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 diff --git a/meet-ce/backend/openapi/paths/recordings.yaml b/meet-ce/backend/openapi/paths/recordings.yaml index 639c33db..f35c4ed0 100644 --- a/meet-ce/backend/openapi/paths/recordings.yaml +++ b/meet-ce/backend/openapi/paths/recordings.yaml @@ -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' diff --git a/meet-ce/backend/src/controllers/recording.controller.ts b/meet-ce/backend/src/controllers/recording.controller.ts index d6f89dd4..b506dc43 100644 --- a/meet-ce/backend/src/controllers/recording.controller.ts +++ b/meet-ce/backend/src/controllers/recording.controller.ts @@ -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}'`); diff --git a/meet-ce/backend/src/helpers/recording.helper.ts b/meet-ce/backend/src/helpers/recording.helper.ts index 5509ebbf..fa91a720 100644 --- a/meet-ce/backend/src/helpers/recording.helper.ts +++ b/meet-ce/backend/src/helpers/recording.helper.ts @@ -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 { const status = RecordingHelper.extractOpenViduStatus(egressInfo.status); const size = RecordingHelper.extractSize(egressInfo); diff --git a/meet-ce/backend/src/middlewares/request-validators/recording-validator.middleware.ts b/meet-ce/backend/src/middlewares/request-validators/recording-validator.middleware.ts index 83282ecc..d789ee23 100644 --- a/meet-ce/backend/src/middlewares/request-validators/recording-validator.middleware.ts +++ b/meet-ce/backend/src/middlewares/request-validators/recording-validator.middleware.ts @@ -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(); }; diff --git a/meet-ce/backend/src/models/zod-schemas/recording.schema.ts b/meet-ce/backend/src/models/zod-schemas/recording.schema.ts index 6810c2f4..013f7754 100644 --- a/meet-ce/backend/src/models/zod-schemas/recording.schema.ts +++ b/meet-ce/backend/src/models/zod-schemas/recording.schema.ts @@ -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, + query: Record +): 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') diff --git a/meet-ce/backend/src/routes/recording.routes.ts b/meet-ce/backend/src/routes/recording.routes.ts index e3ff3fd9..81078467 100644 --- a/meet-ce/backend/src/routes/recording.routes.ts +++ b/meet-ce/backend/src/routes/recording.routes.ts @@ -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 );