mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
OpenAPI: Avoid re-serialization on non-RSC mode
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': patch
|
||||
---
|
||||
|
||||
Avoid re-serialization on non-RSC mode
|
||||
@@ -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",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
+3
-31
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user