loom-schema
Version:
Class-based JSON Schema library with full TypeScript support
532 lines (531 loc) • 20.9 kB
JavaScript
import addFormats from "ajv-formats";
import draft2020 from "ajv/dist/2020.js";
const schemaIdCache = new WeakMap();
function sortedStringify(obj) {
if (obj === null)
return "null";
if (Array.isArray(obj)) {
return "[" + obj.map(sortedStringify).join(",") + "]";
}
if (typeof obj === "object") {
const keys = Reflect.ownKeys(obj).sort();
return ("{" +
keys
.map((key) => JSON.stringify(key) + ":" + sortedStringify(obj[key]))
.join(",") +
"}");
}
return JSON.stringify(obj);
}
function getSchemaId(schema) {
if (schema !== null && typeof schema === "object") {
const cached = schemaIdCache.get(schema);
if (cached)
return cached;
}
const str = sortedStringify(schema);
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
const id = `urn:loom:hash-${Math.abs(hash).toString(16)}`;
if (schema !== null && typeof schema === "object") {
schemaIdCache.set(schema, id);
}
return id;
}
function isShorthandObject(options) {
const reserved = new Set([
"type",
"properties",
"required",
"patternProperties",
"additionalProperties",
"title",
"description",
"$schema",
"$id",
"$defs",
"allOf",
"anyOf",
"oneOf",
"not",
"if",
"then",
"else",
"default",
"examples",
]);
for (const key of Object.keys(options)) {
if (reserved.has(key))
return false;
}
return true;
}
function resolveInput(input, visited = new Map(), usageMap = new Map()) {
return isFragment(input)
? input.toSchema(undefined, visited, usageMap)
: input;
}
function filterInvalidKeywords(schema, _context = "schema") {
return schema;
}
function isFragment(x) {
return x && typeof x.toSchema === "function";
}
function processCompositionKeys(schema, visited, usageMap) {
const compositionKeys = ["allOf", "anyOf", "oneOf"];
for (const key of compositionKeys) {
if (Array.isArray(schema[key])) {
schema[key] = schema[key].map((item) => resolveInput(item, visited, usageMap));
}
}
return schema;
}
function resolveSchemaFragments(schema, visited = new Map(), usageMap = new Map()) {
if (isFragment(schema)) {
return schema.toSchema(undefined, visited, usageMap);
}
if (Array.isArray(schema)) {
return schema.map((item) => resolveSchemaFragments(item, visited, usageMap));
}
if (schema && typeof schema === "object") {
const keysToResolve = [
"if",
"then",
"else",
"not",
"dependentSchemas",
"patternProperties",
"additionalProperties",
"prefixItems",
];
for (const key of Reflect.ownKeys(schema)) {
if (keysToResolve.includes(key)) {
if (Array.isArray(schema[key])) {
schema[key] = schema[key].map((item) => resolveSchemaFragments(item, visited, usageMap));
}
else if (schema[key] && typeof schema[key] === "object") {
if (["dependentSchemas", "patternProperties"].includes(key)) {
for (const subKey of Reflect.ownKeys(schema[key])) {
schema[key][subKey] = resolveSchemaFragments(schema[key][subKey], visited, usageMap);
}
}
else {
schema[key] = resolveSchemaFragments(schema[key], visited, usageMap);
}
}
}
}
}
return schema;
}
function withValidation(fragment) {
fragment.validate = async function (data, ajvOptions) {
return defaultValidate(fragment, data, ajvOptions);
};
fragment.toSchemaWithUsage = function (name) {
const usageMap = new Map();
collectUsage(this, usageMap);
return this.toSchema(name, new Map(), usageMap);
};
return fragment;
}
async function defaultValidate(fragment, data, ajvOptions) {
let schema;
try {
const fragmentWithUsage = fragment;
schema = fragmentWithUsage.toSchemaWithUsage
? fragmentWithUsage.toSchemaWithUsage(undefined)
: fragment.toSchema(undefined, new Map());
if (typeof schema !== "object" && typeof schema !== "boolean") {
throw new Error("Schema generation resulted in invalid type.");
}
}
catch (error) {
return {
valid: false,
errors: [
{
keyword: "schemaGeneration",
message: `Failed to generate schema: ${error instanceof Error ? error.message : String(error)}`,
params: {},
schemaPath: "#",
instancePath: "",
},
],
};
}
let ajv;
let finalOpts;
try {
let requiresDynamic = false;
let requiresUnevaluated = false;
try {
const schemaStr = JSON.stringify(schema);
requiresDynamic =
schemaStr.includes('"$dynamicRef"') ||
schemaStr.includes('"$recursiveRef"') ||
schemaStr.includes('"$dynamicAnchor"');
requiresUnevaluated =
schemaStr.includes('"unevaluatedProperties"') ||
schemaStr.includes('"unevaluatedItems"');
}
catch { }
const defaultOpts = {
allErrors: true,
strict: "log",
...(requiresDynamic && { $dynamicRef: true }),
...(requiresUnevaluated && { unevaluated: true }),
verbose: true,
logger: console,
};
const { customKeywords, customVocabularies, ...coreOpts } = ajvOptions || {};
finalOpts = { ...defaultOpts, ...coreOpts };
ajv = new draft2020(finalOpts);
addFormats(ajv, { mode: "fast", keywords: true });
customVocabularies?.forEach((v) => ajv.addVocabulary(v));
customKeywords?.forEach((k) => ajv.addKeyword(k));
}
catch (error) {
return {
valid: false,
errors: [
{
keyword: "ajvInitialization",
message: `Failed to initialize AJV: ${error instanceof Error ? error.message : String(error)}`,
params: {},
schemaPath: "#",
instancePath: "",
},
],
};
}
let validateFn;
try {
const compileResult = ajv.compile(schema);
validateFn =
typeof compileResult === "function" ? compileResult : await compileResult;
}
catch (error) {
return {
valid: false,
errors: [
{
keyword: "compilation",
message: `Schema failed to compile${schema && schema.title
? " (title: " + schema.title + ")"
: ""}: ${error instanceof Error ? error.message : String(error)}`,
params: {},
schemaPath: error.schemaPath || "#",
instancePath: "",
},
],
};
}
try {
const validResult = await validateFn(data);
return { valid: validResult, errors: validateFn.errors || null };
}
catch (error) {
return {
valid: false,
errors: [
{
keyword: "runtimeValidation",
message: `Error during data validation: ${error instanceof Error ? error.message : String(error)}${schema && schema.title
? " (schema title: " + schema.title + ")"
: ""}`,
params: {},
schemaPath: "#",
instancePath: "",
},
],
};
}
}
function collectUsage(node, usageMap) {
if (isFragment(node)) {
usageMap.set(node, (usageMap.get(node) || 0) + 1);
const schema = node.toSchema(undefined, new Map(), usageMap);
collectUsage(schema, usageMap);
}
else if (Array.isArray(node)) {
node.forEach((item) => collectUsage(item, usageMap));
}
else if (node && typeof node === "object") {
for (const key of Reflect.ownKeys(node)) {
collectUsage(node[key], usageMap);
}
}
}
function createFragment(jsonType, options = {}) {
const baseFragment = {
toSchema: function (name, visited, usageMap) {
visited = visited || new Map();
usageMap = usageMap || new Map();
const base = Object.assign(Object.create(null), { type: jsonType }, options);
let schema = processCompositionKeys(filterInvalidKeywords(base, "fragment"), visited, usageMap);
schema = resolveSchemaFragments(schema, visited, usageMap);
const usageCount = usageMap ? usageMap.get(this) || 0 : 0;
if (!name && (isSimpleSchema(schema) || usageCount <= 1)) {
return schema;
}
if (visited.has(this)) {
return { $ref: visited.get(this) };
}
const id = name ? `urn:loom:${name}` : getSchemaId(schema);
visited.set(this, id);
return name ? wrapWithName(schema, name) : { ...schema, $id: id };
},
validate: async function () {
throw new Error("validate method not attached");
},
};
return withValidation(baseFragment);
}
function isSimpleSchema(schema) {
if (typeof schema !== "object" || schema === null)
return true;
const keys = Reflect.ownKeys(schema).filter((k) => k !== "$id" && k !== "title" && k !== "$ref");
const compositionKeys = ["allOf", "anyOf", "oneOf"];
if (keys.some((k) => compositionKeys.includes(k)))
return false;
return keys.length <= 2;
}
function wrapWithName(schema, name) {
if (typeof schema === "boolean")
return schema;
const merged = Object.assign(Object.create(null), schema, {
title: name,
$id: `urn:loom:${name}`,
});
return processCompositionKeys(filterInvalidKeywords(merged, "namedSchema"), new Map());
}
export const schema = (options = {}) => {
const baseFragment = {
toSchema: function (name, visited, usageMap) {
visited = visited || new Map();
usageMap = usageMap || new Map();
const deepResolve = (schema) => {
if (isFragment(schema)) {
return schema.toSchema(undefined, visited, usageMap);
}
if (Array.isArray(schema)) {
return schema.map(deepResolve);
}
if (schema && typeof schema === "object") {
const resolved = Object.create(null);
for (const key of Reflect.ownKeys(schema)) {
resolved[key] = deepResolve(schema[key]);
}
return resolved;
}
return schema;
};
const base = deepResolve(Object.assign(Object.create(null), options));
let schema = processCompositionKeys(filterInvalidKeywords(base, "base"), visited, usageMap);
schema = resolveSchemaFragments(schema, visited, usageMap);
const usageCount = usageMap ? usageMap.get(this) || 0 : 0;
if (!name && (isSimpleSchema(schema) || usageCount <= 1)) {
return schema;
}
if (visited.has(this)) {
return { $ref: visited.get(this) };
}
const id = name ? `urn:loom:${name}` : getSchemaId(schema);
visited.set(this, id);
return name ? wrapWithName(schema, name) : { ...schema, $id: id };
},
validate: async function () {
throw new Error("validate method not attached");
},
};
return withValidation(baseFragment);
};
export const string = (options = {}) => createFragment("string", options);
export const number = (options = {}) => createFragment("number", options);
export const integer = (options = {}) => createFragment("integer", options);
export const boolean = (options = {}) => createFragment("boolean", options);
export const nil = (options = {}) => createFragment("null", options);
export const array = (options) => {
const baseFragment = {
toSchema: function (name, visited, usageMap) {
visited = visited || new Map();
usageMap = usageMap || new Map();
const base = Object.assign(Object.create(null), { type: "array" }, options);
if (options.items) {
base.items = resolveInput(options.items, visited, usageMap);
}
let schema = processCompositionKeys(filterInvalidKeywords(base, "array"), visited, usageMap);
schema = resolveSchemaFragments(schema, visited, usageMap);
const usageCount = usageMap ? usageMap.get(this) || 0 : 0;
if (!name && (isSimpleSchema(schema) || usageCount <= 1)) {
return schema;
}
if (visited.has(this)) {
return { $ref: visited.get(this) };
}
const id = name ? `urn:loom:${name}` : getSchemaId(schema);
visited.set(this, id);
return name ? wrapWithName(schema, name) : { ...schema, $id: id };
},
validate: async function () {
throw new Error("validate method not attached");
},
};
return withValidation(baseFragment);
};
export function object(options) {
const baseFragment = {
toSchema: function (name, visited, usageMap) {
visited = visited || new Map();
usageMap = usageMap || new Map();
let resolvedOptions = options;
if (resolvedOptions.properties === undefined &&
isShorthandObject(resolvedOptions)) {
resolvedOptions = { properties: resolvedOptions };
}
if (resolvedOptions.properties) {
const newProps = {};
for (const key of Reflect.ownKeys(resolvedOptions.properties)) {
if (typeof key === "string") {
const prop = resolvedOptions.properties[key];
if (prop !== undefined) {
newProps[key] = resolveInput(prop, visited, usageMap);
}
}
}
resolvedOptions.properties = newProps;
}
const base = Object.assign(Object.create(null), { type: "object" }, resolvedOptions);
let schema = processCompositionKeys(filterInvalidKeywords(base, "object"), visited, usageMap);
schema = resolveSchemaFragments(schema, visited, usageMap);
const usageCount = usageMap ? usageMap.get(this) || 0 : 0;
if (!name && (isSimpleSchema(schema) || usageCount <= 1)) {
return schema;
}
if (visited.has(this)) {
return { $ref: visited.get(this) };
}
const id = name ? `urn:loom:${name}` : getSchemaId(schema);
visited.set(this, id);
return name ? wrapWithName(schema, name) : { ...schema, $id: id };
},
validate: async function () {
throw new Error("validate method not attached");
},
};
return withValidation(baseFragment);
}
export const allOf = (fragments, options = {}) => {
const baseFragment = {
toSchema: function (name, visited, usageMap) {
visited = visited || new Map();
usageMap = usageMap || new Map();
const all = fragments.map((frag) => resolveInput(frag, visited, usageMap));
const base = { allOf: all, ...options };
let schema = processCompositionKeys(filterInvalidKeywords(base, "allOf"), visited, usageMap);
schema = resolveSchemaFragments(schema, visited, usageMap);
const usageCount = usageMap ? usageMap.get(this) || 0 : 0;
if (!name && (isSimpleSchema(schema) || usageCount <= 1)) {
return schema;
}
if (visited.has(this)) {
return { $ref: visited.get(this) };
}
const id = name ? `urn:loom:${name}` : getSchemaId(schema);
visited.set(this, id);
return name ? wrapWithName(schema, name) : { ...schema, $id: id };
},
validate: async function () {
throw new Error("validate method not attached");
},
};
return withValidation(baseFragment);
};
export const anyOf = (fragments, options = {}) => {
const baseFragment = {
toSchema: function (name, visited, usageMap) {
visited = visited || new Map();
usageMap = usageMap || new Map();
const any = fragments.map((frag) => resolveInput(frag, visited, usageMap));
const base = { anyOf: any, ...options };
let schema = processCompositionKeys(filterInvalidKeywords(base, "anyOf"), visited, usageMap);
schema = resolveSchemaFragments(schema, visited, usageMap);
const usageCount = usageMap ? usageMap.get(this) || 0 : 0;
if (!name && (isSimpleSchema(schema) || usageCount <= 1)) {
return schema;
}
if (visited.has(this)) {
return { $ref: visited.get(this) };
}
const id = name ? `urn:loom:${name}` : getSchemaId(schema);
visited.set(this, id);
return name ? wrapWithName(schema, name) : { ...schema, $id: id };
},
validate: async function () {
throw new Error("validate method not attached");
},
};
return withValidation(baseFragment);
};
export const oneOf = (fragments, options = {}) => {
const baseFragment = {
toSchema: function (name, visited, usageMap) {
visited = visited || new Map();
usageMap = usageMap || new Map();
const one = fragments.map((frag) => resolveInput(frag, visited, usageMap));
const base = { oneOf: one, ...options };
let schema = processCompositionKeys(filterInvalidKeywords(base, "oneOf"), visited, usageMap);
schema = resolveSchemaFragments(schema, visited, usageMap);
const usageCount = usageMap ? usageMap.get(this) || 0 : 0;
if (!name && (isSimpleSchema(schema) || usageCount <= 1)) {
return schema;
}
if (visited.has(this)) {
return { $ref: visited.get(this) };
}
const id = name ? `urn:loom:${name}` : getSchemaId(schema);
visited.set(this, id);
return name ? wrapWithName(schema, name) : { ...schema, $id: id };
},
validate: async function () {
throw new Error("validate method not attached");
},
};
return withValidation(baseFragment);
};
export const conditional = ({ if: ifFragment, then: thenFragment, else: elseFragment, }, options = {}) => {
const baseFragment = {
toSchema: function (name, visited, usageMap) {
visited = visited || new Map();
usageMap = usageMap || new Map();
const ifSchema = resolveInput(ifFragment, visited, usageMap);
const thenSchema = resolveInput(thenFragment, visited, usageMap);
const elseSchema = resolveInput(elseFragment, visited, usageMap);
let schema = {
if: ifSchema,
then: thenSchema,
else: elseSchema,
...options,
};
schema = resolveSchemaFragments(schema, visited, usageMap);
const usageCount = usageMap ? usageMap.get(this) || 0 : 0;
if (!name && (isSimpleSchema(schema) || usageCount <= 1)) {
return schema;
}
if (visited.has(this)) {
return { $ref: visited.get(this) };
}
const id = name ? `urn:loom:${name}` : getSchemaId(schema);
visited.set(this, id);
return name ? wrapWithName(schema, name) : { ...schema, $id: id };
},
validate: async function () {
throw new Error("validate method not attached");
},
};
return withValidation(baseFragment);
};