feat: OpsxAPI v1.0 全协议接口工作台

- 六协议调试:HTTP/GraphQL/WebSocket/SSE/Socket.IO/MQTT
- 集合树/多标签/多环境变量/历史记录,localStorage 持久化
- 导入:OpenAPI 2/3/3.1、Postman、Apifox、Apipost、cURL、HAR、
  Insomnia、Hoppscotch、Markdown(自动识别,21 个单测覆盖)
- 导出:OpenAPI 3.0 JSON/YAML、Postman v2.1、Markdown 文档、
  全量备份;单请求生成 cURL/fetch/Python/Go 代码
- Gin 服务:HTTP 代理、NDJSON 流式代理(SSE 透传)、
  WebSocket 隧道(浏览器自定义握手 Header)
- 内置产品官网 /home 与用户文档站 /docs(SSR,随二进制交付)
- lucide 图标体系 + 品牌标识(Logo/favicon),明暗双主题
- 环境变量:彩色徽标切换器、管理弹窗、{{ 自动补全
This commit is contained in:
logan committed 2026-09-18 00:16:59 +08:00
commit dec66db5aa
182 files changed
+23377

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
{
"name": "@opsxapi/core",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"js-yaml": "^4.1.1"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"typescript": "^5.8.2",
"vitest": "^3.0.9"
}
}
+184
View File
@@ -0,0 +1,184 @@
/**
* 代码生成:JavaScript fetch / Python requests / Go net/http。
* 变量保留 {{var}} 原样(用户可自行替换)。
*/
import type { GraphqlRequestData, HttpRequestData } from "../model";
import { authHeader, buildUrlWithQuery, exportCurl } from "./curl";
export type CodeTarget = "curl" | "fetch" | "python" | "go";
export function generateCode(
target: CodeTarget,
data: HttpRequestData | GraphqlRequestData,
): string {
if (data.kind === "graphql") return generateGraphql(target, data);
switch (target) {
case "curl":
return generateCurlHttp(data);
case "fetch":
return generateFetch(data);
case "python":
return generatePython(data);
case "go":
return generateGo(data);
}
}
/* ---------------- HTTP ---------------- */
function commonHeaders(data: HttpRequestData): [string, string][] {
const out: [string, string][] = [];
const auth = authHeader(data);
if (auth) out.push([auth.key, auth.value]);
if (data.auth.mode === "apikey" && data.auth.apiKeyName && data.auth.apiKeyValue && data.auth.apiKeyIn !== "query") {
out.push([data.auth.apiKeyName, data.auth.apiKeyValue]);
}
for (const h of data.headers) {
if (!h.enabled || !h.key) continue;
if (["authorization", "cookie"].includes(h.key.toLowerCase())) continue;
out.push([h.key, h.value]);
}
const cookie = data.cookies.filter((c) => c.enabled && c.key).map((c) => `${c.key}=${c.value}`).join("; ");
if (cookie) out.push(["Cookie", cookie]);
if (data.bodyMode === "json" && !out.some(([k]) => k.toLowerCase() === "content-type")) {
out.push(["Content-Type", "application/json"]);
}
if (data.bodyMode === "urlencoded" && !out.some(([k]) => k.toLowerCase() === "content-type")) {
out.push(["Content-Type", "application/x-www-form-urlencoded"]);
}
return out;
}
function bodyPayload(data: HttpRequestData): { mode: "raw"; raw: string } | { mode: "urlencoded"; pairs: [string, string][] } | { mode: "form"; pairs: [string, string][] } | null {
if (data.bodyMode === "urlencoded") {
return { mode: "urlencoded", pairs: data.bodyForm.filter((f) => f.enabled && f.key).map((f) => [f.key, f.value]) };
}
if (data.bodyMode === "form-data") {
return { mode: "form", pairs: data.bodyForm.filter((f) => f.enabled && f.key).map((f) => [f.key, f.value]) };
}
if (data.bodyMode !== "none" && data.bodyRaw) return { mode: "raw", raw: data.bodyRaw };
return null;
}
function generateCurlHttp(data: HttpRequestData): string {
return exportCurl(data);
}
function generateFetch(data: HttpRequestData): string {
const headers = commonHeaders(data);
const body = bodyPayload(data);
let bodyExpr = "";
if (body?.mode === "raw") bodyExpr = ` body: ${JSON.stringify(body.raw)},\n`;
else if (body?.mode === "urlencoded") bodyExpr = ` body: new URLSearchParams(${JSON.stringify(Object.fromEntries(body.pairs))}).toString(),\n`;
else if (body?.mode === "form") bodyExpr = ` body: new FormData(${JSON.stringify(Object.fromEntries(body.pairs))} as any),\n`;
return `const response = await fetch(${JSON.stringify(buildUrlWithQuery(data))}, {
method: ${JSON.stringify(data.method)},
headers: ${JSON.stringify(Object.fromEntries(headers), null, 2).replace(/\n/g, "\n ")},
${bodyExpr}});
if (!response.ok) throw new Error(\`HTTP \${response.status}\`);
const data = await response.json();
console.log(data);
`;
}
function generatePython(data: HttpRequestData): string {
const headers = commonHeaders(data);
const body = bodyPayload(data);
let bodyLine = "";
if (body?.mode === "raw") {
const asJson = data.bodyMode === "json";
bodyLine = asJson ? `data = json.dumps(${body.raw})` : `data = ${JSON.stringify(body.raw)}`;
} else if (body?.mode === "urlencoded") {
bodyLine = `data = ${JSON.stringify(Object.fromEntries(body.pairs))}`;
} else if (body?.mode === "form") {
bodyLine = `data = ${JSON.stringify(Object.fromEntries(body.pairs))}`;
}
const needsJson = data.bodyMode === "json";
return `import requests${needsJson ? "\nimport json" : ""}
url = ${JSON.stringify(buildUrlWithQuery(data))}
headers = ${JSON.stringify(Object.fromEntries(headers), null, 4).replace(/\n/g, "\n")}
${bodyLine ? bodyLine + "\n" : ""}response = requests.${pyMethod(data.method)}(url, headers=headers${bodyLine ? ", data=data" : ""})
print(response.status_code)
print(response.text)
`;
}
function pyMethod(method: string): string {
return method.toLowerCase();
}
function generateGo(data: HttpRequestData): string {
const headers = commonHeaders(data);
const body = bodyPayload(data);
const bodyLiteral = body?.mode === "raw" ? body.raw : body?.mode === "urlencoded" || body?.mode === "form" ? new URLSearchParams(body.pairs).toString() : "";
const payload = bodyLiteral ? JSON.stringify(bodyLiteral) : `""`;
const headerLines = headers.map(([k, v]) => `\treq.Header.Set(${JSON.stringify(k)}, ${JSON.stringify(v)})`).join("\n");
return `package main
import (
"fmt"
"io"
"net/http"
"strings"
)
func main() {
payload := strings.NewReader(${payload})
req, _ := http.NewRequest(${JSON.stringify(data.method)}, ${JSON.stringify(buildUrlWithQuery(data))}, payload)
${headerLines}
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res.Status)
fmt.Println(string(body))
}
`;
}
/* ---------------- GraphQL ---------------- */
function generateGraphql(target: CodeTarget, data: GraphqlRequestData): string {
const gqlBody = JSON.stringify({ query: data.query, variables: safeParse(data.variables) }, null, 2);
const httpLike: HttpRequestData = {
kind: "http",
method: "POST",
url: data.url,
query: [],
headers: [...data.headers.filter((h) => h.enabled && h.key).map((h) => ({ ...h })), { id: "ct", key: "Content-Type", value: "application/json", enabled: true }],
cookies: [],
auth: data.auth,
bodyMode: "json",
bodyRaw: gqlBody,
bodyForm: [],
};
switch (target) {
case "curl":
return generateCurlHttp(httpLike);
case "fetch": {
const code = generateFetch(httpLike);
return `// GraphQL → POST { query, variables }\n${code}`;
}
case "python": {
const code = generatePython(httpLike);
return `# GraphQL → POST { query, variables }\n${code}`;
}
case "go":
return generateGo(httpLike);
}
}
function safeParse(s: string): unknown {
try {
return JSON.parse(s);
} catch {
return {};
}
}
+76
View File
@@ -0,0 +1,76 @@
/**
* cURL 命令导出(保留 {{var}} 变量原样)。
*/
import type { HttpRequestData } from "../model";
export function shellQuote(s: string): string {
if (/^[A-Za-z0-9_\-:/.@%^=+,?&]+$/.test(s) && !s.includes(" ")) return s;
return `'${s.replaceAll("'", "'\\''")}'`;
}
export function buildUrlWithQuery(data: HttpRequestData): string {
const enabled = data.query.filter((q) => q.enabled && q.key);
if (!enabled.length) return data.url;
const qs = enabled.map((q) => `${encodeURIComponent(q.key)}=${encodeURIComponent(q.value)}`).join("&");
return data.url.includes("?") ? `${data.url}&${qs}` : `${data.url}?${qs}`;
}
export function authHeader(data: HttpRequestData): { key: string; value: string } | null {
if (data.auth.mode === "bearer" && data.auth.bearer) {
return { key: "Authorization", value: `Bearer ${data.auth.bearer}` };
}
if (data.auth.mode === "basic") {
const raw = `${data.auth.basicUser ?? ""}:${data.auth.basicPass ?? ""}`;
try {
return { key: "Authorization", value: `Basic ${btoa(raw)}` };
} catch {
return null;
}
}
return null;
}
export function exportCurl(data: HttpRequestData, name?: string): string {
const lines: string[] = [];
const url = buildUrlWithQuery(data);
const parts = [`curl -X ${data.method} ${shellQuote(url)}`];
if (data.auth.mode === "apikey" && data.auth.apiKeyName && data.auth.apiKeyValue) {
if (data.auth.apiKeyIn === "query") {
parts[0] = parts[0].replace(shellQuote(url), shellQuote(`${url}${url.includes("?") ? "&" : "?"}${encodeURIComponent(data.auth.apiKeyName)}=${encodeURIComponent(data.auth.apiKeyValue)}`));
} else {
parts.push(` -H ${shellQuote(`${data.auth.apiKeyName}: ${data.auth.apiKeyValue}`)}`);
}
}
const auth = authHeader(data);
if (auth) parts.push(` -H ${shellQuote(`${auth.key}: ${auth.value}`)}`);
for (const h of data.headers) {
if (!h.enabled || !h.key) continue;
if (["authorization", "cookie"].includes(h.key.toLowerCase())) continue;
parts.push(` -H ${shellQuote(`${h.key}: ${h.value}`)}`);
}
const cookie = data.cookies.filter((c) => c.enabled && c.key).map((c) => `${c.key}=${c.value}`).join("; ");
if (cookie) parts.push(` -b ${shellQuote(cookie)}`);
const hasJsonHeader = data.headers.some((h) => h.enabled && h.key.toLowerCase() === "content-type" && h.value.includes("json"));
if (data.bodyMode === "urlencoded") {
if (!hasJsonHeader) parts.push(` -H ${shellQuote("Content-Type: application/x-www-form-urlencoded")}`);
const body = data.bodyForm.filter((b) => b.enabled && b.key).map((b) => `${encodeURIComponent(b.key)}=${encodeURIComponent(b.value)}`).join("&");
if (body) parts.push(` --data-raw ${shellQuote(body)}`);
} else if (data.bodyMode === "form-data") {
if (!hasJsonHeader) parts.push(` -H ${shellQuote("Content-Type: multipart/form-data")}`);
for (const f of data.bodyForm) {
if (f.enabled && f.key) parts.push(` -F ${shellQuote(`${f.key}=${f.value}`)}`);
}
} else if (data.bodyMode !== "none" && data.bodyRaw) {
if (data.bodyMode === "json" && !hasJsonHeader) parts.push(` -H ${shellQuote("Content-Type: application/json")}`);
if (data.bodyMode === "xml" && !hasJsonHeader) parts.push(` -H ${shellQuote("Content-Type: application/xml")}`);
parts.push(` --data-raw ${shellQuote(data.bodyRaw)}`);
}
lines.push(parts.join(" \\\n"));
return lines.join("\n");
}
+61
View File
@@ -0,0 +1,61 @@
/**
* 导出入口 + 备份序列化。
*/
import { dump as yamlDump } from "js-yaml";
import type { Collection, Environment, WorkspaceData } from "../model";
import { exportOpenApiWithFolders, type OpenApiExportOptions } from "./openapi";
import { exportPostman } from "./postman";
import { exportMarkdown } from "./markdown";
export type ExportFormat = "openapi-json" | "openapi-yaml" | "postman" | "markdown" | "single-curl" | "backup";
export function exportCollectionAs(
format: ExportFormat,
collection: Collection,
options: OpenApiExportOptions = {},
): string {
switch (format) {
case "openapi-json":
return JSON.stringify(exportOpenApiWithFolders(collection, options), null, 2);
case "openapi-yaml":
return yamlDump(exportOpenApiWithFolders(collection, options), { lineWidth: 120 });
case "postman":
return JSON.stringify(exportPostman(collection), null, 2);
case "markdown":
return exportMarkdown(collection);
default:
throw new Error(`格式 ${format} 不适用于集合导出`);
}
}
export function downloadText(filename: string, text: string, mime = "application/octet-stream"): void {
const blob = new Blob([text], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
export function exportBackup(
collections: Collection[],
environments: Environment[],
): string {
return JSON.stringify(
{
__opsxapi__: "backup",
version: 2,
exportedAt: new Date().toISOString(),
collections,
environments,
},
null,
2,
);
}
export { exportOpenApiWithFolders, exportPostman, exportMarkdown };
export type { OpenApiExportOptions } from "./openapi";
+95
View File
@@ -0,0 +1,95 @@
/**
* 集合 → Markdown 接口文档。
*/
import type { Collection, CollectionNode, HttpRequestData } from "../model";
import { buildUrlWithQuery } from "./curl";
export function exportMarkdown(collection: Collection): string {
const lines: string[] = [];
lines.push(`# ${collection.name}`, "");
if (collection.description) lines.push(collection.description, "");
lines.push("> 由 OpsxAPI 导出 · " + new Date().toLocaleString("zh-CN"), "");
const toc: string[] = [];
const visitToc = (nodes: CollectionNode[], prefix: string) => {
for (const node of nodes) {
if (node.type === "folder") {
toc.push(`${prefix}- 📁 ${node.name}`);
visitToc(node.children, `${prefix} `);
} else {
toc.push(`${prefix}- [${node.name}](#${anchor(node.name)})`);
}
}
};
visitToc(collection.children, "");
if (toc.length) lines.push("## 目录", "", ...toc, "");
const visit = (nodes: CollectionNode[], depth: number) => {
for (const node of nodes) {
const hashes = "#".repeat(Math.min(depth + 1, 6));
if (node.type === "folder") {
lines.push(`${hashes} ${node.name}`, "");
if (node.description) lines.push(node.description, "");
visit(node.children, depth + 1);
continue;
}
lines.push(`${hashes} ${node.name}`, "");
if (node.description) lines.push(node.description, "");
if (node.kind === "http") {
const d: HttpRequestData = node.data;
lines.push(
"",
`- **Method**:\`${d.method}\``,
`- **URL**:\`${buildUrlWithQuery(d)}\``,
);
if (d.auth.mode !== "none") {
lines.push(`- **鉴权**:\`${d.auth.mode}\``);
}
if (d.query.filter((q) => q.enabled && q.key).length) {
lines.push("", "**Query 参数**", "", "| 参数 | 示例值 | 说明 |", "| --- | --- | --- |");
for (const q of d.query) {
if (!q.enabled || !q.key) continue;
lines.push(`| \`${q.key}\` | \`${escapeTable(q.value)}\` | ${escapeTable(q.description ?? "")} |`);
}
}
if (d.headers.filter((h) => h.enabled && h.key).length) {
lines.push("", "**Headers**", "", "| Header | 示例值 | 说明 |", "| --- | --- | --- |");
for (const h of d.headers) {
if (!h.enabled || !h.key) continue;
lines.push(`| \`${h.key}\` | \`${escapeTable(h.value)}\` | ${escapeTable(h.description ?? "")} |`);
}
}
if (d.bodyMode !== "none") {
const lang = d.bodyMode === "json" ? "json" : d.bodyMode === "xml" ? "xml" : "";
if (d.bodyMode === "urlencoded" || d.bodyMode === "form-data") {
lines.push("", `**请求体(${d.bodyMode})**`, "", "| 字段 | 示例值 |", "| --- | --- |");
for (const f of d.bodyForm) {
if (!f.enabled || !f.key) continue;
lines.push(`| \`${f.key}\` | \`${escapeTable(f.value)}\` |`);
}
} else if (d.bodyRaw) {
lines.push("", `**请求体(${d.bodyMode})**`, "", "```" + lang, d.bodyRaw, "```");
}
}
lines.push("");
} else {
const d = node.data as { url?: string; brokerUrl?: string };
const url = "url" in d ? d.url : d.brokerUrl ?? "";
lines.push("", `- **协议**:\`${node.kind}\``, `- **地址**:\`${url}\``);
lines.push("");
}
}
};
visit(collection.children, 1);
return lines.join("\n");
}
function escapeTable(s: string): string {
return s.replaceAll("|", "\\|").replaceAll("\n", " ");
}
function anchor(name: string): string {
return name.toLowerCase().replace(/[^\p{L}\p{N}\s-]/gu, "").trim().replace(/\s+/g, "-");
}
+265
View File
@@ -0,0 +1,265 @@
/**
* 集合 → OpenAPI 3.0.3 导出。
*/
import type { Collection, CollectionNode, FolderNode, HttpApiNode, HttpRequestData, RequestNode } from "../model";
import { walk } from "../tree";
type Spec = Record<string, unknown>;
type Op = Record<string, unknown>;
export type OpenApiExportOptions = {
title?: string;
version?: string;
yaml?: boolean;
};
export function exportOpenApi(
collection: Collection,
options: OpenApiExportOptions = {},
): Spec {
const requests: HttpApiNode[] = [];
const skipped: RequestNode[] = [];
walk(collection.children, (node) => {
if (node.type !== "request") return;
if (node.kind === "http") requests.push(node);
else skipped.push(node);
});
// server 推断:{{baseUrl}} 前缀或首个绝对 URL 的 origin
const serverUrl = inferServerUrl(requests.map((r) => r.data.url));
const paths: Spec = {};
const usedPathParams = new Map<string, Set<string>>();
const securities = new Set<string>();
for (const req of requests) {
const data = req.data;
const path = toOpenApiPath(stripServer(data.url, serverUrl));
const paramsInPath = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
const method = data.method.toLowerCase();
if (method === "head" || method === "options") continue; // OpenAPI 常规导出不含这两个
const op: Op = {
summary: req.name,
operationId: slugify(req.name),
};
if (req.description) op.description = req.description;
const parameters: Spec[] = [];
for (const p of paramsInPath) {
if (!usedPathParams.has(path)) usedPathParams.set(path, new Set());
usedPathParams.get(path)!.add(p);
parameters.push({ name: p, in: "path", required: true, schema: { type: "string" } });
}
for (const q of data.query) {
if (!q.enabled || !q.key) continue;
parameters.push({
name: q.key,
in: "query",
required: false,
schema: { type: "string", example: q.value },
...(q.description ? { description: q.description } : {}),
});
}
for (const h of data.headers) {
if (!h.enabled || !h.key) continue;
if (["authorization", "content-type", "cookie"].includes(h.key.toLowerCase())) continue;
parameters.push({
name: h.key,
in: "header",
required: false,
schema: { type: "string", example: h.value },
});
}
if (parameters.length) op.parameters = parameters;
// body
if (data.bodyMode !== "none" && method !== "get" && method !== "head") {
op.requestBody = buildRequestBody(data);
}
// security
if (data.auth.mode === "bearer" && data.auth.bearer) {
securities.add("bearerAuth");
op.security = [{ bearerAuth: [] }];
} else if (data.auth.mode === "basic") {
securities.add("basicAuth");
op.security = [{ basicAuth: [] }];
} else if (data.auth.mode === "apikey" && data.auth.apiKeyName) {
securities.add("apiKeyAuth");
op.security = [{ apiKeyAuth: [] }];
}
if (!paths[path]) paths[path] = {};
(paths[path] as Spec)[method] = op;
}
const components: Spec = {};
const schemes: Spec = {};
if (securities.has("bearerAuth")) schemes.bearerAuth = { type: "http", scheme: "bearer", bearerFormat: "JWT" };
if (securities.has("basicAuth")) schemes.basicAuth = { type: "http", scheme: "basic" };
if (securities.has("apiKeyAuth"))
schemes.apiKeyAuth = {
type: "apiKey",
name: findApiKeyHeader(requests) ?? "X-API-Key",
in: "header",
};
if (Object.keys(schemes).length) components.securitySchemes = schemes;
const spec: Spec = {
openapi: "3.0.3",
info: {
title: options.title ?? collection.name,
version: options.version ?? "1.0.0",
...(collection.description ? { description: collection.description } : {}),
},
servers: [{ url: serverUrl }],
paths,
...(Object.keys(components).length ? { components } : {}),
};
if (skipped.length) {
(spec.info as Spec).description = `${str((spec.info as Spec).description)}\n\n> 注:${skipped.length} 个非 HTTP 节点(GraphQL/WS/SSE/Socket.IO/MQTT)不参与 OpenAPI 导出。`.trim();
}
return spec;
}
function str(v: unknown): string {
return typeof v === "string" ? v : "";
}
function inferServerUrl(urls: string[]): string {
for (const u of urls) {
if (u.includes("{{baseUrl}}")) return "http://localhost";
}
for (const u of urls) {
try {
return new URL(u).origin;
} catch {
/* next */
}
}
return "http://localhost";
}
function stripServer(url: string, serverUrl: string): string {
let u = url;
if (u.includes("{{baseUrl}}")) {
u = u.split("{{baseUrl}}")[1] || "/";
} else if (serverUrl && u.startsWith(serverUrl)) {
u = u.slice(serverUrl.length) || "/";
}
if (!u.startsWith("/")) u = `/${u}`;
return u.split("?")[0];
}
function toOpenApiPath(path: string): string {
// {{var}} → {var}
return path.replace(/\{\{([^}]+)\}\}/g, "{$1}");
}
function buildRequestBody(data: HttpRequestData): Spec {
if (data.bodyMode === "urlencoded") {
const props: Spec = {};
for (const f of data.bodyForm) {
if (f.key) props[f.key] = { type: "string", example: f.value };
}
return {
required: true,
content: {
"application/x-www-form-urlencoded": {
schema: { type: "object", properties: props },
},
},
};
}
if (data.bodyMode === "form-data") {
const props: Spec = {};
for (const f of data.bodyForm) {
if (f.key) props[f.key] = { type: "string", example: f.value };
}
return {
required: true,
content: {
"multipart/form-data": {
schema: { type: "object", properties: props },
},
},
};
}
const mime =
data.bodyMode === "json"
? "application/json"
: data.bodyMode === "xml"
? "application/xml"
: data.bodyMode === "html"
? "text/html"
: "text/plain";
let example: unknown = data.bodyRaw;
if (data.bodyMode === "json") {
try {
example = JSON.parse(data.bodyRaw);
} catch {
/* keep raw */
}
}
return {
required: true,
content: { [mime]: { example } },
};
}
function findApiKeyHeader(requests: HttpApiNode[]): string | null {
for (const r of requests) {
if (r.data.auth.mode === "apikey" && r.data.auth.apiKeyName) return r.data.auth.apiKeyName;
}
return null;
}
function slugify(name: string): string {
const cleaned = name
.replace(/[^\p{L}\p{N}\s-]/gu, "")
.trim()
.replace(/\s+/g, "_");
return cleaned || "operation";
}
/** 导出包含 tag 分组(目录名 = tag)的 spec */
export function exportOpenApiWithFolders(
collection: Collection,
options: OpenApiExportOptions = {},
): Spec {
const spec = exportOpenApi(collection, options);
// 目录名 → tag:重新走一遍树,为每个请求打 tag
const paths = spec.paths as Spec;
const tags: { name: string }[] = [];
const tagByPath = new Map<string, string>();
const visit = (nodes: CollectionNode[], tag: string | null) => {
for (const node of nodes) {
if (node.type === "folder") {
visit(node.children, node.name);
} else if (node.kind === "http" && tag) {
const path = toOpenApiPath(stripServer(node.data.url, "http://localhost"));
tagByPath.set(`${path}:${node.data.method.toLowerCase()}`, tag);
}
}
};
visit(collection.children, null);
for (const t of tagByPath.values()) {
if (!tags.some((x) => x.name === t)) tags.push({ name: t });
}
if (tags.length) {
spec.tags = tags;
for (const [path, methods] of Object.entries(paths)) {
for (const [method, opRaw] of Object.entries(methods as Spec)) {
const tag = tagByPath.get(`${path}:${method}`);
if (tag) (opRaw as Op).tags = [tag];
}
}
}
return spec;
}
export type { FolderNode };
+146
View File
@@ -0,0 +1,146 @@
/**
* 集合 → Postman Collection v2.1 导出。
*/
import type { Collection, CollectionNode, GraphqlRequestData, HttpRequestData, RequestNode } from "../model";
type Obj = Record<string, unknown>;
export function exportPostman(collection: Collection): Obj {
return {
info: {
name: collection.name,
_postman_id: `opsxapi-${collection.id}`,
description: collection.description || undefined,
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
},
item: convertNodes(collection.children),
};
}
function convertNodes(nodes: CollectionNode[]): Obj[] {
const out: Obj[] = [];
for (const node of nodes) {
if (node.type === "folder") {
out.push({
name: node.name,
description: node.description || undefined,
item: convertNodes(node.children),
});
} else if (node.kind === "http" || node.kind === "graphql") {
out.push(convertRequest(node));
}
}
return out;
}
function convertRequest(node: RequestNode): Obj {
const data = node.data as HttpRequestData | GraphqlRequestData;
const header = data.headers
.filter((h) => h.enabled && h.key)
.map((h) => ({ key: h.key, value: h.value, type: "text" }));
const authObj = postmanAuth(data.auth);
if (data.kind === "graphql") {
return {
name: node.name,
request: {
method: "POST",
header: [{ key: "Content-Type", value: "application/json", type: "text" }, ...header],
body: {
mode: "graphql",
graphql: { query: data.query, variables: data.variables },
},
url: { raw: data.url, host: splitHost(data.url) },
...(authObj ? { auth: authObj } : {}),
...(node.description ? { description: node.description } : {}),
},
};
}
const query = data.query
.filter((q) => q.enabled && q.key)
.map((q) => ({ key: q.key, value: q.value, disabled: false }));
const raw = buildRaw(data);
let body: Obj | undefined;
if (data.bodyMode === "json" || data.bodyMode === "xml" || data.bodyMode === "text" || data.bodyMode === "html") {
body = {
mode: "raw",
raw: data.bodyRaw,
options: { raw: { language: data.bodyMode === "json" ? "json" : data.bodyMode === "xml" ? "xml" : "text" } },
};
} else if (data.bodyMode === "urlencoded") {
body = {
mode: "urlencoded",
urlencoded: data.bodyForm.filter((f) => f.key).map((f) => ({ key: f.key, value: f.value, disabled: !f.enabled })),
};
} else if (data.bodyMode === "form-data") {
body = {
mode: "formdata",
formdata: data.bodyForm.filter((f) => f.key).map((f) => ({ key: f.key, value: f.value, type: "text", disabled: !f.enabled })),
};
}
const cookie = data.cookies.filter((c) => c.enabled && c.key).map((c) => `${c.key}=${c.value}`).join("; ");
return {
name: node.name,
request: {
method: data.method,
header: header.length ? header : undefined,
body,
...(cookie ? { header: [...(header ?? []), { key: "Cookie", value: cookie, type: "text" }] } : {}),
url: {
raw,
host: splitHost(raw),
query: query.length ? query : undefined,
},
...(authObj ? { auth: authObj } : {}),
...(node.description ? { description: node.description } : {}),
},
};
}
function buildRaw(data: HttpRequestData): string {
const enabled = data.query.filter((q) => q.enabled && q.key);
if (!enabled.length) return data.url;
const qs = enabled.map((q) => `${encodeURIComponent(q.key)}=${encodeURIComponent(q.value)}`).join("&");
return data.url.includes("?") ? `${data.url}&${qs}` : `${data.url}?${qs}`;
}
function splitHost(url: string): string[] {
try {
const u = new URL(url.includes("://") ? url : `http://${url}`);
return [u.host];
} catch {
return [url];
}
}
function postmanAuth(auth: HttpRequestData["auth"]): Obj | undefined {
if (auth.mode === "bearer" && auth.bearer) {
return { type: "bearer", bearer: [{ key: "token", value: auth.bearer, type: "string" }] };
}
if (auth.mode === "basic") {
return {
type: "basic",
basic: [
{ key: "username", value: auth.basicUser ?? "", type: "string" },
{ key: "password", value: auth.basicPass ?? "", type: "string" },
],
};
}
if (auth.mode === "apikey" && auth.apiKeyName) {
return {
type: "apikey",
apikey: [
{ key: "key", value: auth.apiKeyName, type: "string" },
{ key: "value", value: auth.apiKeyValue ?? "", type: "string" },
{ key: "in", value: auth.apiKeyIn ?? "header", type: "string" },
],
};
}
return undefined;
}
+267
View File
@@ -0,0 +1,267 @@
/**
* 节点 / 集合 / 环境的默认工厂。
*/
import type {
AuthConfig,
Collection,
CollectionNode,
Environment,
FolderNode,
GraphqlRequestData,
HttpRequestData,
KVRow,
MqttRequestData,
RequestKind,
RequestNode,
RequestDataOf,
SocketIoRequestData,
SseRequestData,
WebsocketRequestData,
WorkspaceData,
} from "./model";
import { newId } from "./tree";
export function kv(
key = "",
value = "",
enabled = true,
description?: string,
): KVRow {
return { id: newId(), key, value, enabled, description };
}
export const noAuth: AuthConfig = { mode: "none" };
export function defaultRequestData(kind: RequestKind) {
switch (kind) {
case "http":
return {
kind,
method: "GET",
url: "{{baseUrl}}/",
query: [],
headers: [],
cookies: [],
auth: { ...noAuth },
bodyMode: "none",
bodyRaw: "",
bodyForm: [],
} satisfies HttpRequestData;
case "graphql":
return {
kind,
url: "{{baseUrl}}/graphql",
headers: [kv("Content-Type", "application/json")],
auth: { ...noAuth },
query: "query {\n \n}",
variables: "{}",
} satisfies GraphqlRequestData;
case "websocket":
return {
kind,
url: "wss://echo.websocket.events",
headers: [],
protocols: "",
} satisfies WebsocketRequestData;
case "sse":
return {
kind,
url: "{{baseUrl}}/events",
headers: [kv("Accept", "text/event-stream")],
query: [],
auth: { ...noAuth },
lastEventId: "",
dataOnly: true,
} satisfies SseRequestData;
case "socketio":
return {
kind,
url: "http://127.0.0.1:3000",
options: '{\n "path": "/socket.io",\n "transports": ["websocket"]\n}',
listenEvents: ["message"],
sendEvent: "message",
sendArgs: '[\n "hello"\n]',
} satisfies SocketIoRequestData;
case "mqtt":
return {
kind,
brokerUrl: "wss://broker.emqx.io:8084/mqtt",
username: "",
password: "",
clientId: `opsxapi-${Math.random().toString(16).slice(2, 10)}`,
subscribeTopics: "opsxapi/demo",
publishTopic: "opsxapi/demo",
publishPayload: '{\n "hello": "opsxapi"\n}',
qos: 0,
} satisfies MqttRequestData;
}
}
export function createRequest<K extends RequestKind>(
kind: K,
name?: string,
partial?: { data?: Partial<RequestDataOf<K>>; description?: string },
): Extract<RequestNode, { kind: K }> {
const data = {
...defaultRequestData(kind),
...(partial?.data ?? {}),
kind,
} as RequestDataOf<K>;
const base = {
id: newId(),
type: "request" as const,
name: name ?? defaultName(kind),
description: partial?.description,
createdAt: Date.now(),
updatedAt: Date.now(),
};
return { ...base, kind, data } as unknown as Extract<RequestNode, { kind: K }>;
}
export function defaultName(kind: RequestKind): string {
switch (kind) {
case "http":
return "新建 HTTP 接口";
case "graphql":
return "新建 GraphQL 查询";
case "websocket":
return "新建 WebSocket";
case "sse":
return "新建 SSE";
case "socketio":
return "新建 Socket.IO";
case "mqtt":
return "新建 MQTT";
}
}
export function createFolder(name = "新建目录", children: CollectionNode[] = []): FolderNode {
return {
id: newId(),
type: "folder",
name,
children,
createdAt: Date.now(),
updatedAt: Date.now(),
};
}
export function createCollection(
name: string,
children: CollectionNode[] = [],
): Collection {
return {
id: newId(),
type: "collection",
name,
children,
createdAt: Date.now(),
updatedAt: Date.now(),
};
}
export function createEnvironment(
name: string,
vars: { key: string; value: string }[] = [],
): Environment {
return { id: newId(), name, variables: vars.map((v) => kv(v.key, v.value)) };
}
/** 首次启动的演示集合:覆盖全部协议,开箱即用 */
export function createSeedWorkspace(): WorkspaceData {
const http = (
name: string,
method: HttpRequestData["method"],
url: string,
extra?: Partial<HttpRequestData>,
description?: string,
): RequestNode =>
createRequest("http", name, {
data: { method, url, ...extra },
description,
});
const demo = createCollection("示例集合", [
createFolder("HTTP · httpbin", [
http("获取请求回显", "GET", "https://httpbin.org/get", undefined, "经典回显接口,返回请求头 / IP / 参数。"),
http(
"提交 JSON",
"POST",
"https://httpbin.org/post",
{
bodyMode: "json",
bodyRaw: '{\n "name": "opsxapi",\n "lang": "zh-CN"\n}',
headers: [kv("Content-Type", "application/json")],
},
"POST 一个 JSON 体,观察 Pretty 视图。",
),
http(
"表单提交",
"POST",
"https://httpbin.org/post",
{
bodyMode: "urlencoded",
bodyForm: [kv("username", "logan"), kv("role", "ops")],
},
),
http(
"Bearer 鉴权示例",
"GET",
"https://httpbin.org/bearer",
{
auth: { mode: "bearer", bearer: "{{token}}" },
},
"在 Auth 标签填 Token;环境变量 token 默认 demo-token。",
),
http("状态码 · 404", "GET", "https://httpbin.org/status/404"),
]),
createFolder("GraphQL", [
createRequest("graphql", "国家查询 · countries", {
data: {
url: "https://countries.trevorblades.com/graphql",
query:
"query ($code: ID!) {\n country(code: $code) {\n name\n capital\n currency\n }\n}",
variables: '{\n "code": "CN"\n}',
},
description: "公共 GraphQL 服务,无需鉴权。",
}),
]),
createFolder("实时协议", [
createRequest("websocket", "WebSocket · echo", {
data: { url: "wss://echo.websocket.events" },
description: "发送什么就回什么。",
}),
createRequest("sse", "SSE · 维基媒体变更流", {
data: {
url: "https://stream.wikimedia.org/v2/stream/recentchange",
headers: [kv("Accept", "text/event-stream")],
},
description: "维基百科实时编辑事件流。",
}),
createRequest("mqtt", "MQTT · EMQX 公共 Broker", {
data: {
subscribeTopics: "opsxapi/demo",
publishTopic: "opsxapi/demo",
},
description: "wss 公共 broker;两个窗口同时打开可互相收发。",
}),
createRequest("socketio", "Socket.IO · 本地服务", {
description: "连你的本地 socket.io 服务,或改 URL 到任意 demo 站点。",
}),
]),
]);
const envs = [
createEnvironment("正式", [{ key: "baseUrl", value: "https://httpbin.org" }, { key: "token", value: "demo-token" }]),
createEnvironment("本地", [{ key: "baseUrl", value: "http://127.0.0.1:8787" }, { key: "token", value: "dev-token" }]),
];
return {
version: 2,
collections: [demo],
environments: envs,
activeEnvironmentId: envs[0]?.id ?? null,
history: [],
settings: { theme: "dark", timeoutMs: 30000 },
};
}
+451
View File
@@ -0,0 +1,451 @@
import { describe, expect, it } from "vitest";
import {
detectImportFormat,
importAuto,
importCurl,
importOpenApi,
parseCurl,
} from "./importers";
import { importPostman } from "./importers/postman";
import { importHar } from "./importers/har";
import { importInsomnia } from "./importers/insomnia";
import { importHoppscotch } from "./importers/hoppscotch";
import { importApipost } from "./importers/apipost";
import { importMarkdown } from "./importers/markdown";
import { findNode, insertIntoCollection, moveNode, removeNode } from "./tree";
import { createSeedWorkspace } from "./factory";
import { exportCollectionAs, exportBackup } from "./exporters";
import { exportCurl } from "./exporters/curl";
const openapi3 = JSON.stringify({
openapi: "3.0.3",
info: { title: "Petstore", version: "1.0.0" },
servers: [{ url: "https://petstore.example.com/v1" }],
tags: [{ name: "pets" }],
paths: {
"/pets": {
get: {
tags: ["pets"],
summary: "列出宠物",
operationId: "listPets",
parameters: [
{ name: "limit", in: "query", schema: { type: "integer" }, example: 10 },
{ name: "X-Trace", in: "header", schema: { type: "string" }, example: "abc" },
],
responses: {},
},
post: {
tags: ["pets"],
summary: "新增宠物",
operationId: "createPet",
security: [{ petstoreAuth: [] }],
requestBody: {
content: {
"application/json": {
schema: {
type: "object",
properties: { name: { type: "string" }, age: { type: "integer" } },
},
},
},
},
responses: {},
},
},
"/pets/{petId}": {
get: {
summary: "查询单个宠物",
parameters: [{ name: "petId", in: "path", required: true, schema: { type: "string" }, example: "42" }],
responses: {},
},
},
},
components: {
securitySchemes: { petstoreAuth: { type: "http", scheme: "bearer" } },
},
});
const openapi2 = `
swagger: "2.0"
info:
title: Old API
version: "1.0"
host: old.example.com
basePath: /api
schemes: [https]
paths:
/users:
get:
summary: 用户列表
parameters:
- name: page
in: query
type: integer
example: 1
responses:
200:
description: ok
post:
summary: 创建用户
consumes: [application/json]
parameters:
- name: body
in: body
schema:
type: object
properties:
username:
type: string
responses:
201:
description: created
`;
const postmanV21 = JSON.stringify({
info: { name: "Demo", schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" },
item: [
{
name: "目录A",
item: [
{
name: "获取用户",
request: {
method: "GET",
url: { raw: "https://api.example.com/users?page=2", query: [{ key: "page", value: "2" }] },
header: [{ key: "Accept", value: "application/json" }],
},
},
{
name: "登录",
request: {
method: "POST",
url: { raw: "{{baseUrl}}/login" },
header: [{ key: "Content-Type", value: "application/json" }],
body: { mode: "raw", raw: "{\n \"user\": \"a\",\n \"pass\": \"b\"\n}", options: { raw: { language: "json" } } },
auth: { type: "bearer", bearer: [{ key: "token", value: "xyz" }] },
},
},
],
},
],
variable: [{ key: "baseUrl", value: "https://api.example.com" }],
});
const harDoc = JSON.stringify({
log: {
entries: [
{
request: {
method: "GET",
url: "https://api.example.com/items?size=5",
headers: [{ name: "Accept", value: "application/json" }],
queryString: [{ name: "size", value: "5" }],
},
},
{
request: {
method: "POST",
url: "https://api.example.com/items",
headers: [{ name: "Content-Type", value: "application/json" }],
postData: { mimeType: "application/json", text: '{"a":1}' },
},
},
],
},
});
const insomniaDoc = JSON.stringify({
_type: "export",
__export_format: 4,
resources: [
{ _id: "wrk_1", _type: "workspace", name: "我的服务" },
{ _id: "fld_1", _type: "request_group", parentId: "wrk_1", name: "用户" },
{
_id: "req_1",
_type: "request",
parentId: "fld_1",
name: "资料",
method: "GET",
url: "https://api.example.com/me",
headers: [{ name: "Authorization", value: "Bearer t" }],
parameters: [],
authentication: { type: "bearer", token: "t" },
body: {},
},
],
});
const hoppscotchDoc = JSON.stringify([
{
name: "SC",
folders: [{ name: "子", requests: [] }],
requests: [
{
name: "列表",
method: "GET",
url: "https://api.example.com/list",
headers: [{ key: "Accept", value: "application/json", active: true }],
params: [],
bodyMode: "none",
},
],
},
]);
const apipostDoc = JSON.stringify({
name: "Apipost 项目",
api_collection: [
{ target_id: "f1", parent_id: "0", name: "订单", children: [] },
{
target_id: "r1",
parent_id: "f1",
name: "下单",
method: "POST",
url: "https://api.example.com/orders",
request: {
headers: { "Content-Type": "application/json" },
body: { mode: "json", raw: '{"sku":1}' },
},
},
],
});
const curlCmd = `curl -X POST 'https://api.example.com/v1/users?dry=1' \\
-H 'Content-Type: application/json' \\
-H 'Authorization: Bearer tok' \\
-d '{"name":"logan","tags":["a","b"]}'`;
describe("格式识别", () => {
it("识别各格式", () => {
expect(detectImportFormat(openapi3)).toBe("openapi");
expect(detectImportFormat(openapi2)).toBe("openapi");
expect(detectImportFormat(postmanV21)).toBe("postman");
expect(detectImportFormat(harDoc)).toBe("har");
expect(detectImportFormat(insomniaDoc)).toBe("insomnia");
expect(detectImportFormat(hoppscotchDoc)).toBe("hoppscotch");
expect(detectImportFormat(apipostDoc)).toBe("apipost");
expect(detectImportFormat(curlCmd)).toBe("curl");
expect(detectImportFormat("# 文档\n\n```curl\ncurl http://a.com\n```")).toBe("markdown");
});
});
describe("cURL", () => {
it("解析方法/URL/头/体", () => {
const parsed = parseCurl(curlCmd);
expect(parsed.method).toBe("POST");
expect(parsed.url).toBe("https://api.example.com/v1/users");
expect(parsed.query).toEqual([{ key: "dry", value: "1", enabled: true }]);
expect(parsed.bodyMode).toBe("json");
expect(JSON.parse(parsed.bodyRaw)).toEqual({ name: "logan", tags: ["a", "b"] });
});
it("表单与 basic auth", () => {
const parsed = parseCurl("curl -u tom:pw -F file=@x.txt -F name=x https://a.io/up");
expect(parsed.auth.mode).toBe("basic");
expect(parsed.auth.basicUser).toBe("tom");
expect(parsed.bodyMode).toBe("form-data");
expect(parsed.bodyForm.some((f) => f.key === "name" && f.value === "x")).toBe(true);
});
it("urlencoded 推断", () => {
const parsed = parseCurl(`curl -d 'a=1&b=2' http://x.io/f`);
expect(parsed.method).toBe("POST");
expect(parsed.bodyMode).toBe("urlencoded");
expect(parsed.bodyForm.length).toBe(2);
});
it("导入为集合", () => {
const c = importCurl(curlCmd);
expect(c.children.length).toBe(1);
expect(c.children[0].type).toBe("request");
});
});
describe("OpenAPI", () => {
it("导入 v3(tag 目录、query/header、bearer、path 参数)", () => {
const result = importOpenApi(openapi3);
const c = result.collections[0];
expect(c.name).toBe("Petstore");
expect(c.children.length).toBe(2); // pets 目录 + 根级 /pets/{petId}
const folder = c.children[0];
expect(folder.type).toBe("folder");
if (folder.type !== "folder") return;
const list = folder.children[0];
if (list.type !== "request" || list.kind !== "http") return;
expect(list.data.method).toBe("GET");
expect(list.data.url).toBe("{{baseUrl}}/pets");
expect(result.environmentVariables.find((v) => v.key === "baseUrl")?.value).toBe(
"https://petstore.example.com/v1",
);
expect(list.data.query[0].key).toBe("limit");
expect(list.data.query[0].value).toBe("10");
expect(list.data.headers.some((h) => h.key === "X-Trace")).toBe(true);
const single = c.children[1];
if (single.type !== "request" || single.kind !== "http") return;
expect(single.data.url).toContain("{{petId}}");
expect(result.environmentVariables.find((v) => v.key === "petId")?.value).toBe("42");
});
it("导入 v2 YAML(basePath、body schema)", () => {
const result = importOpenApi(openapi2);
const c = result.collections[0];
expect(result.environmentVariables.find((v) => v.key === "baseUrl")?.value).toBe("https://old.example.com/api");
const post = c.children.find((n) => n.type === "request" && n.kind === "http" && n.data.method === "POST");
expect(post).toBeTruthy();
if (post && post.type === "request" && post.kind === "http") {
expect(post.data.bodyMode).toBe("json");
expect(post.data.bodyRaw).toContain("username");
}
});
});
describe("Postman", () => {
it("导入 v2.1(目录/鉴权/变量)", () => {
const result = importPostman(postmanV21);
const c = result.collections[0];
expect(c.name).toBe("Demo");
const folder = c.children[0];
if (folder.type !== "folder") throw new Error("应当是目录");
const login = folder.children[1];
if (login.type !== "request" || login.kind !== "http") throw new Error("应当是请求");
expect(login.data.auth.mode).toBe("bearer");
expect(login.data.bodyMode).toBe("json");
expect(result.environmentVariables.find((v) => v.key === "baseUrl")?.value).toBe("https://api.example.com");
const getUser = folder.children[0];
if (getUser.type !== "request" || getUser.kind !== "http") throw new Error("应当是请求");
expect(getUser.data.query[0].key).toBe("page");
});
});
describe("HAR", () => {
it("按域名分组导入", () => {
const result = importHar(harDoc);
const c = result.collections[0];
expect(c.children.length).toBe(1);
const hostFolder = c.children[0];
if (hostFolder.type !== "folder") throw new Error("应当是目录");
expect(hostFolder.name).toBe("api.example.com");
expect(hostFolder.children.length).toBe(2);
});
});
describe("Insomnia", () => {
it("导入 workspace/group/request 层级", () => {
const result = importInsomnia(insomniaDoc);
const c = result.collections[0];
expect(c.name).toBe("我的服务");
const folder = c.children[0];
if (folder.type !== "folder") throw new Error("应当是目录");
expect(folder.name).toBe("用户");
const req = folder.children[0];
if (req.type !== "request" || req.kind !== "http") throw new Error("应当是请求");
expect(req.data.url).toBe("https://api.example.com/me");
expect(req.data.auth.mode).toBe("bearer");
});
});
describe("Hoppscotch", () => {
it("导入集合数组", () => {
const result = importHoppscotch(hoppscotchDoc);
const c = result.collections[0];
expect(c.name).toBe("SC");
expect(c.children.length).toBe(2); // 子目录 + 列表
});
});
describe("Apipost", () => {
it("导入 api_collection(目录 + 请求)", () => {
const result = importApipost(apipostDoc);
const c = result.collections[0];
expect(c.name).toBe("Apipost 项目");
const folder = c.children[0];
if (folder.type !== "folder") throw new Error("应当是目录");
expect(folder.name).toBe("订单");
const req = folder.children[0];
if (req.type !== "request" || req.kind !== "http") throw new Error("应当是请求");
expect(req.data.method).toBe("POST");
expect(req.data.bodyMode).toBe("json");
});
});
describe("Markdown", () => {
it("提取 curl 代码块", () => {
const md = `# 用户文档
## 登录
\`\`\`curl
curl -X POST https://a.io/login -d '{"u":1}'
\`\`\`
`;
const result = importMarkdown(md);
const c = result.collections[0];
expect(c.name).toBe("用户文档");
expect(c.children.length).toBe(1);
});
});
describe("树操作", () => {
it("插入/移动/删除/查找", () => {
const ws = createSeedWorkspace();
const collections = ws.collections;
const demo = collections[0];
const folder = demo.children[0];
if (folder.type !== "folder") throw new Error("seed 第一个子节点应为目录");
const req = folder.children[0];
const found = findNode(collections, req.id);
expect(found?.node.id).toBe(req.id);
// 移动到顶层
const moved = moveNode(collections, req.id, demo.id, null);
const movedRef = findNode(moved, req.id);
expect(movedRef?.parent).toBeNull();
// 删除
const removed = removeNode(moved, req.id);
expect(findNode(removed, req.id)).toBeNull();
// 插入顶层
const newNode = { ...req, id: "new-1" };
const inserted = insertIntoCollection(removed, demo.id, null, newNode);
expect(findNode(inserted, "new-1")).toBeTruthy();
});
});
describe("导出", () => {
it("OpenAPI / Postman / Markdown 往返", () => {
const imported = importAuto(openapi3);
const c = imported.collections[0];
const openapiJson = exportCollectionAs("openapi-json", c);
const reparsed = JSON.parse(openapiJson);
expect(reparsed.openapi).toBe("3.0.3");
expect(reparsed.paths["/pets"].get).toBeTruthy();
const postmanJson = exportCollectionAs("postman", c);
const reparsedPostman = JSON.parse(postmanJson);
expect(reparsedPostman.info.schema).toContain("v2.1.0");
const md = exportCollectionAs("markdown", c);
expect(md).toContain("# Petstore");
expect(md).toContain("GET");
});
it("cURL 导出", () => {
const c = importCurl(curlCmd);
const req = c.children[0];
if (req.type !== "request" || req.kind !== "http") throw new Error("应当是请求");
const exported = exportCurl(req.data);
expect(exported).toContain("-X POST");
expect(exported).toContain("--data-raw");
expect(exported).toContain("api.example.com/v1/users");
});
it("备份导出", () => {
const ws = createSeedWorkspace();
const backup = exportBackup(ws.collections, ws.environments);
const parsed = JSON.parse(backup);
expect(parsed.collections.length).toBeGreaterThan(0);
expect(detectImportFormat(backup)).toBe("backup");
});
});
+209
View File
@@ -0,0 +1,209 @@
/**
* Apipost 项目导出文件导入器(宽容解析:api_collection / parent_id / children 均支持)。
* Apidoc / Apizza / Eolink / YApi 建议先导出为 OpenAPI 或 Postman 格式再导入。
*/
import type { Collection, CollectionNode, FolderNode, HttpRequestData } from "../model";
import { createCollection, createFolder, createRequest, kv } from "../factory";
import type { ImportResult } from "../model";
import { normalizeMethod } from "./curl";
type Obj = Record<string, unknown>;
const asObj = (v: unknown): Obj => (v && typeof v === "object" ? (v as Obj) : {});
const asArr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
export function isApipostExport(doc: Obj): boolean {
if (Array.isArray(doc.api_collection)) return true;
if (Array.isArray(asObj(doc.data).api_collection)) return true;
// apipost v7 导出:{"name":..., "api_collection":[...]} 或数组包裹
if (Array.isArray(doc.item_list)) return true;
return false;
}
export function importApipost(text: string): ImportResult {
const doc = asObj(JSON.parse(text));
if (!isApipostExport(doc)) throw new Error("不是 Apipost 导出文件(缺少 api_collection)");
const warnings: string[] = [];
const rawItems = asArr(doc.api_collection ?? asObj(doc.data).api_collection ?? doc.item_list);
const items = rawItems.map(asObj);
// 取出文件夹(target_type/name+children 或 parent_id 结构)
const requestItems = items.filter((it) => !isFolderItem(it));
const folderItems = items.filter(isFolderItem);
const byParent = new Map<string, Obj[]>();
const parentKeyOf = (it: Obj): string => {
const parent = str(it.parent_id) || str(it.parentId);
if (!parent || parent === "0" || parent === "null" || parent === "root") return "__root__";
return parent;
};
for (const it of items) {
const parent = parentKeyOf(it);
if (!byParent.has(parent)) byParent.set(parent, []);
byParent.get(parent)!.push(it);
}
const envVars: { key: string; value: string }[] = [];
const build = (parentKey: string): CollectionNode[] => {
const out: CollectionNode[] = [];
for (const it of byParent.get(parentKey) ?? []) {
if (isFolderItem(it)) {
const name = str(it.name) || str(it.title) || "目录";
const folder: FolderNode = createFolder(name, build(str(it.target_id) || str(it.id) || str(it._id) || name));
out.push(folder);
} else {
const node = convertRequest(it, envVars, warnings);
if (node) out.push(node);
}
}
return out;
};
const children = build("__root__");
// parent_id 断链的请求兜底平铺
const placed = new Set<string>();
for (const it of folderItems) placed.add(str(it.target_id) || str(it.id));
for (const it of requestItems) {
const parent = parentKeyOf(it);
if (parent !== "__root__" && !placed.has(parent)) {
const node = convertRequest(it, envVars, warnings);
if (node) children.push(node);
}
}
const collection = createCollection(str(doc.name) || str(asObj(doc.data).name) || "Apipost 导入", children);
let count = countNodes(children);
if (count === 0) warnings.push("没有解析到请求;若是新版导出格式,请改用 OpenAPI/Postman 格式导出后导入");
// apipost 环境
for (const env of asArr(doc.env ?? asObj(doc.data).env)) {
const envObj = asObj(env);
for (const v of asArr(envObj.param ?? envObj.variable)) {
const vObj = asObj(v);
const key = str(vObj.key);
if (key) envVars.push({ key, value: str(vObj.value) });
}
}
return { collections: [collection], environmentVariables: envVars, warnings };
}
function isFolderItem(it: Obj): boolean {
const hasChildren = Array.isArray(it.children) || Array.isArray(it.item);
const typeMark = str(it.target_type ?? it.type ?? it.item_type);
return hasChildren || typeMark === "folder" || typeMark === "group" || typeMark === "目录";
}
function convertRequest(
it: Obj,
_envVars: { key: string; value: string }[],
warnings: string[],
): CollectionNode | null {
const name = str(it.name) || str(it.title) || "未命名请求";
const method = normalizeMethod(str(it.method) || "GET");
const request = asObj(it.request);
// url 可能是字符串或对象
let url = "";
if (typeof request.url === "string") url = request.url;
else {
url = str(asObj(request.url).raw ?? asObj(it.url).raw ?? it.url ?? "");
}
const headers: HttpRequestData["headers"] = [];
const query: HttpRequestData["query"] = [];
// params/headers 可能是数组或对象
const paramContainer = request.parameter ?? request.params ?? it.parameter;
collectKV(paramContainer, query);
const headerContainer = request.headers ?? request.header ?? it.headers;
collectKV(headerContainer, headers);
const body = asObj(request.body ?? it.request_body);
const mode = str(body.mode ?? body.type ?? "none");
let bodyMode: HttpRequestData["bodyMode"] = "none";
let bodyRaw = "";
let bodyForm: HttpRequestData["bodyForm"] = [];
if (mode === "json" || mode === "raw") {
bodyRaw = str(body.raw ?? body.text ?? "");
bodyMode = looksLikeJson(bodyRaw) || mode === "json" ? "json" : "text";
if (bodyMode === "json") bodyRaw = formatJson(bodyRaw);
} else if (mode === "urlencoded" || mode === "urlencoded[]") {
bodyMode = "urlencoded";
collectKV(body.parameter ?? body.params, bodyForm);
} else if (mode === "form-data" || mode === "formdata") {
bodyMode = "form-data";
collectKV(body.parameter ?? body.params, bodyForm);
} else if (mode === "xml") {
bodyMode = "xml";
bodyRaw = str(body.raw ?? "");
} else if (mode === "binary" || mode === "file") {
warnings.push(`${name}:binary body 不支持导入`);
}
const auth = asObj(request.auth ?? it.auth);
let authConfig: HttpRequestData["auth"] = { mode: "none" };
const authType = str(auth.type ?? auth.mode);
if (authType === "bearer") authConfig = { mode: "bearer", bearer: str(auth.bearer ?? auth.token) };
if (authType === "basic") authConfig = { mode: "basic", basicUser: str(auth.user), basicPass: str(auth.password) };
return createRequest("http", name, {
data: {
kind: "http",
method,
url,
query,
headers,
cookies: [],
auth: authConfig,
bodyMode,
bodyRaw,
bodyForm,
},
description: str(it.description) || str(request.description) || undefined,
});
}
/** apipost 的 KV 容器:[{key,value}] / [{name,value}] / {k:v} 都收 */
function collectKV(container: unknown, out: HttpRequestData["headers"] | HttpRequestData["query"] | HttpRequestData["bodyForm"]): void {
if (Array.isArray(container)) {
for (const item of container) {
const itemObj = asObj(item);
const key = str(itemObj.key ?? itemObj.name);
const value = str(itemObj.value ?? "");
const fieldTypeId = itemObj.field_type ?? itemObj.fieldType;
const disabled = itemObj.is_checked === 0 || itemObj.disabled === true || itemObj.enable === false;
if (key && str(fieldTypeId) !== "2") out.push(kv(key, value, !disabled));
}
} else if (container && typeof container === "object") {
for (const [k, v] of Object.entries(container as Obj)) {
const vObj = asObj(v);
if (typeof v === "string") out.push(kv(k, v));
else if (vObj.value !== undefined || vObj.key !== undefined) out.push(kv(str(vObj.key ?? k), str(vObj.value)));
}
}
}
function looksLikeJson(s: string): boolean {
const t = s.trimStart();
return t.startsWith("{") || t.startsWith("[");
}
function formatJson(s: string): string {
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return s;
}
}
function countNodes(nodes: CollectionNode[]): number {
let n = 0;
for (const node of nodes) {
if (node.type === "request") n++;
else n += countNodes(node.children);
}
return n;
}
+386
View File
@@ -0,0 +1,386 @@
/**
* cURL 命令解析器(覆盖 curl 常用 flag,容忍换行续行 / 引号 / 嵌套引号)。
*/
import type { Collection } from "../model";
import { kv } from "../factory";
import { createCollection, createRequest } from "../factory";
/** 分词:支持 '…"…'、"…'…"、\" 转义、\<newline> 续行 */
export function tokenizeCurl(input: string): string[] {
const text = input.replace(/\\\r?\n/g, " ").replace(/\r?\n/g, " ");
const tokens: string[] = [];
let i = 0;
while (i < text.length) {
while (i < text.length && /\s/.test(text[i])) i++;
if (i >= text.length) break;
let token = "";
let quote: '"' | "'" | null = null;
while (i < text.length) {
const ch = text[i];
if (quote) {
if (ch === "\\" && quote === '"') {
const next = text[i + 1];
if (next === '"' || next === "\\") {
token += next;
i += 2;
continue;
}
token += ch;
i++;
continue;
}
if (ch === quote) {
quote = null;
i++;
continue;
}
token += ch;
i++;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
i++;
continue;
}
if (/\s/.test(ch)) break;
token += ch;
i++;
}
if (token) tokens.push(token);
}
return tokens;
}
export type ParsedCurl = {
method: string;
url: string;
headers: { key: string; value: string; enabled: boolean }[];
query: { key: string; value: string; enabled: boolean }[];
cookies: { key: string; value: string; enabled: boolean }[];
bodyMode: "none" | "json" | "xml" | "text" | "html" | "urlencoded" | "form-data";
bodyRaw: string;
bodyForm: { key: string; value: string; enabled: boolean }[];
auth: { mode: "none" | "bearer" | "basic"; bearer?: string; basicUser?: string; basicPass?: string };
warnings: string[];
};
export function parseCurl(input: string): ParsedCurl {
const warnings: string[] = [];
const tokens = tokenizeCurl(input);
let url = "";
let method = "";
const headers: ParsedCurl["headers"] = [];
const formRows: ParsedCurl["bodyForm"] = [];
const dataParts: string[] = [];
let auth: ParsedCurl["auth"] = { mode: "none" };
let cookieRaw = "";
let forceJson = false;
const flagAliases: Record<string, string> = {
"-X": "--request",
"-H": "--header",
"-d": "--data",
"--data-raw": "--data",
"--data-binary": "--data",
"--data-ascii": "--data",
"--data-urlencode": "--data",
"-F": "--form",
"--form-string": "--form",
"-u": "--user",
"--user": "--user",
"-b": "--cookie",
"-A": "--user-agent",
"-e": "--referer",
"-o": "--output",
"-x": "--proxy",
};
for (let i = 0; i < tokens.length; i++) {
let t = tokens[i];
if (t === "curl") continue;
if (t === "npx" || t === "wget") warnings.push(`检测到 ${t},仅按 curl 子集解析`);
const next = (): string | undefined => {
const v = tokens[++i];
return v === "$" || v === undefined ? undefined : v;
};
if (!t.startsWith("-")) {
// 位置参数 → URL
const value = t.startsWith("$'") ? t.slice(2, -1) : t;
if (/^https?:\/\//i.test(value) || !url) url = url || value;
continue;
}
// --flag=value 形式
let inlineValue: string | undefined;
const eq = t.indexOf("=");
if (t.startsWith("--") && eq > 0) {
inlineValue = t.slice(eq + 1);
t = t.slice(0, eq);
}
const flag = flagAliases[t] ?? t;
const take = (): string => {
if (inlineValue !== undefined) return inlineValue;
const v = next();
return v ?? "";
};
switch (flag) {
case "--request":
method = take().toUpperCase();
break;
case "--header": {
const raw = take();
const idx = raw.indexOf(":");
if (idx > 0) {
const key = raw.slice(0, idx).trim();
const value = raw.slice(idx + 1).trim();
if (key.endsWith(";")) {
headers.push({ key: key.slice(0, -1), value: "", enabled: false });
} else {
headers.push({ key, value, enabled: true });
}
}
break;
}
case "--data":
case "--json": {
if (t === "--json") forceJson = true;
let v = take();
if (v.startsWith("$'") && v.endsWith("'")) v = v.slice(2, -1);
if (v === "-" || v.startsWith("@")) {
warnings.push("忽略 @file / stdin 形式的数据体");
break;
}
if (t === "--data-urlencode") {
v = encodeURIComponent(v);
}
dataParts.push(v);
break;
}
case "--form": {
const v = take();
const [k, ...rest] = v.split("=");
if (k && !k.startsWith("@")) {
formRows.push({ key: k, value: rest.join("="), enabled: true });
} else {
warnings.push(`忽略文件表单项 ${k}`);
}
break;
}
case "--user": {
const v = take();
const idx = v.indexOf(":");
auth = {
mode: "basic",
basicUser: idx >= 0 ? v.slice(0, idx) : v,
basicPass: idx >= 0 ? v.slice(idx + 1) : "",
};
break;
}
case "--cookie":
cookieRaw = take();
break;
case "--user-agent":
headers.push({ key: "User-Agent", value: take(), enabled: true });
break;
case "--referer":
headers.push({ key: "Referer", value: take(), enabled: true });
break;
case "--output":
case "--proxy":
take();
break;
default:
// -k -s -L -i --compressed 等 → 忽略;未知带值 flag 尝试吞掉一个值
if (!flag.startsWith("--") && flag.length > 2 && !inlineValue) {
// 组合短 flag(-sS)忽略
} else if (flag.startsWith("--") && inlineValue === undefined && !isNoArgFlag(flag)) {
next();
}
break;
}
}
const data = dataParts.join("&");
const inferred =
method ||
(data || formRows.length
? "POST"
: url.includes(";type=")
? "PUT"
: "GET");
const headerMap = new Map(headers.map((h) => [h.key.toLowerCase(), h]));
const contentType = headerMap.get("content-type")?.value ?? "";
let bodyMode: ParsedCurl["bodyMode"] = "none";
let bodyRaw = "";
const bodyForm: ParsedCurl["bodyForm"] = [];
if (formRows.length) {
bodyMode = "form-data";
bodyForm.push(...formRows);
} else if (data) {
if (forceJson || contentType.includes("json") || looksLikeJson(data)) {
bodyMode = "json";
bodyRaw = tryFormatJson(data);
} else if (
contentType.includes("x-www-form-urlencoded") ||
(!contentType && /^[^={\[][\s\S]*=[^=]*(&|$)/.test(data) && !data.trim().startsWith("<"))
) {
bodyMode = "urlencoded";
for (const pair of data.split("&")) {
const idx = pair.indexOf("=");
if (idx > 0) {
bodyForm.push({
key: decodeMaybe(pair.slice(0, idx)),
value: decodeMaybe(pair.slice(idx + 1)),
enabled: true,
});
}
}
} else if (contentType.includes("xml") || data.trimStart().startsWith("<")) {
bodyMode = "xml";
bodyRaw = data;
} else {
bodyMode = "text";
bodyRaw = data;
}
}
// Cookie 头 → cookies 行
const cookies: ParsedCurl["cookies"] = [];
if (cookieRaw) {
for (const pair of cookieRaw.split(";")) {
const idx = pair.indexOf("=");
if (idx > 0) {
cookies.push({ key: pair.slice(0, idx).trim(), value: pair.slice(idx + 1).trim(), enabled: true });
}
}
}
// URL 查询参数拆行
const query: ParsedCurl["query"] = [];
let normalizedUrl = url;
const qIdx = url.indexOf("?");
if (qIdx >= 0) {
normalizedUrl = url.slice(0, qIdx);
for (const pair of url.slice(qIdx + 1).split("&")) {
if (!pair) continue;
const idx = pair.indexOf("=");
query.push({
key: decodeMaybe(idx > 0 ? pair.slice(0, idx) : pair),
value: idx > 0 ? decodeMaybe(pair.slice(idx + 1)) : "",
enabled: true,
});
}
}
return {
method: inferred,
url: normalizedUrl,
headers,
query,
cookies,
bodyMode,
bodyRaw,
bodyForm,
auth,
warnings,
};
}
function isNoArgFlag(flag: string): boolean {
return [
"--compressed",
"--insecure",
"--silent",
"--verbose",
"--location",
"--include",
"--head",
"-k",
"-s",
"-S",
"-L",
"-i",
"-I",
"-v",
"-f",
"-G",
"--get",
"--no-buffer",
].includes(flag);
}
function looksLikeJson(s: string): boolean {
const t = s.trimStart();
return t.startsWith("{") || t.startsWith("[");
}
function tryFormatJson(s: string): string {
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return s;
}
}
function decodeMaybe(s: string): string {
try {
return decodeURIComponent(s.replace(/\+/g, " "));
} catch {
return s;
}
}
export function importCurl(input: string, collectionName = "cURL 导入"): Collection {
const parsed = parseCurl(input);
const name = deriveCurlName(parsed);
const node = createRequest("http", name, {
data: {
method: normalizeMethod(parsed.method),
url: parsed.url,
query: parsed.query.map((q) => kv(q.key, q.value, q.enabled)),
headers: parsed.headers
.filter((h) => h.key.toLowerCase() !== "cookie")
.map((h) => kv(h.key, h.value, h.enabled)),
cookies: parsed.cookies.map((c) => kv(c.key, c.value, c.enabled)),
auth:
parsed.auth.mode === "basic"
? { mode: "basic", basicUser: parsed.auth.basicUser, basicPass: parsed.auth.basicPass }
: parsed.auth.mode === "bearer"
? { mode: "bearer", bearer: parsed.auth.bearer }
: { mode: "none" },
bodyMode: parsed.bodyMode,
bodyRaw: parsed.bodyRaw,
bodyForm: parsed.bodyForm.map((f) => kv(f.key, f.value, f.enabled)),
},
description: parsed.warnings.join("\n") || undefined,
});
const collection = createCollection(collectionName, [node]);
collection.description = "由 cURL 命令导入";
return collection;
}
export function normalizeMethod(m: string) {
const upper = m.toUpperCase();
const allowed = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] as const;
return (allowed as readonly string[]).includes(upper)
? (upper as (typeof allowed)[number])
: "GET";
}
function deriveCurlName(parsed: ParsedCurl): string {
try {
const u = new URL(parsed.url);
const seg = u.pathname.split("/").filter(Boolean).pop();
return `${parsed.method} /${seg ?? ""}`.trim();
} catch {
return `${parsed.method} ${parsed.url}`.slice(0, 60);
}
}
+151
View File
@@ -0,0 +1,151 @@
/**
* HAR(HTTP Archive)导入器:按域名分组。
*/
import type { Collection, CollectionNode, FolderNode, HttpRequestData } from "../model";
import { createCollection, createFolder, createRequest, kv } from "../factory";
import type { ImportResult } from "../model";
import { normalizeMethod } from "./curl";
type Obj = Record<string, unknown>;
const asObj = (v: unknown): Obj => (v && typeof v === "object" ? (v as Obj) : {});
const asArr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
export function isHarDocument(doc: Obj): boolean {
const log = asObj(doc.log);
return Array.isArray(log.entries);
}
export function importHar(text: string): ImportResult {
const doc = asObj(JSON.parse(text));
if (!isHarDocument(doc)) throw new Error("不是 HAR 文件(缺少 log.entries)");
const warnings: string[] = [];
const entries = asArr(asObj(doc.log).entries);
const byHost = new Map<string, FolderNode>();
const seen = new Set<string>();
let count = 0;
for (const entryRaw of entries) {
const entry = asObj(entryRaw);
const request = asObj(entry.request);
const urlStr = str(request.url);
if (!/^https?:\/\//i.test(urlStr)) continue;
const method = normalizeMethod(str(request.method) || "GET");
const dedupeKey = `${method} ${urlStr}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
let u: URL;
try {
u = new URL(urlStr);
} catch {
warnings.push(`跳过非法 URL:${urlStr.slice(0, 60)}`);
continue;
}
const host = u.host;
let folder = byHost.get(host);
if (!folder) {
folder = createFolder(host, []);
byHost.set(host, folder);
}
// query
const query: HttpRequestData["query"] = [];
for (const q of asArr(request.queryString)) {
const qObj = asObj(q);
if (qObj.name) query.push(kv(str(qObj.name), str(qObj.value)));
}
// headers(剔除 cookie)
const headers: HttpRequestData["headers"] = [];
const cookies: HttpRequestData["cookies"] = [];
for (const h of asArr(request.headers)) {
const hObj = asObj(h);
const name = str(hObj.name);
if (!name) continue;
if (name.toLowerCase() === "cookie") {
for (const pair of str(hObj.value).split(";")) {
const idx = pair.indexOf("=");
if (idx > 0) cookies.push(kv(pair.slice(0, idx).trim(), pair.slice(idx + 1).trim()));
}
continue;
}
headers.push(kv(name, str(hObj.value)));
}
// body
const postData = asObj(request.postData);
let bodyMode: HttpRequestData["bodyMode"] = "none";
let bodyRaw = "";
let bodyForm: HttpRequestData["bodyForm"] = [];
const mime = str(postData.mimeType);
const text = str(postData.text);
if (mime.includes("json") || (!mime && looksLikeJson(text))) {
bodyMode = "json";
bodyRaw = formatJson(text);
} else if (mime.includes("x-www-form-urlencoded")) {
bodyMode = "urlencoded";
const params = asArr(postData.params);
if (params.length) {
for (const p of params) {
const pObj = asObj(p);
if (pObj.name) bodyForm.push(kv(str(pObj.name), str(pObj.value)));
}
} else {
for (const pair of text.split("&")) {
const idx = pair.indexOf("=");
if (idx > 0) bodyForm.push(kv(pair.slice(0, idx), pair.slice(idx + 1)));
}
}
} else if (mime.includes("xml")) {
bodyMode = "xml";
bodyRaw = text;
} else if (mime.includes("multipart")) {
bodyMode = "form-data";
for (const p of asArr(postData.params)) {
const pObj = asObj(p);
if (pObj.name && str(pObj.fileName) === "") bodyForm.push(kv(str(pObj.name), str(pObj.value)));
}
} else if (mime) {
bodyMode = "text";
bodyRaw = text;
}
const pathname = decodeURIComponent(u.pathname) || "/";
const node = createRequest("http", `${method} ${pathname}`, {
data: {
kind: "http",
method,
url: `${u.origin}${u.pathname}`,
query,
headers,
cookies,
auth: { mode: "none" },
bodyMode,
bodyRaw,
bodyForm,
},
});
folder.children.push(node);
count++;
}
const children: CollectionNode[] = Array.from(byHost.values());
const collection = createCollection("HAR 导入", children);
collection.description = `从 HAR 捕获导入 ${count} 个请求`;
if (count === 0) warnings.push("HAR 里没有可导入的 http(s) 请求");
return { collections: [collection], environmentVariables: [], warnings };
}
function looksLikeJson(s: string): boolean {
const t = s.trimStart();
return t.startsWith("{") || t.startsWith("[");
}
function formatJson(s: string): string {
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return s;
}
}
+135
View File
@@ -0,0 +1,135 @@
/**
* Hoppscotch 集合导入器(数组或单对象,folders/requests 递归)。
*/
import type { Collection, CollectionNode, FolderNode, HttpRequestData } from "../model";
import { createCollection, createFolder, createRequest, kv } from "../factory";
import type { ImportResult } from "../model";
import { normalizeMethod } from "./curl";
type Obj = Record<string, unknown>;
const asObj = (v: unknown): Obj => (v && typeof v === "object" ? (v as Obj) : {});
const asArr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
export function isHoppscotchCollection(doc: unknown): boolean {
const list = Array.isArray(doc) ? doc.map(asObj) : [asObj(doc)];
return list.some((c) => Array.isArray(c.requests) || Array.isArray(c.folders));
}
export function importHoppscotch(text: string): ImportResult {
const parsed: unknown = JSON.parse(text);
const roots = Array.isArray(parsed) ? parsed.map(asObj) : [asObj(parsed)];
if (!isHoppscotchCollection(parsed)) throw new Error("不是 Hoppscotch 集合(缺少 requests/folders)");
const warnings: string[] = [];
const results: Collection[] = [];
for (const root of roots) {
if (!Array.isArray(root.requests) && !Array.isArray(root.folders)) continue;
const children = convertFolder(root, warnings);
const collection = createCollection(str(root.name) || "Hoppscotch 导入", children);
results.push(collection);
}
if (results.length === 0) warnings.push("没有可导入的集合");
return { collections: results, environmentVariables: [], warnings };
}
function convertFolder(folder: Obj, warnings: string[]): CollectionNode[] {
const out: CollectionNode[] = [];
for (const f of asArr(folder.folders)) {
const fObj = asObj(f);
out.push(createFolder(str(fObj.name) || "目录", convertFolder(fObj, warnings)));
}
for (const r of asArr(folder.requests)) {
out.push(convertRequest(asObj(r), warnings));
}
return out;
}
function convertRequest(r: Obj, warnings: string[]): CollectionNode {
const method = normalizeMethod(str(r.method) || "GET");
const url = str(r.url) + str(r.path === "/" || r.path === "" ? "" : str(r.path));
const headers: HttpRequestData["headers"] = [];
for (const h of asArr(r.headers)) {
const hObj = asObj(h);
if (hObj.key) headers.push(kv(str(hObj.key), str(hObj.value), hObj.active !== false));
}
const query: HttpRequestData["query"] = [];
for (const p of asArr(r.params)) {
const pObj = asObj(p);
if (pObj.key) query.push(kv(str(pObj.key), str(pObj.value), pObj.active !== false));
}
const bodyModeRaw = str(r.bodyMode ?? r.requestBodyMode ?? "none");
let bodyMode: HttpRequestData["bodyMode"] = "none";
let bodyRaw = "";
let bodyForm: HttpRequestData["bodyForm"] = [];
if (bodyModeRaw === "raw" || bodyModeRaw === "application/json" || bodyModeRaw === "application/xml" || bodyModeRaw === "text/plain") {
bodyRaw = str(r.rawRequestBody ?? r.rawParams ?? "");
bodyMode = bodyModeRaw.includes("xml") ? "xml" : looksLikeJson(bodyRaw) ? "json" : "text";
} else if (bodyModeRaw === "urlencoded" || bodyModeRaw === "application/x-www-form-urlencoded") {
bodyMode = "urlencoded";
for (const p of asArr(r.bodyParams ?? r.params)) {
const pObj = asObj(p);
if (pObj.key) bodyForm.push(kv(str(pObj.key), str(pObj.value), pObj.active !== false));
}
} else if (bodyModeRaw === "formdata" || bodyModeRaw === "multipart/form-data") {
bodyMode = "form-data";
for (const p of asArr(r.bodyParams)) {
const pObj = asObj(p);
if (pObj.key && !str(pObj.value).startsWith("@file")) bodyForm.push(kv(str(pObj.key), str(pObj.value), pObj.active !== false));
}
}
let auth: HttpRequestData["auth"] = { mode: "none" };
const authType = str(r.auth);
if (authType === "bearer") auth = { mode: "bearer", bearer: str(r.bearerToken ?? r.httpUser) };
if (authType === "basic")
auth = { mode: "basic", basicUser: str(r.httpUser), basicPass: str(r.httpPassword) };
if (authType === "oauth2") auth = { mode: "bearer", bearer: str(r.oauth2Token ?? "") };
if (r.graphqlQuery !== undefined || str(r.requestType).includes("graphql")) {
return createRequest("graphql", str(r.name) || "GraphQL", {
data: {
kind: "graphql",
url,
headers,
auth,
query: str(r.graphqlQuery) || "query {\n \n}",
variables: safeStringify(r.graphqlVariables) || "{}",
},
});
}
return createRequest("http", str(r.name) || `${method} 请求`, {
data: {
kind: "http",
method,
url,
query,
headers,
cookies: [],
auth,
bodyMode,
bodyRaw,
bodyForm,
},
});
}
function looksLikeJson(s: string): boolean {
const t = s.trimStart();
return t.startsWith("{") || t.startsWith("[");
}
function safeStringify(v: unknown): string {
if (typeof v === "string") return v;
try {
return JSON.stringify(v, null, 2) ?? "";
} catch {
return "";
}
}
+138
View File
@@ -0,0 +1,138 @@
/**
* 导入入口:格式自动识别 + 统一 importWorkspaceData()。
*/
import type { Collection, ImportResult } from "../model";
import { importCurl, tokenizeCurl, parseCurl } from "./curl";
import { importOpenApi, isOpenApiDocument, parseSpecDocument } from "./openapi";
import { importPostman, isPostmanCollection } from "./postman";
import { importHar, isHarDocument } from "./har";
import { importInsomnia, isInsomniaExport } from "./insomnia";
import { importHoppscotch, isHoppscotchCollection } from "./hoppscotch";
import { importApipost, isApipostExport } from "./apipost";
import { importMarkdown, looksLikeMarkdown } from "./markdown";
export type ImportFormat =
| "openapi"
| "postman"
| "curl"
| "har"
| "insomnia"
| "hoppscotch"
| "apipost"
| "markdown"
| "backup"
| "unknown";
export const FORMAT_LABELS: Record<ImportFormat, string> = {
openapi: "OpenAPI / Swagger / Apifox",
postman: "Postman Collection",
curl: "cURL 命令",
har: "HAR 抓包文件",
insomnia: "Insomnia 导出",
hoppscotch: "Hoppscotch 集合",
apipost: "Apipost 项目",
markdown: "Markdown 文档",
backup: "OpsxAPI 备份",
unknown: "未知格式",
};
export function detectImportFormat(text: string): ImportFormat {
const trimmed = text.trim();
if (/^(curl|wget)\b/.test(trimmed) || (trimmed.startsWith("npx ") && trimmed.includes(" curl"))) {
return "curl";
}
let json: unknown = null;
let isJson = false;
try {
json = JSON.parse(trimmed);
isJson = true;
} catch {
isJson = false;
}
if (isJson && json && typeof json === "object") {
const doc = json as Record<string, unknown>;
if (isOpenApiDocument(doc)) return "openapi";
if (isPostmanCollection(doc)) return "postman";
if (isHarDocument(doc)) return "har";
if (isInsomniaExport(doc)) return "insomnia";
if (isApipostExport(doc)) return "apipost";
if (isHoppscotchCollection(json)) return "hoppscotch";
if (isOpsxBackup(doc)) return "backup";
// 数组也可能是 hoppscotch
if (Array.isArray(json) && isHoppscotchCollection(json)) return "hoppscotch";
}
// YAML(OpenAPI 常见)
if (!isJson && /^(openapi|swagger)\s*:/m.test(trimmed)) {
try {
const doc = parseSpecDocument(trimmed);
if (isOpenApiDocument(doc)) return "openapi";
} catch {
/* fallthrough */
}
}
if (looksLikeMarkdown(trimmed)) return "markdown";
return "unknown";
}
export function isOpsxBackup(doc: Record<string, unknown>): boolean {
return doc.__opsxapi__ === "backup" || (doc.version === 2 && Array.isArray(doc.collections));
}
export function importByFormat(text: string, format: ImportFormat): ImportResult {
switch (format) {
case "openapi":
return importOpenApi(text);
case "postman":
return importPostman(text);
case "curl":
return { collections: [importCurl(text)], environmentVariables: [], warnings: [] };
case "har":
return importHar(text);
case "insomnia":
return importInsomnia(text);
case "hoppscotch":
return importHoppscotch(text);
case "apipost":
return importApipost(text);
case "markdown":
return importMarkdown(text);
case "backup":
return importBackup(text);
case "unknown": {
const detected = detectImportFormat(text);
if (detected !== "unknown") return importByFormat(text, detected);
throw new Error("无法识别格式。支持:OpenAPI/Swagger、Postman、Apifox、Apipost、cURL、HAR、Insomnia、Hoppscotch、Markdown、OpsxAPI 备份。");
}
}
}
/** 自动识别并导入 */
export function importAuto(text: string): ImportResult {
const format = detectImportFormat(text);
return importByFormat(text, format);
}
export function importBackup(text: string): ImportResult {
const doc = JSON.parse(text) as { collections?: Collection[] };
if (!Array.isArray(doc.collections)) throw new Error("备份文件缺少 collections");
return {
collections: doc.collections,
environmentVariables: [],
warnings: [],
};
}
export { tokenizeCurl, parseCurl, importCurl };
export { importOpenApi, isOpenApiDocument, parseSpecDocument };
export { importPostman, isPostmanCollection };
export { importHar, isHarDocument };
export { importInsomnia, isInsomniaExport };
export { importHoppscotch, isHoppscotchCollection };
export { importApipost, isApipostExport };
export { importMarkdown, looksLikeMarkdown };
+203
View File
@@ -0,0 +1,203 @@
/**
* Insomnia 导出文件(export_format v3/v4)导入器。
*/
import type { Collection, CollectionNode, FolderNode, HttpRequestData } from "../model";
import { createCollection, createFolder, createRequest, kv } from "../factory";
import type { ImportResult } from "../model";
import { normalizeMethod } from "./curl";
type Obj = Record<string, unknown>;
const asObj = (v: unknown): Obj => (v && typeof v === "object" ? (v as Obj) : {});
const asArr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
export function isInsomniaExport(doc: Obj): boolean {
if (str(doc._type) === "export" && Array.isArray(doc.resources)) return true;
// v4 嵌套格式:resources 里是 workspace
return Array.isArray(doc.resources) && asArr(doc.resources).some((r) => asObj(r)._type === "workspace");
}
export function importInsomnia(text: string): ImportResult {
const doc = asObj(JSON.parse(text));
if (!isInsomniaExport(doc)) throw new Error("不是 Insomnia 导出文件(缺少 resources)");
const warnings: string[] = [];
const resources = asArr(doc.resources).map(asObj);
const workspaces = resources.filter((r) => str(r._type) === "workspace");
const results: Collection[] = [];
for (const ws of workspaces) {
const scopeIds = new Set<string>([str(ws._id)]);
// 找出属于该 workspace 的所有 resource(递归 parentId)
const byParent = new Map<string, Obj[]>();
for (const r of resources) {
if (str(r._type) === "workspace") continue;
const parent = str(r.parentId);
if (!byParent.has(parent)) byParent.set(parent, []);
byParent.get(parent)!.push(r);
}
const children = collectChildren(scopeIds, byParent, warnings);
const collection = createCollection(str(ws.name) || "Insomnia 导入", children);
results.push(collection);
}
if (results.length === 0) {
// 无 workspace(v3):全部平铺
const byParent = new Map<string, Obj[]>();
for (const r of resources) {
if (str(r._type) === "workspace") continue;
const parent = str(r.parentId) || "__root__";
if (!byParent.has(parent)) byParent.set(parent, []);
byParent.get(parent)!.push(r);
}
const children = collectChildren(new Set(["__root__"]), byParent, warnings);
results.push(createCollection("Insomnia 导入", children));
}
let total = 0;
for (const c of results) total += countNodes(c.children);
if (total === 0) warnings.push("没有可导入的请求");
return { collections: results, environmentVariables: [], warnings };
}
function collectChildren(
parentIds: Set<string>,
byParent: Map<string, Obj[]>,
warnings: string[],
): CollectionNode[] {
const out: CollectionNode[] = [];
for (const id of parentIds) {
for (const r of byParent.get(id) ?? []) {
const type = str(r._type);
if (type === "request_group") {
const folder = createFolder(str(r.name) || "目录", collectChildren(new Set([str(r._id)]), byParent, warnings));
if (r.description) folder.description = str(r.description);
out.push(folder);
} else if (type === "request") {
out.push(convertRequest(r, warnings));
}
}
}
return out;
}
function convertRequest(r: Obj, warnings: string[]): CollectionNode {
const url = str(r.url) || str(asObj(r.url).raw) || "";
const method = normalizeMethod(str(r.method) || "GET");
const headers: HttpRequestData["headers"] = [];
for (const h of asArr(r.headers)) {
const hObj = asObj(h);
if (hObj.name && str(hObj.name).toLowerCase() !== "cookie") {
headers.push(kv(str(hObj.name), str(hObj.value), hObj.disabled !== true));
}
}
const query: HttpRequestData["query"] = [];
let baseUrl = url;
for (const p of asArr(r.parameters)) {
const pObj = asObj(p);
if (pObj.name) query.push(kv(str(pObj.name), str(pObj.value), pObj.disabled !== true));
}
const body = asObj(r.body);
const mime = str(body.mimeType);
let bodyMode: HttpRequestData["bodyMode"] = "none";
let bodyRaw = "";
let bodyForm: HttpRequestData["bodyForm"] = [];
if (mime.includes("graphql")) {
const gqlText = typeof body.text === "string" ? body.text : "";
const gql = asObj(gqlText ? safeJsonParse(gqlText) : {});
return createRequest("graphql", str(r.name) || "GraphQL", {
data: {
kind: "graphql",
url: baseUrl,
headers,
auth: authFromInsomnia(asObj(r.authentication)),
query: str(gql.query) || "query {\n \n}",
variables: safeStringify(gql.variables) || "{}",
},
});
}
if (mime.includes("urlencoded")) {
bodyMode = "urlencoded";
for (const p of asArr(body.params)) {
const pObj = asObj(p);
if (pObj.name) bodyForm.push(kv(str(pObj.name), str(pObj.value), pObj.disabled !== true));
}
} else if (mime.includes("form-data") || mime.includes("multipart")) {
bodyMode = "form-data";
for (const p of asArr(body.params)) {
const pObj = asObj(p);
if (pObj.name && !pObj.file) bodyForm.push(kv(str(pObj.name), str(pObj.value), pObj.disabled !== true));
}
} else if (mime.includes("json")) {
bodyMode = "json";
bodyRaw = formatJson(str(body.text));
} else if (mime) {
bodyMode = mime.includes("xml") ? "xml" : mime.includes("html") ? "html" : "text";
bodyRaw = str(body.text);
}
return createRequest("http", str(r.name) || `${method} 请求`, {
data: {
kind: "http",
method,
url: baseUrl,
query,
headers,
cookies: [],
auth: authFromInsomnia(asObj(r.authentication)),
bodyMode,
bodyRaw,
bodyForm,
},
description: str(r.description) || undefined,
});
}
function authFromInsomnia(auth: Obj): HttpRequestData["auth"] {
const type = str(auth.type);
if (type === "bearer") return { mode: "bearer", bearer: str(auth.token) };
if (type === "basic") return { mode: "basic", basicUser: str(auth.username), basicPass: str(auth.password) };
if (type === "apikey" || type === "api_key")
return {
mode: "apikey",
apiKeyName: str(auth.key),
apiKeyValue: str(auth.value),
apiKeyIn: str(auth.in) === "query" ? "query" : "header",
};
return { mode: "none" };
}
function safeJsonParse(s: string): unknown {
try {
return JSON.parse(s);
} catch {
return {};
}
}
function safeStringify(v: unknown): string {
try {
return JSON.stringify(v, null, 2) ?? "";
} catch {
return "";
}
}
function formatJson(s: string): string {
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return s;
}
}
function countNodes(nodes: CollectionNode[]): number {
let n = 0;
for (const node of nodes) {
if (node.type === "request") n++;
else n += countNodes(node.children);
}
return n;
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Markdown API 文档导入器:提取 curl 代码块(及可选 JSON 请求体示例)。
*/
import type { Collection } from "../model";
import { createCollection, createFolder } from "../factory";
import type { ImportResult } from "../model";
import { importCurl } from "./curl";
export function looksLikeMarkdown(text: string): boolean {
const t = text.trimStart();
if (!t.startsWith("#") && !t.includes("```")) return false;
return /^#{1,6}\s/m.test(text) || /```/.test(text);
}
export function importMarkdown(text: string): ImportResult {
const warnings: string[] = [];
const blocks: { heading: string; code: string }[] = [];
const lines = text.split(/\r?\n/);
let heading = "";
let inBlock = false;
let lang = "";
let buf: string[] = [];
for (const line of lines) {
const fence = line.match(/^\s*```(\w*)\s*$/);
if (fence) {
if (!inBlock) {
inBlock = true;
lang = fence[1].toLowerCase();
buf = [];
} else {
inBlock = false;
const code = buf.join("\n");
if (["curl", "bash", "sh", "shell", ""].includes(lang) && code.trim().startsWith("curl")) {
blocks.push({ heading: heading || `curl ${blocks.length + 1}`, code });
}
}
continue;
}
if (inBlock) {
buf.push(line);
continue;
}
const h = line.match(/^#{1,6}\s+(.*)$/);
if (h) heading = h[1].trim();
}
if (blocks.length === 0) {
warnings.push("未在 Markdown 中找到 curl 代码块(也支持直接粘贴 cURL 导入)");
}
const folders = new Map<string, ReturnType<typeof createFolder>>();
const rootChildren = [];
for (const block of blocks) {
try {
const temp = importCurl(block.code, "temp");
const node = temp.children[0];
if (!node) continue;
// 一级标题做目录,二级及以下做请求名
const parts = block.heading.split(/[::]/);
const folderName = parts[0]?.trim();
if (folderName && folders.has(folderName)) {
folders.get(folderName)!.children.push(node);
} else if (folderName) {
const f = createFolder(folderName, [node]);
folders.set(folderName, f);
} else {
rootChildren.push(node);
}
} catch (e) {
warnings.push(`解析代码块失败:${e instanceof Error ? e.message : String(e)}`);
}
}
const collection = createCollection(
firstHeading(text) || "Markdown 导入",
[...folders.values(), ...rootChildren],
);
return { collections: [collection], environmentVariables: [], warnings };
}
function firstHeading(text: string): string {
const m = text.match(/^#\s+(.+)$/m);
return m ? m[1].trim().slice(0, 60) : "";
}
+400
View File
@@ -0,0 +1,400 @@
/**
* OpenAPI 3.1 / 3.0 / Swagger 2.0 导入器(JSON 或 YAML;Apifox 导出的 OpenAPI 格式同样适用)。
*/
import { load as yamlLoad } from "js-yaml";
import type {
Collection,
CollectionNode,
FolderNode,
HttpRequestData,
RequestMethod,
} from "../model";
import { createCollection, createFolder, createRequest, kv } from "../factory";
import type { ImportResult } from "../model";
type Obj = Record<string, unknown>;
const asObj = (v: unknown): Obj => (v && typeof v === "object" ? (v as Obj) : {});
const asArr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
const str = (v: unknown): string => (typeof v === "string" ? v : v === undefined || v === null ? "" : String(v));
const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"];
export function parseSpecDocument(text: string): Obj {
const trimmed = text.trim();
if (trimmed.startsWith("{")) {
return asObj(JSON.parse(trimmed));
}
return asObj(yamlLoad(trimmed));
}
export function isOpenApiDocument(doc: Obj): boolean {
return "openapi" in doc || "swagger" in doc;
}
export function importOpenApi(text: string): ImportResult {
const doc = parseSpecDocument(text);
if (!isOpenApiDocument(doc)) throw new Error("不是 OpenAPI/Swagger 文档(缺少 openapi/swagger 字段)");
const version = str(doc.openapi || doc.swagger);
const info = asObj(doc.info);
const warnings: string[] = [];
const baseUrl = resolveBaseUrl(doc);
const envVars: { key: string; value: string }[] = [];
if (baseUrl) envVars.push({ key: "baseUrl", value: baseUrl });
const securitySchemes = asObj(
version.startsWith("2")
? doc.securityDefinitions
: asObj(doc.components).securitySchemes,
);
// tag → folder
const tagOrder: string[] = [];
const tagDesc: Record<string, string> = {};
for (const t of asArr(doc.tags)) {
const tag = asObj(t);
if (tag.name) {
tagOrder.push(str(tag.name));
if (tag.description) tagDesc[str(tag.name)] = str(tag.description);
}
}
const folderMap = new Map<string, FolderNode>();
const rootChildren: CollectionNode[] = [];
const ensureFolder = (tag: string): FolderNode | null => {
if (!tag) return null;
let f = folderMap.get(tag);
if (!f) {
f = createFolder(tag, []);
if (tagDesc[tag]) f.description = tagDesc[tag];
folderMap.set(tag, f);
}
return f;
};
const paths = asObj(doc.paths);
let count = 0;
for (const [path, pathItemRaw] of Object.entries(paths)) {
const pathItem = asObj(pathItemRaw);
for (const method of HTTP_METHODS) {
const opRaw = pathItem[method];
if (!opRaw) continue;
const op = asObj(opRaw);
count++;
const node = buildOperationNode({
method: method.toUpperCase() as RequestMethod,
path,
op,
doc,
version,
securitySchemes,
baseUrl,
envVars,
warnings,
name: str(op.summary) || str(op.operationId) || `${method.toUpperCase()} ${path}`,
});
const tags = asArr(op.tags).map(str).filter(Boolean);
const folder = tags.length ? ensureFolder(tags[0]) : null;
if (folder) folder.children.push(node);
else rootChildren.push(node);
}
}
if (count === 0) warnings.push("文档中没有可导入的路径操作");
const children: CollectionNode[] = [
...tagOrder.filter((t) => folderMap.has(t)).map((t) => folderMap.get(t)!),
...Array.from(folderMap.values()).filter((f) => !tagOrder.includes(f.name)),
...rootChildren,
];
const collection = createCollection(str(info.title) || "OpenAPI 导入", children);
collection.description = str(info.description) || undefined;
return { collections: [collection], environmentVariables: envVars, warnings };
}
function resolveBaseUrl(doc: Obj): string {
// v3: servers[]; v2: host + basePath + schemes
const servers = asArr(doc.servers);
if (servers.length) {
const first = asObj(servers[0]);
let url = str(first.url);
const vars = asObj(first.variables);
for (const [k, v] of Object.entries(vars)) {
const def = asObj(v).default;
if (def !== undefined) url = url.replaceAll(`{${k}}`, str(def));
}
if (url) return url.replace(/\/$/, "");
}
const host = str(doc.host);
if (host) {
const schemes = asArr(doc.schemes).map(str);
const scheme = schemes.includes("https") ? "https" : schemes[0] || "https";
const basePath = str(doc.basePath) || "";
return `${scheme}://${host}${basePath}`.replace(/\/$/, "");
}
return "";
}
function buildOperationNode(ctx: {
method: RequestMethod;
path: string;
op: Obj;
doc: Obj;
version: string;
securitySchemes: Obj;
baseUrl: string;
envVars: { key: string; value: string }[];
warnings: string[];
name: string;
}): CollectionNode {
const { method, path, op, doc, version, securitySchemes, baseUrl, envVars, warnings } = ctx;
// path 参数 {id} → {{id}}
let urlPath = path;
const pathParams: { name: string; example: string }[] = [];
for (const p of asArr(asObj(op).parameters ?? asObj(asObj(doc.paths)[path]).parameters)) {
const param = asObj(p);
if (str(param.in) === "path" && param.name) {
pathParams.push({ name: str(param.name), example: exampleOf(param) || "1" });
}
}
for (const { name, example } of pathParams) {
urlPath = urlPath.replaceAll(`{${name}}`, `{{${name}}}`);
if (!envVars.some((v) => v.key === name)) envVars.push({ key: name, value: example });
}
// 统一使用 {{baseUrl}} 前缀(服务器地址写入环境变量),保持环境切换能力
const url = `{{baseUrl}}${urlPath.startsWith("/") ? urlPath : `/${urlPath.replace(/^\{\{baseUrl\}\}?\/?/, "/")}`}`;
const query: HttpRequestData["query"] = [];
const headers: HttpRequestData["headers"] = [];
let auth: HttpRequestData["auth"] = { mode: "none" };
for (const p of asArr(op.parameters)) {
const param = asObj(p);
if (!param.name) continue;
const in_ = str(param.in);
const row = kv(str(param.name), exampleOf(param), true, str(param.description));
if (in_ === "query") query.push(row);
else if (in_ === "header") headers.push(row);
}
// v2 顶层 parameters(path 级)
const pathLevel = asArr(asObj(asObj(doc.paths)[path]).parameters);
for (const p of pathLevel) {
const param = asObj(p);
if (!param.name) continue;
const in_ = str(param.in);
if (in_ === "query") query.push(kv(str(param.name), exampleOf(param), true, str(param.description)));
else if (in_ === "header") headers.push(kv(str(param.name), exampleOf(param), true, str(param.description)));
}
// 鉴权
const securities = asArr(op.security ?? doc.security);
if (securities.length) {
const first = asObj(securities[0]);
const schemeName = Object.keys(first)[0];
if (schemeName && securitySchemes[schemeName] !== undefined) {
const scheme = asObj(securitySchemes[schemeName]);
const type = str(scheme.type);
if (version.startsWith("2")) {
if (type === "basic") auth = { mode: "basic", basicUser: "", basicPass: "" };
else if (type === "apiKey")
auth = {
mode: "apikey",
apiKeyName: str(scheme.name),
apiKeyIn: str(scheme.in) === "query" ? "query" : "header",
apiKeyValue: "",
};
} else {
if (type === "http" && str(scheme.scheme) === "bearer") auth = { mode: "bearer", bearer: "" };
else if (type === "http" && str(scheme.scheme) === "basic") auth = { mode: "basic", basicUser: "", basicPass: "" };
else if (type === "apiKey")
auth = {
mode: "apikey",
apiKeyName: str(scheme.name),
apiKeyIn: str(scheme.in) === "query" ? "query" : "header",
apiKeyValue: "",
};
else if (type === "oauth2") auth = { mode: "bearer", bearer: "" };
}
}
}
// 请求体
let bodyMode: HttpRequestData["bodyMode"] = "none";
let bodyRaw = "";
let bodyForm: HttpRequestData["bodyForm"] = [];
if (version.startsWith("2")) {
for (const p of [...asArr(op.parameters), ...pathLevel]) {
const param = asObj(p);
if (str(param.in) === "body") {
const consumes = asArr(op.consumes).map(str);
const mime = consumes[0] ?? "application/json";
const example = schemaToExample(asObj(param.schema), doc, 0);
if (mime.includes("json")) {
bodyMode = "json";
bodyRaw = safeStringify(example);
} else if (mime.includes("xml")) {
bodyMode = "xml";
bodyRaw = safeStringify(example);
} else {
bodyMode = "text";
bodyRaw = safeStringify(example);
}
}
if (str(param.in) === "formData") {
bodyMode = asArr(op.consumes).map(str)[0]?.includes("urlencoded") ? "urlencoded" : "form-data";
bodyForm.push(kv(str(param.name), exampleOf(param), true, str(param.description)));
}
}
} else {
const requestBody = asObj(op.requestBody);
const content = asObj(requestBody.content);
const mimes = Object.keys(content);
if (mimes.length) {
const mime =
mimes.find((m) => m.includes("json")) ??
mimes.find((m) => m.includes("xml")) ??
mimes.find((m) => m.includes("urlencoded")) ??
mimes.find((m) => m.includes("form-data")) ??
mimes.find((m) => m.includes("text")) ??
mimes[0];
const media = asObj(content[mime]);
if (mime.includes("urlencoded")) {
bodyMode = "urlencoded";
const schema = asObj(media.schema);
for (const [k, v] of Object.entries(asObj(schema.properties))) {
bodyForm.push(kv(k, schemaFieldExample(asObj(v)), true, str(asObj(v).description)));
}
if (media.example) bodyRaw = safeStringify(media.example);
} else if (mime.includes("form-data") || mime.includes("multipart")) {
bodyMode = "form-data";
const schema = asObj(media.schema);
for (const [k, v] of Object.entries(asObj(schema.properties))) {
bodyForm.push(kv(k, schemaFieldExample(asObj(v)), true, str(asObj(v).description)));
}
} else {
const example =
media.example !== undefined
? media.example
: firstExampleObject(media.examples) ?? schemaToExample(asObj(media.schema), doc, 0);
if (mime.includes("xml")) {
bodyMode = "xml";
bodyRaw = str(example);
} else if (mime.includes("text") || mime.includes("html")) {
bodyMode = mime.includes("html") ? "html" : "text";
bodyRaw = str(example);
} else {
bodyMode = "json";
bodyRaw = safeStringify(example);
}
}
}
}
if (bodyMode !== "none" && !headers.some((h) => h.key.toLowerCase() === "content-type")) {
if (bodyMode === "json") headers.unshift(kv("Content-Type", "application/json"));
if (bodyMode === "xml") headers.unshift(kv("Content-Type", "application/xml"));
if (bodyMode === "urlencoded")
headers.unshift(kv("Content-Type", "application/x-www-form-urlencoded"));
}
const descriptionParts: string[] = [];
if (op.description) descriptionParts.push(str(op.description));
if (op.deprecated === true) descriptionParts.push("> ⚠️ 已废弃(deprecated)");
return createRequest("http", ctx.name.slice(0, 120), {
data: {
kind: "http",
method,
url,
query,
headers,
cookies: [],
auth,
bodyMode,
bodyRaw,
bodyForm,
},
description: descriptionParts.join("\n\n") || undefined,
});
}
function exampleOf(param: Obj): string {
if (param.example !== undefined) return str(param.example);
const ex = asObj(param.examples);
const first = Object.values(ex)[0];
if (first !== undefined) return str(asObj(first).value);
const schema = asObj(param.schema);
if (schema.default !== undefined) return str(schema.default);
if (schema.example !== undefined) return str(schema.example);
const t = str(schema.type);
if (t === "integer" || t === "number") return "1";
if (t === "boolean") return "true";
if (Array.isArray(schema.enum) && schema.enum.length) return str(schema.enum[0]);
return "";
}
function firstExampleObject(examples: unknown): unknown {
const first = Object.values(asObj(examples))[0];
return asObj(first).value;
}
function schemaFieldExample(field: Obj): string {
if (field.example !== undefined) return str(field.example);
if (field.default !== undefined) return str(field.default);
if (Array.isArray(field.enum) && field.enum.length) return str(field.enum[0]);
const t = str(field.type);
if (t === "integer" || t === "number") return "0";
if (t === "boolean") return "true";
return "";
}
/** 从 JSON Schema 生成示例值(解析 $ref,深度封顶) */
export function schemaToExample(schema: Obj, doc: Obj, depth: number): unknown {
if (!schema || depth > 6) return null;
if (schema.example !== undefined) return schema.example;
if (schema.default !== undefined) return schema.default;
if (Array.isArray(schema.enum) && schema.enum.length) return schema.enum[0];
const resolved = resolveRef(schema, doc);
if (resolved !== schema) return schemaToExample(resolved, doc, depth + 1);
const t = str(schema.type);
if (t === "object" || schema.properties) {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(asObj(schema.properties))) {
out[k] = schemaToExample(asObj(v), doc, depth + 1);
}
return out;
}
if (t === "array" || schema.items) {
return [schemaToExample(asObj(schema.items), doc, depth + 1)];
}
if (t === "integer" || t === "number") return 0;
if (t === "boolean") return true;
return "";
}
function resolveRef(schema: Obj, doc: Obj): Obj {
const ref = str(schema.$ref);
if (!ref) return schema;
// #/components/schemas/X 或 #/definitions/X
const parts = ref.replace(/^#\//, "").split("/");
let cur: unknown = doc;
for (const part of parts) {
cur = asObj(cur)[part];
if (cur === undefined) return schema;
}
return asObj(cur);
}
function safeStringify(v: unknown): string {
if (typeof v === "string") return v;
try {
return JSON.stringify(v, null, 2) ?? "";
} catch {
return "";
}
}
+228
View File
@@ -0,0 +1,228 @@
/**
* Postman Collection v2.0/v2.1 导入器。
*/
import type {
Collection,
CollectionNode,
FolderNode,
HttpRequestData,
RequestMethod,
} from "../model";
import { createCollection, createFolder, createRequest, kv } from "../factory";
import type { ImportResult } from "../model";
import { normalizeMethod } from "./curl";
type Obj = Record<string, unknown>;
const asObj = (v: unknown): Obj => (v && typeof v === "object" ? (v as Obj) : {});
const asArr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
export function isPostmanCollection(doc: Obj): boolean {
const info = asObj(doc.info);
const schema = str(info.schema);
return schema.includes("postman") || Array.isArray(doc.item) || info._postman_id !== undefined;
}
export function importPostman(text: string): ImportResult {
const doc = asObj(JSON.parse(text));
if (!isPostmanCollection(doc)) throw new Error("不是 Postman Collection(缺少 item/info.schema)");
const warnings: string[] = [];
const envVars: { key: string; value: string }[] = [];
const root = createFolder("root", []);
convertItems(asArr(doc.item), root, envVars, warnings);
// collection.variable → 环境变量
for (const v of asArr(doc.variable)) {
const varObj = asObj(v);
const key = str(varObj.key);
if (key) envVars.push({ key, value: str(varObj.value) });
}
const info = asObj(doc.info);
const collection = createCollection(str(info.name) || "Postman 导入", root.children);
collection.description = str(info.description) || undefined;
let count = 0;
const countReqs = (nodes: CollectionNode[]) => {
for (const n of nodes) {
if (n.type === "request") count++;
else countReqs(n.children);
}
};
countReqs(collection.children);
if (count === 0) warnings.push("集合里没有请求");
return { collections: [collection], environmentVariables: dedupeVars(envVars), warnings };
}
function convertItems(
items: unknown[],
folder: FolderNode,
envVars: { key: string; value: string }[],
warnings: string[],
): void {
for (const itemRaw of items) {
const item = asObj(itemRaw);
if (Array.isArray(item.item)) {
const sub = createFolder(str(item.name) || "目录", []);
if (item.description) sub.description = str(item.description);
folder.children.push(sub);
convertItems(item.item, sub, envVars, warnings);
continue;
}
const request = item.request;
if (request === undefined) continue;
if (typeof request === "string") {
// url-only 形式,跳过
warnings.push(`跳过仅有 URL 的 item:${str(item.name)}`);
continue;
}
folder.children.push(convertRequest(asObj(request), str(item.name) || "未命名请求", envVars));
}
}
function convertRequest(
request: Obj,
name: string,
envVars: { key: string; value: string }[],
): CollectionNode {
const urlObj = typeof request.url === "string" ? { raw: request.url } : asObj(request.url);
// URL:raw 优先;query 行会拼进 raw,因此从 raw 里剥掉 query 后再拼 query 行
let raw = str(urlObj.raw);
let hostPath = raw;
if (!raw) {
const host = asArr(urlObj.host).map((s) => str(s)).join(".");
const path = asArr(urlObj.path).map((s) => str(s)).join("/");
const protocol = str(urlObj.protocol) || "https";
const port = str(urlObj.port) ? `:${str(urlObj.port)}` : "";
hostPath = `${protocol}://${host}${port}${path ? `/${path}` : ""}`;
raw = hostPath;
}
const query: HttpRequestData["query"] = [];
for (const q of asArr(urlObj.query)) {
const qObj = asObj(q);
query.push(kv(str(qObj.key), str(qObj.value), qObj.disabled !== true, str(qObj.description)));
}
// raw 自带 query 时拆出来
const qMark = hostPath.indexOf("?");
let baseUrl = hostPath;
if (qMark >= 0) {
baseUrl = hostPath.slice(0, qMark);
if (query.length === 0) {
for (const pair of hostPath.slice(qMark + 1).split("&")) {
if (!pair) continue;
const idx = pair.indexOf("=");
query.push(kv(idx > 0 ? pair.slice(0, idx) : pair, idx > 0 ? pair.slice(idx + 1) : ""));
}
}
}
// path 变量 {{var}}
for (const v of asArr(urlObj.variable)) {
const varObj = asObj(v);
if (varObj.key) envVars.push({ key: str(varObj.key), value: str(varObj.value) });
}
const headers: HttpRequestData["headers"] = [];
for (const h of asArr(request.header)) {
const hObj = asObj(h);
headers.push(kv(str(hObj.key), str(hObj.value), hObj.disabled !== true));
}
const cookies: HttpRequestData["cookies"] = [];
// body
let bodyMode: HttpRequestData["bodyMode"] = "none";
let bodyRaw = "";
let bodyForm: HttpRequestData["bodyForm"] = [];
const body = asObj(request.body);
if (body.mode === "raw") {
bodyRaw = str(body.raw);
const lang = str(asObj(asObj(body.options).raw).language);
bodyMode =
lang === "json" || looksLikeJson(bodyRaw)
? "json"
: lang === "xml"
? "xml"
: lang === "html"
? "html"
: "text";
} else if (body.mode === "urlencoded") {
bodyMode = "urlencoded";
for (const p of asArr(body.urlencoded)) {
const pObj = asObj(p);
bodyForm.push(kv(str(pObj.key), str(pObj.value), pObj.disabled !== true));
}
} else if (body.mode === "formdata") {
bodyMode = "form-data";
for (const p of asArr(body.formdata)) {
const pObj = asObj(p);
if (str(pObj.type) === "file") continue;
bodyForm.push(kv(str(pObj.key), str(pObj.value), pObj.disabled !== true));
}
} else if (body.mode === "graphql") {
const g = asObj(body.graphql);
return createRequest("graphql", name, {
data: {
kind: "graphql",
url: baseUrl,
headers,
auth: authFromPostman(asObj(request.auth)),
query: str(g.query) || "query {\n \n}",
variables: str(g.variables) || "{}",
},
});
}
return createRequest("http", name, {
data: {
kind: "http",
method: normalizeMethod(str(request.method) || "GET") as RequestMethod,
url: baseUrl,
query,
headers,
cookies,
auth: authFromPostman(asObj(request.auth)),
bodyMode,
bodyRaw,
bodyForm,
},
description: str(request.description) || undefined,
});
}
function authFromPostman(auth: Obj): HttpRequestData["auth"] {
const type = str(auth.type);
const read = (key: string): string => {
for (const kvRow of asArr(auth[type])) {
const kvObj = asObj(kvRow);
if (str(kvObj.key) === key) return str(kvObj.value);
}
return "";
};
if (type === "bearer") return { mode: "bearer", bearer: read("token") };
if (type === "basic") return { mode: "basic", basicUser: read("username"), basicPass: read("password") };
if (type === "apikey")
return {
mode: "apikey",
apiKeyName: read("key"),
apiKeyValue: read("value"),
apiKeyIn: read("in") === "query" ? "query" : "header",
};
return { mode: "none" };
}
function looksLikeJson(s: string): boolean {
const t = s.trimStart();
return t.startsWith("{") || t.startsWith("[");
}
function dedupeVars(vars: { key: string; value: string }[]) {
const map = new Map<string, string>();
for (const v of vars) if (!map.has(v.key)) map.set(v.key, v.value);
return Array.from(map, ([key, value]) => ({ key, value }));
}
+118
View File
@@ -0,0 +1,118 @@
// 传输层契约(HttpSpec / WsSpec / Transport)
export type {
HttpMethod,
HttpBodyMode,
HttpSpec,
WsSpec,
HttpResult,
WsSession,
Transport,
} from "./types";
// 集合数据模型
export type {
RequestMethod,
RequestKind,
KVRow,
BodyMode,
AuthMode,
AuthConfig,
HttpRequestData,
GraphqlRequestData,
WebsocketRequestData,
SseRequestData,
SocketIoRequestData,
MqttRequestData,
RequestData,
RequestDataOf,
RequestNodeBase,
HttpApiNode,
GraphqlApiNode,
WebsocketApiNode,
SseApiNode,
SocketIoApiNode,
MqttApiNode,
RequestNode,
FolderNode,
CollectionNode,
Collection,
Environment,
HistoryEntry,
ThemeMode,
WorkspaceSettings,
WorkspaceData,
ImportResult,
} from "./model";
export {
REQUEST_METHODS,
REQUEST_KINDS,
KIND_LABELS,
BODY_MODE_LABELS,
RAW_BODY_MODES,
} from "./model";
// 树操作
export {
newId,
walk,
findNode,
mapTree,
replaceNode,
removeNode,
insertNode,
insertIntoCollection,
moveNode,
containsId,
deepCopyNode,
uniqueName,
countRequests,
searchTree,
} from "./tree";
export type { NodeRef } from "./tree";
// 工厂 / 种子
export {
kv,
defaultRequestData,
createRequest,
defaultName,
createFolder,
createCollection,
createEnvironment,
createSeedWorkspace,
} from "./factory";
// 变量替换
export { replaceVariables, replaceVariablesInSpec } from "./replaceVariables";
// 导入
export {
detectImportFormat,
importAuto,
importByFormat,
importBackup,
FORMAT_LABELS,
} from "./importers";
export type { ImportFormat } from "./importers";
export { parseCurl, importCurl, tokenizeCurl, normalizeMethod } from "./importers/curl";
export { importOpenApi } from "./importers/openapi";
export { importPostman } from "./importers/postman";
export { importHar } from "./importers/har";
export { importInsomnia } from "./importers/insomnia";
export { importHoppscotch } from "./importers/hoppscotch";
export { importApipost } from "./importers/apipost";
export { importMarkdown } from "./importers/markdown";
// 导出
export {
exportCollectionAs,
exportBackup,
exportOpenApiWithFolders,
exportPostman,
exportMarkdown,
downloadText,
} from "./exporters";
export type { ExportFormat, OpenApiExportOptions } from "./exporters";
export { exportCurl, shellQuote, buildUrlWithQuery, authHeader } from "./exporters/curl";
export { generateCode } from "./exporters/codegen";
export type { CodeTarget } from "./exporters/codegen";
+250
View File
@@ -0,0 +1,250 @@
/**
* OpsxAPI 集合数据模型 —— UI / 导入导出 / 持久化共用的唯一事实源。
*/
export type RequestMethod =
| "GET"
| "POST"
| "PUT"
| "PATCH"
| "DELETE"
| "HEAD"
| "OPTIONS";
export const REQUEST_METHODS: RequestMethod[] = [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"HEAD",
"OPTIONS",
];
export type RequestKind = "http" | "graphql" | "websocket" | "sse" | "socketio" | "mqtt";
export const REQUEST_KINDS: RequestKind[] = [
"http",
"graphql",
"websocket",
"sse",
"socketio",
"mqtt",
];
export const KIND_LABELS: Record<RequestKind, string> = {
http: "HTTP",
graphql: "GraphQL",
websocket: "WebSocket",
sse: "SSE",
socketio: "Socket.IO",
mqtt: "MQTT",
};
/** 键值行:Params / Headers / Cookies / 表单 / 环境变量共用 */
export type KVRow = {
id: string;
key: string;
value: string;
enabled: boolean;
description?: string;
};
export type BodyMode =
| "none"
| "json"
| "xml"
| "text"
| "html"
| "urlencoded"
| "form-data";
export const BODY_MODE_LABELS: Record<BodyMode, string> = {
none: "none",
json: "JSON",
xml: "XML",
text: "Text",
html: "HTML",
urlencoded: "x-www-form-urlencoded",
"form-data": "form-data",
};
export const RAW_BODY_MODES: BodyMode[] = ["json", "xml", "text", "html"];
export type AuthMode = "none" | "bearer" | "basic" | "apikey";
export type AuthConfig = {
mode: AuthMode;
bearer?: string;
basicUser?: string;
basicPass?: string;
apiKeyName?: string;
apiKeyValue?: string;
apiKeyIn?: "header" | "query";
};
export type HttpRequestData = {
kind: "http";
method: RequestMethod;
url: string;
query: KVRow[];
headers: KVRow[];
cookies: KVRow[];
auth: AuthConfig;
bodyMode: BodyMode;
bodyRaw: string;
bodyForm: KVRow[];
};
export type GraphqlRequestData = {
kind: "graphql";
url: string;
headers: KVRow[];
auth: AuthConfig;
query: string;
variables: string;
};
export type WebsocketRequestData = {
kind: "websocket";
url: string;
headers: KVRow[];
protocols: string;
};
export type SseRequestData = {
kind: "sse";
url: string;
headers: KVRow[];
query: KVRow[];
auth: AuthConfig;
lastEventId: string;
/** 仅解析 data: 行为 JSON */
dataOnly: boolean;
};
export type SocketIoRequestData = {
kind: "socketio";
url: string;
/** 额外连接参数(JSON:auth / transports / path …) */
options: string;
/** 监听的服务端事件名 */
listenEvents: string[];
sendEvent: string;
sendArgs: string;
};
export type MqttRequestData = {
kind: "mqtt";
brokerUrl: string;
username: string;
password: string;
clientId: string;
/** 订阅主题,逗号分隔 */
subscribeTopics: string;
publishTopic: string;
publishPayload: string;
qos: 0 | 1 | 2;
};
export type RequestData =
| HttpRequestData
| GraphqlRequestData
| WebsocketRequestData
| SseRequestData
| SocketIoRequestData
| MqttRequestData;
export type RequestDataOf<K extends RequestKind> = Extract<RequestData, { kind: K }>;
export type RequestNodeBase = {
id: string;
type: "request";
name: string;
description?: string;
createdAt: number;
updatedAt: number;
};
export type HttpApiNode = RequestNodeBase & { kind: "http"; data: HttpRequestData };
export type GraphqlApiNode = RequestNodeBase & { kind: "graphql"; data: GraphqlRequestData };
export type WebsocketApiNode = RequestNodeBase & { kind: "websocket"; data: WebsocketRequestData };
export type SseApiNode = RequestNodeBase & { kind: "sse"; data: SseRequestData };
export type SocketIoApiNode = RequestNodeBase & { kind: "socketio"; data: SocketIoRequestData };
export type MqttApiNode = RequestNodeBase & { kind: "mqtt"; data: MqttRequestData };
/** 判别联合:`node.kind === "http"` 时自动收窄 data 类型 */
export type RequestNode =
| HttpApiNode
| GraphqlApiNode
| WebsocketApiNode
| SseApiNode
| SocketIoApiNode
| MqttApiNode;
export type FolderNode = {
id: string;
type: "folder";
name: string;
description?: string;
children: CollectionNode[];
createdAt: number;
updatedAt: number;
};
export type CollectionNode = RequestNode | FolderNode;
/** 一个集合(项目),本身是一个顶层目录 */
export type Collection = {
id: string;
type: "collection";
name: string;
description?: string;
children: CollectionNode[];
createdAt: number;
updatedAt: number;
};
export type Environment = {
id: string;
name: string;
variables: KVRow[];
};
export type HistoryEntry = {
id: string;
at: number;
kind: RequestKind;
label: string;
method?: RequestMethod;
url: string;
status?: number;
durationMs?: number;
ok: boolean;
/** 关联的请求节点 id(可回跳) */
nodeId?: string;
};
export type ThemeMode = "dark" | "light";
export type WorkspaceSettings = {
theme: ThemeMode;
/** 请求超时(毫秒) */
timeoutMs: number;
};
export type WorkspaceData = {
version: 2;
collections: Collection[];
environments: Environment[];
activeEnvironmentId: string | null;
history: HistoryEntry[];
settings: WorkspaceSettings;
};
/** 导入结果:集合 + 建议写入环境的前缀变量(如 baseUrl) */
export type ImportResult = {
collections: Collection[];
environmentVariables: { key: string; value: string }[];
warnings: string[];
};
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { replaceVariables, replaceVariablesInSpec } from "./replaceVariables";
describe("replaceVariables", () => {
it("replaces Postman-style {{var}}", () => {
expect(replaceVariables("{{baseUrl}}/api", { baseUrl: "https://x.test" })).toBe(
"https://x.test/api",
);
});
it("leaves unknown vars intact", () => {
expect(replaceVariables("hello {{missing}}", {})).toBe("hello {{missing}}");
});
it("supports multiple and spaced keys", () => {
expect(
replaceVariables("{{ host }}/{{path}}", { host: "h", path: "p" }),
).toBe("h/p");
});
it("replaceVariablesInSpec walks nested strings", () => {
const out = replaceVariablesInSpec(
{ url: "{{baseUrl}}/v1", headers: { Authorization: "Bearer {{token}}" } },
{ baseUrl: "http://a", token: "t" },
);
expect(out.url).toBe("http://a/v1");
expect(out.headers.Authorization).toBe("Bearer t");
});
});
+35
View File
@@ -0,0 +1,35 @@
/**
* Postman-style {{var}} replacement.
* Unknown variables are left as-is.
*/
export function replaceVariables(
input: string,
env: Record<string, string>,
): string {
if (!input) return input;
return input.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key: string) => {
if (Object.prototype.hasOwnProperty.call(env, key)) {
return env[key] ?? "";
}
return match;
});
}
export function replaceVariablesInSpec<T extends Record<string, unknown>>(
spec: T,
env: Record<string, string>,
): T {
const walk = (v: unknown): unknown => {
if (typeof v === "string") return replaceVariables(v, env);
if (Array.isArray(v)) return v.map(walk);
if (v && typeof v === "object") {
const out: Record<string, unknown> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
out[k] = walk(val);
}
return out;
}
return v;
};
return walk(spec) as T;
}
+227
View File
@@ -0,0 +1,227 @@
/**
* 集合树纯函数操作(不可变更新)。
*/
import type {
Collection,
CollectionNode,
FolderNode,
RequestNode,
} from "./model";
export type NodeRef = {
collection: Collection;
/** 父目录;集合顶层时为 null */
parent: FolderNode | null;
node: CollectionNode;
};
export function newId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
export function walk(
nodes: CollectionNode[],
fn: (node: CollectionNode, parent: FolderNode | null) => void,
parent: FolderNode | null = null,
): void {
for (const node of nodes) {
fn(node, parent);
if (node.type === "folder") walk(node.children, fn, node);
}
}
export function findNode(
collections: Collection[],
id: string,
): NodeRef | null {
for (const collection of collections) {
let found: NodeRef | null = null;
walk(collection.children, (node, parent) => {
if (!found && node.id === id) found = { collection, parent, node };
});
if (found) return found;
}
return null;
}
export function mapTree<T extends CollectionNode>(
nodes: T[],
fn: (node: T) => T,
): T[] {
return nodes.map((node) => {
const mapped = fn(node);
if (mapped.type === "folder") {
return { ...mapped, children: mapTree(mapped.children as T[], fn) };
}
return mapped;
});
}
/** 返回新的 collections 数组;未命中时原样返回 */
export function replaceNode(
collections: Collection[],
id: string,
updater: (node: CollectionNode) => CollectionNode,
): Collection[] {
const visit = (nodes: CollectionNode[]): CollectionNode[] =>
nodes.map((node) => {
if (node.id === id) return updater(node);
if (node.type === "folder") return { ...node, children: visit(node.children) };
return node;
});
let changed = false;
const result = collections.map((c) => {
const children = visit(c.children);
if (children !== c.children) changed = true;
return changed || children !== c.children ? { ...c, children } : c;
});
return result;
}
export function removeNode(
collections: Collection[],
id: string,
): Collection[] {
const visit = (nodes: CollectionNode[]): CollectionNode[] =>
nodes
.filter((node) => node.id !== id)
.map((node) =>
node.type === "folder" ? { ...node, children: visit(node.children) } : node,
);
return collections.map((c) => ({ ...c, children: visit(c.children) }));
}
export function insertNode(
collections: Collection[],
target: { parentId: string | null },
node: CollectionNode,
): Collection[] {
const visit = (nodes: CollectionNode[]): CollectionNode[] => {
if (target.parentId === null) return [...nodes, node];
return nodes.map((n) => {
if (n.type === "folder") {
if (n.id === target.parentId) return { ...n, children: [...n.children, node] };
return { ...n, children: visit(n.children) };
}
return n;
});
};
// parentId 为 null 时插入第一个集合顶层(调用方一般会用 appendToCollection)
return collections.map((c) => ({ ...c, children: visit(c.children) }));
}
/** 插入到指定集合(collectionId)的指定目录(parentId 可为 null = 顶层) */
export function insertIntoCollection(
collections: Collection[],
collectionId: string,
parentId: string | null,
node: CollectionNode,
): Collection[] {
return collections.map((c) => {
if (c.id !== collectionId) return c;
if (parentId === null) return { ...c, children: [...c.children, node] };
const visit = (nodes: CollectionNode[]): CollectionNode[] =>
nodes.map((n) => {
if (n.type !== "folder") return n;
if (n.id === parentId) return { ...n, children: [...n.children, node] };
return { ...n, children: visit(n.children) };
});
return { ...c, children: visit(c.children) };
});
}
/** 把节点移动到目标目录(跨集合移动也支持;null = 集合顶层) */
export function moveNode(
collections: Collection[],
id: string,
targetCollectionId: string,
targetParentId: string | null,
): Collection[] {
const ref = findNode(collections, id);
if (!ref) return collections;
if (id === targetParentId) return collections; // 不能移动到自己下面
// 目标是自己后代时拒绝
if (ref.node.type === "folder" && containsId(ref.node.children, targetParentId)) {
return collections;
}
const node = ref.node;
const removed = removeNode(collections, id);
return insertIntoCollection(removed, targetCollectionId, targetParentId, node);
}
export function containsId(nodes: CollectionNode[], id: string | null): boolean {
if (id === null) return false;
let hit = false;
walk(nodes, (n) => {
if (n.id === id) hit = true;
});
return hit;
}
export function deepCopyNode(node: CollectionNode, rename?: (name: string) => string): CollectionNode {
if (node.type === "request") {
return { ...node, id: newId(), name: rename ? rename(node.name) : node.name, createdAt: Date.now(), updatedAt: Date.now() };
}
return {
...node,
id: newId(),
name: rename ? rename(node.name) : node.name,
children: node.children.map((c) => deepCopyNode(c, rename)),
createdAt: Date.now(),
updatedAt: Date.now(),
};
}
export function uniqueName(siblings: CollectionNode[], base: string): string {
const names = new Set(siblings.map((n) => n.name));
if (!names.has(base)) return base;
for (let i = 2; i < 999; i++) {
const candidate = `${base} ${i}`;
if (!names.has(candidate)) return candidate;
}
return `${base} ${Date.now()}`;
}
export function countRequests(collection: Collection): number {
let n = 0;
walk(collection.children, (node) => {
if (node.type === "request") n++;
});
return n;
}
/** 名称匹配搜索(大小写不敏感),返回命中的请求/目录 id 集合(目录命中自身或含命中后代) */
export function searchTree(
collections: Collection[],
term: string,
): Set<string> {
const q = term.trim().toLowerCase();
const hits = new Set<string>();
if (!q) return hits;
const visitFolder = (nodes: CollectionNode[], ancestors: string[]): boolean => {
let any = false;
for (const node of nodes) {
if (node.type === "request") {
const text = `${node.name} ${node.kind} ${
node.kind === "http" ? node.data.method + " " + node.data.url : ""
}`.toLowerCase();
if (text.includes(q)) {
hits.add(node.id);
any = true;
}
} else {
const childHit = visitFolder(node.children, [...ancestors, node.id]);
if (childHit || node.name.toLowerCase().includes(q)) {
hits.add(node.id);
any = true;
}
}
}
return any;
};
for (const c of collections) visitFolder(c.children, [c.id]);
return hits;
}
+57
View File
@@ -0,0 +1,57 @@
export type HttpMethod =
| "GET"
| "POST"
| "PUT"
| "PATCH"
| "DELETE"
| "HEAD"
| "OPTIONS";
export type HttpBodyMode =
| "none"
| "json"
| "raw"
| "xml"
| "text"
| "html"
| "urlencoded"
| "form"
| "form-data";
export type HttpSpec = {
method: HttpMethod;
url: string;
headers: Record<string, string>;
query?: Record<string, string>;
body?: {
mode: HttpBodyMode;
raw?: string;
};
timeoutMs?: number;
};
export type WsSpec = {
url: string;
protocols?: string[];
headers?: Record<string, string>;
};
export type HttpResult = {
status: number;
headers: Record<string, string>;
body: string;
timeMs: number;
size: number;
};
export interface WsSession {
onMessage(cb: (data: string) => void): void;
onStatus(cb: (s: string) => void): void;
send(data: string): void;
close(): void;
}
export interface Transport {
http(spec: HttpSpec): Promise<HttpResult>;
ws(spec: WsSpec): WsSession;
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": [
"src"
]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});