chore: openapi doc generator (#2644)

* chore: extract the type and comment from apis

* chore: template code

* feat: openapi

* pref: openapi generator. send into public/openapi folder
This commit is contained in:
Finley Ge
2024-09-09 15:43:09 +08:00
committed by GitHub
parent 5f3c8e9046
commit 78ad2791cd
12 changed files with 2214 additions and 24 deletions

1
scripts/openapi/.env Normal file
View File

@@ -0,0 +1 @@
SEARCH_PATH=support

3
scripts/openapi/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*.js
openapi.json
openapi.out

54
scripts/openapi/index.ts Normal file
View File

@@ -0,0 +1,54 @@
import { parseAPI } from './utils';
import * as fs from 'fs';
import * as path from 'path';
import { convertOpenApi } from './openapi';
const rootPath = 'projects/app/src/pages/api';
const exclude = ['/admin', '/proApi'];
function getAllFiles(dir: string) {
let files: string[] = [];
const stat = fs.statSync(dir);
if (stat.isDirectory()) {
const list = fs.readdirSync(dir);
list.forEach((item) => {
const fullPath = path.join(dir, item);
if (!exclude.some((excluded) => fullPath.includes(excluded))) {
files = files.concat(getAllFiles(fullPath));
}
});
} else {
files.push(dir);
}
return files;
}
const searchPath = process.env.SEARCH_PATH || '';
const files = getAllFiles(path.join(rootPath, searchPath));
// console.log(files)
const apis = files.map((file) => {
return parseAPI({ path: file, rootPath });
});
const openapi = convertOpenApi({
apis,
openapi: '3.0.0',
info: {
title: 'FastGPT OpenAPI',
version: '1.0.0',
author: 'FastGPT'
},
servers: [
{
url: 'http://localhost:4000'
}
]
});
const json = JSON.stringify(openapi, null, 2);
fs.writeFileSync('./scripts/openapi/openapi.json', json);
fs.writeFileSync('./scripts/openapi/openapi.out', JSON.stringify(apis, null, 2));
console.log('Total APIs:', files.length);

181
scripts/openapi/openapi.ts Normal file
View File

@@ -0,0 +1,181 @@
import { ApiType } from './type';
type OpenAPIParameter = {
name: string;
in: string;
description: string;
required: boolean;
schema: {
type: string;
};
};
type OpenAPIResponse = {
[code: string]: {
description?: string;
content: {
[mediaType: string]: {
schema: {
type: string;
properties?: {
[key: string]: {
type: string;
description?: string;
};
};
};
};
};
};
};
type PathType = {
[method: string]: {
description: string;
parameters: OpenAPIParameter[];
responses: OpenAPIResponse;
};
};
type PathsType = {
[url: string]: PathType;
};
type OpenApiType = {
openapi: string;
info: {
title: string;
version: string;
author: string;
};
paths: PathsType;
servers?: {
url: string;
}[];
};
export function convertPath(api: ApiType): PathType {
const method = api.method.toLowerCase();
const parameters: any[] = [];
if (api.query) {
if (Array.isArray(api.query)) {
api.query.forEach((item) => {
parameters.push({
name: item.key,
description: item.comment,
in: 'query',
required: item.required,
schema: {
type: item.type
}
});
});
} else {
parameters.push({
description: api.query.comment,
name: api.query.key,
in: 'query',
required: api.query.required,
schema: {
type: api.query.type
}
});
}
} else if (api.body) {
if (Array.isArray(api.body)) {
api.body.forEach((item) => {
parameters.push({
description: item.comment,
name: item.key,
in: 'body',
required: item.required,
schema: {
type: item.type
}
});
});
}
}
const responses: OpenAPIResponse = (() => {
if (api.response) {
if (Array.isArray(api.response)) {
const properties: {
[key: string]: {
type: string;
description?: string;
};
} = {};
api.response.forEach((item) => {
properties[item.type] = {
type: item.key ?? item.type,
description: item.comment
};
});
const res: OpenAPIResponse = {
'200': {
description: api.description ?? '',
content: {
'application/json': {
schema: {
type: 'object',
properties
}
}
}
}
};
return res;
} else {
return {
'200': {
description: api.response.comment ?? '',
content: {
'application/json': {
schema: {
type: api.response.type
}
}
}
}
};
}
} else {
return {
'200': {
description: api.description ?? '',
content: {
'application/json': {
schema: {
type: 'object'
}
}
}
}
};
}
})();
return {
[method]: {
description: api.description ?? '',
parameters,
responses
}
};
}
export function convertOpenApi({
apis,
...rest
}: {
apis: ApiType[];
} & Omit<OpenApiType, 'paths'>): OpenApiType {
const paths: PathsType = {};
apis.forEach((api) => {
paths[api.url] = convertPath(api);
});
return {
paths,
...rest
};
}

View File

@@ -0,0 +1,21 @@
{
"name": "test",
"module": "index.js",
"scripts": {
"build": "tsc index.ts"
},
"devDependencies": {
"@babel/types": "^7.25.6",
"@types/babel__generator": "^7.6.8",
"@types/babel__traverse": "^7.20.6"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"@babel/generator": "^7.25.6",
"@babel/parser": "^7.25.6",
"@babel/traverse": "^7.25.6",
"babel": "^6.23.0"
}
}

View File

@@ -0,0 +1,40 @@
```ts
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
// This should be at the top of the file after the imports
export const ApiMetadata = {
name: 'template example api',
author: 'Finley',
version: '0.1.0',
}
export type TemplateQuery = {
// The App's ID
appId?: string[],
// The App's Name
name: string,
// The App's Description
description: string | Something<AppDetailType>,
};
export type TemplateBody = {
// The App's Name
name: string,
};
// This is the response type for the API
export type TemplateResponse = AppDetailType;
// This is the template API for FASTGPT NextAPI
async function handler(
req: ApiRequestProps<TemplateBody, TemplateQuery>,
res: ApiResponseType<any>,
): Promise<TemplateResponse> {
return {}
}
export default NextAPI(handler);
```

22
scripts/openapi/type.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
export type ApiMetaData = {
name?: string;
author?: string;
version?: string;
};
export type ApiType = {
description?: string;
path: string;
url: string;
query?: itemType | itemType[];
body?: itemType | itemType[];
response?: itemType | itemType[];
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
} & ApiMetaData;
export type itemType = {
comment?: string;
key?: string;
type: string;
required?: boolean;
};

223
scripts/openapi/utils.ts Normal file
View File

@@ -0,0 +1,223 @@
import type { TSType, TSTypeLiteral } from '@babel/types';
import { parse } from '@babel/parser';
import traverse, { NodePath } from '@babel/traverse';
import * as fs from 'fs';
import type { ApiMetaData, ApiType, itemType } from './type';
function getMetadata(path: NodePath): ApiMetaData | undefined {
const metadata = {
name: '',
author: '',
version: '',
method: ''
};
if (
path.isExportNamedDeclaration() && // get metadata
path.node.declaration?.type === 'VariableDeclaration' &&
path.node.declaration.declarations[0]?.id.type === 'Identifier' &&
path.node.declaration.declarations[0].id.name === 'ApiMetadata' &&
path.node.declaration.declarations[0].init?.type === 'ObjectExpression'
) {
path.node.declaration.declarations[0].init.properties.forEach((item) => {
if (item.type === 'ObjectProperty') {
const key = item.key.type === 'Identifier' ? item.key.name : item.key.type;
if (key === 'name') {
metadata.name = item.value.type === 'StringLiteral' ? item.value.value : item.value.type;
}
if (key === 'author') {
metadata.author =
item.value.type === 'StringLiteral' ? item.value.value : item.value.type;
}
if (key === 'version') {
metadata.version =
item.value.type === 'StringLiteral' ? item.value.value : item.value.type;
} else if (key === 'method') {
metadata.method =
item.value.type === 'StringLiteral' ? item.value.value : item.value.type;
metadata.method = metadata.method.toUpperCase();
}
}
});
if (metadata.name && metadata.author && metadata.version) {
return metadata;
}
}
}
function getDescription(path: NodePath) {
if (path.isFunctionDeclaration() && path.node.id?.name === 'handler') {
const comments = path.node.leadingComments?.map((item) => item.value.trim()).join('\n');
return comments;
}
}
type ApiDataType = {
type?: 'query' | 'body' | 'response';
comment?: string;
items?: itemType[];
dataType?: string;
};
function parseType(type?: TSType): string {
if (!type) {
return '';
}
if (type.type === 'TSTypeReference') {
return type.typeName.type === 'Identifier' ? type.typeName.name : type.typeName.type;
} else if (type.type === 'TSArrayType') {
return `${parseType(type.elementType)}[]`;
} else if (type.type === 'TSUnionType') {
return type.types.map((item) => parseType(item)).join(' | ');
} else if (type.type === 'TSIntersectionType') {
return type.types.map((item) => parseType(item)).join(' & ');
} else if (type.type === 'TSLiteralType') {
return type.literal.type === 'StringLiteral' ? type.literal.value : type.literal.type;
// } else if (type.type === 'TSTypeLiteral') {
// return parseTypeLiteral(type);
} else if (type.type === 'TSStringKeyword') {
return 'string';
} else if (type.type === 'TSNumberKeyword') {
return 'number';
} else if (type.type === 'TSBooleanKeyword') {
return 'boolean';
} else {
return type.type;
}
}
function parseTypeLiteral(type: TSTypeLiteral): itemType[] {
const items: itemType[] = [];
type.members.forEach((item) => {
if (item.type === 'TSPropertySignature') {
const key = item.key.type === 'Identifier' ? item.key.name : item.key.type;
const value = parseType(item.typeAnnotation?.typeAnnotation);
const comments = [
item.leadingComments?.map((item) => item.value.trim()).join('\n'),
item.trailingComments?.map((item) => item.value.trim()).join('\n')
].join('\n');
const required = item.optional ? false : true;
items.push({
type: value,
comment: comments,
key,
required
});
}
});
return items;
}
function getData(path: NodePath): ApiDataType | undefined {
const type: ApiDataType = {};
if (path.isExportNamedDeclaration()) {
const comments = [
path.node.leadingComments?.map((item) => item.value.trim()).join('\n'),
path.node.trailingComments?.map((item) => item.value.trim()).join('\n')
].join('\n');
if (comments) {
type.comment = comments;
}
if (path.node.declaration?.type === 'TSTypeAliasDeclaration') {
if (path.node.declaration.id.type === 'Identifier') {
if (path.node.declaration.id.name.endsWith('Query')) {
type.type = 'query';
const queryType = path.node.declaration.typeAnnotation;
if (queryType) {
if (queryType.type === 'TSTypeLiteral') {
type.items = parseTypeLiteral(queryType);
} else {
type.dataType = parseType(queryType);
}
}
} else if (path.node.declaration.id.name.endsWith('Body')) {
type.type = 'body';
if (path.node.declaration.typeAnnotation) {
if (path.node.declaration.typeAnnotation.type === 'TSTypeLiteral') {
type.items = parseTypeLiteral(path.node.declaration.typeAnnotation);
} else {
type.dataType = parseType(path.node.declaration.typeAnnotation);
}
}
} else if (path.node.declaration.id.name.endsWith('Response')) {
type.type = 'response';
if (path.node.declaration.typeAnnotation) {
if (path.node.declaration.typeAnnotation.type === 'TSTypeLiteral') {
type.items = parseTypeLiteral(path.node.declaration.typeAnnotation);
} else {
type.dataType = parseType(path.node.declaration.typeAnnotation);
}
}
} else {
return;
}
}
}
}
return type;
}
function parseCode(code: string): ApiType {
const ast = parse(code, {
sourceType: 'module',
plugins: ['typescript', 'jsx']
});
const api = <ApiType>{};
traverse(ast, {
enter(path) {
const metadata = getMetadata(path);
const description = getDescription(path);
const data = getData(path);
if (metadata) {
api.name = metadata.name;
api.author = metadata.author;
api.version = metadata.version;
}
if (description) {
api.description = description;
}
if (data) {
if (data.type === 'query') {
api.query = data.items ?? {
type: data.dataType ?? '',
comment: data.comment ?? ''
};
} else if (data.type === 'body') {
api.body = data.items ?? {
type: data.dataType ?? '',
comment: data.comment ?? ''
};
} else if (data.type === 'response') {
api.response = data.items ?? {
type: data.dataType ?? '',
comment: data.comment ?? ''
};
}
}
}
});
return api;
}
function getMethod(api: ApiType): 'GET' | 'POST' {
if (api.query && !(Array.isArray(api.query) && api.query.length === 0)) {
return 'GET';
} else if (api.body && !(Array.isArray(api.body) && api.body.length === 0)) {
return 'POST';
} else {
return 'GET';
}
}
export function parseAPI({ path, rootPath }: { path: string; rootPath: string }): ApiType {
const code = fs.readFileSync(path, 'utf-8');
const api = parseCode(code);
api.url = path.replace('.ts', '').replace(rootPath, '');
api.path = path;
if (api.method === undefined) {
api.method = getMethod(api);
}
return api;
}