import { type NextFunction, type Request, type RequestHandler, type Response, Router } from 'express'; import type * as z from 'zod'; import type { Edition, FeatureKey, ValidationIssue } from '@openvidu-dashboard/typings'; import { ApiError } from '../http/api-error.js'; import { parsePart, throwIfInvalid } from './validation.js'; export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'; /** * Who may call a route: anyone, a valid bearer token, or anyone with a token checked only when an `Authorization` * header is present (info answers a public subset without it). */ export type RouteSecurity = 'public' | 'bearer' | 'optional-bearer'; /** Identity of the caller of a bearer route. */ export interface AuthContext { username: string; edition: Edition; expiresAt: Date; } declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Express { interface Request { auth?: AuthContext; } } } export interface RouteInput
{ params: P; query: Q; body: B; req: Request; res: Response; } export interface RouteResponse { description: string; schema?: z.ZodType; /** Media type of `schema`; default `application/json`. */ contentType?: string; headers?: z.ZodObject; } export interface RouteDefinition
{ method: HttpMethod; /** Path under `/api/v1` in OpenAPI form, e.g. `/rooms/{sid}`. */ path: string; /** Edition that implements the route; CE answers `PRO_FEATURE` for PRO routes. */ edition: Edition; /** Feature of a PRO route, reported in `details.feature`. */ feature?: FeatureKey; summary: string; tags: string[]; security: RouteSecurity; request?: { params?: z.ZodType
; query?: z.ZodType ) => unknown;
}
/** A PRO route as CE knows it: answered with `PRO_FEATURE` and left out of the OpenAPI document. */
export interface RouteLock {
method: HttpMethod;
/** Path under `/api/v1` in OpenAPI form, e.g. `/rooms/{sid}`. */
path: string;
feature: FeatureKey;
}
/** Authenticates a request; installed by the auth module. */
export type Authenticator = (req: Request, required: boolean) => Promise (route: RouteDefinition ): void {
const key = routeKey(route.method, route.path);
const current = this.routes.get(key);
if (current !== undefined && !this.locks.has(key)) {
throw new Error(`Route ${key} is already registered`);
}
if (current !== undefined && current.feature !== route.feature) {
throw new Error(`Route ${key} is locked for the feature ${String(current.feature)}`);
}
if (route.edition === 'pro' && route.feature === undefined) {
throw new Error(`PRO route ${key} needs a feature`);
}
// 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);
}
/** Declares a PRO route by method, path and feature; the PRO edition registers its full definition in its place. */
lock(lock: RouteLock): void {
const key = routeKey(lock.method, lock.path);
if (this.routes.has(key)) {
throw new Error(`Route ${key} is already registered`);
}
this.locks.add(key);
this.routes.set(key, {
...lock,
edition: 'pro',
summary: '',
tags: [],
security: 'bearer',
responses: {}
});
}
/** Replaces the definition of a registered route where it stands; the PRO edition extends CE routes with it. */
replace (route: RouteDefinition ): void {
const key = routeKey(route.method, route.path);
if (!this.routes.has(key) || this.locks.has(key)) {
throw new Error(`Route ${key} is not registered`);
}
this.routes.set(key, route as RouteDefinition);
}
/** `METHOD path` of every lock no definition has replaced. */
unimplemented(): string[] {
return [...this.locks];
}
/** The registered routes, locks aside. */
list(): RouteDefinition[] {
return [...this.routes].filter(([key]) => !this.locks.has(key)).map(([, route]) => route);
}
/** True when the running edition answers the route with `PRO_FEATURE`. */
isLocked(route: RouteDefinition): boolean {
return route.edition === 'pro' && this.edition === 'ce';
}
router(authenticate: Authenticator): Router {
const router = Router();
for (const route of this.routes.values()) {
router[route.method](expressPath(route.path), ...(route.before ?? []), this.handle(route, authenticate));
}
return router;
}
private handle(route: RouteDefinition, authenticate: Authenticator): RequestHandler {
return async (req: Request, res: Response, next: NextFunction) => {
try {
if (route.security !== 'public') {
await authenticate(req, route.security === 'bearer');
}
if (this.isLocked(route) || route.handler === undefined) {
throw new ApiError('PRO_FEATURE', 'This feature requires OpenVidu PRO', {
feature: route.feature ?? null
});
}
const issues: ValidationIssue[] = [];
const params = route.request?.params ? parsePart(route.request.params, req.params, issues) : undefined;
const query = route.request?.query ? parsePart(route.request.query, req.query, issues) : undefined;
const body = route.request?.body ? parsePart(route.request.body, req.body ?? {}, issues) : undefined;
throwIfInvalid(issues);
const result: unknown = await route.handler({ params, query, body, req, res });
if (!res.headersSent) {
res.status(200).json(result);
}
} catch (error) {
next(error);
}
};
}
}
; body?: z.ZodType };
responses: Record