|
| 1 | +import { readFile } from "node:fs/promises"; |
| 2 | +import { resolve } from "node:path"; |
| 3 | + |
| 4 | +function isObject(value) { |
| 5 | + return typeof value === "object" && value !== null && !Array.isArray(value); |
| 6 | +} |
| 7 | + |
| 8 | +function fail(errors) { |
| 9 | + const message = ["Statement policy spec validation failed:", ...errors.map((e) => `- ${e}`)].join("\n"); |
| 10 | + throw new Error(message); |
| 11 | +} |
| 12 | + |
| 13 | +function validateRule(rule, path, errors) { |
| 14 | + if (!isObject(rule)) { |
| 15 | + errors.push(`${path} must be an object`); |
| 16 | + return; |
| 17 | + } |
| 18 | + if (typeof rule.id !== "string" || rule.id.length === 0) { |
| 19 | + errors.push(`${path}.id must be a non-empty string`); |
| 20 | + } |
| 21 | + if (typeof rule.description !== "string" || rule.description.length === 0) { |
| 22 | + errors.push(`${path}.description must be a non-empty string`); |
| 23 | + } |
| 24 | + if (typeof rule.path !== "string" || rule.path.length === 0) { |
| 25 | + errors.push(`${path}.path must be a non-empty string`); |
| 26 | + } |
| 27 | +} |
| 28 | + |
| 29 | +export async function loadValidatedStatementPolicySpec(fcpRoot) { |
| 30 | + const specPath = resolve(fcpRoot, "spec/v0/statement-policy.json"); |
| 31 | + const schemaPath = resolve(fcpRoot, "spec/v0/statement-policy.schema.json"); |
| 32 | + |
| 33 | + const spec = JSON.parse(await readFile(specPath, "utf8")); |
| 34 | + const schema = JSON.parse(await readFile(schemaPath, "utf8")); |
| 35 | + const errors = []; |
| 36 | + |
| 37 | + if (!isObject(spec)) { |
| 38 | + fail(["spec root must be an object"]); |
| 39 | + } |
| 40 | + |
| 41 | + const rootRequired = schema.required ?? []; |
| 42 | + for (const key of rootRequired) { |
| 43 | + if (!(key in spec)) errors.push(`missing required root field: ${key}`); |
| 44 | + } |
| 45 | + |
| 46 | + const specVersionPattern = new RegExp(schema.properties.specVersion.pattern); |
| 47 | + const datePattern = new RegExp(schema.properties.specDate.pattern); |
| 48 | + |
| 49 | + if (typeof spec.namespaceUrl !== "string" || spec.namespaceUrl.length === 0) { |
| 50 | + errors.push("namespaceUrl must be a non-empty URI string"); |
| 51 | + } |
| 52 | + if (typeof spec.specVersion !== "string" || !specVersionPattern.test(spec.specVersion)) { |
| 53 | + errors.push(`specVersion must match ${schema.properties.specVersion.pattern}`); |
| 54 | + } |
| 55 | + if (typeof spec.specDate !== "string" || !datePattern.test(spec.specDate)) { |
| 56 | + errors.push(`specDate must match ${schema.properties.specDate.pattern}`); |
| 57 | + } |
| 58 | + |
| 59 | + if (!isObject(spec.predicateRole)) { |
| 60 | + errors.push("predicateRole must be an object"); |
| 61 | + } else { |
| 62 | + if (spec.predicateRole.entityType !== "Concept") { |
| 63 | + errors.push("predicateRole.entityType must equal Concept"); |
| 64 | + } |
| 65 | + if (spec.predicateRole.referenceType !== "NetworkResource") { |
| 66 | + errors.push("predicateRole.referenceType must equal NetworkResource"); |
| 67 | + } |
| 68 | + if (typeof spec.predicateRole.description !== "string" || spec.predicateRole.description.length === 0) { |
| 69 | + errors.push("predicateRole.description must be a non-empty string"); |
| 70 | + } |
| 71 | + if (typeof spec.predicateRole.path !== "string" || spec.predicateRole.path.length === 0) { |
| 72 | + errors.push("predicateRole.path must be a non-empty string"); |
| 73 | + } |
| 74 | + } |
| 75 | + |
| 76 | + if (!Array.isArray(spec.forbiddenPredicates)) { |
| 77 | + errors.push("forbiddenPredicates must be an array"); |
| 78 | + } else { |
| 79 | + for (const [index, rule] of spec.forbiddenPredicates.entries()) { |
| 80 | + validateRule(rule, `forbiddenPredicates[${index}]`, errors); |
| 81 | + if (typeof rule?.predicateIri !== "string" || !rule.predicateIri.startsWith("https://")) { |
| 82 | + errors.push(`forbiddenPredicates[${index}].predicateIri must be an https URI string`); |
| 83 | + } |
| 84 | + } |
| 85 | + } |
| 86 | + |
| 87 | + if (!Array.isArray(spec.typeAssertionPredicates)) { |
| 88 | + errors.push("typeAssertionPredicates must be an array"); |
| 89 | + } else { |
| 90 | + for (const [index, iri] of spec.typeAssertionPredicates.entries()) { |
| 91 | + if (typeof iri !== "string" || !iri.startsWith("https://")) { |
| 92 | + errors.push(`typeAssertionPredicates[${index}] must be an https URI string`); |
| 93 | + } |
| 94 | + } |
| 95 | + } |
| 96 | + |
| 97 | + if (!Array.isArray(spec.guideRules)) { |
| 98 | + errors.push("guideRules must be an array"); |
| 99 | + } else { |
| 100 | + for (const [index, rule] of spec.guideRules.entries()) { |
| 101 | + validateRule(rule, `guideRules[${index}]`, errors); |
| 102 | + } |
| 103 | + } |
| 104 | + |
| 105 | + if (!Array.isArray(spec.canonicalInversePredicates)) { |
| 106 | + errors.push("canonicalInversePredicates must be an array"); |
| 107 | + } else { |
| 108 | + for (const [index, rule] of spec.canonicalInversePredicates.entries()) { |
| 109 | + if (!isObject(rule)) { |
| 110 | + errors.push(`canonicalInversePredicates[${index}] must be an object`); |
| 111 | + continue; |
| 112 | + } |
| 113 | + if (typeof rule.canonicalPredicateIri !== "string" || !rule.canonicalPredicateIri.startsWith("https://")) { |
| 114 | + errors.push(`canonicalInversePredicates[${index}].canonicalPredicateIri must be an https URI string`); |
| 115 | + } |
| 116 | + if (typeof rule.inversePredicateIri !== "string" || !rule.inversePredicateIri.startsWith("https://")) { |
| 117 | + errors.push(`canonicalInversePredicates[${index}].inversePredicateIri must be an https URI string`); |
| 118 | + } |
| 119 | + if (rule.strength !== "SHOULD" && rule.strength !== "MAY") { |
| 120 | + errors.push(`canonicalInversePredicates[${index}].strength must be SHOULD or MAY`); |
| 121 | + } |
| 122 | + if (typeof rule.description !== "string" || rule.description.length === 0) { |
| 123 | + errors.push(`canonicalInversePredicates[${index}].description must be a non-empty string`); |
| 124 | + } |
| 125 | + if (typeof rule.path !== "string" || rule.path.length === 0) { |
| 126 | + errors.push(`canonicalInversePredicates[${index}].path must be a non-empty string`); |
| 127 | + } |
| 128 | + } |
| 129 | + } |
| 130 | + |
| 131 | + if (errors.length > 0) { |
| 132 | + fail(errors); |
| 133 | + } |
| 134 | + |
| 135 | + return spec; |
| 136 | +} |
0 commit comments