Skip to content

Commit b52829e

Browse files
Ebonsignoridocs-botsaritai
authored
fix: handle OAS 3.1 nullable arrays and objects correctly (#60368)
Co-authored-by: docs-bot <77750099+docs-bot@users.noreply.github.com> Co-authored-by: Sarita Iyer <66540150+saritai@users.noreply.github.com>
1 parent 8c27efe commit b52829e

File tree

7 files changed

+412
-13
lines changed

7 files changed

+412
-13
lines changed

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

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -112,22 +112,30 @@ function renderProperties(
112112
continue
113113
}
114114

115-
const propType = Array.isArray(prop.type) ? prop.type[0] : prop.type
115+
const typeArr = Array.isArray(prop.type) ? prop.type : prop.type ? [prop.type] : []
116+
const isNullable = typeArr.includes('null')
117+
const propType = typeArr.find((t) => t !== 'null')
116118

117119
if (propType === 'array' && prop.items) {
118120
const itemTitle = prop.items.title
119121
if (prop.items.properties && depth < MAX_DEPTH) {
120-
const label = itemTitle ? `array of \`${itemTitle}\`` : 'array of objects'
122+
const label = itemTitle
123+
? `array of \`${itemTitle}\`${isNullable ? ' or null' : ''}`
124+
: `array of objects${isNullable ? ' or null' : ''}`
121125
lines.push(`${prefix}* \`${name}\`: ${reqStr}${label}:`)
122126
lines.push(renderProperties(prop.items, indent + 1, depth + 1))
123127
} else {
124-
lines.push(`${prefix}* \`${name}\`: ${reqStr}array of ${renderTypeConstraints(prop.items)}`)
128+
lines.push(
129+
`${prefix}* \`${name}\`: ${reqStr}array of ${renderTypeConstraints(prop.items)}${isNullable ? ' or null' : ''}`,
130+
)
125131
}
126132
} else if (prop.properties && depth < MAX_DEPTH) {
133+
// renderTypeConstraints handles string[] types (e.g. ["object","null"] → "object or null")
127134
const label = prop.title ? `\`${prop.title}\`` : renderTypeConstraints(prop)
128135
lines.push(`${prefix}* \`${name}\`: ${reqStr}${label}:`)
129136
lines.push(renderProperties(prop, indent + 1, depth + 1))
130137
} else {
138+
// renderTypeConstraints handles string[] types (e.g. ["string","null"] → "string or null")
131139
lines.push(`${prefix}* \`${name}\`: ${reqStr}${renderTypeConstraints(prop)}`)
132140
}
133141
}
@@ -151,7 +159,11 @@ export function summarizeSchema(schema: JsonSchema): string {
151159
}
152160

