Skip to content

Commit cbef863

Browse files
committed
feat: support OpenAPI 3.1 contentMediaType and contentEncoding keywords (fixes #1137)
- Added isBinaryFileField helper to identify file payloads using contentMediaType and contentEncoding - Extracted contentMediaType, contentEncoding, and contentSchema in getTypeInfo and createPrimitiveAST - Added Media-Type and Encoding chips to SchemaTree and SchemaTable components - Added example generation for contentEncoding (base64) and contentMediaType (application/json with contentSchema) - Updated form-data Try console to support contentMediaType file uploads (single and array inputs) and fixed left alignment - Added unit tests in schema-ast.test.js and schema-utils.test.js - Added test spec, Astro test page, and Test Matrix entry
1 parent 676465d commit cbef863

11 files changed

Lines changed: 783 additions & 9770 deletions

File tree

‎docs/generated-docs/rapidoc/rapidoc-min.js‎

Lines changed: 233 additions & 4877 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
openapi: 3.1.0
2+
info:
3+
title: OpenAPI 3.1 contentMediaType & contentEncoding Support
4+
version: 1.0.0
5+
description: Demonstrates support for JSON Schema / OpenAPI 3.1 contentMediaType and contentEncoding keywords replacing format binary/base64.
6+
7+
paths:
8+
/upload-form:
9+
post:
10+
summary: Upload Files via Multipart Form
11+
description: Single and multi-file uploads defined using standard contentMediaType keywords.
12+
requestBody:
13+
required: true
14+
content:
15+
multipart/form-data:
16+
schema:
17+
type: object
18+
required:
19+
- avatar
20+
properties:
21+
avatar:
22+
type: string
23+
contentMediaType: image/png
24+
description: Profile avatar image file (single file input)
25+
documents:
26+
type: array
27+
description: Multiple PDF attachments
28+
items:
29+
type: string
30+
contentMediaType: application/pdf
31+
notes:
32+
type: string
33+
description: Form notes text
34+
responses:
35+
'200':
36+
description: Upload successful
37+
content:
38+
application/json:
39+
schema:
40+
type: object
41+
properties:
42+
fileId:
43+
type: string
44+
format: uuid
45+
size:
46+
type: integer
47+
48+
/json-payload:
49+
post:
50+
summary: JSON Payload with Encoded and Structured Strings
51+
description: Demonstrates base64 encoded strings and JSON string payloads with contentSchema.
52+
requestBody:
53+
required: true
54+
content:
55+
application/json:
56+
schema:
57+
type: object
58+
required:
59+
- encodedImage
60+
properties:
61+
encodedImage:
62+
type: string
63+
contentMediaType: image/jpeg
64+
contentEncoding: base64
65+
description: Base64-encoded JPEG image string
66+
rawBase64Data:
67+
type: string
68+
contentEncoding: base64
69+
description: Generic base64 encoded data
70+
embeddedJsonConfig:
71+
type: string
72+
contentMediaType: application/json
73+
description: A JSON string containing a structured config object
74+
contentSchema:
75+
type: object
76+
required:
77+
- featureFlag
78+
properties:
79+
featureFlag:
80+
type: boolean
81+
default: true
82+
maxRetries:
83+
type: integer
84+
default: 3
85+
responses:
86+
'200':
87+
description: OK
88+
content:
89+
application/json:
90+
schema:
91+
type: object
92+
properties:
93+
status:
94+
type: string
95+
default: processed

‎docs/src/page-data/tests.yaml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,13 @@ advanced:
179179
validates:
180180
- Full backwards compatibility with Swagger/OpenAPI 2.0 specs in RapiDoc 9.3.9+
181181
- Accurate path, parameter, and response visualization for v2 documents
182+
- openapi-3.1-content-media-type:
183+
title: OpenAPI 3.1 Binary & Media Types (contentMediaType & contentEncoding)
184+
validates:
185+
- Multipart single-file uploads with contentMediaType (e.g. image/png)
186+
- Multi-file array uploads with items.contentMediaType (e.g. application/pdf)
187+
- Base64 encoded payload strings with contentEncoding base64 and contentMediaType
188+
- Embedded structured JSON payload strings with contentSchema validation
189+
- Detail chips rendering for Media-Type and Encoding in Tree and Table views
190+
182191

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
import ExampleLayout from '../../layouts/ExampleLayout.astro';
3+
import { getSpecUrl } from '../../utils/getSpecUrl';
4+
5+
const specUrl = getSpecUrl(Astro.url);
6+
---
7+
<ExampleLayout>
8+
<rapi-doc
9+
id = "thedoc"
10+
spec-url = "/specs-test/openapi-3.1-content-media-type.yaml"
11+
render-style = "read"
12+
default-schema-tab = "schema"
13+
show-header = "false"
14+
show-info = "true"
15+
allow-server-selection = "false"
16+
allow-authentication = "false"
17+
>
18+
<div slot="nav-logo" style="height:120px; display:flex; flex-direction:column; align-items:center; gap:8px;">
19+
<button class='btn' style="width:150px; margin-bottom:16px" onclick="toggleSchemaStyle()">Toggle Schema Style</button>
20+
</div>
21+
22+
</rapi-doc>
23+
</ExampleLayout>

‎packages/rapidoc/dist/rapidoc-min.js‎

Lines changed: 233 additions & 4877 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/rapidoc/src/components/api-request.js‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { copyToClipboard, downloadResource, viewResource } from '~/utils/common-
1919
import {
2020
schemaToAST,
2121
getTypeInfo,
22+
isBinaryFileField,
2223
generateExample,
2324
normalizeExamples,
2425
getSchemaFromParam,
@@ -169,7 +170,13 @@ export default class ApiRequest extends LitElement {
169170
}
170171
171172
.file-input-container {
172-
align-items: flex-end;
173+
align-items: flex-start;
174+
width: 100%;
175+
}
176+
.file-input-container .input-set {
177+
width: 100%;
178+
display: flex;
179+
align-items: center;
173180
}
174181
.file-input-container .input-set:first-child .file-input-remove-btn {
175182
visibility: hidden;
@@ -445,7 +452,7 @@ export default class ApiRequest extends LitElement {
445452
${
446453
paramSchema.type === 'array' || paramSchema.type.split('┃').includes('array')
447454
? `${paramSchema.arrayType || paramSchema.type}`
448-
: `${paramSchema.format && !paramSchema.type.includes('┃') ? paramSchema.format : paramSchema.type}`
455+
: `${paramSchema.format && !paramSchema.type.includes('┃') ? paramSchema.format : paramSchema.contentMediaType && !paramSchema.type.includes('┃') ? paramSchema.contentMediaType : paramSchema.type}`
449456
}
450457
</div>
451458
</td>
@@ -1096,7 +1103,7 @@ ${v.exampleFormat === 'text' ? v.exampleValue : JSON.stringify(v.exampleValue, n
10961103
schema.required?.includes(fieldName) || fieldSchema.required ? html`<span style="color:var(--red);">*</span>` : ''
10971104
}
10981105
</div>
1099-
<div class="param-type">${paramSchema.type}</div>
1106+
<div class="param-type">${paramSchema.format && !paramSchema.type.includes('┃') ? paramSchema.format : paramSchema.contentMediaType && !paramSchema.type.includes('┃') ? paramSchema.contentMediaType : paramSchema.type}</div>
11001107
</td>
11011108
<td
11021109
style="${
@@ -1106,14 +1113,14 @@ ${v.exampleFormat === 'text' ? v.exampleValue : JSON.stringify(v.exampleValue, n
11061113
>
11071114
${
11081115
fieldType === 'array'
1109-
? fieldSchema.items?.format === 'binary'
1116+
? isBinaryFileField(fieldSchema.items)
11101117
? html`
11111118
<div
11121119
class="file-input-container col"
1113-
style="align-items:flex-end;"
1120+
style="align-items:flex-start; width:100%;"
11141121
@click="${(e) => this.onAddRemoveFileInput(e, fieldName, mimeType)}"
11151122
>
1116-
<div class="input-set row">
1123+
<div class="input-set row" style="width:100%;">
11171124
<input
11181125
type="file"
11191126
part="file-input"
@@ -1128,7 +1135,7 @@ ${v.exampleFormat === 'text' ? v.exampleValue : JSON.stringify(v.exampleValue, n
11281135
<button
11291136
class="m-btn primary file-input-add-btn"
11301137
part="btn btn-fill"
1131-
style="margin:2px 25px 0 0; padding:2px 6px;"
1138+
style="margin:4px 0 0 0; padding:2px 8px; align-self:flex-start;"
11321139
>
11331140
ADD
11341141
</button>
@@ -1157,7 +1164,7 @@ ${v.exampleFormat === 'text' ? v.exampleValue : JSON.stringify(v.exampleValue, n
11571164
? html`<input
11581165
.value="${this.fillRequestFieldsWithExample === 'true' ? example.exampleVal : ''}"
11591166
spellcheck="false"
1160-
type="${fieldSchema.format === 'binary' ? 'file' : fieldSchema.format === 'password' ? 'password' : 'text'}"
1167+
type="${isBinaryFileField(fieldSchema) ? 'file' : fieldSchema.format === 'password' ? 'password' : 'text'}"
11611168
part="textbox textbox-param"
11621169
style="width:100%"
11631170
data-ptype="${mimeType.includes('form-urlencode') ? 'form-urlencode' : 'form-data'}"
@@ -2072,11 +2079,13 @@ ${responseContent}</pre>
20722079
// Container
20732080
const newInputContainerEl = document.createElement('div');
20742081
newInputContainerEl.setAttribute('class', 'input-set row');
2082+
newInputContainerEl.style = 'width:100%; margin-top:4px;';
20752083

20762084
// File Input
20772085
const newInputEl = document.createElement('input');
20782086
newInputEl.type = 'file';
2079-
newInputEl.style = 'width:200px; margin-top:2px;';
2087+
newInputEl.style = 'width:100%;';
2088+
newInputEl.setAttribute('part', 'file-input');
20802089
newInputEl.setAttribute('data-pname', pname);
20812090
newInputEl.setAttribute('data-ptype', ptype.includes('form-urlencode') ? 'form-urlencode' : 'form-data');
20822091
newInputEl.setAttribute('data-array', 'false');

‎packages/rapidoc/src/components/schema-table.js‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,14 @@ export default class SchemaTable extends LitElement {
413413
.substring(0, 4)
414414
.toLowerCase();
415415
const hasText = !!(node.description || node.title);
416-
const hasDetailChips = !!(node.constraints || node.defaultValue || node.allowedValues || node.pattern);
416+
const hasDetailChips = !!(
417+
node.constraints ||
418+
node.defaultValue ||
419+
node.allowedValues ||
420+
node.pattern ||
421+
node.contentMediaType ||
422+
node.contentEncoding
423+
);
417424
const hasExtra = hasDetailChips || hasMultilineDescription(node.description);
418425
const descrExpander =
419426
hasText && hasExtra
@@ -425,6 +432,8 @@ export default class SchemaTable extends LitElement {
425432
${node.defaultValue ? html`<div style="display:inline-block; line-break:anywhere; margin-right:8px"><span class="bold-text">Default: </span>${node.defaultValue}</div>` : ''}
426433
${node.allowedValues ? html`<div style="display:inline-block; line-break:anywhere; margin-right:8px"><span class="bold-text">${node.type === 'const' ? 'Value' : 'Allowed'}: </span>${node.allowedValues}</div>` : ''}
427434
${node.pattern ? html`<div style="display:inline-block; line-break:anywhere; margin-right:8px"><span class="bold-text">Pattern: </span>${node.pattern}</div>` : ''}
435+
${node.contentMediaType ? html`<div style="display:inline-block; line-break:anywhere; margin-right:8px"><span class="bold-text">Media-Type: </span>${node.contentMediaType}</div>` : ''}
436+
${node.contentEncoding ? html`<div style="display:inline-block; line-break:anywhere; margin-right:8px"><span class="bold-text">Encoding: </span>${node.contentEncoding}</div>` : ''}
428437
`;
429438

430439
return html`

‎packages/rapidoc/src/components/schema-tree.js‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,14 @@ export default class SchemaTree extends LitElement {
398398
.substring(0, 4)
399399
.toLowerCase();
400400
const hasText = !!(node.description || node.title);
401-
const hasDetailChips = !!(node.constraints || node.defaultValue || node.allowedValues || node.pattern);
401+
const hasDetailChips = !!(
402+
node.constraints ||
403+
node.defaultValue ||
404+
node.allowedValues ||
405+
node.pattern ||
406+
node.contentMediaType ||
407+
node.contentEncoding
408+
);
402409
const hasExtra = hasDetailChips || hasMultilineDescription(node.description);
403410
const descrExpander =
404411
hasText && hasExtra
@@ -410,6 +417,8 @@ export default class SchemaTree extends LitElement {
410417
${node.defaultValue ? html`<div style="display:inline-block; line-break:anywhere; margin-right:8px"><span class="bold-text">Default: </span>${node.defaultValue}</div>` : ''}
411418
${node.allowedValues ? html`<div style="display:inline-block; line-break:anywhere; margin-right:8px"><span class="bold-text">${node.type === 'const' ? 'Value' : 'Allowed'}: </span>${node.allowedValues}</div>` : ''}
412419
${node.pattern ? html`<div style="display:inline-block; line-break:anywhere; margin-right:8px"><span class="bold-text">Pattern: </span>${node.pattern}</div>` : ''}
420+
${node.contentMediaType ? html`<div style="display:inline-block; line-break:anywhere; margin-right:8px"><span class="bold-text">Media-Type: </span>${node.contentMediaType}</div>` : ''}
421+
${node.contentEncoding ? html`<div style="display:inline-block; line-break:anywhere; margin-right:8px"><span class="bold-text">Encoding: </span>${node.contentEncoding}</div>` : ''}
413422
`;
414423

415424
return html`

‎packages/rapidoc/src/utils/schema-utils.js‎

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,18 @@ export function getPrintableVal(val) {
110110
return val.toString().replace(/^ +| +$/g, (m) => '●'.repeat(m.length)) || '';
111111
}
112112

113+
/* Helper to detect binary file fields across OpenAPI 3.0 and 3.1 */
114+
export function isBinaryFileField(schema) {
115+
if (!schema) {
116+
return false;
117+
}
118+
return (
119+
schema.format === 'binary' ||
120+
schema.contentEncoding === 'binary' ||
121+
(Boolean(schema.contentMediaType) && schema.contentEncoding !== 'base64')
122+
);
123+
}
124+
113125
/* Generates an schema object containing type and constraint info */
114126
export function getTypeInfo(schema) {
115127
if (!schema) {
@@ -165,6 +177,9 @@ export function getTypeInfo(schema) {
165177
const info = {
166178
type: dataType,
167179
format: schema.format || effectiveSchema.format || '',
180+
contentMediaType: schema.contentMediaType || effectiveSchema.contentMediaType || '',
181+
contentEncoding: schema.contentEncoding || effectiveSchema.contentEncoding || '',
182+
contentSchema: schema.contentSchema || effectiveSchema.contentSchema || null,
168183
pattern:
169184
(schema.pattern || effectiveSchema.pattern) && !schema.enum && !effectiveSchema.enum ? schema.pattern || effectiveSchema.pattern : '',
170185
readOrWriteOnly: schema.readOnly ? '🆁' : schema.writeOnly ? '🆆' : effectiveSchema.readOnly ? '🆁' : effectiveSchema.writeOnly ? '🆆' : '',
@@ -493,6 +508,30 @@ export function getSampleValueByType(schemaObj) {
493508
return schemaObj.pattern;
494509
}
495510
}
511+
if (schemaObj.contentEncoding) {
512+
switch (schemaObj.contentEncoding.toLowerCase()) {
513+
case 'base64':
514+
return 'ZXhhbXBsZQ=='; // 'example' base64 encoded
515+
case 'binary':
516+
case '7bit':
517+
case '8bit':
518+
case 'quoted-printable':
519+
return 'string';
520+
}
521+
}
522+
if (schemaObj.contentMediaType) {
523+
if (schemaObj.contentMediaType.toLowerCase() === 'application/json') {
524+
if (schemaObj.contentSchema) {
525+
const sample = schemaToSampleObj(schemaObj.contentSchema);
526+
const sampleVal = sample?.['example-0'] ?? sample;
527+
return JSON.stringify(sampleVal);
528+
}
529+
return '{}';
530+
}
531+
if (schemaObj.contentMediaType.startsWith('text/')) {
532+
return 'text content';
533+
}
534+
}
496535
if (schemaObj.format) {
497536
switch (schemaObj.format.toLowerCase()) {
498537
case 'url':
@@ -519,7 +558,10 @@ export function getSampleValueByType(schemaObj) {
519558
case 'uuid':
520559
return '3fa85f64-5717-4562-b3fc-2c963f66afa6';
521560
case 'byte':
561+
case 'base64':
522562
return 'ZXhhbXBsZQ=='; // 'example' base64 encoded. See https://spec.openapis.org/oas/v3.0.0#data-types
563+
case 'binary':
564+
return 'binary';
523565
default:
524566
return '';
525567
}
@@ -1022,8 +1064,8 @@ function handleMultiTypeSchema(schema, baseObj = {}, level = 0) {
10221064
typeof subSchema.items?.type === 'string' &&
10231065
subSchema.items?.type.match(/integer|number|string|null|boolean/g)
10241066
) {
1025-
if (subSchema.items.type === 'string' && subSchema.items.format) {
1026-
primitiveType.push(`[${subSchema.items.format}]`);
1067+
if (subSchema.items.type === 'string' && (subSchema.items.format || subSchema.items.contentMediaType)) {
1068+
primitiveType.push(`[${subSchema.items.format || subSchema.items.contentMediaType}]`);
10271069
} else {
10281070
primitiveType.push(`[${subSchema.items.type}]`);
10291071
}
@@ -1161,6 +1203,9 @@ function createPrimitiveAST(schema, name, isRequired) {
11611203
name,
11621204
type: typeInfo.type || schema.type || '',
11631205
format: typeInfo.format || schema.format || '',
1206+
contentMediaType: typeInfo.contentMediaType || schema.contentMediaType || '',
1207+
contentEncoding: typeInfo.contentEncoding || schema.contentEncoding || '',
1208+
contentSchema: typeInfo.contentSchema || schema.contentSchema || null,
11641209
pattern: typeInfo.pattern || schema.pattern || '',
11651210
constraints: typeInfo.constrain || '',
11661211
defaultValue: typeInfo.default !== undefined && typeInfo.default !== '' ? String(typeInfo.default) : '',
@@ -1339,8 +1384,8 @@ export function schemaToAST(schema, level = 0, name = '', isRequired = false, su
13391384
typeof subSchema.items?.type === 'string' &&
13401385
subSchema.items?.type.match(/integer|number|string|null|boolean/g)
13411386
) {
1342-
if (subSchema.items.type === 'string' && subSchema.items.format) {
1343-
primitiveType.push(`[${subSchema.items.format}]`);
1387+
if (subSchema.items.type === 'string' && (subSchema.items.format || subSchema.items.contentMediaType)) {
1388+
primitiveType.push(`[${subSchema.items.format || subSchema.items.contentMediaType}]`);
13441389
} else {
13451390
primitiveType.push(`[${subSchema.items.type}]`);
13461391
}

‎tests/unit/schema-ast.test.js‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,18 @@ describe('schemaToAST', () => {
8080
assert.strictEqual(ast.kind, 'primitive');
8181
assert.strictEqual(ast.defaultValue, 'hello');
8282
});
83+
84+
it('should capture contentMediaType and contentEncoding in primitive AST', () => {
85+
const ast = schemaToAST({
86+
type: 'string',
87+
contentMediaType: 'image/png',
88+
contentEncoding: 'base64',
89+
});
90+
assert.strictEqual(ast.kind, 'primitive');
91+
assert.strictEqual(ast.type, 'string');
92+
assert.strictEqual(ast.contentMediaType, 'image/png');
93+
assert.strictEqual(ast.contentEncoding, 'base64');
94+
});
8395
});
8496

8597
describe('object schemas', () => {

0 commit comments

Comments
 (0)