Files
2026-08-28 09:21:06 +09:00

150 lines
6.3 KiB
JavaScript

function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function valuesEqual(left, right) {
return JSON.stringify(left) === JSON.stringify(right);
}
function resolvePointer(rootSchema, reference) {
if (!reference.startsWith("#/")) throw new Error(`외부 schema ref는 지원하지 않습니다: ${reference}`);
return reference
.slice(2)
.split("/")
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
.reduce((current, key) => current?.[key], rootSchema);
}
function matchesType(value, type) {
if (type === "null") return value === null;
if (type === "array") return Array.isArray(value);
if (type === "object") return isRecord(value);
if (type === "integer") return Number.isInteger(value);
if (type === "number") return typeof value === "number" && Number.isFinite(value);
return typeof value === type;
}
function addIssue(issues, path, message) {
issues.push({ path, message });
}
function validateNode(value, schema, rootSchema, path, issues) {
if (schema === false) {
addIssue(issues, path, "허용되지 않는 값입니다.");
return;
}
if (schema === true) return;
if (schema.$ref) {
const target = resolvePointer(rootSchema, schema.$ref);
if (!target) throw new Error(`schema ref를 찾을 수 없습니다: ${schema.$ref}`);
validateNode(value, target, rootSchema, path, issues);
}
if (schema.oneOf) {
const branchResults = schema.oneOf.map((branch) => {
const branchIssues = [];
validateNode(value, branch, rootSchema, path, branchIssues);
return branchIssues;
});
if (branchResults.filter((branchIssues) => branchIssues.length === 0).length !== 1) {
addIssue(issues, path, "허용된 형식 중 정확히 하나와 일치해야 합니다.");
}
return;
}
if (Object.hasOwn(schema, "const") && !valuesEqual(value, schema.const)) {
addIssue(issues, path, `${JSON.stringify(schema.const)} 값이어야 합니다.`);
}
if (schema.enum && !schema.enum.some((candidate) => valuesEqual(value, candidate))) {
addIssue(issues, path, `허용 값: ${schema.enum.join(", ")}`);
}
const expectedTypes = schema.type === undefined ? [] : Array.isArray(schema.type) ? schema.type : [schema.type];
if (expectedTypes.length > 0 && !expectedTypes.some((type) => matchesType(value, type))) {
addIssue(issues, path, `${expectedTypes.join(" 또는 ")} 형식이어야 합니다.`);
return;
}
if (typeof value === "number" && Number.isFinite(value)) {
if (schema.minimum !== undefined && value < schema.minimum) addIssue(issues, path, `${schema.minimum} 이상이어야 합니다.`);
if (schema.maximum !== undefined && value > schema.maximum) addIssue(issues, path, `${schema.maximum} 이하여야 합니다.`);
if (schema.exclusiveMinimum !== undefined && value <= schema.exclusiveMinimum) {
addIssue(issues, path, `${schema.exclusiveMinimum}보다 커야 합니다.`);
}
if (schema.exclusiveMaximum !== undefined && value >= schema.exclusiveMaximum) {
addIssue(issues, path, `${schema.exclusiveMaximum}보다 작아야 합니다.`);
}
}
if (typeof value === "string") {
if (schema.minLength !== undefined && value.length < schema.minLength) addIssue(issues, path, `길이가 ${schema.minLength} 이상이어야 합니다.`);
if (schema.maxLength !== undefined && value.length > schema.maxLength) addIssue(issues, path, `길이가 ${schema.maxLength} 이하여야 합니다.`);
if (schema.pattern && !new RegExp(schema.pattern, "u").test(value)) addIssue(issues, path, `형식 ${schema.pattern}과 일치해야 합니다.`);
if (schema.format === "date-time" && (!/^\d{4}-\d{2}-\d{2}T/.test(value) || !Number.isFinite(Date.parse(value)))) {
addIssue(issues, path, "유효한 ISO 8601 date-time이어야 합니다.");
}
}
if (Array.isArray(value)) {
if (schema.minItems !== undefined && value.length < schema.minItems) addIssue(issues, path, `항목이 ${schema.minItems}개 이상이어야 합니다.`);
if (schema.maxItems !== undefined && value.length > schema.maxItems) addIssue(issues, path, `항목이 ${schema.maxItems}개 이하여야 합니다.`);
if (schema.prefixItems) {
schema.prefixItems.forEach((itemSchema, index) => {
if (index < value.length) validateNode(value[index], itemSchema, rootSchema, `${path}[${index}]`, issues);
});
}
if (schema.items !== undefined) {
const startIndex = schema.prefixItems?.length ?? 0;
for (let index = startIndex; index < value.length; index += 1) {
validateNode(value[index], schema.items, rootSchema, `${path}[${index}]`, issues);
}
}
}
if (isRecord(value)) {
for (const requiredKey of schema.required ?? []) {
if (!Object.hasOwn(value, requiredKey)) addIssue(issues, `${path}.${requiredKey}`, "필수 항목입니다.");
}
const properties = schema.properties ?? {};
for (const [key, childValue] of Object.entries(value)) {
if (Object.hasOwn(properties, key)) {
validateNode(childValue, properties[key], rootSchema, `${path}.${key}`, issues);
} else if (schema.additionalProperties === false) {
addIssue(issues, `${path}.${key}`, "알 수 없는 항목입니다.");
} else if (isRecord(schema.additionalProperties)) {
validateNode(childValue, schema.additionalProperties, rootSchema, `${path}.${key}`, issues);
}
}
}
}
export function validateAgainstSchema(value, schema) {
const issues = [];
validateNode(value, schema, schema, "$", issues);
return issues;
}
const schemaPaths = {
array: "/schemas/array.schema.json",
scene: "/schemas/scene.schema.json",
experiment: "/schemas/experiment.schema.json",
result: "/schemas/result.schema.json",
};
const schemaPromises = new Map();
export async function loadSchema(name) {
if (!Object.hasOwn(schemaPaths, name)) throw new Error(`알 수 없는 schema: ${name}`);
if (!schemaPromises.has(name)) {
schemaPromises.set(
name,
fetch(schemaPaths[name]).then((response) => {
if (!response.ok) throw new Error(`${schemaPaths[name]} 로드 실패: HTTP ${response.status}`);
return response.json();
}),
);
}
return schemaPromises.get(name);
}
export async function validateDocumentStructure(name, value) {
return validateAgainstSchema(value, await loadSchema(name));
}