OpenAPI: render deprecated badge for deprecated operations (#3281)

* OpenAPI: render deprecated badge for deprecated operations

Show a yellow "Deprecated" badge for any OpenAPI operation marked
`deprecated: true`. The badge appears in two places: inline with the
operation heading when `showTitle` is true, and as a standalone header
above the playground card when `showTitle` is false (which is the
default for individual operation pages generated by `openapiSource`).

This restores the behaviour originally added in #2417, which was lost
during the v9→v10 move of the operation renderer from
`packages/openapi/src/render/operation/index.tsx` to
`packages/openapi/src/ui/operation/index.tsx`. The parameter-level,
schema-property-level and auth-scheme-level deprecated badges all
survived the refactor; the operation-level one was silently dropped.

The example spec's `deletePlanet` operation already claimed in its
description to be deprecated but never set the flag (inherited from the
upstream Scalar Galaxy fixture). Setting it both fixes the
inconsistency and provides a visible demo for the new badge.

* OpenAPI: improve operation deprecation styles

---------

Co-authored-by: Fuma Nama <[email protected]>
This commit is contained in:
Jakub Kukul
2026-05-14 17:18:41 +08:00
committed by GitHub
co-authored by Fuma Nama
parent 0287f49f65
commit d4c78e4aef
11 changed files with 68 additions and 41 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
OpenAPI: display a "Deprecated" badge for operations marked as `deprecated: true` in the spec, next to the operation heading and the method/path bar.
+1
View File
@@ -351,6 +351,7 @@ paths:
- Planets
summary: Delete a planet
operationId: deletePlanet
deprecated: true
description: This endpoint was used to delete planets. Unfortunately, that
caused a lot of trouble for planets with life. So, this endpoint is now
deprecated and should not be used anymore.
+3 -1
View File
@@ -71,6 +71,7 @@ export interface PlaygroundClientProps extends ComponentProps<'form'>, SchemaSco
/** the OpenAPI document (not dereferenced) */
doc: Document;
proxyUrl?: string;
deprecated?: boolean;
}
export type { ResultDisplayProps };
@@ -131,6 +132,7 @@ export default function PlaygroundClient({
proxyUrl,
writeOnly,
readOnly,
deprecated,
...rest
}: PlaygroundClientProps) {
const t = useTranslations();
@@ -279,7 +281,7 @@ export default function PlaygroundClient({
<ServerSelect className="border-b" />
<div className="flex flex-row items-center gap-2 text-sm p-3 not-last:pb-0">
<MethodLabel>{method}</MethodLabel>
<Route route={route} className="flex-1" />
<Route route={route} className={cn('flex-1', deprecated && 'line-through')} />
<button
type="submit"
className={cn(buttonVariants({ color: 'primary', size: 'sm' }), 'w-14 py-1.5')}
@@ -36,6 +36,7 @@ declare module 'fumadocs-core/source' {
export interface InternalOpenAPIMeta {
method?: string;
webhook?: boolean;
deprecated?: boolean;
}
/**
@@ -54,6 +55,10 @@ export function openapiPlugin(): LoaderPlugin {
const openApiData = file.data._openapi;
if (!openApiData || typeof openApiData !== 'object') return node;
if (openApiData.deprecated) {
node.name = <span className="fd-page-tree-item-name line-through">{node.name}</span>;
}
if (openApiData.webhook) {
node.name = (
<>
@@ -151,6 +156,7 @@ export async function openapiSource(
? entry.item.method
: undefined,
webhook: entry.type === 'webhook',
deprecated: entry.info.deprecated,
},
},
});
+1
View File
@@ -262,6 +262,7 @@ export function createAPIPage(
proxyUrl={ctx.proxyUrl}
writeOnly
readOnly={false}
deprecated={method.deprecated}
/>
);
}
@@ -95,6 +95,7 @@ export function createClientAPIPage({
proxyUrl={ctx.proxyUrl}
writeOnly
readOnly={false}
deprecated={method.deprecated}
/>
);
}
+21 -2
View File
@@ -26,6 +26,7 @@ import { RequestTabs } from './request-tabs';
import { cn } from '@/utils/cn';
import { getExampleRequests } from './get-example-requests';
import { SelectTabs, SelectTabTrigger, SelectTab } from '../components/select-tab';
import { Callout } from 'fumadocs-ui/components/callout';
const paramTypeKeys = ['path', 'query', 'header', 'cookie'] as const;
@@ -63,8 +64,21 @@ export function Operation({
if (showTitle) {
const title = method.summary || (method.operationId ? idToTitle(method.operationId) : path);
headNode = ctx.renderHeading(headingLevel, title);
headNode = (
<div className="flex gap-2 items-center justify-between">
{ctx.renderHeading(headingLevel, title, {
className: 'my-0!',
})}
{method.deprecated && (
<Badge color="yellow" className="text-xs not-prose">
<I18nLabel label="deprecated" />
</Badge>
)}
</div>
);
headingLevel++;
} else if (method.deprecated) {
headNode = <Callout type="warn" title={<I18nLabel label="deprecated" />} className="mt-0!" />;
}
const contentTypes = body?.content ? Object.entries(body.content) : null;
@@ -280,7 +294,12 @@ export function Operation({
) : (
<div className="flex flex-row items-center gap-2.5 p-3 rounded-xl border bg-fd-card text-fd-card-foreground not-prose">
<MethodLabel className="text-xs">{method.method}</MethodLabel>
<code className="flex-1 overflow-auto text-nowrap text-[0.8125rem] text-fd-muted-foreground">
<code
className={cn(
'flex-1 overflow-auto text-nowrap text-[0.8125rem] text-fd-muted-foreground',
method.deprecated && 'line-through',
)}
>
{path}
</code>
</div>
@@ -64,6 +64,7 @@ export function UsageTab({
} = useOperationContext();
const { server } = useServerContext();
const codegen = codeUsages.get(id);
const [mounted, setMounted] = useState(false);
const [data, setData] = useState(
() => examples.find((example) => example.id === selectedExampleId)?.encoded,
);
@@ -72,6 +73,7 @@ export function UsageTab({
const listener: ExampleUpdateListener = (_, encoded) => setData(encoded);
addListener(listener);
setMounted(true);
return () => {
removeListener(listener);
};
@@ -80,7 +82,7 @@ export function UsageTab({
const code = useMemo(() => {
if (!data) return;
const url = joinURL(
server && typeof window !== 'undefined'
server && mounted
? withBase(resolveServerUrl(server.url, server.variables), window.location.origin)
: 'https://example.com',
resolveRequestData(route, data),
@@ -100,7 +102,7 @@ export function UsageTab({
mediaAdapters,
server: null,
});
}, [data, server, route, _client, codegen, mediaAdapters]);
}, [data, server, route, mounted, _client, codegen, mediaAdapters]);
if (!code) return null;
@@ -11,6 +11,7 @@ interface BaseEntry {
info: {
title: string;
description?: string;
deprecated?: boolean;
};
}
@@ -317,6 +317,7 @@ export function createAutoPreset(options: SchemaToPagesOptions): PagesBuilderCon
info: {
title: displayName,
description: operation.description ?? pathItem.description,
deprecated: operation.deprecated,
},
});
}
@@ -330,6 +331,7 @@ export function createAutoPreset(options: SchemaToPagesOptions): PagesBuilderCon
info: {
title: displayName,
description: operation.description ?? pathItem.description,
deprecated: operation.deprecated,
},
item: webhook,
});
+23 -36
View File
@@ -61,13 +61,8 @@ export function toText(
{
operations: [entry.item],
},
{
...options,
...entry.info,
},
{
type: 'operation',
},
options,
entry,
);
case 'page':
return generatePage(
@@ -78,18 +73,8 @@ export function toText(
webhooks: entry.webhooks,
showTitle: true,
},
{
...options,
...entry.info,
},
entry.tag
? {
type: 'tag',
tag: entry.tag,
}
: {
type: 'file',
},
options,
entry,
);
case 'webhook':
return generatePage(
@@ -98,13 +83,8 @@ export function toText(
{
webhooks: [entry.item],
},
{
...options,
...entry.info,
},
{
type: 'operation',
},
options,
entry,
);
}
}
@@ -146,7 +126,7 @@ export function generateDocument(
export type DocumentContext =
| {
type: 'tag';
tag: TagObject | undefined;
tag: TagObject;
}
| {
type: 'operation';
@@ -159,14 +139,19 @@ function generatePage(
schemaId: string,
processed: DereferencedDocument,
pageProps: Omit<ApiPageProps, 'document'>,
options: PagesToTextOptions & {
title: string;
description?: string;
},
context: DocumentContext,
options: PagesToTextOptions,
entry: PageOutput | OperationOutput | WebhookOutput,
): string {
const { frontmatter, includeDescription = false } = options;
const extend = frontmatter?.(options.title, options.description, context);
const extend = frontmatter?.(
entry.info.title,
entry.info.description,
entry.type === 'page'
? entry.tag
? { type: 'tag', tag: entry.tag }
: { type: 'file' }
: { type: 'operation' },
);
const page: ApiPageProps = {
...pageProps,
document: schemaId,
@@ -178,6 +163,7 @@ function generatePage(
meta = {
method: operation.method.toUpperCase(),
deprecated: entry.info.deprecated,
};
} else if (page.webhooks?.length === 1) {
const webhook = page.webhooks[0];
@@ -185,19 +171,20 @@ function generatePage(
meta = {
method: webhook.method.toUpperCase(),
webhook: true,
deprecated: entry.info.deprecated,
};
}
const data = toStaticData(page, processed.dereferenced);
const content: string[] = [];
if (options.description && includeDescription) content.push(options.description);
if (entry.info.description && includeDescription) content.push(entry.info.description);
content.push(pageContent(page));
return generateDocument(
{
title: options.title,
description: !includeDescription ? options.description : undefined,
title: entry.info.title,
description: !includeDescription ? entry.info.description : undefined,
full: true,
...extend,
_openapi: {