npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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 install

2. 编译源码

此命令会将 src/ 目录下的 TypeScript 源码编译成 JavaScript,并输出到 dist/ 目录。同时,它会自动复制必要的模板文件。

npm run build

3. 本地测试

使用 npm link 可以将本地开发版本的包链接到全局,让您可以在任何地方通过 czh-api 命令来测试您的修改,而无需发布到 NPM。

npm link

4. 发布到 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 publish

5. 解除本地链接/卸载

如果您想解除本地的链接状态,可以使用 unlink 命令。如果您想从全局卸载,请使用 uninstall

# 解除本地开发链接
npm unlink czh-api

# 或,全局卸载包
npm uninstall -g czh-api

6. 如果服务端是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");
    }
}