OpenAPI: Avoid re-serialization on non-RSC mode

This commit is contained in:
Fuma Nama
2026-04-02 18:16:17 +08:00
parent e3b92afd61
commit 9b88a564dc
30 changed files with 480 additions and 400 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Avoid re-serialization on non-RSC mode
-1
View File
@@ -21,7 +21,6 @@
"exports": {
".": "./dist/index.js",
"./i18n": "./dist/i18n.js",
"./playground": "./dist/playground/index.js",
"./playground/client": "./dist/playground/client.js",
"./requests/generators": "./dist/requests/generators/index.js",
"./requests/generators/all": "./dist/requests/generators/all.js",
+2 -2
View File
@@ -1,7 +1,7 @@
import { mkdir, writeFile } from 'node:fs/promises';
import * as path from 'node:path';
import { generateDocument, type PagesToTextOptions, toText } from './utils/pages/to-text';
import type { ProcessedDocument } from '@/utils/process-document';
import type { DereferencedDocument } from '@/utils/document/dereference';
import type { OpenAPIServer } from '@/server';
import { createGetUrl, getSlugs, PathUtils } from 'fumadocs-core/source';
import { createAutoPreset, type SchemaToPagesOptions } from '@/utils/pages/preset-auto';
@@ -90,7 +90,7 @@ export type Config = SchemaToPagesOptions &
interface BeforeWriteContext {
readonly generated: Record<string, OutputFile[]>;
readonly generatedEntries: Record<string, OutputEntry[]>;
readonly documents: Record<string, ProcessedDocument>;
readonly documents: Record<string, DereferencedDocument>;
}
export async function generateFiles(options: Config): Promise<void> {
+74 -50
View File
@@ -11,7 +11,6 @@ import {
} from 'react';
import { useApiContext, useServerContext } from '@/ui/contexts/api';
import type { BrowserFetcherOptions, FetchResult } from '@/playground/fetcher';
import type { SecurityEntry } from '@/playground/index';
import { getStatusInfo } from './status-info';
import { joinURL, resolveRequestData, resolveServerUrl, withBase } from '@/utils/url';
import { MethodLabel } from '@/ui/components/method-label';
@@ -34,7 +33,12 @@ import {
SelectValue,
} from '@/ui/components/select';
import { labelVariants } from '@/ui/components/input';
import type { ParsedSchema } from '@/utils/schema';
import {
getPreferredType,
type SecurityEntry,
type NoReference,
type ParsedSchema,
} from '@/utils/schema';
import ServerSelect from './components/server-select';
import { useStorageKey } from '@/ui/client/storage-key';
import {
@@ -48,11 +52,13 @@ import {
} from '@fumari/stf';
import { objectGet, objectSet, stringifyFieldKey } from '@fumari/stf/lib/utils';
import { FieldInput, FieldSet, JsonInput, ObjectInput } from './components/inputs';
import type { ParameterObject } from '@/types';
import type { Document, HttpMethods, OperationObject, ParameterObject } from '@/types';
import { ClientCodeBlock } from '@/ui/components/codeblock';
import { useTranslations } from '@/ui/client/i18n';
import { useOperationContext } from '@/ui/operation/client';
import { OauthDialog, OauthDialogTrigger } from './components/oauth-dialog';
import { dereferenceDocument } from '@/utils/document/dereference';
import type { ProcessedDocument } from '@/utils/document/process';
export interface FormValues extends Record<string, unknown> {
path: Record<string, unknown>;
@@ -64,17 +70,16 @@ export interface FormValues extends Record<string, unknown> {
export interface PlaygroundClientProps extends ComponentProps<'form'>, SchemaScope {
route: string;
method: string;
parameters?: ParameterObject[];
method: HttpMethods;
securities: SecurityEntry[][];
body?: {
schema: ParsedSchema;
mediaType: string;
};
/**
* Resolver for $ref schemas you've passed
*/
references: Record<string, ParsedSchema>;
/** the OpenAPI document */
doc:
| {
bundled: Document;
}
| {
processed: ProcessedDocument;
};
proxyUrl?: string;
}
@@ -122,28 +127,46 @@ export interface PlaygroundClientOptions {
*
* @see renderParameterField for customisation tips
*/
renderBodyField?: (
fieldName: 'body',
info: {
schema: ParsedSchema;
mediaType: string;
},
) => ReactNode;
renderBodyField?: (fieldName: 'body', info: RequestBodyInfo) => ReactNode;
}
interface RequestBodyInfo {
schema: ParsedSchema;
mediaType: string;
}
function getBodyInfo(operation: NoReference<OperationObject>): RequestBodyInfo | undefined {
const content = operation.requestBody?.content;
if (!content) return;
const mediaType = getPreferredType(content);
if (mediaType) {
return {
mediaType,
schema: content[mediaType].schema ?? true,
};
}
}
export default function PlaygroundClient({
route,
method = 'GET',
method,
securities,
parameters = [],
body,
references,
doc,
proxyUrl,
writeOnly,
readOnly,
...rest
}: PlaygroundClientProps) {
const t = useTranslations();
const schema =
'processed' in doc
? doc.processed
: // oxlint-disable-next-line eslint-plugin-react-hooks/rules-of-hooks -- assume unchanged
useMemo(() => dereferenceDocument(doc.bundled), [doc.bundled]);
const operation: NoReference<OperationObject> = schema.dereferenced.paths![route]![method]!;
const body = getBodyInfo(operation);
const { example: exampleId, examples, setExampleData } = useOperationContext();
const { server } = useServerContext();
const storageKeys = useStorageKey();
@@ -151,10 +174,14 @@ export default function PlaygroundClient({
mediaAdapters,
client: {
playground: {
components: { ResultDisplay = DefaultResultDisplay } = {},
components: {
ResultDisplay = DefaultResultDisplay,
CollapsiblePanel = DefaultCollapsiblePanel,
} = {},
requestTimeout,
fetchOptions = { requestTimeout },
transformAuthInputs,
renderBodyField,
} = {},
},
} = useApiContext();
@@ -189,10 +216,11 @@ export default function PlaygroundClient({
const fetcher = await import('./fetcher').then((mod) =>
mod.createBrowserFetcher(mediaAdapters, { proxyUrl, ...fetchOptions }),
);
const encoded = encodeRequestData(
{ ...mapInputs(input), method, bodyMediaType: body?.mediaType },
mediaAdapters,
parameters,
operation.parameters,
);
return fetcher.fetch(
joinURL(
@@ -227,7 +255,7 @@ export default function PlaygroundClient({
method,
bodyMediaType: body?.mediaType,
};
setExampleData(data, encodeRequestData(data, mediaAdapters, parameters));
setExampleData(data, encodeRequestData(data, mediaAdapters, operation.parameters));
},
timerRef.current ? 400 : 0,
);
@@ -249,7 +277,7 @@ export default function PlaygroundClient({
return (
<StfProvider value={stf}>
<SchemaProvider references={references} writeOnly={writeOnly} readOnly={readOnly}>
<SchemaProvider schema={schema} writeOnly={writeOnly} readOnly={readOnly}>
<form
{...rest}
className={cn(
@@ -286,7 +314,12 @@ export default function PlaygroundClient({
))}
</SecurityTabs>
)}
<FormBody body={body} parameters={parameters} />
<ParametersForm parameters={operation.parameters} />
{body && (
<CollapsiblePanel data-type="body" title={t.body}>
{renderBodyField ? renderBodyField('body', body) : <BodyInput field={body.schema} />}
</CollapsiblePanel>
)}
</form>
</SchemaProvider>
</StfProvider>
@@ -381,7 +414,7 @@ function SecurityTabs({
const ParamTypes = ['path', 'header', 'cookie', 'query'] as const;
type ParamType = (typeof ParamTypes)[number];
function FormBodyItem({ type, parameters }: { type: ParamType; parameters: ParameterObject[] }) {
function ParameterItem({ type, parameters }: { type: ParamType; parameters: ParameterObject[] }) {
const { renderParameterField } = useApiContext().client.playground ?? {};
return parameters.map((field) => {
@@ -408,8 +441,8 @@ function FormBodyItem({ type, parameters }: { type: ParamType; parameters: Param
});
}
function FormBody({ parameters = [], body }: Pick<PlaygroundClientProps, 'parameters' | 'body'>) {
const { renderBodyField, components: { CollapsiblePanel = DefaultCollapsiblePanel } = {} } =
function ParametersForm({ parameters = [] }: { parameters?: ParameterObject[] }) {
const { components: { CollapsiblePanel = DefaultCollapsiblePanel } = {} } =
useApiContext().client.playground ?? {};
const t = useTranslations();
const displayNames = {
@@ -419,25 +452,16 @@ function FormBody({ parameters = [], body }: Pick<PlaygroundClientProps, 'parame
path: t.path,
};
return (
<>
{ParamTypes.map((type) => {
const items = parameters.filter((v) => v.in === type);
if (items.length === 0) return;
return ParamTypes.map((type) => {
const items = parameters.filter((v) => v.in === type);
if (items.length === 0) return;
return (
<CollapsiblePanel key={type} data-type={type} title={displayNames[type]}>
<FormBodyItem parameters={items} type={type} />
</CollapsiblePanel>
);
})}
{body && (
<CollapsiblePanel data-type="body" title={t.body}>
{renderBodyField ? renderBodyField('body', body) : <BodyInput field={body.schema} />}
</CollapsiblePanel>
)}
</>
);
return (
<CollapsiblePanel key={type} data-type={type} title={displayNames[type]}>
<ParameterItem parameters={items} type={type} />
</CollapsiblePanel>
);
});
}
function BodyInput({ field: _field }: { field: ParsedSchema }) {
-174
View File
@@ -1,174 +0,0 @@
import type {
MediaTypeObject,
MethodInformation,
ParameterObject,
RenderContext,
SecuritySchemeObject,
} from '@/types';
import { getPreferredType, NoReference, type ParsedSchema } from '@/utils/schema';
import { type PlaygroundClientProps } from './client';
interface Context {
references: Record<string, ParsedSchema>;
registered: WeakMap<Exclude<ParsedSchema, boolean>, string>;
id: (schema?: object) => string;
}
export interface APIPlaygroundProps {
path: string;
method: MethodInformation;
ctx: RenderContext;
}
export type SecurityEntry = SecuritySchemeObject & {
scopes: string[];
id: string;
};
export function APIPlayground({ path, method, ctx }: APIPlaygroundProps) {
if (ctx.playground?.render) {
return ctx.playground.render({ path, method, ctx });
}
const bodyContent = method.requestBody?.content;
const mediaType = bodyContent ? getPreferredType(bodyContent) : undefined;
const takenIds = new Map<string, number>();
const context: Context = {
references: {},
id(schema) {
let name = 'r';
if (schema) {
const ref = ctx.schema.getRawRef(schema)?.split('/');
if (ref && ref.length > 0) name = ref[ref.length - 1];
}
const count = takenIds.get(name) ?? 0;
takenIds.set(name, count + 1);
return count === 0 ? name : `${name}${count}`;
},
registered: new WeakMap(),
};
const props: PlaygroundClientProps = {
securities: parseSecurities(method, ctx),
method: method.method,
route: path,
parameters: method.parameters?.map((param: NoReference<ParameterObject>): ParameterObject => {
if (param.schema !== undefined) {
return {
...param,
schema: writeReferences(param.schema, context),
} as ParameterObject;
}
if (param.content !== undefined) {
const content: Record<string, MediaTypeObject> = {};
for (const k in param.content) {
const original = param.content[k] as NoReference<MediaTypeObject>;
if (!original || original.schema === undefined) continue;
content[k] = {
...original,
schema: writeReferences(original.schema, context),
} as MediaTypeObject;
}
return {
...param,
content,
} as ParameterObject;
}
return param;
}),
body:
bodyContent && mediaType
? ({
schema: writeReferences(bodyContent[mediaType].schema as ParsedSchema, context),
mediaType,
} as PlaygroundClientProps['body'])
: undefined,
references: context.references,
proxyUrl: ctx.proxyUrl,
writeOnly: true,
readOnly: false,
};
return <ctx.clientBoundary.PlaygroundClient {...props} />;
}
function writeReferences(
schema: ParsedSchema,
ctx: Context,
stack: WeakMap<object, object> = new WeakMap(),
): ParsedSchema {
if (typeof schema !== 'object' || !schema) return schema;
if (stack.has(schema)) {
const out = stack.get(schema)!;
const id = ctx.id(schema);
ctx.references[id] = out;
return {
$ref: `#/${id}`,
};
}
const output = { ...schema };
stack.set(schema, output);
for (const _n in output) {
const name = _n as keyof typeof output;
if (!output[name]) continue;
switch (name) {
case 'oneOf':
case 'allOf':
case 'anyOf':
output[name] = output[name].map((item) => writeReferences(item, ctx, stack));
continue;
case 'items':
case 'additionalProperties':
case 'not':
output[name] = writeReferences(output[name], ctx, stack);
continue;
case 'properties':
case 'patternProperties':
output[name] = { ...output[name] };
for (const key in output[name]) {
output[name][key] = writeReferences(output[name][key], ctx, stack);
}
}
}
return output;
}
function parseSecurities(
method: MethodInformation,
{ schema: { dereferenced } }: RenderContext,
): PlaygroundClientProps['securities'] {
const result: PlaygroundClientProps['securities'] = [];
const security = method.security ?? dereferenced.security ?? [];
if (security.length === 0) return result;
for (const map of security) {
const list: PlaygroundClientProps['securities'][number] = [];
for (const [key, scopes] of Object.entries(map)) {
const scheme = dereferenced.components?.securitySchemes?.[key];
if (!scheme) continue;
list.push({
...scheme,
scopes,
id: key,
});
}
if (list.length > 0) result.push(list);
}
return result;
}
+21 -45
View File
@@ -6,10 +6,10 @@ import { FieldKey, useDataEngine, useFieldValue, useNamespace } from '@fumari/st
import { stringifyFieldKey } from '@fumari/stf/lib/utils';
import { sample } from 'openapi-sampler';
import { FormatFlags, schemaToString } from '@/utils/schema/to-string';
import { resolveRefSync } from '@/utils/schema/resolve-ref';
import { DereferencedDocument } from '@/utils/document/dereference';
interface SchemaContextType extends SchemaScope {
references: Record<string, ParsedSchema>;
schema: DereferencedDocument;
ajv: Ajv2020;
}
@@ -45,7 +45,7 @@ export const anyFields = {
} satisfies ParsedSchema;
export function SchemaProvider({
references,
schema,
readOnly,
writeOnly,
children,
@@ -63,8 +63,8 @@ export function SchemaProvider({
return (
<SchemaContext.Provider
value={useMemo(
() => ({ references, ajv, readOnly, writeOnly }),
[references, ajv, readOnly, writeOnly],
() => ({ schema, ajv, readOnly, writeOnly }),
[schema, ajv, readOnly, writeOnly],
)}
>
{children}
@@ -92,7 +92,7 @@ export function useFieldInfo(
schema: Exclude<ParsedSchema, boolean>;
updateInfo: (value: Partial<FieldInfo>) => void;
} {
const { ajv, references } = use(SchemaContext)!;
const { ajv } = use(SchemaContext)!;
const engine = useDataEngine();
const { generateDefault } = useSchemaUtils();
const fieldData = useNamespace({
@@ -106,9 +106,7 @@ export function useFieldInfo(
if (union) {
const [members, field] = union;
out.oneOf = members.findIndex((item) =>
ajv.validate(typeof item === 'object' ? { ...item, ...references } : item, value),
);
out.oneOf = members.findIndex((item) => ajv.validate(item, value));
if (out.oneOf === -1) out.oneOf = 0;
out.unionField = field;
}
@@ -118,7 +116,7 @@ export function useFieldInfo(
out.selectedType =
types.find((type) => {
return ajv.validate({ ...schema, ...references, type }, value);
return ajv.validate({ ...schema, type }, value);
}) ?? types[0];
}
@@ -156,57 +154,35 @@ export function useFieldInfo(
}
export function useSchemaUtils() {
const { references } = use(SchemaContext)!;
const { schema, readOnly } = use(SchemaContext)!;
return {
generateDefault(schema: ParsedSchema): unknown {
return sample(
schema as never,
{ skipNonRequired: true, skipReadOnly: true, quiet: true },
references,
);
return sample(schema as never, {
skipNonRequired: true,
skipReadOnly: !readOnly,
quiet: true,
});
},
schemaToString(value: ResolvedSchema, flags?: FormatFlags) {
return schemaToString(
value,
(s) => ({ dereferenced: dereference(s, references), raw: s }),
flags,
);
return schemaToString(value, schema, flags);
},
};
}
/**
* resolve $ref & merge `allOf`.
* merge `allOf`.
*/
export function useResolvedSchema(raw: ParsedSchema): Exclude<ParsedSchema, boolean> {
const { references } = use(SchemaContext)!;
return useMemo(() => {
const schema = dereference(raw, references);
if (typeof schema === 'boolean') return anyFields;
let out = raw;
if (schema.allOf) {
const merged = mergeAllOf(schema, {
dereference(schema) {
return dereference(schema, references);
},
});
if (typeof merged === 'boolean') return anyFields;
return merged;
if (typeof out === 'object' && out.allOf) {
out = mergeAllOf(out);
}
return schema;
}, [raw, references]);
}
function dereference(schema: ParsedSchema, references: Record<string, ParsedSchema>): ParsedSchema {
if (typeof schema === 'boolean') return schema;
if (schema.$ref) {
return resolveRefSync(schema.$ref, references) as ParsedSchema;
}
return schema;
return typeof out === 'boolean' ? anyFields : out;
}, [raw]);
}
function getUnion(
+1 -1
View File
@@ -1,5 +1,5 @@
import { createProxy } from '@/server/proxy';
import { processDocument, type ProcessedDocument } from '@/utils/process-document';
import { processDocument, type ProcessedDocument } from '@/utils/document/process';
import type { Document } from '@/types';
import type { InlineCodeUsageGenerator } from '@/requests/generators';
import fs from 'node:fs';
+2 -2
View File
@@ -13,7 +13,7 @@ import type { SchemaToPagesOptions } from '@/utils/pages/preset-auto';
import type { ApiPageProps } from '@/ui/api-page';
import type { StructuredData } from 'fumadocs-core/mdx-plugins';
import type { TOCItemType } from 'fumadocs-core/toc';
import type { ProcessedDocument } from '@/utils/process-document';
import type { DereferencedDocument } from '@/utils/document/dereference';
import type {
OperationOutput,
OutputEntry,
@@ -82,7 +82,7 @@ export function openapiPlugin(): LoaderPlugin {
interface OpenAPIPageData extends PageData {
getAPIPageProps: () => ApiPageProps;
getSchema: () => { id: string } & ProcessedDocument;
getSchema: () => { id: string } & DereferencedDocument;
getClientAPIPageProps: () => Promise<ClientApiPageProps>;
structuredData: StructuredData;
toc: TOCItemType[];
+3 -3
View File
@@ -1,6 +1,6 @@
import type { OpenAPIV3_2, OpenAPIV3 } from './_openapi/types';
import type { NoReference } from '@/utils/schema';
import type { ProcessedDocument } from '@/utils/process-document';
import type { DereferencedDocument } from '@/utils/document/dereference';
import type { MediaAdapter } from '@/requests/media/adapter';
import type { OpenAPIOptions } from '@/server';
import type { CreateAPIPageOptions } from './ui/base';
@@ -25,7 +25,7 @@ export type MediaTypeObject = OpenAPIV3_2.MediaTypeObject;
export type RequestBodyObject = OpenAPIV3_2.RequestBodyObject;
export type MethodInformation = NoReference<OperationObject> & {
method: string;
method: HttpMethods;
'x-codeSamples'?: InlineCodeUsageGenerator[];
'x-selectedCodeSample'?: string;
'x-exclusiveCodeSample'?: string;
@@ -43,7 +43,7 @@ export interface RenderContext
/**
* dereferenced schema
*/
schema: ProcessedDocument;
schema: DereferencedDocument;
clientBoundary: typeof import('@/ui/client/boundary');
mediaAdapters: Record<string, MediaAdapter>;
+36 -9
View File
@@ -1,8 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- rehype-react without types */
import Slugger from 'github-slugger';
import type { Awaitable, MethodInformation, RenderContext } from '@/types';
import type { NoReference } from '@/utils/schema';
import type { ProcessedDocument } from '@/utils/process-document';
import { parseSecurities, type NoReference } from '@/utils/schema';
import type { DereferencedDocument } from '@/utils/document/dereference';
import { defaultAdapters, MediaAdapter } from '@/requests/media/adapter';
import type { FC, HTMLAttributes, ReactNode } from 'react';
import type { OpenAPIServer } from '@/server';
@@ -25,6 +25,8 @@ import type { BundledTheme, CodeOptionsThemes, CodeToHastOptionsCommon } from 's
import { highlightHast, type ShikiFactory } from 'fumadocs-core/highlight/shiki';
import type { ExampleRequestItem } from './operation/get-example-requests';
import { compile } from '@fumari/json-schema-ts';
import { pickSchema } from '@/utils/schema/pick';
import { encodeInternalRef } from '@/utils/schema/ref';
export interface GenerateTypeScriptDefinitionsContext extends RenderContext {
operation: NoReference<MethodInformation>;
@@ -37,6 +39,12 @@ export interface GenerateTypeScriptDefinitionsContext extends RenderContext {
};
}
export interface APIPlaygroundProps {
path: string;
method: MethodInformation;
ctx: RenderContext;
}
export interface CreateAPIPageOptions {
/**
* Generate TypeScript definitions from response schema.
@@ -196,11 +204,7 @@ export interface CreateAPIPageOptions {
/**
* replace the server-side renderer
*/
render?: (props: {
path: string;
method: MethodInformation;
ctx: RenderContext;
}) => Awaitable<ReactNode>;
render?: (props: APIPlaygroundProps) => ReactNode;
};
renderHeading?: (props: HTMLAttributes<HTMLHeadingElement>, depth: number) => ReactNode;
@@ -210,7 +214,7 @@ export interface CreateAPIPageOptions {
}
export interface ServerApiPageProps extends Omit<ApiPageProps, 'document'> {
document: string | ProcessedDocument;
document: string | DereferencedDocument;
}
export function createAPIPage(
@@ -243,8 +247,27 @@ export function createAPIPage(
.use(rehypeReact);
}
function renderPlaygroundDefault({ path, method, ctx }: APIPlaygroundProps) {
return (
<ctx.clientBoundary.PlaygroundClient
route={path}
securities={parseSecurities(method, ctx.schema.dereferenced)}
method={method.method}
doc={{
bundled: pickSchema(
ctx.schema.bundled,
encodeInternalRef(['paths', path, method.method]),
),
}}
proxyUrl={ctx.proxyUrl}
writeOnly
readOnly={false}
/>
);
}
return async function APIPageWrapper({ document, ...props }) {
let processed: ProcessedDocument;
let processed: DereferencedDocument;
if (typeof document === 'string') {
processed = await server.getSchema(document);
} else {
@@ -271,6 +294,10 @@ export function createAPIPage(
...defaultAdapters,
...options.mediaAdapters,
},
playground: {
...options.playground,
render: options.playground?.render ?? renderPlaygroundDefault,
},
renderHeading(depth, text, props) {
const id = typeof text === 'string' ? slugger.slug(text) : props?.id;
if (!id) throw new Error("missing 'id' for non-string children");
+22 -18
View File
@@ -1,7 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- rehype-react without types */
import type { Document, RenderContext } from '@/types';
import type { NoReference } from '@/utils/schema';
import type { ProcessedDocument } from '@/utils/process-document';
import { defaultAdapters } from '@/requests/media/adapter';
import {
Children,
@@ -19,14 +17,14 @@ import remarkRehype from 'remark-rehype';
import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
import * as JsxRuntime from 'react/jsx-runtime';
import { APIPage, type ApiPageProps } from './api-page';
import type { CreateAPIPageOptions } from './base';
import type { APIPlaygroundProps, CreateAPIPageOptions } from './base';
import { defaultShikiFactory } from 'fumadocs-core/highlight/shiki/full';
import { compile } from '@fumari/json-schema-ts';
import { ClientCodeBlock, ClientCodeBlockProvider } from './components/codeblock';
import { dereferenceSync } from '@/utils/schema/dereference';
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
import { slug } from 'github-slugger';
import * as ClientBoundary from '@/ui/client/boundary';
import { dereferenceDocument } from '@/utils/document/dereference';
import { parseSecurities } from '@/utils/schema';
export interface ClientApiPageProps extends Omit<ApiPageProps, 'document'> {
payload: ClientApiPagePayload;
@@ -86,20 +84,22 @@ export function createClientAPIPage({
return remark().use(remarkGfm).use(remarkRehype).use(rehypeReact);
}
return function ClientAPIPage({ payload, ...props }) {
const processed = useMemo<ProcessedDocument>(() => {
const dereferenceMap = new Map<object, string>();
function renderPlaygroundDefault({ method, path, ctx }: APIPlaygroundProps) {
return (
<ctx.clientBoundary.PlaygroundClient
route={path}
securities={parseSecurities(method, ctx.schema.dereferenced)}
method={method.method}
doc={{ processed: ctx.schema }}
proxyUrl={ctx.proxyUrl}
writeOnly
readOnly={false}
/>
);
}
return {
bundled: payload.bundled,
dereferenced: dereferenceSync(payload.bundled as JSONSchema, (schema, ref) => {
dereferenceMap.set(schema as object, ref);
}) as NoReference<Document>,
getRawRef(obj) {
return dereferenceMap.get(obj);
},
};
}, [payload.bundled]);
return function ClientAPIPage({ payload, ...props }) {
const processed = useMemo(() => dereferenceDocument(payload.bundled), [payload.bundled]);
const ctx: RenderContext = useMemo(
() => ({
@@ -114,6 +114,10 @@ export function createClientAPIPage({
...defaultAdapters,
...options.mediaAdapters,
},
playground: {
...options.playground,
render: options.playground?.render ?? renderPlaygroundDefault,
},
renderHeading(depth, text, props) {
const id = typeof text === 'string' ? slug(text) : props?.id;
if (!id) throw new Error("missing 'id' for non-string children");
@@ -113,8 +113,8 @@ function getRequestData(
result.body = bodyOfType.example;
} else {
result.body = sample((bodyOfType?.schema ?? {}) as object, {
skipReadOnly: method.method !== 'GET',
skipWriteOnly: method.method === 'GET',
skipReadOnly: method.method !== 'get',
skipWriteOnly: method.method === 'get',
skipNonRequired: true,
});
}
+5 -6
View File
@@ -22,7 +22,6 @@ import {
AccordionTrigger,
} from '@/ui/components/accordion';
import { isMediaTypeSupported } from '@/requests/media/adapter';
import { APIPlayground } from '@/playground';
import { RequestTabs } from './request-tabs';
import { cn } from '@/utils/cn';
import { getExampleRequests } from './get-example-requests';
@@ -152,8 +151,8 @@ export function Operation({
}
: param.schema
}
readOnly={method.method === 'GET'}
writeOnly={method.method !== 'GET'}
readOnly={method.method === 'get'}
writeOnly={method.method !== 'get'}
ctx={ctx}
/>
),
@@ -277,7 +276,7 @@ export function Operation({
parameters: parameterNode,
responses: responseNode,
apiPlayground: playgroundEnabled ? (
<APIPlayground path={path} method={method} ctx={ctx} />
ctx.playground?.render?.({ path, method, ctx })
) : (
<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>
@@ -371,8 +370,8 @@ function RequestBodyContentItem({
required: method.requestBody?.required,
}}
root={content.schema}
readOnly={method.method === 'GET'}
writeOnly={method.method !== 'GET'}
readOnly={method.method === 'get'}
writeOnly={method.method !== 'get'}
ctx={ctx}
/>
)}
+11 -9
View File
@@ -1,3 +1,5 @@
import { isPlainObject } from './is-plain-object';
export function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) {
return true;
@@ -22,16 +24,16 @@ export function deepEqual(a: unknown, b: unknown): boolean {
return false;
}
const keysA = Object.keys(a as object);
const keysB = Object.keys(b as object);
if (isPlainObject(a) && isPlainObject(b)) {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) {
return false;
if (keysA.length !== keysB.length) {
return false;
}
return keysA.every((key) => key in b && deepEqual(a[key], b[key]));
}
return keysA.every(
(key) =>
Object.prototype.hasOwnProperty.call(b, key) &&
deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
);
return false;
}
@@ -0,0 +1,35 @@
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
import type { NoReference } from '../schema';
import { dereferenceSync } from '../schema/dereference';
import type { Document } from '@/types';
export interface DereferencedDocument {
/**
* dereferenced document
*/
dereferenced: NoReference<Document>;
/**
* Get raw $ref from dereferenced object
*/
getRawRef: (obj: object) => string | undefined;
bundled: Document;
}
export function dereferenceDocument(bundled: Document): DereferencedDocument {
/**
* Dereferenced value and its original `$ref` value
*/
const dereferenceMap = new Map<object, string>();
return {
bundled,
dereferenced: dereferenceSync(bundled as JSONSchema, (schema, ref) => {
dereferenceMap.set(schema as object, ref);
}) as NoReference<Document>,
getRawRef(obj: object) {
return dereferenceMap.get(obj);
},
};
}
@@ -1,24 +1,10 @@
import type { Document } from '@/types';
import type { NoReference } from '@/utils/schema';
import { bundle } from '@scalar/json-magic/bundle';
import { upgrade } from '@scalar/openapi-upgrader';
import { fetchUrls, readFiles } from '@scalar/json-magic/bundle/plugins/node';
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
import { dereferenceSync } from './schema/dereference';
import { dereferenceDocument, type DereferencedDocument } from './dereference';
export interface ProcessedDocument {
/**
* dereferenced document
*/
dereferenced: NoReference<Document>;
/**
* Get raw $ref from dereferenced object
*/
getRawRef: (obj: object) => string | undefined;
bundled: Document;
}
export type ProcessedDocument = DereferencedDocument;
/**
* process & reference input document to a Fumadocs OpenAPI compatible format
@@ -39,19 +25,5 @@ export async function processDocument(input: string | Document): Promise<Process
cause: e,
});
});
/**
* Dereferenced value and its original `$ref` value
*/
const dereferenceMap = new Map<object, string>();
return {
dereferenced: dereferenceSync(bundled as JSONSchema, (schema, ref) => {
dereferenceMap.set(schema as object, ref);
}) as NoReference<Document>,
getRawRef(obj) {
return dereferenceMap.get(obj);
},
bundled,
};
return dereferenceDocument(bundled);
}
@@ -0,0 +1,12 @@
export function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return (
prototype === null ||
prototype === Object.prototype ||
Object.getPrototypeOf(prototype) === null
);
}
+3 -3
View File
@@ -1,4 +1,4 @@
import type { ProcessedDocument } from '@/utils/process-document';
import type { DereferencedDocument } from '@/utils/document/dereference';
import type { OpenAPIServer } from '@/server';
import type { OperationItem, WebhookItem } from '@/ui/api-page';
import type { OperationObject, PathItemObject, TagObject } from '@/types';
@@ -50,7 +50,7 @@ export interface PagesBuilder {
* the input ID in OpenAPI server
*/
id: string;
document: ProcessedDocument;
document: DereferencedDocument;
/**
* add output entry.
*/
@@ -118,7 +118,7 @@ export async function fromServer(
export function fromSchema(
schemaId: string,
processed: ProcessedDocument,
processed: DereferencedDocument,
config: PagesBuilderConfig,
): OutputEntry[] {
const files: OutputEntry[] = [];
@@ -1,5 +1,5 @@
import * as path from 'node:path';
import type { ProcessedDocument } from '@/utils/process-document';
import type { DereferencedDocument } from '@/utils/document/dereference';
import type {
OperationOutput,
OutputEntry,
@@ -79,7 +79,7 @@ type NameFn<
> = (
this: PagesBuilder,
output: DistributiveOmit<Entry, 'path'>,
document: ProcessedDocument['dereferenced'],
document: DereferencedDocument['dereferenced'],
) => string;
interface NameFnOptions {
+3 -3
View File
@@ -1,5 +1,5 @@
import type { ApiPageProps, OperationItem, WebhookItem } from '@/ui/api-page';
import type { ProcessedDocument } from '@/utils/process-document';
import type { DereferencedDocument } from '@/utils/document/dereference';
import type { TagObject } from '@/types';
import { dump } from 'js-yaml';
import { removeUndefined } from '@/utils/remove-undefined';
@@ -49,7 +49,7 @@ export interface PagesToTextOptions {
export function toText(
entry: PageOutput | OperationOutput | WebhookOutput,
processed: ProcessedDocument,
processed: DereferencedDocument,
options: PagesToTextOptions = {},
) {
switch (entry.type) {
@@ -156,7 +156,7 @@ export type DocumentContext =
function generatePage(
schemaId: string,
processed: ProcessedDocument,
processed: DereferencedDocument,
pageProps: Omit<ApiPageProps, 'document'>,
options: PagesToTextOptions & {
title: string;
+15 -11
View File
@@ -1,19 +1,23 @@
import { isPlainObject } from './is-plain-object';
export function removeUndefined<T extends object>(value: T, deep = false): T {
const obj = value as Record<string, unknown>;
if (!isPlainObject(value)) return value;
for (const key in obj) {
if (obj[key] === undefined) delete obj[key];
if (!deep) continue;
const entry = obj[key];
if (typeof entry === 'object' && entry !== null) {
removeUndefined(entry, deep);
for (const key in value) {
const prop = value[key];
if (prop === undefined) {
delete value[key];
continue;
}
if (Array.isArray(entry)) {
for (const item of entry) removeUndefined(item, deep);
if (deep) {
if (Array.isArray(prop)) {
for (const item of prop) removeUndefined(item, deep);
}
if (isPlainObject(prop)) {
removeUndefined(prop, deep);
}
}
}
+39 -2
View File
@@ -1,11 +1,14 @@
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
import type {
Document,
ExampleObject,
HttpMethods,
MediaTypeObject,
MethodInformation,
OperationObject,
PathItemObject,
ReferenceObject,
SecuritySchemeObject,
TagObject,
} from '@/types';
import { idToTitle } from '@/utils/id-to-title';
@@ -53,7 +56,7 @@ export function getTagDisplayName(tag: TagObject): string {
* Summarize method endpoint information
*/
export function createMethod(
method: string,
method: HttpMethods,
path: NoReference<PathItemObject>,
operation: NoReference<OperationObject>,
): MethodInformation {
@@ -63,7 +66,7 @@ export function createMethod(
...operation,
servers: operation.servers ?? path.servers,
parameters: [...(operation.parameters ?? []), ...(path.parameters ?? [])],
method: method.toUpperCase(),
method,
};
}
@@ -97,3 +100,37 @@ export function pickExample(value: ExampleLike): unknown | undefined {
if (examples.length > 0) return examples[0].value;
}
}
/** parsed security scheme objects */
export type SecurityEntry = SecuritySchemeObject & {
scopes: string[];
id: string;
};
export function parseSecurities(
method: MethodInformation,
dereferenced: NoReference<Document>,
): SecurityEntry[][] {
const result: SecurityEntry[][] = [];
const security = method.security ?? dereferenced.security ?? [];
if (security.length === 0) return result;
for (const map of security) {
const list: SecurityEntry[] = [];
for (const [key, scopes] of Object.entries(map)) {
const scheme = dereferenced.components?.securitySchemes?.[key];
if (!scheme) continue;
list.push({
...scheme,
scopes,
id: key,
});
}
if (list.length > 0) result.push(list);
}
return result;
}
+50
View File
@@ -0,0 +1,50 @@
import { isPlainObject } from '../is-plain-object';
import { decodeInternalRef } from './ref';
import { resolveRefSync } from './resolve-ref';
/**
* return a filtered object that only contains the given `$ref` & its referenced fields.
*/
export function pickSchema(root: object, $ref: string): object {
const out: object = {};
const scanned = new Set<string>();
const queue: unknown[] = [{ $ref }];
for (const next of queue) {
if (isPlainObject(next)) {
if (typeof next.$ref === 'string') {
if (scanned.has(next.$ref)) continue;
const resolved = resolveRefSync(next.$ref, root);
scanned.add(next.$ref);
queue.push(resolved);
setField(out, decodeInternalRef(next.$ref), resolved);
}
for (const k in next) queue.push(next[k]);
continue;
}
if (Array.isArray(next)) {
queue.push(...next);
}
}
return out;
}
function setField(obj: unknown, field: string[], value: unknown, i = 0): unknown {
if (i >= field.length) return value;
const k = field[i];
if (k.length === 0) return setField(obj, field, value, i + 1);
if (isPlainObject(obj)) {
obj[k] = setField(obj[k], field, value, i + 1);
return obj;
} else {
return {
[k]: setField(null, field, value, i + 1),
};
}
}
+41
View File
@@ -0,0 +1,41 @@
/**
* RFC 6901 reference-token encoding for JSON Pointer fragments used in in-document `$ref` (`#/…`).
*
* @see https://datatracker.ietf.org/doc/html/rfc6901
*/
function encodeSegment(segment: string): string {
return segment.replace(/~/g, '~0').replace(/\//g, '~1');
}
function decodeSegment(segment: string): string {
return segment.replace(/~1/g, '/').replace(/~0/g, '~');
}
/**
* Build an in-document `$ref` string from logical path segments (e.g. `['components','schemas','Pet']` → `#/components/schemas/Pet`).
*/
export function encodeInternalRef(segments: readonly string[]): string {
if (segments.length === 0) return '#/';
return `#/${segments.map(encodeSegment).join('/')}`;
}
/**
* Parse an in-document `$ref` (`#/…`) into decoded path segments for walking the root document.
*/
export function decodeInternalRef(ref: string): string[] {
if (!ref.startsWith('#')) {
throw new Error('expected in-document $ref starting with `#`');
}
const raw = ref.slice(1);
const out: string[] = [];
if (raw.length === 0) return out;
for (const token of raw.split('/')) {
if (token.length === 0) continue;
out.push(decodeSegment(token));
}
return out;
}
@@ -1,29 +1,13 @@
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
import { isPlainObject } from '../is-plain-object';
import { decodeInternalRef } from './ref';
export function resolveRefSync(ref: string, schema: JSONSchema): unknown | undefined {
if (!ref.startsWith('#')) return;
const segments = ref.slice(1).split('/');
let current: unknown = schema;
for (const seg of segments) {
if (seg.length === 0) continue;
export function resolveRefSync(ref: string, schema: unknown): unknown | undefined {
let current = schema;
for (const seg of decodeInternalRef(ref)) {
if (isPlainObject(current)) current = current[seg];
else return;
}
return current;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return (
prototype === null ||
prototype === Object.prototype ||
Object.getPrototypeOf(prototype) === null
);
}
@@ -1,5 +1,5 @@
import type { ParsedSchema, ResolvedSchema } from '@/utils/schema';
import type { ProcessedDocument } from '@/utils/process-document';
import type { DereferencedDocument } from '@/utils/document/dereference';
export enum FormatFlags {
None = 0,
@@ -13,7 +13,7 @@ type Resolver = (schema: ResolvedSchema) => {
export function schemaToString(
value: ResolvedSchema,
_resolver?: ProcessedDocument | Resolver,
_resolver?: DereferencedDocument | Resolver,
flags: FormatFlags = FormatFlags.None,
): string {
const resolver: Resolver =
@@ -1,4 +1,4 @@
import { processDocument } from '@/utils/process-document';
import { processDocument } from '@/utils/document/process';
import { expect, test } from 'vitest';
test('merges $ref target into local schema for sibling keywords', async () => {
+83
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'vitest';
import { mergeAllOf } from '@/utils/merge-schema';
import { pickSchema } from '@/utils/schema/pick';
import { joinURL, resolveRequestData, resolveServerUrl, withBase } from '@/utils/url';
import type { RequestData } from '@/requests/types';
@@ -648,3 +649,85 @@ describe('URL utilities', () => {
});
});
});
describe('pickSchema', () => {
test('places resolved schema at JSON Pointer path', () => {
const pet = {
type: 'object',
properties: { name: { type: 'string' } },
};
const root = {
components: {
schemas: {
Pet: pet,
},
},
};
expect(pickSchema(root, '#/components/schemas/Pet')).toEqual({
components: {
schemas: {
Pet: pet,
},
},
});
});
test('follows nested $ref and includes referenced definitions', () => {
const address = {
type: 'object',
properties: { street: { type: 'string' } },
};
const person = {
type: 'object',
properties: {
home: { $ref: '#/components/schemas/Address' },
},
};
const root = {
components: {
schemas: {
Address: address,
Person: person,
},
},
};
expect(pickSchema(root, '#/components/schemas/Person')).toEqual({
components: {
schemas: {
Address: address,
Person: person,
},
},
});
});
test('does not revisit the same $ref', () => {
const shared = { type: 'string' };
const root = {
components: {
schemas: {
Shared: shared,
A: { $ref: '#/components/schemas/Shared' },
B: { $ref: '#/components/schemas/Shared' },
},
},
};
expect(pickSchema(root, '#/components/schemas/A')).toEqual({
components: {
schemas: {
Shared: shared,
A: { $ref: '#/components/schemas/Shared' },
},
},
});
});
test('rejects external $ref', () => {
expect(() => pickSchema({}, 'https://example.com/schema.json#/Foo')).toThrow(
'external $ref unsupported',
);
});
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { RenderContext } from '@/types';
import { processDocument } from '@/utils/process-document';
import { processDocument } from '@/utils/document/process';
import { defaultShikiFactory } from 'fumadocs-core/highlight/shiki/full';
import * as ClientBoundary from '@/ui/client/boundary.lazy';
+1 -1
View File
@@ -10,7 +10,7 @@ export default defineConfig({
'./src/ui/base.tsx',
'./src/ui/create-client.tsx',
'./src/ui/client/index.tsx',
'./src/playground/{index,client}.tsx',
'./src/playground/client.tsx',
'./src/scalar/index.tsx',
'./src/server/index.ts',
'./src/requests/generators/*.ts',