OpenAPI: Support more JSON schema features

This commit is contained in:
Fuma Nama
2025-04-29 21:07:57 +08:00
parent 8c67955196
commit 47670c84de
17 changed files with 407 additions and 571 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Support more JSON schema features
+7 -2
View File
@@ -325,7 +325,7 @@
"type": "object"
},
"V1EncryptRequestBody": {
"additionalProperties": false,
"additionalProperties": true,
"properties": {
"$schema": {
"description": "A URL to the JSON Schema for this object.",
@@ -344,6 +344,11 @@
"type": "string"
}
},
"patternProperties": {
"test\\s.+": {
"type": "string"
}
},
"required": ["keyring", "data"],
"type": "object"
},
@@ -546,7 +551,7 @@
"title": "Unkey API",
"version": "1.0.0"
},
"openapi": "3.0.0",
"openapi": "3.1.0",
"paths": {
"/v0/events": {
"post": {
@@ -15,7 +15,6 @@ async function generate() {
// input files
input: ['./openapi.json'],
output: out,
groupBy: 'tag',
});
}
+1
View File
@@ -80,6 +80,7 @@
"@types/openapi-sampler": "^1.0.3",
"@types/react": "^19.1.2",
"eslint-config-custom": "workspace:*",
"json-schema-typed": "^8.0.1",
"next": "15.3.1",
"openapi-types": "^12.1.3",
"tailwindcss": "^4.1.4",
+43 -22
View File
@@ -1,5 +1,5 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join, parse } from 'node:path';
import * as path from 'node:path';
import fg from 'fast-glob';
import {
generateAll,
@@ -59,7 +59,6 @@ export async function generateFiles(options: Config): Promise<void> {
groupBy = 'none',
cwd = process.cwd(),
} = options;
const outputDir = join(cwd, output);
const urlInputs: string[] = [];
const fileInputs: string[] = [];
@@ -90,7 +89,7 @@ export async function generateFiles(options: Config): Promise<void> {
if (result.pathItem.summary) {
file = getFilename(result.pathItem.summary);
} else if (result.type === 'operation') {
file = join(
file = path.join(
getOutputPathFromRoute(result.item.path),
result.item.method.toLowerCase(),
);
@@ -111,10 +110,10 @@ export async function generateFiles(options: Config): Promise<void> {
}
for (const tag of tags) {
outPaths.push(join(outputDir, getFilename(tag), `${file}.mdx`));
outPaths.push(path.join(getFilename(tag), `${file}.mdx`));
}
} else {
outPaths.push(join(outputDir, `${file}.mdx`));
outPaths.push(`${file}.mdx`);
}
return outPaths;
@@ -130,11 +129,15 @@ export async function generateFiles(options: Config): Promise<void> {
}
async function generateFromDocument(pathOrUrl: string) {
const outputDir = path.join(cwd, output);
if (per === 'file') {
let filename = isUrl(pathOrUrl) ? 'index' : parse(pathOrUrl).name;
let filename = isUrl(pathOrUrl)
? 'index'
: path.basename(pathOrUrl, path.extname(pathOrUrl));
if (nameFn) filename = nameFn('file', filename);
const outPath = join(outputDir, `${filename}.mdx`);
const outPath = path.join(outputDir, `${filename}.mdx`);
const result = await generateAll(pathOrUrl, options);
await write(outPath, result);
@@ -143,22 +146,39 @@ export async function generateFiles(options: Config): Promise<void> {
if (per === 'operation') {
const results = await generatePages(pathOrUrl, options);
const mapping = new Map<string, GeneratePageOutput>();
for (const result of results) {
const outputPaths = getOutputPaths(result);
await Promise.all(
outputPaths.map(async (outPath) => {
await write(outPath, result.content);
for (const outputPath of outputPaths) {
mapping.set(outputPath, result);
}
}
if (groupBy === 'route' && result.pathItem.summary) {
await writeMetafile(join(dirname(outPath), 'meta.json'), {
title: result.pathItem.summary,
});
}
}),
for (const [key, output] of mapping.entries()) {
let outputPath = key;
const isSharedDir = Array.from(mapping.keys()).some(
(item) =>
item !== outputPath &&
path.dirname(item) === path.dirname(outputPath),
);
console.log(`Generated: ${outputPaths.join(', ')}`);
if (!isSharedDir && path.dirname(outputPath).length > 0) {
outputPath = path.join(path.dirname(outputPath) + '.mdx');
}
await write(path.join(outputDir, outputPath), output.content);
if (groupBy === 'route' && output.pathItem.summary) {
await writeMetafile(
path.join(outputDir, path.dirname(outputPath), 'meta.json'),
{
title: output.pathItem.summary,
},
);
}
console.log(`Generated: ${outputPath}`);
}
}
@@ -169,7 +189,7 @@ export async function generateFiles(options: Config): Promise<void> {
let tagName = result.tag;
tagName = nameFn?.('tag', tagName) ?? getFilename(tagName);
const outPath = join(outputDir, `${tagName}.mdx`);
const outPath = path.join(outputDir, `${tagName}.mdx`);
await write(outPath, result.content);
console.log(`Generated: ${outPath}`);
}
@@ -186,7 +206,8 @@ function isUrl(input: string): boolean {
function getOutputPathFromRoute(path: string): string {
return (
path
.replaceAll('.', '/')
.toLowerCase()
.replaceAll('.', '-')
.split('/')
.map((v) => {
if (v.startsWith('{') && v.endsWith('}')) return v.slice(1, -1);
@@ -200,7 +221,7 @@ function getFilename(s: string): string {
return s.replace(/\s+/g, '-').toLowerCase();
}
async function write(path: string, content: string): Promise<void> {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, content);
async function write(file: string, content: string): Promise<void> {
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, content);
}
+21 -24
View File
@@ -28,11 +28,7 @@ import {
import { useApiContext, useServerSelectContext } from '@/ui/contexts/api';
import type { FetchResult } from '@/playground/fetcher';
import { FieldSet, JsonInput, ObjectInput } from './inputs';
import type {
ParameterField,
ReferenceSchema,
RequestSchema,
} from '@/playground/index';
import type { ParameterField, RequestSchema } from '@/playground/index';
import { getStatusInfo } from './status-info';
import { getUrl } from '@/utils/server-url';
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
@@ -56,6 +52,7 @@ import type { RequestData } from '@/requests/_shared';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { cn } from 'fumadocs-ui/utils/cn';
import { cva } from 'class-variance-authority';
import { resolve } from '@/playground/resolve';
interface FormValues {
authorization:
@@ -90,11 +87,12 @@ export type ClientProps = HTMLAttributes<HTMLFormElement> & {
persistentId: string;
};
parameters?: ParameterField[];
body?: RequestSchema & {
body?: {
schema: RequestSchema;
mediaType: string;
};
/**
* Resolver for reference schemas you've passed
* Resolver for $ref schemas you've passed
*/
references: Record<string, RequestSchema>;
proxyUrl?: string;
@@ -114,7 +112,6 @@ export type ClientProps = HTMLAttributes<HTMLFormElement> & {
};
interface SchemaContextType {
references: Record<string, RequestSchema>;
dynamic: RefObject<Map<string, DynamicField>>;
}
@@ -125,7 +122,7 @@ export type DynamicField =
}
| {
type: 'field';
schema: RequestSchema | ReferenceSchema;
selected: string;
};
const SchemaContext = createContext<SchemaContextType | undefined>(undefined);
@@ -156,8 +153,8 @@ export default function Client({
route,
method = 'GET',
authorization,
parameters,
body,
parameters: _parameters,
body: _body,
fields,
references,
proxyUrl,
@@ -165,10 +162,15 @@ export default function Client({
...rest
}: ClientProps) {
const { server } = useServerSelectContext();
const dynamicRef = useRef(new Map<string, DynamicField>());
const requestData = useRequestData();
const authInfo = usePersistentAuthInfo(authorization);
const parameters = useMemo(
() => resolve(_parameters, references),
[_parameters, references],
);
const body = useMemo(() => resolve(_body, references), [_body, references]);
const defaultValues: FormValues = useMemo(
() => ({
authorization: authInfo.info,
@@ -237,10 +239,7 @@ export default function Client({
return (
<FormProvider {...form}>
<SchemaContext.Provider
value={useMemo(
() => ({ references: references, dynamic: dynamicRef }),
[references],
)}
value={useMemo(() => ({ dynamic: dynamicRef }), [])}
>
<AuthProvider authorization={authorization}>
<form
@@ -306,21 +305,18 @@ function FormBody({
authorization.type === 'http' && authorization.scheme === 'basic'
? {
type: 'object',
isRequired: true,
required: ['username', 'password'],
properties: {
username: {
type: 'string',
isRequired: true,
},
password: {
type: 'string',
isRequired: true,
},
},
}
: {
type: 'string',
isRequired: true,
description: 'The Authorization access token',
};
@@ -333,6 +329,7 @@ function FormBody({
fieldName="authorization"
name="Authorization"
field={schema}
isRequired
/>
{authorization?.type === 'oauth2' && (
<OauthDialogTrigger
@@ -352,7 +349,7 @@ function FormBody({
return (
<>
{servers.length > 1 ? (
{servers.length > 0 ? (
<CollapsiblePanel title="Server URL">
<ServerSelect
server={server}
@@ -399,10 +396,10 @@ function FormBody({
{body ? (
fields.body ? (
<CollapsiblePanel title="Body">
{renderCustomField('body', body, fields.body)}
{renderCustomField('body', body.schema, fields.body)}
</CollapsiblePanel>
) : (
<BodyInput field={body} />
<BodyInput field={body.schema} />
)
) : null}
</>
@@ -444,7 +441,7 @@ function BodyInput({ field }: { field: RequestSchema }) {
</div>
{isJson ? (
<JsonInput fieldName="body" />
) : field.type === 'object' ? (
) : typeof field === 'object' && field.type === 'object' ? (
<ObjectInput field={field} fieldName="body" />
) : (
<FieldSet field={field} fieldName="body" />
@@ -1,34 +1,33 @@
import type { ReferenceSchema, RequestSchema } from '@/playground/index';
import { resolve } from '@/playground/resolve';
import type { JSONSchema } from 'json-schema-typed';
import type { RequestSchema } from '@/playground/index';
export function getDefaultValue(
item: RequestSchema | ReferenceSchema,
references: Record<string, RequestSchema>,
type: JSONSchema.TypeValue,
schema?: RequestSchema,
): unknown {
if (item.type === 'ref')
return getDefaultValue(resolve(item, references), references);
if (item.type === 'object')
if (Array.isArray(type)) return getDefaultValue(type[0], schema);
if (type === 'object' && typeof schema === 'object')
return Object.fromEntries(
Object.entries(item.properties).map(([key, prop]) => [
key,
getDefaultValue(
prop.type === 'ref' ? references[prop.schema] : prop,
references,
),
]),
Object.entries(schema.properties ?? {}).map(([key, prop]) => {
return [
key,
getDefaultValue(
typeof prop === 'object' ? (prop.type ?? 'string') : 'string',
prop,
),
];
}),
);
if (item.type === 'array') return [];
if (item.type === 'null') return null;
if (item.type === 'switcher') {
const first = Object.values(item.items).at(0);
if (!first) return '';
if (type === 'array') return [];
if (type === 'null') return null;
if (type === 'string') {
if (typeof schema === 'object' && schema.format === 'binary')
return undefined;
return getDefaultValue(resolve(first, references), references);
return '';
}
if (item.type === 'file') return undefined;
if (item.type === 'string') return '';
if (item.type === 'number') return 0;
if (item.type === 'boolean') return false;
if (type === 'number' || type === 'integer') return 0;
if (type === 'boolean') return false;
}
+50 -259
View File
@@ -1,80 +1,22 @@
import type {
MethodInformation,
ParameterObject,
RenderContext,
} from '@/types';
import {
getPreferredType,
type NoReference,
type ParsedSchema,
} from '@/utils/schema';
import type { MethodInformation, RenderContext } from '@/types';
import { getPreferredType, type ParsedSchema } from '@/utils/schema';
import { getSecurities } from '@/utils/get-security';
import { type ClientProps } from './client';
import { ClientLazy } from '@/playground/client.lazy';
interface BaseSchema {
description?: string;
isRequired: boolean;
}
export type ParameterField = (PrimitiveSchema | ArraySchema) & {
export type ParameterField = {
name: string;
description?: string;
schema: ParsedSchema;
in: 'cookie' | 'header' | 'query' | 'path';
};
interface PrimitiveSchema extends BaseSchema {
type: 'boolean' | 'string' | 'number';
}
export interface ReferenceSchema extends BaseSchema {
type: 'ref';
schema: string;
}
interface ArraySchema extends BaseSchema {
type: 'array';
/**
* Reference to item schema or the schema
*/
items: string | RequestSchema;
}
interface FileSchema extends BaseSchema {
type: 'file';
}
interface ObjectSchema extends BaseSchema {
type: 'object';
properties: Record<string, RequestSchema | ReferenceSchema>;
/**
* Reference to schema, or true if it's `any`
*/
additionalProperties?: boolean | string;
}
interface SwitcherSchema extends BaseSchema {
type: 'switcher';
items: Record<string, ReferenceSchema | RequestSchema>;
}
interface NullSchema extends BaseSchema {
type: 'null';
}
export type RequestSchema =
| PrimitiveSchema
| ArraySchema
| ObjectSchema
| SwitcherSchema
| NullSchema
| FileSchema;
export type RequestSchema = ParsedSchema;
interface Context {
allowFile: boolean;
references: Record<string, RequestSchema>;
registered: WeakMap<ParsedSchema, string>;
registered: WeakMap<Exclude<ParsedSchema, boolean>, string>;
nextId: () => string;
render: RenderContext;
}
@@ -109,22 +51,25 @@ export async function APIPlayground({
render: ctx,
};
const bodySchema =
bodyContent && mediaType && bodyContent[mediaType].schema
? toSchema(bodyContent[mediaType].schema, true, context)
: undefined;
const props: ClientProps = {
authorization: getAuthorizationField(method, ctx),
method: method.method,
route: path,
parameters: method.parameters?.map((v) => parameterToField(v, context)),
parameters: method.parameters?.map((v) => ({
name: v.name,
in: v.in as ParameterField['in'],
schema: writeReferences(v.schema ?? true, context) as ParsedSchema,
description: v.description,
})),
body:
bodySchema && mediaType
? {
...bodySchema,
mediaType: mediaType as string,
}
bodyContent && mediaType
? ({
schema: writeReferences(
bodyContent[mediaType].schema,
context,
) as ParsedSchema,
mediaType,
} as ClientProps['body'])
: undefined,
references: context.references,
proxyUrl: ctx.proxyUrl,
@@ -134,6 +79,36 @@ export async function APIPlayground({
return <ClientLazy {...props} />;
}
function writeReferences(
schema: unknown,
ctx: Context,
stack: WeakMap<object, string> = new WeakMap(),
): unknown {
if (typeof schema !== 'object' || !schema) return schema;
if (stack.has(schema)) {
const id = stack.get(schema)!;
ctx.references[id] = schema;
return {
$ref: id,
};
}
stack.set(schema, ctx.nextId());
if (Array.isArray(schema)) {
return schema.map((item) => writeReferences(item, ctx, stack));
}
const v: Record<string, unknown> = {};
for (const key in schema) {
v[key] = writeReferences(schema[key as keyof object], ctx, stack);
}
return v;
}
function getAuthorizationField(
method: MethodInformation,
{ schema: { document } }: RenderContext,
@@ -173,187 +148,3 @@ function getAuthorizationField(
...scheme,
};
}
function getIdFromSchema(
schema: ParsedSchema,
required: boolean,
ctx: Context,
): string {
const registered = ctx.registered.get(schema);
if (registered === undefined) {
const id = ctx.nextId();
ctx.registered.set(schema, id);
ctx.references[id] = toSchema(schema, required, ctx);
return id;
}
return registered;
}
function parameterToField(
v: NoReference<ParameterObject>,
ctx: Context,
): ParameterField {
const allowed = ['header', 'cookie', 'query', 'path'] as const;
if (!allowed.includes(v.in as (typeof allowed)[number]))
throw new Error(`Unsupported parameter in: "${v.in}"`);
return {
name: v.name,
in: v.in as (typeof allowed)[number],
...(toSchema(
v.schema ?? { type: 'string' },
v.required ?? false,
ctx,
) as PrimitiveSchema),
};
}
function toReference(
schema: ParsedSchema,
required: boolean,
ctx: Context,
): ReferenceSchema {
return {
type: 'ref',
isRequired: required,
schema: getIdFromSchema(schema, false, ctx),
};
}
function toSchema(
schema: NoReference<ParsedSchema>,
required: boolean,
ctx: Context,
): RequestSchema {
if (schema.type === 'array') {
return {
type: 'array',
description: schema.description ?? schema.title,
isRequired: required,
items: getIdFromSchema(schema.items, false, ctx),
};
}
if (
schema.type === 'object' ||
schema.properties !== undefined ||
schema.allOf !== undefined
) {
const properties: Record<string, ReferenceSchema> = {};
Object.entries(schema.properties ?? {}).forEach(([key, prop]) => {
properties[key] = toReference(
prop,
schema.required?.includes(key) ?? false,
ctx,
);
});
schema.allOf?.forEach((c) => {
const field = toSchema(c, true, ctx);
if (field.type === 'object') Object.assign(properties, field.properties);
});
const additional = schema.additionalProperties;
let additionalProperties: string | boolean | undefined;
if (additional && typeof additional === 'object') {
if (
(!additional.type || additional.type.length === 0) &&
!additional.anyOf &&
!additional.allOf &&
!additional.oneOf
) {
additionalProperties = true;
} else {
additionalProperties = getIdFromSchema(additional, false, ctx);
}
} else {
additionalProperties = additional;
}
return {
type: 'object',
isRequired: required,
description: schema.description ?? schema.title,
properties,
additionalProperties,
};
}
if (schema.type === undefined) {
const combine = schema.anyOf ?? schema.oneOf;
if (combine) {
return {
type: 'switcher',
description: schema.description ?? schema.title,
items: Object.fromEntries(
combine.map((item, idx) => {
return [
item.title ?? item.type ?? `Item ${idx.toString()}`,
toReference(item, true, ctx),
];
}),
),
isRequired: required,
};
}
return {
type: 'null',
isRequired: false,
};
}
if (ctx.allowFile && schema.type === 'string' && schema.format === 'binary') {
return {
type: 'file',
isRequired: required,
description: schema.description ?? schema.title,
};
}
if (Array.isArray(schema.type)) {
const items: Record<string, RequestSchema> = {};
for (const type of schema.type) {
if (type === 'array') {
items[type] = {
type,
items:
'items' in schema && schema.items
? toSchema(schema.items, false, ctx)
: toSchema({}, required, ctx),
isRequired: required,
};
} else {
items[type] = toSchema(
{
...schema,
type,
},
required,
ctx,
);
}
}
return {
type: 'switcher',
description: schema.description ?? schema.title,
items,
isRequired: required,
};
}
return {
type: schema.type === 'integer' ? 'number' : schema.type,
isRequired: required,
description: schema.description ?? schema.title,
};
}
+82 -132
View File
@@ -20,14 +20,18 @@ import {
SelectValue,
} from '@/ui/components/select';
import type { RequestSchema } from '@/playground/index';
import { resolve } from '@/playground/resolve';
import { Input, labelVariants } from '@/ui/components/input';
import { getDefaultValue } from './get-default-values';
import { useSchemaContext } from './client';
import { cn } from 'fumadocs-ui/utils/cn';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
type FieldOfType<Type> = Extract<RequestSchema, { type: Type }>;
const anyFields = {
type: ['string', 'number', 'boolean', 'array', 'object'],
items: true,
additionalProperties: true,
} satisfies RequestSchema;
interface InputHeaderProps {
name?: ReactNode;
required?: boolean;
@@ -61,31 +65,48 @@ export function ObjectInput({
fieldName,
...props
}: {
field: FieldOfType<'object'>;
field: Exclude<RequestSchema, boolean>;
fieldName: string;
} & HTMLAttributes<HTMLDivElement>) {
const { references } = useSchemaContext();
return (
<div {...props} className={cn('flex flex-col gap-6', props.className)}>
{Object.entries(field.properties).map(([key, child]) => (
{Object.entries(field.properties ?? {}).map(([key, child]) => (
<FieldSet
key={key}
name={key}
field={resolve(child, references)}
field={child}
fieldName={`${fieldName}.${key}`}
isRequired={field.required?.includes(key)}
/>
))}
{field.additionalProperties ? (
<AdditionalProperties
{(field.additionalProperties || field.patternProperties) && (
<DynamicProperties
fieldName={fieldName}
type={field.additionalProperties}
filterKey={(v) =>
!field.properties || !Object.keys(field.properties).includes(v)
}
getType={(key) => {
for (const pattern in field.patternProperties) {
if (key.match(RegExp(pattern))) {
return fallbackAny(field.patternProperties[pattern]);
}
}
if (field.additionalProperties)
return fallbackAny(field.additionalProperties);
return anyFields;
}}
/>
) : null}
)}
</div>
);
}
function fallbackAny(schema: RequestSchema): Exclude<RequestSchema, boolean> {
return typeof schema === 'boolean' || !schema.type ? anyFields : schema;
}
export function JsonInput({ fieldName }: { fieldName: string }) {
const controller = useController({
name: fieldName,
@@ -111,39 +132,33 @@ export function JsonInput({ fieldName }: { fieldName: string }) {
);
}
function AdditionalProperties({
function DynamicProperties({
fieldName,
type,
filterKey = () => true,
getType = () => anyFields,
}: {
fieldName: string;
type: boolean | string;
filterKey?: (key: string) => boolean;
getType: (key: string) => Exclude<RequestSchema, boolean>;
}) {
const { control, setValue, getValues } = useFormContext();
const { references } = useSchemaContext();
const [nextName, setNextName] = useState('');
const [properties, setProperties] = useState<string[]>(() => {
const value = getValues(fieldName);
if (value) return Object.keys(value);
if (value) return Object.keys(value).filter(filterKey);
return [];
});
const types =
typeof type === 'string'
? resolveDynamicTypes(references[type], references)
: anyFields;
const onAppend = () => {
const name = nextName.trim();
if (name.length === 0) return;
setProperties((p) => {
if (p.includes(name)) return p;
if (p.includes(name) || !filterKey(name)) return p;
const type = getType(name);
setValue(
`${fieldName}.${name}`,
getDefaultValue(Object.values(types)[0], references),
);
setValue(`${fieldName}.${name}`, getDefaultValue(type.type!, type));
setNextName('');
return [...p, name];
});
@@ -155,11 +170,7 @@ function AdditionalProperties({
<FieldSet
key={item}
name={item}
field={{
type: 'switcher',
items: types,
isRequired: false,
}}
field={getType(item)}
fieldName={`${fieldName}.${item}`}
toolbar={
<button
@@ -185,9 +196,7 @@ function AdditionalProperties({
<Input
value={nextName}
placeholder="Enter Property Name"
onChange={(e) => {
setNextName(e.target.value);
}}
onChange={(e) => setNextName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onAppend();
@@ -207,63 +216,16 @@ function AdditionalProperties({
);
}
function resolveDynamicTypes(
schema: RequestSchema,
references: Record<string, RequestSchema>,
): Record<string, RequestSchema> {
if (schema.type !== 'switcher') return { [schema.type]: schema };
return Object.fromEntries(
Object.entries(schema.items).map(([key, value]) => [
key,
resolve(value, references),
]),
);
}
const anyFields: Record<string, RequestSchema> = {
string: {
type: 'string',
isRequired: false,
},
boolean: {
type: 'boolean',
isRequired: false,
},
number: {
type: 'number',
isRequired: false,
},
object: {
type: 'object',
properties: {},
additionalProperties: true,
isRequired: false,
},
};
anyFields.array = {
type: 'array',
isRequired: false,
items: {
type: 'switcher',
isRequired: false,
items: anyFields,
},
};
export function FieldInput({
field,
fieldName,
...props
}: HTMLAttributes<HTMLElement> & {
field: Exclude<RequestSchema, { type: 'switcher' }>;
field: Exclude<RequestSchema, boolean>;
fieldName: string;
}) {
const { control, register } = useFormContext();
if (field.type === 'null') return null;
if (field.type === 'object') {
return (
<ObjectInput
@@ -282,7 +244,7 @@ export function FieldInput({
return (
<ArrayInput
fieldName={fieldName}
field={field}
items={field.items ?? anyFields}
{...props}
className={cn(
'rounded-lg border border-fd-primary/20 bg-fd-background/50 p-3 shadow-sm',
@@ -292,7 +254,7 @@ export function FieldInput({
);
}
if (field.type === 'file') {
if (field.type === 'string' && field.format === 'binary') {
return (
<Controller
control={control}
@@ -337,7 +299,7 @@ export function FieldInput({
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
{field.isRequired ? null : (
{Array.isArray(field.type) && field.type.includes('null') && (
<SelectItem value="null">Null</SelectItem>
)}
</SelectContent>
@@ -353,7 +315,7 @@ export function FieldInput({
placeholder="Enter value"
type={field.type === 'string' ? 'text' : 'number'}
{...register(fieldName, {
valueAsNumber: field.type === 'number',
valueAsNumber: field.type === 'number' || field.type === 'integer',
})}
{...props}
/>
@@ -361,60 +323,52 @@ export function FieldInput({
}
export function FieldSet({
field,
field: _field,
fieldName,
toolbar,
name,
isRequired,
...props
}: HTMLAttributes<HTMLElement> & {
isRequired?: boolean;
name?: ReactNode;
field: RequestSchema;
fieldName: string;
toolbar?: ReactNode;
}) {
const form = useFormContext();
const { references, dynamic } = useSchemaContext();
const { dynamic } = useSchemaContext();
const field = fallbackAny(_field);
const [type, setType] = useState<string>(() => {
if (field.type !== 'switcher') return '';
if (!Array.isArray(field.type)) return '';
const d = dynamic.current.get(fieldName);
const items = Object.keys(field.items);
if (d?.type === 'field') {
// schemas are passed from server components, object references are maintained
const cached = items.find((item) => d.schema === field.items[item]);
if (cached) return cached;
if (d?.type === 'field' && field.type.includes(d.selected)) {
return d.selected;
}
const value = form.getValues(fieldName);
let type: string = typeof value;
let out = null;
if (Array.isArray(value)) {
type = 'array';
out = 'array';
} else if (value instanceof File) {
type = 'file';
out = 'file';
} else if (value === null) {
type = 'null';
out = 'null';
}
return items.find((item) => field.items[item].type === type) ?? items[0];
return field.type.includes(out) ? out : field.type[0];
});
if (field.type === 'null') return null;
if (field.type === 'switcher') {
const child = resolve(field.items[type], references);
if (_field === false) return null;
if (Array.isArray(field.type)) {
return (
<fieldset
{...props}
className={cn('flex flex-col gap-1.5', props.className)}
>
<FieldHeader
name={name}
htmlFor={fieldName}
required={field.isRequired}
>
<FieldHeader name={name} htmlFor={fieldName} required={isRequired}>
<select
value={type}
onChange={(e) => {
@@ -424,16 +378,13 @@ export function FieldSet({
setType(value);
dynamic.current.set(fieldName, {
type: 'field',
schema: field.items[value],
selected: value,
});
form.setValue(
fieldName,
getDefaultValue(field.items[value], references),
);
form.setValue(fieldName, getDefaultValue(value, field));
}}
className="text-xs"
>
{Object.keys(field.items).map((item) => (
{field.type.map((item) => (
<option key={item} value={item}>
{item}
</option>
@@ -442,11 +393,13 @@ export function FieldSet({
{toolbar}
</FieldHeader>
<p className="text-xs text-fd-muted-foreground">{field.description}</p>
{child.type === 'switcher' ? (
<FieldSet field={child} fieldName={fieldName} />
) : (
<FieldInput field={child} fieldName={fieldName} />
)}
<FieldInput
field={{
...field,
type,
}}
fieldName={fieldName}
/>
</fieldset>
);
}
@@ -459,8 +412,8 @@ export function FieldSet({
<FieldHeader
htmlFor={fieldName}
name={name}
required={field.isRequired}
type={field.type}
required={isRequired}
type={String(field.type)}
>
{toolbar}
</FieldHeader>
@@ -471,17 +424,12 @@ export function FieldSet({
function ArrayInput({
fieldName,
field,
items,
...props
}: {
fieldName: string;
field: {
description?: string;
items: RequestSchema | string;
};
items: RequestSchema;
} & HTMLAttributes<HTMLDivElement>) {
const { references } = useSchemaContext();
const items = resolve(field.items, references);
const name = fieldName.split('.').at(-1) ?? '';
const { fields, append, remove } = useFieldArray({
name: fieldName,
@@ -528,7 +476,9 @@ function ArrayInput({
}),
)}
onClick={() => {
append(getDefaultValue(items, references));
const itemType = fallbackAny(items);
append(getDefaultValue(itemType.type!, itemType));
}}
>
<Plus className="size-4" />
+17 -11
View File
@@ -1,18 +1,24 @@
import { type ReferenceSchema, type RequestSchema } from '@/playground/index';
import { type RequestSchema } from '@/playground/index';
/**
* Resolve reference
*/
export function resolve(
schema: RequestSchema | ReferenceSchema | string,
export function resolve<T>(
schema: T,
references: Record<string, RequestSchema>,
): RequestSchema {
if (typeof schema === 'string') return references[schema];
if (schema.type !== 'ref') return schema;
): T {
if (!schema || typeof schema !== 'object') return schema;
return {
...references[schema.schema],
description: schema.description,
isRequired: schema.isRequired,
};
if (Array.isArray(schema))
return schema.map((item) => resolve(item, references)) as T;
if ('$ref' in schema && schema.$ref) {
return resolve(references[schema.$ref as string], references) as T;
}
for (const key in schema) {
schema[key] = resolve(schema[key], references);
}
return schema;
}
@@ -1,14 +1,13 @@
import type { MethodInformation, RenderContext } from '@/types';
import type { NoReference } from '@/utils/schema';
import type { ResolvedSchema } from '@/utils/schema';
import { getPreferredType } from '@/utils/schema';
import { sample } from 'openapi-sampler';
import { getPreferredType, type ParsedSchema } from '@/utils/schema';
import { getSecurities, getSecurityPrefix } from '@/utils/get-security';
import type { RequestData } from '@/requests/_shared';
export function getRequestData(
path: string,
method: NoReference<MethodInformation>,
method: MethodInformation,
sampleKey: string | null,
{ schema: { document } }: RenderContext,
): RequestData {
@@ -75,17 +74,17 @@ export function getRequestData(
} else if (bodyOfType.example) {
result.body = bodyOfType.example;
} else {
result.body = generateBody(method.method, bodyOfType?.schema ?? {});
result.body = generateBody(
method.method,
(bodyOfType?.schema ?? {}) as ResolvedSchema,
);
}
}
return result;
}
function generateBody(
method: string,
schema: NoReference<ParsedSchema>,
): unknown {
function generateBody(method: string, schema: ResolvedSchema): unknown {
return sample(schema as object, {
skipReadOnly: method !== 'GET',
skipWriteOnly: method === 'GET',
+16 -10
View File
@@ -6,7 +6,11 @@ import type {
RenderContext,
SecurityRequirementObject,
} from '@/types';
import { getPreferredType, type NoReference } from '@/utils/schema';
import {
getPreferredType,
type NoReference,
type ResolvedSchema,
} from '@/utils/schema';
import { getSecurities, getSecurityPrefix } from '@/utils/get-security';
import { idToTitle } from '@/utils/id-to-title';
import { Markdown } from '../markdown';
@@ -99,7 +103,7 @@ export function Operation({
{body.description ? <Markdown text={body.description} /> : null}
<Schema
name="body"
schema={body.content[type].schema ?? {}}
schema={(body.content[type].schema ?? {}) as ResolvedSchema}
required={body.required}
ctx={{
readOnly: method.method === 'GET',
@@ -148,13 +152,15 @@ export function Operation({
<Schema
key={param.name}
name={param.name}
schema={{
...param.schema,
description: param.description ?? param.schema?.description,
deprecated:
(param.deprecated ?? false) ||
(param.schema?.deprecated ?? false),
}}
schema={
{
...param.schema,
description: param.description ?? param.schema?.description,
deprecated:
(param.deprecated ?? false) ||
(param.schema?.deprecated ?? false),
} as ResolvedSchema
}
parseObject={false}
required={param.required}
ctx={{
@@ -270,7 +276,7 @@ async function ResponseTab({
{responseOfType?.schema && (
<Schema
name="response"
schema={responseOfType.schema}
schema={responseOfType.schema as ResolvedSchema}
required
ctx={{
render: ctx,
+92 -60
View File
@@ -1,11 +1,11 @@
import { type ReactNode } from 'react';
import { isNullable, NoReference, type ParsedSchema } from '@/utils/schema';
import { isNullable, type ResolvedSchema } from '@/utils/schema';
import type { RenderContext } from '@/types';
import { combineSchema } from '@/utils/combine-schema';
import { Markdown } from './markdown';
const keys: {
[T in keyof ParsedSchema]: string;
[T in keyof Exclude<ResolvedSchema, boolean>]: string;
} = {
default: 'Default',
minimum: 'Minimum',
@@ -19,12 +19,14 @@ const keys: {
interface Context {
readOnly: boolean;
writeOnly: boolean;
stack?: ParsedSchema[];
stack?: ResolvedSchema[];
render: RenderContext;
}
function isObject(schema: ParsedSchema): boolean {
function isObject(schema: ResolvedSchema): boolean {
if (typeof schema === 'boolean') return false;
return (
schema.type === 'object' ||
schema.properties !== undefined ||
@@ -41,7 +43,7 @@ export function Schema({
}: {
name: string;
required?: boolean;
schema: NoReference<ParsedSchema>;
schema: ResolvedSchema;
/**
* Render the full object
@@ -52,65 +54,75 @@ export function Schema({
ctx: Context;
}): ReactNode {
const {
render: { renderer },
stack = [],
} = ctx;
if (schema === true) {
return <renderer.Property name={name} type="any" />;
} else if (schema === false) {
return <renderer.Property name={name} type="never" />;
}
if (
(schema.readOnly === true && !ctx.readOnly) ||
(schema.writeOnly === true && !ctx.writeOnly)
)
return null;
const {
render: { renderer },
stack = [],
} = ctx;
// object type
if (
isObject(schema) &&
parseObject &&
(schema.additionalProperties || schema.properties)
) {
let body: ReactNode = null;
let footer: ReactNode = null;
const { additionalProperties, properties } = schema;
const { additionalProperties, patternProperties, properties } = schema;
if (additionalProperties === true) {
footer = <renderer.Property name="[key: string]" type="any" />;
} else if (additionalProperties) {
footer = (
<Schema
name="[key: string]"
schema={additionalProperties}
parseObject={false}
ctx={{
...ctx,
stack: [schema, ...stack],
}}
/>
);
}
if (properties) {
body = Object.entries(properties).map(([key, value]) => {
return (
return (
<div className="flex flex-col gap-4">
{properties &&
Object.entries(properties).map(([key, value]) => {
return (
<Schema
key={key}
name={key}
schema={value}
parseObject={false}
required={schema.required?.includes(key) ?? false}
ctx={{
...ctx,
stack: [schema, ...stack],
}}
/>
);
})}
{patternProperties &&
Object.entries(patternProperties).map(([key, value]) => {
return (
<Schema
key={key}
name={key}
schema={value}
parseObject={false}
ctx={{
...ctx,
stack: [schema, ...stack],
}}
/>
);
})}
{additionalProperties && (
<Schema
key={key}
name={key}
schema={value}
name="[key: string]"
schema={additionalProperties}
parseObject={false}
required={schema.required?.includes(key) ?? false}
ctx={{
...ctx,
stack: [schema, ...stack],
}}
/>
);
});
}
return (
<div className="flex flex-col gap-4">
{body}
{footer}
)}
</div>
);
}
@@ -119,7 +131,7 @@ export function Schema({
return (
<Schema
name={name}
schema={combineSchema(schema.allOf)}
schema={combineSchema(schema.allOf as ResolvedSchema[])}
ctx={{
...ctx,
stack: [schema, ...stack],
@@ -138,7 +150,7 @@ export function Schema({
if (key in schema) {
fields.push({
key: value,
value: JSON.stringify(schema[key as keyof ParsedSchema]),
value: JSON.stringify(schema[key as keyof ResolvedSchema]),
});
}
}
@@ -175,10 +187,11 @@ export function Schema({
<renderer.ObjectCollapsible
key={idx}
name={
s.title ??
(mentionedObjectTypes.length === 1
? 'Show Attributes'
: `Object ${idx + 1}`)
typeof s === 'object' && s.title
? s.title
: mentionedObjectTypes.length === 1
? 'Show Attributes'
: `Object ${idx + 1}`
}
>
<Schema
@@ -219,17 +232,21 @@ export function Schema({
/**
* Check if the schema needs another collapsible to explain
*/
function isComplexType(schema: ParsedSchema): boolean {
function isComplexType(schema: ResolvedSchema): boolean {
if (typeof schema === 'boolean') return false;
if (schema.anyOf ?? schema.oneOf ?? schema.allOf) return true;
return isObject(schema) || schema.type === 'array';
}
function getSchemaType(
schema: ParsedSchema,
schema: ResolvedSchema,
ctx: Context,
isRoot = true,
): string {
if (schema === true) return 'any';
else if (schema === false) return 'never';
if (isNullable(schema) && isRoot) {
const type = getSchemaType(schema, ctx, false);
@@ -239,14 +256,30 @@ function getSchemaType(
if (schema.title) return schema.title;
if (Array.isArray(schema.type)) {
return schema.type
.map((type) =>
getSchemaType(
{
...schema,
type,
},
ctx,
false,
),
)
.join(' | ');
}
if (schema.type === 'array')
return `array<${schema.items ? getSchemaType(schema.items, ctx) : 'unknown'}>`;
if (schema.oneOf)
if (schema.oneOf) {
return schema.oneOf
.map((one) => getSchemaType(one, ctx, false))
.filter((v) => v !== 'unknown')
.join(' | ');
}
if (schema.allOf) {
return schema.allOf
@@ -258,16 +291,15 @@ function getSchemaType(
if (schema.not) return `not ${getSchemaType(schema.not, ctx, false)}`;
if (schema.anyOf) {
const properties = schema.anyOf
const union = schema.anyOf
.map((one) => getSchemaType(one, ctx, false))
.filter((v) => v !== 'unknown');
if (properties.length > 1) {
return `Any properties in ${properties.join(',')}`;
} else if (properties.length === 1) {
return properties[0];
if (union.length > 1) {
return `Any properties in ${union.join(',')}`;
} else if (union.length === 1) {
return union[0];
}
// otherwise unknown
}
if (schema.type === 'string' && schema.format === 'binary') return 'file';
@@ -277,7 +309,7 @@ function getSchemaType(
if (nonNullTypes.length > 0) return nonNullTypes.join(' | ');
} else if (schema.type && schema.type !== 'null') {
return schema.type;
return schema.type as string;
}
if (isObject(schema)) return 'object';
+17 -6
View File
@@ -4,19 +4,26 @@ import { type ParsedSchema } from '@/utils/schema';
* Combine multiple object schemas into one
*/
export function combineSchema(schema: ParsedSchema[]): ParsedSchema {
const result: ParsedSchema = {
let result: ParsedSchema = {
type: undefined,
};
function add(s: ParsedSchema): void {
if (typeof s === 'boolean') {
result = s;
return;
}
if (typeof result === 'boolean') return;
if (s.type) {
result.type ??= [];
if (!Array.isArray(result.type)) {
result.type = [result.type];
result.type = [result.type] as string[];
}
for (const v of Array.isArray(s.type) ? s.type : [s.type]) {
if (!result.type.includes(v)) {
if (Array.isArray(result.type) && !result.type.includes(v)) {
result.type.push(v);
}
}
@@ -27,6 +34,11 @@ export function combineSchema(schema: ParsedSchema[]): ParsedSchema {
Object.assign(result.properties, s.properties);
}
if (s.patternProperties) {
result.patternProperties ??= {};
Object.assign(result.patternProperties, s.patternProperties);
}
if (s.additionalProperties === true) {
result.additionalProperties = true;
} else if (
@@ -39,12 +51,12 @@ export function combineSchema(schema: ParsedSchema[]): ParsedSchema {
if (s.required) {
result.required ??= [];
result.required.push(...s.required);
(result.required as string[]).push(...s.required);
}
if (s.enum && s.enum.length > 0) {
result.enum ??= [];
result.enum.push(...s.enum);
(result.enum as string[]).push(...s.enum);
}
if (s.allOf) {
@@ -53,6 +65,5 @@ export function combineSchema(schema: ParsedSchema[]): ParsedSchema {
}
schema.forEach(add);
return result;
}
+13 -7
View File
@@ -1,5 +1,5 @@
import type { ReferenceObject } from '@/types';
import type { OpenAPIV3_1 } from 'openapi-types';
import { type JSONSchema } from 'json-schema-typed/draft-2020-12';
import { ReferenceObject } from '@/types';
export type NoReference<T> = T extends (infer I)[]
? NoReference<I>[]
@@ -11,7 +11,14 @@ export type NoReference<T> = T extends (infer I)[]
}
: T;
export type ParsedSchema = OpenAPIV3_1.SchemaObject;
type NoReferenceJSONSchema<T> = T extends (infer I)[]
? NoReference<I>[]
: T extends { $ref?: string }
? Omit<T, '$ref'>
: T;
export type ParsedSchema = JSONSchema;
export type ResolvedSchema = NoReferenceJSONSchema<ParsedSchema>;
export function getPreferredType<B extends Record<string, unknown>>(
body: B,
@@ -21,10 +28,9 @@ export function getPreferredType<B extends Record<string, unknown>>(
return Object.keys(body)[0];
}
export function isNullable(
schema: NoReference<ParsedSchema>,
includeOneOf = true,
): boolean {
export function isNullable(schema: ParsedSchema, includeOneOf = true): boolean {
if (typeof schema === 'boolean') return false;
if (Array.isArray(schema.type) && schema.type.includes('null')) return true;
if (includeOneOf && (schema.anyOf || schema.oneOf)) {
if (schema.anyOf?.some((item) => isNullable(item))) return true;
+3 -3
View File
@@ -125,17 +125,17 @@ describe('Generate documents', () => {
expect(fs.writeFile).toBeCalledTimes(3);
expect(fs.writeFile).toBeCalledWith(
join(cwd, './out/products/products/productId/get.mdx'),
join(cwd, './out/products/products/productid.mdx'),
expect.anything(),
);
expect(fs.writeFile).toBeCalledWith(
join(cwd, './out/inventory/inventory/productId/get.mdx'),
join(cwd, './out/inventory/inventory/productid.mdx'),
expect.anything(),
);
expect(fs.writeFile).toBeCalledWith(
join(cwd, './out/products/inventory/productId/get.mdx'),
join(cwd, './out/products/inventory/productid.mdx'),
expect.anything(),
);
});
+8
View File
@@ -1223,6 +1223,9 @@ importers:
eslint-config-custom:
specifier: workspace:*
version: link:../eslint-config-custom
json-schema-typed:
specifier: ^8.0.1
version: 8.0.1
next:
specifier: 15.3.1
version: 15.3.1(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
@@ -7234,6 +7237,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
[email protected]:
resolution: {integrity: sha512-XQmWYj2Sm4kn4WeTYvmpKEbyPsL7nBsb647c7pMe6l02/yx2+Jfc4dT6UZkEXnIUb5LhD55r2HPsJ1milQ4rDg==}
[email protected]:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
@@ -17247,6 +17253,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}