OpenAPI: add native support for +variant media types (#2529)

* add native support for +variant media types

* Delete pnpm-workspace.yaml

* restore workspace file

* improve performance

* add changests

---------

Co-authored-by: Fuma Nama <[email protected]>
This commit is contained in:
Luke Hagar
2025-10-30 17:43:29 +08:00
committed by GitHub
co-authored by Fuma Nama
parent c6604a2836
commit 9845ffc9e5
12 changed files with 180 additions and 42 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Support `+variant` media types
+2 -1
View File
@@ -1,5 +1,6 @@
import type { RequestData } from '@/requests/types';
import type { MediaAdapter } from '@/requests/media/adapter';
import { resolveMediaAdapter } from '@/requests/media/adapter';
export interface FetchOptions extends RequestData {
proxyUrl?: string;
@@ -54,7 +55,7 @@ export function createBrowserFetcher(
let body: BodyInit | undefined = undefined;
if (options.bodyMediaType && options.body) {
const adapter = adapters[options.bodyMediaType];
const adapter = resolveMediaAdapter(options.bodyMediaType, adapters);
if (!adapter)
return {
status: 400,
@@ -32,6 +32,7 @@ import {
Accordions,
AccordionTrigger,
} from '@/ui/components/accordion';
import { isMediaTypeSupported } from '@/requests/media/adapter';
export interface CodeSample<T = unknown> {
lang: string;
@@ -111,7 +112,7 @@ export function Operation({
</div>
{body.description && <Markdown text={body.description} />}
{contentTypes.map(([type, content]) => {
if (!(type in ctx.mediaAdapters)) {
if (!isMediaTypeSupported(type, ctx.mediaAdapters)) {
throw new Error(`Media type ${type} is not supported (in ${path})`);
}
@@ -1,5 +1,6 @@
'use client';
import type { SampleGenerator } from '@/requests/types';
import { resolveMediaAdapter } from '@/requests/media/adapter';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const s: string[] = [];
@@ -8,16 +9,14 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
// Handle request body
let body: string | undefined;
if (data.body && data.bodyMediaType && data.bodyMediaType in mediaAdapters) {
body = mediaAdapters[data.bodyMediaType].generateExample(
data as { body: unknown },
{
lang: 'csharp',
addImport(from) {
imports.add(from);
},
if (data.body && data.bodyMediaType) {
const adapter = resolveMediaAdapter(data.bodyMediaType, mediaAdapters);
body = adapter?.generateExample(data as { body: unknown }, {
lang: 'csharp',
addImport(from) {
imports.add(from);
},
);
});
}
for (const specifier of imports) {
@@ -1,6 +1,7 @@
'use client';
import { ident } from '@/requests/string-utils';
import type { SampleGenerator } from '@/requests/types';
import { resolveMediaAdapter } from '@/requests/media/adapter';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const imports = ['fmt', 'net/http', 'io/ioutil'];
@@ -24,17 +25,15 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
let body: string | undefined;
if (data.body && data.bodyMediaType && data.bodyMediaType in mediaAdapters) {
if (data.body && data.bodyMediaType) {
const adapter = resolveMediaAdapter(data.bodyMediaType, mediaAdapters);
headers.set('Content-Type', `"${data.bodyMediaType}"`);
body = mediaAdapters[data.bodyMediaType].generateExample(
data as { body: unknown },
{
lang: 'go',
addImport(from) {
imports.push(from);
},
body = adapter?.generateExample(data as { body: unknown }, {
lang: 'go',
addImport(from) {
imports.push(from);
},
);
});
}
return `package main
@@ -1,6 +1,7 @@
'use client';
import { ident } from '@/requests/string-utils';
import type { SampleGenerator } from '@/requests/types';
import { resolveMediaAdapter } from '@/requests/media/adapter';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const s: string[] = [];
@@ -16,10 +17,9 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
// Handle body if present
let body: string | undefined;
if (data.body && data.bodyMediaType && data.bodyMediaType in mediaAdapters) {
const adapter = mediaAdapters[data.bodyMediaType];
body = adapter.generateExample(data as { body: unknown }, {
if (data.body && data.bodyMediaType) {
const adapter = resolveMediaAdapter(data.bodyMediaType, mediaAdapters);
body = adapter?.generateExample(data as { body: unknown }, {
lang: 'java',
addImport(specifier) {
imports.add(specifier);
@@ -1,6 +1,7 @@
'use client';
import { ident } from '@/requests/string-utils';
import type { SampleGenerator } from '@/requests/types';
import { resolveMediaAdapter } from '@/requests/media/adapter';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const s: string[] = [];
@@ -28,16 +29,14 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
}
let body: string | undefined;
if (data.body && data.bodyMediaType && data.bodyMediaType in mediaAdapters) {
body = mediaAdapters[data.bodyMediaType].generateExample(
data as { body: unknown },
{
lang: 'js',
addImport(from, name) {
s.unshift(`import { ${name} } from "${from}"`);
},
if (data.body && data.bodyMediaType) {
const adapter = resolveMediaAdapter(data.bodyMediaType, mediaAdapters);
body = adapter?.generateExample(data as { body: unknown }, {
lang: 'js',
addImport(from, name) {
s.unshift(`import { ${name} } from "${from}"`);
},
);
});
}
if (body) {
@@ -1,6 +1,7 @@
'use client';
import type { SampleGenerator } from '@/requests/types';
import { generatePythonObject } from '@/requests/to-python-object';
import { resolveMediaAdapter } from '@/requests/media/adapter';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const headers: Record<string, string> = {};
@@ -10,15 +11,13 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
imports.add('requests');
if (data.body && data.bodyMediaType && data.bodyMediaType in mediaAdapters) {
if (data.body && data.bodyMediaType) {
const adapter = resolveMediaAdapter(data.bodyMediaType, mediaAdapters);
headers['Content-Type'] = data.bodyMediaType;
body = mediaAdapters[data.bodyMediaType].generateExample(
data as { body: unknown },
{
lang: 'python',
},
);
body = adapter?.generateExample(data as { body: unknown }, {
lang: 'python',
});
if (body) {
params.push('data = body');
@@ -1,4 +1,5 @@
import { escapeString, inputToString } from '@/requests/string-utils';
export { resolveMediaAdapter, isMediaTypeSupported } from './resolve-adapter';
// @ts-expect-error -- untyped
import js2xml from 'xml-js/lib/js2xml';
@@ -1,4 +1,5 @@
import type { MediaAdapter } from '@/requests/media/adapter';
import { resolveMediaAdapter } from '@/requests/media/adapter';
import type { NoReference } from '@/utils/schema';
import type { ParameterObject } from '@/types';
import type { RawRequestData, RequestData } from '@/requests/types';
@@ -125,8 +126,9 @@ function getMediaEncoder(
if (!field.content) return;
for (const k in field.content) {
if (k in adapters) {
return (v: unknown) => String(adapters[k].encode({ body: v }));
const adapter = resolveMediaAdapter(k, adapters);
if (adapter) {
return (v: unknown) => String(adapter.encode({ body: v }));
}
}
}
@@ -0,0 +1,47 @@
import type { MediaAdapter } from './adapter';
const VariantMediaTypeRegex =
/^(?<dir>[a-zA-Z0-9._-]+)\/(?<content>[a-zA-Z0-9._-]+)\+(?<variant>[a-zA-Z0-9._-]+)$/;
/**
* Resolve a media adapter for a given media type.
* Supports exact matches and pattern matching (e.g., +json suffix).
*
* @param mediaType - The media type to resolve (e.g., "application/json", "application/json-patch+json")
* @param adapters - Record of media adapters
* @returns The resolved adapter or undefined if not found
*/
export function resolveMediaAdapter(
mediaType: string,
adapters: Record<string, MediaAdapter>,
): MediaAdapter | undefined {
// Normalize media type (remove parameters like charset)
const normalized = mediaType.split(';', 2)[0].trim().toLowerCase();
if (normalized in adapters) {
return adapters[normalized];
}
const match = VariantMediaTypeRegex.exec(normalized);
if (match?.groups) {
const baseType = `${match.groups.dir}/${match.groups.variant}`;
if (baseType in adapters) {
return adapters[baseType];
}
}
}
/**
* Check if a media type is supported by the given adapters.
*
* @param mediaType - The media type to check
* @param adapters - Record of media adapters
* @returns true if the media type is supported
*/
export function isMediaTypeSupported(
mediaType: string,
adapters: Record<string, MediaAdapter>,
): boolean {
return resolveMediaAdapter(mediaType, adapters) !== undefined;
}
@@ -0,0 +1,85 @@
import { describe, expect, test } from 'vitest';
import {
defaultAdapters,
isMediaTypeSupported,
resolveMediaAdapter,
} from '@/requests/media/adapter';
describe('Media Adapter Resolution', () => {
test('resolves exact match', () => {
const adapter = resolveMediaAdapter('application/json', defaultAdapters);
expect(adapter).toBe(defaultAdapters['application/json']);
});
test('resolves case-insensitive match', () => {
const adapter = resolveMediaAdapter('Application/JSON', defaultAdapters);
expect(adapter).toBe(defaultAdapters['application/json']);
});
test('resolves with charset parameter', () => {
const adapter = resolveMediaAdapter(
'application/json; charset=utf-8',
defaultAdapters,
);
expect(adapter).toBe(defaultAdapters['application/json']);
});
test('resolves +json suffix variants', () => {
// These should all resolve to application/json adapter
const variants = [
'application/json-patch+json',
'application/merge-patch+json',
'application/ld+json',
'application/vnd.api+json',
'application/hal+json',
'application/problem+json',
'application/vnd.oai.openapi+json',
];
for (const variant of variants) {
const adapter = resolveMediaAdapter(variant, defaultAdapters);
expect(adapter, variant).toBe(defaultAdapters['application/json']);
}
});
test('resolves +xml suffix variants', () => {
const variants = ['application/soap+xml', 'application/rss+xml'];
for (const variant of variants) {
const adapter = resolveMediaAdapter(variant, defaultAdapters);
expect(adapter).toBe(defaultAdapters['application/xml']);
}
});
test('returns undefined for unsupported media type', () => {
const adapter = resolveMediaAdapter('application/yaml', defaultAdapters);
expect(adapter).toBeUndefined();
});
test('returns undefined for unsupported +suffix', () => {
const adapter = resolveMediaAdapter(
'application/custom+yaml',
defaultAdapters,
);
expect(adapter).toBeUndefined();
});
test('isMediaTypeSupported returns true for supported types', () => {
expect(isMediaTypeSupported('application/json', defaultAdapters)).toBe(
true,
);
expect(
isMediaTypeSupported('application/json-patch+json', defaultAdapters),
).toBe(true);
expect(isMediaTypeSupported('multipart/form-data', defaultAdapters)).toBe(
true,
);
});
test('isMediaTypeSupported returns false for unsupported types', () => {
expect(isMediaTypeSupported('application/yaml', defaultAdapters)).toBe(
false,
);
expect(isMediaTypeSupported('text/plain', defaultAdapters)).toBe(false);
});
});