187 lines
6.2 KiB
TypeScript
187 lines
6.2 KiB
TypeScript
import type {
|
|
ExecuteOperationRequest,
|
|
OperationPageQuery,
|
|
PrepareOperationRequest,
|
|
} from '@multi-simadmin/contracts';
|
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
import { OperationCatalogService } from '../../application/operations/operation-catalog-service.js';
|
|
import type { OperationCatalogRegistry } from '../../application/operations/operation-catalog-service.js';
|
|
import {
|
|
DeleteInstanceOperation,
|
|
DeleteInstanceOperationError,
|
|
} from '../../application/operations/delete-instance-operation.js';
|
|
import {
|
|
SecureOperationExecution,
|
|
SecureOperationExecutionError,
|
|
} from '../../application/operations/secure-operation-execution.js';
|
|
|
|
const capabilities = new Set(['query', 'command', 'job']);
|
|
const keys = new Set([
|
|
'page',
|
|
'pageSize',
|
|
'sort',
|
|
'direction',
|
|
'risk',
|
|
'capability',
|
|
'batchable',
|
|
'search',
|
|
]);
|
|
class CatalogValidationError extends Error {}
|
|
|
|
function scalarQuery(request: FastifyRequest): Record<string, string> {
|
|
const raw = request.query;
|
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
throw new CatalogValidationError();
|
|
const result: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(raw)) {
|
|
if (!keys.has(key) || typeof value !== 'string') throw new CatalogValidationError();
|
|
result[key] = value;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function parseQuery(request: FastifyRequest): OperationPageQuery {
|
|
const query = scalarQuery(request);
|
|
const result: {
|
|
page?: number;
|
|
pageSize?: number;
|
|
sort?: 'operationId' | 'risk' | 'capability';
|
|
direction?: 'asc' | 'desc';
|
|
risk?: 'R0' | 'R1' | 'R2' | 'R3';
|
|
capability?: string;
|
|
batchable?: boolean;
|
|
search?: string;
|
|
} = {};
|
|
const integer = (key: 'page' | 'pageSize', maximum?: number): void => {
|
|
const value = query[key];
|
|
if (value === undefined) return;
|
|
if (!/^[1-9]\d*$/.test(value)) throw new CatalogValidationError();
|
|
const parsed = Number(value);
|
|
if (!Number.isSafeInteger(parsed) || (maximum !== undefined && parsed > maximum))
|
|
throw new CatalogValidationError();
|
|
result[key] = parsed;
|
|
};
|
|
integer('page');
|
|
integer('pageSize', 100);
|
|
const sort = query.sort;
|
|
if (sort !== undefined) {
|
|
if (sort !== 'operationId' && sort !== 'risk' && sort !== 'capability')
|
|
throw new CatalogValidationError();
|
|
result.sort = sort;
|
|
}
|
|
const direction = query.direction;
|
|
if (direction !== undefined) {
|
|
if (direction !== 'asc' && direction !== 'desc') throw new CatalogValidationError();
|
|
result.direction = direction;
|
|
}
|
|
const risk = query.risk;
|
|
if (risk !== undefined) {
|
|
if (risk !== 'R0' && risk !== 'R1' && risk !== 'R2' && risk !== 'R3')
|
|
throw new CatalogValidationError();
|
|
result.risk = risk;
|
|
}
|
|
const capability = query.capability;
|
|
if (capability !== undefined) {
|
|
if (!capabilities.has(capability)) throw new CatalogValidationError();
|
|
result.capability = capability;
|
|
}
|
|
const batchable = query.batchable;
|
|
if (batchable !== undefined) {
|
|
if (batchable !== 'true' && batchable !== 'false') throw new CatalogValidationError();
|
|
result.batchable = batchable === 'true';
|
|
}
|
|
const search = query.search;
|
|
if (search !== undefined) {
|
|
if (search.length > 200) throw new CatalogValidationError();
|
|
result.search = search;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function registerOperationRoutes(
|
|
app: FastifyInstance,
|
|
registry: OperationCatalogRegistry,
|
|
execution?: SecureOperationExecution,
|
|
deletion?: DeleteInstanceOperation,
|
|
): void {
|
|
const service = new OperationCatalogService(registry);
|
|
app.get('/api/v1/operations', async (request, reply) => {
|
|
try {
|
|
return service.list(parseQuery(request));
|
|
} catch (error) {
|
|
if (!(error instanceof CatalogValidationError)) throw error;
|
|
return reply.code(400).type('application/problem+json').send({
|
|
type: 'about:blank',
|
|
title: 'Bad Request',
|
|
status: 400,
|
|
code: 'VALIDATION_FAILED',
|
|
detail: 'The request did not satisfy the endpoint schema.',
|
|
requestId: request.id,
|
|
});
|
|
}
|
|
});
|
|
if (!execution) return;
|
|
const problem = (
|
|
request: FastifyRequest,
|
|
reply: FastifyReply,
|
|
error: SecureOperationExecutionError,
|
|
) => {
|
|
const status =
|
|
error.code === 'NOT_FOUND' ? 404 : error.code === 'REVISION_CONFLICT' ? 409 : 400;
|
|
const title = status === 404 ? 'Not Found' : status === 409 ? 'Conflict' : 'Bad Request';
|
|
return reply.code(status).type('application/problem+json').send({
|
|
type: 'about:blank',
|
|
title,
|
|
status,
|
|
code: error.code,
|
|
detail: error.message,
|
|
requestId: request.id,
|
|
});
|
|
};
|
|
app.post('/api/v1/operations/prepare', async (request, reply) => {
|
|
try {
|
|
const body = request.body as { operationId?: unknown };
|
|
if (body && typeof body === 'object' && body.operationId === 'deleteInstance' && deletion)
|
|
return reply.code(200).send(await deletion.prepare(request.body as never, request.id));
|
|
return reply
|
|
.code(200)
|
|
.send(await execution.prepare(request.body as PrepareOperationRequest, request.id));
|
|
} catch (error) {
|
|
if (error instanceof DeleteInstanceOperationError) {
|
|
const status =
|
|
error.code === 'NOT_FOUND' ? 404 : error.code === 'REVISION_CONFLICT' ? 412 : 400;
|
|
return reply
|
|
.code(status)
|
|
.type('application/problem+json')
|
|
.send({
|
|
type: 'about:blank',
|
|
title:
|
|
status === 404 ? 'Not Found' : status === 412 ? 'Precondition Failed' : 'Bad Request',
|
|
status,
|
|
code: error.code,
|
|
detail: 'The requested operation could not be prepared.',
|
|
requestId: request.id,
|
|
});
|
|
}
|
|
if (error instanceof SecureOperationExecutionError) return problem(request, reply, error);
|
|
throw error;
|
|
}
|
|
});
|
|
app.post('/api/v1/operations/execute', async (request, reply) => {
|
|
try {
|
|
return reply
|
|
.code(202)
|
|
.send(
|
|
await execution.execute(
|
|
request.body as ExecuteOperationRequest,
|
|
'loopback-control-plane',
|
|
request.id,
|
|
),
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof SecureOperationExecutionError) return problem(request, reply, error);
|
|
throw error;
|
|
}
|
|
});
|
|
}
|