front/src/utils/parseCurl.ts
2026-06-04 19:15:28 +08:00

511 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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('<?xml') || (trimmed.startsWith('<') && /^<[\w-]+[\s>]/.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<string>();
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 };