mirror of https://github.com/OpenVidu/openvidu.git
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
parent
a03c8bb09c
commit
bba918f4a5
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-10-27
|
||||
|
|
@ -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.
|
||||
|
|
@ -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."
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
Loading…
Reference in New Issue