511 lines
15 KiB
TypeScript
511 lines
15 KiB
TypeScript
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 };
|