Skip to content

Commit 189007e

Browse files
heiskrCopilotCopilot
authored
Render REST response schemas as markdown to fix activity/events 503 (#59845)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 6f3cb20 commit 189007e

File tree

5 files changed

+391
-36
lines changed

5 files changed

+391
-36
lines changed
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
type JsonSchema = {
2+
type?: string | string[]
3+
format?: string
4+
enum?: (string | number | boolean)[]
5+
default?: unknown
6+
deprecated?: boolean
7+
readOnly?: boolean
8+
minimum?: number
9+
maximum?: number
10+
minLength?: number
11+
maxLength?: number
12+
pattern?: string
13+
minItems?: number
14+
maxItems?: number
15+
uniqueItems?: boolean
16+
additionalProperties?: boolean | JsonSchema
17+
properties?: Record<string, JsonSchema>
18+
required?: string[]
19+
items?: JsonSchema
20+
oneOf?: JsonSchema[]
21+
anyOf?: JsonSchema[]
22+
allOf?: JsonSchema[]
23+
title?: string
24+
description?: string
25+
examples?: unknown[]
26+
example?: unknown
27+
}
28+
29+
const MAX_DEPTH = 4
30+
31+
function renderTypeConstraints(schema: JsonSchema): string {
32+
const parts: string[] = []
33+
34+
const t = schema.type
35+
if (t) {
36+
parts.push(Array.isArray(t) ? t.join(' or ') : t)
37+
}
38+
if (schema.format) parts.push(`format: ${schema.format}`)
39+
if (schema.enum) {
40+
const vals = schema.enum.map((v) => `\`${v}\``).join(', ')
41+
parts.push(`enum: ${vals}`)
42+
}
43+
if (schema.default !== undefined) parts.push(`default: \`${JSON.stringify(schema.default)}\``)
44+
if (schema.deprecated) parts.push('deprecated')
45+
if (schema.readOnly) parts.push('read-only')
46+
if (schema.minimum !== undefined) parts.push(`minimum: ${schema.minimum}`)
47+
if (schema.maximum !== undefined) parts.push(`maximum: ${schema.maximum}`)
48+
if (schema.minLength !== undefined) parts.push(`minLength: ${schema.minLength}`)
49+
if (schema.maxLength !== undefined) parts.push(`maxLength: ${schema.maxLength}`)
50+
if (schema.pattern) parts.push(`pattern: \`${schema.pattern}\``)
51+
if (schema.minItems !== undefined) parts.push(`minItems: ${schema.minItems}`)
52+
if (schema.maxItems !== undefined) parts.push(`maxItems: ${schema.maxItems}`)
53+
if (schema.uniqueItems) parts.push('unique items')
54+
if (schema.additionalProperties === true) {
55+
parts.push('additional properties allowed')
56+
} else if (typeof schema.additionalProperties === 'object') {
57+
parts.push(`additional properties: ${renderTypeConstraints(schema.additionalProperties)}`)
58+
}
59+
60+
return parts.join(', ') || 'object'
61+
}
62+
63+
function renderCompositionVariants(
64+
keyword: string,
65+
variants: JsonSchema[],
66+
indent: number,
67+
depth: number,
68+
): string {
69+
const prefix = ' '.repeat(indent)
70+
const label = keyword.replace('Of', ' of')
71+
const lines: string[] = [`${prefix}* ${label}:`]
72+
73+
for (const variant of variants) {
74+
const name = variant.title || renderTypeConstraints(variant)
75+
lines.push(`${prefix} * **${name}**`)
76+
if (depth < MAX_DEPTH && variant.properties) {
77+
const nested = renderProperties(variant, indent + 2, depth + 1)
78+
if (nested) lines.push(nested)
79+
}
80+
}
81+
82+
return lines.join('\n')
83+
}
84+
85+
function renderProperties(
86+
schema: JsonSchema,
87+
indent: number,
88+
depth: number,
89+
requiredFields?: string[],
90+
): string {
91+
const props = schema.properties || {}
92+
const req = new Set(requiredFields || schema.required || [])
93+
const prefix = ' '.repeat(indent)
94+
const lines: string[] = []
95+
96+
for (const [name, prop] of Object.entries(props)) {
97+
const reqStr = req.has(name) ? 'required, ' : ''
98+
99+
// Check for composition keywords at property level
100+
const compositionKey = (['oneOf', 'anyOf', 'allOf'] as const).find((k) => prop[k])
101+
if (compositionKey) {
102+
const label = compositionKey.replace('Of', ' of')
103+
lines.push(`${prefix}* \`${name}\`: ${reqStr}${label}:`)
104+
for (const variant of prop[compositionKey]!) {
105+
const vName = variant.title || renderTypeConstraints(variant)
106+
lines.push(`${prefix} * **${vName}**`)
107+
if (depth < MAX_DEPTH && variant.properties) {
108+
const nested = renderProperties(variant, indent + 2, depth + 1)
109+
if (nested) lines.push(nested)
110+
}
111+
}
112+
continue
113+
}
114+
115+
const propType = Array.isArray(prop.type) ? prop.type[0] : prop.type
116+
117+
if (propType === 'array' && prop.items) {
118+
const itemTitle = prop.items.title
119+
if (prop.items.properties && depth < MAX_DEPTH) {
120+
const label = itemTitle ? `array of \`${itemTitle}\`` : 'array of objects'
121+
lines.push(`${prefix}* \`${name}\`: ${reqStr}${label}:`)
122+
lines.push(renderProperties(prop.items, indent + 1, depth + 1))
123+
} else {
124+
lines.push(`${prefix}* \`${name}\`: ${reqStr}array of ${renderTypeConstraints(prop.items)}`)
125+
}
126+
} else if (prop.properties && depth < MAX_DEPTH) {
127+
const label = prop.title ? `\`${prop.title}\`` : renderTypeConstraints(prop)
128+
lines.push(`${prefix}* \`${name}\`: ${reqStr}${label}:`)
129+
lines.push(renderProperties(prop, indent + 1, depth + 1))
130+
} else {
131+
lines.push(`${prefix}* \`${name}\`: ${reqStr}${renderTypeConstraints(prop)}`)
132+
}
133+
}
134+
135+
return lines.filter(Boolean).join('\n')
136+
}
137+
138+
/**
139+
* Converts a JSON Schema response object into a readable markdown bullet list.
140+
* Includes type, required, format, enum, default, constraints — but omits
141+
* examples and descriptions to keep the output compact.
142+
*/
143+
export function summarizeSchema(schema: JsonSchema): string {
144+
if (!schema || typeof schema !== 'object') return ''
145+
146+
// Handle top-level composition
147+
for (const keyword of ['oneOf', 'anyOf', 'allOf'] as const) {
148+
if (schema[keyword]) {
149+
return renderCompositionVariants(keyword, schema[keyword]!, 0, 0)
150+
}
151+
}
152+
153+
// Handle top-level array
154+
if (schema.type === 'array' && schema.items) {
155+
const items = schema.items
156+
const constraints: string[] = []
157+
if (schema.minItems !== undefined) constraints.push(`minItems: ${schema.minItems}`)
158+
if (schema.maxItems !== undefined) constraints.push(`maxItems: ${schema.maxItems}`)
159+
if (schema.uniqueItems) constraints.push('unique items')
160+
const constraintStr = constraints.length ? ` (${constraints.join(', ')})` : ''
161+
const itemTitle = items.title
162+
163+
// Composition inside items
164+
const compositionKey = (['oneOf', 'anyOf', 'allOf'] as const).find((k) => items[k])
165+
if (compositionKey) {
166+
const label = compositionKey.replace('Of', ' of')
167+
const titlePart = itemTitle ? `\`${itemTitle}\` ` : ''
168+
const lines = [`Array${constraintStr} of ${titlePart}objects: ${label}:`]
169+
for (const variant of items[compositionKey]!) {
170+
const name = variant.title || renderTypeConstraints(variant)
171+
lines.push(` * **${name}**`)
172+
if (variant.properties) {
173+
const nested = renderProperties(variant, 2, 1)
174+
if (nested) lines.push(nested)
175+
}
176+
}
177+
return lines.join('\n')
178+
}
179+
180+
if (items.properties) {
181+
const label = itemTitle ? `\`${itemTitle}\`` : 'objects'
182+
return `Array${constraintStr} of ${label}:\n${renderProperties(items, 1, 1)}`
183+
}
184+
185+
return `Array${constraintStr} of ${renderTypeConstraints(items)}`
186+
}
187+
188+
// Handle top-level object
189+
if (schema.properties) {
190+
return renderProperties(schema, 0, 0)
191+
}
192+
193+
return renderTypeConstraints(schema)
194+
}

src/article-api/templates/rest-page.template.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
{{ manualContent }}
66

77
{% for operation in restOperations %}
8+
89
## {{ operation.title }}
910

1011
```
@@ -14,22 +15,27 @@
1415
{{ operation.description }}
1516

1617
{% if operation.hasParameters %}
18+
1719
### Parameters
1820

1921
{% if operation.showHeaders %}
22+
2023
#### Headers
2124

2225
{% if operation.needsContentTypeHeader %}
26+
2327
- **`content-type`** (string, required)
2428
Setting to `application/json` is required.
2529

2630
{% endif %}
31+
2732
- **`accept`** (string)
2833
Setting to `application/vnd.github+json` is recommended.
2934

3035
{% endif %}
3136

3237
{% if operation.parameters.size > 0 %}
38+
3339
#### Path and query parameters
3440

3541
{% for param in operation.parameters %}
@@ -39,6 +45,7 @@
3945
{% endif %}
4046

4147
{% if operation.bodyParameters.size > 0 %}
48+
4249
#### Body parameters
4350

4451
{% for param in operation.bodyParameters %}
@@ -49,19 +56,23 @@
4956
{% endif %}
5057

5158
{% if operation.statusCodes.size > 0 %}
59+
5260
### HTTP response status codes
5361

5462
{% for statusCode in operation.statusCodes %}
63+
5564
- **{{ statusCode.httpStatusCode }}**{% if statusCode.description %} - {{ statusCode.description }}{% elsif statusCode.httpStatusMessage %} - {{ statusCode.httpStatusMessage }}{% endif %}
5665

5766
{% endfor %}
5867
{% endif %}
5968

6069
{% if operation.codeExamples.size > 0 %}
70+
6171
### Code examples
6272

6373
{% for example in operation.codeExamples %}
6474
{% if example.request.description %}
75+
6576
#### {{ example.request.description }}
6677

6778
{% endif %}
@@ -80,18 +91,10 @@ curl -L \
8091
-d '{{ example.request.bodyParameters }}'{% endif %}
8192
```
8293

83-
**Response schema:**
94+
**Response schema (Status: {{ example.response.statusCode }}):**
8495

8596
{% if example.response.schema %}
86-
```json
87-
Status: {{ example.response.statusCode }}
88-
8997
{{ example.response.schema }}
90-
```
91-
{% else %}
92-
```
93-
Status: {{ example.response.statusCode }}
94-
```
9598
{% endif %}
9699

97100
{% endfor %}

src/article-api/tests/rest-transformer.ts

Lines changed: 9 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ describe('REST transformer', () => {
107107

108108
// Check for request/response labels
109109
expect(res.body).toContain('**Request:**')
110-
expect(res.body).toContain('**Response schema:**')
110+
expect(res.body).toContain('**Response schema (Status: 200):**')
111111

112112
// Check for curl code block
113113
expect(res.body).toContain('```curl')
@@ -173,36 +173,19 @@ describe('REST transformer', () => {
173173
expect(res.body).toMatch(/\[.*?\]\(\/en\/.*?\)/)
174174
})
175175

176-
test('Response schema is formatted correctly', async () => {
176+
test('Response schema is formatted as markdown', async () => {
177177
const res = await get(makeURL('/en/rest/actions/artifacts'))
178178
expect(res.statusCode).toBe(200)
179179

180-
// Check for JSON code block with schema label
181-
expect(res.body).toContain('**Response schema:**')
182-
expect(res.body).toContain('```json')
183-
expect(res.body).toContain('Status: 200')
180+
// Check for markdown-formatted schema
181+
expect(res.body).toContain('**Response schema (Status: 200):**')
184182

185-
// Verify schema structure is present (not an example)
186-
expect(res.body).toContain('"type":')
187-
expect(res.body).toContain('"properties":')
183+
// Schema should be rendered as a markdown bullet list, not JSON
184+
expect(res.body).toContain('* `total_count`:')
185+
expect(res.body).toContain('* `artifacts`:')
188186

189-
// Check for common schema keywords
190-
const schemaMatch = res.body.match(/```json\s+Status: 200\s+([\s\S]*?)```/)
191-
expect(schemaMatch).toBeTruthy()
192-
193-
if (schemaMatch) {
194-
const schemaContent = schemaMatch[1]
195-
const schema = JSON.parse(schemaContent)
196-
197-
// Verify it's a valid OpenAPI/JSON schema structure
198-
expect(schema).toHaveProperty('type')
199-
expect(schema.type).toBe('object')
200-
expect(schema).toHaveProperty('properties')
201-
202-
// Verify it has expected properties for artifacts response
203-
expect(schema.properties).toHaveProperty('total_count')
204-
expect(schema.properties).toHaveProperty('artifacts')
205-
}
187+
// Should not contain raw JSON Schema keywords
188+
expect(res.body).not.toContain('"properties":')
206189
})
207190

208191
test('Non-REST pages return appropriate error', async () => {

0 commit comments

Comments
 (0)