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;
}