import JSONQuery, { Input } from '@sagold/json-query'; import cloneDeep from 'lodash.clonedeep'; import DxComponentInputSchema from './manifest/v1/DxComponentInputSchema.json'; import DxComponentIcons from './manifest/v1/DxComponentIcons.json'; import DxContentMetaSchema from './manifest/v1/DxContentMetaSchema.json'; import MatrixAssetSchema from './manifest/v1/MatrixAssetSchema.json'; import Draft07Schema from './manifest/v1/Draft-07.json'; import JobV1 from './manifest/v1/JobV1.json'; import v1 from './manifest/v1/v1.json'; import { JSONSchema, Draft } from '@squiz/json-schema-library'; import { draft07Config } from '@squiz/json-schema-library'; import { AnyPrimitiveType, AnyResolvableType, ResolverContext, TypeResolver } from './jsonTypeResolution/TypeResolver'; import { JsonResolutionError } from './errors/JsonResolutionError'; import { processValidationResult } from './processValidationResult'; import { defaultConfig } from './defaultDraftConfig'; import { FORMATTED_TEXT_SCHEMA_ID } from './formatted-text/v1/formattedTextConstants'; export const ComponentInputMetaSchema: MetaSchemaInput = { root: DxComponentInputSchema, remotes: { 'DxComponentInputSchema.json/DxContentMetaSchema.json': DxContentMetaSchema, }, }; export const RenderInputMetaSchema: MetaSchemaInput = { root: Draft07Schema, }; export const ManifestV1MetaSchema: MetaSchemaInput = { root: v1, remotes: { 'DxComponentInputSchema.json/DxContentMetaSchema.json': DxContentMetaSchema, '/DxComponentInputSchema.json': DxComponentInputSchema, '/DxComponentIcons.json': DxComponentIcons, '/MatrixAssetSchema.json': MatrixAssetSchema, 'http://json-schema.org/draft-07/schema': Draft07Schema, 'http://json-schema.org/draft-07/schema#': Draft07Schema, }, }; export const JobV1MetaSchema: MetaSchemaInput = { root: JobV1, remotes: { 'DxComponentInputSchema.json/DxContentMetaSchema.json': DxContentMetaSchema, '/DxComponentInputSchema.json': DxComponentInputSchema, 'http://json-schema.org/draft-07/schema': Draft07Schema, 'http://json-schema.org/draft-07/schema#': Draft07Schema, }, }; interface MetaSchemaInput { root: JSONSchema; remotes?: Record; } /** * A service that can be used to validate and resolve JSON against a schema. */ export class JSONSchemaService

