czh-api
v1.0.2
Published
A CLI tool to generate TypeScript API clients from Swagger/OpenAPI documents.
Readme
CZH API
czh-api 是一款功能强大且灵活的前端 API 同步工具,灵感来源于 Pont。它能够根据 Swagger/OpenAPI 文档自动生成 TypeScript API 代码,帮助您保持前后端接口的一致性,提高开发效率,并减少手动编写代码带来的错误。
✨ 功能特性
- 多标准支持: 完美兼容 Swagger v2, OpenAPI v3, 以及 Knife4j 风格的 JSON 文档。
- 智能模块生成: 根据接口的
x-package字段(例如com.czh.SysConfigController->sysConfig模块)自动对 API 进行模块化分组,当该字段缺失时,则使用 URL 的第一段作为备用方案。 - 清晰的命名规范: API 函数名采用
HTTP方法+驼峰式路径的方式生成(例如POST /sys/user/add->postSysUserAdd),确保了全局唯一性和代码的可读性。 - 可定制化模板: 提供 Handlebars (
.hbs) 模板,覆盖 API、类型和模块索引文件,允许您完全自定义生成的代码风格。 - 自动化类型生成: 为所有请求参数和响应数据生成 TypeScript 类型定义(
types.ts)。 - 详尽的 JSDoc 注释: 为每个 API 函数自动生成全面的 JSDoc 注释,包含接口描述以及每个参数详尽的
@param注解, 参数名称带[]则后端为非必填。 - 灵活的配置: 使用
czh-api.config.json文件来管理所有设置,例如 Swagger 地址、输出目录、需要排除的路径以及自定义导入语句等。 - NPM 包与命令行工具: 已封装为 Node.js 命令行工具,便于在任何前端项目中安装和使用。
🚀 安装
通过 npm 全局安装本工具:
npm install -g czh-api🛠️ 使用说明
主要命令
czh-api 提供了以下核心命令来管理您的 API 代码生成:
| 命令 | 别名 (alias) | 描述 |
|---------------|----------------|-----------------------------------------------------------------------------------------------------------------------------------|
| czh-api | -h, --help | 显示帮助信息。列出所有可用的命令和选项。 |
| czh-api -v| --version | 查看版本号。显示当前安装的 czh-api 的版本。 |
| czh-api init | | 初始化项目。在当前目录下创建 czh-api.config.json 配置文件和 czh-api-template 模板文件夹。 |
| czh-api build | | 生成 API 代码。根据配置文件中的 url 获取远程 API 文档,并在指定的 outputDir 目录中生成所有 TypeScript 模块、类型和索引文件。 |
配置文件 (czh-api.config.json)
init 命令会生成此文件。您需要根据项目需求进行编辑:
{
"url": "https://your-swagger-docs/v2/api-docs",
"outputDir": "./src/api",
"templates": "./czh-api-template",
"customImports": [
"import http from '@/utils/http';"
],
"excludePaths": [
"/sys/log",
"/tool/gen"
],
"includePaths": [
"/sys/user",
"/sys/role"
]
}配置项说明:
| 选项 | 类型 | 是否必须 | 描述 |
|------------------|------------|----------|-------------------------------------------------------------------------|
| url | string | 是 | 您的 Swagger/OpenAPI JSON 文档地址。 |
| outputDir | string | 是 | 用于存放生成的 API 模块的目录。 |
| templates | string | 否 | 指向您的自定义模板目录的路径。默认为 init 命令生成的目录。 |
| customImports | string[] | 否 | 一个自定义导入语句的数组,会被添加到每个生成的 API 文件的顶部。 |
| excludePaths | string[] | 否 | 一个 URL 路径前缀的数组。任何以此数组中前缀开头的 API 都将被忽略。 |
| includePaths | string[] | 否 | 一个 URL 路径前缀的数组。如果配置了此项,则只同步以这些前缀开头的 API。 |
📝 模板配置
czh-api 使用 Handlebars 模板引擎来生成代码。运行 czh-api init 后会在 czh-api-template 目录下生成默认模板,您可以根据项目需求自定义。
模板文件
| 文件名 | 用途 |
|--------------|--------------------------------|
| api.hbs | 生成每个 API 函数的代码 |
| types.hbs | 生成类型定义文件(如有) |
| index.hbs | 生成模块索引文件(如有) |
可用变量
在 api.hbs 模板中,您可以使用以下变量:
| 变量名 | 类型 | 描述 |
|------------------------|------------|------------------------------------------------|
| functionName | string | 生成的函数名,如 postSysUserAdd |
| description | string | 接口描述 |
| method | string | HTTP 方法:get, post, put, delete 等 |
| path | string | 请求路径,路径参数已转为模板字符串格式 |
| hasParams | boolean | 是否有查询/路径参数 |
| hasData | boolean | 是否有请求体 |
| requestParamsTypeName| string | 请求参数的类型名 |
| requestBodyTypeName | string | 请求体的类型名 |
| responseTypeName | string | 响应数据的类型名 |
| contentType | string | Content-Type,如 multipart/form-data |
| jsdocParams | array | JSDoc 参数列表,包含 name, type, description, required |
内置 Helpers
| Helper | 用法 | 描述 |
|--------|-----------------------------------|----------------|
| eq | {{#if (eq method "delete")}}...{{/if}} | 判断两个值是否相等 |
模板示例
默认的 api.hbs 模板:
/**
* @description {{description}}
{{#if jsdocParams}}
{{#each jsdocParams}}
* @param { {{this.type}} } {{#unless this.required}}[{{/unless}}{{this.name}}{{#unless this.required}}]{{/unless}} - {{this.description}}
{{/each}}
{{/if}}
*/
export const {{functionName}} = ({{#if hasParams}}params: {{requestParamsTypeName}}{{/if}}{{#if hasData}}{{#if hasParams}}, {{/if}}data: {{requestBodyTypeName}}{{/if}}) => {
return http.request<{{responseTypeName}}>({
url: `{{path}}`,
method: '{{method}}',
{{#if hasParams}}
params,
{{/if}}
{{#if hasData}}
data,
{{/if}}
{{#if contentType}}
headers: { 'Content-Type': '{{contentType}}' },
{{/if}}
});
};如果您的 HTTP 客户端使用 request.get(), request.post() 这种风格,并且 delete 方法需要改为 del,可以这样写:
export const {{functionName}} = ({{#if hasParams}}params: {{requestParamsTypeName}}{{/if}}{{#if hasData}}{{#if hasParams}}, {{/if}}data: {{requestBodyTypeName}}{{/if}}) => {
return request.{{#if (eq method "delete")}}del{{else}}{{method}}{{/if}}<{{responseTypeName}}>({
url: `{{path}}`,
{{#if hasParams}}
params,
{{/if}}
{{#if hasData}}
data,
{{/if}}
});
};👨💻 开发者指南
如果您克隆了本仓库并希望进行二次开发、贡献或发布您自己的版本,请遵循以下步骤:
1. 安装依赖
在项目根目录下运行,安装所有开发和运行时所需的依赖。
npm install2. 编译源码
此命令会将 src/ 目录下的 TypeScript 源码编译成 JavaScript,并输出到 dist/ 目录。同时,它会自动复制必要的模板文件。
npm run build3. 本地测试
使用 npm link 可以将本地开发版本的包链接到全局,让您可以在任何地方通过 czh-api 命令来测试您的修改,而无需发布到 NPM。
npm link4. 发布到 NPM
当您准备好发布新版本时,请执行此命令。
注意: 在发布前,请确保您已登录 NPM (npm login),并在 package.json 中更新了包的版本号。
npm publish发布到npm官方仓库需要切到官方源
npm config set registry https://registry.npmjs.org/
npm config set //registry.npmjs.org/:_authToken=你的token
npm publish5. 解除本地链接/卸载
如果您想解除本地的链接状态,可以使用 unlink 命令。如果您想从全局卸载,请使用 uninstall。
# 解除本地开发链接
npm unlink czh-api
# 或,全局卸载包
npm uninstall -g czh-api6. 如果服务端是knife4j风格的JSON文档
6.1 SpringBoot3配置
SpringBoot3项目直接复制6.3配置文件即可
6.2 SpringBoot2配置
复制6.3 把jakarta替换为javax
6.3 SpringBoot配置
ResponseWrapper.java
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.WriteListener;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpServletResponseWrapper;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
public class ResponseWrapper extends HttpServletResponseWrapper {
private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
private final StringWriter stringWriter = new StringWriter();
private PrintWriter writer;
private boolean writerUsed = false;
private boolean outputStreamUsed = false;
public ResponseWrapper(HttpServletResponse response) {
super(response);
}
@Override
public PrintWriter getWriter() throws IOException {
if (outputStreamUsed) {
throw new IllegalStateException("getOutputStream() has already been called for this response");
}
if (writer == null) {
writer = new PrintWriter(stringWriter);
}
writerUsed = true;
return writer;
}
@Override
public ServletOutputStream getOutputStream() throws IOException {
if (writerUsed) {
throw new IllegalStateException("getWriter() has already been called for this response");
}
outputStreamUsed = true;
return new ServletOutputStream() {
@Override
public boolean isReady() {
return true;
}
@Override
public void setWriteListener(WriteListener writeListener) {
// Do nothing
}
@Override
public void write(int b) throws IOException {
outputStream.write(b);
}
};
}
public String getContent() {
if (writerUsed) {
writer.flush();
return stringWriter.toString();
} else if (outputStreamUsed) {
return outputStream.toString();
}
return "";
}
}ApiDocsFormDataFilter.java
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Component
@Order(1)
public class ApiDocsFormDataFilter implements Filter {
@Autowired
private RequestMappingHandlerMapping requestMappingHandlerMapping;
// 缓存路径到Controller的映射关系
private final Map<String, String> pathToControllerMap = new ConcurrentHashMap<>();
// 特殊指定的formdata路径(文件上传等)
private static final Set<String> FORCE_FORM_DATA_PATHS = Set.of();
/**
* 初始化时自动扫描所有Controller映射
*/
@PostConstruct
public void initControllerMappings() {
try {
Map<RequestMappingInfo, HandlerMethod> handlerMethods =
requestMappingHandlerMapping.getHandlerMethods();
for (Map.Entry<RequestMappingInfo, HandlerMethod> entry : handlerMethods.entrySet()) {
RequestMappingInfo mappingInfo = entry.getKey();
HandlerMethod handlerMethod = entry.getValue();
// 获取Controller类的完整名称
String controllerClass = handlerMethod.getBeanType().getName();
// 获取路径模式
Set<String> patterns = mappingInfo.getPatternValues();
for (String pattern : patterns) {
// 清理路径模式(移除路径变量)
String cleanPath = cleanPathPattern(pattern);
pathToControllerMap.put(cleanPath, controllerClass);
// 同时存储原始路径
pathToControllerMap.put(pattern, controllerClass);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 清理路径模式,移除路径变量
*/
private String cleanPathPattern(String pattern) {
// 移除路径变量 {id} -> 空
return pattern.replaceAll("\\{[^}]+\\}", "");
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
// 只处理 /v3/api-docs 请求
if (httpRequest.getRequestURI().contains("/v3/api-docs")) {
// 创建响应包装器来捕获原始响应
ResponseWrapper responseWrapper = new ResponseWrapper((HttpServletResponse) response);
try {
// 继续执行原始请求
chain.doFilter(request, responseWrapper);
// 获取原始响应内容
String originalContent = responseWrapper.getContent();
// 智能标识请求类型
String modifiedContent = smartMarkRequestTypes(originalContent);
// 写入修改后的内容
response.setContentType("application/json;charset=UTF-8");
response.setContentLength(modifiedContent.getBytes(StandardCharsets.UTF_8).length);
response.getOutputStream().write(modifiedContent.getBytes(StandardCharsets.UTF_8));
response.getOutputStream().flush();
} catch (Exception e) {
e.printStackTrace();
// 如果出错,返回原始内容
chain.doFilter(request, response);
}
} else {
chain.doFilter(request, response);
}
}
private String smartMarkRequestTypes(String originalContent) {
try {
// 使用 fastjson2 解析
JSONObject rootJson = JSON.parseObject(originalContent);
// 处理所有路径
JSONObject paths = rootJson.getJSONObject("paths");
if (paths != null) {
processAllPathsSmart(paths);
}
return rootJson.toJSONString();
} catch (Exception e) {
e.printStackTrace();
return originalContent;
}
}
private void processAllPathsSmart(JSONObject paths) {
for (String pathKey : paths.keySet()) {
JSONObject pathItem = paths.getJSONObject(pathKey);
if (pathItem != null) {
processPathItemSmart(pathKey, pathItem);
}
}
}
private void processPathItemSmart(String pathKey, JSONObject pathItem) {
// 检查所有HTTP方法
Arrays.asList("get", "post", "put", "delete", "patch").forEach(method -> {
JSONObject operation = pathItem.getJSONObject(method);
if (operation != null) {
smartProcessOperation(operation, pathKey, method);
}
});
}
private void smartProcessOperation(JSONObject operation, String pathKey, String method) {
String requestType = "params"; // 默认为params
String contentType = null;
// 1. 强制指定的formdata路径
if (FORCE_FORM_DATA_PATHS.contains(pathKey)) {
requestType = "formdata";
contentType = "multipart/form-data";
convertToFormData(operation);
}
// 2. 检查是否有 requestBody (对应 @RequestBody)
else {
JSONObject requestBody = operation.getJSONObject("requestBody");
if (requestBody != null) {
JSONObject content = requestBody.getJSONObject("content");
if (content != null) {
// 检查各种 content-type
if (content.containsKey("application/json")) {
// @RequestBody + JSON
requestType = "json";
contentType = "application/json";
}
else if (content.containsKey("application/x-www-form-urlencoded")) {
// 表单数据 -> 转为 formdata
requestType = "formdata";
contentType = "multipart/form-data";
convertFormUrlencodedToFormData(content);
}
else if (content.containsKey("multipart/form-data")) {
// 已经是 formdata
requestType = "formdata";
contentType = "multipart/form-data";
}
// 检查是否包含文件上传
else if (hasFileUpload(content) || isFileUploadPath(pathKey)) {
requestType = "formdata";
contentType = "multipart/form-data";
convertToFormData(operation);
}
}
}
// 3. 没有 requestBody,检查是否有查询参数
else {
JSONArray parameters = operation.getJSONArray("parameters");
if (parameters != null) {
boolean hasQueryParams = false;
for (int i = 0; i < parameters.size(); i++) {
JSONObject param = parameters.getJSONObject(i);
if (param != null && "query".equals(param.getString("in"))) {
hasQueryParams = true;
break;
}
}
if (hasQueryParams) {
requestType = "params";
}
}
// 4. 根据路径和方法推断
if (isFileUploadPath(pathKey)) {
requestType = "formdata";
contentType = "multipart/form-data";
} else if ("post".equals(method) || "put".equals(method) || "patch".equals(method)) {
// POST/PUT/PATCH 但没有 requestBody,可能是表单提交
requestType = "formdata";
contentType = "multipart/form-data";
}
}
}
// 添加自定义扩展字段
operation.put("x-request-type", requestType);
if (contentType != null) {
operation.put("x-content-type", contentType);
}
// 🆕 自动获取Controller包路径
String controllerClass = getControllerClassAuto(pathKey);
if (controllerClass != null) {
operation.put("x-package", controllerClass);
}
}
/**
* 自动获取Controller类路径(从缓存的映射中查找)
*/
private String getControllerClassAuto(String pathKey) {
// 1. 直接匹配
String controllerClass = pathToControllerMap.get(pathKey);
if (controllerClass != null) {
return controllerClass;
}
// 2. 模糊匹配(处理路径变量的情况)
for (Map.Entry<String, String> entry : pathToControllerMap.entrySet()) {
String mappedPath = entry.getKey();
String mappedController = entry.getValue();
// 检查是否是路径变量匹配
if (isPathMatch(pathKey, mappedPath)) {
return mappedController;
}
}
// 3. 前缀匹配
String longestMatch = "";
String bestController = null;
for (Map.Entry<String, String> entry : pathToControllerMap.entrySet()) {
String mappedPath = entry.getKey();
String mappedController = entry.getValue();
// 去除路径变量后进行前缀匹配
String cleanMappedPath = cleanPathPattern(mappedPath);
String cleanPathKey = cleanPathPattern(pathKey);
if (cleanPathKey.startsWith(cleanMappedPath) && cleanMappedPath.length() > longestMatch.length()) {
longestMatch = cleanMappedPath;
bestController = mappedController;
}
}
return bestController;
}
/**
* 检查路径是否匹配(支持路径变量)
*/
private boolean isPathMatch(String actualPath, String patternPath) {
// 简单的路径变量匹配
String[] actualParts = actualPath.split("/");
String[] patternParts = patternPath.split("/");
if (actualParts.length != patternParts.length) {
return false;
}
for (int i = 0; i < actualParts.length; i++) {
String actualPart = actualParts[i];
String patternPart = patternParts[i];
// 如果是路径变量,跳过
if (patternPart.startsWith("{") && patternPart.endsWith("}")) {
continue;
}
// 必须完全匹配
if (!actualPart.equals(patternPart)) {
return false;
}
}
return true;
}
// ... 其他方法保持不变 ...
/**
* 将 application/x-www-form-urlencoded 转换为 multipart/form-data
*/
private void convertFormUrlencodedToFormData(JSONObject content) {
if (content.containsKey("application/x-www-form-urlencoded")) {
Object formContent = content.get("application/x-www-form-urlencoded");
content.remove("application/x-www-form-urlencoded");
content.put("multipart/form-data", formContent);
}
}
/**
* 将 application/json 转换为 multipart/form-data
*/
private void convertToFormData(JSONObject operation) {
JSONObject requestBody = operation.getJSONObject("requestBody");
if (requestBody != null) {
JSONObject content = requestBody.getJSONObject("content");
if (content != null && content.containsKey("application/json")) {
Object jsonContent = content.get("application/json");
content.remove("application/json");
content.put("multipart/form-data", jsonContent);
}
}
}
/**
* 检查是否包含文件上传字段
*/
private boolean hasFileUpload(JSONObject content) {
// 检查各种 content-type 中是否有文件字段
for (String contentType : content.keySet()) {
JSONObject typeContent = content.getJSONObject(contentType);
if (typeContent != null) {
JSONObject schema = typeContent.getJSONObject("schema");
if (schema != null && checkSchemaForFiles(schema)) {
return true;
}
}
}
return false;
}
/**
* 检查 schema 中是否包含文件字段
*/
private boolean checkSchemaForFiles(JSONObject schema) {
JSONObject properties = schema.getJSONObject("properties");
if (properties != null) {
for (String fieldName : properties.keySet()) {
JSONObject field = properties.getJSONObject(fieldName);
if (field != null) {
// 检查是否为文件类型
if ("string".equals(field.getString("type")) && "binary".equals(field.getString("format"))) {
return true;
}
// 检查字段名是否包含文件相关关键词
// if (fieldName.toLowerCase().contains("file") ||
// fieldName.toLowerCase().contains("upload") ||
// fieldName.toLowerCase().contains("image") ||
// fieldName.toLowerCase().contains("document") ||
// fieldName.toLowerCase().contains("attachment")) {
// return true;
// }
// 检查数组类型的文件
if ("array".equals(field.getString("type"))) {
JSONObject items = field.getJSONObject("items");
if (items != null && "string".equals(items.getString("type")) && "binary".equals(items.getString("format"))) {
return true;
}
}
}
}
}
return false;
}
/**
* 根据路径判断是否为文件上传接口
*/
private boolean isFileUploadPath(String pathKey) {
return pathKey.contains("/upload") ||
pathKey.contains("/file") ||
pathKey.contains("/image") ||
pathKey.contains("/document") ||
pathKey.contains("/attachment");
}
}