OpenAPI: Improve dereference & TypeScript generation

This commit is contained in:
Fuma Nama
2026-03-01 19:57:41 +08:00
parent 5453502d03
commit 53856d24ff
16 changed files with 1648 additions and 204 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Improve dereference & TypeScript generation
+1430 -46
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -56,7 +56,6 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@scalar/json-magic": "^0.11.5",
"@scalar/openapi-parser": "0.24.15",
"@scalar/openapi-upgrader": "^0.1.9",
"ajv": "^8.18.0",
"class-variance-authority": "^0.7.1",
@@ -79,6 +78,7 @@
"@types/node": "25.3.3",
"@types/openapi-sampler": "^1.0.3",
"@types/react": "^19.2.14",
"dereference-json-schema": "^0.2.1",
"eslint-config-custom": "workspace:*",
"fumadocs-core": "workspace:*",
"fumadocs-ui": "workspace:*",
+13 -5
View File
@@ -16,7 +16,7 @@ export type ParameterField = NoReference<ParameterObject> & {
interface Context {
references: Record<string, ParsedSchema>;
registered: WeakMap<Exclude<ParsedSchema, boolean>, string>;
nextId: () => string;
id: (schema?: object) => string;
}
export interface APIPlaygroundProps {
@@ -35,14 +35,22 @@ export async function APIPlayground({ path, method, ctx }: APIPlaygroundProps) {
return ctx.playground.render({ path, method, ctx });
}
let currentId = 0;
const bodyContent = method.requestBody?.content;
const mediaType = bodyContent ? getPreferredType(bodyContent) : undefined;
const takenIds = new Map<string, number>();
const context: Context = {
references: {},
nextId() {
return String(currentId++);
id(schema) {
let name = 'r';
if (schema) {
const ref = ctx.schema.getRawRef(schema)?.split('/');
if (ref && ref.length > 0) name = ref[ref.length - 1];
}
const count = takenIds.get(name) ?? 0;
takenIds.set(name, count + 1);
return count === 0 ? name : `${name}${count}`;
},
registered: new WeakMap(),
};
@@ -76,7 +84,7 @@ function writeReferences(
if (typeof schema !== 'object' || !schema) return schema;
if (stack.has(schema)) {
const out = stack.get(schema)!;
const id = ctx.nextId();
const id = ctx.id(schema);
ctx.references[id] = out;
return {
+36 -22
View File
@@ -59,9 +59,8 @@ export function SchemaProvider({
strict: false,
validateSchema: false,
validateFormats: false,
schemas: references,
}),
[references],
[],
);
return (
@@ -95,7 +94,7 @@ export function useFieldInfo(
info: FieldInfo;
updateInfo: (value: Partial<FieldInfo>) => void;
} {
const { ajv } = use(SchemaContext)!;
const { ajv, references } = use(SchemaContext)!;
const engine = useDataEngine();
const { generateDefault } = useSchemaUtils();
const fieldData = useNamespace({
@@ -109,7 +108,9 @@ export function useFieldInfo(
if (union) {
const [members, field] = union;
out.oneOf = members.findIndex((item) => ajv.validate(item, value));
out.oneOf = members.findIndex((item) =>
ajv.validate(typeof item === 'object' ? { ...item, ...references } : item, value),
);
if (out.oneOf === -1) out.oneOf = 0;
out.unionField = field;
}
@@ -119,12 +120,16 @@ export function useFieldInfo(
out.selectedType =
types.find((type) => {
return ajv.validate({ ...schema, type }, value);
return ajv.validate({ ...schema, ...references, type }, value);
}) ?? types[0];
}
if (schema.allOf) {
const merged = mergeAllOf(schema);
const merged = mergeAllOf(schema, {
dereference(schema) {
return dereference(schema, references);
},
});
if (typeof merged !== 'boolean')
out.intersection = {
@@ -154,7 +159,8 @@ export function useFieldInfo(
if (updated.unionField) {
valueSchema = schema[updated.unionField]![updated.oneOf];
} else if (updated.selectedType) {
valueSchema = { ...schema, type: updated.selectedType };
// must remove to `examples` to avoid invalid default values
valueSchema = { ...schema, type: updated.selectedType, examples: undefined };
}
engine.update(fieldName, generateDefault(valueSchema));
@@ -165,18 +171,6 @@ export function useFieldInfo(
export function useSchemaUtils() {
const { references } = use(SchemaContext)!;
function resolve(schema: ParsedSchema): Exclude<ParsedSchema, boolean> {
if (typeof schema === 'boolean') return anyFields;
let ref = schema.$ref;
if (ref) {
// use swallow resolution as it is already preprocessed in `playground/index.tsx`
const prefix = '#/';
if (ref.startsWith(prefix)) ref = ref.slice(prefix.length);
if (ref in references) return fallbackAny(references[ref]);
}
return schema;
}
return {
generateDefault(schema: ParsedSchema): unknown {
return sample(
@@ -188,14 +182,34 @@ export function useSchemaUtils() {
/**
* Resolve `$ref` in the schema, **not recursive**.
*/
resolve,
resolve(schema: ParsedSchema): Exclude<ParsedSchema, boolean> {
return fallbackAny(dereference(schema, references));
},
schemaToString(value: ResolvedSchema, flags?: FormatFlags) {
return schemaToString(value, (s) => ({ dereferenced: resolve(s), raw: s }), flags);
return schemaToString(
value,
(s) => ({ dereferenced: dereference(s, references), raw: s }),
flags,
);
},
};
}
export function fallbackAny(schema: ParsedSchema): Exclude<ParsedSchema, boolean> {
function dereference(schema: ParsedSchema, references: Record<string, ParsedSchema>): ParsedSchema {
if (typeof schema === 'boolean') return schema;
let ref = schema.$ref;
if (ref) {
// use swallow resolution as it is already preprocessed in `playground/index.tsx`
const prefix = '#/';
if (ref.startsWith(prefix)) ref = ref.slice(prefix.length);
if (ref in references) return references[ref];
}
return schema;
}
function fallbackAny(schema: ParsedSchema): Exclude<ParsedSchema, boolean> {
return typeof schema === 'boolean' ? anyFields : schema;
}
+12 -8
View File
@@ -25,6 +25,17 @@ import { APIPage, type ApiPageProps, type OperationItem, type WebhookItem } from
import type { CodeUsageGeneratorRegistry, InlineCodeUsageGenerator } from '@/requests/generators';
import type { JSONSchema } from 'json-schema-typed';
export interface GenerateTypeScriptDefinitionsContext extends RenderContext {
operation: NoReference<MethodInformation>;
readOnly: boolean;
writeOnly: boolean;
/** @deprecated */
_internal_legacy?: {
statusCode: string;
contentType: string;
};
}
export interface CreateAPIPageOptions {
/**
* Generate TypeScript definitions from response schema.
@@ -51,14 +62,7 @@ export interface CreateAPIPageOptions {
*/
generateTypeScriptDefinitions?: (
schema: JSONSchema,
ctx: RenderContext & {
operation: NoReference<MethodInformation>;
/** @deprecated */
_internal_legacy?: {
statusCode: string;
contentType: string;
};
},
ctx: GenerateTypeScriptDefinitionsContext,
) => Awaitable<string | undefined>;
/**
+14 -4
View File
@@ -1,7 +1,6 @@
import type { OpenAPIServer } from '@/server';
import * as base from './base';
import { configDefault } from 'fumadocs-core/highlight';
import { getTypescriptSchema } from '@/utils/get-typescript-schema';
import { ApiPageProps } from './api-page';
import { ShikiConfigProvider } from './full.client';
@@ -9,12 +8,23 @@ export type CreateAPIPageOptions = Partial<base.CreateAPIPageOptions>;
export function createAPIPage(server: OpenAPIServer, options: CreateAPIPageOptions = {}) {
const APIPage = base.createAPIPage(server, {
...options,
shiki: configDefault,
generateTypeScriptDefinitions(schema, ctx) {
async generateTypeScriptDefinitions(schema, ctx) {
if (typeof schema !== 'object') return;
return getTypescriptSchema(schema, ctx);
const { compile } = await import('@fumari/json-schema-ts');
try {
return compile(schema, {
name: 'Response',
readOnly: ctx.readOnly,
writeOnly: ctx.writeOnly,
getSchemaId: ctx.schema.getRawRef,
});
} catch (e) {
console.warn('Failed to generate typescript schema:', e);
}
},
...options,
});
return function APIPageFull(props: ApiPageProps) {
@@ -98,6 +98,8 @@ export async function Operation({
const ts = content.schema
? await ctx.generateTypeScriptDefinitions(content.schema, {
operation: method,
readOnly: false,
writeOnly: true,
...ctx,
})
: undefined;
@@ -390,6 +392,8 @@ async function ResponseAccordion({
const schema = resType.schema;
const ts = schema
? await ctx.generateTypeScriptDefinitions(schema, {
readOnly: true,
writeOnly: false,
operation,
_internal_legacy: {
statusCode: status,
@@ -1,29 +0,0 @@
import type { SchemaObject } from 'ajv';
import type { RenderContext } from '@/types';
export async function getTypescriptSchema(
schema: SchemaObject,
ctx: RenderContext,
): Promise<string | undefined> {
const { compile } = await import('@fumari/json-schema-to-typescript');
try {
const input = structuredClone({
schema,
idToSchema: ctx.schema._internal_idToSchema(),
});
const schemaToId = new WeakMap<object, string>();
for (const [k, v] of input.idToSchema) {
schemaToId.set(v, k);
}
return await compile(input.schema, 'Response', {
enableConstEnums: false,
schemaToId,
});
} catch (e) {
console.warn('Failed to generate typescript schema:', e);
}
}
+18 -7
View File
@@ -1,23 +1,34 @@
import { type ParsedSchema } from '@/utils/schema';
import { deepEqual } from './deep-equal';
interface Options {
dereference?: (schema: ParsedSchema) => ParsedSchema | undefined;
}
/**
* Merge `allOf` object schema
*/
export function mergeAllOf(schema: ParsedSchema): ParsedSchema {
export function mergeAllOf(schema: ParsedSchema, options: Options = {}): ParsedSchema {
if (typeof schema === 'boolean') return schema;
const { dereference } = options;
if (dereference && '$ref' in schema && typeof schema.$ref === 'string') {
schema = dereference(schema) ?? schema;
}
if (typeof schema === 'boolean' || !schema.allOf) return schema;
const { allOf, ...rest } = schema;
let result: ParsedSchema = rest;
for (const item of allOf) {
result = intersection(result, item);
result = intersection(result, item, options);
}
return result;
}
export function intersection(a: ParsedSchema, b: ParsedSchema): ParsedSchema {
a = mergeAllOf(a);
b = mergeAllOf(b);
function intersection(a: ParsedSchema, b: ParsedSchema, options: Options): ParsedSchema {
a = mergeAllOf(a, options);
b = mergeAllOf(b, options);
if (typeof a === 'boolean' && typeof b === 'boolean') return a && b;
if (typeof a === 'boolean') return a;
if (typeof b === 'boolean') return b;
@@ -120,7 +131,7 @@ export function intersection(a: ParsedSchema, b: ParsedSchema): ParsedSchema {
} else if (bProp === undefined) {
out[prop] = aProp;
} else {
out[prop] = intersection(aProp, bProp);
out[prop] = intersection(aProp, bProp, options);
}
}
@@ -133,7 +144,7 @@ export function intersection(a: ParsedSchema, b: ParsedSchema): ParsedSchema {
const value = b[key];
if (value === undefined) break;
result[key] = result[key] === undefined ? value : intersection(result[key], value);
result[key] = result[key] === undefined ? value : intersection(result[key], value, options);
break;
}
case 'not': {
+53 -30
View File
@@ -1,9 +1,10 @@
import type { Document } from '@/types';
import type { NoReference } from '@/utils/schema';
import { dereference } from '@scalar/openapi-parser';
import { bundle } from '@scalar/json-magic/bundle';
import { upgrade } from '@scalar/openapi-upgrader';
import { fetchUrls, readFiles } from '@scalar/json-magic/bundle/plugins/node';
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
import { resolveRefSync } from 'dereference-json-schema';
export interface ProcessedDocument {
/**
@@ -11,8 +12,6 @@ export interface ProcessedDocument {
*/
dereferenced: NoReference<Document>;
_internal_idToSchema: () => Map<string, object>;
/**
* Get raw object from dereferenced object
*/
@@ -21,24 +20,11 @@ export interface ProcessedDocument {
bundled: Document;
}
const cache = new Map<string, ProcessedDocument>();
export async function processDocumentCached(input: string | Document): Promise<ProcessedDocument> {
if (typeof input !== 'string') return processDocument(input);
const cached = cache.get(input);
if (cached) return cached;
const processed = await processDocument(input);
cache.set(input, processed);
return processed;
}
/**
* process & reference input document to a Fumadocs OpenAPI compatible format
*/
export async function processDocument(input: string | Document): Promise<ProcessedDocument> {
const document = await bundle(input, {
const bundled: Document = await bundle(input, {
plugins: [fetchUrls(), readFiles()],
treeShake: true,
hooks: {
@@ -58,23 +44,60 @@ export async function processDocument(input: string | Document): Promise<Process
* Dereferenced value and its original `$ref` value
*/
const dereferenceMap = new WeakMap<object, string>();
const serializable = new Map<string, object>();
return {
dereferenced: dereference(document, {
throwOnError: true,
onDereference({ schema, ref }) {
serializable.set(ref, schema);
dereferenceMap.set(schema, ref);
},
}).schema as NoReference<Document>,
dereferenced: dereferenceSync(bundled as JSONSchema, (ref, schema) => {
dereferenceMap.set(schema as object, ref);
}) as NoReference<Document>,
getRawRef(obj) {
return dereferenceMap.get(obj);
},
_internal_idToSchema() {
return serializable;
},
bundled: document as Document,
bundled,
};
}
/**
* Resolves all $ref pointers in a schema and returns a new schema without any $ref pointers.
*/
function dereferenceSync(
schema: JSONSchema,
onDereference: (ref: string, schema: JSONSchema) => void,
): JSONSchema {
if (typeof schema === 'boolean') return schema;
const visitedNodes = new Set<unknown>();
const cloned = structuredClone(schema);
function resolve(current: unknown, path: string): JSONSchema {
if (typeof current === 'object' && current !== null) {
// make sure we don't visit the same node twice
if (visitedNodes.has(current)) {
return current;
}
visitedNodes.add(current);
if (Array.isArray(current)) {
// array
for (let index = 0; index < current.length; index++) {
current[index] = resolve(current[index], `${path}/${index}`);
}
} else {
// object
if ('$ref' in current && typeof current['$ref'] === 'string') {
const ref = current['$ref'];
const out = resolveRefSync(cloned as never, ref) as JSONSchema;
onDereference(ref, out);
return out;
}
const obj = current as Record<string, unknown>;
for (const key in current) {
obj[key] = resolve(obj[key], `${path}/${key}`);
}
}
}
return current as JSONSchema;
}
return resolve(cloned, '#');
}
@@ -8,7 +8,7 @@ export enum FormatFlags {
type Resolver = (schema: ResolvedSchema) => {
dereferenced: ResolvedSchema;
raw?: Exclude<ParsedSchema, boolean>;
raw?: ParsedSchema;
};
export function schemaToString(
@@ -20,7 +20,8 @@ export function schemaToString(
typeof _resolver === 'function'
? _resolver
: (schema) => {
const ref = _resolver?.getRawRef(schema);
const ref =
_resolver && typeof schema === 'object' ? _resolver.getRawRef(schema) : undefined;
return {
dereferenced: schema,
@@ -55,8 +56,10 @@ export function schemaToString(
if ((flags & FormatFlags.UseAlias) === FormatFlags.UseAlias) {
if (schema.title) return schema.title;
const ref = resolved.raw?.$ref?.split('/');
if (ref && ref.length > 0) return ref[ref.length - 1];
if (typeof resolved.raw === 'object' && resolved.raw.$ref) {
const ref = resolved.raw.$ref.split('/');
if (ref.length > 0) return ref[ref.length - 1];
}
}
if (Array.isArray(schema.type)) {
+5 -3
View File
@@ -28,9 +28,11 @@ type NoReferenceJSONSchema<T> = T extends (infer I)[]
? Omit<T, '$ref'>
: T;
export type ParsedSchema = JSONSchema & {
'x-playground-lazy'?: boolean;
};
export type ParsedSchema =
| (JSONSchema & {
'x-playground-lazy'?: boolean;
})
| boolean;
export type ResolvedSchema = NoReferenceJSONSchema<ParsedSchema>;
export function getPreferredType(body: Record<string, unknown>): string | undefined {
+41 -41
View File
@@ -22,61 +22,61 @@ test('test', async () => {
expect(out).toMatchInlineSnapshot(`
{
"$root": "__0",
"$root": "#/components/schemas/V1KeysVerifyKeyResponse",
"refs": {
"__0": {
"aliasName": "object",
"#/components/schemas/V1KeysVerifyKeyResponse": {
"aliasName": "V1KeysVerifyKeyResponse",
"deprecated": undefined,
"description": undefined,
"infoTags": [],
"props": [
{
"$type": "__1",
"$type": "__0",
"name": "keyId",
"required": false,
},
{
"$type": "__2",
"$type": "__1",
"name": "valid",
"required": true,
},
{
"$type": "__3",
"$type": "__2",
"name": "name",
"required": false,
},
{
"$type": "__4",
"$type": "__3",
"name": "ownerId",
"required": false,
},
{
"$type": "__5",
"$type": "__4",
"name": "meta",
"required": false,
},
{
"$type": "__7",
"$type": "__6",
"name": "expires",
"required": false,
},
{
"$type": "__8",
"$type": "__7",
"name": "ratelimit",
"required": false,
},
{
"$type": "__12",
"$type": "__11",
"name": "remaining",
"required": false,
},
{
"$type": "__13",
"$type": "__12",
"name": "code",
"required": false,
},
{
"$type": "__14",
"$type": "__13",
"name": "enabled",
"required": false,
},
@@ -84,7 +84,7 @@ test('test', async () => {
"type": "object",
"typeName": "object",
},
"__1": {
"__0": {
"aliasName": "string",
"deprecated": undefined,
"description": "The id of the key",
@@ -92,15 +92,16 @@ test('test', async () => {
"type": "primitive",
"typeName": "string",
},
"__10": {
"aliasName": "number",
"__1": {
"aliasName": "boolean",
"deprecated": undefined,
"description": "Remaining requests after this verification",
"description": "Whether the key is valid or not.
A key could be invalid for a number of reasons, for example if it has expired, has no more verifications left or if it has been deleted.",
"infoTags": [],
"type": "primitive",
"typeName": "number",
"typeName": "boolean",
},
"__11": {
"__10": {
"aliasName": "number",
"deprecated": undefined,
"description": "Unix timestamp in milliseconds when the ratelimit will reset",
@@ -108,7 +109,7 @@ test('test', async () => {
"type": "primitive",
"typeName": "number",
},
"__12": {
"__11": {
"aliasName": "number",
"deprecated": undefined,
"description": "The number of requests that can be made with this key before it becomes invalid. If this field is null or undefined, the key has no request limit.",
@@ -116,7 +117,7 @@ test('test', async () => {
"type": "primitive",
"typeName": "number",
},
"__13": {
"__12": {
"aliasName": "string",
"deprecated": undefined,
"description": "If the key is invalid this field will be set to the reason why it is invalid.
@@ -138,7 +139,7 @@ test('test', async () => {
"type": "primitive",
"typeName": "string",
},
"__14": {
"__13": {
"aliasName": "boolean",
"deprecated": undefined,
"description": "Sets the key to be enabled or disabled. Disabled keys will not verify.",
@@ -147,15 +148,6 @@ test('test', async () => {
"typeName": "boolean",
},
"__2": {
"aliasName": "boolean",
"deprecated": undefined,
"description": "Whether the key is valid or not.
A key could be invalid for a number of reasons, for example if it has expired, has no more verifications left or if it has been deleted.",
"infoTags": [],
"type": "primitive",
"typeName": "boolean",
},
"__3": {
"aliasName": "string",
"deprecated": undefined,
"description": "The name of the key, give keys a name to easily identifiy their purpose",
@@ -163,7 +155,7 @@ test('test', async () => {
"type": "primitive",
"typeName": "string",
},
"__4": {
"__3": {
"aliasName": "string",
"deprecated": undefined,
"description": "The id of the tenant associated with this key. Use whatever reference you have in your system to identify the tenant. When verifying the key, we will send this field back to you, so you know who is accessing your API.",
@@ -171,14 +163,14 @@ test('test', async () => {
"type": "primitive",
"typeName": "string",
},
"__5": {
"__4": {
"aliasName": "object",
"deprecated": undefined,
"description": "Any additional metadata you want to store with the key",
"infoTags": [],
"props": [
{
"$type": "__6",
"$type": "__5",
"name": "[key: string]",
"required": false,
},
@@ -186,7 +178,7 @@ test('test', async () => {
"type": "object",
"typeName": "object",
},
"__6": {
"__5": {
"aliasName": "unknown",
"deprecated": undefined,
"description": undefined,
@@ -194,7 +186,7 @@ test('test', async () => {
"type": "primitive",
"typeName": "unknown",
},
"__7": {
"__6": {
"aliasName": "number",
"deprecated": undefined,
"description": "The unix timestamp in milliseconds when the key will expire. If this field is null or undefined, the key is not expiring.",
@@ -202,24 +194,24 @@ test('test', async () => {
"type": "primitive",
"typeName": "number",
},
"__8": {
"__7": {
"aliasName": "object",
"deprecated": undefined,
"description": "The ratelimit configuration for this key. If this field is null or undefined, the key has no ratelimit.",
"infoTags": [],
"props": [
{
"$type": "__9",
"$type": "__8",
"name": "limit",
"required": true,
},
{
"$type": "__10",
"$type": "__9",
"name": "remaining",
"required": true,
},
{
"$type": "__11",
"$type": "__10",
"name": "reset",
"required": true,
},
@@ -227,7 +219,7 @@ test('test', async () => {
"type": "object",
"typeName": "object",
},
"__9": {
"__8": {
"aliasName": "number",
"deprecated": undefined,
"description": "Maximum number of requests that can be made inside a window",
@@ -235,6 +227,14 @@ test('test', async () => {
"type": "primitive",
"typeName": "number",
},
"__9": {
"aliasName": "number",
"deprecated": undefined,
"description": "Remaining requests after this verification",
"infoTags": [],
"type": "primitive",
"typeName": "number",
},
},
}
`);
+1 -1
View File
@@ -22,7 +22,7 @@ export default defineConfig({
async onSuccess() {
await compileInline();
},
inlineOnly: [],
inlineOnly: ['dereference-json-schema'],
exports: {
enabled: true,
customExports(v) {
+8 -3
View File
@@ -2417,9 +2417,6 @@ importers:
'@scalar/json-magic':
specifier: ^0.11.5
version: 0.11.5
'@scalar/openapi-parser':
specifier: 0.24.15
version: 0.24.15
'@scalar/openapi-upgrader':
specifier: ^0.1.9
version: 0.1.9
@@ -2487,6 +2484,9 @@ importers:
'@types/react':
specifier: ^19.2.14
version: 19.2.14
dereference-json-schema:
specifier: ^0.2.1
version: 0.2.1
eslint-config-custom:
specifier: workspace:*
version: link:../eslint-config-custom
@@ -8351,6 +8351,9 @@ packages:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-uzJsrg225owJyRQ8FNTPHIuBOdSzIZlHhss9u6W8mp7jJldHqGuLv9cULagP/E26QVJDnjtG8U7Dw139mM1ydA==}
[email protected]:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
@@ -18190,6 +18193,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}