feat(api): add contract-safe jobs read endpoints
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
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';
|
||||
|
||||
export interface JobRoutesOptions {
|
||||
readonly jobs: JobQueryService;
|
||||
}
|
||||
|
||||
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)),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user