import JSON5 from 'json5'; /** * 解析 cURL 命令为结构化数据(供接口配置智能填充、ApiTest 等复用) */ export type CurlBodyType = 'json' | 'xml' | 'form-urlencoded' | 'form-data' | 'raw' | 'none'; export interface ParsedCurlHeader { name: string; value: string; } export interface ParsedCurlQueryParam { name: string; value: string; } export interface ParsedCurl { method: string; url: string; pathname: string; protocol: 'http' | 'https'; /** URL ?query= 部分 */ queryParams: ParsedCurlQueryParam[]; /** --data-urlencode / x-www-form-urlencoded 请求体字段(与 Query 分离) */ formBodyParams: ParsedCurlQueryParam[]; headers: ParsedCurlHeader[]; body: string; bodyType: CurlBodyType; contentTypeHeader: string; rawCurl: string; } const SKIP_HEADER_NAMES = new Set(['host', 'connection', 'content-length']); /** 合并行续符,保留引号内空格,不做全局压扁(避免破坏长 JSON) */ function normalizeCurl(raw: string): string { let clean = raw.trim(); clean = clean.replace(/\\\s*\r?\n\s*/g, ' '); clean = clean.replace(/\^\s*\r?\n\s*/g, ' '); return clean; } /** * 从 pos 起读取 curl 单/双引号包裹的字符串。 * 单引号(Apifox 导出 JSON body 常见):保留 \" 等 JSON 转义,仅处理 \\'。 * 双引号:按 shell 风格处理 \\ 转义下一字符。 */ function readQuotedString(str: string, pos: number): { text: string; end: number } | null { const quote = str[pos]; if (quote !== '"' && quote !== "'") return null; let result = ''; for (let i = pos + 1; i < str.length; i++) { const ch = str[i]; if (ch === '\\' && i + 1 < str.length) { const next = str[i + 1]; if (quote === "'") { if (next === "'") { result += "'"; i += 2; continue; } // 单引号 curl 体内 \"、\\n 等属于 JSON 字面量,须保留反斜杠 result += ch + next; i += 2; continue; } result += next; i += 2; continue; } if (ch === quote) { return { text: result, end: i + 1 }; } result += ch; } return null; } function looksLikeUrlEncodedBody(body: string): boolean { const t = body.trim(); if (!t || t.startsWith('{') || t.startsWith('[')) return false; return t.includes('=') && (t.includes('&') || !t.includes('{')); } /** 从 --data-raw 后提取 JSON 对象(无引号或引号解析失败时的兜底) */ function extractRawJsonBody(str: string, flag: string): string | null { const idx = str.indexOf(flag); if (idx === -1) return null; let pos = skipWhitespace(str, idx + flag.length); const quoted = readQuotedString(str, pos); if (quoted) { const text = quoted.text.trim(); if (text.startsWith('{') || text.startsWith('[')) { try { JSON.parse(text); return text; } catch { /* fall through to brace scan */ } } else if (text) { return text; } pos = quoted.end; } while (pos < str.length && /\s/.test(str[pos])) pos++; if (str[pos] !== '{' && str[pos] !== '[') return null; const open = str[pos]; const close = open === '{' ? '}' : ']'; let depth = 0; let inString = false; let stringQuote = ''; let escaped = false; for (let i = pos; i < str.length; i++) { const ch = str[i]; if (inString) { if (escaped) { escaped = false; continue; } if (ch === '\\') { escaped = true; continue; } if (ch === stringQuote) inString = false; continue; } if (ch === '"' || ch === "'") { inString = true; stringQuote = ch; continue; } if (ch === open) depth++; else if (ch === close) { depth--; if (depth === 0) { return str.slice(pos, i + 1).trim(); } } } return null; } function skipWhitespace(str: string, pos: number): number { let i = pos; while (i < str.length && /\s/.test(str[i])) i++; return i; } /** 逐个提取 --data-urlencode 'key=value'(支持 param_json=[{...}] 等长 JSON / 数组) */ function parseDataUrlencodePairs(str: string): ParsedCurlQueryParam[] { const pairs: ParsedCurlQueryParam[] = []; const flag = '--data-urlencode'; let searchFrom = 0; while (searchFrom < str.length) { const idx = str.indexOf(flag, searchFrom); if (idx === -1) break; let pos = skipWhitespace(str, idx + flag.length); const quoted = readQuotedString(str, pos); if (!quoted) { searchFrom = idx + flag.length; continue; } const eqIndex = quoted.text.indexOf('='); if (eqIndex === -1) { pairs.push({ name: quoted.text.trim(), value: '' }); } else { pairs.push({ name: quoted.text.slice(0, eqIndex).trim(), value: quoted.text.slice(eqIndex + 1), }); } searchFrom = quoted.end; } return pairs; } /** 逐个提取 --header / -H 'Name: Value' */ function parseHeaders(str: string): ParsedCurlHeader[] { const headers: ParsedCurlHeader[] = []; const flags = ['--header', '-H']; for (const flag of flags) { let searchFrom = 0; while (searchFrom < str.length) { const idx = str.indexOf(flag, searchFrom); if (idx === -1) break; let pos = skipWhitespace(str, idx + flag.length); const quoted = readQuotedString(str, pos); if (!quoted) { searchFrom = idx + flag.length; continue; } const colon = quoted.text.indexOf(':'); if (colon > 0) { const name = quoted.text.slice(0, colon).trim(); const value = quoted.text.slice(colon + 1).trim(); if (name && name.toLowerCase() !== 'content-type' && !SKIP_HEADER_NAMES.has(name.toLowerCase())) { if (!headers.some((h) => h.name.toLowerCase() === name.toLowerCase())) { headers.push({ name, value }); } } } searchFrom = quoted.end; } } return headers; } function parseContentTypeHeader(str: string): string { const flags = ['--header', '-H']; for (const flag of flags) { let searchFrom = 0; while (searchFrom < str.length) { const idx = str.indexOf(flag, searchFrom); if (idx === -1) break; let pos = skipWhitespace(str, idx + flag.length); const quoted = readQuotedString(str, pos); if (!quoted) { searchFrom = idx + flag.length; continue; } const lower = quoted.text.toLowerCase(); if (lower.startsWith('content-type:')) { return quoted.text.slice('content-type:'.length).trim().toLowerCase(); } searchFrom = quoted.end; } } return ''; } function extractQuotedFlagValue(str: string, flag: string): string | null { const idx = str.indexOf(flag); if (idx === -1) return null; const pos = skipWhitespace(str, idx + flag.length); const quoted = readQuotedString(str, pos); return quoted?.text ?? null; } function extractDashDValue(str: string): string | null { const dashMatch = /(?:^|\s)-d\s+/i.exec(str); if (!dashMatch || typeof dashMatch.index !== 'number') return null; const part = str.substring(dashMatch.index + dashMatch[0].length).trim(); if (!part) return null; const firstObjIdx = part.indexOf('{'); const lastObjIdx = part.lastIndexOf('}'); if (firstObjIdx !== -1 && lastObjIdx !== -1 && lastObjIdx > firstObjIdx) { return part.substring(firstObjIdx, lastObjIdx + 1).trim(); } const firstArrIdx = part.indexOf('['); const lastArrIdx = part.lastIndexOf(']'); if (firstArrIdx !== -1 && lastArrIdx !== -1 && lastArrIdx > firstArrIdx) { return part.substring(firstArrIdx, lastArrIdx + 1).trim(); } const pos = skipWhitespace(str, dashMatch.index + dashMatch[0].length); const quoted = readQuotedString(str, pos); return quoted?.text ?? null; } function inferBodyType(body: string, contentType: string, hasFormPairs: boolean): CurlBodyType { if (hasFormPairs) return 'form-urlencoded'; if (!body) return 'none'; if (contentType.includes('application/json')) return 'json'; if (contentType.includes('application/xml') || contentType.includes('text/xml')) return 'xml'; if (contentType.includes('application/x-www-form-urlencoded')) return 'form-urlencoded'; if (contentType.includes('multipart/form-data')) return 'form-data'; const trimmed = body.trim(); if (trimmed.startsWith('{') || trimmed.startsWith('[')) { try { JSON.parse(trimmed); return 'json'; } catch { return 'raw'; } } if (trimmed.startsWith(']/.test(trimmed))) { return 'xml'; } return 'raw'; } function parseBody(str: string): { body: string; formPairs: ParsedCurlQueryParam[] } { const formPairs = parseDataUrlencodePairs(str); let body = extractRawJsonBody(str, '--data-raw') || ''; if (!body) { const quoted = extractQuotedFlagValue(str, '--data-raw'); if (quoted) body = quoted; } // 仅当历史 curl 在双引号内用 \\" 时才需要还原(单引号 curl 已在 readQuotedString 保留) if (body && !body.trim().startsWith('{') && !body.trim().startsWith('[')) { body = body.replace(/\\"/g, '"'); } if (!body) { const dashD = extractDashDValue(str); if (dashD) body = dashD; } if (!body && !str.includes('--data-raw')) { const dataIndex = str.indexOf('--data'); if (dataIndex !== -1 && !str.substring(dataIndex).startsWith('--data-raw') && !str.substring(dataIndex).startsWith('--data-urlencode')) { const extracted = extractQuotedFlagValue(str.substring(dataIndex), '--data'); if (extracted) body = extracted; } } if (formPairs.length === 0 && body && looksLikeUrlEncodedBody(body)) { try { const params = new URLSearchParams(body); params.forEach((value, key) => formPairs.push({ name: key, value })); } catch { /* ignore */ } } return { body, formPairs }; } function extractUrl(str: string): string { const patterns = [ /(?:--request|-X)\s+\w+\s+(['"])(https?:\/\/[\s\S]*?)\1/i, /curl\s+(?:--location\s+)?(?:--request\s+\w+\s+)?(['"])(https?:\/\/[\s\S]*?)\1/i, ]; for (const pattern of patterns) { const match = pattern.exec(str); if (match?.[2]?.startsWith('http')) { return match[2].trim(); } } // 兜底:找第一个引号包裹的 http URL let pos = 0; while (pos < str.length) { const httpIdx = str.indexOf('http', pos); if (httpIdx === -1) break; // 向前找引号起点 let qStart = httpIdx - 1; while (qStart >= 0 && str[qStart] !== "'" && str[qStart] !== '"') qStart--; if (qStart >= 0 && (str[qStart] === "'" || str[qStart] === '"')) { const quoted = readQuotedString(str, qStart); if (quoted?.text.startsWith('http')) return quoted.text; pos = qStart + 1; continue; } pos = httpIdx + 4; } throw new Error('未找到有效的 URL'); } function deriveInterfaceName(pathname: string): string { const segments = pathname.split('/').filter(Boolean); if (!segments.length) return '新接口'; let name = segments[segments.length - 1]; if (/^\d+$/.test(name) && segments.length > 1) { name = segments[segments.length - 2]; } return name.replace(/[^\w.-]/g, '_') || '新接口'; } export function parseCurlCommand(rawCurl: string): ParsedCurl { const raw = rawCurl?.trim(); if (!raw) throw new Error('cURL 内容为空'); if (!/^curl\b/i.test(raw)) throw new Error('请输入以 curl 开头的命令'); const cleanCurl = normalizeCurl(raw); let method = 'GET'; const methodMatch = cleanCurl.match(/-X\s+(\w+)|--request\s+(\w+)/i); if (methodMatch) { method = (methodMatch[1] || methodMatch[2]).toUpperCase(); } const url = extractUrl(cleanCurl); const urlObj = new URL(url); const protocol = urlObj.protocol === 'https:' ? 'https' : 'http'; const pathname = urlObj.pathname || '/'; const queryParams = parseUrlQueryString(urlObj.search); const contentTypeHeader = parseContentTypeHeader(cleanCurl); const { body, formPairs } = parseBody(cleanCurl); const formBodyParams = [...formPairs]; const hasAnyData = formBodyParams.length > 0 || !!body || cleanCurl.includes('--data-raw') || cleanCurl.includes('--data-urlencode') || /\s--data(?:\s|$)/.test(cleanCurl); if (!methodMatch && hasAnyData) { method = 'POST'; } const bodyType = inferBodyType(body, contentTypeHeader, formBodyParams.length > 0); return { method, url: urlObj.toString(), pathname, protocol, queryParams, formBodyParams, headers: parseHeaders(cleanCurl), body, bodyType, contentTypeHeader, rawCurl: raw, }; } /** * 解析 URL query 段(不用 URLSearchParams)。 * URLSearchParams 会把未编码的 + 当作空格,破坏 notifyBizEventDTO 等 JSON 字段(如 +0800 时区)。 */ export function parseUrlQueryString(search: string): ParsedCurlQueryParam[] { const params: ParsedCurlQueryParam[] = []; const raw = search.startsWith('?') ? search.slice(1) : search; if (!raw) return params; for (const segment of raw.split('&')) { if (!segment) continue; const eq = segment.indexOf('='); try { if (eq === -1) { params.push({ name: decodeURIComponent(segment), value: '' }); } else { params.push({ name: decodeURIComponent(segment.slice(0, eq)), value: decodeURIComponent(segment.slice(eq + 1)), }); } } catch { if (eq === -1) { params.push({ name: segment, value: '' }); } else { params.push({ name: segment.slice(0, eq), value: segment.slice(eq + 1) }); } } } return params; } export function deriveInterfaceNameFromCurl(parsed: ParsedCurl): string { return deriveInterfaceName(parsed.pathname); } /** 从 query/body 中提取可解析为 JSON 的字符串(如 param_json、notifyBizEventDTO) */ export function extractJsonCandidates(parsed: ParsedCurl): string[] { const candidates: string[] = []; const seen = new Set(); const tryAdd = (text: string) => { const trimmed = text?.trim(); if (!trimmed || seen.has(trimmed)) return; const attempts = [trimmed]; try { attempts.push(decodeURIComponent(trimmed)); } catch { /* ignore */ } for (const candidate of attempts) { const t = candidate.trim(); if (!t.startsWith('{') && !t.startsWith('[')) continue; let valid = false; try { JSON.parse(t); valid = true; } catch { try { JSON5.parse(t); valid = true; } catch { /* invalid */ } } if (valid && !seen.has(t)) { seen.add(t); candidates.push(t); return; } } }; // Form 场景业务 JSON 多在 body 表单字段,优先于整段 urlencoded body parsed.formBodyParams.forEach((p) => tryAdd(p.value)); parsed.queryParams.forEach((p) => tryAdd(p.value)); tryAdd(parsed.body); return candidates; } export { deriveInterfaceName };