OpenAPI: Support Server Action Proxy

This commit is contained in:
Fuma Nama
2024-12-16 20:02:00 +08:00
parent 2357d402eb
commit 8aff7f4ae5
17 changed files with 466 additions and 354 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': minor
---
Support Server Action Proxy
+29 -8
View File
@@ -10,6 +10,10 @@ import {
type ParsedSchema,
} from '@/utils/schema';
import { getSecurities } from '@/utils/get-security';
import {
createBrowserFetcher,
type FetchOptions,
} from '@/ui/playground/fetcher';
interface BaseRequestField {
name: string;
@@ -74,7 +78,7 @@ export type RequestSchema =
interface Context {
allowFile: boolean;
schema: Record<string, RequestSchema>;
references: Record<string, RequestSchema>;
registered: WeakMap<ParsedSchema, string>;
nextId: () => string;
render: RenderContext;
@@ -90,6 +94,20 @@ export interface APIPlaygroundProps {
header?: PrimitiveRequestField[];
body?: RequestSchema;
schemas: Record<string, RequestSchema>;
fetchAction?: typeof fetch;
}
async function fetch(
input: FetchOptions & {
bodySchema: RequestSchema | undefined;
references: Record<string, RequestSchema>;
},
) {
'use server';
const baseFetcher = createBrowserFetcher(input.bodySchema, input.references);
return baseFetcher.fetch(input);
}
export function Playground({
@@ -107,7 +125,7 @@ export function Playground({
const context: Context = {
allowFile: mediaType === 'multipart/form-data',
schema: {},
references: {},
nextId() {
return String(currentId++);
},
@@ -115,6 +133,11 @@ export function Playground({
render: ctx,
};
const bodySchema =
bodyContent && mediaType && bodyContent[mediaType].schema
? toSchema(bodyContent[mediaType].schema, true, context)
: undefined;
const props: APIPlaygroundProps = {
authorization: getAuthorizationField(method, ctx),
method: method.method,
@@ -129,11 +152,9 @@ export function Playground({
header: method.parameters
?.filter((v) => v.in === 'header')
.map((v) => parameterToField(v, context)),
body:
bodyContent && mediaType && bodyContent[mediaType].schema
? toSchema(bodyContent[mediaType].schema, true, context)
: undefined,
schemas: context.schema,
body: bodySchema,
schemas: context.references,
fetchAction: ctx.enableServerActionProxy ? fetch : undefined,
};
return <ctx.renderer.APIPlayground {...props} />;
@@ -178,7 +199,7 @@ function getIdFromSchema(
if (registered === undefined) {
const id = ctx.nextId();
ctx.registered.set(schema, id);
ctx.schema[id] = toSchema(schema, required, ctx);
ctx.references[id] = toSchema(schema, required, ctx);
return id;
}
+4 -1
View File
@@ -11,7 +11,10 @@ import { type DocumentInput, processDocument } from '@/utils/process-document';
export interface ApiPageProps
extends Pick<
RenderContext,
'generateCodeSamples' | 'generateTypeScriptSchema' | 'shikiOptions'
| 'generateCodeSamples'
| 'generateTypeScriptSchema'
| 'shikiOptions'
| 'enableServerActionProxy'
> {
document: DocumentInput;
hasHead: boolean;
+2
View File
@@ -38,6 +38,8 @@ type Awaitable<T> = T | Promise<T>;
export type DereferenceMap = Map<unknown, string>;
export interface RenderContext {
enableServerActionProxy?: boolean;
renderer: Renderer;
/**
-199
View File
@@ -1,199 +0,0 @@
import { CircleCheckIcon, CircleXIcon } from 'lucide-react';
import { resolve } from '@/ui/api/resolve';
import { type RequestSchema } from '@/render/playground';
import { type DynamicField } from '@/ui/contexts/schema';
/**
* Create request body from value
*/
export function createBodyFromValue(
type: 'json' | 'form-data',
value: unknown,
schema: RequestSchema,
references: Record<string, RequestSchema>,
dynamic: Map<string, DynamicField>,
): string | FormData {
const result = convertValue('body', value, schema, references, dynamic);
if (type === 'json') {
return JSON.stringify(result);
}
const formData = new FormData();
if (typeof result !== 'object' || !result) {
throw new Error(
`Unsupported body type: ${typeof result}, expected: object`,
);
}
for (const key of Object.keys(result)) {
const prop: unknown = result[key as keyof object];
if (typeof prop === 'object' && prop instanceof File) {
formData.set(key, prop);
}
if (Array.isArray(prop) && prop.every((item) => item instanceof File)) {
for (const item of prop) {
formData.append(key, item);
}
}
if (prop && !(prop instanceof File)) {
formData.set(key, JSON.stringify(prop));
}
}
return formData;
}
/**
* Convert a value (object or string) to the corresponding type of schema
*
* @param fieldName - field name of value
* @param value - the original value
* @param schema - the schema of field
* @param references - schema references
* @param dynamic - Dynamic references
*/
function convertValue(
fieldName: string,
value: unknown,
schema: RequestSchema,
references: Record<string, RequestSchema>,
dynamic: Map<string, DynamicField>,
): unknown {
const isEmpty = value === '' || value === undefined || value === null;
if (isEmpty && schema.isRequired)
return schema.type === 'boolean' ? false : '';
else if (isEmpty) return undefined;
if (Array.isArray(value) && schema.type === 'array') {
return value.map((item: unknown, index) =>
convertValue(
`${fieldName}.${String(index)}`,
item,
resolve(schema.items, references),
references,
dynamic,
),
);
}
if (schema.type === 'switcher') {
return convertDynamicValue(fieldName, value, references, dynamic);
}
if (typeof value === 'object' && schema.type === 'object') {
const entries = Object.keys(value).map((key) => {
const prop = value[key as keyof object];
const propFieldName = `${fieldName}.${key}`;
if (key in schema.properties) {
return [
key,
convertValue(
propFieldName,
prop,
resolve(schema.properties[key], references),
references,
dynamic,
),
];
}
if (schema.additionalProperties) {
return [
key,
convertDynamicValue(propFieldName, prop, references, dynamic),
];
}
console.warn('Could not resolve field', propFieldName, dynamic);
return [key, prop];
});
return Object.fromEntries(entries);
}
switch (schema.type) {
case 'number':
return Number(value);
case 'boolean':
return value === 'null' ? undefined : value === 'true';
case 'file':
return value; // file
default:
return String(value);
}
}
function convertDynamicValue(
fieldName: string,
value: unknown,
references: Record<string, RequestSchema>,
dynamic: Map<string, DynamicField>,
): unknown {
const fieldDynamic = dynamic.get(fieldName);
return convertValue(
fieldName,
value,
fieldDynamic?.type === 'field'
? resolve(fieldDynamic.schema, references)
: { type: 'null', isRequired: false },
references,
dynamic,
);
}
interface StatusInfo {
description: string;
color: string;
icon: React.ElementType;
}
const statusMap: Record<number, StatusInfo> = {
400: { description: 'Bad Request', color: 'text-red-500', icon: CircleXIcon },
401: {
description: 'Unauthorized',
color: 'text-red-500',
icon: CircleXIcon,
},
403: { description: 'Forbidden', color: 'text-red-500', icon: CircleXIcon },
404: {
description: 'Not Found',
color: 'text-fd-muted-foreground',
icon: CircleXIcon,
},
500: {
description: 'Internal Server Error',
color: 'text-red-500',
icon: CircleXIcon,
},
};
export function getStatusInfo(status: number): StatusInfo {
if (status in statusMap) {
return statusMap[status];
}
if (status >= 200 && status < 300) {
return {
description: 'Successful',
color: 'text-green-500',
icon: CircleCheckIcon,
};
}
if (status >= 400) {
return { description: 'Error', color: 'text-red-500', icon: CircleXIcon };
}
return {
description: 'No Description',
color: 'text-fd-muted-foreground',
icon: CircleXIcon,
};
}
@@ -1,27 +1,13 @@
import { type ReactNode } from 'react';
import * as Base from 'fumadocs-ui/components/codeblock';
import { useApiContext } from '@/ui/contexts/api';
import { useShiki } from 'fumadocs-core/utils/use-shiki';
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
export type CodeBlockProps = {
code: string;
lang?: string;
};
export function CodeBlock({ code, lang = 'json' }: CodeBlockProps): ReactNode {
export function CodeBlock({ code, lang = 'json' }: CodeBlockProps) {
const { shikiOptions } = useApiContext();
const rendered = useShiki(code, {
lang,
...shikiOptions,
components: {
pre: (props) => (
<Base.Pre className="max-h-[288px]" {...props}>
{props.children}
</Base.Pre>
),
},
});
return <Base.CodeBlock className="my-0">{rendered}</Base.CodeBlock>;
return <DynamicCodeBlock lang={lang} code={code} options={shikiOptions} />;
}
+1 -1
View File
@@ -102,7 +102,7 @@ const FormLabel = forwardRef<
FormLabel.displayName = 'FormLabel';
const FormControl = forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { isError, formItemId, formDescriptionId } = useFormField();
@@ -1,6 +1,6 @@
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { forwardRef } from 'react';
import React, { forwardRef } from 'react';
import { cn } from 'fumadocs-ui/components/api';
const Select = SelectPrimitive.Root;
@@ -10,7 +10,7 @@ const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
@@ -30,7 +30,7 @@ const SelectTrigger = forwardRef<
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
@@ -44,7 +44,7 @@ const SelectScrollUpButton = forwardRef<
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
@@ -59,7 +59,7 @@ SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
@@ -89,7 +89,7 @@ const SelectContent = forwardRef<
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
@@ -101,7 +101,7 @@ const SelectLabel = forwardRef<
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
@@ -122,7 +122,7 @@ const SelectItem = forwardRef<
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
+2 -2
View File
@@ -1,9 +1,9 @@
import { createContext, type MutableRefObject, useContext } from 'react';
import { createContext, type RefObject, useContext } from 'react';
import { type ReferenceSchema, type RequestSchema } from '@/render/playground';
interface SchemaContextType {
references: Record<string, RequestSchema>;
dynamic: MutableRefObject<Map<string, DynamicField>>;
dynamic: RefObject<Map<string, DynamicField>>;
}
export type DynamicField =
@@ -0,0 +1,239 @@
import { resolve } from '@/ui/playground/resolve';
import { type ReferenceSchema, type RequestSchema } from '@/render/playground';
import { type DynamicField } from '@/ui/contexts/schema';
export interface FetchOptions {
url: string;
method: string;
type: 'form-data' | 'json';
header: Record<string, unknown>;
body?: unknown;
dynamicFields?: Map<string, DynamicField>;
}
export interface FetchResult {
status: number;
type: 'json' | 'html' | 'text';
data: unknown;
}
export interface Fetcher {
/**
* @param input - fetch request inputs
* @param dynamicFields - schema of dynamic fields, given by the playground client
*/
fetch: (input: FetchOptions & {}) => Promise<FetchResult>;
}
/**
* @param bodySchema - schema of body
* @param references - defined references of schemas, needed for resolve cyclic references
*/
export function createBrowserFetcher(
bodySchema: RequestSchema | undefined,
references: Record<string, RequestSchema>,
): Fetcher {
return {
async fetch(input) {
const headers = new Headers();
if (input.type !== 'form-data')
headers.append('Content-Type', 'application/json');
for (const key of Object.keys(input.header)) {
const paramValue = input.header[key];
if (typeof paramValue === 'string' && paramValue.length > 0)
headers.append(key, paramValue.toString());
}
return fetch(input.url, {
method: input.method,
headers,
body: bodySchema
? createBodyFromValue(
input.type,
input.body,
bodySchema,
references,
input.dynamicFields ?? new Map(),
)
: undefined,
signal: AbortSignal.timeout(6000),
})
.then(async (res) => {
const contentType = res.headers.get('Content-Type') ?? '';
let type: FetchResult['type'];
let data: unknown;
if (contentType.startsWith('application/json')) {
type = 'json';
data = await res.json();
} else {
type = contentType.startsWith('text/html') ? 'html' : 'text';
data = await res.text();
}
return { status: res.status, type, data };
})
.catch((e) => {
const message =
e instanceof Error ? `[${e.name}] ${e.message}` : e.toString();
return {
status: 400,
type: 'text',
data: `Client side error: ${message}`,
};
});
},
};
}
/**
* Create request body from value
*/
export function createBodyFromValue(
type: 'json' | 'form-data',
value: unknown,
schema: RequestSchema,
references: Record<string, RequestSchema>,
dynamicFields: Map<string, DynamicField>,
): string | FormData {
const result = convertValue('body', value, schema, references, dynamicFields);
if (type === 'json') {
return JSON.stringify(result);
}
const formData = new FormData();
if (typeof result !== 'object' || !result) {
throw new Error(
`Unsupported body type: ${typeof result}, expected: object`,
);
}
for (const key of Object.keys(result)) {
const prop: unknown = result[key as keyof object];
if (typeof prop === 'object' && prop instanceof File) {
formData.set(key, prop);
}
if (Array.isArray(prop) && prop.every((item) => item instanceof File)) {
for (const item of prop) {
formData.append(key, item);
}
}
if (prop && !(prop instanceof File)) {
formData.set(key, JSON.stringify(prop));
}
}
return formData;
}
/**
* Convert a value (object or string) to the corresponding type of schema
*
* @param fieldName - field name of value
* @param value - the original value
* @param schema - the schema of field
* @param references - schema references
* @param dynamicFields - Dynamic references
*/
function convertValue(
fieldName: string,
value: unknown,
schema: RequestSchema,
references: Record<string, RequestSchema>,
dynamicFields: Map<string, DynamicField>,
): unknown {
const isEmpty = value === '' || value === undefined || value === null;
if (isEmpty && schema.isRequired)
return schema.type === 'boolean' ? false : '';
else if (isEmpty) return undefined;
if (Array.isArray(value) && schema.type === 'array') {
return value.map((item: unknown, index) =>
convertValue(
`${fieldName}.${String(index)}`,
item,
resolve(schema.items, references),
references,
dynamicFields,
),
);
}
if (schema.type === 'switcher') {
const schema = resolve(
getDynamicFieldSchema(fieldName, dynamicFields),
references,
);
return convertValue(fieldName, value, schema, references, dynamicFields);
}
if (typeof value === 'object' && schema.type === 'object') {
const entries = Object.keys(value).map((key) => {
const prop = value[key as keyof object];
const propFieldName = `${fieldName}.${key}`;
if (key in schema.properties) {
return [
key,
convertValue(
propFieldName,
prop,
resolve(schema.properties[key], references),
references,
dynamicFields,
),
];
}
if (schema.additionalProperties) {
const schema = resolve(
getDynamicFieldSchema(propFieldName, dynamicFields),
references,
);
return [
key,
convertValue(propFieldName, prop, schema, references, dynamicFields),
];
}
console.warn('Could not resolve field', propFieldName, dynamicFields);
return [key, prop];
});
return Object.fromEntries(entries);
}
switch (schema.type) {
case 'number':
return Number(value);
case 'boolean':
return value === 'null' ? undefined : value === 'true';
case 'file':
return value; // file
default:
return String(value);
}
}
function getDynamicFieldSchema(
name: string,
dynamicFields: Map<string, DynamicField>,
): RequestSchema | ReferenceSchema {
const field = dynamicFields.get(name);
return field?.type === 'field'
? field.schema
: { type: 'null', isRequired: false };
}
@@ -1,5 +1,5 @@
import type { PrimitiveRequestField, RequestSchema } from '@/render/playground';
import { resolve } from '@/ui/api/resolve';
import { resolve } from '@/ui/playground/resolve';
export function getDefaultValue(
item: RequestSchema,
@@ -18,16 +18,20 @@ import type {
} from 'react-hook-form';
import { useApiContext } from '@/ui/contexts/api';
import { Form } from '@/ui/components/form';
import { createBodyFromValue, getStatusInfo } from '@/ui/api/fetcher';
import { getDefaultValue, getDefaultValues } from '@/ui/api/get-default-values';
import { InputField, ObjectInput } from '@/ui/inputs';
import type { Fetcher, FetchResult } from '@/ui/playground/fetcher';
import {
getDefaultValue,
getDefaultValues,
} from '@/ui/playground/get-default-values';
import { InputField, ObjectInput } from '@/ui/playground/inputs';
import type {
APIPlaygroundProps,
PrimitiveRequestField,
RequestSchema,
} from '@/render/playground';
import { CodeBlock } from '@/ui/components/codeblock';
import { type DynamicField, SchemaContext } from './contexts/schema';
import { type DynamicField, SchemaContext } from '../contexts/schema';
import { getStatusInfo } from '@/ui/playground/status-info';
interface FormValues {
authorization: string;
@@ -60,6 +64,7 @@ export function APIPlayground({
body,
fields = {},
schemas,
fetchAction,
}: APIPlaygroundProps & {
fields?: {
auth?: CustomField<'authorization', PrimitiveRequestField>;
@@ -68,7 +73,7 @@ export function APIPlayground({
header?: CustomField<`header.${string}`, PrimitiveRequestField>;
body?: CustomField<'body', RequestSchema>;
};
} & HTMLAttributes<HTMLFormElement>): React.ReactElement {
} & HTMLAttributes<HTMLFormElement>) {
const { baseUrl } = useApiContext();
const dynamicRef = useRef(new Map<string, DynamicField>());
const form = useForm<FormValues>({
@@ -82,44 +87,38 @@ export function APIPlayground({
});
const testQuery = useQuery(async (input: FormValues) => {
const fetcher: Fetcher = fetchAction
? {
fetch(input) {
return fetchAction({
...input,
bodySchema: body,
references: schemas,
});
},
}
: await import('./fetcher').then((mod) =>
mod.createBrowserFetcher(body, schemas),
);
const url = new URL(
`${baseUrl ?? window.location.origin}${createUrlFromInput(route, input.path, input.query)}`,
);
const headers = new Headers();
if (bodyType !== 'form-data')
headers.append('Content-Type', 'application/json');
const header = { ...input.header };
if (input.authorization && authorization) {
headers.append(authorization.name, input.authorization);
header[authorization.name] = input.authorization;
}
Object.keys(input.header).forEach((key) => {
const paramValue = input.header[key];
if (typeof paramValue === 'string' && paramValue.length > 0)
headers.append(key, paramValue);
});
const bodyValue =
body && input.body && Object.keys(input.body).length > 0
? createBodyFromValue(
bodyType,
input.body,
body,
schemas,
dynamicRef.current,
)
: undefined;
const response = await fetch(url, {
return fetcher.fetch({
type: bodyType,
url: url.toString(),
header,
body: input.body,
dynamicFields: dynamicRef.current,
method,
headers,
body: bodyValue,
});
const data: unknown = await response.json().catch(() => undefined);
return { status: response.status, data };
});
useEffect(() => {
@@ -146,12 +145,7 @@ export function APIPlayground({
function renderCustomField<
T extends FieldPath<FormValues>,
F extends RequestSchema & { name?: string },
>(
fieldName: T,
info: F,
field: CustomField<T, F> | undefined,
key?: string,
): ReactElement {
>(fieldName: T, info: F, field: CustomField<T, F> | undefined, key?: string) {
if (field) {
return (
<Controller
@@ -173,6 +167,12 @@ export function APIPlayground({
);
}
const isParamEmpty =
path.length === 0 &&
query.length === 0 &&
header.length === 0 &&
body === undefined;
return (
<Form {...form}>
<SchemaContext.Provider
@@ -199,72 +199,67 @@ export function APIPlayground({
{authorization
? renderCustomField('authorization', authorization, fields.auth)
: null}
<Accordions
type="multiple"
className={cn(
'-m-3 border-0 bg-transparent text-sm',
path.length === 0 &&
query.length === 0 &&
header.length === 0 &&
!body &&
'hidden',
)}
>
{path.length > 0 ? (
<Accordion title="Path">
<div className="flex flex-col gap-4">
{path.map((field) =>
renderCustomField(
`path.${field.name}`,
field,
fields.path,
field.name,
),
)}
</div>
</Accordion>
) : null}
{!isParamEmpty ? (
<Accordions
type="multiple"
className="-m-3 border-0 bg-transparent text-sm"
>
{path.length > 0 ? (
<Accordion title="Path">
<div className="flex flex-col gap-4">
{path.map((field) =>
renderCustomField(
`path.${field.name}`,
field,
fields.path,
field.name,
),
)}
</div>
</Accordion>
) : null}
{query.length > 0 ? (
<Accordion title="Query">
<div className="flex flex-col gap-4">
{query.map((field) =>
renderCustomField(
`query.${field.name}`,
field,
fields.query,
field.name,
),
)}
</div>
</Accordion>
) : null}
{query.length > 0 ? (
<Accordion title="Query">
<div className="flex flex-col gap-4">
{query.map((field) =>
renderCustomField(
`query.${field.name}`,
field,
fields.query,
field.name,
),
)}
</div>
</Accordion>
) : null}
{header.length > 0 ? (
<Accordion title="Headers">
<div className="flex flex-col gap-4">
{header.map((field) =>
renderCustomField(
`header.${field.name}`,
field,
fields.header,
field.name,
),
)}
</div>
</Accordion>
) : null}
{header.length > 0 ? (
<Accordion title="Headers">
<div className="flex flex-col gap-4">
{header.map((field) =>
renderCustomField(
`header.${field.name}`,
field,
fields.header,
field.name,
),
)}
</div>
</Accordion>
) : null}
{body ? (
<Accordion title="Body">
{body.type === 'object' && !fields.body ? (
<ObjectInput field={body} fieldName="body" />
) : (
renderCustomField('body', body, fields.body)
)}
</Accordion>
) : null}
</Accordions>
{body ? (
<Accordion title="Body">
{body.type === 'object' && !fields.body ? (
<ObjectInput field={body} fieldName="body" />
) : (
renderCustomField('body', body, fields.body)
)}
</Accordion>
) : null}
</Accordions>
) : null}
{testQuery.data ? <ResultDisplay data={testQuery.data} /> : null}
</form>
@@ -279,19 +274,20 @@ function createUrlFromInput(
query: Record<string, unknown>,
): string {
let pathname = route;
Object.keys(path).forEach((key) => {
for (const key of Object.keys(path)) {
const paramValue = path[key];
if (typeof paramValue === 'string' && paramValue.length > 0)
pathname = pathname.replace(`{${key}}`, paramValue);
});
}
const searchParams = new URLSearchParams();
Object.keys(query).forEach((key) => {
for (const key of Object.keys(query)) {
const paramValue = query[key];
if (typeof paramValue === 'string' && paramValue.length > 0)
searchParams.append(key, paramValue);
});
}
return searchParams.size > 0
? `${pathname}?${searchParams.toString()}`
@@ -315,11 +311,7 @@ function RouteDisplay({ route }: { route: string }): ReactElement {
);
}
function ResultDisplay({
data,
}: {
data: { status: number; data: unknown };
}): ReactElement {
function ResultDisplay({ data }: { data: FetchResult }) {
const statusInfo = useMemo(() => getStatusInfo(data.status), [data.status]);
return (
@@ -330,7 +322,18 @@ function ResultDisplay({
</div>
<p className="text-sm text-fd-muted-foreground">{data.status}</p>
{data.data ? (
<CodeBlock code={JSON.stringify(data.data, null, 2)} />
<CodeBlock
lang={
typeof data.data === 'string' && data.data.length > 50000
? 'text'
: data.type
}
code={
typeof data.data === 'string'
? data.data
: JSON.stringify(data.data, null, 2)
}
/>
) : null}
</div>
);
@@ -24,10 +24,10 @@ import {
FormLabel,
labelVariants,
} from '@/ui/components/form';
import { resolve } from '@/ui/api/resolve';
import { resolve } from '@/ui/playground/resolve';
import { Input } from '@/ui/components/input';
import { getDefaultValue } from '@/ui/api/get-default-values';
import { useSchemaContext } from './contexts/schema';
import { getDefaultValue } from '@/ui/playground/get-default-values';
import { useSchemaContext } from '../contexts/schema';
interface RenderOptions {
field: RequestSchema;
@@ -0,0 +1,51 @@
import { CircleCheckIcon, CircleXIcon } from 'lucide-react';
interface StatusInfo {
description: string;
color: string;
icon: React.ElementType;
}
const statusMap: Record<number, StatusInfo> = {
400: { description: 'Bad Request', color: 'text-red-500', icon: CircleXIcon },
401: {
description: 'Unauthorized',
color: 'text-red-500',
icon: CircleXIcon,
},
403: { description: 'Forbidden', color: 'text-red-500', icon: CircleXIcon },
404: {
description: 'Not Found',
color: 'text-fd-muted-foreground',
icon: CircleXIcon,
},
500: {
description: 'Internal Server Error',
color: 'text-red-500',
icon: CircleXIcon,
},
};
export function getStatusInfo(status: number): StatusInfo {
if (status in statusMap) {
return statusMap[status];
}
if (status >= 200 && status < 300) {
return {
description: 'Successful',
color: 'text-green-500',
icon: CircleCheckIcon,
};
}
if (status >= 400) {
return { description: 'Error', color: 'text-red-500', icon: CircleXIcon };
}
return {
description: 'No Description',
color: 'text-fd-muted-foreground',
icon: CircleXIcon,
};
}
@@ -3,11 +3,12 @@ import { CodeBlock, Pre } from '@/components/codeblock';
import type { HighlightOptions } from 'fumadocs-core/server';
import { PrerenderScript, useShiki } from 'fumadocs-core/utils/use-shiki';
import { useId } from 'react';
import { cn } from '@/utils/cn';
const components = {
pre(props) {
return (
<CodeBlock {...props}>
<CodeBlock {...props} className={cn('my-0', props.className)}>
<Pre>{props.children}</Pre>
</CodeBlock>
);
+1 -1
View File
@@ -105,7 +105,7 @@ const neutral: Preset = {
ring: '0 0% 63.9%',
},
dark: {
background: '0 0% 8.6%',
background: '0 0% 8.2%',
foreground: '0 0% 92%',
muted: '0 0% 12.9%',
'muted-foreground': '0 0% 60.9%',