131 lines
4.7 KiB
TypeScript
131 lines
4.7 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
import { ProjectPluginServiceError } from '../../coding-plugins/project-service';
|
|
import { CodingProjectServiceError } from '../../coding-projects/project-service';
|
|
import type { HostApiContext } from '../context';
|
|
import { parseJsonBody, sendJson } from '../route-utils';
|
|
import { CodingProjectPluginServiceError } from '../coding-product-services';
|
|
|
|
const PLUGIN_ROUTE = /^\/api\/coding\/plugins\/([^/]+)$/u;
|
|
const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,127}$/u;
|
|
|
|
class CodingPluginRouteError extends Error {
|
|
constructor(
|
|
readonly status: 400 | 404 | 503,
|
|
readonly code: string,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = 'CodingPluginRouteError';
|
|
}
|
|
}
|
|
|
|
function requestError(message: string): never {
|
|
throw new CodingPluginRouteError(400, 'plugin_request_invalid', message);
|
|
}
|
|
|
|
function exactProjectId(url: URL): string {
|
|
if ([...url.searchParams.keys()].some((key) => key !== 'projectId')
|
|
|| url.searchParams.getAll('projectId').length !== 1) {
|
|
return requestError('GET requires exactly one projectId query parameter');
|
|
}
|
|
const projectId = url.searchParams.get('projectId')?.trim() ?? '';
|
|
if (!projectId || projectId.length > 128) return requestError('projectId is invalid');
|
|
return projectId;
|
|
}
|
|
|
|
function exactPutBody(value: unknown): { projectId: string; enabled: boolean } {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return requestError('Request body must be an object');
|
|
}
|
|
const body = value as Record<string, unknown>;
|
|
const keys = Object.keys(body).sort();
|
|
if (keys.length !== 2 || keys[0] !== 'enabled' || keys[1] !== 'projectId') {
|
|
return requestError('Request body must contain only projectId and enabled');
|
|
}
|
|
if (typeof body.projectId !== 'string' || !body.projectId.trim() || body.projectId.length > 128) {
|
|
return requestError('projectId is invalid');
|
|
}
|
|
if (typeof body.enabled !== 'boolean') return requestError('enabled is invalid');
|
|
return { projectId: body.projectId.trim(), enabled: body.enabled };
|
|
}
|
|
|
|
function routePluginId(value: string): string {
|
|
let id: string;
|
|
try {
|
|
id = decodeURIComponent(value).trim();
|
|
} catch {
|
|
return requestError('plugin id is invalid');
|
|
}
|
|
if (!PLUGIN_ID_PATTERN.test(id)) return requestError('plugin id is invalid');
|
|
return id;
|
|
}
|
|
|
|
function sendError(res: ServerResponse, error: unknown): void {
|
|
if (error instanceof CodingPluginRouteError || error instanceof CodingProjectPluginServiceError) {
|
|
sendJson(res, error.status, { success: false, code: error.code, error: error.message });
|
|
return;
|
|
}
|
|
if (error instanceof CodingProjectServiceError) {
|
|
sendJson(res, error.status, { success: false, code: error.code, error: error.message });
|
|
return;
|
|
}
|
|
if (error instanceof ProjectPluginServiceError) {
|
|
const status = error.code === 'CODING_PLUGIN_UNKNOWN' ? 404
|
|
: error.code === 'CODING_PLUGIN_SELECTION_WRITE_FAILED' ? 500
|
|
: 400;
|
|
const code = error.code === 'CODING_PLUGIN_UNKNOWN' ? 'plugin_not_found'
|
|
: error.code === 'CODING_PLUGIN_SELECTION_WRITE_FAILED' ? 'plugin_selection_write_failed'
|
|
: 'plugin_request_invalid';
|
|
sendJson(res, status, { success: false, code, error: error.message });
|
|
return;
|
|
}
|
|
if (error instanceof SyntaxError) {
|
|
sendJson(res, 400, { success: false, code: 'plugin_request_invalid', error: 'Request body is invalid' });
|
|
return;
|
|
}
|
|
sendJson(res, 503, {
|
|
success: false,
|
|
code: 'plugin_backend_unavailable',
|
|
error: 'Plugin service is temporarily unavailable',
|
|
});
|
|
}
|
|
|
|
export async function handleCodingPluginRoutes(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
url: URL,
|
|
ctx: HostApiContext,
|
|
): Promise<boolean> {
|
|
if (url.pathname !== '/api/coding/plugins' && !PLUGIN_ROUTE.test(url.pathname)) return false;
|
|
const plugins = ctx.codingProducts?.plugins;
|
|
if (!plugins) {
|
|
sendJson(res, 503, {
|
|
success: false,
|
|
code: 'plugin_backend_unavailable',
|
|
error: 'Plugin service is temporarily unavailable',
|
|
});
|
|
return true;
|
|
}
|
|
try {
|
|
if (url.pathname === '/api/coding/plugins' && req.method === 'GET') {
|
|
sendJson(res, 200, await plugins.list(exactProjectId(url)));
|
|
return true;
|
|
}
|
|
const match = PLUGIN_ROUTE.exec(url.pathname);
|
|
if (match && req.method === 'PUT') {
|
|
if (url.search) requestError('PUT does not accept query parameters');
|
|
const body = exactPutBody(await parseJsonBody<unknown>(req));
|
|
sendJson(res, 200, await plugins.setEnabled(
|
|
body.projectId,
|
|
routePluginId(match[1]),
|
|
body.enabled,
|
|
));
|
|
return true;
|
|
}
|
|
return false;
|
|
} catch (error) {
|
|
sendError(res, error);
|
|
return true;
|
|
}
|
|
}
|