153161
// Handle top-level array
154-
if (schema.type === 'array' && schema.items) {
162+
const schemaTypes = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : []
163+
const isNullable = schemaTypes.includes('null')
164+
const primaryType = schemaTypes.find((t) => t !== 'null')
165+
166+
if (primaryType === 'array' && schema.items) {
155167
const items = schema.items
156168
const constraints: string[] = []
157169
if (schema.minItems !== undefined) constraints.push(`minItems: ${schema.minItems}`)
@@ -165,7 +177,8 @@ export function summarizeSchema(schema: JsonSchema): string {
165177
if (compositionKey) {
166178
const label = compositionKey.replace('Of', ' of')
167179
const titlePart = itemTitle ? `\`${itemTitle}\` ` : ''
168-
const lines = [`Array${constraintStr} of ${titlePart}objects: ${label}:`]
180+
const nullSuffix = isNullable ? ' or null' : ''
181+
const lines = [`Array${constraintStr} of ${titlePart}objects${nullSuffix}: ${label}:`]
169182
for (const variant of items[compositionKey]!) {
170183
const name = variant.title || renderTypeConstraints(variant)
171184
lines.push(` * **${name}**`)
@@ -179,10 +192,11 @@ export function summarizeSchema(schema: JsonSchema): string {
179192

180193
if (items.properties) {
181194
const label = itemTitle ? `\`${itemTitle}\`` : 'objects'
182-
return `Array${constraintStr} of ${label}:\n${renderProperties(items, 1, 1)}`
195+
const nullSuffix = isNullable ? ' or null' : ''
196+
return `Array${constraintStr} of ${label}${nullSuffix}:\n${renderProperties(items, 1, 1)}`
183197
}
184198

185-
return `Array${constraintStr} of ${renderTypeConstraints(items)}`
199+
return `Array${constraintStr} of ${renderTypeConstraints(items)}${isNullable ? ' or null' : ''}`
186200
}
187201

188202
// Handle top-level object
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { summarizeSchema } from '../lib/summarize-schema'
3+
4+
describe('summarizeSchema — OAS 3.1 nullable handling', () => {
5+
// ── Bug #1 ──────────────────────────────────────────────────────────────────
6+
// renderProperties: type: ["array", "null"] on a property
7+
8+
it('renders nullable array property (primitive items) with "or null"', () => {
9+
const schema = {
10+
type: 'object' as const,
11+
properties: {
12+
tags: {
13+
type: ['array', 'null'],
14+
items: { type: 'string' },
15+
},
16+
},
17+
}
18+
const result = summarizeSchema(schema)
19+
expect(result).toContain('or null')
20+
expect(result).toContain('array of')
21+
})
22+
23+
it('renders nullable array property (object items with title) with "or null"', () => {
24+
const schema = {
25+
type: 'object' as const,
26+
properties: {
27+
items: {
28+
type: ['array', 'null'],
29+
items: {
30+
type: 'object',
31+
title: 'Widget',
32+
properties: { id: { type: 'string' } },
33+
},
34+
},
35+
},
36+
}
37+
const result = summarizeSchema(schema)
38+
expect(result).toContain('array of `Widget` or null')
39+
})
40+
41+
it('renders nullable array property (object items without title) with "or null"', () => {
42+
const schema = {
43+
type: 'object' as const,
44+
properties: {
45+
entries: {
46+
type: ['array', 'null'],
47+
items: {
48+
type: 'object',
49+
properties: { name: { type: 'string' } },
50+
},
51+
},
52+
},
53+
}
54+
const result = summarizeSchema(schema)
55+
expect(result).toContain('array of objects or null')
56+
})
57+
58+
it('renders non-nullable array property without "or null"', () => {
59+
const schema = {
60+
type: 'object' as const,
61+
properties: {
62+
tags: {
63+
type: 'array',
64+
items: { type: 'string' },
65+
},
66+
},
67+
}
68+
const result = summarizeSchema(schema)
69+
expect(result).not.toContain('or null')
70+
expect(result).toContain('array of')
71+
})
72+
73+
it('renders scalar type: ["string", "null"] property as "string or null"', () => {
74+
const schema = {
75+
type: 'object' as const,
76+
properties: {
77+
description: {
78+
type: ['string', 'null'],
79+
},
80+
},
81+
}
82+
const result = summarizeSchema(schema)
83+
expect(result).toContain('string or null')
84+
})
85+
86+
// ── Bug #2 ──────────────────────────────────────────────────────────────────
87+
// summarizeSchema: type: ["array", "null"] at the top level
88+
89+
it('renders top-level type: ["array", "null"] with primitive items as "or null"', () => {
90+
const schema = {
91+
type: ['array', 'null'],
92+
items: { type: 'string' },
93+
}
94+
const result = summarizeSchema(schema)
95+
expect(result).toContain('or null')
96+
expect(result).toContain('Array')
97+
})
98+
99+
it('renders top-level type: ["array", "null"] with object items as "or null"', () => {
100+
const schema = {
101+
type: ['array', 'null'],
102+
items: {
103+
type: 'object',
104+
title: 'Repo',
105+
properties: { id: { type: 'integer' } },
106+
},
107+
}
108+
const result = summarizeSchema(schema)
109+
expect(result).toContain('or null')
110+
expect(result).toContain('`Repo`')
111+
})
112+
113+
it('renders top-level type: ["array", "null"] with composition items as "or null"', () => {
114+
const schema = {
115+
type: ['array', 'null'],
116+
items: {
117+
oneOf: [
118+
{ title: 'TypeA', type: 'object' },
119+
{ title: 'TypeB', type: 'object' },
120+
],
121+
},
122+
}
123+
const result = summarizeSchema(schema)
124+
expect(result).toContain('or null')
125+
})
126+
127+
it('renders top-level plain array (not nullable) without "or null"', () => {
128+
const schema = {
129+
type: 'array',
130+
items: { type: 'string' },
131+
}
132+
const result = summarizeSchema(schema)
133+
expect(result).not.toContain('or null')
134+
})
135+
136+
// ── Existing branches still work ─────────────────────────────────────────
137+
it('handles top-level anyOf', () => {
138+
const schema = {
139+
anyOf: [
140+
{
141+
type: 'object',
142+
title: 'Option A',
143+
properties: { x: { type: 'string' } } as Record<string, object>,
144+
},
145+
{
146+
type: 'object',
147+
title: 'Option B',
148+
properties: { y: { type: 'number' } } as Record<string, object>,
149+
},
150+
],
151+
}
152+
const result = summarizeSchema(schema)
153+
expect(result).toContain('any of')
154+
expect(result).toContain('Option A')
155+
expect(result).toContain('Option B')
156+
})
157+
158+
it('handles top-level oneOf', () => {
159+
const schema = {
160+
oneOf: [
161+
{ title: 'Foo', type: 'object' },
162+
{ title: 'Bar', type: 'object' },
163+
],
164+
}
165+
const result = summarizeSchema(schema)
166+
expect(result).toContain('one of')
167+
})
168+
169+
it('handles top-level allOf', () => {
170+
const schema = {
171+
allOf: [
172+
{ title: 'Base', type: 'object' },
173+
{ title: 'Extension', type: 'object' },
174+
],
175+
}
176+
const result = summarizeSchema(schema)
177+
expect(result).toContain('all of')
178+
})
179+
180+
it('handles property-level anyOf', () => {
181+
const schema = {
182+
type: 'object' as const,
183+
properties: {
184+
value: {
185+
anyOf: [{ type: 'string' }, { type: 'number' }],
186+
},
187+
},
188+
}
189+
const result = summarizeSchema(schema)
190+
expect(result).toContain('any of')
191+
})
192+
193+
it('returns empty string for null/non-object input', () => {
194+
// @ts-expect-error testing runtime behavior
195+
expect(summarizeSchema(null)).toBe('')
196+
// @ts-expect-error testing runtime behavior
197+
expect(summarizeSchema('not an object')).toBe('')
198+
})
199+
})

src/automated-pipelines/components/parameter-table/ParameterTable.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,9 @@ export function ParameterTable({
8888
<ParameterRow
8989
rowParams={{
9090
name: param.name,
91-
type: param.schema.type,
91+
type: Array.isArray(param.schema.type)
92+
? param.schema.type.join(' or ')
93+
: param.schema.type,
9294
description: param.description,
9395
isRequired: param.required,
9496
default: param.schema.default,
@@ -122,7 +124,9 @@ export function ParameterTable({
122124
<ParameterRow
123125
rowParams={{
124126
name: param.name,
125-
type: param.schema.type,
127+
type: Array.isArray(param.schema.type)
128+
? param.schema.type.join(' or ')
129+
: param.schema.type,
126130
description: param.description,
127131
isRequired: param.required,
128132
default: param.schema.default,

src/automated-pipelines/components/parameter-table/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export interface Parameter {
44
description: string
55
required: boolean
66
schema: {
7-
type: string
7+
type: string | string[]
88
default?: string
99
enum?: Array<string>
1010
}

src/rest/components/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export interface Parameter {
3030
description: string
3131
required: boolean
3232
schema: {
33-
type: string
33+
type: string | string[]
3434
default?: string
3535
enum?: Array<string>
3636
}

src/rest/scripts/utils/get-body-params.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { renderContent } from './render-content'
22

33
export interface Schema {
44
oneOf?: Schema[]
5-
type?: string
5+
type?: string | string[]
66
items?: Schema
77
properties?: Record<string, Schema>
88
required?: string[]
@@ -196,7 +196,13 @@ export async function getBodyParams(schema: Schema, topLevel = false): Promise<T
196196
// Handle mixed types or non-object oneOf cases
197197
const descriptions: { type: string; description: string }[] = []
198198
for (const childParam of param.oneOf) {
199-
paramType.push(childParam.type)
199+
paramType.push(
200+
...(Array.isArray(childParam.type)
201+
? childParam.type
202+
: childParam.type
203+
? [childParam.type]
204+
: []),
205+
)
200206
if (!param.description) {
201207
if (childParam.type === 'array') {
202208
if (childParam.items && childParam.items.description) {
@@ -235,8 +241,10 @@ export async function getBodyParams(schema: Schema, topLevel = false): Promise<T
235241
const firstObject = Object.values(param.anyOf).find(
236242
(item) => (item as Schema).type === 'object',
237243
) as Schema
244+
const hasNull = param.anyOf.some((item) => (item as Schema).type === 'null')
238245
if (firstObject) {
239246
paramType.push('object')
247+
if (hasNull) paramType.push('null')
240248
param.description = firstObject.description
241249
childParamsGroups.push(...(await getBodyParams(firstObject, false)))
242250
} else {

0 commit comments

Comments
 (0)