diff --git a/NAMESPACE b/NAMESPACE index bd2be8b7e..4c2235c89 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -25,6 +25,7 @@ export(curl_help) export(curl_translate) export(example_github_client) export(example_url) +export(httr2_translate) export(is_online) export(iterate_with_cursor) export(iterate_with_link_url) diff --git a/NEWS.md b/NEWS.md index 430d71e96..11ae175d0 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,7 @@ # httr2 (development version) * Mocked and cached responses now include the originating request in `resp$request`, just like real responses (#841). +* New `httr2_translate()` translates an httr2 request into the equivalent curl command (#795). * `last_response_json()` now works with content-types that end with `+json`, e.g. `application/problem+json` (@cgiachalis, #782). * `oauth_*()` token refresh now forwards `token_params` from the original flow, so extra token-endpoint parameters (e.g. a `scope` required on the token exchange) are sent on refresh as well as on the initial token request (@simonpcouch). * `oauth_client()` gains a `metadata` argument: pass the result of `oauth_server_metadata()` and the client carries all of the server's endpoints, so the OAuth flows pick them up automatically instead of threading them into each call (#846). diff --git a/R/curl.R b/R/curl.R index 0f4605ad5..c0a3c891d 100644 --- a/R/curl.R +++ b/R/curl.R @@ -1,4 +1,4 @@ -#' Translate curl syntax to httr2 +#' Translate a curl command to a httr2 request #' #' @description #' The curl command line tool is commonly used to demonstrate HTTP APIs and can @@ -24,6 +24,7 @@ #' was copied from the clipboard, the translation will be copied back #' to the clipboard. #' @export +#' @seealso [httr2_translate()] #' @examples #' curl_translate("curl http://example.com") #' curl_translate("curl http://example.com -X DELETE") diff --git a/R/httr2-translate.R b/R/httr2-translate.R new file mode 100644 index 000000000..3f84f80df --- /dev/null +++ b/R/httr2-translate.R @@ -0,0 +1,216 @@ +#' Translate a httr2 request to a curl command +#' +#' Convert a httr2 request object to an approximate curl command line call. +#' This is useful for debugging, for sharing a request with someone who doesn't +#' use R, or for handing off to another tool. +#' +#' @inheritParams req_perform +#' @inheritParams req_get_body +#' @return A string containing the curl command. +#' @seealso [curl_translate()] to translate in the other direction. +#' @export +#' @examples +#' # Basic GET request +#' request("https://httpbin.org/get") |> +#' httr2_translate() +#' +#' # POST with JSON body +#' request("https://httpbin.org/post") |> +#' req_body_json(list(name = "value")) |> +#' httr2_translate() +#' +#' # Secrets are redacted by default, but can be revealed +#' request("https://example.com") |> +#' req_headers_redacted(Authorization = "secret") |> +#' httr2_translate(obfuscated = "reveal") +httr2_translate <- function(req, obfuscated = c("redact", "reveal")) { + check_request(req) + obfuscated <- arg_match(obfuscated) + + req <- req_prepare(req) + req <- auth_sign(req) + + body <- curl_body(req, obfuscated) + args <- c( + dquote(req_get_url(req)), + curl_method(req, has_body = !is.null(body)), + curl_headers(req, obfuscated), + curl_options(req), + body + ) + indent <- c("", rep(" ", length(args) - 1)) + backslash <- c(rep(" \\", length(args) - 1), "") + out <- paste0("curl ", paste0(indent, args, backslash, collapse = "\n")) + + structure(out, class = "httr2_cmd") +} + +curl_method <- function(req, has_body = FALSE) { + method <- req_get_method(req) + if (method == "GET" || (method == "POST" && has_body)) { + NULL + } else if (method == "HEAD") { + "--head" + } else { + paste0("--request ", method) + } +} + +curl_headers <- function(req, obfuscated = c("redact", "reveal")) { + obfuscated <- arg_match(obfuscated) + + headers <- req_get_headers(req, redacted = obfuscated) + if (is_empty(headers)) { + return(NULL) + } + paste0("--header ", dquote(paste0(names(headers), ": ", unlist(headers)))) +} + +curl_options <- function(req) { + options <- req$options + + known_options <- c( + "timeout_ms", # req_timeout() + "connecttimeout", # req_timeout() + "proxy", # req_proxy() + "proxyport", # req_proxy() + "proxyuserpwd", # req_proxy() + "useragent", # req_user_agent() + "followlocation", + "verbose", # req_verbose() + "cookiejar", # req_cookie_preserve() + "cookiefile", # req_cookie_preserve() + "cookie" # req_cookies_set() + ) + # R callbacks and the like, with no command line equivalent + ignored_options <- c( + "debugfunction", # req_verbose() + "xferinfofunction", # req_progress() + "noprogress", # req_progress() + "proxyauth", # req_proxy() + "forbid_reuse", # req_verbose_test() + "nobody", # req_method_apply() + "customrequest", # req_method_apply() + "post", # req_body_apply() + "postfieldsize", # req_body_apply() + "postfields", # req_body_apply() + "readfunction", # req_body_apply() + "seekfunction", # req_body_apply() + "postfieldsize_large" # req_body_apply() + ) + unknown <- setdiff(names(options), c(known_options, ignored_options)) + if (length(unknown) > 0) { + cli::cli_warn("Can't translate option{?s} {.val {unknown}}.") + } + + args <- lapply(names(options), function(name) { + value <- options[[name]] + switch( + name, + timeout_ms = paste0("--max-time ", value / 1000), + connecttimeout = paste0("--connect-timeout ", value), + proxy = paste0( + "--proxy ", + dquote(paste0(c(value, options$proxyport), collapse = ":")) + ), + proxyuserpwd = paste0("--proxy-user ", dquote(value)), + useragent = paste0("--user-agent ", dquote(value)), + verbose = if (value) "--verbose", + cookiejar = paste0("--cookie-jar ", dquote(value)), + cookiefile = paste0("--cookie ", dquote(value)), + cookie = paste0("--cookie ", dquote(value)) + ) + }) + + # httr2 follows redirects by default, but command line curl doesn't + follow <- if (!isFALSE(options$followlocation)) "--location" + + c(follow, unlist(args)) +} + +curl_body <- function(req, obfuscated = c("redact", "reveal")) { + obfuscated <- arg_match(obfuscated) + + body <- req_get_body(req, obfuscated = obfuscated) + if (is.null(body)) { + return(NULL) + } + type <- req_get_body_type(req) + if (type == "raw") { + cli::cli_abort("Can't translate a request with a raw body.", call = NULL) + } + + c( + curl_content_type(req, type), + curl_body_data(body, type, params = req$body$params) + ) +} + +# Skip if Content-Type is already set as a header +curl_content_type <- function(req, type) { + if ("content-type" %in% tolower(names(req_get_headers(req)))) { + return(NULL) + } + + content_type <- req$body$content_type %||% + switch( + type, + json = "application/json", + form = "application/x-www-form-urlencoded" + ) + if (is.null(content_type) || !nzchar(content_type)) { + return(NULL) + } + paste0("--header ", dquote(paste0("Content-Type: ", content_type))) +} + +curl_body_data <- function(body, type, params = list(auto_unbox = TRUE)) { + switch( + type, + string = paste0("--data ", dquote(body)), + file = paste0("--data-binary ", dquote(paste0("@", body))), + json = paste0("--data ", dquote(exec(jsonlite::toJSON, body, !!!params))), + form = paste0("--data ", dquote(url_query_build(body))), + multipart = curl_body_multipart(body) + ) +} + +curl_body_multipart <- function(body) { + unlist(Map(curl_body_multipart_field, names(body), body), use.names = FALSE) +} + +curl_body_multipart_field <- function(name, value) { + if (inherits(value, "form_file")) { + spec <- paste0(name, "=@", curl_form_quote(value$path)) + if (!is.null(value$type)) { + spec <- paste0(spec, ";type=", value$type) + } + if (!is.null(value$name)) { + spec <- paste0(spec, ";filename=", curl_form_quote(value$name)) + } + paste0("--form ", dquote(spec)) + } else if (inherits(value, "form_data")) { + type <- value$type + value <- rawToChar(value$value) + if (is.null(type)) { + paste0("--form-string ", dquote(paste0(name, "=", value))) + } else { + spec <- paste0(name, "=", curl_form_quote(value), ";type=", type) + paste0("--form ", dquote(spec)) + } + } else { + paste0("--form-string ", dquote(paste0(name, "=", value))) + } +} + +curl_form_quote <- function(x) { + paste0('"', gsub('(["\\\\])', "\\\\\\1", x), '"') +} + +dquote <- function(x) { + ifelse( + grepl("[^A-Za-z0-9._~:/@%+=,-]", x), + paste0("'", gsub("'", "'\"'\"'", x, fixed = TRUE), "'"), + x + ) +} diff --git a/_pkgdown.yml b/_pkgdown.yml index 9154994ff..aaac1698f 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -77,6 +77,7 @@ reference: - title: Miscellaneous helpers contents: - curl_translate + - httr2_translate - is_online - title: OAuth diff --git a/man/curl_translate.Rd b/man/curl_translate.Rd index f978854c7..e4a9ae7bb 100644 --- a/man/curl_translate.Rd +++ b/man/curl_translate.Rd @@ -3,7 +3,7 @@ \name{curl_translate} \alias{curl_translate} \alias{curl_help} -\title{Translate curl syntax to httr2} +\title{Translate a curl command to a httr2 request} \usage{ curl_translate(cmd, simplify_headers = TRUE) @@ -44,3 +44,6 @@ curl_translate("curl http://example.com -X DELETE") curl_translate("curl http://example.com --header A:1 --header B:2") curl_translate("curl http://example.com --verbose") } +\seealso{ +\code{\link[=httr2_translate]{httr2_translate()}} +} diff --git a/man/httr2_translate.Rd b/man/httr2_translate.Rd new file mode 100644 index 000000000..66dc0812c --- /dev/null +++ b/man/httr2_translate.Rd @@ -0,0 +1,41 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/httr2-translate.R +\name{httr2_translate} +\alias{httr2_translate} +\title{Translate a httr2 request to a curl command} +\usage{ +httr2_translate(req, obfuscated = c("redact", "reveal")) +} +\arguments{ +\item{req}{A httr2 \link{request} object.} + +\item{obfuscated}{Form and JSON bodies can contain \link{obfuscated} values. +This argument control what happens to them: should they be removed, +redacted, or revealed.} +} +\value{ +A string containing the curl command. +} +\description{ +Convert a httr2 request object to an approximate curl command line call. +This is useful for debugging, for sharing a request with someone who doesn't +use R, or for handing off to another tool. +} +\examples{ +# Basic GET request +request("https://httpbin.org/get") |> + httr2_translate() + +# POST with JSON body +request("https://httpbin.org/post") |> + req_body_json(list(name = "value")) |> + httr2_translate() + +# Secrets are redacted by default, but can be revealed +request("https://example.com") |> + req_headers_redacted(Authorization = "secret") |> + httr2_translate(obfuscated = "reveal") +} +\seealso{ +\code{\link[=curl_translate]{curl_translate()}} to translate in the other direction. +} diff --git a/man/req_throttle.Rd b/man/req_throttle.Rd index 3a508529d..7bb1ca97a 100644 --- a/man/req_throttle.Rd +++ b/man/req_throttle.Rd @@ -61,10 +61,12 @@ resp <- req_perform(req) throttle_status() \dontshow{httr2:::throttle_reset()} -# Enforce multiple limits at once: no more than 10 requests every 10s -# and no more than 50 requests every 60s +# Enforce multiple limits at once: no more than 10 requests every 1s +# and no more than 100 requests every 60s req <- request(example_url()) |> - req_throttle(capacity = c(10, 50), fill_time_s = c(10, 60)) + req_throttle(capacity = c(10, 100), fill_time_s = c(1, 60)) +resp <- req_perform(req) +throttle_status() \dontshow{httr2:::throttle_reset()} } \seealso{ diff --git a/tests/testthat/_snaps/httr2-translate.md b/tests/testthat/_snaps/httr2-translate.md new file mode 100644 index 000000000..e3f57e23a --- /dev/null +++ b/tests/testthat/_snaps/httr2-translate.md @@ -0,0 +1,273 @@ +# httr2_translate() works with basic GET requests + + Code + httr2_translate(request("https://hb.cran.dev/get")) + Output + curl https://hb.cran.dev/get \ + --location \ + --user-agent httr2 + +# httr2_translate() works with POST methods + + Code + httr2_translate(req_method(request("https://hb.cran.dev/post"), "POST")) + Output + curl https://hb.cran.dev/post \ + --request POST \ + --location \ + --user-agent httr2 + +# httr2_translate() works with headers + + Code + httr2_translate(req_headers(request("https://hb.cran.dev/get"), Accept = "application/json", + `User-Agent` = "httr2/1.0")) + Output + curl https://hb.cran.dev/get \ + --header 'Accept: application/json' \ + --header 'User-Agent: httr2/1.0' \ + --location \ + --user-agent httr2 + +# httr2_translate() works with JSON bodies + + Code + httr2_translate(req_body_json(request("https://hb.cran.dev/post"), list(name = "test", + value = 123))) + Output + curl https://hb.cran.dev/post \ + --header 'Content-Type: application/json' \ + --location \ + --user-agent httr2 \ + --data '{"name":"test","value":123}' + +# httr2_translate() works with form bodies + + Code + httr2_translate(req_body_form(request("https://hb.cran.dev/post"), name = "test", + value = "123")) + Output + curl https://hb.cran.dev/post \ + --location \ + --user-agent httr2 \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data 'name=test&value=123' + +# httr2_translate() works with multipart bodies + + Code + httr2_translate(req_body_multipart(request("https://hb.cran.dev/post"), name = "test", + value = "123")) + Output + curl https://hb.cran.dev/post \ + --location \ + --user-agent httr2 \ + --form-string name=test \ + --form-string value=123 + +# httr2_translate() works with string bodies + + Code + httr2_translate(req_body_raw(request("https://hb.cran.dev/post"), "test data", + type = "text/plain")) + Output + curl https://hb.cran.dev/post \ + --header 'Content-Type: text/plain' \ + --location \ + --user-agent httr2 \ + --data 'test data' + +# httr2_translate() works with file bodies + + Code + httr2_translate(req_body_file(request("https://hb.cran.dev/post"), path, type = "text/plain")) + Output + curl https://hb.cran.dev/post \ + --header 'Content-Type: text/plain' \ + --location \ + --user-agent httr2 \ + --data-binary @ + +# httr2_translate() works with custom content types + + Code + httr2_translate(req_body_json(request("https://hb.cran.dev/post"), list(test = "data"), + type = "application/vnd.api+json")) + Output + curl https://hb.cran.dev/post \ + --header 'Content-Type: application/vnd.api+json' \ + --location \ + --user-agent httr2 \ + --data '{"test":"data"}' + +# httr2_translate() works with options + + Code + httr2_translate(req_options(request("https://hb.cran.dev/get"), verbose = TRUE, + ssl_verifypeer = FALSE)) + Condition + Warning: + Can't translate option "ssl_verifypeer". + Output + curl https://hb.cran.dev/get \ + --location \ + --verbose \ + --user-agent httr2 + +# httr2_translate() works with cookies + + Code + httr2_translate(req_options(request("https://hb.cran.dev/cookies"), cookiejar = cookie_file, + cookiefile = cookie_file)) + Output + curl https://hb.cran.dev/cookies \ + --location \ + --cookie-jar \ + --cookie \ + --user-agent httr2 + +# httr2_translate() works with obfuscated values in headers + + Code + httr2_translate(req_headers(request("https://hb.cran.dev/get"), Authorization = obfuscated( + "ZdYJeG8zwISodg0nu4UxBhs"))) + Output + curl https://hb.cran.dev/get \ + --header 'Authorization: ' \ + --location \ + --user-agent httr2 + +# httr2_translate() can reveal obfuscated values + + Code + httr2_translate(req_headers_redacted(request("https://hb.cran.dev/get"), + Authorization = "secret-token"), obfuscated = "reveal") + Output + curl https://hb.cran.dev/get \ + --header 'Authorization: secret-token' \ + --location \ + --user-agent httr2 + +# httr2_translate() works with obfuscated values in JSON body + + Code + httr2_translate(req_body_json(request("https://hb.cran.dev/post"), list( + username = "test", password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs")))) + Output + curl https://hb.cran.dev/post \ + --header 'Content-Type: application/json' \ + --location \ + --user-agent httr2 \ + --data '{"username":"test","password":""}' + +# httr2_translate() works with obfuscated values in form body + + Code + httr2_translate(req_body_form(request("https://hb.cran.dev/post"), username = "test", + password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs"))) + Output + curl https://hb.cran.dev/post \ + --location \ + --user-agent httr2 \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data 'username=test&password=%3CREDACTED%3E' + +# httr2_translate() works with complex requests + + Code + httr2_translate(req_body_json(req_headers(req_method(request( + "https://api.github.com/user/repos"), "POST"), Accept = "application/vnd.github.v3+json", + Authorization = obfuscated("ZdYJeG8zwISodg0nu4UxBhs"), `User-Agent` = "MyApp/1.0"), + list(name = "test-repo", description = "A test repository", private = TRUE))) + Output + curl https://api.github.com/user/repos \ + --header 'Accept: application/vnd.github.v3+json' \ + --header 'Authorization: ' \ + --header 'User-Agent: MyApp/1.0' \ + --header 'Content-Type: application/json' \ + --location \ + --user-agent httr2 \ + --data '{"name":"test-repo","description":"A test repository","private":true}' + +# httr2_translate() puts a request with no arguments on a single line + + Code + httr2_translate(req_options(request("https://hb.cran.dev/get"), followlocation = FALSE)) + Output + curl https://hb.cran.dev/get \ + --user-agent httr2 + +# httr2_translate() validates input + + Code + httr2_translate("not a request") + Condition + Error in `httr2_translate()`: + ! `req` must be an HTTP request object, not the string "not a request". + +# httr2_translate() errors for raw bodies + + Code + httr2_translate(req) + Condition + Error: + ! Can't translate a request with a raw body. + +# an explicit Content-Type header isn't duplicated by the body + + Code + httr2_translate(req_body_raw(req_headers(request("https://hb.cran.dev/post"), + `Content-Type` = "application/json"), "{}")) + Output + curl https://hb.cran.dev/post \ + --header 'Content-Type: application/json' \ + --location \ + --user-agent httr2 \ + --data '{}' + +# curl_body_data() translates multipart values + + Code + writeLines(curl_body_data(body, "multipart")) + Output + --form-string 'text=@literal;value' + --form 'file=@"";type=text/plain;filename="name.txt"' + --form 'data="a b";type=text/plain' + +# curl_options() translates each known option + + Code + cat(curl_options(req), sep = "\n") + Output + --location + --max-time 30 + --connect-timeout 5 + --proxy http://proxy.example.com + --user-agent agent + --verbose + --cookie-jar jar.txt + --cookie file.txt + +# curl_options() translates options set by httr2 functions + + Code + cat(curl_options(req), sep = "\n") + Output + --location + --max-time 30 + --connect-timeout 0 + --proxy proxy.example.com:8080 + --proxy-user u:p + --user-agent agent + --cookie-jar cookies.txt + --cookie cookies.txt + --cookie session=abc + +# curl_options() warns about untranslatable options + + Code + out <- curl_options(req) + Condition + Warning: + Can't translate option "ssl_verifypeer". + diff --git a/tests/testthat/helper.R b/tests/testthat/helper.R index 2d0550cca..da0757fa2 100644 --- a/tests/testthat/helper.R +++ b/tests/testthat/helper.R @@ -8,3 +8,15 @@ testthat::set_state_inspector(function() { expect_redacted <- function(req, expected) { expect_equal(which_redacted(req$headers), expected) } + +# The default user agent includes httr2/curl/libcurl versions, which vary, so +# mock it to a fixed value. Mock req_user_agent() rather than +# default_user_agent() since the latter is cached in an internal environment. +local_mocked_user_agent <- function(env = caller_env()) { + local_mocked_bindings( + req_user_agent = function(req, string = NULL) { + req_options(req, useragent = string %||% "httr2") + }, + .env = env + ) +} diff --git a/tests/testthat/test-httr2-translate.R b/tests/testthat/test-httr2-translate.R new file mode 100644 index 000000000..aac8b78cf --- /dev/null +++ b/tests/testthat/test-httr2-translate.R @@ -0,0 +1,406 @@ +test_that("httr2_translate() works with basic GET requests", { + local_mocked_user_agent() + expect_snapshot({ + request("https://hb.cran.dev/get") |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with POST methods", { + local_mocked_user_agent() + expect_snapshot({ + request("https://hb.cran.dev/post") |> + req_method("POST") |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with headers", { + local_mocked_user_agent() + expect_snapshot({ + request("https://hb.cran.dev/get") |> + req_headers( + "Accept" = "application/json", + "User-Agent" = "httr2/1.0" + ) |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with JSON bodies", { + local_mocked_user_agent() + expect_snapshot({ + request("https://hb.cran.dev/post") |> + req_body_json(list(name = "test", value = 123)) |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with form bodies", { + local_mocked_user_agent() + expect_snapshot({ + request("https://hb.cran.dev/post") |> + req_body_form(name = "test", value = "123") |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with multipart bodies", { + local_mocked_user_agent() + expect_snapshot({ + request("https://hb.cran.dev/post") |> + req_body_multipart(name = "test", value = "123") |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with string bodies", { + local_mocked_user_agent() + expect_snapshot({ + request("https://hb.cran.dev/post") |> + req_body_raw("test data", type = "text/plain") |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with file bodies", { + local_mocked_user_agent() + path <- tempfile() + writeLines("test content", path) + + # normalize the path + path <- normalizePath(path, winslash = "/") + + expect_snapshot( + { + request("https://hb.cran.dev/post") |> + req_body_file(path, type = "text/plain") |> + httr2_translate() + }, + transform = function(x) { + gsub(path, "", x, fixed = TRUE) + } + ) +}) + +test_that("httr2_translate() works with custom content types", { + local_mocked_user_agent() + expect_snapshot({ + request("https://hb.cran.dev/post") |> + req_body_json( + list(test = "data"), + type = "application/vnd.api+json" + ) |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with options", { + local_mocked_user_agent() + expect_snapshot({ + request("https://hb.cran.dev/get") |> + req_options(verbose = TRUE, ssl_verifypeer = FALSE) |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with cookies", { + local_mocked_user_agent() + cookie_file <- tempfile() + + # create the tempfile + file.create(cookie_file) + + # normalize the path + cookie_file <- normalizePath(cookie_file, winslash = "/") + + expect_snapshot( + { + request("https://hb.cran.dev/cookies") |> + req_options(cookiejar = cookie_file, cookiefile = cookie_file) |> + httr2_translate() + }, + transform = function(x) { + gsub(cookie_file, "", x, fixed = TRUE) + } + ) +}) + +test_that("httr2_translate() works with obfuscated values in headers", { + local_mocked_user_agent() + expect_snapshot({ + request("https://hb.cran.dev/get") |> + req_headers("Authorization" = obfuscated("ZdYJeG8zwISodg0nu4UxBhs")) |> + httr2_translate() + }) +}) + +test_that("httr2_translate() can reveal obfuscated values", { + local_mocked_user_agent() + expect_snapshot({ + request("https://hb.cran.dev/get") |> + req_headers_redacted(Authorization = "secret-token") |> + httr2_translate(obfuscated = "reveal") + }) +}) + +test_that("httr2_translate() works with obfuscated values in JSON body", { + local_mocked_user_agent() + expect_snapshot({ + request("https://hb.cran.dev/post") |> + req_body_json(list( + username = "test", + password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs") + )) |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with obfuscated values in form body", { + local_mocked_user_agent() + expect_snapshot({ + request("https://hb.cran.dev/post") |> + req_body_form( + username = "test", + password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs") + ) |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with complex requests", { + local_mocked_user_agent() + expect_snapshot({ + request("https://api.github.com/user/repos") |> + req_method("POST") |> + req_headers( + "Accept" = "application/vnd.github.v3+json", + "Authorization" = obfuscated("ZdYJeG8zwISodg0nu4UxBhs"), + "User-Agent" = "MyApp/1.0" + ) |> + req_body_json(list( + name = "test-repo", + description = "A test repository", + private = TRUE + )) |> + httr2_translate() + }) +}) + +test_that("httr2_translate() puts a request with no arguments on a single line", { + local_mocked_user_agent() + expect_snapshot({ + request("https://hb.cran.dev/get") |> + req_options(followlocation = FALSE) |> + httr2_translate() + }) +}) + +test_that("httr2_translate() validates input", { + expect_snapshot(error = TRUE, { + httr2_translate("not a request") + }) +}) + +test_that("httr2_translate() signs AWS requests", { + req <- request("https://sts.us-east-1.amazonaws.com/") |> + req_body_form( + Action = "GetCallerIdentity", + Version = "2011-06-15" + ) |> + req_auth_aws_v4( + aws_access_key_id = "AKIAIOSFODNN7EXAMPLE", + aws_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + ) + + command <- as.character(httr2_translate(req, obfuscated = "reveal")) + expect_match( + command, + "Authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/" + ) + expect_match( + command, + "SignedHeaders=host;x-amz-date" + ) + expect_match(command, "--header 'x-amz-date: [0-9]{8}T[0-9]{6}Z'") +}) + +test_that("httr2_translate() errors for raw bodies", { + req <- request("https://hb.cran.dev/post") |> + req_body_raw(as.raw(c(0x00, 0x68, 0x69, 0xff))) + expect_snapshot(httr2_translate(req), error = TRUE) +}) + +test_that("an explicit Content-Type header isn't duplicated by the body", { + local_mocked_user_agent() + expect_snapshot({ + request("https://hb.cran.dev/post") |> + req_headers("Content-Type" = "application/json") |> + req_body_raw("{}") |> + httr2_translate() + }) +}) + +test_that("dquote() quotes only when needed", { + # plain values are left alone + expect_equal(dquote("https://example.com/get"), "https://example.com/get") + # spaces and query-string metacharacters force quoting + expect_equal(dquote("a b"), "'a b'") + expect_equal( + dquote("https://example.com?a=1&b=2"), + "'https://example.com?a=1&b=2'" + ) +}) + +test_that("dquote() protects shell metacharacters", { + expect_equal(dquote('a"b'), "'a\"b'") + expect_equal(dquote("a'b"), "'a'\"'\"'b'") + expect_equal(dquote("a$b`c`\\d"), "'a$b`c`\\d'") +}) + +test_that("curl_body_data() safely quotes strings and JSON", { + expect_equal( + curl_body_data("a'b$HOME", "string"), + "--data 'a'\"'\"'b$HOME'" + ) + expect_equal( + curl_body_data(list(value = "a'b$HOME"), "json"), + "--data '{\"value\":\"a'\"'\"'b$HOME\"}'" + ) +}) + +test_that("curl_body_data() uses the request's JSON parameters", { + expect_equal( + curl_body_data( + list(x = 1.23456, y = NULL), + "json", + params = list(auto_unbox = TRUE, digits = 2, null = "list") + ), + "--data '{\"x\":1.23,\"y\":{}}'" + ) +}) + +test_that("curl_body_data() URL encodes form data", { + expect_equal( + curl_body_data(list(x = "a b&c=d", y = "x+y", z = "é"), "form"), + "--data 'x=a%20b%26c%3Dd&y=x%2By&z=%C3%A9'" + ) +}) + +test_that("curl_body_data() translates multipart values", { + path <- file.path(withr::local_tempdir(), "contents") + writeLines("contents", path) + + body <- list( + text = "@literal;value", + file = curl::form_file(path, type = "text/plain", name = "name.txt"), + data = curl::form_data("a b", type = "text/plain") + ) + expect_snapshot( + writeLines(curl_body_data(body, "multipart")), + # the temp file path varies and uses (escaped) \ on Windows + transform = function(x) gsub('@"[^"]+"', '@""', x) + ) +}) + +test_that("curl_method() only sets the method when curl can't infer it", { + req <- request("https://example.com") + + # GET is the default, and curl infers POST from a body + expect_null(curl_method(req)) + expect_null(curl_method(req_method(req, "POST"), has_body = TRUE)) + + # HEAD has its own flag + expect_equal(curl_method(req_method(req, "HEAD")), "--head") + + # a body-less POST and other methods need --request + expect_equal(curl_method(req_method(req, "POST")), "--request POST") + expect_equal( + curl_method(req_method(req, "DELETE")), + "--request DELETE" + ) + # a body alone implies POST, but not PUT/DELETE/etc. + expect_equal( + curl_method(req_method(req, "PUT"), has_body = TRUE), + "--request PUT" + ) +}) + +test_that("curl_headers() drops missing headers and reveals secrets", { + expect_null(curl_headers(request("https://example.com"))) + + req <- request("https://example.com") |> + req_headers_redacted(Authorization = "secret") + expect_equal( + curl_headers(req, "redact"), + "--header 'Authorization: '" + ) + expect_equal( + curl_headers(req, "reveal"), + "--header 'Authorization: secret'" + ) +}) + +test_that("curl_options() translates each known option", { + req <- request("https://example.com") |> + req_options( + timeout_ms = 30000, + connecttimeout = 5, + proxy = "http://proxy.example.com", + useragent = "agent", + followlocation = TRUE, + verbose = TRUE, + cookiejar = "jar.txt", + cookiefile = "file.txt" + ) + expect_snapshot(cat(curl_options(req), sep = "\n")) +}) + +test_that("curl_options() translates options set by httr2 functions", { + req <- request("https://example.com") |> + req_timeout(30) |> + req_proxy( + "proxy.example.com", + port = 8080, + username = "u", + password = "p" + ) |> + req_user_agent("agent") |> + req_cookie_preserve("cookies.txt") |> + req_cookies_set(session = "abc") + expect_snapshot(cat(curl_options(req), sep = "\n")) +}) + +test_that("curl_options() follows redirects by default, unlike curl", { + expect_equal( + curl_options(request("https://example.com")), + "--location" + ) + + req <- request("https://example.com") |> + req_options(followlocation = FALSE) + expect_null(curl_options(req)) +}) + +test_that("curl_options() ignores options with no curl equivalent", { + # req_verbose() also sets a debugfunction; req_progress() sets callbacks + req <- request("https://example.com") |> + req_options(followlocation = FALSE) |> + req_verbose() |> + req_progress() + expect_no_warning(out <- curl_options(req)) + expect_equal(out, "--verbose") +}) + +test_that("curl_options() drops disabled flags", { + req <- request("https://example.com") |> + req_options(followlocation = FALSE, verbose = FALSE) + expect_null(curl_options(req)) +}) + +test_that("curl_options() warns about untranslatable options", { + req <- request("https://example.com") |> + req_options(followlocation = FALSE, ssl_verifypeer = FALSE) + expect_snapshot(out <- curl_options(req)) + expect_null(out) +})