Files
multi-simadmin/apps/api/src/interface/http/job-routes.ts
T
chick f9186bd851 feat(api): absorb the Hub control plane into the local instance model
Add central notification channels, rules, queue and delivery logs, fleet
organization groups and tags, device discovery, the device action catalog,
instance module reads, the log centre, connection settings and system
maintenance as native /api/v1 routes backed by the existing secret store,
audit trail and pinned upstream transport.
2026-09-05 18:53:04 +08:00

175 lines
5.2 KiB
TypeScript

import { JOB_STATUSES, type JobPageQuery, type JobStatus } from '@multi-simadmin/contracts';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { JobQueryError, JobQueryService } from '../../application/jobs/job-query-service.js';
import {
JobCancelError,
JobReconcileService,
} from '../../application/jobs/job-reconcile-service.js';
export interface JobRoutesOptions {
readonly jobs: JobQueryService;
readonly reconcile?: JobReconcileService;
}
const QUERY_KEYS = new Set([
'page',
'pageSize',
'sort',
'direction',
'status',
'operationId',
'rootJobId',
'instanceId',
]);
const errorResponse = {
VALIDATION_FAILED: {
status: 400,
title: 'Bad Request',
detail: 'The jobs query is invalid.',
},
NOT_FOUND: {
status: 404,
title: 'Not Found',
detail: 'The requested job does not exist.',
},
UNREPRESENTABLE: {
status: 503,
title: 'Service Unavailable',
detail: 'The requested job data is temporarily unavailable.',
},
} as const;
function invalid(): never {
throw new JobQueryError('VALIDATION_FAILED', 'Invalid jobs query');
}
function parseQuery(query: unknown): JobPageQuery {
if (query === null || typeof query !== 'object' || Array.isArray(query)) invalid();
const input = query as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (!QUERY_KEYS.has(key)) invalid();
if (typeof input[key] !== 'string') invalid();
}
const scalar = (key: string): string | undefined => {
const value = input[key];
return value === undefined ? undefined : (value as string);
};
const integer = (key: 'page' | 'pageSize', fallback: number, maximum: number): number => {
const value = scalar(key);
if (value === undefined) return fallback;
if (!/^[1-9]\d*$/u.test(value)) invalid();
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed > maximum) invalid();
return parsed;
};
const pageSize = integer('pageSize', 25, 100);
const maximumPage = Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1;
const result: {
page: number;
pageSize: number;
sort?: 'createdAt' | 'status' | 'operationId';
direction: 'asc' | 'desc';
status?: JobStatus;
operationId?: string;
rootJobId?: string;
instanceId?: string;
} = {
page: integer('page', 1, maximumPage),
pageSize,
direction: 'asc',
};
const sort = scalar('sort');
if (sort !== undefined) {
if (sort !== 'createdAt' && sort !== 'status' && sort !== 'operationId') invalid();
result.sort = sort;
}
const direction = scalar('direction');
if (direction !== undefined) {
if (direction !== 'asc' && direction !== 'desc') invalid();
result.direction = direction;
}
const status = scalar('status');
if (status !== undefined) {
if (!(JOB_STATUSES as readonly string[]).includes(status)) invalid();
result.status = status as JobStatus;
}
for (const key of ['operationId', 'rootJobId', 'instanceId'] as const) {
const value = scalar(key);
if (value !== undefined) {
if (value.length === 0) invalid();
result[key] = value;
}
}
return result;
}
export function registerJobRoutes(app: FastifyInstance, options: JobRoutesOptions): void {
const handler =
<T>(action: (request: FastifyRequest) => T) =>
async (request: FastifyRequest, reply: FastifyReply) => {
try {
return action(request);
} catch (error) {
if (!(error instanceof JobQueryError)) throw error;
const mapped = errorResponse[error.code];
return reply.code(mapped.status).type('application/problem+json').send({
type: 'about:blank',
title: mapped.title,
status: mapped.status,
code: error.code,
detail: mapped.detail,
requestId: request.id,
});
}
};
app.get(
'/api/v1/jobs',
handler((request) => options.jobs.list(parseQuery(request.query))),
);
app.get(
'/api/v1/jobs/:jobId',
handler((request) => options.jobs.get((request.params as { jobId: string }).jobId)),
);
const reconcile = options.reconcile;
if (reconcile) {
app.get('/api/v1/jobs/reconcile', async () => reconcile.summary());
app.post('/api/v1/jobs/reconcile', () => reconcile.reconcile());
app.post('/api/v1/jobs/:jobId/cancel', async (request, reply) => {
const jobId = (request.params as { jobId: string }).jobId;
try {
reconcile.cancel(jobId);
} catch (error) {
if (!(error instanceof JobCancelError)) throw error;
const mapped =
error.code === 'NOT_FOUND'
? {
status: 404,
title: 'Not Found',
detail: 'The requested job does not exist.',
}
: {
status: 409,
title: 'Conflict',
detail: 'The job already reached a terminal state.',
};
return reply.code(mapped.status).type('application/problem+json').send({
type: 'about:blank',
title: mapped.title,
status: mapped.status,
code: error.code,
detail: mapped.detail,
requestId: request.id,
});
}
return reply.code(202).send(options.jobs.get(jobId));
});
}
}