diff --git a/openvidu-dashboard/CHANGELOG.md b/openvidu-dashboard/CHANGELOG.md index 1ee1fc3cb..76d4b247b 100644 --- a/openvidu-dashboard/CHANGELOG.md +++ b/openvidu-dashboard/CHANGELOG.md @@ -76,6 +76,8 @@ queried) and `Deployment` (running the service). One line per observable change, - Overview API: `GET /api/v1/overview` with live counters, today's totals and recent rooms. - Status API: `GET /api/v1/status` with the schema status, migration progress, writer health and the writer's batch interval, from which the writer, node and stale-data windows widen for slow batches. +- API reference outside production: an OpenAPI 3.1 document at `/api/v1/docs/openapi.json` and a browsable page at + `/api/v1/docs`, with operation IDs, described parameters and every error response. #### Deployment diff --git a/openvidu-dashboard/dashboard-ce/backend/AGENTS.md b/openvidu-dashboard/dashboard-ce/backend/AGENTS.md index 0cc4b85c9..b881981d2 100644 --- a/openvidu-dashboard/dashboard-ce/backend/AGENTS.md +++ b/openvidu-dashboard/dashboard-ce/backend/AGENTS.md @@ -12,9 +12,15 @@ Behaviour is specified in `openspec/specs/*-api`, `api-conventions`, `auth`, `st `TOKENS` (`src/di/tokens.ts`) are bound with `toResolvedValue` factories, without decorators, so esbuild and Vitest need no metadata. - Routes are declared once in the `RouteRegistry` (`src/api/route-registry.ts`): method, path under `/api/v1`, edition, - `feature` of a PRO route, security (`public`, `bearer`, `optional-bearer`), Zod schemas of params, query, body and - responses, and the handler. It drives validation, authentication and the OpenAPI document (only when `NODE_ENV` is - not `production`). + `feature` of a PRO route, a unique `operationId` (camelCase, verb first: `listRooms`, `getRoomsSummary`), tags + declared first with `registry.tag(name, description)`, security (`public`, `bearer`, `optional-bearer`, plus + `refreshCookie` for the auth routes), Zod schemas of params, query, body and responses, and the handler. It drives + validation, authentication and the OpenAPI document (`src/api/openapi.ts`, only when `NODE_ENV` is not `production`). +- OpenAPI rules, enforced by `test/integration/openapi-document.test.ts` through `documentationIssues()` + (`test/support/openapi-checks.ts`): every parameter has a description (`.meta({ description })`; the generator lifts + one set before `.default()` or `.optional()`), comma-separated lists use `csvParam()` or an array `override`, errors + without a schema get `ApiError`, `400` and `401` are added from the input and the security, and a response only one + edition answers carries `edition`. - PRO routes are only locks here: `registry.lock({ method, path, feature })` answers `403 PRO_FEATURE` after authentication and stays out of the OpenAPI document. An edition that extends CE registers the full definition in the lock's place (mount order kept) and swaps a CE route for its extension with `registry.replace()`; a `pro` startup diff --git a/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-routes.ts b/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-routes.ts index 5bf84a99b..19fd85b05 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-routes.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-routes.ts @@ -17,7 +17,7 @@ import { import type { AnalyticsService } from './analytics-service.js'; const TAGS = ['Analytics']; -const LOCKED_ITEM = { description: 'PRO metric, dimension or node scope on CE' }; +const LOCKED_ITEM = { description: 'PRO metric, dimension or node scope on CE', edition: 'ce' } as const; /** * Analytics routes: series, breakdowns, summary and CSV export, with the PRO metrics, dimensions and scopes rejected on @@ -28,12 +28,14 @@ export function registerAnalyticsRoutes( analytics: () => AnalyticsService, now: () => Date ): void { + registry.tag('Analytics', 'Usage series, breakdowns and KPIs aggregated from the rollups'); const ce = { edition: 'ce', tags: TAGS, security: 'bearer' } as const; registry.register({ ...ce, method: 'get', path: '/analytics/series', + operationId: 'getAnalyticsSeries', summary: 'Usage series from the rollups', request: { query: analyticsSeriesQuery(now) }, responses: { @@ -46,6 +48,7 @@ export function registerAnalyticsRoutes( ...ce, method: 'get', path: '/analytics/breakdowns', + operationId: 'getAnalyticsBreakdowns', summary: 'Counts per key of each dimension', request: { query: analyticsBreakdownsQuery(now) }, responses: { @@ -58,6 +61,7 @@ export function registerAnalyticsRoutes( ...ce, method: 'get', path: '/analytics/summary', + operationId: 'getAnalyticsSummary', summary: 'KPI values of a range', request: { query: analyticsSummaryQuery(now) }, responses: { 200: { description: 'The KPI values', schema: analyticsSummaryResponse }, 403: LOCKED_ITEM }, @@ -67,6 +71,7 @@ export function registerAnalyticsRoutes( ...ce, method: 'get', path: '/analytics/export.csv', + operationId: 'exportAnalyticsCsv', summary: 'Series or breakdowns as CSV', request: { query: analyticsExportQuery(now) }, responses: { diff --git a/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-schemas.ts b/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-schemas.ts index 301d3e80f..5242f0ea4 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-schemas.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-schemas.ts @@ -21,7 +21,8 @@ const granularityParam = z ? 'the finest analytics granularity is 5m' : `must be one of ${GRANULARITIES.join(', ')}` }) - .default('auto'); + .default('auto') + .meta({ description: 'Bucket size; auto picks one from the length of the range' }); // A transformed schema keeps no examples in its input JSON Schema, so these go on the parameter. const scopeParam = z @@ -29,18 +30,29 @@ const scopeParam = z .regex(ANALYTICS_SCOPE_PATTERN, { error: 'must be global or node:' }) .transform((value) => value as AnalyticsScope) .default('global') - .meta({ description: '`global` or `node:` (PRO)', param: { example: 'global' } }); + .meta({ + description: 'Data of every node (`global`) or of one node (`node:`, PRO)', + param: { example: 'global' } + }); const metricsParam = csvParam(enumValues(AnalyticsMetric), { strict: true }).meta({ - description: 'Comma-separated metrics, each at most once', - param: { example: 'roomsCreated,bytesUp' } + description: 'Metrics to compute, each at most once', + param: { example: ['roomsCreated', 'bytesUp'] } }); const dimensionsParam = csvParam(enumValues(AnalyticsDimension), { strict: true }).meta({ - description: 'Comma-separated dimensions, each at most once', - param: { example: 'sdk,os' } + description: 'Dimensions to break down, each at most once', + param: { example: ['sdk', 'os'] } }); +/** A comma-separated list that the series or breakdowns schema validates, rendered as an array of `values`. */ +function exportList(values: readonly string[], description: string) { + return z + .string() + .optional() + .meta({ description, override: { type: 'array', items: { type: 'string', enum: [...values] } } }); +} + export function analyticsSeriesQuery(now: () => Date) { return rangedQuery(now, { metrics: metricsParam, granularity: granularityParam, scope: scopeParam }); } @@ -69,14 +81,22 @@ export function analyticsExportQuery(now: () => Date) { return z .object({ - kind: z.enum(['series', 'breakdowns'], { error: 'must be series or breakdowns' }), - metrics: z.string().optional().meta({ description: 'Metrics of `kind=series`' }), - dimensions: z.string().optional().meta({ description: 'Dimensions of `kind=breakdowns`' }), - granularity: z.string().optional().meta({ description: 'Granularity of `kind=series`' }), - scope: z.string().optional(), - from: z.string().optional(), - to: z.string().optional(), - tz: z.string().optional() + kind: z + .enum(['series', 'breakdowns'], { error: 'must be series or breakdowns' }) + .meta({ description: 'Export the series or the breakdowns' }), + metrics: exportList(enumValues(AnalyticsMetric), 'Metrics of `kind=series`, as in the series endpoint'), + dimensions: exportList( + enumValues(AnalyticsDimension), + 'Dimensions of `kind=breakdowns`, as in the breakdowns endpoint' + ), + granularity: z + .string() + .optional() + .meta({ description: 'Bucket size of `kind=series`', override: { enum: [...GRANULARITIES] } }), + scope: z.string().optional().meta({ description: 'Scope, as in the series and breakdowns endpoints' }), + from: z.string().optional().meta({ description: 'Start of the range, as in the series endpoint' }), + to: z.string().optional().meta({ description: 'End of the range, as in the series endpoint' }), + tz: z.string().optional().meta({ description: 'IANA time zone, as in the series endpoint' }) }) .transform((value, context): AnalyticsExportParams => { const { kind, ...rest } = value; diff --git a/openvidu-dashboard/dashboard-ce/backend/src/api/openapi.ts b/openvidu-dashboard/dashboard-ce/backend/src/api/openapi.ts index 8c090d714..ef94c6110 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/api/openapi.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/api/openapi.ts @@ -7,8 +7,9 @@ import { type ZodOpenApiResponseObject } from 'zod-openapi'; -import { ErrorCode } from '@openvidu-dashboard/typings'; +import { type Edition, ErrorCode } from '@openvidu-dashboard/typings'; +import { REFRESH_COOKIE } from '../auth/cookies.js'; import type { RouteDefinition, RouteRegistry } from './route-registry.js'; const errorSchema = z @@ -21,82 +22,211 @@ const errorSchema = z }) .meta({ id: 'ApiError' }); +const ifNoneMatch = z.object({ + 'If-None-Match': z + .string() + .optional() + .meta({ description: 'ETag of a previous response; the server answers 304 while it still matches' }) +}); + +const DESCRIPTION = `REST API of the OpenVidu Dashboard, read from the analytics database that the OpenVidu server writes. + +**Authentication.** \`POST /api/v1/auth/login\` returns an access token valid for 15 minutes, sent as +\`Authorization: Bearer \`, and sets the \`${REFRESH_COOKIE}\` cookie, scoped to the auth endpoints; +\`POST /api/v1/auth/refresh\` exchanges that cookie for a new access token. + +**Time ranges.** \`from\` and \`to\` take an ISO 8601 instant with an offset or a relative expression (\`now\`, +\`now-m|h|d\`, \`now/d\`, \`now/M\`), resolved in the IANA time zone \`tz\` (default \`UTC\`). + +**Pagination.** Lists return \`items\`, the opaque cursors \`nextCursor\` and \`prevCursor\`, and \`total\`. Pass a +cursor back unchanged with the same filters; \`limit\` sets the page size. + +**Errors.** Every error answers \`{ error: { code, message, details? } }\` (\`ApiError\`); a \`400\` lists the invalid +parameters in \`details.issues\`. + +**Conditional requests.** A response with an \`ETag\` answers \`304\` to a matching \`If-None-Match\`. + +**Request IDs.** Every response carries \`X-Request-Id\`: the one the request sent, or a generated one. + +**Versioning.** Additive changes (new endpoints, optional parameters, response fields and enum values) stay in \`v1\`; +clients ignore unknown fields and enum values.`; + +const APACHE_LICENSE: OpenApiLicense = { name: 'Apache 2.0', identifier: 'Apache-2.0' }; + +export interface OpenApiLicense { + name: string; + identifier?: string; + url?: string; +} + +export interface OpenApiOptions { + version: string; + /** Public base path of the dashboard, ending with `/`; default `/`. */ + basePath?: string; + /** Default Apache 2.0. */ + license?: OpenApiLicense; +} + /** The object a parameter schema parses: the input of a transformed object (resolved ranges, for instance). */ function inputObject(schema: z.ZodType): z.ZodObject { return (schema instanceof z.ZodPipe ? inputObject(schema.in as z.ZodType) : schema) as z.ZodObject; } -function operation(route: RouteDefinition, locked: boolean): ZodOpenApiOperationObject { +function security(route: RouteDefinition): Record[] { + switch (route.security) { + case 'bearer': + return [{ bearerAuth: [] }]; + case 'optional-bearer': + return [{}, { bearerAuth: [] }]; + default: + if (route.refreshCookie === undefined) { + return []; + } + + return route.refreshCookie === 'required' ? [{ refreshCookie: [] }] : [{}, { refreshCookie: [] }]; + } +} + +function operation(route: RouteDefinition, edition: Edition): ZodOpenApiOperationObject { const responses: Record = {}; for (const [status, response] of Object.entries(route.responses)) { + if (response.edition !== undefined && response.edition !== edition) { + continue; + } + + const content = response.schema + ? { [response.contentType ?? 'application/json']: { schema: response.schema } } + : Number(status) >= 400 + ? { 'application/json': { schema: errorSchema } } + : undefined; + responses[status] = { description: response.description, - ...(response.schema - ? { content: { [response.contentType ?? 'application/json']: { schema: response.schema } } } - : {}), + ...(content ? { content } : {}), ...(response.headers ? { headers: response.headers } : {}) }; } - const errors: [string, string][] = [['400', 'Invalid request']]; + const errors: [string, string][] = []; - if (route.security !== 'public') { - errors.push(['401', 'Missing, invalid or expired access token']); + if (route.request?.params || route.request?.query || route.request?.body) { + errors.push(['400', 'Invalid request']); } - if (route.edition === 'pro') { - errors.push(['403', 'PRO feature on CE']); + if (route.security === 'bearer') { + errors.push(['401', 'Missing, invalid or expired access token']); + } else if (route.security === 'optional-bearer') { + errors.push(['401', 'Invalid or expired access token']); } for (const [status, description] of errors) { responses[status] ??= { description, content: { 'application/json': { schema: errorSchema } } }; } - const security = - route.security === 'bearer' - ? [{ bearerAuth: [] }] - : route.security === 'optional-bearer' - ? [{}, { bearerAuth: [] }] - : []; + const conditional = '304' in responses; return { + operationId: route.operationId, summary: route.summary, tags: route.tags, - security, - ...(locked ? { description: `PRO feature \`${route.feature ?? ''}\`: CE answers 403 PRO_FEATURE.` } : {}), - ...(route.request?.params || route.request?.query + security: security(route), + ...(route.request?.params || route.request?.query || conditional ? { requestParams: { - ...(route.request.params ? { path: inputObject(route.request.params) } : {}), - ...(route.request.query ? { query: inputObject(route.request.query) } : {}) + ...(route.request?.params ? { path: inputObject(route.request.params) } : {}), + ...(route.request?.query ? { query: inputObject(route.request.query) } : {}), + ...(conditional ? { header: ifNoneMatch } : {}) } } : {}), ...(route.request?.body ? { requestBody: { content: { 'application/json': { schema: route.request.body } } } } : {}), - responses: responses + responses + }; +} + +function licenseObject(license: OpenApiLicense): { name: string; identifier?: string; url?: string } { + return { + name: license.name, + ...(license.identifier === undefined ? {} : { identifier: license.identifier }), + ...(license.url === undefined ? {} : { url: license.url }) }; } /** OpenAPI 3.1 document of every registered route, built from the schemas that validate them. */ -export function openApiDocument(registry: RouteRegistry, version: string): ReturnType { +export function openApiDocument(registry: RouteRegistry, options: OpenApiOptions): ReturnType { const paths: ZodOpenApiPathsObject = {}; + const used = new Set(); for (const route of registry.list()) { const path = `/api/v1${route.path}`; paths[path] ??= {}; - paths[path][route.method] = operation(route, registry.isLocked(route)); + paths[path][route.method] = operation(route, registry.edition); + route.tags.forEach((tag) => used.add(tag)); } - return createDocument({ + const basePath = options.basePath ?? '/'; + const document = createDocument({ openapi: '3.1.0', - info: { title: 'OpenVidu Dashboard API', version }, - components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } } }, + info: { + title: 'OpenVidu Dashboard API', + version: options.version, + description: DESCRIPTION, + license: licenseObject(options.license ?? APACHE_LICENSE) + }, + servers: [{ url: basePath === '/' ? '/' : basePath.replace(/\/$/, '') }], + tags: registry.tags().filter((tag) => used.has(tag.name)), + components: { + securitySchemes: { + bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, + refreshCookie: { + type: 'apiKey', + in: 'cookie', + name: REFRESH_COOKIE, + description: 'Refresh token set by the login, readable only by the auth endpoints' + } + } + }, paths }); + + liftParameterDocs(document); + + return document; +} + +interface RenderedParameter { + in: string; + description?: string; + style?: string; + explode?: boolean; + schema?: { description?: unknown; type?: unknown }; +} + +/** + * zod-openapi keeps a description set before `.default()` or `.optional()` inside the parameter's schema; lift it to the + * parameter. A query parameter rendered as an array is a comma-separated list (`csvParam`). + */ +function liftParameterDocs(document: ReturnType): void { + for (const item of Object.values(document.paths ?? {})) { + for (const operation of Object.values(item as Record)) { + for (const parameter of operation.parameters ?? []) { + const description = parameter.schema?.description; + + if (parameter.description === undefined && typeof description === 'string') { + parameter.description = description; + } + + if (parameter.in === 'query' && parameter.schema?.type === 'array') { + parameter.style = 'form'; + parameter.explode = false; + } + } + } + } } const SCALAR_PAGE = ` diff --git a/openvidu-dashboard/dashboard-ce/backend/src/api/probes.ts b/openvidu-dashboard/dashboard-ce/backend/src/api/probes.ts index e6760a331..6b42a0d38 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/api/probes.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/api/probes.ts @@ -32,12 +32,14 @@ export async function readiness(db: Db): Promise<{ ready: boolean; checks: Ready /** `GET /health` (liveness, no MongoDB) and `GET /ready` (readiness). */ export function registerProbes(registry: RouteRegistry, version: string, db: Db): void { + registry.tag('Probes', 'Liveness and readiness of the dashboard, for orchestrators and load balancers'); registry.register({ method: 'get', path: '/health', + operationId: 'getHealth', edition: 'ce', summary: 'Liveness: the process answers; MongoDB is not checked', - tags: ['probes'], + tags: ['Probes'], security: 'public', responses: { 200: { description: 'Alive', schema: healthSchema } }, handler: () => ({ status: 'ok', version }) @@ -45,9 +47,10 @@ export function registerProbes(registry: RouteRegistry, version: string, db: Db) registry.register({ method: 'get', path: '/ready', + operationId: 'getReadiness', edition: 'ce', summary: 'Readiness: MongoDB answers and the writer schema is supported', - tags: ['probes'], + tags: ['Probes'], security: 'public', responses: { 200: { description: 'Ready', schema: readySchema }, diff --git a/openvidu-dashboard/dashboard-ce/backend/src/api/query.ts b/openvidu-dashboard/dashboard-ce/backend/src/api/query.ts index 662a8f2b0..c66081187 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/api/query.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/api/query.ts @@ -14,18 +14,30 @@ export const tzParam = z .default('UTC') .meta({ description: 'IANA time zone for relative snapping and buckets', examples: ['Europe/Madrid'] }); -const timeExpression = z +const TIME_EXPRESSION = 'an ISO 8601 instant with an offset, now, now-m|h|d, now/d or now/M'; +const fromExpression = z .string() - .meta({ description: 'ISO 8601 with offset, now, now-m|h|d, now/d or now/M', examples: ['now-24h'] }); + .meta({ description: `Start of the range: ${TIME_EXPRESSION}`, examples: ['now-24h'] }); +const toExpression = z.string().meta({ description: `End of the range: ${TIME_EXPRESSION}`, examples: ['now'] }); export const limitParam = z.coerce .number({ error: 'must be an integer between 1 and 100' }) .int({ error: 'must be an integer between 1 and 100' }) .min(1, { error: 'must be an integer between 1 and 100' }) .max(100, { error: 'must be an integer between 1 and 100' }) - .default(25); + .default(25) + .meta({ description: 'Page size, from 1 to 100' }); -export const orderParam = z.enum([SortOrder.ASC, SortOrder.DESC], { error: 'must be asc or desc' }); +export const orderParam = z + .enum([SortOrder.ASC, SortOrder.DESC], { error: 'must be asc or desc' }) + .meta({ description: 'Sort direction' }); + +/** Opaque keyset cursor of a previous page. */ +export const cursorParam = z + .string() + .min(1) + .optional() + .meta({ description: 'nextCursor or prevCursor of a previous page, sent with the same filters and sort' }); /** Trimmed search text; empty means no search. */ export const qParam = z @@ -46,41 +58,44 @@ export function csvParam( const allowed = new Set(values); const isAllowed = (item: string): item is T[number] => allowed.has(item); - return z.string().transform((value, context) => { - const items = value - .split(',') - .map((item) => item.trim()) - .filter((item) => item !== ''); + return z + .string() + .meta({ override: { type: 'array', items: { type: 'string', enum: [...values] } } }) + .transform((value, context) => { + const items = value + .split(',') + .map((item) => item.trim()) + .filter((item) => item !== ''); - if (!items.every(isAllowed)) { - const unknown = items.find((item) => !isAllowed(item)) ?? ''; + if (!items.every(isAllowed)) { + const unknown = items.find((item) => !isAllowed(item)) ?? ''; - context.addIssue({ - code: 'custom', - message: options.strict - ? `${unknown} is not one of ${values.join(', ')}` - : `must be a comma-separated list of ${values.join(', ')}` - }); + context.addIssue({ + code: 'custom', + message: options.strict + ? `${unknown} is not one of ${values.join(', ')}` + : `must be a comma-separated list of ${values.join(', ')}` + }); - return z.NEVER; - } + return z.NEVER; + } - if (options.strict && items.length === 0) { - context.addIssue({ code: 'custom', message: 'must list at least one value' }); + if (options.strict && items.length === 0) { + context.addIssue({ code: 'custom', message: 'must list at least one value' }); - return z.NEVER; - } + return z.NEVER; + } - const repeated = items.find((item, index) => items.indexOf(item) !== index); + const repeated = items.find((item, index) => items.indexOf(item) !== index); - if (options.strict && repeated !== undefined) { - context.addIssue({ code: 'custom', message: `lists ${repeated} more than once` }); + if (options.strict && repeated !== undefined) { + context.addIssue({ code: 'custom', message: `lists ${repeated} more than once` }); - return z.NEVER; - } + return z.NEVER; + } - return items; - }); + return items; + }); } export interface ResolvedRange { @@ -89,7 +104,7 @@ export interface ResolvedRange { tz: string; } -const rangeShape = { from: timeExpression.default(DEFAULT_FROM), to: timeExpression.default(DEFAULT_TO), tz: tzParam }; +const rangeShape = { from: fromExpression.default(DEFAULT_FROM), to: toExpression.default(DEFAULT_TO), tz: tzParam }; /** Resolves `from` and `to` at `instant`, adding an issue per invalid bound; `from` must be strictly before `to`. */ function resolveBounds( @@ -155,7 +170,7 @@ export function timeRangeQuery(now: () => Date) { * `now()` when given. */ export function optionalRangeQuery(now: () => Date, shape: S) { - const input = z.object({ from: timeExpression.optional(), to: timeExpression.optional(), tz: tzParam, ...shape }); + const input = z.object({ from: fromExpression.optional(), to: toExpression.optional(), tz: tzParam, ...shape }); type Output = Omit, 'from' | 'to'> & { from?: Date; to?: Date; tz: string }; diff --git a/openvidu-dashboard/dashboard-ce/backend/src/api/route-registry.ts b/openvidu-dashboard/dashboard-ce/backend/src/api/route-registry.ts index 7af632096..f1f88e28d 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/api/route-registry.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/api/route-registry.ts @@ -44,6 +44,8 @@ export interface RouteResponse { /** Media type of `schema`; default `application/json`. */ contentType?: string; headers?: z.ZodObject; + /** The only edition that answers it; the other editions leave it out of their OpenAPI document. */ + edition?: Edition; } export interface RouteDefinition

