File

libs/core/mcp/src/utils/json-schema-ref-resolver.ts

Indexable

[key: string]:
const { get } = require('lodash-es');

/**
 * JSON Schema Reference Resolver
 *
 * This utility resolves $ref pointers in JSON schemas by:
 * 1. Starting at a sub-schema and identifying all $refs
 * 2. Loading external schema references and placing them in a definitions block
 * 3. Updating $refs to point to local JSON Pointers
 * 4. Recursively processing nested $refs within resolved definitions
 * 5. Preventing circular references by checking already-processed schemas
 * 6. Optionally filtering properties based on FilterOptions
 */

const DYNAMIC_VALIDATION_KEYS = [
  'DYNAMIC_MIN',
  'DYNAMIC_MAX',
  'DYNAMIC_MIN_DATE',
  'DYNAMIC_MAX_DATE',
  'DYNAMIC_MIN_LENGTH',
  'DYNAMIC_MAX_LENGTH',
  'DYNAMIC_PATTERN'
];

// e.g. "DynamicMin", "DynamicMaxDate", ...
const DYNAMIC_VALIDATIONS_PASCAL_CASE = DYNAMIC_VALIDATION_KEYS.map(toPascalCase);

function toPascalCase(input: string) {
  return input
    .toLowerCase()
    .split('_')
    .map((w) => w[0].toUpperCase() + w.slice(1))
    .join('');
}

interface JsonSchema {
  [key: string]: unknown;
}

export interface FilterOptions {
  includeAcl: boolean;
  includeValidation: boolean;
  includePfeIntegration: boolean;
}

interface ResolverContext {
  fullSchema: JsonSchema;
  resolvedReferences: Record<string, JsonSchema>;
  filterOptions?: FilterOptions;
}

function resolveReference(ref: string, schema: JsonSchema): JsonSchema | 'Failed to resolve' {
  // Only handle local references starting with "#/"
  if (!ref.startsWith('#/')) {
    return 'Failed to resolve';
  }

  const path = ref.substring(2).split('/').join('.');
  return get(schema, path, 'Failed to resolve') as JsonSchema | 'Failed to resolve';
}

function filterProperties(node: JsonSchema, filterOptions: FilterOptions): JsonSchema {
  const propertiesToRemove = new Set<string>();

  if (!filterOptions.includeAcl) {
    propertiesToRemove.add('acl');
  }

  if (!filterOptions.includeValidation) {
    propertiesToRemove.add('validators');
  }

  if (!filterOptions.includePfeIntegration) {
    propertiesToRemove.add('onValueChangesEvent');
    propertiesToRemove.add('onBlurEvent');
    propertiesToRemove.add('onInitEvent');
  }

  const filtered: JsonSchema = {};

  for (const [key, value] of Object.entries(node)) {
    if (propertiesToRemove.has(key)) {
      continue;
    }

    filtered[key] = value;
  }

  return filtered;
}

function containsDynamicValidator(str: string) {
  return DYNAMIC_VALIDATIONS_PASCAL_CASE.some((t) => str.includes(t));
}

function processNode(node: unknown, context: ResolverContext): unknown {
  if (node === null || node === undefined) {
    return node;
  }

  if (Array.isArray(node)) {
    return node.map((item) => processNode(item, context));
  }

  if (typeof node !== 'object') {
    return node;
  }

  const nodeObj = node as JsonSchema;
  const { filterOptions } = context;

  if (nodeObj['$ref'] && typeof nodeObj['$ref'] === 'string') {
    const ref = nodeObj['$ref'];

    const includeDynamicValidators =
      filterOptions?.includePfeIntegration && filterOptions?.includeValidation;

    if (containsDynamicValidator(ref) && !includeDynamicValidators) {
      return undefined;
    }

    if (context.resolvedReferences[ref]) {
      // Already resolved
      return { $ref: ref };
    }

    const resolvedSchema = resolveReference(ref, context.fullSchema);
    if (!resolvedSchema) {
      return { $ref: 'Failed to resolve' };
    }

    // Temporarily set to empty object to mark as being processed (prevents infinite recursion)
    context.resolvedReferences[ref] = {} as JsonSchema;

    const processedReference = processNode(resolvedSchema, context) as JsonSchema;
    context.resolvedReferences[ref] = processedReference;

    return { $ref: ref };
  }

  const result: JsonSchema = {};

  for (const [key, value] of Object.entries(node)) {
    result[key] = processNode(value, context);
  }

  if (filterOptions) {
    return filterProperties(result, filterOptions);
  }

  return result;
}

