mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
OpenAPI: Support Server Action Proxy
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': minor
|
||||
---
|
||||
|
||||
Support Server Action Proxy
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -38,6 +38,8 @@ type Awaitable<T> = T | Promise<T>;
|
||||
export type DereferenceMap = Map<unknown, string>;
|
||||
|
||||
export interface RenderContext {
|
||||
enableServerActionProxy?: boolean;
|
||||
|
||||
renderer: Renderer;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
-1
@@ -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,
|
||||
+115
-112
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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%',
|
||||
|
||||
Reference in New Issue
Block a user