openvidu-dashboard: OpenAPI reference with operation IDs, described parameters and every error response

Adds the openapi-reference change. The document gains servers, license, the API conventions and described tags.
Every operation gets an operationId; parameters are described, and comma-separated lists are rendered as form
arrays. Error responses carry ApiError, and responses are declared only where an edition can answer them. The
document also covers If-None-Match and the refresh cookie.
chore/update-openvidu-actions-1.0.25
juancarmore 2026-10-27 10:42:41 +01:00
parent a03c8bb09c
commit bba918f4a5
39 changed files with 1232 additions and 155 deletions

View File

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

View File

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

View File

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

View File

@ -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:<id>' })
.transform((value) => value as AnalyticsScope)
.default('global')
.meta({ description: '`global` or `node:<id>` (PRO)', param: { example: 'global' } });
.meta({
description: 'Data of every node (`global`) or of one node (`node:<id>`, 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;

View File

@ -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 <token>\`, 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-<n>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<string, string[]>[] {
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<string, ZodOpenApiResponseObject> = {};
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<typeof createDocument> {
export function openApiDocument(registry: RouteRegistry, options: OpenApiOptions): ReturnType<typeof createDocument> {
const paths: ZodOpenApiPathsObject = {};
const used = new Set<string>();
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<typeof createDocument>): void {
for (const item of Object.values(document.paths ?? {})) {
for (const operation of Object.values(item as Record<string, { parameters?: RenderedParameter[] }>)) {
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 = `<!doctype html>

View File

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

View File

@ -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-<n>m|h|d, now/d or now/M';
const fromExpression = z
.string()
.meta({ description: 'ISO 8601 with offset, now, now-<n>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 T extends readonly [string, ...string[]]>(
const allowed = new Set<string>(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<S extends z.core.$ZodLooseShape>(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<z.output<typeof input>, 'from' | 'to'> & { from?: Date; to?: Date; tz: string };

View File

@ -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<P = unknown, Q = unknown, B = unknown> {
@ -54,9 +56,14 @@ export interface RouteDefinition<P = unknown, Q = unknown, B = unknown> {
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<P>; query?: z.ZodType<Q>; body?: z.ZodType<B> };
responses: Record<number, RouteResponse>;
/** 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<string, RouteDefinition>();
private readonly locks = new Set<string>();
private readonly tagDescriptions = new Map<string, string>();
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<P, Q, B>(route: RouteDefinition<P, Q, B>): 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();

View File

@ -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}=<token>; Path=<base 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 }) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Running
appRoot: options.appRoot,
trustProxy: config.http.trustProxy,
apiRouter: registry.router(bearerAuthenticator(() => container.get<AuthService>(TOKENS.AuthService))),
...(docsEnabled(env) ? { docsRouter: docsRouter(() => openApiDocument(registry, options.version)) } : {})
...(docsEnabled(env)
? {
docsRouter: docsRouter(() =>
openApiDocument(registry, {
version: options.version,
basePath: container.get<string>(TOKENS.BasePath),
...(options.apiLicense ? { license: options.apiLicense } : {})
})
)
}
: {})
});
return new Promise((resolve) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<RouteRegistry>(TOKENS.RouteRegistry);
const paths = Object.keys(openApiDocument(registry, 'test').paths ?? {});
const paths = Object.keys(openApiDocument(registry, { version: 'test' }).paths ?? {});
expect(paths).toEqual(
expect.arrayContaining([

View File

@ -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<RouteRegistry>(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([]);

View File

@ -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<RouteRegistry>(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([]);

View File

@ -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<string, string[]>[];
parameters?: { name: string; in: string; style?: string; explode?: boolean; schema: unknown }[];
responses: Record<string, { headers?: Record<string, unknown> }>;
}
describe.skipIf(mongoUri === '')('OpenAPI document', () => {
let ce: Client;
let document: CheckedDocument & {
servers: { url: string }[];
paths: Record<string, Record<string, Operation>>;
components: { securitySchemes: Record<string, unknown> };
};
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']);
});
});

View File

@ -264,10 +264,9 @@ describe.skipIf(mongoUri === '')('overview API', () => {
});
it('documents the overview without nodes', () => {
const document = openApiDocument(
ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry),
'test'
);
const document = openApiDocument(ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry), {
version: 'test'
});
const schema = document.components?.schemas?.OverviewResponse as { properties?: object } | undefined;
expect(Object.keys(schema?.properties ?? {})).not.toContain('nodes');

View File

@ -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<RouteRegistry>(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'])

View File

@ -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<RouteRegistry>(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([]);

View File

@ -113,10 +113,9 @@ describe.skipIf(mongoUri === '')('status API', () => {
});
it('documents the route', () => {
const document = openApiDocument(
ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry),
'test'
);
const document = openApiDocument(ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry), {
version: 'test'
});
expect(Object.keys(document.paths ?? {})).toContain('/api/v1/status');
expect(Object.keys(document.components?.schemas ?? {})).toContain('StatusResponse');

View File

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

View File

@ -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<string, Record<string, CheckedOperation>>;
}
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<string, { content?: Record<string, { schema?: { $ref?: string } }> }>;
}
/**
* 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<string>();
const operationIds = new Set<string>();
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;
}

View File

@ -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<string, string[]>[];
parameters?: {
name: string;
in: string;
description?: string;
style?: string;
explode?: boolean;
schema: { type?: string; items?: { enum?: string[] } };
}[];
responses: Record<string, { content?: Record<string, { schema: { $ref?: string } }>; headers?: object }>;
}
interface Document {
info: { description: string; license: { name: string; identifier?: string } };
servers: { url: string }[];
tags: { name: string; description: string }[];
paths: Record<string, Record<string, Operation>>;
components: { securitySchemes: Record<string, { in?: string; name?: string }> };
}
function documentOf(routes: RouteRegistry, options: Partial<Parameters<typeof openApiDocument>[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;
}

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-27

View File

@ -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<Resources>` for paged lists;
- `get<Resource>` for single documents;
- `get<Resource><Sub>` 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.

View File

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

View File

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

View File

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