function findUsedReferences(
  schema: JsonSchema,
  references: Record<string, JsonSchema>
): Set<string> {
  const usedRefs = new Set<string>();
  const toProcess = new Set<string>();

  // Find all $ref in the main schema
  function collectRefs(node: unknown) {
    if (node && typeof node === 'object') {
      if ('$ref' in node && typeof node.$ref === 'string') {
        toProcess.add(node.$ref);
      }
      if (Array.isArray(node)) {
        node.forEach(collectRefs);
      } else {
        Object.values(node).forEach(collectRefs);
      }
    }
  }

  collectRefs(schema);

  // Process references recursively (they might reference other references)
  while (toProcess.size > 0) {
    const ref = toProcess.values().next().value as string;

    toProcess.delete(ref);

    if (usedRefs.has(ref)) {
      continue;
    }

    usedRefs.add(ref);

    // Check if this reference contains other references
    const refDefinition = references[ref];
    if (refDefinition) {
      collectRefs(refDefinition);
    }
  }

  return usedRefs;
}

/**
 * Resolves all $ref references in a sub-schema using definitions from a full schema
 *
 * @param subSchema - The sub-schema to resolve references in
 * @param fullSchema - The full schema containing all definitions
 * @param filterOptions - Optional filtering options to remove specific properties
 * @returns A new schema object with all references resolved and definitions included
 *
 * @example
 * ```typescript
 * const fullSchema = {
 *   definitions: {
 *     Person: { type: 'object', properties: { name: { type: 'string' } } },
 *   }
 * };
 *
 * const subSchema = {
 *   type: 'object',
 *   properties: {
 *     company: { $ref: '#/definitions/Person' }
 *   }
 * };
 *
 * const resolved = resolveSchemaReferences(subSchema, fullSchema);
 * // resolved is:
 * {
 *   type: 'object',
 *   properties: {
 *     company: { $ref: '#/definitions/Person' }
 *   },
 *   references: {
 *     '#/definitions/Person': {
 *       type: 'object',
 *       properties: {
 *         name: { type: 'string' },
 *       }
 *     },
 *   }
 * }
 * ```
 */
export function resolveSchemaReferences(
  subSchema: JsonSchema,
  fullSchema: JsonSchema,
  filterOptions?: FilterOptions
): JsonSchema {
  // Initialize the resolver context
  const context: ResolverContext = {
    fullSchema,
    resolvedReferences: {},
    filterOptions
  };

  // Process the sub-schema to resolve all references
  const resolvedSubSchema = processNode(subSchema, context) as JsonSchema;

  // Build the final schema with the definitions block
  const result: JsonSchema = {
    ...resolvedSubSchema
  };

  // Add the definitions block if any definitions were resolved
  if (Object.keys(context.resolvedReferences).length > 0) {
    if (filterOptions) {
      const usedRefs = findUsedReferences(resolvedSubSchema, context.resolvedReferences);
      const filteredReferences: Record<string, JsonSchema> = {};

      for (const ref of usedRefs) {
        if (context.resolvedReferences[ref]) {
          filteredReferences[ref] = context.resolvedReferences[ref];
        }
      }

      if (Object.keys(filteredReferences).length > 0) {
        result['references'] = filteredReferences;
      }
    } else {
      result['references'] = context.resolvedReferences;
    }
  }

  return result;
}

results matching ""

    No results matching ""