UNPKG

curlconverter

Version:

convert curl commands to Python, JavaScript, Go, PHP and more

676 lines 24.5 kB
import { warnIfPartsIgnored } from "../../Warnings.js"; import { Word, eq, joinWords } from "../../shell/Word.js"; import { parse, COMMON_SUPPORTED_ARGS } from "../../parse.js"; import { parseQueryString } from "../../Query.js"; import jsescObj from "jsesc"; export const javaScriptSupportedArgs = new Set([ ...COMMON_SUPPORTED_ARGS, "upload-file", "form", "form-string", "digest", "no-digest", "next", // --no-compressed (the default) is unsupported though "compressed", ]); export const nodeSupportedArgs = new Set([...javaScriptSupportedArgs, "proxy"]); // https://fetch.spec.whatwg.org/#forbidden-method export const FORBIDDEN_METHODS = ["CONNECT", "TRACE", "TRACK"]; // https://fetch.spec.whatwg.org/#forbidden-request-header export const FORBIDDEN_HEADERS = [ "Accept-Charset", "Accept-Encoding", "Access-Control-Request-Headers", "Access-Control-Request-Method", "Connection", "Content-Length", "Cookie", "Cookie2", "Date", "DNT", "Expect", "Host", "Keep-Alive", "Origin", "Referer", "Set-Cookie", "TE", "Trailer", "Transfer-Encoding", "Upgrade", "Via", ].map((h) => h.toLowerCase()); // TODO: implement? export function reprObj(value, indentLevel) { const escaped = jsescObj(value, { quotes: "single", minimal: false, compact: false, indent: " ", indentLevel: indentLevel ? indentLevel : 0, }); if (typeof value === "string") { return "'" + escaped + "'"; } return escaped; } export function reprPairs(d, indentLevel = 0, indent = " ", list = true, imports) { if (d.length === 0) { return list ? "[]" : "{}"; } let code = list ? "[\n" : "{\n"; for (const [i, [key, value]] of d.entries()) { code += indent.repeat(indentLevel + 1); if (list) { code += "[" + repr(key, imports) + ", " + repr(value, imports) + "]"; } else { code += repr(key, imports) + ": " + repr(value, imports); } code += i < d.length - 1 ? ",\n" : "\n"; } code += indent.repeat(indentLevel) + (list ? "]" : "}"); return code; } export function reprAsStringToStringDict(d, indentLevel = 0, imports, indent = " ") { return reprPairs(d, indentLevel, indent, false, imports); } export function reprAsStringTuples(d, indentLevel = 0, imports, indent = " ") { return reprPairs(d, indentLevel, indent, true, imports); } export function reprStringToStringList(d, indentLevel = 0, imports, indent = " ", list = true) { if (d.length === 0) { return list ? "[]" : "{}"; } let code = "{\n"; for (const [i, [key, value]] of d.entries()) { let valueStr; if (Array.isArray(value)) { valueStr = "[" + value.map((v) => repr(v, imports)).join(", ") + "]"; } else { valueStr = repr(value, imports); } code += indent.repeat(indentLevel + 1); code += repr(key, imports) + ": " + valueStr; code += i < d.length - 1 ? ",\n" : "\n"; } code += indent.repeat(indentLevel) + "}"; return code; } // Backtick quotes are not supported const regexEscape = /'|"|\\|\p{C}|[^ \P{Z}]/gu; const regexDigit = /[0-9]/; export function esc(s, quote = "'") { return s.replace(regexEscape, (c, index, string) => { switch (c[0]) { // https://mathiasbynens.be/notes/javascript-escapes#single case "\\": return "\\\\"; case "\b": return "\\b"; case "\f": return "\\f"; case "\n": return "\\n"; case "\r": return "\\r"; case "\t": return "\\t"; case "\v": return "\\v"; case "'": case '"': return c === quote ? "\\" + c : c; case "\0": // \0 is null but \01 is an octal escape // if we have ['\0', '1', '2'] // and we converted it to '\\012', it would be interpreted as octal // so it needs to be converted to '\\x0012' if (!regexDigit.test(string.charAt(index + 1))) { return "\\0"; } break; } if (c.length === 2) { const first = c.charCodeAt(0); const second = c.charCodeAt(1); return ("\\u" + first.toString(16).padStart(4, "0") + "\\u" + second.toString(16).padStart(4, "0")); } const hex = c.charCodeAt(0).toString(16); if (hex.length > 2) { return "\\u" + hex.padStart(4, "0"); } return "\\x" + hex.padStart(2, "0"); }); } export function reprStr(s, quote) { if (quote === undefined) { quote = "'"; if (s.includes("'") && !s.includes('"')) { quote = '"'; } } return quote + esc(s, quote) + quote; } export function addImport(imports, name, from) { // TODO: this is linear for (const [n, f] of imports) { if (n === name && f === from) return; } imports.push([name, from]); } export function reprImports(imports) { let ret = ""; for (const [name, from] of imports.sort(bySecondElem)) { if (name.startsWith("* as")) { ret += `import ${name} from ${reprStr(from)};\n`; } else { ret += `import { ${name} } from ${reprStr(from)};\n`; } } return ret; } export function reprImportsRequire(imports) { const ret = []; if (imports.length === 0) { return ""; } for (const [name, from] of imports.sort(bySecondElem)) { if (name.startsWith("* as ")) { ret.push(`const ${name.slice("* as ".length)} = require(${reprStr(from)});`); } else if (name.includes(".")) { ret.push(`const ${name} = require(${reprStr(from)}).${name};`); } else { ret.push(`const ${name} = require(${reprStr(from)});`); } } return ret.join("\n") + "\n"; } export function repr(w, imports) { // Node const ret = []; for (const t of w.tokens) { if (typeof t === "string") { ret.push(reprStr(t)); } else if (t.type === "variable") { ret.push("process.env[" + reprStr(t.value) + "]"); } else { ret.push("execSync(" + reprStr(t.value) + ").stdout"); addImport(imports, "execSync", "node:child_process"); } } return ret.join(" + "); } export function reprBrowser(w, warnings) { const ret = []; for (const t of w.tokens) { if (typeof t === "string") { ret.push(reprStr(t)); } else { ret.push(reprStr(t.text)); if (t.type === "variable") { warnings.push([ "browser-has-no-env", "Can't access environment variable in browser JS: " + JSON.stringify(t.value), ]); } else { warnings.push([ "browser-has-no-shell", "Can't run subcommands in browser JS: " + JSON.stringify(t.value), ]); } } } return ret.join(" + "); } export function reprFetch(w, isNode, imports) { if (!isNode) { // TODO: warn return reprStr(w.toString()); } return repr(w, imports); } export function asParseFloat(w, imports) { if (w.isString()) { const originalValue = w.toString(); // TODO: reimplement curl's float parsing instead of parseFloat() const asFloat = parseFloat(originalValue); if (!isNaN(asFloat)) { return originalValue; } } return "parseFloat(" + repr(w, imports) + ")"; } export function asParseFloatTimes1000(w, imports) { if (w.isString()) { const originalValue = w.toString(); // TODO: reimplement curl's float parsing instead of parseFloat() // TODO: check overflow const asFloat = parseFloat(originalValue) * 1000; if (!isNaN(asFloat)) { return asFloat.toString(); } } return "parseFloat(" + repr(w, imports) + ") * 1000"; } export function asParseInt(w, imports) { if (w.isString()) { const originalValue = w.toString(); // TODO: reimplement curl's int parsing instead of parseInt() const asInt = parseInt(originalValue); if (!isNaN(asInt)) { return originalValue; } } return "parseInt(" + repr(w, imports) + ")"; } export function bySecondElem(a, b) { return a[1].localeCompare(b[1]); } export function toURLSearchParams(query, imports, indent = 1) { const [queryList, queryDict] = query; const queryObj = queryDict && queryDict.every((q) => !Array.isArray(q[1])) ? reprAsStringToStringDict(queryDict, indent, imports) : reprAsStringTuples(queryList, indent, imports); return "new URLSearchParams(" + queryObj + ")"; } export function toDictOrURLSearchParams(query, imports, indent = 1) { const [queryList, queryDict] = query; if (queryDict && queryDict.every((v) => !Array.isArray(v[1]))) { return reprAsStringToStringDict(queryDict, indent, imports); } return ("new URLSearchParams(" + reprAsStringTuples(queryList, indent, imports) + ")"); } export function toFormData(multipartUploads, imports, fetchImports, warnings, isNode = true) { let code = "new FormData();\n"; for (const m of multipartUploads) { // TODO: use .set() if all names are unique? code += "form.append(" + reprFetch(m.name, isNode, imports) + ", "; if ("contentFile" in m) { if (isNode) { if (eq(m.contentFile, "-")) { addImport(imports, "* as fs", "fs"); code += "fs.readFileSync(0).toString()"; if (m.filename) { code += ", " + reprFetch(m.filename, isNode, imports); } } else { fetchImports.add("fileFromSync"); // TODO: do this in a way that doesn't set filename="" if we don't have filename code += "fileFromSync(" + reprFetch(m.contentFile, isNode, imports) + ")"; } } else { // TODO: does the second argument get sent as filename="" ? code += "File(['<data goes here>'], " + reprFetch(m.contentFile, isNode, imports) + ")"; // TODO: (massive todo) we could read the file if we're running in the command line warnings.push([ "--form", "you can't read a file for --form/-F in the browser", ]); } } else { code += reprFetch(m.content, isNode, imports); } code += ");\n"; } return code; } function getDataString(request, data, isNode, imports) { const originalStringRepr = reprFetch(data, isNode, imports); const contentType = request.headers.getContentType(); if (contentType === "application/json") { try { const dataStr = data.toString(); const parsed = JSON.parse(dataStr); // Only bother for arrays and {} if (typeof parsed !== "object" || parsed === null) { return [originalStringRepr, null]; } const roundtrips = JSON.stringify(parsed) === dataStr; const jsonAsJavaScript = reprObj(parsed, 1); const dataString = "JSON.stringify(" + jsonAsJavaScript + ")"; return [dataString, roundtrips ? null : originalStringRepr]; } catch (_a) { return [originalStringRepr, null]; } } if (contentType === "application/x-www-form-urlencoded") { try { const [queryList, queryDict] = parseQueryString(data); if (queryList) { // Technically node-fetch sends // application/x-www-form-urlencoded;charset=utf-8 // TODO: handle repeated content-type header if (eq(request.headers.get("content-type"), "application/x-www-form-urlencoded")) { request.headers.delete("content-type"); } // TODO: check roundtrip, add a comment // TODO: this isn't a dict anymore return [toURLSearchParams([queryList, queryDict], imports), null]; } return [originalStringRepr, null]; } catch (_b) { return [originalStringRepr, null]; } } return [originalStringRepr, null]; } export function getData(request, isNode, imports) { if (!request.dataArray || request.multipartUploads) { return ["", null]; } if (request.dataArray.length === 1 && request.dataArray[0] instanceof Word && request.dataArray[0].isString()) { try { return getDataString(request, request.dataArray[0], isNode, imports); } catch (_a) { } } const parts = []; const hasBinary = request.dataArray.some((d) => !(d instanceof Word) && d.filetype === "binary"); const encoding = hasBinary ? "" : ", 'utf-8'"; for (const d of request.dataArray) { if (d instanceof Word) { parts.push(repr(d, imports)); } else { const { filetype, name, filename } = d; if (filetype === "urlencode" && name) { // TODO: add this to the previous Word parts.push(reprFetch(name, isNode, imports)); } // TODO: use the filetype if (eq(filename, "-")) { if (isNode) { addImport(imports, "* as fs", "fs"); parts.push("fs.readFileSync(0" + encoding + ")"); } else { // TODO: something else // TODO: warn that file needs content parts.push("new File([/* contents */], '<stdin>')"); } } else { if (isNode) { addImport(imports, "* as fs", "fs"); parts.push("fs.readFileSync(" + reprFetch(filename, isNode, imports) + encoding + ")"); } else { // TODO: warn that file needs content parts.push("new File([/* contents */], " + reprFetch(filename, isNode, imports) + ")"); } } } } if (parts.length === 0) { return ["''", null]; } if (parts.length === 1) { return [parts[0], null]; } let [start, joiner, end] = ["new ArrayBuffer(", ", ", ")"]; const totalLength = parts.reduce((a, b) => a + b.length, 0); if (totalLength > 80) { start += "\n "; joiner = ",\n "; end = "\n )"; } return [start + parts.join(joiner) + end, null]; } function requestToJavaScriptOrNode(request, warnings, fetchImports, imports, isNode) { warnIfPartsIgnored(request, warnings, { multipleUrls: true, dataReadsFile: true, // Not actually supported, just warned per-URL queryReadsFile: true, }); let code = ""; if (request.multipartUploads) { if (isNode) { // TODO: remove once Node 16 is EOL'd on 2023-09-11 fetchImports.add("FormData"); } code += "const form = " + toFormData(request.multipartUploads, imports, fetchImports, warnings, isNode); code += "\n"; } // Can delete content-type header const [dataString, commentedOutDataString] = getData(request, isNode, imports); let fn = "fetch"; if (request.urls[0].auth && request.authType === "digest") { // TODO: if 'Authorization:' header is specified, don't set this const [user, password] = request.urls[0].auth; addImport(imports, "* as DigestFetch", "digest-fetch"); code += "const client = new DigestFetch(" + reprFetch(user, isNode, imports) + ", " + reprFetch(password, isNode, imports) + ");\n"; fn = "client.fetch"; } for (const urlObj of request.urls) { code += fn + "(" + reprFetch(urlObj.url, isNode, imports); if (urlObj.queryReadsFile) { warnings.push([ "unsafe-query", // TODO: better wording "the URL query string is not correct, " + JSON.stringify("@" + urlObj.queryReadsFile) + " means it should read the file " + JSON.stringify(urlObj.queryReadsFile), ]); } const method = urlObj.method.toLowerCase(); const methodStr = urlObj.method.toString(); if (method.isString() && FORBIDDEN_METHODS.includes(methodStr)) { warnings.push([ "forbidden-method", "the method " + JSON.stringify(methodStr) + " is not allowed in fetch()", ]); } let optionsCode = ""; if (!eq(method, "get")) { // TODO: If you pass a weird method to fetch() it won't uppercase it // const methods = [] // const method = methods.includes(request.method.toLowerCase()) ? request.method.toUpperCase() : request.method optionsCode += " method: " + reprFetch(urlObj.method, isNode, imports) + ",\n"; } if (request.headers.length || (urlObj.auth && request.authType === "basic")) { optionsCode += " headers: {\n"; for (const [headerName, headerValue] of request.headers) { optionsCode += " " + reprFetch(headerName, isNode, imports) + ": " + reprFetch(headerValue || new Word(), isNode, imports) + ",\n"; if (!isNode && headerName.isString() && FORBIDDEN_HEADERS.includes(headerName.toString().toLowerCase())) { warnings.push([ "forbidden-header", JSON.stringify(headerName.toString()) + " header is forbidden in fetch()", ]); } } if (urlObj.auth && request.authType === "basic") { // TODO: if -H 'Authorization:' is passed, don't set this optionsCode += " 'Authorization': 'Basic ' + btoa(" + reprFetch(joinWords(urlObj.auth, ":"), isNode, imports) + "),\n"; } if (optionsCode.endsWith(",\n")) { optionsCode = optionsCode.slice(0, -2); optionsCode += "\n"; } optionsCode += " },\n"; } if (urlObj.uploadFile) { if (isNode) { fetchImports.add("fileFromSync"); optionsCode += " body: fileFromSync(" + reprFetch(urlObj.uploadFile, isNode, imports) + "),\n"; } else { optionsCode += " body: File(['<data goes here>'], " + reprFetch(urlObj.uploadFile, isNode, imports) + "),\n"; warnings.push([ "--form", "you can't read a file for --upload-file/-F in the browser", ]); } } else if (request.multipartUploads) { optionsCode += " body: form,\n"; } else if (request.data) { if (commentedOutDataString) { optionsCode += " // body: " + commentedOutDataString + ",\n"; } optionsCode += " body: " + dataString + ",\n"; } if (isNode && request.proxy) { // TODO: do this parsing in utils.ts const proxy = request.proxy.includes("://") ? request.proxy : request.proxy.prepend("http://"); // TODO: could be more accurate let [protocol] = proxy.split("://", 2); protocol = protocol.toLowerCase(); if (!protocol.toBool()) { protocol = new Word("http"); } if (eq(protocol, "socks")) { protocol = new Word("socks4"); proxy.replace(/^socks/, "socks4"); } if (eq(protocol, "socks4") || eq(protocol, "socks5") || eq(protocol, "socks5h") || eq(protocol, "socks4a")) { addImport(imports, "{ SocksProxyAgent }", "socks-proxy-agent"); optionsCode += " agent: new SocksProxyAgent(" + reprFetch(proxy, isNode, imports) + "),\n"; } else if (eq(protocol, "http") || eq(protocol, "https")) { addImport(imports, "HttpsProxyAgent", "https-proxy-agent"); optionsCode += " agent: new HttpsProxyAgent(" + reprFetch(proxy, isNode, imports) + "),\n"; } else { warnings.push([ "--proxy", "failed to parse --proxy/-x or unknown protocol: " + protocol, ]); // or this? // throw new CCError('Unsupported proxy scheme for ' + reprFetch(request.proxy)) } } if (optionsCode) { if (optionsCode.endsWith(",\n")) { optionsCode = optionsCode.slice(0, -2); } code += ", {\n"; code += optionsCode; code += "\n}"; } code += ");\n"; } // TODO: generate some code for the output, like .json() if 'Accept': 'application/json' return code; } export function _toJavaScriptOrNode(requests, warnings, isNode) { const fetchImports = new Set(); const imports = []; const code = requests .map((r) => requestToJavaScriptOrNode(r, warnings, fetchImports, imports, isNode)) .join("\n"); let importCode = ""; if (isNode) { importCode += "import fetch"; if (fetchImports.size) { importCode += ", { " + Array.from(fetchImports).sort().join(", ") + " }"; } importCode += " from 'node-fetch';\n"; } if (imports.length) { for (const [varName, imp] of Array.from(imports).sort(bySecondElem)) { // TODO: check this importCode += "import " + varName + " from " + reprStr(imp) + ";\n"; } } if (importCode) { return importCode + "\n" + code; } return code; } export function _toJavaScript(requests, warnings = []) { return _toJavaScriptOrNode(requests, warnings, false); } export function _toNode(requests, warnings = []) { return _toJavaScriptOrNode(requests, warnings, true); } export function toJavaScriptWarn(curlCommand, warnings = []) { const requests = parse(curlCommand, javaScriptSupportedArgs, warnings); return [_toJavaScript(requests, warnings), warnings]; } export function toJavaScript(curlCommand) { const [result] = toJavaScriptWarn(curlCommand); return result; } export function toNodeWarn(curlCommand, warnings = []) { const requests = parse(curlCommand, nodeSupportedArgs, warnings); return [_toNode(requests, warnings), warnings]; } export function toNode(curlCommand) { return toNodeWarn(curlCommand)[0]; } //# sourceMappingURL=javascript.js.map