430 lines
15 KiB
TypeScript
430 lines
15 KiB
TypeScript
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
|
import type {
|
|
InstanceInput,
|
|
InstancePageQuery,
|
|
InstancePatch,
|
|
PasswordUpdate,
|
|
Instance,
|
|
} from '@multi-simadmin/contracts';
|
|
import {
|
|
InstanceLoginService,
|
|
InstanceLoginServiceError,
|
|
} from '../../application/connections/instance-login-service.js';
|
|
import { ConnectionProbe } from '../../application/connections/connection-probe.js';
|
|
import {
|
|
InstanceService,
|
|
InstanceServiceError,
|
|
} from '../../application/instances/instance-service.js';
|
|
import {
|
|
DeleteInstanceOperation,
|
|
DeleteInstanceOperationError,
|
|
} from '../../application/operations/delete-instance-operation.js';
|
|
import type { InstanceResourceService } from '../../application/resources/instance-resource-service.js';
|
|
|
|
export interface InstanceRoutesOptions {
|
|
readonly instances: InstanceService;
|
|
readonly connections?: ConnectionProbe;
|
|
readonly login?: InstanceLoginService;
|
|
readonly deletion?: DeleteInstanceOperation;
|
|
readonly resources?: InstanceResourceService;
|
|
readonly registerDeletionPreparationRoute?: boolean;
|
|
}
|
|
const problem = (
|
|
request: FastifyRequest,
|
|
status: number,
|
|
code: string,
|
|
detail: string,
|
|
): Record<string, unknown> => ({
|
|
type: 'about:blank',
|
|
title:
|
|
status === 500
|
|
? 'Internal Server Error'
|
|
: status === 404
|
|
? 'Not Found'
|
|
: status === 412
|
|
? 'Precondition Failed'
|
|
: status === 409
|
|
? 'Conflict'
|
|
: 'Bad Request',
|
|
status,
|
|
code,
|
|
detail,
|
|
requestId: request.id,
|
|
});
|
|
const domainProblem = (request: FastifyRequest, error: InstanceServiceError) => {
|
|
const status = instanceErrorStatus[error.code];
|
|
return problem(
|
|
request,
|
|
status,
|
|
error.code,
|
|
'The requested instance operation could not be completed.',
|
|
);
|
|
};
|
|
const instanceErrorStatus = {
|
|
VALIDATION_FAILED: 400,
|
|
NOT_FOUND: 404,
|
|
REVISION_CONFLICT: 412,
|
|
HAS_JOB_HISTORY: 409,
|
|
DUPLICATE_ID: 409,
|
|
DUPLICATE_ORIGIN: 409,
|
|
SECRET_STORE_FAILED: 500,
|
|
DATABASE_FAILED: 500,
|
|
SECRET_CLEANUP_FAILED: 500,
|
|
COMPENSATION_FAILED: 500,
|
|
COMPENSATION_PERSISTENCE_FAILED: 500,
|
|
} as const satisfies Record<
|
|
import('../../application/instances/instance-service.js').InstanceServiceErrorCode,
|
|
number
|
|
>;
|
|
const etag = (instance: Instance): string => `"rev-${instance.revision}"`;
|
|
const revision = (request: FastifyRequest): number => {
|
|
const value = request.headers['if-match'];
|
|
const match = typeof value === 'string' ? /^"rev-([1-9]\d*)"$/.exec(value) : null;
|
|
if (!match) throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid revision');
|
|
const parsed = Number(match[1]);
|
|
if (!Number.isSafeInteger(parsed))
|
|
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid revision');
|
|
return parsed;
|
|
};
|
|
const strings = (value: unknown): readonly string[] | undefined =>
|
|
Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : undefined;
|
|
const record = (body: unknown): Record<string, unknown> => {
|
|
if (body === null || typeof body !== 'object' || Array.isArray(body))
|
|
throw new InstanceServiceError('VALIDATION_FAILED', 'Request body must be an object');
|
|
return body as Record<string, unknown>;
|
|
};
|
|
const bodyInput = (body: unknown): InstanceInput => {
|
|
const value = record(body);
|
|
const tags = strings(value.tags);
|
|
const patch: {
|
|
name: string;
|
|
origin: string;
|
|
tags?: readonly string[];
|
|
password?: PasswordUpdate;
|
|
} = {
|
|
name: typeof value.name === 'string' ? value.name : '',
|
|
origin: typeof value.origin === 'string' ? value.origin : '',
|
|
};
|
|
if (tags) patch.tags = tags;
|
|
if (value.password && typeof value.password === 'object')
|
|
patch.password = value.password as PasswordUpdate;
|
|
return patch;
|
|
};
|
|
const bodyPatch = (body: unknown): InstancePatch => {
|
|
const value = record(body);
|
|
const tags = strings(value.tags);
|
|
const patch: {
|
|
name?: string;
|
|
origin?: string;
|
|
tags?: readonly string[];
|
|
password?: PasswordUpdate;
|
|
} = {};
|
|
if (typeof value.name === 'string') patch.name = value.name;
|
|
if (typeof value.origin === 'string') patch.origin = value.origin;
|
|
if (tags) patch.tags = tags;
|
|
if (value.password && typeof value.password === 'object')
|
|
patch.password = value.password as PasswordUpdate;
|
|
return patch;
|
|
};
|
|
const page = (query: unknown): InstancePageQuery => {
|
|
const value = query as Record<string, unknown>;
|
|
const optionalString = (key: string): string | undefined => {
|
|
const item = value[key];
|
|
if (item === undefined) return undefined;
|
|
if (typeof item !== 'string')
|
|
throw new InstanceServiceError('VALIDATION_FAILED', `Invalid ${key}`);
|
|
return item;
|
|
};
|
|
const integer = (key: 'page' | 'pageSize', maximum?: number) => {
|
|
const item = optionalString(key);
|
|
if (item === undefined) return undefined;
|
|
if (!/^[1-9]\d*$/.test(item))
|
|
throw new InstanceServiceError('VALIDATION_FAILED', `Invalid ${key}`);
|
|
const parsed = Number(item);
|
|
if (!Number.isSafeInteger(parsed) || (maximum !== undefined && parsed > maximum))
|
|
throw new InstanceServiceError('VALIDATION_FAILED', `Invalid ${key}`);
|
|
return parsed;
|
|
};
|
|
const queryValue: {
|
|
page?: number;
|
|
pageSize?: number;
|
|
sort?: 'name' | 'status' | 'freshness' | 'updatedAt';
|
|
direction?: 'asc' | 'desc';
|
|
search?: string;
|
|
tag?: string;
|
|
capabilityStatus?: 'supported' | 'unsupported' | 'auth-required' | 'degraded' | 'unknown';
|
|
freshness?: 'fresh' | 'stale' | 'expired' | 'unknown';
|
|
credentialConfigured?: boolean;
|
|
} = {};
|
|
const parsedPage = integer('page');
|
|
const parsedPageSize = integer('pageSize', 100);
|
|
if (parsedPage !== undefined) queryValue.page = parsedPage;
|
|
if (parsedPageSize !== undefined) queryValue.pageSize = parsedPageSize;
|
|
const sort = optionalString('sort');
|
|
if (sort !== undefined) {
|
|
if (!['name', 'status', 'freshness', 'updatedAt'].includes(sort))
|
|
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid sort');
|
|
queryValue.sort = sort as NonNullable<typeof queryValue.sort>;
|
|
}
|
|
const direction = optionalString('direction');
|
|
if (direction !== undefined) {
|
|
if (direction !== 'asc' && direction !== 'desc')
|
|
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid direction');
|
|
queryValue.direction = direction;
|
|
}
|
|
const search = optionalString('search');
|
|
if (search !== undefined) {
|
|
if (search.length > 200) throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid search');
|
|
queryValue.search = search;
|
|
}
|
|
const tag = optionalString('tag');
|
|
if (tag !== undefined) queryValue.tag = tag;
|
|
const capabilityStatus = optionalString('capabilityStatus');
|
|
if (capabilityStatus !== undefined) {
|
|
if (
|
|
!['supported', 'unsupported', 'auth-required', 'degraded', 'unknown'].includes(
|
|
capabilityStatus,
|
|
)
|
|
)
|
|
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid capabilityStatus');
|
|
queryValue.capabilityStatus = capabilityStatus as NonNullable<
|
|
typeof queryValue.capabilityStatus
|
|
>;
|
|
}
|
|
const freshness = optionalString('freshness');
|
|
if (freshness !== undefined) {
|
|
if (!['fresh', 'stale', 'expired', 'unknown'].includes(freshness))
|
|
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid freshness');
|
|
queryValue.freshness = freshness as NonNullable<typeof queryValue.freshness>;
|
|
}
|
|
const credentialConfigured = optionalString('credentialConfigured');
|
|
if (credentialConfigured !== undefined) {
|
|
if (credentialConfigured !== 'true' && credentialConfigured !== 'false')
|
|
throw new InstanceServiceError('VALIDATION_FAILED', 'Invalid credentialConfigured');
|
|
queryValue.credentialConfigured = credentialConfigured === 'true';
|
|
}
|
|
return queryValue;
|
|
};
|
|
const passwordUpdateSchema = {
|
|
oneOf: [
|
|
{
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['action'],
|
|
properties: { action: { const: 'preserve' } },
|
|
},
|
|
{
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['action', 'password'],
|
|
properties: { action: { const: 'set' }, password: { type: 'string', minLength: 1 } },
|
|
},
|
|
{
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['action'],
|
|
properties: { action: { const: 'clear' } },
|
|
},
|
|
],
|
|
} as const;
|
|
const instanceProperties = {
|
|
name: { type: 'string' },
|
|
origin: { type: 'string' },
|
|
tags: { type: 'array', items: { type: 'string' } },
|
|
password: passwordUpdateSchema,
|
|
} as const;
|
|
const instanceInputSchema = {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['name', 'origin'],
|
|
properties: instanceProperties,
|
|
} as const;
|
|
const instancePatchSchema = {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
minProperties: 1,
|
|
properties: instanceProperties,
|
|
} as const;
|
|
const loginInputSchema = {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: { password: { type: 'string', minLength: 1 } },
|
|
} as const;
|
|
|
|
export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRoutesOptions): void {
|
|
const wrap =
|
|
<T>(
|
|
handler: (
|
|
request: FastifyRequest,
|
|
reply: { header: (name: string, value: string) => unknown },
|
|
) => Promise<T>,
|
|
) =>
|
|
async (
|
|
request: FastifyRequest,
|
|
reply: {
|
|
code: (status: number) => typeof reply;
|
|
header: (name: string, value: string) => typeof reply;
|
|
type: (mime: string) => typeof reply;
|
|
send: (body: unknown) => unknown;
|
|
},
|
|
) => {
|
|
try {
|
|
return await handler(request, reply);
|
|
} catch (error) {
|
|
if (error instanceof InstanceServiceError)
|
|
return reply
|
|
.code(instanceErrorStatus[error.code])
|
|
.type('application/problem+json')
|
|
.send(domainProblem(request, 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(
|
|
problem(
|
|
request,
|
|
status,
|
|
error.code,
|
|
'The destructive operation could not be confirmed.',
|
|
),
|
|
);
|
|
}
|
|
if (error instanceof InstanceLoginServiceError)
|
|
return reply
|
|
.code(error.code === 'NOT_FOUND' ? 404 : 409)
|
|
.type('application/problem+json')
|
|
.send(
|
|
problem(
|
|
request,
|
|
error.code === 'NOT_FOUND' ? 404 : 409,
|
|
error.code,
|
|
'The requested session operation could not be completed.',
|
|
),
|
|
);
|
|
throw error;
|
|
}
|
|
};
|
|
app.get(
|
|
'/api/v1/instances',
|
|
wrap(async (request) => options.instances.list(page(request.query))),
|
|
);
|
|
app.post(
|
|
'/api/v1/instances',
|
|
{ schema: { body: instanceInputSchema } },
|
|
async (request, reply) => {
|
|
try {
|
|
const created = await options.instances.create(bodyInput(request.body));
|
|
return reply.header('ETag', etag(created)).code(201).send(created);
|
|
} catch (error) {
|
|
if (error instanceof InstanceServiceError)
|
|
return reply
|
|
.code(instanceErrorStatus[error.code])
|
|
.type('application/problem+json')
|
|
.send(domainProblem(request, error));
|
|
throw error;
|
|
}
|
|
},
|
|
);
|
|
app.get(
|
|
'/api/v1/instances/:instanceId',
|
|
wrap(async (request, reply) => {
|
|
const result = await options.instances.get(
|
|
(request.params as { instanceId: string }).instanceId,
|
|
);
|
|
if (!result) throw new InstanceServiceError('NOT_FOUND', 'Instance was not found');
|
|
reply.header('ETag', etag(result));
|
|
return result;
|
|
}),
|
|
);
|
|
if (options.resources)
|
|
app.get(
|
|
'/api/v1/instances/:instanceId/resources',
|
|
wrap(async (request) =>
|
|
options.resources!.get((request.params as { instanceId: string }).instanceId),
|
|
),
|
|
);
|
|
app.patch(
|
|
'/api/v1/instances/:instanceId',
|
|
{ schema: { body: instancePatchSchema } },
|
|
wrap(async (request, reply) => {
|
|
const result = await options.instances.update(
|
|
(request.params as { instanceId: string }).instanceId,
|
|
revision(request),
|
|
bodyPatch(request.body),
|
|
);
|
|
reply.header('ETag', etag(result));
|
|
return result;
|
|
}),
|
|
);
|
|
if (options.connections)
|
|
app.post(
|
|
'/api/v1/instances/:instanceId/test-connection',
|
|
wrap(async (request) =>
|
|
options.connections!.test((request.params as { instanceId: string }).instanceId),
|
|
),
|
|
);
|
|
|
|
if (options.login) {
|
|
app.post(
|
|
'/api/v1/instances/:instanceId/login',
|
|
{ schema: { body: loginInputSchema } },
|
|
wrap(async (request) => {
|
|
const body = request.body as Record<string, unknown> | undefined;
|
|
if (body?.password !== undefined && typeof body.password !== 'string')
|
|
throw new InstanceServiceError('VALIDATION_FAILED', 'Password must be a string');
|
|
return options.login!.login(
|
|
(request.params as { instanceId: string }).instanceId,
|
|
body?.password,
|
|
);
|
|
}),
|
|
);
|
|
app.post(
|
|
'/api/v1/instances/:instanceId/logout',
|
|
wrap(async (request) =>
|
|
options.login!.logout((request.params as { instanceId: string }).instanceId),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (options.deletion) {
|
|
if (options.registerDeletionPreparationRoute !== false) {
|
|
app.post(
|
|
'/api/v1/operations/prepare',
|
|
wrap(async (request) => options.deletion!.prepare(request.body as never, request.id)),
|
|
);
|
|
}
|
|
app.delete('/api/v1/instances/:instanceId', async (request, reply) => {
|
|
const handled = await wrap(async (wrappedRequest) => {
|
|
const preparationId = wrappedRequest.headers['x-preparation-id'];
|
|
const confirmationToken = wrappedRequest.headers['x-confirmation-token'];
|
|
if (
|
|
typeof preparationId !== 'string' ||
|
|
preparationId.length === 0 ||
|
|
typeof confirmationToken !== 'string' ||
|
|
confirmationToken.length < 20
|
|
)
|
|
throw new InstanceServiceError('VALIDATION_FAILED', 'Confirmation headers are required');
|
|
return options.deletion!.executeDelete({
|
|
instanceId: (wrappedRequest.params as { instanceId: string }).instanceId,
|
|
revision: revision(wrappedRequest),
|
|
preparationId,
|
|
confirmationToken,
|
|
actor: 'loopback-control-plane',
|
|
requestId: wrappedRequest.id,
|
|
});
|
|
})(request, reply);
|
|
if (reply.sent) return;
|
|
return reply.code(202).send(handled);
|
|
});
|
|
} else {
|
|
app.delete(
|
|
'/api/v1/instances/:instanceId',
|
|
wrap(async () => {
|
|
throw new InstanceServiceError('VALIDATION_FAILED', 'Confirmed deletion is unavailable');
|
|
}),
|
|
);
|
|
}
|
|
}
|