mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': patch
|
||||
---
|
||||
|
||||
Support `+variant` media types
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user