curlconverter
Version:
convert curl commands to Python, JavaScript, Go, PHP and more
565 lines (524 loc) • 16.2 kB
text/typescript
import { CCError, has } from "../../utils.js";
import { warnIfPartsIgnored } from "../../Warnings.js";
import { Word, eq } from "../../shell/Word.js";
import { parse, COMMON_SUPPORTED_ARGS } from "../../parse.js";
import type { Request, Warnings } from "../../parse.js";
import { parseQueryString, type QueryDict } from "../../Query.js";
// https://ruby-doc.org/stdlib-2.7.0/libdoc/net/http/rdoc/Net/HTTP.html
// https://github.com/ruby/net-http/tree/master/lib/net
// https://github.com/augustl/net-http-cheat-sheet
export const supportedArgs = new Set([
...COMMON_SUPPORTED_ARGS,
"form",
"form-string",
"http0.9",
"http1.0",
"http1.1",
"insecure",
"no-digest",
"no-http0.9",
"no-insecure",
"output",
"proxy",
"proxy-user",
"upload-file",
"next",
]);
// https://docs.ruby-lang.org/en/3.1/syntax/literals_rdoc.html#label-Strings
const regexSingleEscape = /'|\\/gu;
const regexDoubleEscape = /"|\\|\p{C}|[^ \P{Z}]|#[{@$]/gu;
const regexCurlyEscape = /\}|\\|\p{C}|[^ \P{Z}]|#[{@$]/gu; // TODO: escape { ?
const regexDigit = /[0-9]/;
export function reprStr(s: string, quote?: "'" | '"' | "{}"): string {
if (quote === undefined) {
quote = "'";
if (s.match(/\p{C}|[^ \P{Z}]/gu) || (s.includes("'") && !s.includes('"'))) {
quote = '"';
}
}
const regexEscape =
quote === "'"
? regexSingleEscape
: quote === '"'
? regexDoubleEscape
: regexCurlyEscape;
const startQuote = quote[0];
const endQuote = quote === "{}" ? quote[1] : quote[0];
return (
startQuote +
s.replace(regexEscape, (c: string, index: number, string: string) => {
switch (c[0]) {
case "\x07":
return "\\a";
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 "\x1B":
return "\\e";
case "\\":
return "\\\\";
case "'":
return "\\'";
case '"':
return '\\"';
case "#":
return "\\" + c;
case "}":
return "\\}";
case "\0":
// \0 is null but \01 would be an octal escape
if (!regexDigit.test(string.charAt(index + 1))) {
return "\\0";
}
break;
}
const codePoint = c.codePointAt(0) as number;
const hex = codePoint.toString(16);
if (hex.length <= 2 && codePoint < 0x7f) {
return "\\x" + hex.padStart(2, "0");
}
if (hex.length <= 4) {
return "\\u" + hex.padStart(4, "0");
}
return "\\u{" + hex + "}";
}) +
endQuote
);
}
export function repr(w: Word): string {
const args: string[] = [];
for (const t of w.tokens) {
if (typeof t === "string") {
args.push(reprStr(t));
} else if (t.type === "variable") {
args.push("ENV[" + reprStr(t.value) + "]");
} else {
args.push("%x" + reprStr(t.value, "{}"));
}
}
return args.join(" + ");
}
// https://gist.github.com/misfo/1072693 but simplified
function validSymbol(s: Word): boolean {
// TODO: can also start with @ $ and end with ! = ? are those special?
return s.isString() && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(s.toString());
}
export function reprSymbol(s: Word): string {
if (!validSymbol(s)) {
return repr(s);
}
return s.toString();
}
export function objToRuby(
obj: Word | Word[] | string | number | boolean | object | null,
indent = 0,
): string {
if (obj instanceof Word) {
return repr(obj);
}
switch (typeof obj) {
case "string":
return reprStr(obj);
case "number":
return obj.toString();
case "boolean":
return obj ? "true" : "false";
case "object":
if (obj === null) {
return "nil";
}
if (Array.isArray(obj)) {
if (obj.length === 0) {
return "[]";
} else {
let s = "[\n";
for (const [i, item] of obj.entries()) {
s += " ".repeat(indent + 2) + objToRuby(item, indent + 2);
s += i === obj.length - 1 ? "\n" : ",\n";
}
s += " ".repeat(indent) + "]";
return s;
}
} else {
if (Object.keys(obj).length === 0) {
return "{}";
}
let s = "{\n";
const objEntries = Object.entries(obj);
for (const [i, [k, v]] of objEntries.entries()) {
// reprStr() because JSON keys must be strings.
s +=
" ".repeat(indent + 2) +
reprStr(k) +
" => " +
objToRuby(v, indent + 2);
s += i === objEntries.length - 1 ? "\n" : ",\n";
}
s += " ".repeat(indent) + "}";
return s;
}
default:
throw new CCError(
"unexpected object type that shouldn't appear in JSON: " + typeof obj,
);
}
}
export function queryToRubyDict(q: QueryDict, indent = 0) {
if (q.length === 0) {
return "{}";
}
let s = "{\n";
for (const [i, [k, v]] of q.entries()) {
s += " ".repeat(indent + 2) + repr(k) + " => " + objToRuby(v, indent + 2);
s += i === q.length - 1 ? "\n" : ",\n";
}
s += " ".repeat(indent) + "}";
return s;
}
export function getDataString(request: Request): [string, boolean] {
if (!request.data) {
return ["", false];
}
if (
request.dataArray &&
request.dataArray.length === 1 &&
!(request.dataArray[0] instanceof Word) &&
!request.dataArray[0].name
) {
const { filetype, filename } = request.dataArray[0];
if (eq(filename, "-")) {
if (filetype === "binary") {
// TODO: read stdin in binary
// https://ruby-doc.org/core-2.3.0/IO.html#method-i-binmode
// TODO: .delete("\\r\\n") ?
return ['req.body = STDIN.read.delete("\\n")\n', false];
} else {
return ['req.body = STDIN.read.delete("\\n")\n', false];
}
}
switch (filetype) {
case "binary":
return [
// TODO: What's the difference between binread() and read()?
// TODO: .delete("\\r\\n") ?
"req.body = File.binread(" + repr(filename) + ').delete("\\n")\n',
false,
];
case "data":
case "json":
return [
"req.body = File.read(" + repr(filename) + ').delete("\\n")\n',
false,
];
case "urlencode":
// TODO: urlencode
return [
"req.body = File.read(" + repr(filename) + ').delete("\\n")\n',
false,
];
}
}
const contentTypeHeader = request.headers.get("content-type");
const isJson =
contentTypeHeader &&
eq(contentTypeHeader.split(";")[0].trim(), "application/json");
if (isJson && request.data.isString()) {
try {
const dataAsStr = request.data.toString();
const dataAsJson = JSON.parse(dataAsStr);
if (typeof dataAsJson === "object" && dataAsJson !== null) {
// TODO: we actually want to know how it's serialized by Ruby's builtin
// JSON formatter but this is hopefully good enough.
const roundtrips = JSON.stringify(dataAsJson) === dataAsStr;
let code = "";
if (!roundtrips) {
code += "# The object won't be serialized exactly like this\n";
code += "# req.body = " + repr(request.data) + "\n";
}
code += "req.body = " + objToRuby(dataAsJson) + ".to_json\n";
return [code, true];
}
} catch {}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, queryAsDict] = parseQueryString(request.data);
if (!request.isDataBinary && queryAsDict) {
// If the original request contained %20, Ruby will encode them as "+"
return ["req.set_form_data(" + queryToRubyDict(queryAsDict) + ")\n", false];
}
return ["req.body = " + repr(request.data) + "\n", false];
}
export function getFilesString(request: Request): string {
if (!request.multipartUploads) {
return "";
}
const multipartUploads = request.multipartUploads.map((m) => {
// https://github.com/psf/requests/blob/2d5517682b3b38547634d153cea43d48fbc8cdb5/requests/models.py#L117
//
// net/http's multipart syntax looks like this:
// [[name, file, {filename: filename}]]
const name = repr(m.name); // TODO: what if name is empty string?
const sentFilename = "filename" in m && m.filename && repr(m.filename);
if ("contentFile" in m) {
if (eq(m.contentFile, "-")) {
if (request.stdinFile) {
return [
name,
"File.open(" + repr(request.stdinFile) + ")",
sentFilename,
];
} else if (request.stdin) {
return [name, repr(request.stdin), sentFilename];
}
// TODO: does this work?
return [name, "STDIN", sentFilename];
} else if (m.contentFile === m.filename) {
// TODO: curl will look at the file extension to determine each content-type
return [name, "File.open(" + repr(m.contentFile) + ")"];
}
return [name, "File.open(" + repr(m.contentFile) + ")", sentFilename];
}
return [name, repr(m.content), sentFilename];
});
let filesString = "req.set_form(\n";
if (multipartUploads.length === 0) {
filesString += " [],\n";
} else {
filesString += " [\n";
for (const [i, [name, content, filename]] of multipartUploads.entries()) {
filesString += " [\n";
filesString += " " + name + ",\n";
filesString += " " + content;
if (typeof filename === "string") {
filesString += ",\n";
filesString += " {filename: " + filename + "}\n";
} else {
filesString += "\n";
}
if (i === multipartUploads.length - 1) {
filesString += " ]\n";
} else {
filesString += " ],\n";
}
}
filesString += " ],\n";
}
// TODO: what if there's other stuff in the content type?
filesString += " 'multipart/form-data'\n";
// TODO: charset
filesString += ")\n";
return filesString;
}
function requestToRuby(
request: Request,
warnings: Warnings,
imports: Set<string>,
): string {
warnIfPartsIgnored(request, warnings, { dataReadsFile: true });
if (
request.dataReadsFile &&
request.dataArray &&
request.dataArray.length &&
(request.dataArray.length > 1 ||
(!(request.dataArray[0] instanceof Word) && request.dataArray[0].name))
) {
warnings.push([
"unsafe-data",
"the generated data content is wrong, " +
// TODO: might not come from "@"
JSON.stringify("@" + request.dataReadsFile) +
" means read the file " +
JSON.stringify(request.dataReadsFile),
]);
}
let code = "";
const methods = {
GET: "Get",
HEAD: "Head",
POST: "Post",
PATCH: "Patch",
PUT: "Put",
PROPPATCH: "Proppatch",
LOCK: "Lock",
UNLOCK: "Unlock",
OPTIONS: "Options",
PROPFIND: "Propfind",
DELETE: "Delete",
MOVE: "Move",
COPY: "Copy",
MKCOL: "Mkcol",
TRACE: "Trace",
};
if (
request.urls[0].queryDict &&
request.urls[0].queryDict.every((q) => validSymbol(q[0]))
) {
code += "uri = URI(" + repr(request.urls[0].urlWithoutQueryList) + ")\n";
code += "params = {\n";
for (const [key, value] of request.urls[0].queryDict) {
// TODO: warn that %20 becomes +
code += " :" + key.toString() + " => " + objToRuby(value, 2) + ",\n";
}
code += "}\n";
code += "uri.query = URI.encode_www_form(params)\n\n";
} else {
code += "uri = URI(" + repr(request.urls[0].url) + ")\n";
}
const simple = !(
request.headers.length ||
request.urls[0].auth ||
request.multipartUploads ||
request.data ||
request.urls[0].uploadFile ||
request.insecure ||
request.proxy ||
request.urls[0].output
);
const method = request.urls[0].method;
if (method.isString() && has(methods, method.toString())) {
if (method.toString() === "GET" && simple) {
code += "res = Net::HTTP.get_response(uri)\n";
return code;
} else {
code += "req = Net::HTTP::" + methods[method.toString()] + ".new(uri)\n";
}
} else {
code +=
"req = Net::HTTPGenericRequest.new(" +
repr(request.urls[0].method) +
", true, true, uri)\n";
}
if (request.urls[0].auth && request.authType === "basic") {
code +=
"req.basic_auth " +
repr(request.urls[0].auth[0]) +
", " +
repr(request.urls[0].auth[1]) +
"\n";
}
let reqBody;
if (request.urls[0].uploadFile) {
if (
eq(request.urls[0].uploadFile, "-") ||
eq(request.urls[0].uploadFile, ".")
) {
reqBody = "req.body = STDIN.read\n";
} else {
reqBody =
"req.body = File.read(" + repr(request.urls[0].uploadFile) + ")\n";
}
} else if (request.multipartUploads) {
reqBody = getFilesString(request);
request.headers.delete("content-type");
} else if (request.data) {
let importJson = false;
[reqBody, importJson] = getDataString(request);
if (importJson) {
imports.add("json");
}
}
const contentType = request.headers.get("content-type");
if (contentType !== null && contentType !== undefined) {
// If the content type has stuff after the content type, like
// application/x-www-form-urlencoded; charset=UTF-8
// then we generate misleading code here because the charset won't be sent.
code += "req.content_type = " + repr(contentType) + "\n";
request.headers.delete("content-type");
}
if (request.headers.length) {
for (const [headerName, headerValue] of request.headers) {
if (
["accept-encoding", "content-length"].includes(
headerName.toLowerCase().toString(),
)
) {
code += "# ";
}
// TODO: nil?
code +=
"req[" +
repr(headerName) +
"] = " +
repr(headerValue ?? new Word("nil")) +
"\n";
}
}
if (reqBody) {
code += "\n" + reqBody;
}
code += "\n";
if (request.proxy) {
const proxy = request.proxy.includes("://")
? request.proxy
: request.proxy.prepend("http://");
code += "proxy = URI(" + repr(proxy) + ")\n";
}
code += "req_options = {\n";
code += " use_ssl: uri.scheme == 'https'";
if (request.insecure) {
imports.add("openssl");
code += ",\n";
code += " verify_mode: OpenSSL::SSL::VERIFY_NONE\n";
} else {
code += "\n";
}
code += "}\n";
if (!request.proxy) {
code +=
"res = Net::HTTP.start(uri.hostname, uri.port, req_options) do |http|\n";
} else {
if (request.proxyAuth) {
const [proxyUser, proxyPassword] = request.proxyAuth.split(":", 2);
code +=
"res = Net::HTTP.start(uri.hostname, uri.port, proxy.hostname, proxy.port, " +
repr(proxyUser) +
", " +
repr(proxyPassword || "") +
", req_options) do |http|\n";
} else {
code +=
"res = Net::HTTP.new(uri.hostname, uri.port, proxy.hostname, proxy.port, req_options).start do |http|\n";
}
}
code += " http.request(req)\n";
code += "end";
if (request.urls[0].output && !eq(request.urls[0].output, "/dev/null")) {
if (eq(request.urls[0].output, "-")) {
code += "\nputs res.body";
} else {
code += "\nFile.write(" + repr(request.urls[0].output) + ", res.body)";
}
}
return code + "\n";
}
export function _toRuby(requests: Request[], warnings: Warnings = []): string {
const imports = new Set<string>();
const code = requests
.map((r) => requestToRuby(r, warnings, imports))
.join("\n\n");
let prelude = "require 'net/http'\n";
for (const imp of Array.from(imports).sort()) {
prelude += "require '" + imp + "'\n";
}
return prelude + "\n" + code;
}
export function toRubyWarn(
curlCommand: string | string[],
warnings: Warnings = [],
): [string, Warnings] {
const requests = parse(curlCommand, supportedArgs, warnings);
const ruby = _toRuby(requests, warnings);
return [ruby, warnings];
}
export function toRuby(curlCommand: string | string[]): string {
return toRubyWarn(curlCommand)[0];
}