Hoppscotch开源API调试:跨域、变量脚本与自托管实战
简介:Hoppscotch 是一套基于 Node.js 的开源 API 调试工具前端源码,面向开发人员、测试人员以及需要频繁验证接口的团队。它以 Vue 技术栈构建,提供直观的请求编辑界面,支持多种 HTTP 方法与 GraphQL 查询,可帮助使用者在联调阶段快速定位参数、鉴权与响应结构问题,减少对商业调试客户端的依赖。压缩包共收录 1376 个文件,约 5.28MB;其中 592 个 TypeScript 文件承担核心逻辑与类型定义,211 个 Vue 文件对应页面与交互组件,203 个 GraphQL 文件与 120 个 JSON 配置共同支撑接口描述与项目配置,另含 SVG 图标、SCSS 样式、Dockerfile、Caddyfile 与 Prisma 数据库脚本等部署配套内容。目前已有 914 人学习下载。借助完整目录结构与多环境 Caddyfile 示例,读者可据此梳理前后端分层、理解自托管部署方式,并在此基础上进行二次开发或接口调试流程优化。
1. 从一行 CORS 报错说起:这个开源 API 调试工具能接手哪些活
后端把接口甩过来,说“照着调一下”。你在浏览器里敲了个 POST,控制台直接一行红字:blocked by CORS policy。换工具、装客户端、找同事要 license,一圈下来接口还没发出去 —— 这种时候,Hoppscotch 这类开源 API 调试工具的价值就显出来了:网页打开就能发请求,桌面版基于 Tauri 打包,REST、GraphQL、WebSocket、SSE、Socket.IO、MQTT 都能覆盖,历史记录和集合存在本机,不强制注册账号。
它适合三类人:接口调试不想被客户端体积和授权绑住的前端和测试人员;需要把集合放进内网自托管、不能把请求内容发到第三方服务的团队;以及偶尔调一次 MQTT 或 WebSocket、不想为此再开一个软件的人。反过来,重度依赖复杂测试管理、把用例本身当作关键资产的团队,用它会觉得有点薄 —— 后面几章我会把这条边界说清楚。
2. 跨域这道坎:直连、拦截器、自建转发三条路的取舍
刚上手时最大的困惑不是怎么填参数,而是“同样的接口,在别的工具里好好的,在这里就是发不出去”。原因不在工具,在浏览器的安全模型。这一章把三种发请求方式的原理、适用场景和落地命令讲透,后面再谈变量和脚本才不容易蒙。
2.1 同源策略到底拦下了什么
Hoppscotch 的 Web 端本质是浏览器里的一个页面,它发出的请求走的是fetch,因此完整继承了同源策略。当你从hoppscotch.io或自己部署的http://10.0.8.21:3000去请求https://api.internal/v1/users时,只要协议、域名、端口任意一项不同,就是跨域。跨域并不必然失败,失败的是下面这几件事:
一是响应头里没有Access-Control-Allow-Origin,或者值不匹配当前页面的源,浏览器会丢弃响应体,控制台报 CORS。二是请求带了自定义头(比如X-Sign、Authorization之外的头部)、或者Content-Type是application/json,浏览器会先发一个OPTIONS预检;很多内网服务在网关层直接不给OPTIONS路由,返回 404 或 405,预检就挂了,真正的 POST 根本没发出去。三是响应带了Access-Control-Allow-Origin: *,但请求同时带 cookie,浏览器会认为这是不安全的组合,照样拦。
判断方法很简单:在浏览器的开发者工具 Network 面板里看有没有一条OPTIONS请求。有且状态码是 2xx,说明预检通了,问题在响应头;压根没出现OPTIONS但请求红了,说明是简单请求被响应头卡住;连请求记录都没有,那是被扩展或企业策略拦了,不在工具的责任范围内。
提示:
OPTIONS预检的结果会被浏览器缓存一段时间(Access-Control-Max-Age),改完后端响应头如果没生效,先清缓存或换个无痕窗口再试,别急着怀疑工具。
2.2 三种发请求姿势的对比与选型
Hoppscotch 给了三条绕开浏览器限制的路,代价各不相同,选错了会浪费很多时间。
| 方式 | 请求实际从哪发出 | 是否受同源策略约束 | 适用场景 | 主要代价 |
|---|---|---|---|---|
| 直连(默认) | 浏览器fetch | 受 | 对方已配好 CORS 的公网接口 | 无,最轻 |
| 拦截器 | 浏览器扩展或桌面版内置通道 | 不受 | 内网接口、需要带 Cookie 的调试 | 需要装桌面版或扩展,多一层授权 |
| 自建转发 | 你部署的服务端 | 不受 | 团队共用、CI 环境 | 要维护一个容器,转发端能看到的明文请求 |
| 桌面版直发 | 本地原生 HTTP 栈 | 不受 | 单机调试、大文件上传 | 需要安装,多端同步要额外配置 |
我一般的判断顺序是:先看对方接口有没有开 CORS,开了就直连,最省事;没开、又是临时调一两个接口,装桌面版开拦截器;如果整个团队天天调这套内网接口,或者要放进流水线跑,直接自建转发,一次性投入换长期省心。
这里有个容易被忽略的边界:拦截器解决的是“浏览器不让我发”,不是“服务器不认我”。证书、鉴权、IP 白名单这些问题,换哪种方式都绕不过去。
2.3 桌面版拦截器与自建转发端点的落地步骤
桌面版最省事,装完在设置里把请求方式从Direct切到Interceptor就行。切完之后你会发现,原本报 CORS 的接口通了,而且响应里的Set-Cookie也能正常落到本地 cookie jar 里 —— 这对需要维持会话的后台系统很有用。
团队场景我更推荐自建转发。Hoppscotch 官方提供的转发镜像可以单独跑,默认监听 9159:
# 起一个转发端点,供团队内所有人共用 docker run -d \ --name hopp-forwarder \ --restart unless-stopped \ -p 9159:9159 \ hoppscotch/proxyscotch:latest # 验证它是否活着:让它替你请求一个内网地址 curl -s "http://10.0.8.21:9159/?url=https://api.internal/v1/ping" | head -c 300第一条命令把容器放到 9159 端口,--restart unless-stopped保证机器重启后自动拉起,这在测试环境很关键,否则第二天同事会来问你“工具又坏了”。第二条命令是验证手段:转发端接受一个url参数,替你把请求发出去再把响应原样返回,如果这条 curl 能拿到正常的 JSON,说明转发链路本身没问题,剩下的就是让 Hoppscotch 指向它。
指向的位置在设置里的请求方式选项中,选自定义转发地址,填http://10.0.8.21:9159。填完建议随手发一个带Authorization头的请求验证,因为有些转发端在默认配置下会剥掉部分头部。
注意:转发端能看到完整的请求体和鉴权头。放在公网等于把凭据暴露给任何能访问这个端口的人,务必只在内网监听,或在网关层加访问控制。
3. 变量与脚本:把登录态、签名、断言串成一条链
跨域解决之后,第二个瓶颈是重复劳动:每次调接口都要手动贴 token、手动算时间戳、手动核对返回值对不对。Hoppscotch 的变量体系和脚本沙箱就是用来消灭这些动作的,用顺了能把一次联调从半小时压到几分钟。
3.1 四层变量的作用域与覆盖顺序
变量踩坑十有八九是作用域没理清。它大致分四层,从宽到窄依次是全局变量、环境变量、集合变量、请求级变量,同名时窄的覆盖宽的。请求里用双尖括号引用,比如<<host>>/v1/users,环境切换时整条 URL 跟着变。
| 层级 | 典型用途 | 生命周期 | 常见误用 |
|---|---|---|---|
| 全局 | 跨环境的常量,如userAgent | 一直存在 | 把环境相关的域名放这里 |
| 环境 | host、appKey、账号密码 | 跟环境走,可导出 | 忘记在右上角选中环境 |
| 集合 | 整套接口共享的路径前缀 | 跟集合走 | 和请求级变量重名,排查半天 |
| 请求级 | 一次性参数 | 单次请求 | 想用它跨请求传 token,传不过去 |
第二列和第四列是重点。我见过最常见的翻车是:脚本里明明pw.env.set("TOKEN", ...)写成功了,下一个请求还是 401。原因是那个请求的Authorization头里引用的是请求级变量,脚本写的是环境变量,两者根本不是同一个槽位。排查时把鼠标悬停在变量引用上,看它解析出来的实际值,一眼就能定位。
另外,带secret标记的变量在脚本里读取要留意权限,不同版本的沙箱策略不一样,取不到值时先怀疑这个,而不是怀疑脚本写错了。
3.2 预请求脚本:登录拿 token 与参数签名
预请求脚本在每个请求发出前执行,沙箱里挂了一个全局对象pw,环境读写、请求构造、控制台输出都在它身上。下面这段是“先用账号密码换 token,再写回环境变量”的典型写法:
// 预请求脚本:调登录接口换 token,写回环境变量供后续请求引用 const loginUrl = pw.env.get("host") + "/v1/auth/login"; // pw.env.resolve 会把 <<>> 占位符解析成真实值,避免拼串时拿到字面量 const body = JSON.stringify({ username: pw.env.get("username"), password: pw.env.get("password"), }); const res = await pw.sendRequest({ method: "POST", url: loginUrl, headers: { "Content-Type": "application/json" }, body, }); if (res.status === 200 && res.body && res.body.token) { // 写回环境变量,后续请求用 <<TOKEN>> 引用即可 pw.env.set("TOKEN", res.body.token); pw.env.set("TOKEN_EXPIRE_AT", String(Date.now() + res.body.expiresIn * 1000)); console.log("token refreshed, expire at " + pw.env.get("TOKEN_EXPIRE_AT")); } else { // 主动抛错,让这个请求直接失败,而不是带着空 token 继续往下跑 throw new Error("login failed, status=" + res.status); }第一行的pw.env.resolve是整段的关键:直接拿pw.env.get("host")拼串在某些版本里得到的是原始字符串,resolve才会做占位符替换。中间的await pw.sendRequest是沙箱提供的发请求能力,参数结构和普通 HTTP 请求一致,注意它不受页面同源策略影响的范围有限,跨域时仍然取决于你的转发配置。最后那个throw是刻意加的:脚本报错会让请求中断,这比带着空 token 往下跑、最后在一堆 401 里找原因要好得多。
如果接口需要 HMAC 签名,思路一样,只是在发请求前多算一步。需要注意的是加密库:沙箱里能不能直接拿到CryptoJS取决于版本,稳妥写法是先探一下:
// 签名前先探测沙箱是否挂了加密库,避免直接 ReferenceError const ts = String(Date.now()); const nonce = Math.random().toString(36).slice(2, 10); const raw = pw.env.get("appKey") + ts + nonce; let sign; if (typeof CryptoJS !== "undefined") { sign = CryptoJS.HmacSHA256(raw, pw.env.get("appSecret")).toString(); } else { // 兜底:沙箱没有加密库时,走一个内部签名接口,把计算挪到服务端 const r = await pw.sendRequest({ method: "POST", url: pw.env.get("host") + "/v1/sign", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ raw }), }); sign = r.body.sign; } pw.env.set("X_TS", ts); pw.env.set("X_NONCE", nonce); pw.env.set("X_SIGN", sign);这段的取舍很现实:自己在前端算签名意味着密钥要下发到客户端,仅适合内网调试;生产链路应该走兜底那条,把签名收在服务端。typeof CryptoJS !== "undefined"这个探测看起来啰嗦,但能省掉一次“升级镜像后脚本全挂”的事故。
3.3 测试脚本与断言:让集合自己判断成败
预请求脚本负责“发得出去”,测试脚本负责“发得对不对”。它在响应返回之后执行,pw.response里是状态码、头部和已解析的响应体。
// 测试脚本:断言状态码与关键字段,并把耗时的阈值卡住 pw.test("订单查询返回 200", () => { pw.expect(pw.response.status).toBe(200); }); pw.test("响应体结构正确", () => { const body = pw.response.body; // 字段存在性优先于值断言,结构错了先报这一条,定位更快 pw.expect(body).toHaveProperty("data.list"); pw.expect(body.data.list).toBeType("array"); pw.expect(body.data.list.length).toBeGreaterThan(0); }); pw.test("响应时间在 800ms 以内", () => { pw.expect(pw.response.time).toBeLessThan(800); });pw.test的好处是分组,一个请求里跑多条断言时,报告里能精确告诉你是结构错了还是性能不达标。断言链的风格接近 chai,toBe、toHaveProperty、toBeType、toBeGreaterThan这些够覆盖日常场景。pw.response.time这类元数据容易被忽略,但把它卡进断言之后,接口慢化的趋势会在回归里自动暴露,比等到用户投诉强。
写断言时有个习惯我建议养成:先断言结构,再断言值。原因是字段改名、层级调整这类破坏性变更,如果先断言值,报错信息会是一句莫名其妙的undefined !== "ok",而结构断言会直接告诉你哪个字段不见了。
4. 自托管一套团队实例:Docker Compose 里五个组件的分工
公网版够个人用,但一旦涉及内网接口、账号统一、历史数据留存,自托管就是必选项。这一章讲清楚每个容器在干什么、变量该怎么填,以及升级时最容易被忽略的迁移动作。
4.1 组件拆解与端口规划
自托管版本的镜像里其实揉了好几个角色,拆开看就五个部分:前端静态资源、后端接口(GraphQL 与 REST)、实时通道(WebSocket,负责协作同步)、PostgreSQL(账号、集合、历史)、Redis(会话、队列、在线状态)。邮件服务是可选的第六个,但没有它就拿不到登录链接。
| 组件 | 作用 | 挂掉之后的表现 |
|---|---|---|
| 前端 | 提供页面与静态资源 | 页面打不开,接口本身没事 |
| 后端 | 账号、集合、团队接口 | 页面能开,登录与同步全失败 |
| 实时通道 | 协作编辑、状态推送 | 单人可用,多人协作静默失效 |
| PostgreSQL | 持久化账号与集合 | 登录报 500,日志里是连接超时 |
| Redis | 会话与队列 | 登录态反复失效,验证码收不到 |
端口规划上,容器通常暴露三个:前端页面、管理后台、后端接口。具体端口号在不同镜像版本里调整过,我的做法是先用docker inspect看镜像声明了哪些端口,再决定映射,而不是照着某篇旧文章抄。内网部署建议把三个端口都放在同一个网关后面,前端与接口同源,能省掉一批跨域配置。
4.2 compose 编排与关键环境变量
下面这份编排是能跑起来的最小骨架,变量值全部要换成自己的:
services: web: image: hoppscotch/hoppscotch:latest restart: unless-stopped ports: - "3000:3000" # 前端页面 - "3170:3170" # 管理后台 environment: # 数据层:库名与账号要和下面的 db 服务对齐 - DATABASE_URL=postgresql://hopp:hopp_pwd@db:5432/hoppscotch - REDIS_URL=redis://cache:6379 # 会话与令牌:两个 secret 必须换成长随机串,且不要复用 - JWT_SECRET=replace_with_64_char_random - SESSION_SECRET=replace_with_another_random - TOKEN_SALT_COMPLEXITY=10 - MAGIC_LINK_TTL=1800 # 对外地址:填错会导致登录邮件里的链接指向 localhost - VITE_BASE_URL=http://10.0.8.21:3000 - VITE_BACKEND_GQL_URL=http://10.0.8.21:3000/graphql - VITE_BACKEND_WS_URL=ws://10.0.8.21:3000/graphql - VITE_BACKEND_API_URL=http://10.0.8.21:3000/v1 # 邮件:不配就发不出登录链接,只能靠管理员手动干预 - MAILER_SMTP_URL=smtp://mailer:secret@smtp.corp.local:587 - MAILER_ADDRESS_FROM=hoppscotch@corp.local depends_on: - db - cache db: image: postgres:15-alpine restart: unless-stopped environment: - POSTGRES_USER=hopp - POSTGRES_PASSWORD=hopp_pwd - POSTGRES_DB=hoppscotch volumes: - ./pgdata:/var/lib/postgresql/data cache: image: redis:7-alpine restart: unless-stopped volumes: - ./redisdata:/dataVITE_开头那几个变量是编译进前端的,改完必须重建容器才生效,docker restart不管用 —— 这是新手最容易卡住的地方。MAGIC_LINK_TTL单位是秒,1800 表示链接半小时内有效,内网邮件投递慢的话可以放宽到 3600。TOKEN_SALT_COMPLEXITY是口令哈希的代价因子,调高更安全但登录会变慢,10 是个折中值。
volumes那两行是保命的。没有持久化卷,docker compose down一次,几个月的集合和历史全没。我一般会在部署脚本里加一句检查,确认./pgdata目录非空才允许执行 down。
4.3 HTTPS、子路径与邮件登录
内网也建议上 HTTPS,因为部分浏览器 API(剪贴板、Cookie 的Secure属性)在 http 下行为不一致,调试时会得出错误结论。用 Nginx 做 TLS 终结和路径转发,把 3000 端口的页面挂到https://hopp.corp.local/下面:
server { listen 443 ssl; server_name hopp.corp.local; ssl_certificate /etc/nginx/certs/hopp.crt; ssl_certificate_key /etc/nginx/certs/hopp.key; location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; # WebSocket 升级必须带这两个头,否则协作与订阅功能静默失效 proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } }X-Forwarded-Proto这一行不能省,后端要靠它判断外部是 https 还是 http,进而决定 cookie 要不要加Secure、邮件链接用哪个协议。少了它,典型现象是登录链接点进去一直跳回登录页。Upgrade和Connection两个头是 WebSocket 的握手前提,漏掉的表现非常隐蔽:页面一切正常,只是 GraphQL 订阅和实时协作永远收不到消息。
如果要部署在子路径下(比如https://corp.local/tools/hopp/),除了 Nginx 的location要相应改写,还得打开ENABLE_SUBPATH_BASED_ACCESS并同时调整VITE_BASE_URL,两者缺一,静态资源就会 404。
致于邮件,MAILER_SMTP_URL的格式是smtp://用户:密码@主机:端口,密码里有特殊字符要做 URL 编码,@写成%40、:写成%3A。这个细节坑过不少人:配置看着没问题,日志里就是认证失败。
5. 排查清单:五类翻车现场的现象、成因与处置
下面这五条都是我在实际部署和日常使用里真碰过的,按现象、原因、解决三段写,遇到问题时可以直接对号入座。
5.1 跨域与转发相关的两个坑
第一条:开了拦截器,请求还是红的。
现象:设置里已经切到拦截器模式,发请求依旧报 CORS,但换成桌面版就好了。原因:浏览器扩展的拦截器只对授权过的域名生效,新加的内网域名没在扩展的允许列表里;或者切模式之后没有刷新页面,旧标签页还跑在直连模式上。解决:先在无痕窗口重开一次,排除页面状态问题;确认扩展对该域名有权限;如果扩展装在企业策略受限的环境里,直接换桌面版,别在权限上耗时间。
第二条:转发端点通了,但带鉴权的请求返回 401。
现象:用 curl 直接请求接口正常,经过转发端点就变成 401 或者响应头缺失。原因:转发端在拼装上游请求时会重组头部,部分自定义头(尤其带下划线的,如X_Sign)会被中间件丢弃;另外Host头被改写成转发端自己的地址,一些按 Host 做校验的服务会直接拒绝。解决:先在转发端前面抓一次包,对比进出请求的头部差异;把自定义头改成中划线命名;如果服务端强校验 Host,那就不要用转发,改用桌面版直发。
5.2 变量与脚本相关的两个坑
第三条:URL 里的<<host>>原样发出去了。
现象:请求地址栏显示正常,实际发出去的路径里带着双尖括号字面量。原因:右上角没有选中任何环境,占位符无从解析,工具就按原样发送;或者变量定义在集合里,但当前请求不在那个集合下。解决:先确认环境选择器不是空的;再检查变量所在的层级是否覆盖到当前请求;实在不确定,就在预请求脚本里console.log(pw.env.get("host"))打一行,比在界面上猜快得多。
第四条:导入的 Postman 集合,脚本一条都没跑。
现象:集合导入成功,请求能发,但原本的测试全被跳过,报告里全是绿的。原因:Postman 的脚本用的是pm.*命名空间,兼容层只覆盖了常用的一部分,pm.sendRequest、pm.collectionVariables、pm.execution这些在沙箱里可能不存在;脚本抛错被吞掉时,表现就是“静默跳过”。解决:先挑一个集合跑一遍 CLI,看有没有脚本错误输出;把pm.test、pm.expect批量替换成pw.test、pw.expect;涉及跨请求变量的,改写成pw.env.set。这件事没有捷径,只能逐个改,但改完之后这套集合在网页端和流水线里行为一致,值得。
5.3 自托管与升级相关的一个坑
第五条:升级镜像后登录 500,日志里是表不存在。
现象:docker compose pull之后重启,页面能开,登录直接 500。原因:新版本带了数据库结构变更,需要执行迁移;很多人只换了镜像没跑迁移,代码在查一个还不存在的字段。解决:升级前先备份pgdata,再按镜像文档执行迁移命令;如果迁移脚本要求停服,就老老实实停,别一边跑业务一边改表结构。另外VITE_*变量在升级后可能改名,重建前用docker compose config核对一遍实际生效的值,比重启完再排查省事。
注意:自托管实例的账号体系、团队协作能力在不同版本和授权下开关不一样,落地前先看镜像自带的说明文件,别默认所有功能都开。
6. 接进流水线:用 CLI 把集合跑成一次回归
网页端点得再熟,也只是单次调试。真正省时间的是把集合变成可重复执行的回归:接口改了,跑一遍,红了就是有问题。Hoppscotch 的 CLI 入口命令是hopp,通过npx调用,读取的是从界面导出的集合 JSON 和环境 JSON。
先把集合和环境各导出一份,放到仓库的tests/目录下。环境文件里不要放真实口令,把敏感值留空,在 CI 里用环境变量注入。
# 在 CI 里跑一遍集合,输出 json 报告供后续解析 npx @hoppscotch/cli test ./tests/collection.json \ -e ./tests/staging.json \ --reporter json \ --output ./reports/hopp.json \ --delay 200-e指定环境文件,缺了它所有<<>>占位符都会原样发送,报错信息会非常迷惑。--delay 200是每个请求之间停 200 毫秒,看着不起眼,但对于带频控的接口,不加这个参数会出现“本地跑全绿、CI 跑一半 429”的玄学现象 —— 这个坑我踩过一次,之后固定加。--reporter和--output的参数名在版本间有过调整,落到自己环境前先跑一次--help确认,别照抄。
报告出来之后,我一般再做一层过滤,只把失败的用例打回给提交者:
# 从 json 报告里捞出失败的用例名,退出码非 0 时打印 jq -r '.failures[]? | "FAIL: \(.name) -> \(.error.message)"' ./reports/hopp.json 2>/dev/null || true # 用退出码驱动流水线,非 0 即中断 npx @hoppscotch/cli test ./tests/collection.json -e ./tests/staging.json > /dev/null 2>&1 echo "exit=$?"jq那行的字段路径跟你用的报告格式有关,第一次接的时候把报告完整打开看一遍结构再写。第二段是关键:CLI 的退出码就是流水线的判断依据,有用例失败就非 0,set -e环境下自动中断,不需要额外写解析逻辑。
还有一个容易忽略的用法:数据驱动。把多组参数写成一个 JSON 数组,配合迭代参数,同一套断言能覆盖十几组边界值,比自己写循环稳。具体参数名同样以--help为准,不同版本叫法不一致。
最后说个习惯。这套流水线刚接上的时候,我图省事,环境文件里的host直接写死成预发地址,本地和 CI 共用一份。结果某天有人把预发域名切到新集群,所有本地调试的请求静默打到旧环境上,查了两小时才发现。从那以后我每次导出环境文件,都会强制走一遍docker compose config那种级别的核对:把文件里的每一个<<>>占位符打印出来,确认它解析到的值确实是我以为的那个,才允许提交。这套动作多花两分钟,能省掉一整个下午。希望帮到你。
本文还有配套的精品资源,点击获取