|
| 1 | +#' Translate a httr2 request to a curl command |
| 2 | +#' |
| 3 | +#' Convert a httr2 request object to an approximate curl command line call. |
| 4 | +#' This is useful for debugging, for sharing a request with someone who doesn't |
| 5 | +#' use R, or for handing off to another tool. |
| 6 | +#' |
| 7 | +#' @inheritParams req_perform |
| 8 | +#' @inheritParams req_get_body |
| 9 | +#' @return A string containing the curl command. |
| 10 | +#' @seealso [curl_translate()] to translate in the other direction. |
| 11 | +#' @export |
| 12 | +#' @examples |
| 13 | +#' # Basic GET request |
| 14 | +#' request("https://httpbin.org/get") |> |
| 15 | +#' httr2_translate() |
| 16 | +#' |
| 17 | +#' # POST with JSON body |
| 18 | +#' request("https://httpbin.org/post") |> |
| 19 | +#' req_body_json(list(name = "value")) |> |
| 20 | +#' httr2_translate() |
| 21 | +#' |
| 22 | +#' # Secrets are redacted by default, but can be revealed |
| 23 | +#' request("https://example.com") |> |
| 24 | +#' req_headers_redacted(Authorization = "secret") |> |
| 25 | +#' httr2_translate(obfuscated = "reveal") |
| 26 | +httr2_translate <- function(req, obfuscated = c("redact", "reveal")) { |
| 27 | + check_request(req) |
| 28 | + obfuscated <- arg_match(obfuscated) |
| 29 | + |
| 30 | + req <- req_prepare(req) |
| 31 | + req <- auth_sign(req) |
| 32 | + |
| 33 | + body <- curl_body(req, obfuscated) |
| 34 | + args <- c( |
| 35 | + dquote(req_get_url(req)), |
| 36 | + curl_method(req, has_body = !is.null(body)), |
| 37 | + curl_headers(req, obfuscated), |
| 38 | + curl_options(req), |
| 39 | + body |
| 40 | + ) |
| 41 | + indent <- c("", rep(" ", length(args) - 1)) |
| 42 | + backslash <- c(rep(" \\", length(args) - 1), "") |
| 43 | + out <- paste0("curl ", paste0(indent, args, backslash, collapse = "\n")) |
| 44 | + |
| 45 | + structure(out, class = "httr2_cmd") |
| 46 | +} |
| 47 | + |
| 48 | +curl_method <- function(req, has_body = FALSE) { |
| 49 | + method <- req_get_method(req) |
| 50 | + if (method == "GET" || (method == "POST" && has_body)) { |
| 51 | + NULL |
| 52 | + } else if (method == "HEAD") { |
| 53 | + "--head" |
| 54 | + } else { |
| 55 | + paste0("--request ", method) |
| 56 | + } |
| 57 | +} |
| 58 | + |
| 59 | +curl_headers <- function(req, obfuscated = c("redact", "reveal")) { |
| 60 | + obfuscated <- arg_match(obfuscated) |
| 61 | + |
| 62 | + headers <- req_get_headers(req, redacted = obfuscated) |
| 63 | + if (is_empty(headers)) { |
| 64 | + return(NULL) |
| 65 | + } |
| 66 | + paste0("--header ", dquote(paste0(names(headers), ": ", unlist(headers)))) |
| 67 | +} |
| 68 | + |
| 69 | +curl_options <- function(req) { |
| 70 | + options <- req$options |
| 71 | + |
| 72 | + known_options <- c( |
| 73 | + "timeout_ms", # req_timeout() |
| 74 | + "connecttimeout", # req_timeout() |
| 75 | + "proxy", # req_proxy() |
| 76 | + "proxyport", # req_proxy() |
| 77 | + "proxyuserpwd", # req_proxy() |
| 78 | + "useragent", # req_user_agent() |
| 79 | + "followlocation", |
| 80 | + "verbose", # req_verbose() |
| 81 | + "cookiejar", # req_cookie_preserve() |
| 82 | + "cookiefile", # req_cookie_preserve() |
| 83 | + "cookie" # req_cookies_set() |
| 84 | + ) |
| 85 | + # R callbacks and the like, with no command line equivalent |
| 86 | + ignored_options <- c( |
| 87 | + "debugfunction", # req_verbose() |
| 88 | + "xferinfofunction", # req_progress() |
| 89 | + "noprogress", # req_progress() |
| 90 | + "proxyauth", # req_proxy() |
| 91 | + "forbid_reuse", # req_verbose_test() |
| 92 | + "nobody", # req_method_apply() |
| 93 | + "customrequest", # req_method_apply() |
| 94 | + "post", # req_body_apply() |
| 95 | + "postfieldsize", # req_body_apply() |
| 96 | + "postfields", # req_body_apply() |
| 97 | + "readfunction", # req_body_apply() |
| 98 | + "seekfunction", # req_body_apply() |
| 99 | + "postfieldsize_large" # req_body_apply() |
| 100 | + ) |
| 101 | + unknown <- setdiff(names(options), c(known_options, ignored_options)) |
| 102 | + if (length(unknown) > 0) { |
| 103 | + cli::cli_warn("Can't translate option{?s} {.val {unknown}}.") |
| 104 | + } |
| 105 | + |
| 106 | + args <- lapply(names(options), function(name) { |
| 107 | + value <- options[[name]] |
| 108 | + switch( |
| 109 | + name, |
| 110 | + timeout_ms = paste0("--max-time ", value / 1000), |
| 111 | + connecttimeout = paste0("--connect-timeout ", value), |
| 112 | + proxy = paste0( |
| 113 | + "--proxy ", |
| 114 | + dquote(paste0(c(value, options$proxyport), collapse = ":")) |
| 115 | + ), |
| 116 | + proxyuserpwd = paste0("--proxy-user ", dquote(value)), |
| 117 | + useragent = paste0("--user-agent ", dquote(value)), |
| 118 | + verbose = if (value) "--verbose", |
| 119 | + cookiejar = paste0("--cookie-jar ", dquote(value)), |
| 120 | + cookiefile = paste0("--cookie ", dquote(value)), |
| 121 | + cookie = paste0("--cookie ", dquote(value)) |
| 122 | + ) |
| 123 | + }) |
| 124 | + |
| 125 | + # httr2 follows redirects by default, but command line curl doesn't |
| 126 | + follow <- if (!isFALSE(options$followlocation)) "--location" |
| 127 | + |
| 128 | + c(follow, unlist(args)) |
| 129 | +} |
| 130 | + |
| 131 | +curl_body <- function(req, obfuscated = c("redact", "reveal")) { |
| 132 | + obfuscated <- arg_match(obfuscated) |
| 133 | + |
| 134 | + body <- req_get_body(req, obfuscated = obfuscated) |
| 135 | + if (is.null(body)) { |
| 136 | + return(NULL) |
| 137 | + } |
| 138 | + type <- req_get_body_type(req) |
| 139 | + if (type == "raw") { |
| 140 | + cli::cli_abort("Can't translate a request with a raw body.", call = NULL) |
| 141 | + } |
| 142 | + |
| 143 | + c( |
| 144 | + curl_content_type(req, type), |
| 145 | + curl_body_data(body, type, params = req$body$params) |
| 146 | + ) |
| 147 | +} |
| 148 | + |
| 149 | +# Skip if Content-Type is already set as a header |
| 150 | +curl_content_type <- function(req, type) { |
| 151 | + if ("content-type" %in% tolower(names(req_get_headers(req)))) { |
| 152 | + return(NULL) |
| 153 | + } |
| 154 | + |
| 155 | + content_type <- req$body$content_type %||% |
| 156 | + switch( |
| 157 | + type, |
| 158 | + json = "application/json", |
| 159 | + form = "application/x-www-form-urlencoded" |
| 160 | + ) |
| 161 | + if (is.null(content_type) || !nzchar(content_type)) { |
| 162 | + return(NULL) |
| 163 | + } |
| 164 | + paste0("--header ", dquote(paste0("Content-Type: ", content_type))) |
| 165 | +} |
| 166 | + |
| 167 | +curl_body_data <- function(body, type, params = list(auto_unbox = TRUE)) { |
| 168 | + switch( |
| 169 | + type, |
| 170 | + string = paste0("--data ", dquote(body)), |
| 171 | + file = paste0("--data-binary ", dquote(paste0("@", body))), |
| 172 | + json = paste0("--data ", dquote(exec(jsonlite::toJSON, body, !!!params))), |
| 173 | + form = paste0("--data ", dquote(url_query_build(body))), |
| 174 | + multipart = curl_body_multipart(body) |
| 175 | + ) |
| 176 | +} |
| 177 | + |
| 178 | +curl_body_multipart <- function(body) { |
| 179 | + unlist(Map(curl_body_multipart_field, names(body), body), use.names = FALSE) |
| 180 | +} |
| 181 | + |
| 182 | +curl_body_multipart_field <- function(name, value) { |
| 183 | + if (inherits(value, "form_file")) { |
| 184 | + spec <- paste0(name, "=@", curl_form_quote(value$path)) |
| 185 | + if (!is.null(value$type)) { |
| 186 | + spec <- paste0(spec, ";type=", value$type) |
| 187 | + } |
| 188 | + if (!is.null(value$name)) { |
| 189 | + spec <- paste0(spec, ";filename=", curl_form_quote(value$name)) |
| 190 | + } |
| 191 | + paste0("--form ", dquote(spec)) |
| 192 | + } else if (inherits(value, "form_data")) { |
| 193 | + type <- value$type |
| 194 | + value <- rawToChar(value$value) |
| 195 | + if (is.null(type)) { |
| 196 | + paste0("--form-string ", dquote(paste0(name, "=", value))) |
| 197 | + } else { |
| 198 | + spec <- paste0(name, "=", curl_form_quote(value), ";type=", type) |
| 199 | + paste0("--form ", dquote(spec)) |
| 200 | + } |
| 201 | + } else { |
| 202 | + paste0("--form-string ", dquote(paste0(name, "=", value))) |
| 203 | + } |
| 204 | +} |
| 205 | + |
| 206 | +curl_form_quote <- function(x) { |
| 207 | + paste0('"', gsub('(["\\\\])', "\\\\\\1", x), '"') |
| 208 | +} |
| 209 | + |
| 210 | +dquote <- function(x) { |
| 211 | + ifelse( |
| 212 | + grepl("[^A-Za-z0-9._~:/@%+=,-]", x), |
| 213 | + paste0("'", gsub("'", "'\"'\"'", x, fixed = TRUE), "'"), |
| 214 | + x |
| 215 | + ) |
| 216 | +} |
0 commit comments