{ schema: Draft; constructor(private typeResolver: TypeResolver, metaSchema: MetaSchemaInput) { this.schema = new Draft( { ...defaultConfig, resolveRef: (schema, rootSchema) => this.doResolveRef(schema, rootSchema), validate: (core, data, schema, pointer) => defaultConfig.validate(core, data, schema, pointer), resolveOneOf: (core, data, schema, pointer) => defaultConfig.resolveOneOf(core, data, schema, pointer), each: (core, data, callback, schema, pointer = '#') => { // Stop iterating when FormattedText if (schema?.type === 'FormattedText' || schema?.$id === FORMATTED_TEXT_SCHEMA_ID) { schema = core.resolveRef(schema); callback(schema, data, pointer); } else { // CRITICAL FIX: Preserve data integrity during allOf schema traversal // Only apply protection for allOf schemas that contain arrays with primitive types if (schema?.allOf && Array.isArray(schema.allOf) && this.hasArraysWithPrimitiveTypes(schema)) { // Get the original input data stored at the beginning of resolveInput const originalInputData = (this as any).__originalInputData; // Create a deep clone to prevent Draft library from corrupting original data const safeDataForTraversal = JSON.parse(JSON.stringify(data)); // Use the safe clone for traversal, but preserve callbacks with original data defaultConfig.each( core, safeDataForTraversal, (resolvedSchema, corruptedValue, resolvedPointer) => { // Only preserve data for paths that contain arrays of SquizLink/SquizImage if (this.isProtectedPrimitivePath(resolvedSchema, resolvedPointer)) { const originalValueAtPointer = originalInputData ? this.getValueAtPointer(originalInputData, resolvedPointer) : corruptedValue; callback(resolvedSchema, originalValueAtPointer, resolvedPointer); } else { // Use normal resolution for non-primitive arrays callback(resolvedSchema, corruptedValue, resolvedPointer); } }, schema, pointer, ); } else { defaultConfig.each(core, data, callback, schema, pointer); } } }, }, metaSchema.root, ); for (const [key, value] of Object.entries(metaSchema.remotes || {})) { this.schema.addRemoteSchema(key, value); } for (const schema of this.typeResolver.validationSchemaDefinitions) { // Please find a better way of doing this. this.schema.addRemoteSchema(`/${schema.title}.json`, schema as JSONSchema); this.schema.addRemoteSchema(`#/${schema.title}.json`, schema as JSONSchema); } } private doResolveRef(schema: JSONSchema, rootSchema: JSONSchema): JSONSchema { const initialRef = draft07Config.resolveRef(schema, rootSchema); if (!initialRef) return initialRef; if (!this.typeResolver.isPrimitiveType(initialRef.type)) return initialRef; const validationSchemas = this.typeResolver.getValidationSchemaForPrimitive(initialRef.type); // All validation schemas are pre-compiled as remote schemas and are referenced below const fullValidationSchema = { oneOf: validationSchemas.map((schema) => ({ $ref: `${schema.title}.json` })), }; return this.schema.compileSchema(fullValidationSchema); } /** * Recursively check if a schema contains allOf combinators that could cause mutation */ private hasAllOfCombinator(schema: any, visited: WeakSet = new WeakSet()): boolean { if (!schema || typeof schema !== 'object') return false; // Prevent infinite recursion from circular references if (visited.has(schema)) return false; visited.add(schema); // Direct allOf check if (schema.allOf) return true; // Check in properties if (schema.properties) { for (const prop of Object.values(schema.properties)) { if (this.hasAllOfCombinator(prop, visited)) return true; } } // Check in items (arrays) if (schema.items && this.hasAllOfCombinator(schema.items, visited)) return true; // Check in nested combinators if (schema.oneOf?.some((subSchema: any) => this.hasAllOfCombinator(subSchema, visited))) return true; if (schema.anyOf?.some((subSchema: any) => this.hasAllOfCombinator(subSchema, visited))) return true; if (schema.not && this.hasAllOfCombinator(schema.not, visited)) return true; // Check in conditional schemas (if/then/else) if (schema.if && this.hasAllOfCombinator(schema.if, visited)) return true; if (schema.then && this.hasAllOfCombinator(schema.then, visited)) return true; if (schema.else && this.hasAllOfCombinator(schema.else, visited)) return true; return false; } /** * Validate an input value against a specified schema * @throws {SchemaValidationError} if the input is invalid * @returns true if the input is valid */ public validateInput(input: unknown, inputSchema: JSONSchema = this.schema.rootSchema): true | never { inputSchema = this.schema.compileSchema(inputSchema); // Only clone if schema contains allOf combinators that could cause mutation // This optimizes performance by avoiding unnecessary cloning for simple schemas const needsCloning = this.hasAllOfCombinator(inputSchema); const inputToValidate = needsCloning ? cloneDeep(input) : input; const errors = this.schema.validate(inputToValidate, inputSchema); return processValidationResult(errors); } /** * Resolve an input object by replacing all resolvable shapes with their resolved values * @param input any input object which matches the input schema * @param inputSchema a JSONSchema which provides type information about the input object * @returns the input object with all resolvable shapes resolved */ public async resolveInput(input: Input, inputSchema: JSONSchema, ctx: ResolverContext = {}) { // Store the original input data to prevent corruption during nested allOf processing (this as any).__originalInputData = JSON.parse(JSON.stringify(input)); const setters: Array Input>> = []; this.schema.each( input, async (schema, value, pointer) => { // Debug logging for investigation of resolution path // console.debug('JSONSchemaService.each', { // pointer, // title: (schema as any)?.title, // type: (schema as any)?.type, // value, // }); // Guard: if value is an array of already-resolved primitives (e.g., SquizLink/SquizImage), skip if (Array.isArray(value)) { const isLinkLike = (v: any) => v && typeof v === 'object' && typeof v.text === 'string' && typeof v.url === 'string'; const isImageLike = (v: any) => v && typeof v === 'object' && typeof v.name === 'string' && v.imageVariations && typeof v.imageVariations === 'object'; const allResolvedPrimitives = value.every((v) => isLinkLike(v) || isImageLike(v)); if (allResolvedPrimitives) { return; } } // Bug in library for Array item schemas which won't resolve the oneOf schema if (Array.isArray(schema?.oneOf)) { const oldSchema = schema; schema = this.schema.resolveOneOf(value, schema); schema.oneOfSchema = oldSchema; } // Skip resolution if this is already a complete SquizLink or SquizImage // This prevents primitive types from being incorrectly processed when they're already in final form if (this.isAlreadyResolvedPrimitive(schema, value)) { return; } if (!this.typeResolver.isResolvableSchema(schema)) return; // If its a resolvable schema, it should exist in a oneOf array with other schemas // Including a primitive schema const allPossibleSchemaTitles: Array = schema.oneOfSchema.oneOf.map((o: JSONSchema) => o.$ref.replace('.json', ''), ); const primitiveSchema = allPossibleSchemaTitles.find((title) => this.typeResolver.isPrimitiveType(title)); if (!primitiveSchema) return; const resolver = this.typeResolver.tryGetResolver(primitiveSchema, schema as any); if (!resolver) return; const setResolvedData = Promise.resolve() .then(() => resolver(value, ctx)) .then((resolvedData) => (item: typeof input) => JSONQuery.set(item, pointer, resolvedData, 'replace' as any)) .catch((e) => Promise.reject(new JsonResolutionError(e, pointer, value))); setters.push(setResolvedData); }, inputSchema, ); const potentialResolutionErrors = []; for (const resolveResult of await Promise.allSettled(setters)) { if (resolveResult.status === 'rejected') { potentialResolutionErrors.push(resolveResult.reason); continue; } input = resolveResult.value(input); } if (potentialResolutionErrors.length) { throw new Error(`Error(s) occurred when resolving JSON:\n${potentialResolutionErrors.join('\n')}`); } // Clean up stored original data to prevent memory leaks delete (this as any).__originalInputData; return input; } /** * Check if the allOf schema contains arrays with primitive types (SquizLink/SquizImage/FormattedText) * Used to determine if we need to apply data preservation */ private hasArraysWithPrimitiveTypes(schema: any): boolean { if (!schema?.allOf || !Array.isArray(schema.allOf)) return false; const checkForPrimitiveArrays = (obj: any): boolean => { if (!obj || typeof obj !== 'object') return false; // Check if this is an array with SquizLink/SquizImage/FormattedText items if (obj.type === 'array' && obj.items?.type) { return obj.items.type === 'SquizLink' || obj.items.type === 'SquizImage' || obj.items.type === 'FormattedText'; } // Recursively check properties if (obj.properties) { return Object.values(obj.properties).some((prop: any) => checkForPrimitiveArrays(prop)); } // Check in then/else branches and nested allOf if (obj.then && checkForPrimitiveArrays(obj.then)) return true; if (obj.else && checkForPrimitiveArrays(obj.else)) return true; if (obj.allOf && Array.isArray(obj.allOf)) { return obj.allOf.some((item: any) => checkForPrimitiveArrays(item)); } return false; }; // Check both inside allOf conditions AND in the main schema properties // because primitive arrays in main properties can be affected by allOf processing const hasInAllOf = schema.allOf.some((condition: any) => checkForPrimitiveArrays(condition)); const hasInMainSchema = checkForPrimitiveArrays(schema); return hasInAllOf || hasInMainSchema; } /** * Check if a specific schema path should be protected from data corruption * Only protects arrays of SquizLink/SquizImage types */ private isProtectedPrimitivePath(resolvedSchema: any, _resolvedPointer: string): boolean { if (!resolvedSchema) return false; // Check if this is an array of SquizLink/SquizImage if (resolvedSchema.type === 'array' && resolvedSchema.items?.type) { return resolvedSchema.items.type === 'SquizLink' || resolvedSchema.items.type === 'SquizImage'; } // Check if this is a direct SquizLink/SquizImage type if (resolvedSchema.type === 'SquizLink' || resolvedSchema.type === 'SquizImage') { return true; } return false; } /** * Get value at a specific JSON pointer path from the data structure * Used to preserve original data during schema traversal */ private getValueAtPointer(data: any, pointer: string): any { if (pointer === '#') return data; // Remove the '#/' prefix and split by '/' const path = pointer.replace(/^#\//, '').split('/'); let current = data; for (const segment of path) { if (current === null || current === undefined) return undefined; // Handle array indices if (Array.isArray(current)) { const index = parseInt(segment, 10); if (isNaN(index) || index < 0 || index >= current.length) return undefined; current = current[index]; } else if (typeof current === 'object') { // Handle object properties current = current[segment]; } else { return undefined; } } return current; } /** * Check if the value is already a resolved primitive type (SquizLink, SquizImage) * and doesn't need further resolution */ private isAlreadyResolvedPrimitive(schema: JSONSchema, value: unknown): boolean { if (!value || typeof value !== 'object') return false; // Schema hints: check either title or type when present const schemaType = (schema as any)?.type ?? (schema as any)?.title; // Shape-based detection for SquizLink const v: any = value; const looksLikeSquizLink = typeof v.text === 'string' && typeof v.url === 'string'; if (looksLikeSquizLink) { if (schemaType === 'SquizLink' || schemaType === undefined) return true; // Even if schema hint differs (e.g., through allOf), preserve already-formed link objects return true; } // Shape-based detection for SquizImage const looksLikeSquizImage = typeof v.name === 'string' && v.imageVariations && typeof v.imageVariations === 'object'; if (looksLikeSquizImage) { if (schemaType === 'SquizImage' || schemaType === undefined) return true; return true; } return false; } }