Skip to content

Commit 7886060

Browse files
heiskrCopilot
andauthored
Deduplicate reused titled types in REST schema summaries (#62012)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 873be89 commit 7886060

2 files changed

Lines changed: 158 additions & 13 deletions

File tree

src/article-api/lib/summarize-schema.ts

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -60,21 +60,44 @@ function renderTypeConstraints(schema: JsonSchema): string {
6060
return parts.join(', ') || 'object'
6161
}
6262

63+
// When a titled object type has already been fully expanded once in a schema,
64+
// later occurrences are rendered as a short "(see above)" reference instead of
65+
// being re-expanded. Large REST response schemas reuse shared types (e.g.
66+
// `Simple User` recurs dozens of times), so this keeps output compact and fast
67+
// to render. Returns true if the caller should emit a reference; marks the
68+
// title as seen when it is about to be expanded for the first time.
69+
function shouldReference(
70+
title: string | undefined,
71+
canExpand: boolean,
72+
seen: Set<string>,
73+
): boolean {
74+
if (!canExpand || !title) return false
75+
if (seen.has(title)) return true
76+
seen.add(title)
77+
return false
78+
}
79+
6380
function renderCompositionVariants(
6481
keyword: string,
6582
variants: JsonSchema[],
6683
indent: number,
6784
depth: number,
85+
seen: Set<string>,
6886
): string {
6987
const prefix = ' '.repeat(indent)
7088
const label = keyword.replace('Of', ' of')
7189
const lines: string[] = [`${prefix}* ${label}:`]
7290

7391
for (const variant of variants) {
7492
const name = variant.title || renderTypeConstraints(variant)
93+
const canExpand = depth < MAX_DEPTH && Boolean(variant.properties)
94+
if (shouldReference(variant.title, canExpand, seen)) {
95+
lines.push(`${prefix} * **${name}** (see above)`)
96+
continue
97+
}
7598
lines.push(`${prefix} * **${name}**`)
76-
if (depth < MAX_DEPTH && variant.properties) {
77-
const nested = renderProperties(variant, indent + 2, depth + 1)
99+
if (canExpand) {
100+
const nested = renderProperties(variant, indent + 2, depth + 1, seen)
78101
if (nested) lines.push(nested)
79102
}
80103
}
@@ -86,6 +109,7 @@ function renderProperties(
86109
schema: JsonSchema,
87110
indent: number,
88111
depth: number,
112+
seen: Set<string>,
89113
requiredFields?: string[],
90114
): string {
91115
const props = schema.properties || {}
@@ -103,9 +127,14 @@ function renderProperties(
103127
lines.push(`${prefix}* \`${name}\`: ${reqStr}${label}:`)
104128
for (const variant of prop[compositionKey]!) {
105129
const vName = variant.title || renderTypeConstraints(variant)
130+
const canExpand = depth < MAX_DEPTH && Boolean(variant.properties)
131+
if (shouldReference(variant.title, canExpand, seen)) {
132+
lines.push(`${prefix} * **${vName}** (see above)`)
133+
continue
134+
}
106135
lines.push(`${prefix} * **${vName}**`)
107-
if (depth < MAX_DEPTH && variant.properties) {
108-
const nested = renderProperties(variant, indent + 2, depth + 1)
136+
if (canExpand) {
137+
const nested = renderProperties(variant, indent + 2, depth + 1, seen)
109138
if (nested) lines.push(nested)
110139
}
111140
}
@@ -118,12 +147,17 @@ function renderProperties(
118147

119148
if (propType === 'array' && prop.items) {
120149
const itemTitle = prop.items.title
121-
if (prop.items.properties && depth < MAX_DEPTH) {
150+
const canExpand = Boolean(prop.items.properties) && depth < MAX_DEPTH
151+
if (shouldReference(itemTitle, canExpand, seen)) {
152+
lines.push(
153+
`${prefix}* \`${name}\`: ${reqStr}array of \`${itemTitle}\`${isNullable ? ' or null' : ''} (see above)`,
154+
)
155+
} else if (canExpand) {
122156
const label = itemTitle
123157
? `array of \`${itemTitle}\`${isNullable ? ' or null' : ''}`
124158
: `array of objects${isNullable ? ' or null' : ''}`
125159
lines.push(`${prefix}* \`${name}\`: ${reqStr}${label}:`)
126-
lines.push(renderProperties(prop.items, indent + 1, depth + 1))
160+
lines.push(renderProperties(prop.items, indent + 1, depth + 1, seen))
127161
} else {
128162
lines.push(
129163
`${prefix}* \`${name}\`: ${reqStr}array of ${renderTypeConstraints(prop.items)}${isNullable ? ' or null' : ''}`,
@@ -132,8 +166,12 @@ function renderProperties(
132166
} else if (prop.properties && depth < MAX_DEPTH) {
133167
// renderTypeConstraints handles string[] types (e.g. ["object","null"] → "object or null")
134168
const label = prop.title ? `\`${prop.title}\`` : renderTypeConstraints(prop)
135-
lines.push(`${prefix}* \`${name}\`: ${reqStr}${label}:`)
136-
lines.push(renderProperties(prop, indent + 1, depth + 1))
169+
if (shouldReference(prop.title, true, seen)) {
170+
lines.push(`${prefix}* \`${name}\`: ${reqStr}${label} (see above)`)
171+
} else {
172+
lines.push(`${prefix}* \`${name}\`: ${reqStr}${label}:`)
173+
lines.push(renderProperties(prop, indent + 1, depth + 1, seen))
174+
}
137175
} else {
138176
// renderTypeConstraints handles string[] types (e.g. ["string","null"] → "string or null")
139177
lines.push(`${prefix}* \`${name}\`: ${reqStr}${renderTypeConstraints(prop)}`)
@@ -151,10 +189,15 @@ function renderProperties(
151189
export function summarizeSchema(schema: JsonSchema): string {
152190
if (!schema || typeof schema !== 'object') return ''
153191

192+
// Tracks titled object types already expanded once in this schema. Later
193+
// occurrences are rendered as a "(see above)" reference to keep output
194+
// compact and fast to render (shared types can recur dozens of times).
195+
const seen = new Set<string>()
196+
154197
// Handle top-level composition
155198
for (const keyword of ['oneOf', 'anyOf', 'allOf'] as const) {
156199
if (schema[keyword]) {
157-
return renderCompositionVariants(keyword, schema[keyword]!, 0, 0)
200+
return renderCompositionVariants(keyword, schema[keyword]!, 0, 0, seen)
158201
}
159202
}
160203

@@ -181,9 +224,14 @@ export function summarizeSchema(schema: JsonSchema): string {
181224
const lines = [`Array${constraintStr} of ${titlePart}objects${nullSuffix}: ${label}:`]
182225
for (const variant of items[compositionKey]!) {
183226
const name = variant.title || renderTypeConstraints(variant)
227+
const canExpand = Boolean(variant.properties)
228+
if (shouldReference(variant.title, canExpand, seen)) {
229+
lines.push(` * **${name}** (see above)`)
230+
continue
231+
}
184232
lines.push(` * **${name}**`)
185-
if (variant.properties) {
186-
const nested = renderProperties(variant, 2, 1)
233+
if (canExpand) {
234+
const nested = renderProperties(variant, 2, 1, seen)
187235
if (nested) lines.push(nested)
188236
}
189237
}
@@ -193,15 +241,21 @@ export function summarizeSchema(schema: JsonSchema): string {
193241
if (items.properties) {
194242
const label = itemTitle ? `\`${itemTitle}\`` : 'objects'
195243
const nullSuffix = isNullable ? ' or null' : ''
196-
return `Array${constraintStr} of ${label}${nullSuffix}:\n${renderProperties(items, 1, 1)}`
244+
if (itemTitle) seen.add(itemTitle)
245+
return `Array${constraintStr} of ${label}${nullSuffix}:\n${renderProperties(items, 1, 1, seen)}`
197246
}
198247

199248
return `Array${constraintStr} of ${renderTypeConstraints(items)}${isNullable ? ' or null' : ''}`
200249
}
201250

202251
// Handle top-level object
203252
if (schema.properties) {
204-
return renderProperties(schema, 0, 0)
253+
// Note: we deliberately do NOT pre-mark schema.title here. Unlike the
254+
// array-items case above, a top-level object emits no visible titled
255+
// header, so pre-marking would make a self-referential property render a
256+
// dangling "(see above)" pointing at nothing. Letting it expand one
257+
// depth-bounded level is correct and clearer.
258+
return renderProperties(schema, 0, 0, seen)
205259
}
206260

207261
return renderTypeConstraints(schema)

src/article-api/tests/summarize-schema.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,94 @@ describe('summarizeSchema — OAS 3.1 nullable handling', () => {
197197
expect(summarizeSchema('not an object')).toBe('')
198198
})
199199
})
200+
201+
describe('summarizeSchema — repeated titled type deduplication', () => {
202+
type Schema = Parameters<typeof summarizeSchema>[0]
203+
const user: Schema = {
204+
type: 'object',
205+
title: 'Simple User',
206+
properties: {
207+
login: { type: 'string' },
208+
id: { type: 'integer' },
209+
},
210+
}
211+
212+
it('expands a titled type once then references it with "(see above)"', () => {
213+
const schema: Schema = {
214+
type: 'object',
215+
properties: {
216+
actor: user,
217+
assignee: user,
218+
assigner: user,
219+
},
220+
}
221+
const result = summarizeSchema(schema)
222+
// Expanded exactly once
223+
expect(result.match(/`login`/g)?.length).toBe(1)
224+
// Two later references
225+
expect(result.match(/\(see above\)/g)?.length).toBe(2)
226+
expect(result).toContain('`assignee`: `Simple User` (see above)')
227+
})
228+
229+
it('references repeated titled variants inside a composition', () => {
230+
const schema: Schema = {
231+
type: 'array',
232+
items: {
233+
anyOf: [
234+
{
235+
type: 'object',
236+
title: 'Event A',
237+
properties: { actor: user, target: user },
238+
},
239+
{
240+
type: 'object',
241+
title: 'Event B',
242+
properties: { actor: user },
243+
},
244+
],
245+
},
246+
}
247+
const result = summarizeSchema(schema)
248+
// Simple User expanded once across the whole schema
249+
expect(result.match(/`login`/g)?.length).toBe(1)
250+
expect((result.match(/\(see above\)/g) || []).length).toBeGreaterThanOrEqual(2)
251+
})
252+
253+
it('does not reference untitled (anonymous) objects', () => {
254+
const anon: Schema = {
255+
type: 'object',
256+
properties: { a: { type: 'string' } },
257+
}
258+
const schema: Schema = {
259+
type: 'object',
260+
properties: { first: anon, second: anon },
261+
}
262+
const result = summarizeSchema(schema)
263+
expect(result).not.toContain('(see above)')
264+
expect(result.match(/`a`/g)?.length).toBe(2)
265+
})
266+
267+
it('expands a self-referential top-level object before referencing it (no dangling ref)', () => {
268+
// A top-level object emits no visible titled header. With the top-level
269+
// title NOT pre-marked, the first occurrence of the recursive property
270+
// expands visibly, so any deeper "(see above)" points to that expansion
271+
// rather than a header that was never rendered.
272+
const node: Schema = {
273+
type: 'object',
274+
title: 'Category',
275+
properties: {
276+
name: { type: 'string' },
277+
},
278+
}
279+
node.properties!.parent = node
280+
const result = summarizeSchema(node)
281+
// `Category` is expanded at least once (its `name` field is visible)
282+
// before the recursive reference appears.
283+
const firstExpansion = result.indexOf('`name`')
284+
const firstReference = result.indexOf('(see above)')
285+
expect(firstExpansion).toBeGreaterThanOrEqual(0)
286+
if (firstReference >= 0) {
287+
expect(firstExpansion).toBeLessThan(firstReference)
288+
}
289+
})
290+
})

0 commit comments

Comments
 (0)