dimensionlab-website/src/lib/model/validation.ts

322 lines
7.9 KiB
TypeScript

import Ajv, { type ErrorObject } from "ajv";
import addFormats from "ajv-formats";
import {
DashboardDocumentSchema,
type DashboardDocument,
} from "./schema";
type DashboardValidationIssue = ErrorObject | SemanticValidationIssue;
interface SemanticValidationIssue {
instancePath: string;
schemaPath: string;
keyword: "semantic";
params: Record<string, unknown>;
message: string;
}
export interface DashboardValidationFailure {
valid: false;
errors: string[];
details: DashboardValidationIssue[];
}
export interface DashboardValidationSuccess {
valid: true;
data: DashboardDocument;
}
export type DashboardValidationResult =
| DashboardValidationFailure
| DashboardValidationSuccess;
const ajv = addFormats(
new Ajv({
allErrors: true,
strict: false,
strictNumbers: true,
}),
);
const validateDashboard = ajv.compile<DashboardDocument>(DashboardDocumentSchema);
export function formatValidationErrors(
errors: DashboardValidationIssue[] = [],
): string[] {
return errors.map((error) => {
const path = error.instancePath || "/";
const suffix = formatErrorParams(error);
const message = error.message || "is invalid";
return `${path} ${message}${suffix}`;
});
}
export function validateDashboardDocument(
value: unknown,
): DashboardValidationResult {
const finiteNumberIssues: SemanticValidationIssue[] = [];
const undefinedIssues: SemanticValidationIssue[] = [];
collectFiniteNumberIssues(value, "", finiteNumberIssues);
collectUndefinedIssues(value, "", undefinedIssues);
if (validateDashboard(value)) {
const semanticIssues = [
...finiteNumberIssues,
...undefinedIssues,
...validateSemanticRules(value),
];
if (semanticIssues.length === 0) {
return { valid: true, data: value };
}
return {
valid: false,
errors: formatValidationErrors(semanticIssues),
details: semanticIssues,
};
}
const details = [
...(validateDashboard.errors || []),
...finiteNumberIssues,
...undefinedIssues,
];
return {
valid: false,
errors: formatValidationErrors(details),
details,
};
}
export function assertDashboardDocument(
value: unknown,
): asserts value is DashboardDocument {
const result = validateDashboardDocument(value);
if (!result.valid) {
throw new Error(`Invalid dashboard document: ${result.errors.join("; ")}`);
}
}
export function isDashboardDocument(value: unknown): value is DashboardDocument {
return validateDashboardDocument(value).valid;
}
function formatErrorParams(error: DashboardValidationIssue): string {
if (error.keyword === "additionalProperties") {
const additionalProperty = error.params.additionalProperty;
return typeof additionalProperty === "string"
? `: ${additionalProperty}`
: "";
}
if (error.keyword === "semantic") {
const id = error.params.id;
const ref = error.params.ref;
if (typeof id === "string") return `: ${id}`;
if (typeof ref === "string") return `: ${ref}`;
}
return "";
}
function validateSemanticRules(document: DashboardDocument): SemanticValidationIssue[] {
const issues: SemanticValidationIssue[] = [];
collectDuplicateIdIssues("telemetry", document.telemetry, issues);
collectDuplicateIdIssues("serviceGroups", document.serviceGroups, issues);
collectDuplicateIdIssues("statusStrips", document.statusStrips, issues);
collectDuplicateIdIssues("modules", document.modules || [], issues);
document.serviceGroups.forEach((group, groupIndex) => {
collectDuplicateIdIssues(
`serviceGroups/${groupIndex}/services`,
group.services,
issues,
);
});
document.statusStrips.forEach((strip, stripIndex) => {
collectDuplicateIdIssues(
`statusStrips/${stripIndex}/items`,
strip.items,
issues,
);
});
collectMissingReferenceIssues(
"layout/telemetry",
document.layout.telemetry,
new Set(document.telemetry.map((item) => item.id)),
issues,
);
collectMissingReferenceIssues(
"layout/serviceGroups",
document.layout.serviceGroups,
new Set(document.serviceGroups.map((item) => item.id)),
issues,
);
collectMissingReferenceIssues(
"layout/statusStrips",
document.layout.statusStrips,
new Set(document.statusStrips.map((item) => item.id)),
issues,
);
collectMissingReferenceIssues(
"layout/modules",
document.layout.modules || [],
new Set((document.modules || []).map((item) => item.id)),
issues,
);
document.telemetry.forEach((card, index) => {
if (card.value.kind === "text" && card.thresholds !== undefined) {
issues.push(
semanticIssue(
`/telemetry/${index}/thresholds`,
"must not be set for text metric values",
{ id: card.id },
),
);
}
if (
card.value.kind === "percent" &&
((card.thresholds?.warning !== undefined && card.thresholds.warning > 100) ||
(card.thresholds?.danger !== undefined && card.thresholds.danger > 100))
) {
issues.push(
semanticIssue(
`/telemetry/${index}/thresholds`,
"percent thresholds must be between 0 and 100",
{ id: card.id },
),
);
}
const warning = card.thresholds?.warning;
const danger = card.thresholds?.danger;
if (warning !== undefined && danger !== undefined && warning > danger) {
issues.push(
semanticIssue(
`/telemetry/${index}/thresholds`,
"warning threshold must be less than or equal to danger threshold",
{ id: card.id },
),
);
}
});
return issues;
}
function collectUndefinedIssues(
value: unknown,
path: string,
issues: SemanticValidationIssue[],
) {
if (value === undefined) {
issues.push(
semanticIssue(path || "/", "must be omitted instead of undefined", {}),
);
return;
}
if (Array.isArray(value)) {
value.forEach((item, index) => {
collectUndefinedIssues(item, `${path}/${index}`, issues);
});
return;
}
if (value && typeof value === "object") {
Object.entries(value).forEach(([key, item]) => {
collectUndefinedIssues(item, `${path}/${escapeJsonPointer(key)}`, issues);
});
}
}
function collectFiniteNumberIssues(
value: unknown,
path: string,
issues: SemanticValidationIssue[],
) {
if (typeof value === "number") {
if (!Number.isFinite(value)) {
issues.push(
semanticIssue(path || "/", "must be a finite JSON number", {}),
);
}
return;
}
if (Array.isArray(value)) {
value.forEach((item, index) => {
collectFiniteNumberIssues(item, `${path}/${index}`, issues);
});
return;
}
if (value && typeof value === "object") {
Object.entries(value).forEach(([key, item]) => {
collectFiniteNumberIssues(item, `${path}/${escapeJsonPointer(key)}`, issues);
});
}
}
function collectDuplicateIdIssues(
collectionPath: string,
items: Array<{ id: string }>,
issues: SemanticValidationIssue[],
) {
const seen = new Set<string>();
items.forEach((item, index) => {
if (seen.has(item.id)) {
issues.push(
semanticIssue(
`/${collectionPath}/${index}/id`,
"must be unique within its collection",
{ id: item.id },
),
);
return;
}
seen.add(item.id);
});
}
function collectMissingReferenceIssues(
layoutPath: string,
refs: string[],
validIds: Set<string>,
issues: SemanticValidationIssue[],
) {
refs.forEach((ref, index) => {
if (!validIds.has(ref)) {
issues.push(
semanticIssue(
`/${layoutPath}/${index}`,
"must reference an existing item",
{ ref },
),
);
}
});
}
function semanticIssue(
instancePath: string,
message: string,
params: Record<string, unknown>,
): SemanticValidationIssue {
return {
instancePath,
schemaPath: "",
keyword: "semantic",
params,
message,
};
}
function escapeJsonPointer(value: string): string {
return value.replaceAll("~", "~0").replaceAll("/", "~1");
}