diff --git a/src/utils/find-matching-node.ts b/src/utils/find-matching-node.ts index c82a67a49..e7f06bc69 100644 --- a/src/utils/find-matching-node.ts +++ b/src/utils/find-matching-node.ts @@ -19,7 +19,7 @@ export const findMatchingNode = (spDoc: ServiceNode, forPath: string): ServiceCh child with uri = '/paths/list/get' and tags: `system`, `mesh`. this item should be returned as path whith tag matched the path. */ - const multiTagNodes = spDoc.children.filter(child => child.tags.length > 1) + const multiTagNodes = spDoc.children.filter(child => child?.tags.length > 1) for (const multiTagNode of multiTagNodes) { for (const tagName of multiTagNode.tags) { if (multiTagNode.uri.split('/').toSpliced(2, 0, slugify(tagName).toLowerCase()).join('/') === forPath) { diff --git a/src/utils/schema-model.spec.ts b/src/utils/schema-model.spec.ts index a961d83ff..48a89af8d 100644 --- a/src/utils/schema-model.spec.ts +++ b/src/utils/schema-model.spec.ts @@ -441,5 +441,54 @@ describe('removeReadonlyFields', () => { } expect(removeFieldsFromSchemaObject(schemaObject)).toEqual(expectedSchemaObject) }) + + it('does not recurse infinitely for circular references', () => { + type CircularSchemaObject = SchemaObject & { + parent?: SchemaObject + } + + const schemaObject: SchemaObject = { + type: 'object', + properties: { + id: { + type: 'string', + }, + }, + } + + const itemsSchema: CircularSchemaObject = { + type: 'object', + properties: { + name: { + type: 'string', + readOnly: true, + }, + age: { + type: 'number', + }, + }, + } + + // create circular references between schemaObject and itemsSchema + schemaObject.items = itemsSchema + itemsSchema.parent = schemaObject + + expect(removeFieldsFromSchemaObject(schemaObject)).toEqual({ + type: 'object', + properties: { + id: { + type: 'string', + }, + }, + items: { + type: 'object', + properties: { + age: { + type: 'number', + }, + }, + }, + }) + }) }) }) diff --git a/src/utils/schema-model.ts b/src/utils/schema-model.ts index a5a51cb34..4f6450693 100644 --- a/src/utils/schema-model.ts +++ b/src/utils/schema-model.ts @@ -18,22 +18,59 @@ export function filterSchemaObjectArray(candidate: unknown): SchemaObject[] { return Array.isArray(candidate) ? candidate.filter(isValidSchemaObject) : [] } +/** + * Clones an object graph into a tree by dropping repeated object references. + * + * Why this exists: + * - `JSON.stringify` fails on circular structures. + * - Recursive walkers can overflow the call stack for deeply nested schemas. + * + * How it works: + * - Uses a DFS stack (iterative traversal, no recursion). + * - Uses a WeakMap to track objects that were already cloned. + * - If a value was already seen, we skip assigning that key. + * + * Result: + * - Circular links and duplicate shared refs are removed. + * - Primitive values and the first encounter of each object are preserved. + */ +const removeCircularRefs = (obj: Record): Record => { + const rootClone: Record = Array.isArray(obj) ? [] : {} + try { + const seen = new WeakMap>() + seen.set(obj, rootClone) -const removeCircularRefs = (obj: Record):Record => { - const cache = new Set() - const jsonString = JSON.stringify(obj, (key, value) => { - if (typeof value === 'object' && value !== null) { - if (cache.has(value)) { - // Circular reference found, discard key - return - } - // Store value in our collection - cache.add(value) + const stack: Array<{ source: Record, target: Record }> = [{ source: obj, target: rootClone }] + + // Iterate explicitly to avoid recursive stack growth on deep schemas. + while (stack.length > 0) { + const current = stack.pop() + if (!current) continue + + const { source, target } = current + + Object.keys(source).forEach((key) => { + const value = source[key] + + if (typeof value === 'object' && value !== null) { + // Skip repeated refs (including circular refs) to keep result tree-shaped. + if (seen.has(value)) return + + const childClone: Record = Array.isArray(value) ? [] : {} + seen.set(value, childClone) + target[key] = childClone + stack.push({ source: value, target: childClone }) + return + } + + target[key] = value + }) } - return value - }) - const res = JSON.parse(jsonString) - return res + } catch (error) { + throw new Error(`Failed to remove circular references: ${error}`) + } + + return rootClone } /** @@ -53,7 +90,7 @@ const resolveAllOf = (schema: SchemaObject): SchemaObject => { let resolvedAllOf: SchemaObject = {} for (const allEl of schema.allOf) { - resolvedAllOf = { ...resolveAllOf, ...removeCircularRefs(allEl as Record) as Record } + resolvedAllOf = { ...resolvedAllOf, ...removeCircularRefs(allEl as Record) as Record } } return { ...resolvedAllOf, @@ -182,20 +219,20 @@ function filterSchemaProperties( * - oneOf/anyOf */ export function removeFieldsFromSchemaObject(schemaObject: SchemaObject, filterMethod: SchemaPropertyFilterMethod = removeReadonlyFields): SchemaObject { - const newObj: SchemaObject = JSON.parse(JSON.stringify(schemaObject)) + const newObj: SchemaObject = removeCircularRefs(schemaObject) - if (schemaObject.properties) { - const filteredProperties = filterSchemaProperties(schemaObject.properties, filterMethod) + if (newObj.properties) { + const filteredProperties = filterSchemaProperties(newObj.properties, filterMethod) newObj.properties = filteredProperties } - if (isValidSchemaObject(schemaObject.items)) { + if (isValidSchemaObject(newObj.items)) { // items itself is a valid schema object, so we need to filter its properties, oneOf and anyOf - const filteredItemsObject = removeFieldsFromSchemaObject(schemaObject.items, filterMethod) + const filteredItemsObject = removeFieldsFromSchemaObject(newObj.items, filterMethod) newObj.items = filteredItemsObject } - if (schemaObject.oneOf?.length) { + if (newObj.oneOf?.length) { const newOneOfList: SchemaObject['oneOf'] = [] - schemaObject.oneOf.forEach((item) => { + newObj.oneOf.forEach((item) => { // if the item is not a valid schema object or it fails the condiiton in filterMethod, we skip it if (!isValidSchemaObject(item) || filterMethod(item)) return @@ -205,9 +242,9 @@ export function removeFieldsFromSchemaObject(schemaObject: SchemaObject, filterM }) newObj.oneOf = newOneOfList } - if (schemaObject.anyOf?.length) { + if (newObj.anyOf?.length) { const newAnyOfList: SchemaObject['anyOf'] = [] - schemaObject.anyOf.forEach((item) => { + newObj.anyOf.forEach((item) => { if (!isValidSchemaObject(item) || filterMethod(item)) return const filteredProperties = filterSchemaProperties(item.properties, filterMethod) newAnyOfList.push({ ...item, properties: filteredProperties })