curlconverter
Version:
convert curl commands to Python, JavaScript, Go, PHP and more
507 lines (465 loc) • 14.4 kB
text/typescript
import { CCError } from "../utils.js";
import { warnIfPartsIgnored } from "../Warnings.js";
import { Word, eq, mergeWords } from "../shell/Word.js";
import { parse, COMMON_SUPPORTED_ARGS } from "../parse.js";
import type { Request, RequestUrl, Warnings } from "../parse.js";
import { Headers } from "../Headers.js";
import { parseQueryString } from "../Query.js";
import { repr, reprStr } from "./wget.js";
export const supportedArgs = new Set([
...COMMON_SUPPORTED_ARGS,
"form",
"form-string",
"location",
"no-location",
"location-trusted",
"no-location-trusted",
"max-redirs",
"ciphers",
"insecure",
"cert",
"key",
"cacert",
"capath",
"proxy",
// "proxy-user", // not supported
"noproxy", // not supported, just better error
"timeout",
"connect-timeout",
// Wget picks the auth and some it doesn't support but there's a more
// specific error message for those.
"anyauth",
"no-anyauth",
"digest",
"no-digest",
// "aws-sigv4", // not supported
"negotiate",
"no-negotiate",
"delegation", // GSS/kerberos
// "service-name", // GSS/kerberos, not supported
"ntlm",
"no-ntlm",
"ntlm-wb",
"no-ntlm-wb",
// HTTPie looks for a netrc file by default
// TODO: this doesn't work
"no-netrc", // only explicitly disabling netrc has an effect,
"verbose",
"silent",
// "output",
"upload-file",
"next",
]);
function escapeHeader(name: Word): Word {
// TODO: more complicated, have to check that it's not already backslashed
// and not all values might be representable
return name.replace("=", "\\=");
}
function escapeHeaderValue(value: Word): Word {
if ((value.startsWith("="), value.startsWith("@"))) {
value = value.prepend("\\");
}
return value;
}
function escapeJsonName(name: string, isFirstKey = false): string {
name = name
.replace("\\", "\\\\")
.replace("[", "\\[")
.replace("]", "\\]")
.replace(":", "\\:") // both := need to be escaped individually or you get weird results
.replace("=", "\\=");
// "A regular integer in a path (e.g [10]) means an array index;
// but if you want it to be treated as a string, you can escape
// the whole number by using a backslash (\) prefix."
// TODO: check this regex
if (!isFirstKey && /^\d+$/.test(name)) {
name = "\\" + name;
}
return name;
}
function escapeJsonStr(value: string): string {
// The backslash only has an effect on some characters and not
// the backslash itself, so it seems like there's no way to send a literal '\='
// value = value.replace("\\=", "\\\\=");
if (value.startsWith("\\=")) {
throw new CCError(
"Unrepresentable JSON string: " +
JSON.stringify(value) +
' (starts with "\\=")',
);
}
if (value.startsWith("\\@")) {
throw new CCError(
"Unrepresentable JSON string: " +
JSON.stringify(value) +
' (starts with "\\@")',
);
}
if (value.startsWith("=") || value.startsWith("@")) {
value = "\\" + value;
}
return value;
}
function toJson(obj: any, key = ""): string[] {
if (obj === null) {
return [reprStr(key) + ":=null"];
} else if (typeof obj === "boolean") {
return [reprStr(key) + ":=" + obj.toString()];
} else if (typeof obj === "number") {
return [reprStr(key) + ":=" + reprStr(obj.toString())];
} else if (typeof obj === "string") {
return [reprStr(key) + "=" + reprStr(escapeJsonStr(obj))];
} else if (Array.isArray(obj)) {
if (!obj.length) {
return [reprStr(key) + ":=" + "[]"];
}
return obj.map((item) => toJson(item, key + "[]")).flat();
} else {
if (!Object.keys(obj).length) {
return [reprStr(key) + ":=" + "{}"];
}
return Object.entries(obj)
.map(([name, value]) =>
toJson(
value,
key
? key + "[" + escapeJsonName(name) + "]"
: escapeJsonName(name, true),
),
)
.flat();
}
}
function jsonAsHttpie(flags: string[], items: string[], data: string) {
let json;
try {
json = JSON.parse(data);
} catch {}
// Only non-empty, top-level objects and arrays can be serialized as command line arguments
if (
(typeof json === "object" && json !== null && Object.keys(json).length) ||
(Array.isArray(json) && json.length)
) {
let jsonItems;
try {
jsonItems = toJson(json);
} catch {}
if (jsonItems) {
for (const jsonItem of jsonItems) {
items.push(jsonItem);
}
return;
}
}
flags.push("--raw " + (reprStr(data) || "''"));
}
function escapeQueryName(name: Word): Word {
// an unquoted ":" turns into ": "
return name.replace("\\", "\\\\").replace(":", "\\:").replace("=", "\\=");
}
function escapeQueryValue(value: Word): Word {
// TODO: the backslash only has an effect on some characters and not
// the backslash itself, so it seems like there's no way to send a literal '\='
// value = value.replace("\\=", "\\\\=");
if ((value.startsWith("="), value.startsWith("@"))) {
value = value.prepend("\\");
}
return value;
}
function urlencodedAsHttpie(flags: string[], items: string[], data: Word) {
let queryList;
try {
[queryList] = parseQueryString(data);
} catch {}
if (!queryList) {
flags.push("--raw " + (repr(data) || "''"));
return;
}
flags.push("--form");
for (const [name, value] of queryList) {
items.push(
repr(mergeWords(escapeQueryName(name), "=", escapeQueryValue(value))),
);
}
}
function formatData(
flags: string[],
items: string[],
data: Word,
headers: Headers,
) {
const contentType = headers.getContentType();
if (contentType === "application/json" && data.isString()) {
jsonAsHttpie(flags, items, data.toString());
} else if (contentType === "application/x-www-form-urlencoded") {
urlencodedAsHttpie(flags, items, data);
} else {
flags.push("--raw " + (repr(data) || "''"));
}
}
// TODO: does this work?
function escapeFormName(name: Word): Word {
return name.replace("\\", "\\\\").replace("@", "\\@").replace("=", "\\=");
}
function requestToHttpie(
request: Request,
url: RequestUrl,
warnings: Warnings,
): string {
const flags: string[] = [];
let method: string | null = null;
let urlArg = url.url;
const items: string[] = [];
if (url.uploadFile || request.data || request.multipartUploads) {
if (!eq(url.method, "POST")) {
method = repr(url.method);
}
} else if (!eq(url.method, "GET")) {
method = repr(url.method);
}
// TODO: don't merge headers
if (request.headers.length) {
for (const [headerName, headerValue] of request.headers) {
if (headerValue === null) {
items.push(repr(mergeWords(escapeHeader(headerName), ":")));
} else if (!headerValue.toBool()) {
items.push(repr(mergeWords(escapeHeader(headerName), ";")));
} else {
items.push(
repr(
mergeWords(
escapeHeader(headerName),
":",
escapeHeaderValue(headerValue),
),
),
);
}
}
}
if (url.auth) {
const [user, password] = url.auth;
if (request.authType === "digest") {
flags.push("-A digest");
} else if (request.authType === "ntlm" || request.authType === "ntlm-wb") {
flags.push("--auth-type=ntlm");
warnings.push([
"httpie-ntlm",
"NTLM auth requires the httpie-ntlm plugin",
]);
} else if (request.authType === "negotiate") {
flags.push("--auth-type=negotiate");
warnings.push([
"httpie-negotiate",
"SPNEGO (GSS Negotiate) auth requires the httpie-negotiate plugin",
]);
// TODO: is this the same thing?
// } else if (request.authType === "aws-sigv4") {
// warnings.push([
// "httpie-ntlm",
// "aws-sigv4 auth is not supported and requires the httpie-aws-auth plugin",
// ]);
} else if (request.authType !== "basic") {
warnings.push([
"httpie-unsupported-auth",
"HTTPie doesn't support " + request.authType + " authentication",
]);
}
// TODO: -A bearer -a token
flags.push("-a " + repr(mergeWords(user, ":", password)));
}
if (url.queryList) {
urlArg = url.urlWithoutQueryList;
for (const [name, value] of url.queryList) {
items.push(
repr(mergeWords(escapeQueryName(name), "==", escapeQueryValue(value))),
);
}
}
if (url.uploadFile) {
if (eq(url.uploadFile, "-") || eq(url.uploadFile, ".")) {
warnings.push([
"httpie-stdin",
"pass in the file contents to HTTPie through stdin",
]);
} else {
items.push("@" + repr(url.uploadFile));
}
} else if (request.multipartUploads) {
flags.push("--multipart");
for (const m of request.multipartUploads) {
if ("content" in m) {
items.push(repr(escapeFormName(m.name)) + "=" + repr(m.content));
} else {
if ("filename" in m && m.filename) {
items.push(repr(escapeFormName(m.name)) + "@" + repr(m.filename));
if (!eq(m.filename, m.contentFile)) {
warnings.push([
"httpie-multipart-fake-filename",
"HTTPie doesn't support multipart uploads that read a certain filename but send a different filename",
]);
}
} else {
items.push(repr(escapeFormName(m.name)) + "=@" + repr(m.contentFile));
}
}
}
} else if (
request.dataArray &&
request.dataArray.length === 1 &&
!(request.dataArray[0] instanceof Word) &&
!request.dataArray[0].name
) {
// TODO: surely --upload-file and this can't be identical,
// doesn't this ignore url encoding?
items.push("@" + repr(request.dataArray[0].filename));
} else if (request.data) {
formatData(flags, items, request.data, request.headers);
}
if (request.followRedirects || request.followRedirectsTrusted) {
flags.push("--follow");
}
if (request.maxRedirects && request.maxRedirects.toString() !== "30") {
// TODO: escape/parse?
flags.push("--max-redirects=" + repr(request.maxRedirects));
}
if (request.netrc === "ignored") {
flags.push("--ignore-netrc");
}
if (request.proxy) {
flags.push("--proxy=http:" + repr(request.proxy));
flags.push("--proxy=https:" + repr(request.proxy));
}
if (request.proxyAuth) {
// TODO: add to proxy URL
}
if (request.noproxy) {
warnings.push([
"httpie-noproxy",
"HTTPie requires passing noproxy through environment variables: " +
JSON.stringify(request.noproxy.toString()),
]);
}
if (request.insecure) {
flags.push("--verify=no");
}
if (request.cacert) {
flags.push("--verify=" + repr(request.cacert));
}
if (request.capath) {
flags.push("--verify=" + repr(request.capath));
}
if (request.cert) {
flags.push("--cert=" + repr(request.cert[0]));
}
if (request.key) {
flags.push("--cert-key=" + repr(request.key));
}
if (request.cert && request.cert[1]) {
flags.push("--cert-key-pass=" + repr(request.cert[1]));
}
// TODO: --ssl= for the version
if (request.ciphers) {
flags.push("--ciphers=" + repr(request.ciphers));
}
if (request.connectTimeout) {
flags.push("--timeout=" + repr(request.connectTimeout));
}
if (request.timeout) {
if (request.connectTimeout) {
warnings.push([
"httpie-timeout-with-connect-timeout",
"ignoring --timeout because HTTPie's timeout is more similar to curl's --connect-timeout",
]);
} else {
flags.push("--timeout=" + repr(request.timeout));
// warn that this is not for the whole request
warnings.push([
"httpie-timeout",
"HTTPie's timeout is just for the connection, not for the whole request",
]);
}
}
if (request.verbose) {
flags.push("--verbose");
}
if (request.silent) {
flags.push("--quiet");
}
function localhostShorthand(u: Word): Word {
if (u.startsWith("localhost:")) {
return u.slice("localhost".length);
} else if (u.startsWith("localhost/") || eq(u, "localhost")) {
return u.slice("localhost".length).prepend(":");
}
return u;
}
let command = "http";
if (urlArg.startsWith("https://")) {
command = "https";
urlArg = localhostShorthand(urlArg.slice("https://".length));
} else if (urlArg.startsWith("http://")) {
urlArg = localhostShorthand(urlArg.slice("http://".length));
}
if (url.output) {
// TODO: pipe output
}
if (method) {
flags.push(method);
}
// If any of the field names or headers starts with a dash, add a -- argument
// TODO: this might have edge cases
if (items.some((i) => i.startsWith("-"))) {
items.unshift("--");
}
const args = [...flags, repr(urlArg), ...items];
const multiline =
args.length > 3 || args.reduce((a, b) => a + b.length, 0) > 80 - 5;
const joiner = multiline ? " \\\n " : " ";
return command + " " + args.join(joiner) + "\n";
}
export function _toHttpie(
requests: Request[],
warnings: Warnings = [],
): string {
const commands = [];
for (const request of requests) {
warnIfPartsIgnored(request, warnings, {
dataReadsFile: true,
// HTTPie has its own session.json file format
// cookieFiles: true,
multipleUrls: 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),
]);
}
for (const url of request.urls) {
commands.push(requestToHttpie(request, url, warnings));
}
}
return commands.join("\n\n");
}
export function toHttpieWarn(
curlCommand: string | string[],
warnings: Warnings = [],
): [string, Warnings] {
const requests = parse(curlCommand, supportedArgs, warnings);
const httpie = _toHttpie(requests, warnings);
return [httpie, warnings];
}
export function toHttpie(curlCommand: string | string[]): string {
return toHttpieWarn(curlCommand)[0];
}