179 lines
9.5 KiB
JavaScript
179 lines
9.5 KiB
JavaScript
export const PROVIDER_CAPABILITY_CATALOG_SCHEMA_VERSION = "nodedc.external-provider-capability-catalog/v1";
|
|
|
|
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,159}$/;
|
|
const SEMVER = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i;
|
|
const SOURCE_PATH = /^[A-Za-z_][A-Za-z0-9_]*(?:\[\])?(?:\.[A-Za-z_][A-Za-z0-9_]*(?:\[\])?)*$/;
|
|
const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
|
|
const CAPABILITY_CLASSES = new Set(["read", "metadata", "write", "destructive", "control"]);
|
|
const CAPABILITY_STATUSES = new Set(["implemented", "catalogued", "disabled"]);
|
|
const CADENCE_CLASSES = new Set(["hot", "warm", "cold", "on_demand", "never"]);
|
|
const DATA_CLASSES = new Set(["operational", "restricted", "secret", "control", "internal", "unclassified"]);
|
|
const DISPOSITIONS = new Set(["publish", "profile", "catalogue", "forbid"]);
|
|
const FAMILY_STATUSES = new Set(["implemented", "planned", "catalogued", "forbidden"]);
|
|
const SENSITIVE_PATH = /(?:imei|phone|decrypt|vin|numberPlate|driver|creator|access|customFields|stationary(?:Lat|Lon)|(?:^|\.)raw(?:\.|$)|(?:^|\.)params(?:\.|$))/i;
|
|
const CATALOG_KEYS = new Set(["schemaVersion", "providerId", "version", "authority", "coverage", "queryFlags", "dataFamilies", "capabilities"]);
|
|
const AUTHORITY_KEYS = new Set(["kind", "url", "checkedAt"]);
|
|
const COVERAGE_KEYS = new Set(["schemaId", "sourceRoots"]);
|
|
const QUERY_FLAG_KEYS = new Set(["id", "capabilityId", "familyIds"]);
|
|
const FAMILY_KEYS = new Set(["id", "title", "sourcePaths", "cadence", "classification", "disposition", "status", "ontologyEntities", "uiTab", "dataProductIds"]);
|
|
const CAPABILITY_KEYS = new Set(["id", "method", "path", "classification", "status", "familyIds"]);
|
|
|
|
export function validateProviderCapabilityCatalog(value) {
|
|
const errors = [];
|
|
if (!plain(value)) return result(["catalog_must_be_object"]);
|
|
rejectUnknown(value, CATALOG_KEYS, "catalog", errors);
|
|
if (value.schemaVersion !== PROVIDER_CAPABILITY_CATALOG_SCHEMA_VERSION) errors.push("schemaVersion_mismatch");
|
|
identifier(value.providerId, "providerId", errors);
|
|
if (typeof value.version !== "string" || !SEMVER.test(value.version)) errors.push("version_invalid");
|
|
validateAuthority(value.authority, errors);
|
|
validateCoverage(value.coverage, errors);
|
|
|
|
const families = artifactArray(value.dataFamilies, "dataFamilies", validateFamily, errors);
|
|
const capabilities = artifactArray(value.capabilities, "capabilities", validateCapability, errors);
|
|
const flags = artifactArray(value.queryFlags, "queryFlags", validateQueryFlag, errors);
|
|
unique(families.map((item) => item.id), "dataFamilies.id_duplicate", errors);
|
|
unique(capabilities.map((item) => item.id), "capabilities.id_duplicate", errors);
|
|
unique(flags.map((item) => item.id), "queryFlags.id_duplicate", errors);
|
|
|
|
const familyIds = new Set(families.map((item) => item.id));
|
|
const capabilityIds = new Set(capabilities.map((item) => item.id));
|
|
for (const [index, family] of families.entries()) {
|
|
for (const entity of family.ontologyEntities || []) identifier(entity, `dataFamilies[${index}].ontologyEntities`, errors);
|
|
for (const product of family.dataProductIds || []) identifier(product, `dataFamilies[${index}].dataProductIds`, errors);
|
|
if (family.disposition === "publish" && !(family.dataProductIds || []).length) {
|
|
errors.push(`dataFamilies[${index}].publish_requires_data_product`);
|
|
}
|
|
if (new Set(["restricted", "secret", "control", "internal", "unclassified"]).has(family.classification)
|
|
&& family.disposition === "publish") {
|
|
errors.push(`dataFamilies[${index}].restricted_family_must_not_publish`);
|
|
}
|
|
if ((family.sourcePaths || []).some((path) => SENSITIVE_PATH.test(path))
|
|
&& family.classification === "operational") {
|
|
errors.push(`dataFamilies[${index}].sensitive_path_must_be_classified`);
|
|
}
|
|
}
|
|
unique(families.flatMap((family) => family.sourcePaths || []), "dataFamilies.sourcePath_duplicate", errors);
|
|
|
|
for (const [index, capability] of capabilities.entries()) {
|
|
for (const familyId of capability.familyIds || []) {
|
|
if (!familyIds.has(familyId)) errors.push(`capabilities[${index}].family_not_found:${familyId}`);
|
|
}
|
|
if (capability.classification === "control" && capability.status !== "disabled") {
|
|
errors.push(`capabilities[${index}].control_capability_must_be_disabled`);
|
|
}
|
|
if (capability.classification === "destructive" && capability.status !== "disabled") {
|
|
errors.push(`capabilities[${index}].destructive_capability_must_be_disabled`);
|
|
}
|
|
}
|
|
for (const [index, flag] of flags.entries()) {
|
|
if (!capabilityIds.has(flag.capabilityId)) errors.push(`queryFlags[${index}].capability_not_found`);
|
|
for (const familyId of flag.familyIds || []) {
|
|
if (!familyIds.has(familyId)) errors.push(`queryFlags[${index}].family_not_found:${familyId}`);
|
|
}
|
|
}
|
|
|
|
const roots = new Set(families.flatMap((family) => (family.sourcePaths || []).map(sourceRoot)));
|
|
for (const root of value.coverage?.sourceRoots || []) {
|
|
if (!roots.has(root)) errors.push(`coverage.source_root_unaccounted:${root}`);
|
|
}
|
|
return result(errors);
|
|
}
|
|
|
|
function validateAuthority(value, errors) {
|
|
if (!plain(value)) return errors.push("authority_must_be_object");
|
|
rejectUnknown(value, AUTHORITY_KEYS, "authority", errors);
|
|
if (value.kind !== "official-openapi") errors.push("authority.kind_invalid");
|
|
if (typeof value.url !== "string" || !/^https:\/\//.test(value.url)) errors.push("authority.url_invalid");
|
|
if (typeof value.checkedAt !== "string" || Number.isNaN(Date.parse(value.checkedAt))) errors.push("authority.checkedAt_invalid");
|
|
}
|
|
|
|
function validateCoverage(value, errors) {
|
|
if (!plain(value)) return errors.push("coverage_must_be_object");
|
|
rejectUnknown(value, COVERAGE_KEYS, "coverage", errors);
|
|
if (typeof value.schemaId !== "string" || !value.schemaId.trim()) errors.push("coverage.schemaId_invalid");
|
|
strings(value.sourceRoots, "coverage.sourceRoots", errors, { sourcePaths: true, min: 1 });
|
|
}
|
|
|
|
function validateFamily(value, index, errors) {
|
|
const path = `dataFamilies[${index}]`;
|
|
if (!plain(value)) return errors.push(`${path}_must_be_object`);
|
|
rejectUnknown(value, FAMILY_KEYS, path, errors);
|
|
identifier(value.id, `${path}.id`, errors);
|
|
nonempty(value.title, `${path}.title`, errors);
|
|
strings(value.sourcePaths, `${path}.sourcePaths`, errors, { sourcePaths: true, min: 1 });
|
|
if (!CADENCE_CLASSES.has(value.cadence)) errors.push(`${path}.cadence_invalid`);
|
|
if (!DATA_CLASSES.has(value.classification)) errors.push(`${path}.classification_invalid`);
|
|
if (!DISPOSITIONS.has(value.disposition)) errors.push(`${path}.disposition_invalid`);
|
|
if (!FAMILY_STATUSES.has(value.status)) errors.push(`${path}.status_invalid`);
|
|
strings(value.ontologyEntities, `${path}.ontologyEntities`, errors);
|
|
strings(value.dataProductIds, `${path}.dataProductIds`, errors);
|
|
if (value.uiTab !== undefined && value.uiTab !== null) identifier(value.uiTab, `${path}.uiTab`, errors);
|
|
}
|
|
|
|
function validateCapability(value, index, errors) {
|
|
const path = `capabilities[${index}]`;
|
|
if (!plain(value)) return errors.push(`${path}_must_be_object`);
|
|
rejectUnknown(value, CAPABILITY_KEYS, path, errors);
|
|
identifier(value.id, `${path}.id`, errors);
|
|
if (!HTTP_METHODS.has(value.method)) errors.push(`${path}.method_invalid`);
|
|
if (typeof value.path !== "string" || !value.path.startsWith("/api/")) errors.push(`${path}.path_invalid`);
|
|
if (!CAPABILITY_CLASSES.has(value.classification)) errors.push(`${path}.classification_invalid`);
|
|
if (!CAPABILITY_STATUSES.has(value.status)) errors.push(`${path}.status_invalid`);
|
|
strings(value.familyIds, `${path}.familyIds`, errors);
|
|
}
|
|
|
|
function validateQueryFlag(value, index, errors) {
|
|
const path = `queryFlags[${index}]`;
|
|
if (!plain(value)) return errors.push(`${path}_must_be_object`);
|
|
rejectUnknown(value, QUERY_FLAG_KEYS, path, errors);
|
|
if (typeof value.id !== "string" || !/^[a-z][a-z0-9_]{2,63}$/.test(value.id)) errors.push(`${path}.id_invalid`);
|
|
identifier(value.capabilityId, `${path}.capabilityId`, errors);
|
|
strings(value.familyIds, `${path}.familyIds`, errors, { min: 1 });
|
|
}
|
|
|
|
function artifactArray(value, path, validator, errors) {
|
|
if (!Array.isArray(value) || value.length < 1 || value.length > 256) {
|
|
errors.push(`${path}_must_be_bounded_nonempty_array`);
|
|
return [];
|
|
}
|
|
value.forEach((item, index) => validator(item, index, errors));
|
|
return value.filter(plain);
|
|
}
|
|
|
|
function strings(value, path, errors, { sourcePaths = false, min = 0 } = {}) {
|
|
if (!Array.isArray(value) || value.length < min || value.length > 512) return errors.push(`${path}_invalid`);
|
|
for (const item of value) {
|
|
if (typeof item !== "string" || !item.trim() || (sourcePaths && !SOURCE_PATH.test(item))) errors.push(`${path}_invalid`);
|
|
}
|
|
unique(value, `${path}_duplicate`, errors);
|
|
}
|
|
|
|
function identifier(value, path, errors) {
|
|
if (typeof value !== "string" || !IDENTIFIER.test(value)) errors.push(`${path}_invalid`);
|
|
}
|
|
|
|
function nonempty(value, path, errors) {
|
|
if (typeof value !== "string" || !value.trim()) errors.push(`${path}_invalid`);
|
|
}
|
|
|
|
function rejectUnknown(value, allowed, path, errors) {
|
|
if (!plain(value)) return;
|
|
for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`${path}.${key}_not_allowed`);
|
|
}
|
|
|
|
function unique(values, code, errors) {
|
|
if (new Set(values).size !== values.length) errors.push(code);
|
|
}
|
|
|
|
function sourceRoot(path) {
|
|
return String(path).split(".")[0].replace(/\[\]$/, "");
|
|
}
|
|
|
|
function plain(value) {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function result(errors) {
|
|
return { ok: errors.length === 0, errors };
|
|
}
|