mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-26 06:31:49 +00:00
OpenAPI: Support more JSON schema features
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': patch
|
||||
---
|
||||
|
||||
Support more JSON schema features
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
|
||||
Generated
+8
@@ -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]: {}
|
||||
|
||||
Reference in New Issue
Block a user