{ @@ -54,9 +56,14 @@ export interface RouteDefinition

{ edition: Edition; /** Feature of a PRO route, reported in `details.feature`. */ feature?: FeatureKey; + /** Unique across the registry, camelCase and verb first, e.g. `listRooms`. */ + operationId: string; summary: string; + /** Tags declared with `RouteRegistry.tag()`. */ tags: string[]; security: RouteSecurity; + /** The refresh cookie the route reads, for the OpenAPI document; authentication ignores it. */ + refreshCookie?: 'required' | 'optional'; request?: { params?: z.ZodType

; query?: z.ZodType; body?: z.ZodType }; responses: Record; /** Middlewares that run before authentication, such as a rate limiter. */ @@ -93,8 +100,25 @@ function expressPath(path: string): string { export class RouteRegistry { private readonly routes = new Map(); private readonly locks = new Set(); + private readonly tagDescriptions = new Map(); - constructor(private readonly edition: Edition) {} + constructor(readonly edition: Edition) {} + + /** Declares a tag of the OpenAPI document; routes may only use declared tags. */ + tag(name: string, description: string): void { + const current = this.tagDescriptions.get(name); + + if (current !== undefined && current !== description) { + throw new Error(`Tag ${name} is already declared with another description`); + } + + this.tagDescriptions.set(name, description); + } + + /** The declared tags, in declaration order. */ + tags(): { name: string; description: string }[] { + return [...this.tagDescriptions].map(([name, description]) => ({ name, description })); + } register(route: RouteDefinition): void { const key = routeKey(route.method, route.path); @@ -112,6 +136,7 @@ export class RouteRegistry { throw new Error(`PRO route ${key} needs a feature`); } + this.checkDocumentation(key, route as RouteDefinition); // Setting an existing key keeps its position, so a lock is replaced where it was registered. this.locks.delete(key); this.routes.set(key, route as RouteDefinition); @@ -129,6 +154,7 @@ export class RouteRegistry { this.routes.set(key, { ...lock, edition: 'pro', + operationId: '', summary: '', tags: [], security: 'bearer', @@ -144,6 +170,7 @@ export class RouteRegistry { throw new Error(`Route ${key} is not registered`); } + this.checkDocumentation(key, route as RouteDefinition); this.routes.set(key, route as RouteDefinition); } @@ -162,6 +189,20 @@ export class RouteRegistry { return route.edition === 'pro' && this.edition === 'ce'; } + private checkDocumentation(key: string, route: RouteDefinition): void { + const undeclared = route.tags.find((tag) => !this.tagDescriptions.has(tag)); + + if (undeclared !== undefined) { + throw new Error(`Route ${key} uses the undeclared tag ${undeclared}`); + } + + for (const [other, existing] of this.routes) { + if (other !== key && !this.locks.has(other) && existing.operationId === route.operationId) { + throw new Error(`Route ${key} reuses the operationId ${route.operationId} of ${other}`); + } + } + } + router(authenticate: Authenticator): Router { const router = Router(); diff --git a/openvidu-dashboard/dashboard-ce/backend/src/auth/auth-routes.ts b/openvidu-dashboard/dashboard-ce/backend/src/auth/auth-routes.ts index 92b738c97..9fc718bd1 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/auth/auth-routes.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/auth/auth-routes.ts @@ -6,7 +6,13 @@ import { ApiError } from '../http/api-error.js'; import { errorBody } from '../http/errors.js'; import type { RouteRegistry } from '../api/route-registry.js'; import type { AuthService, ClientInfo } from './auth-service.js'; -import { clearRefreshCookie, readRefreshCookie, type RefreshCookiePolicy, setRefreshCookie } from './cookies.js'; +import { + clearRefreshCookie, + readRefreshCookie, + REFRESH_COOKIE, + type RefreshCookiePolicy, + setRefreshCookie +} from './cookies.js'; export const LOGIN_WINDOW_MS = 15 * 60_000; export const LOGIN_FAILED_ATTEMPTS = 10; @@ -22,6 +28,16 @@ const tokenResponse = z .object({ accessToken: z.string(), expiresAt: z.iso.datetime() }) .meta({ id: 'AuthTokenResponse' }); +const setsRefreshCookie = z.object({ + 'Set-Cookie': z.string().meta({ + description: `${REFRESH_COOKIE}=; Path=api/v1/auth; HttpOnly; SameSite=Strict (Secure over HTTPS)` + }) +}); + +const clearsRefreshCookie = z.object({ + 'Set-Cookie': z.string().meta({ description: `${REFRESH_COOKIE}=; Max-Age=0: clears the refresh cookie` }) +}); + const meResponse = z .object({ username: z.string(), edition: z.enum(['ce', 'pro']), expiresAt: z.iso.datetime() }) .meta({ id: 'MeResponse' }); @@ -56,19 +72,30 @@ export function registerAuthRoutes( auth: () => AuthService, cookiePolicy: () => RefreshCookiePolicy ): void { + registry.tag('Auth', 'Login, token refresh and logout of the administrator'); registry.register({ method: 'post', path: '/auth/login', + operationId: 'login', edition: 'ce', summary: 'Log in with the administrator credentials', - tags: ['auth'], + tags: ['Auth'], security: 'public', before: [loginLimiter()], request: { body: credentials }, responses: { - 200: { description: 'Logged in; the refresh token is set as a cookie', schema: tokenResponse }, + 200: { + description: 'Logged in; the refresh token is set as a cookie', + schema: tokenResponse, + headers: setsRefreshCookie + }, 401: { description: 'Wrong credentials (INVALID_CREDENTIALS)' }, - 429: { description: 'Too many failed logins (RATE_LIMITED)' } + 429: { + description: 'Too many failed logins (RATE_LIMITED)', + headers: z.object({ + 'Retry-After': z.string().meta({ description: 'Seconds until the next attempt is accepted' }) + }) + } }, handler: async ({ body, req, res }) => { const session = await auth().login(body.username, body.password, client(req)); @@ -86,13 +113,22 @@ export function registerAuthRoutes( registry.register({ method: 'post', path: '/auth/refresh', + operationId: 'refreshSession', edition: 'ce', summary: 'Rotate the refresh cookie and get a new access token', - tags: ['auth'], + tags: ['Auth'], security: 'public', + refreshCookie: 'required', responses: { - 200: { description: 'New access token; the refresh cookie is rotated', schema: tokenResponse }, - 401: { description: 'Missing, unknown, expired or revoked session; the cookie is cleared' } + 200: { + description: 'New access token; the refresh cookie is rotated', + schema: tokenResponse, + headers: setsRefreshCookie + }, + 401: { + description: 'Missing, unknown, expired or revoked session; the cookie is cleared', + headers: clearsRefreshCookie + } }, handler: async ({ req, res }) => { try { @@ -112,11 +148,13 @@ export function registerAuthRoutes( registry.register({ method: 'post', path: '/auth/logout', + operationId: 'logout', edition: 'ce', summary: 'End the session of the refresh cookie', - tags: ['auth'], + tags: ['Auth'], security: 'public', - responses: { 204: { description: 'Logged out; the cookie is cleared' } }, + refreshCookie: 'optional', + responses: { 204: { description: 'Logged out; the cookie is cleared', headers: clearsRefreshCookie } }, handler: async ({ req, res }) => { await auth().logout(readRefreshCookie(req)); clearRefreshCookie(res, req, cookiePolicy()); @@ -127,9 +165,10 @@ export function registerAuthRoutes( registry.register({ method: 'get', path: '/auth/me', + operationId: 'getCurrentAdmin', edition: 'ce', summary: 'The authenticated administrator', - tags: ['auth'], + tags: ['Auth'], security: 'bearer', responses: { 200: { description: 'Current session', schema: meResponse } }, handler: ({ req }) => { diff --git a/openvidu-dashboard/dashboard-ce/backend/src/egress/egress-routes.ts b/openvidu-dashboard/dashboard-ce/backend/src/egress/egress-routes.ts index 0c0d5773f..892065165 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/egress/egress-routes.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/egress/egress-routes.ts @@ -12,9 +12,11 @@ const TAGS = ['Egress']; * `now` is read per request. */ export function registerEgressRoutes(registry: RouteRegistry, egress: () => EgressService, now: () => Date): void { + registry.tag('Egress', 'Recordings and streams'); registry.register({ method: 'get', path: '/egresses', + operationId: 'listEgresses', edition: 'ce', summary: 'Egresses started in a range', tags: TAGS, @@ -27,6 +29,7 @@ export function registerEgressRoutes(registry: RouteRegistry, egress: () => Egre registry.register({ method: 'get', path: '/egresses/summary', + operationId: 'getEgressesSummary', edition: 'ce', summary: 'Egress counts of a range', tags: TAGS, diff --git a/openvidu-dashboard/dashboard-ce/backend/src/egress/egress-schemas.ts b/openvidu-dashboard/dashboard-ce/backend/src/egress/egress-schemas.ts index 2eae23c34..318db74a7 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/egress/egress-schemas.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/egress/egress-schemas.ts @@ -2,23 +2,28 @@ import * as z from 'zod'; import { EgressSort, EgressStatus, EgressType, ENTITY_ID_PATTERNS } from '@openvidu-dashboard/typings'; -import { csvParam, limitParam, orderParam, qParam, rangedQuery } from '../api/query.js'; +import { csvParam, cursorParam, limitParam, orderParam, qParam, rangedQuery } from '../api/query.js'; import { ROOM_SID } from '../rooms/rooms-schemas.js'; import { enumValues } from '../util/enum-values.js'; export const EGRESS_ID = ENTITY_ID_PATTERNS.egress; const STATUSES = enumValues(EgressStatus); -const cursorParam = z.string().min(1).optional(); - /** Room name prefix of two characters or more; an egress id is accepted whatever its length. */ -const egressSearch = qParam.refine((value) => value === undefined || EGRESS_ID.test(value) || value.length >= 2, { - error: 'must have at least two characters' -}); +const egressSearch = qParam + .refine((value) => value === undefined || EGRESS_ID.test(value) || value.length >= 2, { + error: 'must have at least two characters' + }) + .meta({ + description: + 'Case-insensitive prefix of the room name (two characters or more), or an exact egress ID that ignores the ' + + 'range and the other filters' + }); /** `all`, or a comma-separated subset of the egress statuses. */ const statusParam = z .string() + .meta({ override: { type: 'array', items: { type: 'string', enum: ['all', ...STATUSES] }, default: ['all'] } }) .transform((value, context): 'all' | EgressStatus[] => { if (value.trim() === 'all') { return 'all'; @@ -44,17 +49,27 @@ const statusParam = z return [...new Set(items)]; }) .default('all') - .meta({ description: '`all` or a comma-separated subset of the egress statuses' }); + .meta({ description: '`all`, or the egress statuses to keep' }); export function egressesListQuery(now: () => Date) { return rangedQuery(now, { status: statusParam, - type: csvParam(enumValues(EgressType)).optional(), - room: z.string().regex(ROOM_SID, { error: 'must be a room SID (RM_…)' }).optional(), + type: csvParam(enumValues(EgressType)) + .optional() + .meta({ description: 'Egress types to keep; absent means every type' }), + room: z + .string() + .regex(ROOM_SID, { error: 'must be a room SID (RM_…)' }) + .optional() + .meta({ description: 'Egresses of this room (exact room SID)' }), q: egressSearch, sort: z .enum(enumValues(EgressSort), { error: 'must be startedAt, endedAt or duration' }) - .default(EgressSort.STARTED_AT), + .default(EgressSort.STARTED_AT) + .meta({ + description: + 'Sort key: startedAt in index order, the others ranked within the range limit of GET /api/v1/info' + }), order: orderParam.default('desc'), cursor: cursorParam, limit: limitParam diff --git a/openvidu-dashboard/dashboard-ce/backend/src/index.ts b/openvidu-dashboard/dashboard-ce/backend/src/index.ts index e9ed9d90f..594b7e535 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/index.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/index.ts @@ -11,7 +11,7 @@ export { type StartupContext, type StartupHook } from './lifecycle/startup.js'; -export { openApiDocument } from './api/openapi.js'; +export { type OpenApiLicense, type OpenApiOptions, openApiDocument } from './api/openapi.js'; export { type AuthContext, type HttpMethod, diff --git a/openvidu-dashboard/dashboard-ce/backend/src/info/info-routes.ts b/openvidu-dashboard/dashboard-ce/backend/src/info/info-routes.ts index 635f2ad3f..c68c6d4b2 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/info/info-routes.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/info/info-routes.ts @@ -72,12 +72,14 @@ const infoResponse = z /** `GET /info`: public subset without `Authorization`, full document with a valid bearer token. */ export function registerInfoRoute(registry: RouteRegistry, info: () => InfoService): void { + registry.tag('Info', 'Edition, versions, writer schema, retention, storage, features and limits'); registry.register({ method: 'get', path: '/info', + operationId: 'getInfo', edition: 'ce', summary: 'Edition, versions, writer schema, migration, retention, storage and features', - tags: ['info'], + tags: ['Info'], security: 'optional-bearer', responses: { 200: { description: 'Info document', schema: infoResponse } }, handler: ({ req }) => (req.auth === undefined ? info().publicInfo() : info().fullInfo()) diff --git a/openvidu-dashboard/dashboard-ce/backend/src/ingress/ingress-responses.ts b/openvidu-dashboard/dashboard-ce/backend/src/ingress/ingress-responses.ts index d4680e760..a5a46f906 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/ingress/ingress-responses.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/ingress/ingress-responses.ts @@ -34,7 +34,7 @@ export const ingressSummary = z .object({ publishingNow: count, ingressesInRange: count, - byInputType: z.record(inputType, count), + byInputType: z.object({ rtmp: count, whip: count, url: count }), sessionsInRange: count, totalDurationMs: z.number(), approximate: z.boolean() diff --git a/openvidu-dashboard/dashboard-ce/backend/src/ingress/ingress-routes.ts b/openvidu-dashboard/dashboard-ce/backend/src/ingress/ingress-routes.ts index 234392afb..786ac5c86 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/ingress/ingress-routes.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/ingress/ingress-routes.ts @@ -12,9 +12,11 @@ const TAGS = ['Ingress']; * its events). `now` is read per request. */ export function registerIngressRoutes(registry: RouteRegistry, ingress: () => IngressService, now: () => Date): void { + registry.tag('Ingress', 'Ingresses and their publishing sessions'); registry.register({ method: 'get', path: '/ingresses', + operationId: 'listIngresses', edition: 'ce', summary: 'Ingresses created in a range', tags: TAGS, @@ -27,6 +29,7 @@ export function registerIngressRoutes(registry: RouteRegistry, ingress: () => In registry.register({ method: 'get', path: '/ingresses/summary', + operationId: 'getIngressesSummary', edition: 'ce', summary: 'Ingress counts of a range', tags: TAGS, diff --git a/openvidu-dashboard/dashboard-ce/backend/src/ingress/ingress-schemas.ts b/openvidu-dashboard/dashboard-ce/backend/src/ingress/ingress-schemas.ts index 9915d2d1d..89106e368 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/ingress/ingress-schemas.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/ingress/ingress-schemas.ts @@ -2,17 +2,21 @@ import * as z from 'zod'; import { ENTITY_ID_PATTERNS, IngressInputType, IngressSort, IngressStatus } from '@openvidu-dashboard/typings'; -import { limitParam, orderParam, qParam, rangedQuery } from '../api/query.js'; +import { cursorParam, limitParam, orderParam, qParam, rangedQuery } from '../api/query.js'; import { enumValues } from '../util/enum-values.js'; export const INGRESS_ID = ENTITY_ID_PATTERNS.ingress; -const cursorParam = z.string().min(1).optional(); - /** Name prefix of two characters or more; an ingress id is accepted whatever its length. */ -const ingressSearch = qParam.refine((value) => value === undefined || INGRESS_ID.test(value) || value.length >= 2, { - error: 'must have at least two characters' -}); +const ingressSearch = qParam + .refine((value) => value === undefined || INGRESS_ID.test(value) || value.length >= 2, { + error: 'must have at least two characters' + }) + .meta({ + description: + 'Case-insensitive prefix of the ingress name (two characters or more), or an exact ingress ID that ignores ' + + 'the range and the other filters' + }); export function ingressesListQuery(now: () => Date) { return rangedQuery(now, { @@ -20,14 +24,17 @@ export function ingressesListQuery(now: () => Date) { .enum(['all', ...enumValues(IngressStatus)], { error: `must be all or one of ${enumValues(IngressStatus).join(', ')}` }) - .default('all'), + .default('all') + .meta({ description: 'Ingresses in this status, or all' }), inputType: z .enum(enumValues(IngressInputType), { error: `must be one of ${enumValues(IngressInputType).join(', ')}` }) - .optional(), + .optional() + .meta({ description: 'Ingresses of this input type' }), q: ingressSearch, sort: z .enum(enumValues(IngressSort), { error: 'must be createdAt, totalDuration, sessions or lastSession' }) - .default(IngressSort.CREATED_AT), + .default(IngressSort.CREATED_AT) + .meta({ description: 'Sort key' }), order: orderParam.default('desc'), cursor: cursorParam, limit: limitParam diff --git a/openvidu-dashboard/dashboard-ce/backend/src/lifecycle/startup.ts b/openvidu-dashboard/dashboard-ce/backend/src/lifecycle/startup.ts index d170e7ffe..02a751e45 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/lifecycle/startup.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/lifecycle/startup.ts @@ -5,7 +5,7 @@ import { type Db, MongoClient } from 'mongodb'; import { type Edition, type InfoSchema, WriterSchemaStatus } from '@openvidu-dashboard/typings'; -import { docsRouter, openApiDocument } from '../api/openapi.js'; +import { docsRouter, type OpenApiLicense, openApiDocument } from '../api/openapi.js'; import { type RouteRegistry } from '../api/route-registry.js'; import { type AuthService } from '../auth/auth-service.js'; import { bearerAuthenticator } from '../auth/bearer.js'; @@ -53,6 +53,8 @@ export interface DashboardOptions { exit?: Exit; /** Installs SIGTERM and SIGINT handlers (default true). */ handleSignals?: boolean; + /** License of the API in the OpenAPI document; default Apache 2.0. */ + apiLicense?: OpenApiLicense; } export interface RunningDashboard { @@ -217,7 +219,17 @@ export async function startDashboard(options: DashboardOptions): Promise container.get(TOKENS.AuthService))), - ...(docsEnabled(env) ? { docsRouter: docsRouter(() => openApiDocument(registry, options.version)) } : {}) + ...(docsEnabled(env) + ? { + docsRouter: docsRouter(() => + openApiDocument(registry, { + version: options.version, + basePath: container.get(TOKENS.BasePath), + ...(options.apiLicense ? { license: options.apiLicense } : {}) + }) + ) + } + : {}) }); return new Promise((resolve) => { diff --git a/openvidu-dashboard/dashboard-ce/backend/src/overview/overview-routes.ts b/openvidu-dashboard/dashboard-ce/backend/src/overview/overview-routes.ts index b913eb427..99ca9e26e 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/overview/overview-routes.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/overview/overview-routes.ts @@ -14,6 +14,7 @@ export function overviewRoute( return { method: 'get', path: '/overview', + operationId: 'getOverview', edition: 'ce', summary: 'Live counters, today and recent rooms', tags: ['Overview'], @@ -47,5 +48,6 @@ export function overviewRoute( } export function registerOverviewRoutes(registry: RouteRegistry, overview: () => OverviewService): void { + registry.tag('Overview', "Live counters, today's totals and the most recent rooms"); registry.register(overviewRoute(overview, overviewResponse)); } diff --git a/openvidu-dashboard/dashboard-ce/backend/src/participants/participants-routes.ts b/openvidu-dashboard/dashboard-ce/backend/src/participants/participants-routes.ts index 0f784f8ff..f9589d49d 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/participants/participants-routes.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/participants/participants-routes.ts @@ -16,9 +16,12 @@ export function registerParticipantsRoutes( participants: () => ParticipantsService, now: () => Date ): void { + registry.tag('Participants', 'Participant sessions of every kind'); + registry.tag('Agents', 'Sessions of AI agents'); registry.register({ method: 'get', path: '/participants', + operationId: 'listParticipants', edition: 'ce', summary: 'Search participant sessions', tags: TAGS, @@ -30,6 +33,7 @@ export function registerParticipantsRoutes( registry.register({ method: 'get', path: '/participants/summary', + operationId: 'getParticipantsSummary', edition: 'ce', summary: 'Participant and service counts of a range', tags: TAGS, @@ -41,6 +45,7 @@ export function registerParticipantsRoutes( registry.register({ method: 'get', path: '/agents', + operationId: 'listAgents', edition: 'ce', summary: 'List agent sessions', tags: ['Agents'], diff --git a/openvidu-dashboard/dashboard-ce/backend/src/participants/participants-schemas.ts b/openvidu-dashboard/dashboard-ce/backend/src/participants/participants-schemas.ts index 487238baa..f5454dc38 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/participants/participants-schemas.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/participants/participants-schemas.ts @@ -2,26 +2,40 @@ import * as z from 'zod'; import { ENTITY_ID_PATTERNS, ParticipantKind, ParticipantSort, ParticipantStatus } from '@openvidu-dashboard/typings'; -import { csvParam, limitParam, orderParam, qParam, rangedQuery } from '../api/query.js'; +import { csvParam, cursorParam, limitParam, orderParam, qParam, rangedQuery } from '../api/query.js'; import { ROOM_SID } from '../rooms/rooms-schemas.js'; import { enumValues } from '../util/enum-values.js'; export const PARTICIPANT_SID = ENTITY_ID_PATTERNS.participant; /** Identity prefix of two characters or more; a participant SID is accepted whatever its length. */ -const participantSearch = qParam.refine( - (value) => value === undefined || PARTICIPANT_SID.test(value) || value.length >= 2, - { error: 'must have at least two characters' } -); - -const cursorParam = z.string().min(1).optional(); +const participantSearch = qParam + .refine((value) => value === undefined || PARTICIPANT_SID.test(value) || value.length >= 2, { + error: 'must have at least two characters' + }) + .meta({ + description: + 'Case-insensitive prefix of the identity (two characters or more), or an exact participant SID that ' + + 'ignores the range and the other filters' + }); const listShape = { - status: z.enum(enumValues(ParticipantStatus), { error: 'must be joining, active, left or aborted' }).optional(), - room: z.string().regex(ROOM_SID, { error: 'must be a room SID (RM_…)' }).optional(), + status: z + .enum(enumValues(ParticipantStatus), { error: 'must be joining, active, left or aborted' }) + .optional() + .meta({ description: 'Sessions in this status; absent means every status' }), + room: z + .string() + .regex(ROOM_SID, { error: 'must be a room SID (RM_…)' }) + .optional() + .meta({ description: 'Sessions of this room (exact room SID)' }), sort: z .enum(enumValues(ParticipantSort), { error: 'must be joinedAt, leftAt or duration' }) - .default(ParticipantSort.JOINED_AT), + .default(ParticipantSort.JOINED_AT) + .meta({ + description: + 'Sort key: joinedAt in index order, the others ranked within the range limit of GET /api/v1/info' + }), order: orderParam.default('desc'), cursor: cursorParam, limit: limitParam @@ -31,7 +45,9 @@ export function participantsListQuery(now: () => Date) { return rangedQuery(now, { ...listShape, q: participantSearch, - kind: csvParam(enumValues(ParticipantKind)).optional(), + kind: csvParam(enumValues(ParticipantKind)) + .optional() + .meta({ description: 'Participant kinds to keep; absent means every kind' }), publisher: z .literal('true', { error: 'must be true' }) .transform(() => true as const) diff --git a/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-routes.ts b/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-routes.ts index aec075ee4..a4149f547 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-routes.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-routes.ts @@ -12,9 +12,11 @@ const TAGS = ['Rooms']; * and series). `now` is read per request. */ export function registerRoomsRoutes(registry: RouteRegistry, rooms: () => RoomsService, now: () => Date): void { + registry.tag('Rooms', 'Room sessions'); registry.register({ method: 'get', path: '/rooms', + operationId: 'listRooms', edition: 'ce', summary: 'List room sessions', tags: TAGS, @@ -27,6 +29,7 @@ export function registerRoomsRoutes(registry: RouteRegistry, rooms: () => RoomsS registry.register({ method: 'get', path: '/rooms/summary', + operationId: 'getRoomsSummary', edition: 'ce', summary: 'Room and participant counts of a range', tags: TAGS, diff --git a/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-schemas.ts b/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-schemas.ts index d4cbeeb0d..fd903da59 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-schemas.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-schemas.ts @@ -2,29 +2,40 @@ import * as z from 'zod'; import { ENTITY_ID_PATTERNS, RoomFeature, RoomSort, RoomStatusFilter } from '@openvidu-dashboard/typings'; -import { csvParam, limitParam, orderParam, qParam, rangedQuery } from '../api/query.js'; +import { csvParam, cursorParam, limitParam, orderParam, qParam, rangedQuery } from '../api/query.js'; import { enumValues } from '../util/enum-values.js'; export const ROOM_SID = ENTITY_ID_PATTERNS.room; /** Search text of two characters or more; a room SID is accepted whatever its length. */ -const roomSearch = qParam.refine((value) => value === undefined || ROOM_SID.test(value) || value.length >= 2, { - error: 'must have at least two characters' -}); - -const cursorParam = z.string().min(1).optional(); +const roomSearch = qParam + .refine((value) => value === undefined || ROOM_SID.test(value) || value.length >= 2, { + error: 'must have at least two characters' + }) + .meta({ + description: + 'Case-insensitive prefix of the room name (two characters or more), or an exact room SID that ignores the ' + + 'range and the other filters' + }); export function roomsListQuery(now: () => Date) { return rangedQuery(now, { status: z .enum(enumValues(RoomStatusFilter), { error: 'must be active, closed or all' }) - .default(RoomStatusFilter.ALL), + .default(RoomStatusFilter.ALL) + .meta({ description: 'Rooms active now, closed, or both' }), q: roomSearch, - features: csvParam(enumValues(RoomFeature)).optional(), - nodeId: z.string().min(1).max(128).optional(), + features: csvParam(enumValues(RoomFeature)) + .optional() + .meta({ description: 'Rooms that used any of these features' }), + nodeId: z.string().min(1).max(128).optional().meta({ description: 'Rooms hosted by this node' }), sort: z .enum(enumValues(RoomSort), { error: 'must be createdAt, endedAt, duration, uniqueIdentities or active' }) - .default(RoomSort.CREATED_AT), + .default(RoomSort.CREATED_AT) + .meta({ + description: + 'Sort key: createdAt in index order, the others ranked within the range limit of GET /api/v1/info' + }), order: orderParam.default('desc'), cursor: cursorParam, limit: limitParam diff --git a/openvidu-dashboard/dashboard-ce/backend/src/status/status-routes.ts b/openvidu-dashboard/dashboard-ce/backend/src/status/status-routes.ts index bfdd8007a..63cb21626 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/status/status-routes.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/status/status-routes.ts @@ -27,9 +27,11 @@ export const statusResponse = z /** `GET /status`: what every page polls to follow the server, whatever its auto-refresh. */ export function registerStatusRoutes(registry: RouteRegistry, status: () => StatusService): void { + registry.tag('Status', 'Writer schema status, legacy migration progress and writer health'); registry.register({ method: 'get', path: '/status', + operationId: 'getStatus', edition: 'ce', summary: 'Writer schema status, migration progress and writer health', tags: ['Status'], diff --git a/openvidu-dashboard/dashboard-ce/backend/test/integration/analytics-api.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/integration/analytics-api.test.ts index dabf92ade..25a11e3f4 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/integration/analytics-api.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/integration/analytics-api.test.ts @@ -279,7 +279,7 @@ describe.skipIf(mongoUri === '')('analytics API', () => { it('documents the CE analytics routes, and none of the locked ones', () => { const registry = ce.dashboard.running.container.get(TOKENS.RouteRegistry); - const paths = Object.keys(openApiDocument(registry, 'test').paths ?? {}); + const paths = Object.keys(openApiDocument(registry, { version: 'test' }).paths ?? {}); expect(paths).toEqual( expect.arrayContaining([ diff --git a/openvidu-dashboard/dashboard-ce/backend/test/integration/egress-api.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/integration/egress-api.test.ts index a5e73e15d..dfb99fed7 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/integration/egress-api.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/integration/egress-api.test.ts @@ -277,7 +277,7 @@ describe.skipIf(mongoUri === '')('egress API', () => { it('documents the list and the summary, and none of the locked routes', () => { const registry = ce.dashboard.running.container.get(TOKENS.RouteRegistry); - const paths = Object.keys(openApiDocument(registry, 'test').paths ?? {}); + const paths = Object.keys(openApiDocument(registry, { version: 'test' }).paths ?? {}); expect(paths).toEqual(expect.arrayContaining(['/api/v1/egresses', '/api/v1/egresses/summary'])); expect(paths.filter((path) => path.startsWith('/api/v1/egresses/{id}'))).toEqual([]); diff --git a/openvidu-dashboard/dashboard-ce/backend/test/integration/ingress-api.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/integration/ingress-api.test.ts index 8fe554889..859774e1b 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/integration/ingress-api.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/integration/ingress-api.test.ts @@ -266,7 +266,7 @@ describe.skipIf(mongoUri === '')('ingress API', () => { it('documents the list and the summary, and none of the locked routes', () => { const registry = ce.dashboard.running.container.get(TOKENS.RouteRegistry); - const paths = Object.keys(openApiDocument(registry, 'test').paths ?? {}); + const paths = Object.keys(openApiDocument(registry, { version: 'test' }).paths ?? {}); expect(paths).toEqual(expect.arrayContaining(['/api/v1/ingresses', '/api/v1/ingresses/summary'])); expect(paths.filter((path) => path.startsWith('/api/v1/ingresses/{id}'))).toEqual([]); diff --git a/openvidu-dashboard/dashboard-ce/backend/test/integration/openapi-document.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/integration/openapi-document.test.ts new file mode 100644 index 000000000..d6aee1203 --- /dev/null +++ b/openvidu-dashboard/dashboard-ce/backend/test/integration/openapi-document.test.ts @@ -0,0 +1,75 @@ +import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest'; + +import { ParticipantKind } from '@openvidu-dashboard/typings'; + +import { bootClient, type Client, json } from '../support/api-client.js'; +import { type CheckedDocument, documentationIssues } from '../support/openapi-checks.js'; + +const mongoUri = inject('mongoUri'); + +/** The operations that may answer `403`: PRO metrics, dimensions and node scopes on CE. */ +const CE_403 = ['getAnalyticsSeries', 'getAnalyticsBreakdowns', 'getAnalyticsSummary', 'exportAnalyticsCsv']; + +interface Operation { + security: Record[]; + parameters?: { name: string; in: string; style?: string; explode?: boolean; schema: unknown }[]; + responses: Record }>; +} + +describe.skipIf(mongoUri === '')('OpenAPI document', () => { + let ce: Client; + let document: CheckedDocument & { + servers: { url: string }[]; + paths: Record>; + components: { securitySchemes: Record }; + }; + + beforeAll(async () => { + ce = await bootClient(mongoUri, { NODE_ENV: 'development' }); + document = await json(ce, '/docs/openapi.json'); + }); + + afterAll(async () => { + await ce.dashboard.stop(); + }); + + it('documents every operation by the api-conventions rules', () => { + expect(documentationIssues(document, CE_403)).toEqual([]); + expect(document.servers).toEqual([{ url: '/' }]); + }); + + it('documents the refresh cookie of the auth routes', () => { + const refresh = document.paths['/api/v1/auth/refresh']?.post; + const logout = document.paths['/api/v1/auth/logout']?.post; + + expect(document.components.securitySchemes.refreshCookie).toMatchObject({ + type: 'apiKey', + in: 'cookie', + name: 'ov_dashboard_refresh' + }); + expect(refresh?.security).toEqual([{ refreshCookie: [] }]); + expect(logout?.security).toEqual([{}, { refreshCookie: [] }]); + expect(Object.keys(refresh?.responses['200']?.headers ?? {})).toEqual(['Set-Cookie']); + expect(Object.keys(refresh?.responses['401']?.headers ?? {})).toEqual(['Set-Cookie']); + }); + + it('documents list parameters as arrays and conditional requests', () => { + const kind = document.paths['/api/v1/participants']?.get?.parameters?.find( + (parameter) => parameter.name === 'kind' + ); + const overview = document.paths['/api/v1/overview']?.get; + + expect(kind).toMatchObject({ + style: 'form', + explode: false, + schema: { + type: 'array', + items: { type: 'string', enum: Object.values(ParticipantKind) } + } + }); + expect(overview?.parameters?.map((parameter) => `${parameter.in}:${parameter.name}`)).toContain( + 'header:If-None-Match' + ); + expect(Object.keys(document.paths['/api/v1/health']?.get?.responses ?? {})).toEqual(['200']); + }); +}); diff --git a/openvidu-dashboard/dashboard-ce/backend/test/integration/overview-api.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/integration/overview-api.test.ts index 3992ea4f8..b160f56a9 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/integration/overview-api.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/integration/overview-api.test.ts @@ -264,10 +264,9 @@ describe.skipIf(mongoUri === '')('overview API', () => { }); it('documents the overview without nodes', () => { - const document = openApiDocument( - ce.dashboard.running.container.get(TOKENS.RouteRegistry), - 'test' - ); + const document = openApiDocument(ce.dashboard.running.container.get(TOKENS.RouteRegistry), { + version: 'test' + }); const schema = document.components?.schemas?.OverviewResponse as { properties?: object } | undefined; expect(Object.keys(schema?.properties ?? {})).not.toContain('nodes'); diff --git a/openvidu-dashboard/dashboard-ce/backend/test/integration/participants-api.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/integration/participants-api.test.ts index e95fa3574..33ed55d02 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/integration/participants-api.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/integration/participants-api.test.ts @@ -313,7 +313,7 @@ describe.skipIf(mongoUri === '')('participants API', () => { it('documents the lists and the summary, and none of the locked routes', () => { const registry = ce.dashboard.running.container.get(TOKENS.RouteRegistry); - const paths = Object.keys(openApiDocument(registry, 'test').paths ?? {}); + const paths = Object.keys(openApiDocument(registry, { version: 'test' }).paths ?? {}); expect(paths).toEqual( expect.arrayContaining(['/api/v1/participants', '/api/v1/participants/summary', '/api/v1/agents']) diff --git a/openvidu-dashboard/dashboard-ce/backend/test/integration/rooms-api.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/integration/rooms-api.test.ts index ba02eadc7..b77f9e219 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/integration/rooms-api.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/integration/rooms-api.test.ts @@ -306,7 +306,7 @@ describe.skipIf(mongoUri === '')('rooms API', () => { it('documents the list and the summary, and none of the locked routes', () => { const registry = ce.dashboard.running.container.get(TOKENS.RouteRegistry); - const paths = Object.keys(openApiDocument(registry, 'test').paths ?? {}); + const paths = Object.keys(openApiDocument(registry, { version: 'test' }).paths ?? {}); expect(paths).toEqual(expect.arrayContaining(['/api/v1/rooms', '/api/v1/rooms/summary'])); expect(paths.filter((path) => path.startsWith('/api/v1/rooms/{sid}'))).toEqual([]); diff --git a/openvidu-dashboard/dashboard-ce/backend/test/integration/status-api.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/integration/status-api.test.ts index 9c8aa4501..50b21ebaa 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/integration/status-api.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/integration/status-api.test.ts @@ -113,10 +113,9 @@ describe.skipIf(mongoUri === '')('status API', () => { }); it('documents the route', () => { - const document = openApiDocument( - ce.dashboard.running.container.get(TOKENS.RouteRegistry), - 'test' - ); + const document = openApiDocument(ce.dashboard.running.container.get(TOKENS.RouteRegistry), { + version: 'test' + }); expect(Object.keys(document.paths ?? {})).toContain('/api/v1/status'); expect(Object.keys(document.components?.schemas ?? {})).toContain('StatusResponse'); diff --git a/openvidu-dashboard/dashboard-ce/backend/test/support/index.ts b/openvidu-dashboard/dashboard-ce/backend/test/support/index.ts index b186449bc..cb126efe5 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/support/index.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/support/index.ts @@ -8,6 +8,7 @@ export { loadFixture, seedFixtures } from './fixtures.js'; export { freePort } from './free-port.js'; export { expectWithinBudgets } from './latency-budget.js'; export { type MongoHandle, startMongo } from './mongo-container.js'; +export { type CheckedDocument, type CheckedOperation, documentationIssues } from './openapi-checks.js'; export { type ExplainSummary, expectIdLookup, diff --git a/openvidu-dashboard/dashboard-ce/backend/test/support/openapi-checks.ts b/openvidu-dashboard/dashboard-ce/backend/test/support/openapi-checks.ts new file mode 100644 index 000000000..6e8d14ecb --- /dev/null +++ b/openvidu-dashboard/dashboard-ce/backend/test/support/openapi-checks.ts @@ -0,0 +1,120 @@ +/** The parts of an OpenAPI document that `documentationIssues` checks. */ +export interface CheckedDocument { + info: { description?: string; license?: { name?: string } }; + servers?: { url: string }[]; + tags?: { name: string; description?: string }[]; + paths: Record>; +} + +export interface CheckedOperation { + operationId?: string; + tags?: string[]; + parameters?: { + name: string; + in: string; + description?: string; + style?: string; + explode?: boolean; + schema?: { type?: string }; + }[]; + requestBody?: unknown; + responses: Record }>; +} + +/** + * Every break of the `api-conventions` rules of the OpenAPI document, one line each: metadata, unique operation IDs, + * declared tags, described parameters, list parameters as `form` arrays, `ApiError` bodies, `400` only with input, + * `If-None-Match` with `304`, and `403` only on the operations in `allowed403`. + */ +export function documentationIssues(document: CheckedDocument, allowed403: readonly string[] = []): string[] { + const issues: string[] = []; + const declared = new Map((document.tags ?? []).map((tag) => [tag.name, tag.description])); + const usedTags = new Set(); + const operationIds = new Set(); + + if (!document.info.description) { + issues.push('info has no description'); + } + + if (!document.info.license?.name) { + issues.push('info has no license'); + } + + if (document.servers?.length !== 1) { + issues.push('the document needs exactly one server'); + } + + for (const [path, item] of Object.entries(document.paths)) { + for (const [method, operation] of Object.entries(item)) { + const name = `${method.toUpperCase()} ${path}`; + const id = operation.operationId; + + if (id === undefined || id === '') { + issues.push(`${name} has no operationId`); + } else if (operationIds.has(id)) { + issues.push(`${name} repeats the operationId ${id}`); + } else { + operationIds.add(id); + } + + if ((operation.tags ?? []).length === 0) { + issues.push(`${name} has no tags`); + } + + for (const tag of operation.tags ?? []) { + usedTags.add(tag); + + if (!declared.get(tag)) { + issues.push(`${name} uses the tag ${tag}, not declared with a description`); + } + } + + const parameters = operation.parameters ?? []; + + for (const parameter of parameters) { + if (!parameter.description) { + issues.push(`${name} ${parameter.in} parameter ${parameter.name} has no description`); + } + + if ( + parameter.in === 'query' && + parameter.schema?.type === 'array' && + (parameter.style !== 'form' || parameter.explode !== false) + ) { + issues.push(`${name} list parameter ${parameter.name} is not a form array without explode`); + } + } + + const hasInput = parameters.some((parameter) => parameter.in !== 'header') || operation.requestBody; + + for (const [status, response] of Object.entries(operation.responses)) { + if (Number(status) >= 400 && response.content?.['application/json']?.schema?.$ref === undefined) { + issues.push(`${name} ${status} has no ApiError body`); + } + } + + if ('400' in operation.responses && !hasInput) { + issues.push(`${name} declares 400 without any input`); + } + + if ('403' in operation.responses && (id === undefined || !allowed403.includes(id))) { + issues.push(`${name} declares 403`); + } + + if ( + '304' in operation.responses && + !parameters.some((parameter) => parameter.in === 'header' && parameter.name === 'If-None-Match') + ) { + issues.push(`${name} answers 304 without the If-None-Match parameter`); + } + } + } + + for (const tag of declared.keys()) { + if (!usedTags.has(tag)) { + issues.push(`tag ${tag} has no operation`); + } + } + + return issues; +} diff --git a/openvidu-dashboard/dashboard-ce/backend/test/unit/route-registry.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/unit/route-registry.test.ts index 7a87fa80d..1566767a6 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/unit/route-registry.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/unit/route-registry.test.ts @@ -7,6 +7,7 @@ import type { ApiError as ApiErrorBody } from '@openvidu-dashboard/typings'; import * as z from 'zod'; import { docsRouter, openApiDocument } from '../../src/api/openapi.js'; +import { csvParam, limitParam } from '../../src/api/query.js'; import { type Authenticator, type RouteDefinition, RouteRegistry } from '../../src/api/route-registry.js'; import { ApiError } from '../../src/http/api-error.js'; import { createHttpApp } from '../../src/http/app.js'; @@ -32,12 +33,14 @@ const authenticate: Authenticator = (req, required) => { function registry(edition: 'ce' | 'pro') { const routes = new RouteRegistry(edition); + routes.tag('Rooms', 'Room sessions'); routes.register({ method: 'get', path: '/rooms', edition: 'ce', + operationId: 'listRooms', summary: 'List rooms', - tags: ['rooms'], + tags: ['Rooms'], security: 'bearer', request: { query: z.object({ limit: z.coerce.number().int().max(100).default(25) }) }, responses: { 200: { description: 'Rooms', schema: z.object({ limit: z.number() }) } }, @@ -48,8 +51,9 @@ function registry(edition: 'ce' | 'pro') { path: '/rooms/{sid}', edition: 'pro', feature: 'room-detail', + operationId: 'getRoom', summary: 'Room detail', - tags: ['rooms'], + tags: ['Rooms'], security: 'bearer', request: { params: z.object({ sid: z.string() }) }, responses: { 200: { description: 'Room' } } @@ -62,8 +66,9 @@ const SUMMARY: RouteDefinition = { method: 'get', path: '/rooms/summary', edition: 'ce', + operationId: 'getRoomsSummary', summary: 'Rooms summary', - tags: ['rooms'], + tags: ['Rooms'], security: 'bearer', responses: { 200: { description: 'Summary' } }, handler: () => ({ summary: true }) @@ -74,8 +79,9 @@ const ROOM_DETAIL: RouteDefinition = { path: '/rooms/{sid}', edition: 'pro', feature: 'room-detail', + operationId: 'getRoom', summary: 'Room detail', - tags: ['rooms'], + tags: ['Rooms'], security: 'bearer', responses: { 200: { description: 'Room' } } }; @@ -84,9 +90,15 @@ const ROOM_DETAIL: RouteDefinition = { function locked(edition: 'ce' | 'pro') { const routes = new RouteRegistry(edition); + routes.tag('Rooms', 'Room sessions'); routes.register(SUMMARY); routes.lock({ method: 'get', path: '/rooms/{sid}', feature: 'room-detail' }); - routes.register({ ...SUMMARY, path: '/rooms/{sid}/events', handler: () => ({ events: [] }) }); + routes.register({ + ...SUMMARY, + path: '/rooms/{sid}/events', + operationId: 'listRoomEvents', + handler: () => ({ events: [] }) + }); return routes; } @@ -97,7 +109,7 @@ function app(routes: RouteRegistry, docs: boolean) { appRoot: '/nonexistent', trustProxy: false, apiRouter: routes.router(authenticate), - ...(docs ? { docsRouter: docsRouter(() => openApiDocument(routes, '3.10.0')) } : {}) + ...(docs ? { docsRouter: docsRouter(() => openApiDocument(routes, { version: '3.10.0' })) } : {}) }); } @@ -138,6 +150,7 @@ describe('route registry', () => { method: 'get', path: '/rooms', edition: 'ce', + operationId: 'listRoomsAgain', summary: '', tags: [], security: 'public', @@ -149,6 +162,7 @@ describe('route registry', () => { method: 'get', path: '/participants/{sid}', edition: 'pro', + operationId: 'getParticipant', summary: '', tags: [], security: 'bearer', @@ -244,7 +258,7 @@ describe('route registry', () => { (await request(app(routes, false)).get('/api/v1/rooms/summary').set('Authorization', 'Bearer good')).body ).toEqual({ summary: 'extended' }); expect(() => { - routes.replace({ ...SUMMARY, path: '/rooms/unknown' }); + routes.replace({ ...SUMMARY, path: '/rooms/unknown', operationId: 'getUnknown' }); }).toThrow('not registered'); expect(() => { routes.replace(ROOM_DETAIL); @@ -262,3 +276,151 @@ describe('route registry', () => { } }); }); + +describe('OpenAPI document', () => { + interface Operation { + operationId: string; + tags: string[]; + security: Record[]; + parameters?: { + name: string; + in: string; + description?: string; + style?: string; + explode?: boolean; + schema: { type?: string; items?: { enum?: string[] } }; + }[]; + responses: Record; headers?: object }>; + } + interface Document { + info: { description: string; license: { name: string; identifier?: string } }; + servers: { url: string }[]; + tags: { name: string; description: string }[]; + paths: Record>; + components: { securitySchemes: Record }; + } + + function documentOf(routes: RouteRegistry, options: Partial[1]> = {}): Document { + return openApiDocument(routes, { version: '3.10.0', ...options }) as unknown as Document; + } + + it('refuses an undeclared tag and a repeated operationId', () => { + const routes = registry('ce'); + + expect(() => { + routes.register({ ...SUMMARY, tags: ['Unknown'] }); + }).toThrow('undeclared tag Unknown'); + expect(() => { + routes.register({ ...SUMMARY, operationId: 'listRooms' }); + }).toThrow('reuses the operationId listRooms'); + expect(() => { + routes.tag('Rooms', 'Another description'); + }).toThrow('already declared'); + }); + + it('carries the conventions, the license, the base path and the used tags', () => { + const routes = registry('pro'); + + routes.tag('Unused', 'No operation uses it'); + + const document = documentOf(routes, { basePath: '/dashboard/' }); + + expect(document.servers).toEqual([{ url: '/dashboard' }]); + expect(document.info.license).toEqual({ name: 'Apache 2.0', identifier: 'Apache-2.0' }); + expect(document.info.description).toContain('If-None-Match'); + expect(document.tags).toEqual([{ name: 'Rooms', description: 'Room sessions' }]); + expect(documentOf(routes).servers).toEqual([{ url: '/' }]); + expect(documentOf(routes, { license: { name: 'Commercial' } }).info.license).toEqual({ name: 'Commercial' }); + }); + + it('declares the errors each operation can answer, with the ApiError body', () => { + const routes = registry('pro'); + + routes.register({ + ...SUMMARY, + responses: { + 200: { description: 'Summary' }, + 403: { description: 'Locked on CE', edition: 'ce' }, + 429: { description: 'Too many requests' } + } + }); + + const document = documentOf(routes); + const list = document.paths['/api/v1/rooms']?.get; + const detail = document.paths['/api/v1/rooms/{sid}']?.get; + const summary = document.paths['/api/v1/rooms/summary']?.get; + + expect(list?.operationId).toBe('listRooms'); + expect(Object.keys(list?.responses ?? {})).toEqual(['200', '400', '401']); + expect(Object.keys(detail?.responses ?? {})).toEqual(['200', '400', '401']); + expect(Object.keys(summary?.responses ?? {})).toEqual(['200', '401', '429']); + expect(summary?.responses['429']?.content?.['application/json']?.schema.$ref).toBe( + '#/components/schemas/ApiError' + ); + expect( + Object.keys(documentOf(routesWithCeError('ce')).paths['/api/v1/rooms/summary']?.get?.responses ?? {}) + ).toContain('403'); + }); + + it('declares If-None-Match on conditional operations and the refresh cookie of the auth routes', () => { + const routes = registry('ce'); + + routes.register({ + ...SUMMARY, + security: 'public', + refreshCookie: 'optional', + responses: { 200: { description: 'Summary' }, 304: { description: 'Unchanged' } } + }); + + const document = documentOf(routes); + const summary = document.paths['/api/v1/rooms/summary']?.get; + + expect(summary?.parameters?.map((parameter) => `${parameter.in}:${parameter.name}`)).toEqual([ + 'header:If-None-Match' + ]); + expect(summary?.security).toEqual([{}, { refreshCookie: [] }]); + expect(document.components.securitySchemes.refreshCookie).toMatchObject({ + in: 'cookie', + name: 'ov_dashboard_refresh' + }); + }); + + it('describes every parameter and renders comma-separated lists as arrays', () => { + const routes = new RouteRegistry('ce'); + + routes.tag('Rooms', 'Room sessions'); + routes.register({ + ...SUMMARY, + request: { + query: z.object({ + kind: csvParam(['STANDARD', 'AGENT']).optional().meta({ description: 'Kinds to keep' }), + limit: limitParam + }) + } + }); + + const parameters = documentOf(routes).paths['/api/v1/rooms/summary']?.get?.parameters ?? []; + + expect(parameters.find((parameter) => parameter.name === 'kind')).toMatchObject({ + description: 'Kinds to keep', + style: 'form', + explode: false, + schema: { type: 'array', items: { enum: ['STANDARD', 'AGENT'] } } + }); + expect(parameters.find((parameter) => parameter.name === 'limit')?.description).toBe( + 'Page size, from 1 to 100' + ); + }); +}); + +/** A registry whose summary declares an error only CE answers. */ +function routesWithCeError(edition: 'ce' | 'pro') { + const routes = registry(edition); + + routes.register({ + ...SUMMARY, + responses: { 200: { description: 'Summary' }, 403: { description: 'Locked on CE', edition: 'ce' } } + }); + + return routes; +} diff --git a/openvidu-dashboard/openspec/changes/openapi-reference/.openspec.yaml b/openvidu-dashboard/openspec/changes/openapi-reference/.openspec.yaml new file mode 100644 index 000000000..97e364202 --- /dev/null +++ b/openvidu-dashboard/openspec/changes/openapi-reference/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-27 diff --git a/openvidu-dashboard/openspec/changes/openapi-reference/design.md b/openvidu-dashboard/openspec/changes/openapi-reference/design.md new file mode 100644 index 000000000..8f4f57049 --- /dev/null +++ b/openvidu-dashboard/openspec/changes/openapi-reference/design.md @@ -0,0 +1,124 @@ +# Design + +## Context + +- `RouteRegistry` holds one `RouteDefinition` per route, and `openApiDocument(registry, version)` + (`src/api/openapi.ts`) turns the listed routes into a zod-openapi 6 document. +- How the generator fills error responses today: + - It adds `400` to every operation, `401` to every non-public one, and `403` "PRO feature on CE" to every PRO route. + No document lists those routes in an edition that answers that 403: locks are left out in CE, and PRO never + answers it. + - It adds the `ApiError` schema only to the errors it adds itself. Errors a route declares (login `401`/`429`, + `ready` `503`, analytics `403`, detail `404`) have no body. +- Parameter descriptions come from `.meta()` on the Zod schemas: + - A `.meta()` placed before `.default()` stays inside the parameter's schema and never reaches the parameter object. + This is why `from` and `to` show no description. + - `csvParam()` parses a string, so its parameters render as plain strings. +- `DashboardOptions` carries the edition and version. The public base path is bound to `TOKENS.BasePath` by a CE + startup hook, before the docs router is built. + +## Goals / Non-Goals + +**Goals:** +- A document that a client generator and a reader can use as is. +- Pieces that let PRO document its routes the same way, without CE knowing them. +- Tests that keep the invariants for every route, not only for today's. + +**Non-Goals:** +- Changing requests, responses, status codes or authentication. +- Serving the document in production. + +## Decisions + +### Explicit `operationId`, checked by the registry + +- `RouteDefinition` gains a required `operationId`, camelCase, verb first: + - `list` for paged lists; + - `get` for single documents; + - `get` for sub-resources and summaries; + - `login`, `refreshSession`, `logout`, `getCurrentAdmin` for auth; + - `getHealth`, `getReadiness` for the probes. +- `register()` and `replace()` throw on an `operationId` another route already uses. A lock has none and is never + listed. + +Rejected: deriving IDs from method and path. Names such as `getApiV1RoomsSidSeries` read badly, and they would change +when a path changes. + +### Declared tags + +- `registry.tag(name, description)` declares a tag. Each route module declares its tags before registering its + routes. +- `register()` throws on an undeclared tag. +- The document lists the declared tags, in declaration order, that at least one listed operation uses. +- Tag names are Title Case nouns: `Probes`, `Auth`, `Info`, `Overview`, `Rooms`, `Participants`, `Agents`, `Egress`, + `Ingress`, `Analytics`, `Status`. +- PRO declares its own tags (such as `Tracks`) the same way. + +### Error responses built by the generator + +- `400` (`Invalid request`) is added only when the route has params, a query or a body. +- `401` is added for `bearer` and `optional-bearer` routes. +- The automatic `403` for PRO routes is removed. +- Every response with status ≥ 400 gets `application/json` with `ApiError` when the route gives no schema. +- `RouteResponse` gains `edition?: Edition`. A response with an edition is left out of other editions' documents. The + CE analytics routes mark their `PRO_FEATURE` `403` as `ce`. The registry exposes its edition for this. + +### Parameters + +- **Shared pieces in `query.ts`:** + - Shared parameters carry their descriptions after `.default()`, so zod-openapi lifts them to the parameter: `from`, + `to`, `tz`, `limit`, `order`, `cursor` and `q`. + - `to` gets its own example (`now`). +- **`csvParam(values)`:** adds `param: { style: 'form', explode: false }` and + `override: { type: 'array', items: { type: 'string', enum: values } }`. The route adds the description. The other + comma-separated parameters do the same: `features`, the egress `status` with its `all`, and the analytics `metrics` + and `dimensions`. +- **`If-None-Match`:** the generator adds this header parameter to every operation whose responses include `304`. +- **Integration test:** it asserts that every parameter of the booted document has a description. A new route without + one fails CI instead of silently degrading the document. + +Alternatives rejected for the list parameters: +- Changing their schemas to `z.array()` with `explode`: Express parses `a,b` as one string, so validation would change. +- A document-wide `override` hook keyed on the transform: too implicit. + +### Refresh cookie and `Set-Cookie` + +- `RouteDefinition` gains an optional `refreshCookie: 'required' | 'optional'`. It only affects the document; + authentication is unchanged, and the auth routes stay `public`. +- The generator adds the `refreshCookie` security scheme (`apiKey`, `in: cookie`, name `REFRESH_COOKIE`). It sets + `security: [{ refreshCookie: [] }]` for refresh, and `[{}, { refreshCookie: [] }]` for logout, which also answers + without a cookie. +- The auth responses that set or clear the cookie declare a `Set-Cookie` header with its attributes: login `200`, + refresh `200` and `401`, logout `204`. + +### Metadata + +- `openApiDocument(registry, { version, basePath, license? })`: + - `servers: [{ url: basePath without its trailing slash, or '/' }]`; + - `info.description`: one Markdown text of the conventions, edition-neutral; + - `info.license`: the given one, or `{ name: 'Apache 2.0', identifier: 'Apache-2.0' }`. +- `DashboardOptions` gains an optional `apiLicense`, which an extending edition passes. CE passes nothing. + +### Schema with required keys never defined + +`byInputType: z.record(inputType, count)` renders `required` without `properties`. It becomes +`z.object({ rtmp: count, whip: count, url: count })`, which validates the same values. + +### Unknown-route message + +`apiNotFound` is mounted on `/api`, so `req.path` lacks the prefix. The message uses the path of `req.originalUrl` +(without the query string), which is the path the client sent: `${req.baseUrl}${req.path}` would turn `/api` into +`/api/`. + +## Risks / Trade-offs + +- **[Breaking for PRO at compile time]** `operationId` becomes required, and undeclared tags throw. → PRO adds them in + the same move of `ce.ref`, with its own document test. +- **[`override` drifting from the parser]** The array rendering duplicates the allowed values. → Both come from the same + `values` argument of `csvParam()`. The other list parameters pass their own enum constant to both the parser and the + override. + +## Migration Plan + +This changes the document only; deploy as usual. Clients that generated code from the old document get stable method +names from now on. diff --git a/openvidu-dashboard/openspec/changes/openapi-reference/proposal.md b/openvidu-dashboard/openspec/changes/openapi-reference/proposal.md new file mode 100644 index 000000000..da26fbfef --- /dev/null +++ b/openvidu-dashboard/openspec/changes/openapi-reference/proposal.md @@ -0,0 +1,75 @@ +# Proposal + +## Why + +The OpenAPI document (`GET /api/v1/docs/openapi.json`) lists every route, but it does not describe the API well enough +to use it without reading the code: +- No operation has an `operationId`. +- Most parameters have no description. +- Comma-separated lists are typed as bare strings. +- Several error responses are missing or have no body schema. +- Some responses are declared that the edition never answers. +- There is no `servers`, `license` or tag list. + +The unknown-route `404` also names a path without its `/api` prefix. + +## What Changes + +- **Document metadata:** + - `info` gains a description of the API conventions (authentication, time ranges, pagination, errors, conditional + requests, request IDs, versioning) and the license. + - `servers` gains the public base path. + - A `tags` list, with one description per tag, in a stable order. +- **Operations:** + - Every operation has a unique `operationId` and tags from that list. + - Every parameter has a description. + - Comma-separated list parameters are documented as arrays of their allowed values (`style: form`, `explode: false`). + - Routes answered conditionally declare the `If-None-Match` request header next to their `ETag` and `304`. + - The auth routes declare the refresh cookie they read (as a cookie security scheme) and the `Set-Cookie` header they + send. +- **Error responses:** + - Every error response an operation can answer is declared with the `ApiError` schema. + - `400` is declared only for operations that take input. + - A response that only one edition answers appears only in that edition's document. This covers the automatic + `PRO_FEATURE` `403`, which no listed route answers, and the `403` of PRO metrics on the CE analytics routes. +- **Schemas:** the ingress summary's `byInputType` is a plain object with its three keys. It was a record that + required keys it never defined. +- **404 message:** the unknown-route `404` message names the full request path, `No API route for GET + /api/v1/does-not-exist`. + +Scope excludes the following: +- No route, parameter or response body changes. +- The PRO routes are documented by the PRO workspace on top of the generic pieces this change adds, so CE needs no + PRO-specific code, and the seams stay generic: an optional license for the document and the per-route fields. + +Edition: CE, and the generic pieces PRO builds on. Repository: `OpenVidu/openvidu` (dashboard) only. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `api-conventions`: + - The OpenAPI document gains requirements on its metadata, operations, parameters and error responses. + - The unknown-route scenario fixes its message. + +## Impact + +- Backend: + - `src/api/openapi.ts` (generator). + - `src/api/route-registry.ts` (`operationId`, tag declarations, per-response edition, refresh cookie field). + - `src/api/query.ts` (shared parameter descriptions and the array form of `csvParam`). + - Every `src/*/*-routes.ts` (operation IDs, tags, parameter descriptions, error responses). + - `src/ingress/ingress-responses.ts`. + - `src/http/errors.ts`. + - `src/lifecycle/startup.ts` (base path, and an optional license from `DashboardOptions`). +- Tests: unit tests of the generator and the registry, and an integration test that checks the invariants above on the + document of the booted CE dashboard. +- PRO: its own routes must supply `operationId`, declared tags and parameter descriptions. That is a compile-time + requirement once it moves `ce.ref`. +- CHANGELOG (`CHANGELOG.md`, 3.10.0, Added › Data), a new line: "API reference outside production: an OpenAPI 3.1 + document at `/api/v1/docs/openapi.json` and a browsable page at `/api/v1/docs`, with operation IDs, described + parameters and every error response." diff --git a/openvidu-dashboard/openspec/changes/openapi-reference/specs/api-conventions/spec.md b/openvidu-dashboard/openspec/changes/openapi-reference/specs/api-conventions/spec.md new file mode 100644 index 000000000..3810e8f39 --- /dev/null +++ b/openvidu-dashboard/openspec/changes/openapi-reference/specs/api-conventions/spec.md @@ -0,0 +1,110 @@ +## MODIFIED Requirements + +### Requirement: Base path and resource naming + +Edition: CE. Every dashboard API route SHALL live under `/api/v1`, use lower-case plural nouns for resources +(`rooms`, `participants`, `agents`, `tracks`, `egresses`, `ingresses`, `analytics`, `overview`, `status`, `auth`, +`info`), nest +sub-resources at most one level under the parent identifier, and answer every unknown `/api` path with a JSON +`404` instead of the SPA fallback. + +#### Scenario: Sub-resource path + +- **GIVEN** a room with SID `RM_abc` +- **WHEN** the client requests its participants +- **THEN** the path is `GET /api/v1/rooms/RM_abc/participants` + +#### Scenario: Unknown API path + +- **WHEN** the client requests `GET /api/v1/does-not-exist` or `GET /api/anything` +- **THEN** the response is `404` with body `{ error: { code: 'NOT_FOUND', message } }` and + `Content-Type: application/json`, never the SPA `index.html`, and the message names the full path, such as + `No API route for GET /api/v1/does-not-exist` + +## ADDED Requirements + +### Requirement: OpenAPI document metadata + +Edition: CE. The OpenAPI document SHALL carry the following: +- an `info.description` of the API conventions; +- `info.license`: Apache 2.0 unless the edition provides another; +- one `servers` entry with the dashboard's public base path; +- a `tags` list with a description per tag, holding only the tags of the listed operations. + +#### Scenario: Behind a base path + +- **GIVEN** the server runs with `DASHBOARD_BASE_PATH=/dashboard/` and `NODE_ENV=development` +- **WHEN** the client reads the document +- **THEN** `servers[0].url` is `/dashboard`, and the paths start with `/api/v1` + +#### Scenario: Conventions described + +- **WHEN** the client reads `info.description` +- **THEN** it explains the following: + - the bearer access token and the refresh cookie; + - the `from`, `to` and `tz` time expressions; + - cursor pagination with `limit` and `total`; + - the `ApiError` envelope; + - `ETag` and `If-None-Match`; + - the `X-Request-Id` header; + - the versioning policy. + +#### Scenario: Tags + +- **WHEN** the client reads `tags` +- **THEN** every operation's tags are listed there with a description, and no listed tag lacks an operation + +### Requirement: OpenAPI operations + +Edition: CE. Every operation of the OpenAPI document SHALL have the following: +- a unique `operationId`, a summary and tags; +- a description for every parameter, with comma-separated list parameters as arrays of their allowed values + (`style: form`, `explode: false`); +- an `If-None-Match` header parameter when it can answer `304`; +- on the auth routes, the refresh cookie as a cookie security scheme, and the `Set-Cookie` header of the responses that + set or clear it. + +#### Scenario: Operation identifiers + +- **WHEN** the client reads every operation of the document +- **THEN** each has an `operationId` that no other operation shares, such as `listRooms` for `GET /api/v1/rooms` + +#### Scenario: List parameter + +- **WHEN** the client reads the `kind` parameter of `GET /api/v1/participants` +- **THEN** it has a description, `style: form`, `explode: false`, and an array schema whose items enumerate the + participant kinds + +#### Scenario: Conditional request + +- **WHEN** the client reads `GET /api/v1/overview` +- **THEN** it declares the `If-None-Match` header parameter, the `ETag` header of its `200`, and its `304` + +#### Scenario: Refresh cookie + +- **WHEN** the client reads `POST /api/v1/auth/refresh` +- **THEN** its security names the refresh cookie scheme (`in: cookie`, `name: ov_dashboard_refresh`), and its `200` and + `401` declare `Set-Cookie` + +### Requirement: OpenAPI error responses + +Edition: CE. Every error response an operation can answer SHALL be declared with the `ApiError` schema. This means +`400` only for operations that take parameters or a body, `401` for operations that need or accept a token, and the +route's own errors such as `404`, `429` and `503`. A response that only one edition answers SHALL appear only in that +edition's document. + +#### Scenario: Operation without input + +- **WHEN** the client reads `GET /api/v1/health` +- **THEN** it declares no `400` + +#### Scenario: Error body + +- **WHEN** the client reads the `429` of `POST /api/v1/auth/login` +- **THEN** its content is `application/json` with the `ApiError` schema + +#### Scenario: Edition-specific error + +- **WHEN** the client reads `GET /api/v1/analytics/series` in the CE document +- **THEN** it declares the `403` of a PRO metric, dimension or node scope; and no CE operation declares a `403` + `PRO_FEATURE` for being a PRO route diff --git a/openvidu-dashboard/openspec/changes/openapi-reference/tasks.md b/openvidu-dashboard/openspec/changes/openapi-reference/tasks.md new file mode 100644 index 000000000..d8ef3054e --- /dev/null +++ b/openvidu-dashboard/openspec/changes/openapi-reference/tasks.md @@ -0,0 +1,68 @@ +# Tasks + +## 1. Backend: registry and generator + +- [x] 1.1 In `src/api/route-registry.ts`, add: + - a required `operationId` and an optional `refreshCookie: 'required' | 'optional'` to `RouteDefinition`; + - `edition?: Edition` to `RouteResponse`; + - `tag(name, description)` and the `edition` getter to `RouteRegistry`. + `register()` and `replace()` reject a repeated `operationId` and an undeclared tag. Verify with + `route-registry.test.ts` cases for both rejections. +- [x] 1.2 In `src/api/openapi.ts`, change `openApiDocument(registry, { version, basePath, license? })` to emit: + - `servers`, `info.description`, `info.license` and `tags` (declared and used, in declaration order); + - `operationId`; + - `400` only with input, `401` for `bearer` and `optional-bearer`, no automatic `403`; + - `ApiError` on every error without a schema; + - responses filtered by `edition`; + - the `If-None-Match` parameter on operations with `304`; + - the `refreshCookie` security scheme; + and drop the dead locked-route description. Verify with `route-registry.test.ts` cases for each rule. +- [x] 1.3 In `src/lifecycle/startup.ts` and `src/index.ts`, add the optional `DashboardOptions.apiLicense` and pass it, + with the bound base path, to the document. Verify with the typecheck and the `servers` case of 1.2. + +## 2. Backend: parameters and routes + +- [x] 2.1 In `src/api/query.ts`, describe `from`, `to`, `tz`, `limit`, `order`, `cursor` and `q` at the parameter + level (after `.default()`), with `to` getting its own example. Make `csvParam()` render as an array of its values + with `style: form`, `explode: false`. Verify with a generator unit case on a ranged list query. +- [x] 2.2 In every `src/*/*-routes.ts` and `src/api/probes.ts`: + - declare the module's tags; + - give each route its `operationId`; + - describe every route-specific parameter; + - render the other comma-separated parameters as arrays (`features`, egress `status`, analytics `metrics` and + `dimensions`); + - mark the analytics `403` as `edition: 'ce'`; + - add `refreshCookie` and the `Set-Cookie` headers to the auth routes. + Verify with the integration test of 3.1. +- [x] 2.3 In `src/ingress/ingress-responses.ts`, make `byInputType` a `z.object` with `rtmp`, `whip` and `url`. Verify + with the ingress response unit tests and the mapper checks. +- [x] 2.4 In `src/http/errors.ts`, make `apiNotFound` name the path of `req.originalUrl`. Verify with the app unit test + of the unknown API path, which asserts the message. + +## 3. Tests and docs + +- [x] 3.1 Add the integration test `test/integration/openapi-document.test.ts`. It boots the CE dashboard in + development and reads `/api/v1/docs/openapi.json`, then asserts: + - unique `operationId`s; + - every tag is declared with a description; + - every parameter has a description; + - every response ≥ 400 has `ApiError`; + - no `400` on operations without input; + - no `403` `PRO_FEATURE` outside the analytics routes; + - `If-None-Match` wherever there is a `304`; + - the refresh cookie scheme on refresh and logout; + - `servers` and `license`. + Verify that `./dashboard.sh test-integration` passes. +- [x] 3.2 Lint both a development document and the generated one with Redocly CLI (`recommended`, scratch install, + not a dependency). Expect no errors, and no warnings other than intentional ones recorded in this file. Verify by + running it by hand once. + Result: valid. The only warnings are `operation-4xx-response` on `getHealth`, `getReadiness` and `logout`, which + answer no 4xx by design. +- [x] 3.3 Update `dashboard-ce/backend/AGENTS.md` (route declaration: `operationId`, tags through `registry.tag`, + parameter descriptions, response `edition`, `refreshCookie`), and add the `CHANGELOG.md` line of the proposal. + Verify that `./dashboard.sh check` passes. + +## Workflow follow-up + +- Move `ce.ref` in openvidu-pro and document the PRO routes there (PRO change). +- Archive when the owner asks.