UNPKG

loom-schema

Version:

Class-based JSON Schema library with full TypeScript support

532 lines (531 loc) 20.9 kB
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); };