OpenAPI: redesign generateFiles()

This commit is contained in:
Fuma Nama
2025-05-21 21:11:04 +08:00
parent 155c908fc5
commit bdef238809
9 changed files with 336 additions and 161 deletions
+67
View File
@@ -0,0 +1,67 @@
---
'fumadocs-openapi': major
---
**Redesign `generateFiles`**
This redesign will finalize the behaviour of `generateFiles` to make it simpler, consistent across different versions of Fumadocs OpenAPI.
- Abandoned `groupByFolder`, it's deprecated long time ago and can be replaced with `groupBy`.
- Improved type safety, `groupBy` is now only available with `per` set to `operation`.
- `name` usage changed (see below).
The `name` option was supposed to designate a output path for generated page. Since `groupBy` was introduced, `name` became somehow useless because its design doesn't work well with `groupBy`.
**New `name` Design**:
It now accepts a function:
```ts
generateFiles({
input: ['./content/docs/openapi/museum.yaml'],
output: './content/docs/openapi/(generated)',
per: 'operation',
name: (output, document) => {
// page info
output.item
// parsed OpenAPI schema
document
return 'dir/my-file'
}
})
```
You can set `algorithm` to `v1` to keep the behaviour of Fumadocs OpenAPI v8:
```ts
generateFiles({
input: ['./content/docs/openapi/museum.yaml'],
output: './content/docs/openapi/(generated)',
per: 'operation',
name: {
algorithm: 'v1'
}
})
```
`per: operation`:
File name will be identical with your `operationId` if defined, otherwise fallback to endpoint path or webhook name.
```ts
generateFiles({
input: ['./content/docs/openapi/museum.yaml'],
output: './content/docs/openapi/(generated)',
per: 'operation',
})
```
With `per: operation`, you can use `groupBy` to group pages:
- tag: `{tag}/{file}`
- route: `{endpoint}/{method}` (it will ignore the `name` option)
- none: `{file}` (default)
`per: tag | file`:
They are unchanged.
+228 -112
View File
@@ -1,15 +1,106 @@
import { mkdir, writeFile } from 'node:fs/promises';
import * as path from 'node:path';
import { resolve } from 'node:path';
import { glob } from 'tinyglobby';
import {
generateAll,
type GenerateOptions,
type GeneratePageOutput,
generatePages,
type GenerateTagOutput,
generateTags,
} from './generate';
import {
type DocumentInput,
processDocument,
type ProcessedDocument,
} from '@/utils/process-document';
export interface Config extends GenerateOptions {
interface GenerateFileOutput {
/**
* The original schema file path/url from `input`
*/
pathOrUrl: string;
content: string;
}
interface OperationConfig extends BaseConfig {
/**
* Generate a page for each API endpoint/operation (default).
*/
per?: 'operation';
/**
* Group output using folders (Only works on `operation` mode)
* - tag: `{tag}/{file}`
* - route: `{endpoint}/{method}` (it will ignore the `name` option)
* - none: `{file}` (default)
*
* @defaultValue 'none'
*/
groupBy?: 'tag' | 'route' | 'none';
/**
* Specify name for output file
*/
name?:
| ((
output: GeneratePageOutput,
document: ProcessedDocument['document'],
) => string)
| BaseName;
}
interface TagConfig extends BaseConfig {
/**
* Generate a page for each tag.
*/
per: 'tag';
/**
* Specify name for output file
*/
name?:
| ((
output: GenerateTagOutput,
document: ProcessedDocument['document'],
) => string)
| BaseName;
}
interface FileConfig extends BaseConfig {
/**
* Generate a page for each schema file.
*/
per: 'file';
/**
* Specify name for output file
*/
name?:
| ((
output: GenerateFileOutput,
document: ProcessedDocument['document'],
) => string)
| BaseName;
}
export type Config = FileConfig | TagConfig | OperationConfig;
interface BaseName {
/**
* The version of algorithm used to generate file paths.
*
* v1: Fumadocs OpenAPI v8
* v2: Fumadocs OpenAPI v9
*
* @defaultValue v2
*/
algorithm?: 'v2' | 'v1';
}
interface BaseConfig extends GenerateOptions {
/**
* Schema files
*/
@@ -19,46 +110,10 @@ export interface Config extends GenerateOptions {
* Output directory
*/
output: string;
/**
* tag: Generate a page for each tag
* file: Generate a page for each schema
* operation: Generate a page for each API endpoint/operation
*
* @defaultValue 'operation'
*/
per?: 'tag' | 'file' | 'operation';
/**
* Specify name for output file
*/
name?: (type: 'file' | 'tag', name: string) => string;
/**
* Group output using folders (Only works on `operation` mode)
*
* @deprecated Use `groupBy` instead
* @defaultValue false
*/
groupByFolder?: boolean;
/**
* Group output using folders (Only works on `operation` mode)
*
* @defaultValue 'none'
*/
groupBy?: 'tag' | 'route' | 'none';
}
export async function generateFiles(options: Config): Promise<void> {
const {
input,
output,
name: nameFn,
per = 'operation',
groupBy = 'none',
cwd = process.cwd(),
} = options;
const { input, cwd = process.cwd() } = options;
const urlInputs: string[] = [];
const fileInputs: string[] = [];
@@ -83,24 +138,97 @@ export async function generateFiles(options: Config): Promise<void> {
);
}
function getOutputPaths(result: GeneratePageOutput): string[] {
let file;
await Promise.all(
resolvedInputs.map((input) => generateFromDocument(input, options)),
);
}
if (result.pathItem.summary) {
file = getFilename(result.pathItem.summary);
} else if (result.type === 'operation') {
file = path.join(
getOutputPathFromRoute(result.item.path),
result.item.method.toLowerCase(),
);
async function generateFromDocument(pathOrUrl: string, options: Config) {
const { output, cwd = process.cwd() } = options;
let nameFn: (
output: GeneratePageOutput | GenerateTagOutput | GenerateFileOutput,
document: ProcessedDocument['document'],
) => string;
if (!options.name || typeof options.name !== 'function') {
const { algorithm = 'v2' } = options.name ?? {};
nameFn = (output, document) => {
if (options.per === 'operation') {
const result = output as GeneratePageOutput;
if (result.type === 'operation') {
const operation =
document.paths![result.item.path]![result.item.method]!;
if (algorithm === 'v2' && operation.operationId) {
return operation.operationId;
}
return path.join(
getOutputPathFromRoute(result.item.path),
result.item.method.toLowerCase(),
);
}
const hook = document.webhooks![result.item.name][result.item.method]!;
if (algorithm === 'v2' && hook.operationId) {
return hook.operationId;
}
return getFilename(result.item.name);
}
if (options.per === 'tag') {
const result = output as GenerateTagOutput;
return getFilename(result.tag);
}
return isUrl(pathOrUrl)
? 'index'
: path.basename(pathOrUrl, path.extname(pathOrUrl));
};
} else {
nameFn = options.name as typeof nameFn;
}
const document = await dereference(pathOrUrl, options);
const outputDir = path.join(cwd, output);
async function write(file: string, content: string) {
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, content);
}
function getOutputPaths(result: GeneratePageOutput): string[] {
if (options.per !== 'operation')
throw new Error('only available for per: operation');
const outPaths: string[] = [];
const file = nameFn(result, document.document);
let tags: string[] | undefined;
if (result.type === 'operation') {
const operation =
document.document.paths![result.item.path]![result.item.method]!;
tags = operation.tags;
} else {
file = getFilename(result.item.name);
const hook =
document.document.webhooks![result.item.name][result.item.method]!;
tags = hook.tags;
}
const outPaths: string[] = [];
if (groupBy === 'tag') {
let tags = result.operation.tags;
if (options.groupBy === 'route') {
outPaths.push(
path.join(
result.type === 'operation' ? result.item.path : result.item.name,
result.item.method,
) + '.mdx',
);
} else if (options.groupBy === 'tag') {
if (!tags || tags.length === 0) {
console.warn(
'When `groupBy` is set to `tag`, make sure a `tags` is defined for every operation schema.',
@@ -119,44 +247,37 @@ export async function generateFiles(options: Config): Promise<void> {
return outPaths;
}
const metaFiles = new Set<string>();
async function writeMetafile(file: string, data: object) {
if (metaFiles.has(file)) return;
metaFiles.add(file);
if (options.per === 'file') {
const result = await generateAll(document, options);
const filename = nameFn(
{
pathOrUrl,
content: result,
},
document.document,
);
await write(file, JSON.stringify(data, null, 2));
console.log(`Generated Meta: ${file}`);
const outPath = path.join(outputDir, `${filename}.mdx`);
await write(outPath, result);
console.log(`Generated: ${outPath}`);
}
async function generateFromDocument(pathOrUrl: string) {
const outputDir = path.join(cwd, output);
if (!options.per || options.per === 'operation') {
const results = await generatePages(document, options);
const mapping = new Map<string, GeneratePageOutput>();
if (per === 'file') {
let filename = isUrl(pathOrUrl)
? 'index'
: path.basename(pathOrUrl, path.extname(pathOrUrl));
if (nameFn) filename = nameFn('file', filename);
const outPath = path.join(outputDir, `${filename}.mdx`);
const result = await generateAll(pathOrUrl, options);
await write(outPath, result);
console.log(`Generated: ${outPath}`);
for (const result of results) {
for (const outputPath of getOutputPaths(result)) {
mapping.set(outputPath, result);
}
}
if (per === 'operation') {
const results = await generatePages(pathOrUrl, options);
const mapping = new Map<string, GeneratePageOutput>();
for (const [key, output] of mapping.entries()) {
let outputPath = key;
for (const result of results) {
const outputPaths = getOutputPaths(result);
for (const outputPath of outputPaths) {
mapping.set(outputPath, result);
}
}
for (const [key, output] of mapping.entries()) {
let outputPath = key;
// v1 will remove nested directories
if (typeof options.name === 'object' && options.name.algorithm === 'v1') {
const isSharedDir = Array.from(mapping.keys()).some(
(item) =>
item !== outputPath &&
@@ -166,37 +287,23 @@ export async function generateFiles(options: Config): Promise<void> {
if (!isSharedDir && path.dirname(outputPath) !== '.') {
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}`);
}
}
if (per === 'tag') {
const results = await generateTags(pathOrUrl, options);
for (const result of results) {
let tagName = result.tag;
tagName = nameFn?.('tag', tagName) ?? getFilename(tagName);
const outPath = path.join(outputDir, `${tagName}.mdx`);
await write(outPath, result.content);
console.log(`Generated: ${outPath}`);
}
await write(path.join(outputDir, outputPath), output.content);
console.log(`Generated: ${outputPath}`);
}
}
await Promise.all(resolvedInputs.map(generateFromDocument));
if (options.per === 'tag') {
const results = await generateTags(document, options);
for (const result of results) {
const filename = nameFn(result, document.document);
const outPath = path.join(outputDir, `${filename}.mdx`);
await write(outPath, result.content);
console.log(`Generated: ${outPath}`);
}
}
}
function isUrl(input: string): boolean {
@@ -221,7 +328,16 @@ function getFilename(s: string): string {
return s.replace(/\s+/g, '-').toLowerCase();
}
async function write(file: string, content: string): Promise<void> {
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, content);
async function dereference(
pathOrDocument: DocumentInput,
options: GenerateOptions,
) {
return processDocument(
// resolve paths
typeof pathOrDocument === 'string' &&
!pathOrDocument.startsWith('http://') &&
!pathOrDocument.startsWith('https://')
? resolve(options.cwd ?? process.cwd(), pathOrDocument)
: pathOrDocument,
);
}
+8 -38
View File
@@ -1,14 +1,11 @@
import { resolve } from 'node:path';
import { getAPIPageItems } from '@/build-routes';
import {
type DocumentContext,
generateDocument,
} from '@/utils/generate-document';
import { idToTitle } from '@/utils/id-to-title';
import type { OperationObject, PathItemObject } from './types';
import type { NoReference } from '@/utils/schema';
import type { OperationItem, WebhookItem } from '@/render/api-page';
import { type DocumentInput, processDocument } from '@/utils/process-document';
import type { ProcessedDocument } from '@/utils/process-document';
export interface GenerateOptions {
/**
@@ -60,40 +57,19 @@ export interface GenerateTagOutput {
export type GeneratePageOutput =
| {
type: 'operation';
pathItem: NoReference<PathItemObject>;
operation: NoReference<OperationObject>;
item: OperationItem;
content: string;
}
| {
type: 'webhook';
pathItem: NoReference<PathItemObject>;
operation: NoReference<OperationObject>;
item: WebhookItem;
content: string;
};
async function dereference(
pathOrDocument: DocumentInput,
options: GenerateOptions,
) {
return await processDocument(
// resolve paths
typeof pathOrDocument === 'string' &&
!pathOrDocument.startsWith('http://') &&
!pathOrDocument.startsWith('https://')
? resolve(options.cwd ?? process.cwd(), pathOrDocument)
: pathOrDocument,
).then((res) => res.document);
}
export async function generateAll(
pathOrDocument: DocumentInput,
{ document, downloaded }: ProcessedDocument,
options: GenerateOptions = {},
): Promise<string> {
const document = await dereference(pathOrDocument, options);
const items = getAPIPageItems(document);
return generateDocument({
@@ -105,7 +81,7 @@ export async function generateAll(
operations: items.operations,
webhooks: items.webhooks,
hasHead: true,
document: pathOrDocument,
document: downloaded,
},
context: {
@@ -115,10 +91,9 @@ export async function generateAll(
}
export async function generatePages(
pathOrDocument: DocumentInput,
{ document, downloaded }: ProcessedDocument,
options: GenerateOptions = {},
): Promise<GeneratePageOutput[]> {
const document = await dereference(pathOrDocument, options);
const items = getAPIPageItems(document);
const result: GeneratePageOutput[] = [];
@@ -130,15 +105,13 @@ export async function generatePages(
result.push({
type: 'operation',
pathItem,
operation,
item,
content: generateDocument({
...options,
page: {
operations: [item],
hasHead: false,
document: pathOrDocument,
document: downloaded,
},
dereferenced: document,
title:
@@ -161,15 +134,13 @@ export async function generatePages(
result.push({
type: 'webhook',
pathItem,
operation,
item,
content: generateDocument({
...options,
page: {
webhooks: [item],
hasHead: false,
document: pathOrDocument,
document: downloaded,
},
dereferenced: document,
title: operation.summary ?? pathItem.summary ?? idToTitle(item.name),
@@ -185,10 +156,9 @@ export async function generatePages(
}
export async function generateTags(
pathOrDocument: DocumentInput,
{ document, downloaded }: ProcessedDocument,
options: GenerateOptions = {},
): Promise<GenerateTagOutput[]> {
const document = await dereference(pathOrDocument, options);
if (!document.tags) return [];
const items = getAPIPageItems(document);
@@ -210,7 +180,7 @@ export async function generateTags(
content: generateDocument({
...options,
page: {
document: pathOrDocument,
document: downloaded,
operations,
webhooks,
hasHead: true,
@@ -32,6 +32,12 @@ export async function processDocument(
plugins: [readFiles(), fetchUrls()],
});
if (loaded.errors && loaded.errors.length > 0) {
throw new Error(
loaded.errors.map((err) => `${err.code}: ${err.message}`).join('\n'),
);
}
// upgrade
loaded.specification = upgrade(loaded.specification).specification;
const { schema: dereferenced } = await dereference(loaded.filesystem, {
+23 -7
View File
@@ -3,6 +3,7 @@ import { join } from 'node:path';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { idToTitle } from '@/utils/id-to-title';
import { generateAll, generateFiles, generateTags } from '../src';
import { processDocument } from '@/utils/process-document';
describe('Utilities', () => {
test('Operation ID to Title', () => {
@@ -20,17 +21,23 @@ describe('Generate documents', () => {
});
test('Pet Store', async () => {
const result = await generateAll('./fixtures/petstore.yaml', {
cwd,
});
const result = await generateAll(
await processDocument(join(cwd, './fixtures/petstore.yaml')),
{
cwd,
},
);
await expect(result).toMatchFileSnapshot('./out/petstore.mdx');
});
test('Museum', async () => {
const tags = await generateTags('./fixtures/museum.yaml', {
cwd,
});
const tags = await generateTags(
await processDocument(join(cwd, './fixtures/museum.yaml')),
{
cwd,
},
);
for (const tag of tags) {
await expect(tag.content).toMatchFileSnapshot(
@@ -40,7 +47,10 @@ describe('Generate documents', () => {
});
test('Unkey', async () => {
const tags = await generateTags('./fixtures/unkey.json', { cwd });
const tags = await generateTags(
await processDocument(join(cwd, './fixtures/unkey.json')),
{ cwd },
);
for (const tag of tags) {
await expect(tag.content).toMatchFileSnapshot(
@@ -105,6 +115,9 @@ describe('Generate documents', () => {
output: './out',
per: 'file',
cwd,
name: {
algorithm: 'v1',
},
}),
).rejects.toThrow(
'No input files found. Tried resolving: ./fixtures/non-existent-1.yaml, ./fixtures/non-existent-2.yaml',
@@ -117,6 +130,9 @@ describe('Generate documents', () => {
output: './out',
per: 'operation',
groupBy: 'tag',
name: {
algorithm: 'v1',
},
cwd,
});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -25,4 +25,4 @@ _openapi:
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
<APIPage document={"./fixtures/petstore.yaml"} operations={[{"path":"/pets","method":"get"},{"path":"/pets","method":"post"},{"path":"/pets/{petId}","method":"get"}]} webhooks={[]} hasHead={true} />
<APIPage document={{"openapi":"3.1.1","info":{"version":"1.0.0","title":"Swagger Petstore","license":{"name":"MIT"}},"servers":[{"url":"http://petstore.swagger.io/v1"}],"paths":{"/pets":{"get":{"summary":"List all pets","operationId":"listPets","tags":["pets"],"parameters":[{"name":"limit","in":"query","description":"How many items to return at one time (max 100)","required":false,"schema":{"type":"integer","maximum":100,"format":"int32"}}],"responses":{"200":{"description":"A paged array of pets","headers":{"x-next":{"description":"A link to the next page of responses","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pets"}}}},"default":{"description":"unexpected error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Create a pet","operationId":"createPets","tags":["pets"],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pet"}}},"required":true},"responses":{"201":{"description":"Null response"},"default":{"description":"unexpected error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/pets/{petId}":{"get":{"summary":"Info for a specific pet","operationId":"showPetById","tags":["pets"],"parameters":[{"name":"petId","in":"path","required":true,"description":"The id of the pet to retrieve","schema":{"type":"string"}}],"responses":{"200":{"description":"Expected response to a valid request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pet"}}}},"default":{"description":"unexpected error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"components":{"schemas":{"Pet":{"type":"object","required":["id","name"],"properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"},"tag":{"type":"string"}}},"Pets":{"type":"array","maxItems":100,"items":{"$ref":"#/components/schemas/Pet"}},"Error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"integer","format":"int32"},"message":{"type":"string"}}}}}}} operations={[{"path":"/pets","method":"get"},{"path":"/pets","method":"post"},{"path":"/pets/{petId}","method":"get"}]} webhooks={[]} hasHead={true} />