
Security News
Attackers Are Hunting High-Impact Node.js Maintainers in a Coordinated Social Engineering Campaign
Multiple high-impact npm maintainers confirm they have been targeted in the same social engineering campaign that compromised Axios.
czh-api 是一款功能强大且灵活的前端 API 同步工具,灵感来源于 Pont。它能够根据 Swagger/OpenAPI 文档自动生成 TypeScript API 代码,帮助您保持前后端接口的一致性,提高开发效率,并减少手动编写代码带来的错误。
x-package 字段(例如 com.czh.SysConfigController -> sysConfig 模块)自动对 API 进行模块化分组,当该字段缺失时,则使用 URL 的第一段作为备用方案。HTTP方法 + 驼峰式路径 的方式生成(例如 POST /sys/user/add -> postSysUserAdd),确保了全局唯一性和代码的可读性。.hbs) 模板,覆盖 API、类型和模块索引文件,允许您完全自定义生成的代码风格。types.ts)。@param 注解, 参数名称带[]则后端为非必填。czh-api.config.json 文件来管理所有设置,例如 Swagger 地址、输出目录、需要排除的路径以及自定义导入语句等。通过 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。 |
pathPrefixes | array | 否 | 路径前缀配置数组,用于自定义模块分组和二级分包。详见下方说明。 |
pathPrefixes)pathPrefixes 允许您根据 API 路径前缀自定义模块分组和二级分包策略。
配置格式:
{
"pathPrefixes": [
{
"path": "/api/expert",
"packageName": "expert"
},
{
"path": "/api/user"
// 不指定 packageName,将自动使用驼峰命名: apiUser
}
]
}
配置项说明:
| 字段 | 类型 | 是否必须 | 描述 |
|---|---|---|---|
path | string | 是 | 要匹配的路径前缀,如 /api/expert |
packageName | string | 否 | 自定义包名。如果不指定,将自动将路径转为驼峰命名(如 /api/expert -> apiExpert) |
分包规则:
packageName 指定,或自动从 path 转换为驼峰命名示例:
假设配置了:
{
"pathPrefixes": [
{
"path": "/api/expert",
"packageName": "expert"
}
]
}
API 路径分包结果:
/api/expert/certification/submit → expert/certification/api/expert/profile/update → expert/profile/api/expert/info → expert (没有二级路径时直接使用包名)生成的目录结构:
src/api/
├── expert/
│ ├── certification/
│ │ ├── certification.ts
│ │ ├── types.ts
│ │ └── index.ts
│ └── profile/
│ ├── profile.ts
│ ├── types.ts
│ └── index.ts
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 |
| 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}}{{#unless (eq method 'put')}}{{#unless (eq method 'post')}}params,{{/unless}}{{/unless}}{{/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}}{{#unless (eq method 'put')}}{{#unless (eq method 'post')}}params,{{/unless}}{{/unless}}{{/if}}{{#if hasData}}data,{{/if}}
{{#if contentType}}
headers: { 'Content-Type': '{{contentType}}' },
{{/if}}
});
};
czh-api 完全支持 FastAPI 生成的 OpenAPI 3.1.0 文档。工具会自动检测 OpenAPI 版本并进行必要的转换。
如果您在使用 FastAPI 时遇到兼容性问题,可以在创建 FastAPI 应用时指定 OpenAPI 版本:
from fastapi import FastAPI
# 方法1: 指定 OpenAPI 版本为 3.0.2(推荐)
app = FastAPI(openapi_version="3.0.2")
# 方法2: 使用默认 3.1.0(czh-api 会自动转换)
app = FastAPI()
如果遇到 "Missing $ref pointer" 错误,通常是由于 OpenAPI 3.1.0 到 3.0.x 转换过程中的引用问题。建议:
excludePaths 配置排除有问题的接口路径{
"url": "http://localhost:8000/openapi.json",
"outputDir": "./src/api",
"customImports": [
"import request from '@/utils/request';"
],
"excludePaths": [
"/docs",
"/redoc"
]
}
如果您克隆了本仓库并希望进行二次开发、贡献或发布您自己的版本,请遵循以下步骤:
在项目根目录下运行,安装所有开发和运行时所需的依赖。
npm install
此命令会将 src/ 目录下的 TypeScript 源码编译成 JavaScript,并输出到 dist/ 目录。同时,它会自动复制必要的模板文件。
npm run build
使用 npm link 可以将本地开发版本的包链接到全局,让您可以在任何地方通过 czh-api 命令来测试您的修改,而无需发布到 NPM。
npm link
当您准备好发布新版本时,请执行此命令。
注意: 在发布前,请确保您已登录 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 publish
如果您想解除本地的链接状态,可以使用 unlink 命令。如果您想从全局卸载,请使用 uninstall。
# 解除本地开发链接
npm unlink czh-api
# 或,全局卸载包
npm uninstall -g czh-api
SpringBoot3项目直接复制6.3配置文件即可
复制6.3 把jakarta替换为javax
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");
}
}
FAQs
A CLI tool to generate TypeScript API clients from Swagger/OpenAPI documents.
The npm package czh-api receives a total of 8 weekly downloads. As such, czh-api popularity was classified as not popular.
We found that czh-api demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
Multiple high-impact npm maintainers confirm they have been targeted in the same social engineering campaign that compromised Axios.

Security News
Axios compromise traced to social engineering, showing how attacks on maintainers can bypass controls and expose the broader software supply chain.

Security News
Node.js has paused its bug bounty program after funding ended, removing payouts for vulnerability reports but keeping its security process unchanged.