Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/utils/find-matching-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
49 changes: 49 additions & 0 deletions src/utils/schema-model.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
},
})
})
})
})
85 changes: 62 additions & 23 deletions src/utils/schema-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,55 @@ 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<string, any>): Record<string, any> => {
const rootClone: Record<string, any> = Array.isArray(obj) ? [] : {}
const seen = new WeakMap<object, Record<string, any>>()
seen.set(obj, rootClone)

const stack: Array<{ source: Record<string, any>, target: Record<string, any> }> = [{ 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]

const removeCircularRefs = (obj: Record<string, any>):Record<string, any> => {
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
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<string, any> = Array.isArray(value) ? [] : {}
seen.set(value, childClone)
target[key] = childClone
stack.push({ source: value, target: childClone })
return
}
// Store value in our collection
cache.add(value)
}
return value
})
const res = JSON.parse(jsonString)
return res

target[key] = value
})
}

return rootClone
}

/**
Expand All @@ -53,7 +86,7 @@ const resolveAllOf = (schema: SchemaObject): SchemaObject => {

let resolvedAllOf: SchemaObject = {}
for (const allEl of schema.allOf) {
resolvedAllOf = { ...resolveAllOf, ...removeCircularRefs(allEl as Record<string, any>) as Record<string, any> }
resolvedAllOf = { ...resolvedAllOf, ...removeCircularRefs(allEl as Record<string, any>) as Record<string, any> }
}
return {
...resolvedAllOf,
Expand Down Expand Up @@ -182,20 +215,26 @@ function filterSchemaProperties(
* - oneOf/anyOf
*/
export function removeFieldsFromSchemaObject(schemaObject: SchemaObject, filterMethod: SchemaPropertyFilterMethod = removeReadonlyFields): SchemaObject {
const newObj: SchemaObject = JSON.parse(JSON.stringify(schemaObject))
let newObj: SchemaObject

try {
newObj = JSON.parse(JSON.stringify(schemaObject))
} catch {
newObj = 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

Expand All @@ -205,9 +244,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 })
Expand Down
Loading