274 lines
9.0 KiB
TypeScript
274 lines
9.0 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
import type { HostApiContext } from '../context';
|
|
import { sendJson } from '../route-utils';
|
|
import {
|
|
type DataServiceErrorContext,
|
|
type DataServiceHostResult,
|
|
} from '../../../shared/data-service';
|
|
import {
|
|
COLLECTION_PATTERN,
|
|
parseCollection as parseCollectionSegment,
|
|
parseCursor as parseCursorValue,
|
|
parseData as parseDataValue,
|
|
parseDocumentId as parseDocumentIdValue,
|
|
parseIfMatch as parseIfMatchValue,
|
|
parseLimit as parseLimitValue,
|
|
readBoundedJson as readBoundedJsonBody,
|
|
requireExactKeys as requireExactKeysValue,
|
|
requireQueryKeys as requireQueryKeysValue,
|
|
type ParserMessages,
|
|
} from './data-service-parsers';
|
|
|
|
const LOCAL_ROOT = '/api/works/data-service';
|
|
const MAX_REQUEST_BYTES = 98_304;
|
|
const PARSER_MESSAGES: ParserMessages = {
|
|
invalidRequest: 'Data Service request is invalid',
|
|
invalidRevision: 'Data Service document revision is invalid',
|
|
requestTooLarge: 'Data Service request is too large',
|
|
};
|
|
|
|
class DataServiceRouteError extends Error {
|
|
constructor(
|
|
readonly status: number,
|
|
readonly code: string,
|
|
message: string,
|
|
readonly retryable = false,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
function routeFailure<T>(
|
|
status: number,
|
|
code: string,
|
|
error: string,
|
|
retryable = false,
|
|
extra: { retry_after_seconds?: number; context?: DataServiceErrorContext } = {},
|
|
): DataServiceHostResult<T> {
|
|
return {
|
|
success: false,
|
|
status,
|
|
code,
|
|
error,
|
|
retryable,
|
|
...(extra.retry_after_seconds === undefined ? {} : { retry_after_seconds: extra.retry_after_seconds }),
|
|
...(extra.context === undefined ? {} : { context: extra.context }),
|
|
data: null,
|
|
};
|
|
}
|
|
|
|
function unavailable<T>(): DataServiceHostResult<T> {
|
|
return routeFailure(503, 'data_service_unavailable', 'Data Service is temporarily unavailable', true);
|
|
}
|
|
|
|
function methodNotAllowed<T>(): DataServiceHostResult<T> {
|
|
return routeFailure(405, 'method_not_allowed', 'Data Service method is not allowed');
|
|
}
|
|
|
|
function parserFailure(status: number, code: string, message: string): never {
|
|
throw new DataServiceRouteError(status, code, message);
|
|
}
|
|
|
|
async function readBoundedJson(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
return readBoundedJsonBody(req, MAX_REQUEST_BYTES, parserFailure, PARSER_MESSAGES);
|
|
}
|
|
|
|
function requireExactKeys(value: Record<string, unknown>, keys: readonly string[]): void {
|
|
requireExactKeysValue(value, keys, parserFailure, PARSER_MESSAGES.invalidRequest);
|
|
}
|
|
|
|
function parseCollection(value: string): string {
|
|
return parseCollectionSegment(value, parserFailure, PARSER_MESSAGES.invalidRequest);
|
|
}
|
|
|
|
function parseDocumentId(value: string): string {
|
|
return parseDocumentIdValue(value, parserFailure, PARSER_MESSAGES.invalidRequest);
|
|
}
|
|
|
|
function requireQueryKeys(url: URL, allowed: readonly string[]): void {
|
|
requireQueryKeysValue(url, allowed, parserFailure, PARSER_MESSAGES.invalidRequest);
|
|
}
|
|
|
|
function requireConfirmed(url: URL): void {
|
|
requireQueryKeys(url, ['confirmed']);
|
|
if (url.searchParams.getAll('confirmed').length !== 1
|
|
|| url.searchParams.get('confirmed') !== 'true') {
|
|
throw new DataServiceRouteError(
|
|
400,
|
|
'confirmation_required',
|
|
'Explicit confirmation is required for this Data Service operation',
|
|
);
|
|
}
|
|
}
|
|
|
|
function parseLimit(url: URL): number | undefined {
|
|
return parseLimitValue(url, parserFailure, PARSER_MESSAGES.invalidRequest);
|
|
}
|
|
|
|
function parseCursor(url: URL): string | undefined {
|
|
return parseCursorValue(url, parserFailure, PARSER_MESSAGES.invalidRequest);
|
|
}
|
|
|
|
function parseIfMatch(req: IncomingMessage): number | undefined {
|
|
return parseIfMatchValue(req, parserFailure, PARSER_MESSAGES.invalidRevision);
|
|
}
|
|
|
|
function parseCollections(value: unknown): string[] {
|
|
if (!Array.isArray(value) || value.length > 20
|
|
|| value.some((item) => typeof item !== 'string' || !COLLECTION_PATTERN.test(item))) {
|
|
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
|
}
|
|
return value as string[];
|
|
}
|
|
|
|
function parseData(value: unknown): Record<string, unknown> {
|
|
return parseDataValue(value, parserFailure, PARSER_MESSAGES.invalidRequest);
|
|
}
|
|
|
|
function sendResult<T>(res: ServerResponse, result: DataServiceHostResult<T>): void {
|
|
res.setHeader('Cache-Control', 'private, no-store');
|
|
// Host IPC and the browser fallback both consume the safe envelope. Keep the
|
|
// transport status successful so a remote Data Service status cannot be
|
|
// mistaken for a Host transport failure by the existing proxy.
|
|
sendJson(res, 200, result);
|
|
}
|
|
|
|
function isDataServicePath(pathname: string): boolean {
|
|
return pathname === LOCAL_ROOT || pathname.startsWith(`${LOCAL_ROOT}/`);
|
|
}
|
|
|
|
export async function handleDataServiceRoutes(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
url: URL,
|
|
ctx: HostApiContext,
|
|
): Promise<boolean> {
|
|
if (!isDataServicePath(url.pathname)) return false;
|
|
|
|
const operations = ctx.codingProducts?.dataService;
|
|
if (!operations) {
|
|
sendResult(res, unavailable());
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
const method = (req.method ?? 'GET').toUpperCase();
|
|
const projectListPath = `${LOCAL_ROOT}/projects`;
|
|
const projectPath = `${LOCAL_ROOT}/project`;
|
|
if (url.pathname === projectListPath) {
|
|
requireQueryKeys(url, []);
|
|
if (method !== 'GET') {
|
|
sendResult(res, methodNotAllowed());
|
|
} else {
|
|
sendResult(res, await operations.listProjects());
|
|
}
|
|
return true;
|
|
}
|
|
if (url.pathname === projectPath) {
|
|
if (method === 'PUT') {
|
|
requireQueryKeys(url, []);
|
|
const body = await readBoundedJson(req);
|
|
requireExactKeys(body, ['collections']);
|
|
sendResult(res, await operations.configure({ collections: parseCollections(body.collections) }));
|
|
} else if (method === 'GET') {
|
|
requireQueryKeys(url, []);
|
|
sendResult(res, await operations.inspect());
|
|
} else if (method === 'DELETE') {
|
|
requireConfirmed(url);
|
|
sendResult(res, await operations.removeProject({ confirmed: true }));
|
|
} else {
|
|
sendResult(res, methodNotAllowed());
|
|
}
|
|
return true;
|
|
}
|
|
if (url.pathname === `${projectPath}/reset`) {
|
|
if (method !== 'POST') {
|
|
sendResult(res, methodNotAllowed());
|
|
} else {
|
|
requireConfirmed(url);
|
|
sendResult(res, await operations.reset({ confirmed: true }));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const collectionPath = url.pathname.match(
|
|
new RegExp(`^${LOCAL_ROOT}/project/collections/([^/]+)$`),
|
|
);
|
|
if (collectionPath) {
|
|
if (method !== 'DELETE') {
|
|
sendResult(res, methodNotAllowed());
|
|
} else {
|
|
requireConfirmed(url);
|
|
sendResult(res, await operations.removeCollection({
|
|
collection: parseCollection(collectionPath[1]),
|
|
confirmed: true,
|
|
}));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const documentCollectionPath = url.pathname.match(
|
|
new RegExp(`^${LOCAL_ROOT}/project/collections/([^/]+)/documents$`),
|
|
);
|
|
if (documentCollectionPath) {
|
|
const collection = parseCollection(documentCollectionPath[1]);
|
|
if (method !== 'GET') {
|
|
sendResult(res, methodNotAllowed());
|
|
} else {
|
|
requireQueryKeys(url, ['limit', 'cursor']);
|
|
sendResult(res, await operations.listDocuments({
|
|
collection,
|
|
limit: parseLimit(url),
|
|
cursor: parseCursor(url),
|
|
}));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const documentPath = url.pathname.match(
|
|
new RegExp(`^${LOCAL_ROOT}/project/collections/([^/]+)/documents/([^/]+)$`),
|
|
);
|
|
if (documentPath) {
|
|
const collection = parseCollection(documentPath[1]);
|
|
const documentId = parseDocumentId(documentPath[2]);
|
|
if (method === 'GET') {
|
|
requireQueryKeys(url, []);
|
|
sendResult(res, await operations.getDocument({ collection, document_id: documentId }));
|
|
} else if (method === 'PUT') {
|
|
requireQueryKeys(url, []);
|
|
const body = await readBoundedJson(req);
|
|
requireExactKeys(body, ['data']);
|
|
const ifRevision = parseIfMatch(req);
|
|
sendResult(res, await operations.putDocument({
|
|
collection,
|
|
document_id: documentId,
|
|
data: parseData(body.data),
|
|
...(ifRevision === undefined ? {} : { if_revision: ifRevision }),
|
|
}));
|
|
} else if (method === 'DELETE') {
|
|
requireConfirmed(url);
|
|
const ifRevision = parseIfMatch(req);
|
|
sendResult(res, await operations.deleteDocument({
|
|
collection,
|
|
document_id: documentId,
|
|
...(ifRevision === undefined ? {} : { if_revision: ifRevision }),
|
|
confirmed: true,
|
|
}));
|
|
} else {
|
|
sendResult(res, methodNotAllowed());
|
|
}
|
|
return true;
|
|
}
|
|
|
|
sendResult(res, routeFailure(404, 'route_not_found', 'Data Service route was not found'));
|
|
return true;
|
|
} catch (error) {
|
|
if (error instanceof DataServiceRouteError) {
|
|
sendResult(res, routeFailure(error.status, error.code, error.message, error.retryable));
|
|
return true;
|
|
}
|
|
sendResult(res, unavailable());
|
|
return true;
|
|
}
|
|
}
|