Files
multi-simadmin/apps/web/src/jobs/jobs-api-data-source.ts
T

315 lines
9.8 KiB
TypeScript

import {
ATTEMPT_STATUSES,
JOB_ITEM_TERMINAL_STATES,
JOB_STATUSES,
MAX_PAGE_SIZE,
SORT_DIRECTIONS,
type Attempt,
type Job,
type JobItem,
type JobPage,
type JobPageQuery,
type ProblemDetails,
type ValidationIssue,
} from '@multi-simadmin/contracts';
import type { JobsDataSource } from './jobs-page.js';
const MAX_STRING_LENGTH = 256;
const QUERY_KEYS = [
'page',
'pageSize',
'sort',
'direction',
'status',
'operationId',
'rootJobId',
'instanceId',
] as const;
const ITEM_KEYS = ['id', 'targetId', 'state', 'sourceJobItemId', 'error'] as const;
const ATTEMPT_KEYS = ['id', 'state', 'startedAt', 'finishedAt'] as const;
const PROBLEM_KEYS = [
'type',
'title',
'status',
'detail',
'code',
'requestId',
'validation',
] as const;
function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function hasExactKeys(
value: Record<string, unknown>,
required: readonly string[],
optional: readonly string[] = [],
): boolean {
const keys = Object.keys(value);
const allowed = new Set([...required, ...optional]);
return (
required.every((key) => Object.hasOwn(value, key)) && keys.every((key) => allowed.has(key))
);
}
function boundedString(value: unknown): value is string {
return typeof value === 'string' && value.length > 0 && value.length <= MAX_STRING_LENGTH;
}
function canonicalTimestamp(value: unknown): value is string {
if (!boundedString(value)) return false;
const time = Date.parse(value);
return Number.isFinite(time) && new Date(time).toISOString() === value;
}
function safePositiveInteger(value: unknown, maximum = Number.MAX_SAFE_INTEGER): value is number {
return Number.isSafeInteger(value) && (value as number) >= 1 && (value as number) <= maximum;
}
function parseValidationIssue(value: unknown): ValidationIssue | null {
const source = record(value);
if (
!source ||
!hasExactKeys(source, ['field', 'code', 'message']) ||
!boundedString(source.field) ||
!boundedString(source.code) ||
!boundedString(source.message)
)
return null;
return { field: source.field, code: source.code, message: source.message };
}
function parseProblem(value: unknown): ProblemDetails | null {
const source = record(value);
if (
!source ||
!hasExactKeys(source, PROBLEM_KEYS.slice(0, 6), ['validation']) ||
!boundedString(source.type) ||
!boundedString(source.title) ||
!Number.isSafeInteger(source.status) ||
(source.status as number) < 400 ||
(source.status as number) > 599 ||
!boundedString(source.detail) ||
!boundedString(source.code) ||
!boundedString(source.requestId)
)
return null;
let validation: ValidationIssue[] | undefined;
if (source.validation !== undefined) {
if (!Array.isArray(source.validation) || source.validation.length > MAX_PAGE_SIZE) return null;
validation = [];
for (const candidate of source.validation) {
const issue = parseValidationIssue(candidate);
if (!issue) return null;
validation.push(issue);
}
}
return {
type: source.type,
title: source.title,
status: source.status as number,
detail: source.detail,
code: source.code,
requestId: source.requestId,
...(validation === undefined ? {} : { validation }),
};
}
function parseItem(value: unknown): JobItem | null {
const source = record(value);
if (
!source ||
!hasExactKeys(source, ITEM_KEYS.slice(0, 3), ['sourceJobItemId', 'error']) ||
!boundedString(source.id) ||
!boundedString(source.targetId) ||
typeof source.state !== 'string' ||
!(JOB_ITEM_TERMINAL_STATES as readonly string[]).includes(source.state)
)
return null;
const sourceJobItemId = source.sourceJobItemId;
if (sourceJobItemId !== undefined && !boundedString(sourceJobItemId)) return null;
const error = source.error === undefined ? undefined : parseProblem(source.error);
if (source.error !== undefined && !error) return null;
const result: JobItem = {
id: source.id,
targetId: source.targetId,
state: source.state as JobItem['state'],
...(sourceJobItemId === undefined ? {} : { sourceJobItemId }),
};
return error ? { ...result, error } : result;
}
function parseAttempt(value: unknown): Attempt | null {
const source = record(value);
if (
!source ||
!hasExactKeys(source, ATTEMPT_KEYS.slice(0, 3), ['finishedAt']) ||
!boundedString(source.id) ||
typeof source.state !== 'string' ||
!(ATTEMPT_STATUSES as readonly string[]).includes(source.state) ||
!canonicalTimestamp(source.startedAt) ||
(source.finishedAt !== undefined && !canonicalTimestamp(source.finishedAt))
)
return null;
return {
id: source.id,
state: source.state as Attempt['state'],
startedAt: source.startedAt,
...(source.finishedAt === undefined ? {} : { finishedAt: source.finishedAt as string }),
};
}
function parseJob(value: unknown): Job | null {
const source = record(value);
if (
!source ||
!hasExactKeys(
source,
['id', 'operationId', 'status', 'rootJobId', 'items', 'attempts', 'createdAt'],
['retryOfJobId'],
) ||
!boundedString(source.id) ||
!boundedString(source.operationId) ||
typeof source.status !== 'string' ||
!(JOB_STATUSES as readonly string[]).includes(source.status) ||
!boundedString(source.rootJobId) ||
!Array.isArray(source.items) ||
source.items.length > MAX_PAGE_SIZE ||
!Array.isArray(source.attempts) ||
source.attempts.length > MAX_PAGE_SIZE ||
!canonicalTimestamp(source.createdAt) ||
(source.retryOfJobId !== undefined && !boundedString(source.retryOfJobId))
)
return null;
const items: JobItem[] = [];
for (const candidate of source.items) {
const item = parseItem(candidate);
if (!item) return null;
items.push(item);
}
const attempts: Attempt[] = [];
for (const candidate of source.attempts) {
const attempt = parseAttempt(candidate);
if (!attempt) return null;
attempts.push(attempt);
}
return {
id: source.id,
operationId: source.operationId,
status: source.status as Job['status'],
...(source.retryOfJobId === undefined ? {} : { retryOfJobId: source.retryOfJobId as string }),
rootJobId: source.rootJobId,
items,
attempts,
createdAt: source.createdAt,
};
}
function parseJobPage(value: unknown): JobPage {
const source = record(value);
if (!source || !hasExactKeys(source, ['items', 'page']) || !Array.isArray(source.items))
throw new Error('Jobs response is invalid.');
const page = record(source.page);
if (
!page ||
!hasExactKeys(page, ['page', 'pageSize', 'total']) ||
!safePositiveInteger(page.page) ||
!safePositiveInteger(page.pageSize, MAX_PAGE_SIZE) ||
!Number.isSafeInteger(page.total) ||
(page.total as number) < 0 ||
source.items.length > MAX_PAGE_SIZE ||
source.items.length > (page.pageSize as number) ||
source.items.length > (page.total as number)
)
throw new Error('Jobs response is invalid.');
const items: Job[] = [];
for (const candidate of source.items) {
const item = parseJob(candidate);
if (!item) throw new Error('Jobs response is invalid.');
items.push(item);
}
return {
items,
page: {
page: page.page as number,
pageSize: page.pageSize as number,
total: page.total as number,
},
};
}
function queryString(query: JobPageQuery): string {
if (Object.keys(query).some((key) => !(QUERY_KEYS as readonly string[]).includes(key)))
throw new Error('Jobs query is invalid.');
const params = new URLSearchParams();
for (const key of QUERY_KEYS) {
const value = query[key];
if (value === undefined) continue;
const valid =
(key === 'page' && safePositiveInteger(value)) ||
(key === 'pageSize' && safePositiveInteger(value, MAX_PAGE_SIZE)) ||
(key === 'sort' && ['createdAt', 'status', 'operationId'].includes(value as string)) ||
(key === 'direction' && (SORT_DIRECTIONS as readonly unknown[]).includes(value)) ||
(key === 'status' && (JOB_STATUSES as readonly unknown[]).includes(value)) ||
(['operationId', 'rootJobId', 'instanceId'].includes(key) && boundedString(value));
if (!valid) throw new Error('Jobs query is invalid.');
params.set(key, String(value));
}
const serialized = params.toString();
return serialized ? `?${serialized}` : '';
}
export class JobsApiError extends Error {
readonly status: number;
readonly code: string;
readonly requestId: string;
constructor(status: number, code: string, requestId: string) {
super(`Jobs request failed (${status}; code=${code}; requestId=${requestId}).`);
this.name = 'JobsApiError';
this.status = status;
this.code = code;
this.requestId = requestId;
}
}
async function responseError(response: Response): Promise<JobsApiError> {
let code = 'UNKNOWN';
let requestId = 'unavailable';
try {
const problem = parseProblem(await response.json());
if (problem && problem.status === response.status) {
code = problem.code;
requestId = problem.requestId;
}
} catch {
// Never read or expose raw response text or Problem detail/title.
}
return new JobsApiError(response.status, code, requestId);
}
export function createJobsApiDataSource(fetcher: typeof fetch = fetch): JobsDataSource {
return {
async load(query, signal): Promise<JobPage> {
const response = await fetcher(`/api/v1/jobs${queryString(query)}`, {
method: 'GET',
credentials: 'same-origin',
headers: { accept: 'application/json' },
signal,
});
if (!response.ok) throw await responseError(response);
let body: unknown;
try {
body = await response.json();
} catch {
throw new Error('Jobs response is invalid.');
}
return parseJobPage(body);
},
};
}