From f22105b9be4289fb213179065a867980f84aac91 Mon Sep 17 00:00:00 2001 From: Josiah Parry Date: Wed, 20 Aug 2025 08:44:05 -0700 Subject: [PATCH 01/31] add httr2_translate function --- NAMESPACE | 1 + R/httr2-translate.R | 190 +++++++++++++++++++++++ man/httr2_translate.Rd | 35 +++++ tests/testthat/_snaps/httr2-translate.md | 135 ++++++++++++++++ tests/testthat/test-httr2-translate.R | 163 +++++++++++++++++++ 5 files changed, 524 insertions(+) create mode 100644 R/httr2-translate.R create mode 100644 man/httr2_translate.Rd create mode 100644 tests/testthat/_snaps/httr2-translate.md create mode 100644 tests/testthat/test-httr2-translate.R diff --git a/NAMESPACE b/NAMESPACE index 7cf5bb433..205ec32f9 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -24,6 +24,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/R/httr2-translate.R b/R/httr2-translate.R new file mode 100644 index 000000000..bec13bc4a --- /dev/null +++ b/R/httr2-translate.R @@ -0,0 +1,190 @@ +#' Translate httr2 request to curl command +#' +#' Convert an httr2 request object to equivalent curl command line syntax. +#' This is useful for debugging, sharing requests, or converting to other tools. +#' +#' @param .req An httr2 request object created with [request()]. +#' @return A character string containing the curl command. +#' @export +#' @examples +#' \dontrun{ +#' # 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() +#' +#' # POST with form data +#' request("https://httpbin.org/post") |> +#' req_body_form(name = "value") |> +#' httr2_translate() +#' } +httr2_translate <- function(.req) { + # validate the request + check_request(.req) + + # Extract URL + url <- .req$url + + # use the request's method if it is set, otherwise infer + method <- .req$method %||% + { + if (!is.null(.req$body$data)) { + "POST" + } else { + "GET" + } + } + + # we will append to cmd_parts to build up the request + cmd_parts <- c("curl") + + # if the method isn't GET, it needs to be specified with `-X` + if (method != "GET") { + cmd_parts <- c(cmd_parts, paste0("-X ", method)) + } + + # if headers are present, add them using -H flag + if (!is.null(.req$headers) && length(.req$headers) > 0) { + headers <- .req$headers + for (name in names(headers)) { + value <- headers[[name]] + + # Handle weak references first + if (rlang::is_weakref(value)) { + value <- rlang::wref_value(value) + } + + # Handle obfuscated values by revealing them + if (is_obfuscated(value)) { + value <- unobfuscate(value, handle = "reveal") + } + + cmd_parts <- c(cmd_parts, paste0('-H "', name, ': ', value, '"')) + } + } + + # Handle options (curl options like timeout, proxy, etc.) + if (!is.null(.req$options) && length(.req$options) > 0) { + options <- .req$options + for (name in names(options)) { + value <- options[[name]] + # Convert common curl options to curl command flags + curl_flag <- switch( + name, + "timeout" = paste0("--max-time ", value), + "connecttimeout" = paste0("--connect-timeout ", value), + "proxy" = paste0("--proxy ", value), + "useragent" = paste0('--user-agent "', value, '"'), + "referer" = paste0('--referer "', value, '"'), + "followlocation" = if (value) "--location" else NULL, + "ssl_verifypeer" = if (!value) "--insecure" else NULL, + "verbose" = if (value) "--verbose" else NULL, + "cookiejar" = paste0('--cookie-jar "', value, '"'), + "cookiefile" = paste0('--cookie "', value, '"'), + # For unknown options, create a generic --option format + paste0("--", gsub("_", "-", name), " ", value) + ) + if (!is.null(curl_flag)) { + cmd_parts <- c(cmd_parts, curl_flag) + } + } + } + + # Handle body data if present + if (!is.null(.req$body)) { + body_type <- .req$body$type %||% "empty" + # if content_type set here we use it + content_type <- .req$body$content_type + + # if content_type not set we need to infer from body type + if (is.null(content_type) || !nzchar(content_type)) { + if (body_type == "json") { + content_type <- "application/json" + } else if (body_type == "form") { + content_type <- "application/x-www-form-urlencoded" + } + } + + # Add content-type header if we have one and it's not already set + if (!is.null(content_type)) { + if ( + is.null(.req$headers) || + !("content-type" %in% tolower(names(.req$headers))) + ) { + cmd_parts <- c( + cmd_parts, + paste0('-H "Content-Type: ', content_type, '"') + ) + } + } + + # add body data + switch( + body_type, + "string" = { + data <- .req$body$data + cmd_parts <- c(cmd_parts, paste0('-d "', gsub('"', '\\"', data), '"')) + }, + "raw" = { + # Raw bytes - use --data-binary + cmd_parts <- c(cmd_parts, '--data-binary "@-"') + }, + "file" = { + path <- .req$body$data + cmd_parts <- c(cmd_parts, paste0('--data-binary "@', path, '"')) + }, + "json" = { + data <- unobfuscate(.req$body$data, handle = "reveal") + json_data <- jsonlite::toJSON(data, auto_unbox = TRUE) + cmd_parts <- c(cmd_parts, paste0('-d \'', json_data, '\'')) + }, + "form" = { + form_data <- unobfuscate(.req$body$data, handle = "reveal") + form_string <- paste( + names(form_data), + form_data, + sep = "=", + collapse = "&" + ) + cmd_parts <- c(cmd_parts, paste0('-d "', form_string, '"')) + }, + "multipart" = { + form_data <- unobfuscate(.req$body$data, handle = "reveal") + for (name in names(form_data)) { + value <- form_data[[name]] + cmd_parts <- c(cmd_parts, paste0('-F "', name, '=', value, '"')) + } + } + ) + } + + cmd_parts <- c(cmd_parts, paste0('"', url, '"')) + + # Join all parts with proper formatting + if (length(cmd_parts) <= 2) { + # Simple commands on one line + paste(cmd_parts, collapse = " ") + } else { + # Multi-line format with continuation - but keep first part on same line as 'curl' + first_part <- paste(cmd_parts[1:2], collapse = " ") + remaining_parts <- cmd_parts[-(1:2)] + + if (length(remaining_parts) == 0) { + first_part + } else { + formatted_parts <- paste0(" ", remaining_parts, " \\") + # Remove the trailing backslash from the last part + formatted_parts[length(formatted_parts)] <- gsub( + " \\\\$", + "", + formatted_parts[length(formatted_parts)] + ) + + paste(c(paste0(first_part, " \\"), formatted_parts), collapse = "\n") + } + } +} diff --git a/man/httr2_translate.Rd b/man/httr2_translate.Rd new file mode 100644 index 000000000..a610d2f1e --- /dev/null +++ b/man/httr2_translate.Rd @@ -0,0 +1,35 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/httr2-translate.R +\name{httr2_translate} +\alias{httr2_translate} +\title{Translate httr2 request to curl command} +\usage{ +httr2_translate(.req) +} +\arguments{ +\item{.req}{An httr2 request object created with \code{\link[=request]{request()}}.} +} +\value{ +A character string containing the curl command. +} +\description{ +Convert an httr2 request object to equivalent curl command line syntax. +This is useful for debugging, sharing requests, or converting to other tools. +} +\examples{ +\dontrun{ +# 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() + +# POST with form data +request("https://httpbin.org/post") |> + req_body_form(name = "value") |> + httr2_translate() +} +} diff --git a/tests/testthat/_snaps/httr2-translate.md b/tests/testthat/_snaps/httr2-translate.md new file mode 100644 index 000000000..c29b3fcdf --- /dev/null +++ b/tests/testthat/_snaps/httr2-translate.md @@ -0,0 +1,135 @@ +# httr2_translate() works with basic GET requests + + Code + httr2_translate(request("https://httpbin.org/get")) + Output + [1] "curl \"https://httpbin.org/get\"" + +# httr2_translate() works with POST methods + + Code + httr2_translate(req_method(request("https://httpbin.org/post"), "POST")) + Output + [1] "curl -X POST \\\n \"https://httpbin.org/post\"" + +# httr2_translate() works with headers + + Code + httr2_translate(req_headers(request("https://httpbin.org/get"), Accept = "application/json", + `User-Agent` = "httr2/1.0")) + Output + [1] "curl -H \"Accept: application/json\" \\\n -H \"User-Agent: httr2/1.0\" \\\n \"https://httpbin.org/get\"" + +# httr2_translate() works with JSON bodies + + Code + httr2_translate(req_body_json(request("https://httpbin.org/post"), list(name = "test", + value = 123))) + Output + [1] "curl -X POST \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\":\"test\",\"value\":123}' \\\n \"https://httpbin.org/post\"" + +# httr2_translate() works with form bodies + + Code + httr2_translate(req_body_form(request("https://httpbin.org/post"), name = "test", + value = "123")) + Output + [1] "curl -X POST \\\n -H \"Content-Type: application/x-www-form-urlencoded\" \\\n -d \"name=test&value=123\" \\\n \"https://httpbin.org/post\"" + +# httr2_translate() works with multipart bodies + + Code + httr2_translate(req_body_multipart(request("https://httpbin.org/post"), name = "test", + value = "123")) + Output + [1] "curl -X POST \\\n -F \"name=test\" \\\n -F \"value=123\" \\\n \"https://httpbin.org/post\"" + +# httr2_translate() works with string bodies + + Code + httr2_translate(req_body_raw(request("https://httpbin.org/post"), "test data", + type = "text/plain")) + Output + [1] "curl -X POST \\\n -H \"Content-Type: text/plain\" \\\n -d \"test data\" \\\n \"https://httpbin.org/post\"" + +# httr2_translate() works with file bodies + + Code + httr2_translate(req_body_file(request("https://httpbin.org/post"), path, type = "text/plain")) + Output + [1] "curl -X POST \\\n -H \"Content-Type: text/plain\" \\\n --data-binary \"@\" \\\n \"https://httpbin.org/post\"" + +# httr2_translate() works with custom content types + + Code + httr2_translate(req_body_json(request("https://httpbin.org/post"), list(test = "data"), + type = "application/vnd.api+json")) + Output + [1] "curl -X POST \\\n -H \"Content-Type: application/vnd.api+json\" \\\n -d '{\"test\":\"data\"}' \\\n \"https://httpbin.org/post\"" + +# httr2_translate() works with options + + Code + httr2_translate(req_options(request("https://httpbin.org/get"), timeout = 30, + verbose = TRUE, ssl_verifypeer = FALSE)) + Output + [1] "curl --max-time 30 \\\n --verbose \\\n --insecure \\\n \"https://httpbin.org/get\"" + +# httr2_translate() works with cookies + + Code + httr2_translate(req_options(request("https://httpbin.org/cookies"), cookiejar = cookie_file, + cookiefile = cookie_file)) + Output + [1] "curl --cookie-jar \"\" \\\n --cookie \"\" \\\n \"https://httpbin.org/cookies\"" + +# httr2_translate() works with obfuscated values in headers + + Code + httr2_translate(req_headers(request("https://httpbin.org/get"), Authorization = obfuscated( + "ZdYJeG8zwISodg0nu4UxBhs"))) + Output + [1] "curl -H \"Authorization: y\" \\\n \"https://httpbin.org/get\"" + +# httr2_translate() works with obfuscated values in JSON body + + Code + httr2_translate(req_body_json(request("https://httpbin.org/post"), list( + username = "test", password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs")))) + Output + [1] "curl -X POST \\\n -H \"Content-Type: application/json\" \\\n -d '{\"username\":\"test\",\"password\":\"y\"}' \\\n \"https://httpbin.org/post\"" + +# httr2_translate() works with obfuscated values in form body + + Code + httr2_translate(req_body_form(request("https://httpbin.org/post"), username = "test", + password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs"))) + Output + [1] "curl -X POST \\\n -H \"Content-Type: application/x-www-form-urlencoded\" \\\n -d \"username=test&password=y\" \\\n \"https://httpbin.org/post\"" + +# httr2_translate() works with complex requests + + Code + httr2_translate(req_options(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)), + timeout = 60)) + Output + [1] "curl -X POST \\\n -H \"Accept: application/vnd.github.v3+json\" \\\n -H \"Authorization: y\" \\\n -H \"User-Agent: MyApp/1.0\" \\\n --max-time 60 \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\":\"test-repo\",\"description\":\"A test repository\",\"private\":true}' \\\n \"https://api.github.com/user/repos\"" + +# httr2_translate() works with simple requests (single line) + + Code + httr2_translate(request("https://httpbin.org/get")) + Output + [1] "curl \"https://httpbin.org/get\"" + +# 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". + diff --git a/tests/testthat/test-httr2-translate.R b/tests/testthat/test-httr2-translate.R new file mode 100644 index 000000000..b3980dcde --- /dev/null +++ b/tests/testthat/test-httr2-translate.R @@ -0,0 +1,163 @@ +test_that("httr2_translate() works with basic GET requests", { + expect_snapshot({ + request("https://httpbin.org/get") |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with POST methods", { + expect_snapshot({ + request("https://httpbin.org/post") |> + req_method("POST") |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with headers", { + expect_snapshot({ + request("https://httpbin.org/get") |> + req_headers("Accept" = "application/json", "User-Agent" = "httr2/1.0") |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with JSON bodies", { + expect_snapshot({ + request("https://httpbin.org/post") |> + req_body_json(list(name = "test", value = 123)) |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with form bodies", { + expect_snapshot({ + request("https://httpbin.org/post") |> + req_body_form(name = "test", value = "123") |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with multipart bodies", { + expect_snapshot({ + request("https://httpbin.org/post") |> + req_body_multipart(name = "test", value = "123") |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with string bodies", { + expect_snapshot({ + request("https://httpbin.org/post") |> + req_body_raw("test data", type = "text/plain") |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with file bodies", { + path <- tempfile() + writeLines("test content", path) + + expect_snapshot( + { + request("https://httpbin.org/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", { + expect_snapshot({ + request("https://httpbin.org/post") |> + req_body_json(list(test = "data"), type = "application/vnd.api+json") |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with options", { + expect_snapshot({ + request("https://httpbin.org/get") |> + req_options(timeout = 30, verbose = TRUE, ssl_verifypeer = FALSE) |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with cookies", { + cookie_file <- tempfile() + + expect_snapshot( + { + request("https://httpbin.org/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", { + expect_snapshot({ + request("https://httpbin.org/get") |> + req_headers("Authorization" = obfuscated("ZdYJeG8zwISodg0nu4UxBhs")) |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with obfuscated values in JSON body", { + expect_snapshot({ + request("https://httpbin.org/post") |> + req_body_json(list( + username = "test", + password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs") + )) |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with obfuscated values in form body", { + expect_snapshot({ + request("https://httpbin.org/post") |> + req_body_form( + username = "test", + password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs") + ) |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with complex requests", { + 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 + )) |> + req_options(timeout = 60) |> + httr2_translate() + }) +}) + +test_that("httr2_translate() works with simple requests (single line)", { + expect_snapshot({ + request("https://httpbin.org/get") |> + httr2_translate() + }) +}) + +test_that("httr2_translate() validates input", { + expect_snapshot(error = TRUE, { + httr2_translate("not a request") + }) +}) \ No newline at end of file From 86e8255e2c7e87bb7fc9e860aee070f389629974 Mon Sep 17 00:00:00 2001 From: Josiah Parry Date: Wed, 20 Aug 2025 08:56:05 -0700 Subject: [PATCH 02/31] run air and clarify some comments --- R/httr2-translate.R | 25 ++++++++++++------------- tests/testthat/test-httr2-translate.R | 18 ++++++++++++------ 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/R/httr2-translate.R b/R/httr2-translate.R index bec13bc4a..0e01b0b00 100644 --- a/R/httr2-translate.R +++ b/R/httr2-translate.R @@ -52,27 +52,27 @@ httr2_translate <- function(.req) { headers <- .req$headers for (name in names(headers)) { value <- headers[[name]] - - # Handle weak references first + + # handle weakrefs if (rlang::is_weakref(value)) { value <- rlang::wref_value(value) } - - # Handle obfuscated values by revealing them + + # unobfuscate obfuscated if (is_obfuscated(value)) { value <- unobfuscate(value, handle = "reveal") } - + cmd_parts <- c(cmd_parts, paste0('-H "', name, ': ', value, '"')) } } - # Handle options (curl options like timeout, proxy, etc.) + # manage options if (!is.null(.req$options) && length(.req$options) > 0) { options <- .req$options for (name in names(options)) { value <- options[[name]] - # Convert common curl options to curl command flags + # convert options to curl flags curl_flag <- switch( name, "timeout" = paste0("--max-time ", value), @@ -85,7 +85,7 @@ httr2_translate <- function(.req) { "verbose" = if (value) "--verbose" else NULL, "cookiejar" = paste0('--cookie-jar "', value, '"'), "cookiefile" = paste0('--cookie "', value, '"'), - # For unknown options, create a generic --option format + # for unknown options try guess the flag if it was the intention paste0("--", gsub("_", "-", name), " ", value) ) if (!is.null(curl_flag)) { @@ -94,7 +94,6 @@ httr2_translate <- function(.req) { } } - # Handle body data if present if (!is.null(.req$body)) { body_type <- .req$body$type %||% "empty" # if content_type set here we use it @@ -109,7 +108,7 @@ httr2_translate <- function(.req) { } } - # Add content-type header if we have one and it's not already set + # add content-type header if we have one and it's not already set if (!is.null(content_type)) { if ( is.null(.req$headers) || @@ -164,12 +163,12 @@ httr2_translate <- function(.req) { cmd_parts <- c(cmd_parts, paste0('"', url, '"')) - # Join all parts with proper formatting + # join all parts with proper formatting if (length(cmd_parts) <= 2) { - # Simple commands on one line paste(cmd_parts, collapse = " ") } else { - # Multi-line format with continuation - but keep first part on same line as 'curl' + # need to ensure that "curl" isn't on its own line + # for compatibility with curl_translate() first_part <- paste(cmd_parts[1:2], collapse = " ") remaining_parts <- cmd_parts[-(1:2)] diff --git a/tests/testthat/test-httr2-translate.R b/tests/testthat/test-httr2-translate.R index b3980dcde..03a28268e 100644 --- a/tests/testthat/test-httr2-translate.R +++ b/tests/testthat/test-httr2-translate.R @@ -16,7 +16,10 @@ test_that("httr2_translate() works with POST methods", { test_that("httr2_translate() works with headers", { expect_snapshot({ request("https://httpbin.org/get") |> - req_headers("Accept" = "application/json", "User-Agent" = "httr2/1.0") |> + req_headers( + "Accept" = "application/json", + "User-Agent" = "httr2/1.0" + ) |> httr2_translate() }) }) @@ -56,7 +59,7 @@ test_that("httr2_translate() works with string bodies", { test_that("httr2_translate() works with file bodies", { path <- tempfile() writeLines("test content", path) - + expect_snapshot( { request("https://httpbin.org/post") |> @@ -72,7 +75,10 @@ test_that("httr2_translate() works with file bodies", { test_that("httr2_translate() works with custom content types", { expect_snapshot({ request("https://httpbin.org/post") |> - req_body_json(list(test = "data"), type = "application/vnd.api+json") |> + req_body_json( + list(test = "data"), + type = "application/vnd.api+json" + ) |> httr2_translate() }) }) @@ -87,7 +93,7 @@ test_that("httr2_translate() works with options", { test_that("httr2_translate() works with cookies", { cookie_file <- tempfile() - + expect_snapshot( { request("https://httpbin.org/cookies") |> @@ -123,7 +129,7 @@ test_that("httr2_translate() works with obfuscated values in form body", { expect_snapshot({ request("https://httpbin.org/post") |> req_body_form( - username = "test", + username = "test", password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs") ) |> httr2_translate() @@ -160,4 +166,4 @@ test_that("httr2_translate() validates input", { expect_snapshot(error = TRUE, { httr2_translate("not a request") }) -}) \ No newline at end of file +}) From be8141d32fb5aadfe9361420b2979b234f139b07 Mon Sep 17 00:00:00 2001 From: Josiah Parry Date: Wed, 20 Aug 2025 09:13:48 -0700 Subject: [PATCH 03/31] update snaps to work with windows and update pkgdown --- _pkgdown.yml | 3 ++- tests/testthat/_snaps/httr2-translate.md | 4 ++-- tests/testthat/test-httr2-translate.R | 13 +++++++++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/_pkgdown.yml b/_pkgdown.yml index 7ec7f2367..1231f0c96 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -76,6 +76,7 @@ reference: - title: Miscellaneous helpers contents: - curl_translate + - httr2_translate - is_online - title: OAuth @@ -88,7 +89,7 @@ reference: - title: Developer tooling desc: > These functions are useful when developing packges that use httr2. - + - subtitle: Keeping secrets contents: - obfuscate diff --git a/tests/testthat/_snaps/httr2-translate.md b/tests/testthat/_snaps/httr2-translate.md index c29b3fcdf..f46219f05 100644 --- a/tests/testthat/_snaps/httr2-translate.md +++ b/tests/testthat/_snaps/httr2-translate.md @@ -57,7 +57,7 @@ Code httr2_translate(req_body_file(request("https://httpbin.org/post"), path, type = "text/plain")) Output - [1] "curl -X POST \\\n -H \"Content-Type: text/plain\" \\\n --data-binary \"@\" \\\n \"https://httpbin.org/post\"" + [1] "curl -X POST \\\n -H \"Content-Type: text/plain\" \\\n --data-binary \"@/file123d32a1a3f82\" \\\n \"https://httpbin.org/post\"" # httr2_translate() works with custom content types @@ -81,7 +81,7 @@ httr2_translate(req_options(request("https://httpbin.org/cookies"), cookiejar = cookie_file, cookiefile = cookie_file)) Output - [1] "curl --cookie-jar \"\" \\\n --cookie \"\" \\\n \"https://httpbin.org/cookies\"" + [1] "curl --cookie-jar \"/file123d315b9cab\" \\\n --cookie \"/file123d315b9cab\" \\\n \"https://httpbin.org/cookies\"" # httr2_translate() works with obfuscated values in headers diff --git a/tests/testthat/test-httr2-translate.R b/tests/testthat/test-httr2-translate.R index 03a28268e..8aa88184c 100644 --- a/tests/testthat/test-httr2-translate.R +++ b/tests/testthat/test-httr2-translate.R @@ -60,6 +60,9 @@ test_that("httr2_translate() works with file bodies", { path <- tempfile() writeLines("test content", path) + # normalize the path + path <- normalizePath(path) + expect_snapshot( { request("https://httpbin.org/post") |> @@ -67,7 +70,7 @@ test_that("httr2_translate() works with file bodies", { httr2_translate() }, transform = function(x) { - gsub(path, "", x, fixed = TRUE) + gsub(dirname(path), "", x, fixed = TRUE) } ) }) @@ -94,6 +97,12 @@ test_that("httr2_translate() works with options", { test_that("httr2_translate() works with cookies", { cookie_file <- tempfile() + # create the tempfile + file.create(cookie_file) + + # normalize the path + cookie_file <- normalizePath(cookie_file) + expect_snapshot( { request("https://httpbin.org/cookies") |> @@ -101,7 +110,7 @@ test_that("httr2_translate() works with cookies", { httr2_translate() }, transform = function(x) { - gsub(cookie_file, "", x, fixed = TRUE) + gsub(dirname(cookie_file), "", x, fixed = TRUE) } ) }) From 3b030f3c5b14f6fb2e0df1fe852988a68874786f Mon Sep 17 00:00:00 2001 From: Josiah Parry Date: Wed, 20 Aug 2025 09:48:57 -0700 Subject: [PATCH 04/31] replace entire temp path not just directory --- tests/testthat/_snaps/httr2-translate.md | 4 ++-- tests/testthat/test-httr2-translate.R | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/testthat/_snaps/httr2-translate.md b/tests/testthat/_snaps/httr2-translate.md index f46219f05..c29b3fcdf 100644 --- a/tests/testthat/_snaps/httr2-translate.md +++ b/tests/testthat/_snaps/httr2-translate.md @@ -57,7 +57,7 @@ Code httr2_translate(req_body_file(request("https://httpbin.org/post"), path, type = "text/plain")) Output - [1] "curl -X POST \\\n -H \"Content-Type: text/plain\" \\\n --data-binary \"@/file123d32a1a3f82\" \\\n \"https://httpbin.org/post\"" + [1] "curl -X POST \\\n -H \"Content-Type: text/plain\" \\\n --data-binary \"@\" \\\n \"https://httpbin.org/post\"" # httr2_translate() works with custom content types @@ -81,7 +81,7 @@ httr2_translate(req_options(request("https://httpbin.org/cookies"), cookiejar = cookie_file, cookiefile = cookie_file)) Output - [1] "curl --cookie-jar \"/file123d315b9cab\" \\\n --cookie \"/file123d315b9cab\" \\\n \"https://httpbin.org/cookies\"" + [1] "curl --cookie-jar \"\" \\\n --cookie \"\" \\\n \"https://httpbin.org/cookies\"" # httr2_translate() works with obfuscated values in headers diff --git a/tests/testthat/test-httr2-translate.R b/tests/testthat/test-httr2-translate.R index 8aa88184c..d1c0047dc 100644 --- a/tests/testthat/test-httr2-translate.R +++ b/tests/testthat/test-httr2-translate.R @@ -70,7 +70,7 @@ test_that("httr2_translate() works with file bodies", { httr2_translate() }, transform = function(x) { - gsub(dirname(path), "", x, fixed = TRUE) + gsub(path, "", x, fixed = TRUE) } ) }) @@ -110,7 +110,7 @@ test_that("httr2_translate() works with cookies", { httr2_translate() }, transform = function(x) { - gsub(dirname(cookie_file), "", x, fixed = TRUE) + gsub(cookie_file, "", x, fixed = TRUE) } ) }) From 3e5c4e4b266f01aae5b1ee9ae1c310385a0486b0 Mon Sep 17 00:00:00 2001 From: Josiah Parry Date: Wed, 20 Aug 2025 10:18:41 -0700 Subject: [PATCH 05/31] use / for winslash --- tests/testthat/test-httr2-translate.R | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/testthat/test-httr2-translate.R b/tests/testthat/test-httr2-translate.R index d1c0047dc..652dd7c14 100644 --- a/tests/testthat/test-httr2-translate.R +++ b/tests/testthat/test-httr2-translate.R @@ -61,7 +61,7 @@ test_that("httr2_translate() works with file bodies", { writeLines("test content", path) # normalize the path - path <- normalizePath(path) + path <- normalizePath(path, winslash = "/") expect_snapshot( { @@ -101,7 +101,7 @@ test_that("httr2_translate() works with cookies", { file.create(cookie_file) # normalize the path - cookie_file <- normalizePath(cookie_file) + cookie_file <- normalizePath(cookie_file, winslash = "/") expect_snapshot( { From 822fa8c5a6ec062da0d941d24aea69812b9e274b Mon Sep 17 00:00:00 2001 From: Josiah Parry Date: Wed, 27 Aug 2025 13:45:31 -0700 Subject: [PATCH 06/31] address PR feedback --- NAMESPACE | 2 +- R/curl.R | 1 + R/httr2-translate.R | 189 --------------- R/req-as-curl.R | 224 ++++++++++++++++++ man/curl_translate.Rd | 3 + man/{httr2_translate.Rd => req_as_curl.Rd} | 20 +- tests/testthat/_snaps/httr2-translate.md | 135 ----------- tests/testthat/_snaps/req-as-curl.md | 176 ++++++++++++++ ...t-httr2-translate.R => test-req-as-curl.R} | 98 ++++---- 9 files changed, 463 insertions(+), 385 deletions(-) delete mode 100644 R/httr2-translate.R create mode 100644 R/req-as-curl.R rename man/{httr2_translate.Rd => req_as_curl.Rd} (67%) delete mode 100644 tests/testthat/_snaps/httr2-translate.md create mode 100644 tests/testthat/_snaps/req-as-curl.md rename tests/testthat/{test-httr2-translate.R => test-req-as-curl.R} (53%) diff --git a/NAMESPACE b/NAMESPACE index 205ec32f9..ef35a344a 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -24,7 +24,6 @@ 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) @@ -62,6 +61,7 @@ export(oauth_token) export(oauth_token_cached) export(obfuscate) export(obfuscated) +export(req_as_curl) export(req_auth_aws_v4) export(req_auth_basic) export(req_auth_bearer_token) diff --git a/R/curl.R b/R/curl.R index 0f4605ad5..8bcba7b53 100644 --- a/R/curl.R +++ b/R/curl.R @@ -24,6 +24,7 @@ #' was copied from the clipboard, the translation will be copied back #' to the clipboard. #' @export +#' @seealso [req_as_curl()] #' @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 deleted file mode 100644 index 0e01b0b00..000000000 --- a/R/httr2-translate.R +++ /dev/null @@ -1,189 +0,0 @@ -#' Translate httr2 request to curl command -#' -#' Convert an httr2 request object to equivalent curl command line syntax. -#' This is useful for debugging, sharing requests, or converting to other tools. -#' -#' @param .req An httr2 request object created with [request()]. -#' @return A character string containing the curl command. -#' @export -#' @examples -#' \dontrun{ -#' # 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() -#' -#' # POST with form data -#' request("https://httpbin.org/post") |> -#' req_body_form(name = "value") |> -#' httr2_translate() -#' } -httr2_translate <- function(.req) { - # validate the request - check_request(.req) - - # Extract URL - url <- .req$url - - # use the request's method if it is set, otherwise infer - method <- .req$method %||% - { - if (!is.null(.req$body$data)) { - "POST" - } else { - "GET" - } - } - - # we will append to cmd_parts to build up the request - cmd_parts <- c("curl") - - # if the method isn't GET, it needs to be specified with `-X` - if (method != "GET") { - cmd_parts <- c(cmd_parts, paste0("-X ", method)) - } - - # if headers are present, add them using -H flag - if (!is.null(.req$headers) && length(.req$headers) > 0) { - headers <- .req$headers - for (name in names(headers)) { - value <- headers[[name]] - - # handle weakrefs - if (rlang::is_weakref(value)) { - value <- rlang::wref_value(value) - } - - # unobfuscate obfuscated - if (is_obfuscated(value)) { - value <- unobfuscate(value, handle = "reveal") - } - - cmd_parts <- c(cmd_parts, paste0('-H "', name, ': ', value, '"')) - } - } - - # manage options - if (!is.null(.req$options) && length(.req$options) > 0) { - options <- .req$options - for (name in names(options)) { - value <- options[[name]] - # convert options to curl flags - curl_flag <- switch( - name, - "timeout" = paste0("--max-time ", value), - "connecttimeout" = paste0("--connect-timeout ", value), - "proxy" = paste0("--proxy ", value), - "useragent" = paste0('--user-agent "', value, '"'), - "referer" = paste0('--referer "', value, '"'), - "followlocation" = if (value) "--location" else NULL, - "ssl_verifypeer" = if (!value) "--insecure" else NULL, - "verbose" = if (value) "--verbose" else NULL, - "cookiejar" = paste0('--cookie-jar "', value, '"'), - "cookiefile" = paste0('--cookie "', value, '"'), - # for unknown options try guess the flag if it was the intention - paste0("--", gsub("_", "-", name), " ", value) - ) - if (!is.null(curl_flag)) { - cmd_parts <- c(cmd_parts, curl_flag) - } - } - } - - if (!is.null(.req$body)) { - body_type <- .req$body$type %||% "empty" - # if content_type set here we use it - content_type <- .req$body$content_type - - # if content_type not set we need to infer from body type - if (is.null(content_type) || !nzchar(content_type)) { - if (body_type == "json") { - content_type <- "application/json" - } else if (body_type == "form") { - content_type <- "application/x-www-form-urlencoded" - } - } - - # add content-type header if we have one and it's not already set - if (!is.null(content_type)) { - if ( - is.null(.req$headers) || - !("content-type" %in% tolower(names(.req$headers))) - ) { - cmd_parts <- c( - cmd_parts, - paste0('-H "Content-Type: ', content_type, '"') - ) - } - } - - # add body data - switch( - body_type, - "string" = { - data <- .req$body$data - cmd_parts <- c(cmd_parts, paste0('-d "', gsub('"', '\\"', data), '"')) - }, - "raw" = { - # Raw bytes - use --data-binary - cmd_parts <- c(cmd_parts, '--data-binary "@-"') - }, - "file" = { - path <- .req$body$data - cmd_parts <- c(cmd_parts, paste0('--data-binary "@', path, '"')) - }, - "json" = { - data <- unobfuscate(.req$body$data, handle = "reveal") - json_data <- jsonlite::toJSON(data, auto_unbox = TRUE) - cmd_parts <- c(cmd_parts, paste0('-d \'', json_data, '\'')) - }, - "form" = { - form_data <- unobfuscate(.req$body$data, handle = "reveal") - form_string <- paste( - names(form_data), - form_data, - sep = "=", - collapse = "&" - ) - cmd_parts <- c(cmd_parts, paste0('-d "', form_string, '"')) - }, - "multipart" = { - form_data <- unobfuscate(.req$body$data, handle = "reveal") - for (name in names(form_data)) { - value <- form_data[[name]] - cmd_parts <- c(cmd_parts, paste0('-F "', name, '=', value, '"')) - } - } - ) - } - - cmd_parts <- c(cmd_parts, paste0('"', url, '"')) - - # join all parts with proper formatting - if (length(cmd_parts) <= 2) { - paste(cmd_parts, collapse = " ") - } else { - # need to ensure that "curl" isn't on its own line - # for compatibility with curl_translate() - first_part <- paste(cmd_parts[1:2], collapse = " ") - remaining_parts <- cmd_parts[-(1:2)] - - if (length(remaining_parts) == 0) { - first_part - } else { - formatted_parts <- paste0(" ", remaining_parts, " \\") - # Remove the trailing backslash from the last part - formatted_parts[length(formatted_parts)] <- gsub( - " \\\\$", - "", - formatted_parts[length(formatted_parts)] - ) - - paste(c(paste0(first_part, " \\"), formatted_parts), collapse = "\n") - } - } -} diff --git a/R/req-as-curl.R b/R/req-as-curl.R new file mode 100644 index 000000000..0c027b528 --- /dev/null +++ b/R/req-as-curl.R @@ -0,0 +1,224 @@ +#' Translate an httr2 request to a curl command +#' +#' Convert an httr2 request object to equivalent curl command line syntax. +#' This is useful for debugging, sharing requests, or converting to other tools. +#' +#' @inheritParams req_perform +#' @return A character string containing the curl command. +#' @export +#' @examples +#' @seealso [curl_translate()] +#' \dontrun{ +#' # Basic GET request +#' request("https://httpbin.org/get") |> +#' req_as_curl() +#' +#' # POST with JSON body +#' request("https://httpbin.org/post") |> +#' req_body_json(list(name = "value")) |> +#' req_as_curl() +#' +#' # POST with form data +#' request("https://httpbin.org/post") |> +#' req_body_form(name = "value") |> +#' req_as_curl() +#' } +req_as_curl <- function(req) { + # validate the request + check_request(req) + + # Extract URL + url <- req_get_url(req) + + # use the request's method if it is set, otherwise infer + method <- req$method %||% + { + if (!is.null(req$body$data)) { + "POST" + } else { + "GET" + } + } + + # we will append to cmd_args to build up the request + cmd_args <- c() + + # if the method isn't GET, it needs to be specified with `-X` + if (method != "GET") { + cmd_args <- c(cmd_args, paste0("-X ", method)) + } + + # get headers and reveal obfuscated values + headers <- req_get_headers(req, redacted = "reveal") + + # if headers are present, add them using -H flag + if (!rlang::is_empty(headers)) { + for (name in names(headers)) { + value <- headers[[name]] + cmd_args <- c(cmd_args, paste0('-H "', name, ': ', value, '"')) + } + } + + known_curl_opts <- c( + "timeout", + "connecttimeout", + "proxy", + "useragent", + "referer", + "followlocation", + "verbose", + "cookiejar", + "cookiefile" + ) + + # manage options + # TODO make introspection function for options + options <- req$options + + # extract names of request's options + used_opts <- names(options) + + # identify options that are not known / handled + unknown_opts <- !used_opts %in% known_curl_opts + + # if any options are found that are not handled below, emit a message + if (any(unknown_opts)) { + cli::cli_alert_warning( + "Unable to translate option{?s} {.val {used_opts[unknown_opts]}}" + ) + } + + for (name in used_opts) { + value <- options[[name]] + # convert known options to curl flags other values are ignored + curl_flag <- switch( + name, + # supports req_timeout() + "timeout" = paste0("--max-time ", value), + "connecttimeout" = paste0("--connect-timeout ", value), + # supports req_proxy() + "proxy" = paste0("--proxy ", value), + # supports req_user_agent() + "useragent" = paste0('--user-agent "', value, '"'), + "referer" = paste0('--referer "', value, '"'), + # supports defualt behavior or httr2 following redirects + # rather than returning 302 status + "followlocation" = if (value) "--location" else NULL, + # support req_verbose() + "verbose" = if (value) "--verbose" else NULL, + # support req_cookie_preserve() and req_cookies_set() + "cookiejar" = paste0('--cookie-jar "', value, '"'), + "cookiefile" = paste0('--cookie "', value, '"') + ) + cmd_args <- c(cmd_args, curl_flag) + } + + cmd_args <- req_body_as_curl(req, cmd_args) + + # quote the url + url_quoted <- sprintf('"%s"', url) + + # if we have no arguments we just paste curl and the url together + res <- if (length(cmd_args) == 0) { + paste0("curl ", url_quoted) + } else { + cmd_lines <- paste0(cmd_args, " \\") + + # indent all args except the first + cmd_lines[-1] <- paste0(" ", cmd_lines[-1]) + + # append the url + cmd_lines <- c(cmd_lines, paste0(" ", url_quoted)) + + # combine with new line separation for all but first argument + res <- paste0( + "curl ", + cmd_lines[1], + "\n", + paste0(cmd_lines[-1], collapse = "\n") + ) + } + + structure(res, class = "httr2_cmd") +} + + +req_body_as_curl <- function(req, cmd_args) { + # extract the body and reveal obfuscated values + body <- req_get_body(req, obfuscated = "reveal") + + if (rlang::is_null(body)) { + return(cmd_args) + } + + body_type <- req$body$type %||% "empty" + + # if content_type set here we use it + content_type <- req$body$content_type + + # if content_type not set we need to infer from body type + if (rlang::is_null(content_type) || !nzchar(content_type)) { + content_type <- switch( + body_type, + "json" = "application/json", + "form" = "application/x-www-form-urlencoded" + ) + } + + # fetch headers for content-type check + headers <- req_get_headers(req) + + # if the headers aren't empty AND the content-type header is set + # we use that instead of what is inferred from the request object + if ( + !rlang::is_empty(headers) && ("content-type" %in% tolower(names(headers))) + ) { + content_type <- headers[["content-type"]] + } + + if (!rlang::is_null(content_type)) { + cmd_args <- c( + cmd_args, + paste0('-H "Content-Type: ', content_type, '"') + ) + } + + # add body data + switch( + body_type, + "string" = { + cmd_args <- c( + cmd_args, + paste0('-d "', gsub('"', '\\"', body), '"') + ) + }, + "raw" = { + # TODO: should the raw bytes be written to a temp file + # and be hanlded similarly to file? + cmd_args <- c(cmd_args, '--data-binary "@-"') + }, + "file" = { + cmd_args <- c(cmd_args, paste0('--data-binary "@', body, '"')) + }, + "json" = { + json_data <- jsonlite::toJSON(body, auto_unbox = TRUE) + cmd_args <- c(cmd_args, paste0('-d \'', json_data, '\'')) + }, + "form" = { + form_string <- paste( + names(body), + body, + sep = "=", + collapse = "&" + ) + cmd_args <- c(cmd_args, paste0('-d "', form_string, '"')) + }, + "multipart" = { + for (name in names(body)) { + value <- body[[name]] + cmd_args <- c(cmd_args, paste0('-F "', name, '=', value, '"')) + } + } + ) + cmd_args +} diff --git a/man/curl_translate.Rd b/man/curl_translate.Rd index f978854c7..46ebfc3b9 100644 --- a/man/curl_translate.Rd +++ b/man/curl_translate.Rd @@ -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[=req_as_curl]{req_as_curl()}} +} diff --git a/man/httr2_translate.Rd b/man/req_as_curl.Rd similarity index 67% rename from man/httr2_translate.Rd rename to man/req_as_curl.Rd index a610d2f1e..006fafcc8 100644 --- a/man/httr2_translate.Rd +++ b/man/req_as_curl.Rd @@ -1,13 +1,10 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/httr2-translate.R -\name{httr2_translate} -\alias{httr2_translate} +% Please edit documentation in R/req-as-curl.R +\name{req_as_curl} +\alias{req_as_curl} \title{Translate httr2 request to curl command} \usage{ -httr2_translate(.req) -} -\arguments{ -\item{.req}{An httr2 request object created with \code{\link[=request]{request()}}.} +req_as_curl(.req) } \value{ A character string containing the curl command. @@ -16,20 +13,21 @@ A character string containing the curl command. Convert an httr2 request object to equivalent curl command line syntax. This is useful for debugging, sharing requests, or converting to other tools. } -\examples{ +\seealso{ +\code{\link[=curl_translate]{curl_translate()}} \dontrun{ # Basic GET request request("https://httpbin.org/get") |> - httr2_translate() + req_as_curl() # POST with JSON body request("https://httpbin.org/post") |> req_body_json(list(name = "value")) |> - httr2_translate() + req_as_curl() # POST with form data request("https://httpbin.org/post") |> req_body_form(name = "value") |> - httr2_translate() + req_as_curl() } } diff --git a/tests/testthat/_snaps/httr2-translate.md b/tests/testthat/_snaps/httr2-translate.md deleted file mode 100644 index c29b3fcdf..000000000 --- a/tests/testthat/_snaps/httr2-translate.md +++ /dev/null @@ -1,135 +0,0 @@ -# httr2_translate() works with basic GET requests - - Code - httr2_translate(request("https://httpbin.org/get")) - Output - [1] "curl \"https://httpbin.org/get\"" - -# httr2_translate() works with POST methods - - Code - httr2_translate(req_method(request("https://httpbin.org/post"), "POST")) - Output - [1] "curl -X POST \\\n \"https://httpbin.org/post\"" - -# httr2_translate() works with headers - - Code - httr2_translate(req_headers(request("https://httpbin.org/get"), Accept = "application/json", - `User-Agent` = "httr2/1.0")) - Output - [1] "curl -H \"Accept: application/json\" \\\n -H \"User-Agent: httr2/1.0\" \\\n \"https://httpbin.org/get\"" - -# httr2_translate() works with JSON bodies - - Code - httr2_translate(req_body_json(request("https://httpbin.org/post"), list(name = "test", - value = 123))) - Output - [1] "curl -X POST \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\":\"test\",\"value\":123}' \\\n \"https://httpbin.org/post\"" - -# httr2_translate() works with form bodies - - Code - httr2_translate(req_body_form(request("https://httpbin.org/post"), name = "test", - value = "123")) - Output - [1] "curl -X POST \\\n -H \"Content-Type: application/x-www-form-urlencoded\" \\\n -d \"name=test&value=123\" \\\n \"https://httpbin.org/post\"" - -# httr2_translate() works with multipart bodies - - Code - httr2_translate(req_body_multipart(request("https://httpbin.org/post"), name = "test", - value = "123")) - Output - [1] "curl -X POST \\\n -F \"name=test\" \\\n -F \"value=123\" \\\n \"https://httpbin.org/post\"" - -# httr2_translate() works with string bodies - - Code - httr2_translate(req_body_raw(request("https://httpbin.org/post"), "test data", - type = "text/plain")) - Output - [1] "curl -X POST \\\n -H \"Content-Type: text/plain\" \\\n -d \"test data\" \\\n \"https://httpbin.org/post\"" - -# httr2_translate() works with file bodies - - Code - httr2_translate(req_body_file(request("https://httpbin.org/post"), path, type = "text/plain")) - Output - [1] "curl -X POST \\\n -H \"Content-Type: text/plain\" \\\n --data-binary \"@\" \\\n \"https://httpbin.org/post\"" - -# httr2_translate() works with custom content types - - Code - httr2_translate(req_body_json(request("https://httpbin.org/post"), list(test = "data"), - type = "application/vnd.api+json")) - Output - [1] "curl -X POST \\\n -H \"Content-Type: application/vnd.api+json\" \\\n -d '{\"test\":\"data\"}' \\\n \"https://httpbin.org/post\"" - -# httr2_translate() works with options - - Code - httr2_translate(req_options(request("https://httpbin.org/get"), timeout = 30, - verbose = TRUE, ssl_verifypeer = FALSE)) - Output - [1] "curl --max-time 30 \\\n --verbose \\\n --insecure \\\n \"https://httpbin.org/get\"" - -# httr2_translate() works with cookies - - Code - httr2_translate(req_options(request("https://httpbin.org/cookies"), cookiejar = cookie_file, - cookiefile = cookie_file)) - Output - [1] "curl --cookie-jar \"\" \\\n --cookie \"\" \\\n \"https://httpbin.org/cookies\"" - -# httr2_translate() works with obfuscated values in headers - - Code - httr2_translate(req_headers(request("https://httpbin.org/get"), Authorization = obfuscated( - "ZdYJeG8zwISodg0nu4UxBhs"))) - Output - [1] "curl -H \"Authorization: y\" \\\n \"https://httpbin.org/get\"" - -# httr2_translate() works with obfuscated values in JSON body - - Code - httr2_translate(req_body_json(request("https://httpbin.org/post"), list( - username = "test", password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs")))) - Output - [1] "curl -X POST \\\n -H \"Content-Type: application/json\" \\\n -d '{\"username\":\"test\",\"password\":\"y\"}' \\\n \"https://httpbin.org/post\"" - -# httr2_translate() works with obfuscated values in form body - - Code - httr2_translate(req_body_form(request("https://httpbin.org/post"), username = "test", - password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs"))) - Output - [1] "curl -X POST \\\n -H \"Content-Type: application/x-www-form-urlencoded\" \\\n -d \"username=test&password=y\" \\\n \"https://httpbin.org/post\"" - -# httr2_translate() works with complex requests - - Code - httr2_translate(req_options(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)), - timeout = 60)) - Output - [1] "curl -X POST \\\n -H \"Accept: application/vnd.github.v3+json\" \\\n -H \"Authorization: y\" \\\n -H \"User-Agent: MyApp/1.0\" \\\n --max-time 60 \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\":\"test-repo\",\"description\":\"A test repository\",\"private\":true}' \\\n \"https://api.github.com/user/repos\"" - -# httr2_translate() works with simple requests (single line) - - Code - httr2_translate(request("https://httpbin.org/get")) - Output - [1] "curl \"https://httpbin.org/get\"" - -# 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". - diff --git a/tests/testthat/_snaps/req-as-curl.md b/tests/testthat/_snaps/req-as-curl.md new file mode 100644 index 000000000..e7350bced --- /dev/null +++ b/tests/testthat/_snaps/req-as-curl.md @@ -0,0 +1,176 @@ +# req_as_curl() works with basic GET requests + + Code + req_as_curl(request("https://hb.cran.dev/get")) + Output + curl "https://hb.cran.dev/get" + +# req_as_curl() works with POST methods + + Code + req_as_curl(req_method(request("https://hb.cran.dev/post"), "POST")) + Output + curl -X POST \ + "https://hb.cran.dev/post" + +# req_as_curl() works with headers + + Code + req_as_curl(req_headers(request("https://hb.cran.dev/get"), Accept = "application/json", + `User-Agent` = "httr2/1.0")) + Output + curl -H "Accept: application/json" \ + -H "User-Agent: httr2/1.0" \ + "https://hb.cran.dev/get" + +# req_as_curl() works with JSON bodies + + Code + req_as_curl(req_body_json(request("https://hb.cran.dev/post"), list(name = "test", + value = 123))) + Output + curl -X POST \ + -H "Content-Type: application/json" \ + -d '{"name":"test","value":123}' \ + "https://hb.cran.dev/post" + +# req_as_curl() works with form bodies + + Code + req_as_curl(req_body_form(request("https://hb.cran.dev/post"), name = "test", + value = "123")) + Output + curl -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "name=test&value=123" \ + "https://hb.cran.dev/post" + +# req_as_curl() works with multipart bodies + + Code + req_as_curl(req_body_multipart(request("https://hb.cran.dev/post"), name = "test", + value = "123")) + Output + curl -X POST \ + -F "name=test" \ + -F "value=123" \ + "https://hb.cran.dev/post" + +# req_as_curl() works with string bodies + + Code + req_as_curl(req_body_raw(request("https://hb.cran.dev/post"), "test data", + type = "text/plain")) + Output + curl -X POST \ + -H "Content-Type: text/plain" \ + -d "test data" \ + "https://hb.cran.dev/post" + +# req_as_curl() works with file bodies + + Code + req_as_curl(req_body_file(request("https://hb.cran.dev/post"), path, type = "text/plain")) + Output + curl -X POST \ + -H "Content-Type: text/plain" \ + --data-binary "@" \ + "https://hb.cran.dev/post" + +# req_as_curl() works with custom content types + + Code + req_as_curl(req_body_json(request("https://hb.cran.dev/post"), list(test = "data"), + type = "application/vnd.api+json")) + Output + curl -X POST \ + -H "Content-Type: application/vnd.api+json" \ + -d '{"test":"data"}' \ + "https://hb.cran.dev/post" + +# req_as_curl() works with options + + Code + req_as_curl(req_options(request("https://hb.cran.dev/get"), timeout = 30, + verbose = TRUE, ssl_verifypeer = FALSE)) + Message + ! Unable to translate option "ssl_verifypeer" + Output + curl --max-time 30 \ + --verbose \ + "https://hb.cran.dev/get" + +# req_as_curl() works with cookies + + Code + req_as_curl(req_options(request("https://hb.cran.dev/cookies"), cookiejar = cookie_file, + cookiefile = cookie_file)) + Output + curl --cookie-jar "" \ + --cookie "" \ + "https://hb.cran.dev/cookies" + +# req_as_curl() works with obfuscated values in headers + + Code + req_as_curl(req_headers(request("https://hb.cran.dev/get"), Authorization = obfuscated( + "ZdYJeG8zwISodg0nu4UxBhs"))) + Output + curl -H "Authorization: ZdYJeG8zwISodg0nu4UxBhs" \ + "https://hb.cran.dev/get" + +# req_as_curl() works with obfuscated values in JSON body + + Code + req_as_curl(req_body_json(request("https://hb.cran.dev/post"), list(username = "test", + password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs")))) + Output + curl -X POST \ + -H "Content-Type: application/json" \ + -d '{"username":"test","password":"y"}' \ + "https://hb.cran.dev/post" + +# req_as_curl() works with obfuscated values in form body + + Code + req_as_curl(req_body_form(request("https://hb.cran.dev/post"), username = "test", + password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs"))) + Output + curl -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=test&password=y" \ + "https://hb.cran.dev/post" + +# req_as_curl() works with complex requests + + Code + req_as_curl(req_options(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)), + timeout = 60)) + Output + curl -X POST \ + -H "Accept: application/vnd.github.v3+json" \ + -H "Authorization: ZdYJeG8zwISodg0nu4UxBhs" \ + -H "User-Agent: MyApp/1.0" \ + --max-time 60 \ + -H "Content-Type: application/json" \ + -d '{"name":"test-repo","description":"A test repository","private":true}' \ + "https://api.github.com/user/repos" + +# req_as_curl() works with simple requests (single line) + + Code + req_as_curl(request("https://hb.cran.dev/get")) + Output + curl "https://hb.cran.dev/get" + +# req_as_curl() validates input + + Code + req_as_curl("not a request") + Condition + Error in `req_as_curl()`: + ! `req` must be an HTTP request object, not the string "not a request". + diff --git a/tests/testthat/test-httr2-translate.R b/tests/testthat/test-req-as-curl.R similarity index 53% rename from tests/testthat/test-httr2-translate.R rename to tests/testthat/test-req-as-curl.R index 652dd7c14..303cae3cf 100644 --- a/tests/testthat/test-httr2-translate.R +++ b/tests/testthat/test-req-as-curl.R @@ -1,62 +1,62 @@ -test_that("httr2_translate() works with basic GET requests", { +test_that("req_as_curl() works with basic GET requests", { expect_snapshot({ - request("https://httpbin.org/get") |> - httr2_translate() + request("https://hb.cran.dev/get") |> + req_as_curl() }) }) -test_that("httr2_translate() works with POST methods", { +test_that("req_as_curl() works with POST methods", { expect_snapshot({ - request("https://httpbin.org/post") |> + request("https://hb.cran.dev/post") |> req_method("POST") |> - httr2_translate() + req_as_curl() }) }) -test_that("httr2_translate() works with headers", { +test_that("req_as_curl() works with headers", { expect_snapshot({ - request("https://httpbin.org/get") |> + request("https://hb.cran.dev/get") |> req_headers( "Accept" = "application/json", "User-Agent" = "httr2/1.0" ) |> - httr2_translate() + req_as_curl() }) }) -test_that("httr2_translate() works with JSON bodies", { +test_that("req_as_curl() works with JSON bodies", { expect_snapshot({ - request("https://httpbin.org/post") |> + request("https://hb.cran.dev/post") |> req_body_json(list(name = "test", value = 123)) |> - httr2_translate() + req_as_curl() }) }) -test_that("httr2_translate() works with form bodies", { +test_that("req_as_curl() works with form bodies", { expect_snapshot({ - request("https://httpbin.org/post") |> + request("https://hb.cran.dev/post") |> req_body_form(name = "test", value = "123") |> - httr2_translate() + req_as_curl() }) }) -test_that("httr2_translate() works with multipart bodies", { +test_that("req_as_curl() works with multipart bodies", { expect_snapshot({ - request("https://httpbin.org/post") |> + request("https://hb.cran.dev/post") |> req_body_multipart(name = "test", value = "123") |> - httr2_translate() + req_as_curl() }) }) -test_that("httr2_translate() works with string bodies", { +test_that("req_as_curl() works with string bodies", { expect_snapshot({ - request("https://httpbin.org/post") |> + request("https://hb.cran.dev/post") |> req_body_raw("test data", type = "text/plain") |> - httr2_translate() + req_as_curl() }) }) -test_that("httr2_translate() works with file bodies", { +test_that("req_as_curl() works with file bodies", { path <- tempfile() writeLines("test content", path) @@ -65,9 +65,9 @@ test_that("httr2_translate() works with file bodies", { expect_snapshot( { - request("https://httpbin.org/post") |> + request("https://hb.cran.dev/post") |> req_body_file(path, type = "text/plain") |> - httr2_translate() + req_as_curl() }, transform = function(x) { gsub(path, "", x, fixed = TRUE) @@ -75,26 +75,26 @@ test_that("httr2_translate() works with file bodies", { ) }) -test_that("httr2_translate() works with custom content types", { +test_that("req_as_curl() works with custom content types", { expect_snapshot({ - request("https://httpbin.org/post") |> + request("https://hb.cran.dev/post") |> req_body_json( list(test = "data"), type = "application/vnd.api+json" ) |> - httr2_translate() + req_as_curl() }) }) -test_that("httr2_translate() works with options", { +test_that("req_as_curl() works with options", { expect_snapshot({ - request("https://httpbin.org/get") |> + request("https://hb.cran.dev/get") |> req_options(timeout = 30, verbose = TRUE, ssl_verifypeer = FALSE) |> - httr2_translate() + req_as_curl() }) }) -test_that("httr2_translate() works with cookies", { +test_that("req_as_curl() works with cookies", { cookie_file <- tempfile() # create the tempfile @@ -105,9 +105,9 @@ test_that("httr2_translate() works with cookies", { expect_snapshot( { - request("https://httpbin.org/cookies") |> + request("https://hb.cran.dev/cookies") |> req_options(cookiejar = cookie_file, cookiefile = cookie_file) |> - httr2_translate() + req_as_curl() }, transform = function(x) { gsub(cookie_file, "", x, fixed = TRUE) @@ -115,37 +115,37 @@ test_that("httr2_translate() works with cookies", { ) }) -test_that("httr2_translate() works with obfuscated values in headers", { +test_that("req_as_curl() works with obfuscated values in headers", { expect_snapshot({ - request("https://httpbin.org/get") |> + request("https://hb.cran.dev/get") |> req_headers("Authorization" = obfuscated("ZdYJeG8zwISodg0nu4UxBhs")) |> - httr2_translate() + req_as_curl() }) }) -test_that("httr2_translate() works with obfuscated values in JSON body", { +test_that("req_as_curl() works with obfuscated values in JSON body", { expect_snapshot({ - request("https://httpbin.org/post") |> + request("https://hb.cran.dev/post") |> req_body_json(list( username = "test", password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs") )) |> - httr2_translate() + req_as_curl() }) }) -test_that("httr2_translate() works with obfuscated values in form body", { +test_that("req_as_curl() works with obfuscated values in form body", { expect_snapshot({ - request("https://httpbin.org/post") |> + request("https://hb.cran.dev/post") |> req_body_form( username = "test", password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs") ) |> - httr2_translate() + req_as_curl() }) }) -test_that("httr2_translate() works with complex requests", { +test_that("req_as_curl() works with complex requests", { expect_snapshot({ request("https://api.github.com/user/repos") |> req_method("POST") |> @@ -160,19 +160,19 @@ test_that("httr2_translate() works with complex requests", { private = TRUE )) |> req_options(timeout = 60) |> - httr2_translate() + req_as_curl() }) }) -test_that("httr2_translate() works with simple requests (single line)", { +test_that("req_as_curl() works with simple requests (single line)", { expect_snapshot({ - request("https://httpbin.org/get") |> - httr2_translate() + request("https://hb.cran.dev/get") |> + req_as_curl() }) }) -test_that("httr2_translate() validates input", { +test_that("req_as_curl() validates input", { expect_snapshot(error = TRUE, { - httr2_translate("not a request") + req_as_curl("not a request") }) }) From 46d2360f4799c234a37bf1be4f9c9840f17650e4 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Sun, 21 Jun 2026 17:40:40 -0500 Subject: [PATCH 07/31] Address PR review feedback on req_as_curl() - Extract option translation into req_options_as_curl() so it can be tested in isolation - Drop redundant is_empty() guards before the headers loop and the content-type %in% check - Fix misplaced @seealso so it renders as its own section Co-Authored-By: Claude Opus 4.8 (1M context) --- R/req-as-curl.R | 121 +++++++++++++-------------- man/req_as_curl.Rd | 13 ++- tests/testthat/_snaps/req-as-curl.md | 7 ++ tests/testthat/test-req-as-curl.R | 19 +++++ 4 files changed, 92 insertions(+), 68 deletions(-) diff --git a/R/req-as-curl.R b/R/req-as-curl.R index 0c027b528..a665d6807 100644 --- a/R/req-as-curl.R +++ b/R/req-as-curl.R @@ -5,9 +5,9 @@ #' #' @inheritParams req_perform #' @return A character string containing the curl command. +#' @seealso [curl_translate()] to translate in the other direction. #' @export #' @examples -#' @seealso [curl_translate()] #' \dontrun{ #' # Basic GET request #' request("https://httpbin.org/get") |> @@ -48,17 +48,52 @@ req_as_curl <- function(req) { cmd_args <- c(cmd_args, paste0("-X ", method)) } - # get headers and reveal obfuscated values + # add headers using -H flag, revealing obfuscated values headers <- req_get_headers(req, redacted = "reveal") + for (name in names(headers)) { + value <- headers[[name]] + cmd_args <- c(cmd_args, paste0('-H "', name, ': ', value, '"')) + } - # if headers are present, add them using -H flag - if (!rlang::is_empty(headers)) { - for (name in names(headers)) { - value <- headers[[name]] - cmd_args <- c(cmd_args, paste0('-H "', name, ': ', value, '"')) - } + cmd_args <- req_options_as_curl(req, cmd_args) + cmd_args <- req_body_as_curl(req, cmd_args) + + # quote the url + url_quoted <- sprintf('"%s"', url) + + # if we have no arguments we just paste curl and the url together + res <- if (length(cmd_args) == 0) { + paste0("curl ", url_quoted) + } else { + cmd_lines <- paste0(cmd_args, " \\") + + # indent all args except the first + cmd_lines[-1] <- paste0(" ", cmd_lines[-1]) + + # append the url + cmd_lines <- c(cmd_lines, paste0(" ", url_quoted)) + + # combine with new line separation for all but first argument + res <- paste0( + "curl ", + cmd_lines[1], + "\n", + paste0(cmd_lines[-1], collapse = "\n") + ) } + structure(res, class = "httr2_cmd") +} + + +req_options_as_curl <- function(req, cmd_args = c()) { + # TODO make introspection function for options + options <- req$options + used_opts <- names(options) + + # There's no programmatic mapping between curl's option names (as exposed by + # libcurl and stored in the request) and the command line flags, so each + # supported option has a hand-written translation. Warn about any others. known_curl_opts <- c( "timeout", "connecttimeout", @@ -70,76 +105,38 @@ req_as_curl <- function(req) { "cookiejar", "cookiefile" ) - - # manage options - # TODO make introspection function for options - options <- req$options - - # extract names of request's options - used_opts <- names(options) - - # identify options that are not known / handled - unknown_opts <- !used_opts %in% known_curl_opts - - # if any options are found that are not handled below, emit a message - if (any(unknown_opts)) { + unknown_opts <- setdiff(used_opts, known_curl_opts) + if (length(unknown_opts) > 0) { cli::cli_alert_warning( - "Unable to translate option{?s} {.val {used_opts[unknown_opts]}}" + "Unable to translate option{?s} {.val {unknown_opts}}" ) } for (name in used_opts) { value <- options[[name]] - # convert known options to curl flags other values are ignored curl_flag <- switch( name, - # supports req_timeout() + # req_timeout() "timeout" = paste0("--max-time ", value), "connecttimeout" = paste0("--connect-timeout ", value), - # supports req_proxy() + # req_proxy() "proxy" = paste0("--proxy ", value), - # supports req_user_agent() + # req_user_agent() "useragent" = paste0('--user-agent "', value, '"'), "referer" = paste0('--referer "', value, '"'), - # supports defualt behavior or httr2 following redirects - # rather than returning 302 status + # default behaviour of httr2 following redirects rather than + # returning a 302 status "followlocation" = if (value) "--location" else NULL, - # support req_verbose() + # req_verbose() "verbose" = if (value) "--verbose" else NULL, - # support req_cookie_preserve() and req_cookies_set() + # req_cookie_preserve() and req_cookies_set() "cookiejar" = paste0('--cookie-jar "', value, '"'), "cookiefile" = paste0('--cookie "', value, '"') ) cmd_args <- c(cmd_args, curl_flag) } - cmd_args <- req_body_as_curl(req, cmd_args) - - # quote the url - url_quoted <- sprintf('"%s"', url) - - # if we have no arguments we just paste curl and the url together - res <- if (length(cmd_args) == 0) { - paste0("curl ", url_quoted) - } else { - cmd_lines <- paste0(cmd_args, " \\") - - # indent all args except the first - cmd_lines[-1] <- paste0(" ", cmd_lines[-1]) - - # append the url - cmd_lines <- c(cmd_lines, paste0(" ", url_quoted)) - - # combine with new line separation for all but first argument - res <- paste0( - "curl ", - cmd_lines[1], - "\n", - paste0(cmd_lines[-1], collapse = "\n") - ) - } - - structure(res, class = "httr2_cmd") + cmd_args } @@ -165,14 +162,10 @@ req_body_as_curl <- function(req, cmd_args) { ) } - # fetch headers for content-type check + # if the content-type header is set, use it instead of the type inferred + # from the request object headers <- req_get_headers(req) - - # if the headers aren't empty AND the content-type header is set - # we use that instead of what is inferred from the request object - if ( - !rlang::is_empty(headers) && ("content-type" %in% tolower(names(headers))) - ) { + if ("content-type" %in% tolower(names(headers))) { content_type <- headers[["content-type"]] } diff --git a/man/req_as_curl.Rd b/man/req_as_curl.Rd index 006fafcc8..3b7f28538 100644 --- a/man/req_as_curl.Rd +++ b/man/req_as_curl.Rd @@ -2,9 +2,12 @@ % Please edit documentation in R/req-as-curl.R \name{req_as_curl} \alias{req_as_curl} -\title{Translate httr2 request to curl command} +\title{Translate an httr2 request to a curl command} \usage{ -req_as_curl(.req) +req_as_curl(req) +} +\arguments{ +\item{req}{A httr2 \link{request} object.} } \value{ A character string containing the curl command. @@ -13,8 +16,7 @@ A character string containing the curl command. Convert an httr2 request object to equivalent curl command line syntax. This is useful for debugging, sharing requests, or converting to other tools. } -\seealso{ -\code{\link[=curl_translate]{curl_translate()}} +\examples{ \dontrun{ # Basic GET request request("https://httpbin.org/get") |> @@ -31,3 +33,6 @@ request("https://httpbin.org/post") |> req_as_curl() } } +\seealso{ +\code{\link[=curl_translate]{curl_translate()}} to translate in the other direction. +} diff --git a/tests/testthat/_snaps/req-as-curl.md b/tests/testthat/_snaps/req-as-curl.md index e7350bced..f7dd1ff79 100644 --- a/tests/testthat/_snaps/req-as-curl.md +++ b/tests/testthat/_snaps/req-as-curl.md @@ -174,3 +174,10 @@ Error in `req_as_curl()`: ! `req` must be an HTTP request object, not the string "not a request". +# req_options_as_curl() translates known options and warns about others + + Code + out <- req_options_as_curl(req) + Message + ! Unable to translate option "ssl_verifypeer" + diff --git a/tests/testthat/test-req-as-curl.R b/tests/testthat/test-req-as-curl.R index 303cae3cf..2fb261e75 100644 --- a/tests/testthat/test-req-as-curl.R +++ b/tests/testthat/test-req-as-curl.R @@ -176,3 +176,22 @@ test_that("req_as_curl() validates input", { req_as_curl("not a request") }) }) + +test_that("req_options_as_curl() translates known options and warns about others", { + req <- request("https://example.com") |> + req_options( + timeout = 30, + followlocation = TRUE, + verbose = FALSE + ) + # followlocation = TRUE produces a flag; verbose = FALSE produces nothing + expect_equal( + req_options_as_curl(req), + c("--max-time 30", "--location") + ) + + req <- request("https://example.com") |> + req_options(ssl_verifypeer = FALSE) + expect_snapshot(out <- req_options_as_curl(req)) + expect_null(out) +}) From dc82c38edb44dbea4ab6449a261fc51d621ae02b Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Sun, 21 Jun 2026 18:23:56 -0500 Subject: [PATCH 08/31] Refactor req_as_curl() for clarity and add full test coverage Restructure into small parallel helpers (req_method_as_curl(), req_headers_as_curl(), req_options_as_curl(), req_body_as_curl()) that each return a character vector of curl arguments, assembled by a single curl_command() formatter. This removes the cmd_args accumulation, the manual quote-building (now dquote()), and the multi-line special-casing. - Add `obfuscated` argument (matching req_get_body()), passed through to both header and body extraction; secrets are redacted by default. - Upgrade the untranslatable-option alert to a real cli_warn(). - Avoid emitting a duplicate Content-Type when set as a request header. - Reach 100% coverage with unit tests for each helper plus the raw-body and Content-Type cases. Co-Authored-By: Claude Opus 4.8 (1M context) --- R/req-as-curl.R | 266 +++++++++++---------------- man/req_as_curl.Rd | 24 ++- tests/testthat/_snaps/req-as-curl.md | 76 +++++++- tests/testthat/test-req-as-curl.R | 85 ++++++++- 4 files changed, 265 insertions(+), 186 deletions(-) diff --git a/R/req-as-curl.R b/R/req-as-curl.R index a665d6807..0f239f491 100644 --- a/R/req-as-curl.R +++ b/R/req-as-curl.R @@ -1,14 +1,16 @@ #' Translate an httr2 request to a curl command #' -#' Convert an httr2 request object to equivalent curl command line syntax. -#' This is useful for debugging, sharing requests, or converting to other tools. +#' Convert an httr2 request object to the equivalent 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 -#' @return A character string containing the curl command. +#' @inheritParams req_get_body +#' @return A string containing the curl command, with class `httr2_cmd` so +#' it prints nicely. #' @seealso [curl_translate()] to translate in the other direction. #' @export #' @examples -#' \dontrun{ #' # Basic GET request #' request("https://httpbin.org/get") |> #' req_as_curl() @@ -18,83 +20,50 @@ #' req_body_json(list(name = "value")) |> #' req_as_curl() #' -#' # POST with form data -#' request("https://httpbin.org/post") |> -#' req_body_form(name = "value") |> -#' req_as_curl() -#' } -req_as_curl <- function(req) { - # validate the request +#' # Secrets are redacted by default, but can be revealed +#' request("https://example.com") |> +#' req_headers_redacted(Authorization = "secret") |> +#' req_as_curl(obfuscated = "reveal") +req_as_curl <- function(req, obfuscated = c("redact", "reveal")) { check_request(req) + obfuscated <- arg_match(obfuscated) - # Extract URL - url <- req_get_url(req) - - # use the request's method if it is set, otherwise infer - method <- req$method %||% - { - if (!is.null(req$body$data)) { - "POST" - } else { - "GET" - } - } - - # we will append to cmd_args to build up the request - cmd_args <- c() - - # if the method isn't GET, it needs to be specified with `-X` - if (method != "GET") { - cmd_args <- c(cmd_args, paste0("-X ", method)) - } + args <- c( + req_method_as_curl(req), + req_headers_as_curl(req, obfuscated), + req_options_as_curl(req), + req_body_as_curl(req, obfuscated) + ) + out <- curl_command(args, req_get_url(req)) + structure(out, class = "httr2_cmd") +} - # add headers using -H flag, revealing obfuscated values - headers <- req_get_headers(req, redacted = "reveal") - for (name in names(headers)) { - value <- headers[[name]] - cmd_args <- c(cmd_args, paste0('-H "', name, ': ', value, '"')) +req_method_as_curl <- function(req) { + method <- req_get_method(req) + # curl uses GET by default, so it only needs to be requested explicitly + if (method == "GET") { + return(NULL) } + paste0("-X ", method) +} - cmd_args <- req_options_as_curl(req, cmd_args) - cmd_args <- req_body_as_curl(req, cmd_args) - - # quote the url - url_quoted <- sprintf('"%s"', url) - - # if we have no arguments we just paste curl and the url together - res <- if (length(cmd_args) == 0) { - paste0("curl ", url_quoted) - } else { - cmd_lines <- paste0(cmd_args, " \\") - - # indent all args except the first - cmd_lines[-1] <- paste0(" ", cmd_lines[-1]) - - # append the url - cmd_lines <- c(cmd_lines, paste0(" ", url_quoted)) +req_headers_as_curl <- function(req, obfuscated = c("redact", "reveal")) { + obfuscated <- arg_match(obfuscated) - # combine with new line separation for all but first argument - res <- paste0( - "curl ", - cmd_lines[1], - "\n", - paste0(cmd_lines[-1], collapse = "\n") - ) + headers <- req_get_headers(req, redacted = obfuscated) + if (is_empty(headers)) { + return(NULL) } - - structure(res, class = "httr2_cmd") + paste0("-H ", dquote(paste0(names(headers), ": ", unlist(headers)))) } - -req_options_as_curl <- function(req, cmd_args = c()) { - # TODO make introspection function for options +req_options_as_curl <- function(req) { options <- req$options - used_opts <- names(options) - # There's no programmatic mapping between curl's option names (as exposed by - # libcurl and stored in the request) and the command line flags, so each - # supported option has a hand-written translation. Warn about any others. - known_curl_opts <- c( + # There's no programmatic mapping between libcurl's option names and the + # curl command line flags, so each supported option is translated by hand. + # TODO: replace with a `req_get_options()` introspection helper. + known_options <- c( "timeout", "connecttimeout", "proxy", @@ -105,113 +74,90 @@ req_options_as_curl <- function(req, cmd_args = c()) { "cookiejar", "cookiefile" ) - unknown_opts <- setdiff(used_opts, known_curl_opts) - if (length(unknown_opts) > 0) { - cli::cli_alert_warning( - "Unable to translate option{?s} {.val {unknown_opts}}" - ) + unknown <- setdiff(names(options), known_options) + if (length(unknown) > 0) { + cli::cli_warn("Can't translate option{?s} {.val {unknown}}.") } - for (name in used_opts) { + args <- lapply(names(options), function(name) { value <- options[[name]] - curl_flag <- switch( + switch( name, - # req_timeout() - "timeout" = paste0("--max-time ", value), - "connecttimeout" = paste0("--connect-timeout ", value), - # req_proxy() - "proxy" = paste0("--proxy ", value), - # req_user_agent() - "useragent" = paste0('--user-agent "', value, '"'), - "referer" = paste0('--referer "', value, '"'), - # default behaviour of httr2 following redirects rather than - # returning a 302 status - "followlocation" = if (value) "--location" else NULL, - # req_verbose() - "verbose" = if (value) "--verbose" else NULL, + timeout = paste0("--max-time ", value), # req_timeout() + connecttimeout = paste0("--connect-timeout ", value), + proxy = paste0("--proxy ", value), # req_proxy() + useragent = paste0("--user-agent ", dquote(value)), # req_user_agent() + referer = paste0("--referer ", dquote(value)), + followlocation = if (value) "--location", # httr2 follows redirects + verbose = if (value) "--verbose", # req_verbose() # req_cookie_preserve() and req_cookies_set() - "cookiejar" = paste0('--cookie-jar "', value, '"'), - "cookiefile" = paste0('--cookie "', value, '"') + cookiejar = paste0("--cookie-jar ", dquote(value)), + cookiefile = paste0("--cookie ", dquote(value)) ) - cmd_args <- c(cmd_args, curl_flag) - } - - cmd_args + }) + unlist(args) } +req_body_as_curl <- function(req, obfuscated = c("redact", "reveal")) { + obfuscated <- arg_match(obfuscated) -req_body_as_curl <- function(req, cmd_args) { - # extract the body and reveal obfuscated values - body <- req_get_body(req, obfuscated = "reveal") - - if (rlang::is_null(body)) { - return(cmd_args) + body <- req_get_body(req, obfuscated = obfuscated) + if (is.null(body)) { + return(NULL) } + type <- req_get_body_type(req) - body_type <- req$body$type %||% "empty" - - # if content_type set here we use it - content_type <- req$body$content_type - - # if content_type not set we need to infer from body type - if (rlang::is_null(content_type) || !nzchar(content_type)) { - content_type <- switch( - body_type, - "json" = "application/json", - "form" = "application/x-www-form-urlencoded" - ) - } + c(curl_content_type(req, type), curl_body_data(body, type)) +} - # if the content-type header is set, use it instead of the type inferred - # from the request object - headers <- req_get_headers(req) - if ("content-type" %in% tolower(names(headers))) { - content_type <- headers[["content-type"]] +# Emit a `Content-Type` header for the body, unless one is already set as a +# request header (in which case it's emitted by `req_headers_as_curl()`). +curl_content_type <- function(req, type) { + if ("content-type" %in% tolower(names(req_get_headers(req)))) { + return(NULL) } - if (!rlang::is_null(content_type)) { - cmd_args <- c( - cmd_args, - paste0('-H "Content-Type: ', content_type, '"') + 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("-H ", dquote(paste0("Content-Type: ", content_type))) +} - # add body data +curl_body_data <- function(body, type) { switch( - body_type, - "string" = { - cmd_args <- c( - cmd_args, - paste0('-d "', gsub('"', '\\"', body), '"') - ) - }, - "raw" = { - # TODO: should the raw bytes be written to a temp file - # and be hanlded similarly to file? - cmd_args <- c(cmd_args, '--data-binary "@-"') - }, - "file" = { - cmd_args <- c(cmd_args, paste0('--data-binary "@', body, '"')) - }, - "json" = { - json_data <- jsonlite::toJSON(body, auto_unbox = TRUE) - cmd_args <- c(cmd_args, paste0('-d \'', json_data, '\'')) - }, - "form" = { - form_string <- paste( - names(body), - body, - sep = "=", - collapse = "&" - ) - cmd_args <- c(cmd_args, paste0('-d "', form_string, '"')) - }, - "multipart" = { - for (name in names(body)) { - value <- body[[name]] - cmd_args <- c(cmd_args, paste0('-F "', name, '=', value, '"')) - } - } + type, + string = paste0("-d ", dquote(gsub('"', '\\"', body))), + # raw bodies come from a connection, so read the data from stdin + raw = paste0("--data-binary ", dquote("@-")), + file = paste0("--data-binary ", dquote(paste0("@", body))), + json = paste0("-d '", jsonlite::toJSON(body, auto_unbox = TRUE), "'"), + form = paste0( + "-d ", + dquote(paste(names(body), unlist(body), sep = "=", collapse = "&")) + ), + multipart = paste0("-F ", dquote(paste0(names(body), "=", unlist(body)))) ) - cmd_args +} + +# Assemble curl arguments into a command, placing each argument on its own +# line continued with a trailing backslash, e.g. +# curl -X POST \ +# -H "Accept: application/json" \ +# "https://example.com" +curl_command <- function(args, url) { + args <- c(args, dquote(url)) + + indent <- c("", rep(" ", length(args) - 1)) + backslash <- c(rep(" \\", length(args) - 1), "") + paste0("curl ", paste0(indent, args, backslash, collapse = "\n")) +} + +dquote <- function(x) { + paste0('"', x, '"') } diff --git a/man/req_as_curl.Rd b/man/req_as_curl.Rd index 3b7f28538..6f401f62c 100644 --- a/man/req_as_curl.Rd +++ b/man/req_as_curl.Rd @@ -4,20 +4,25 @@ \alias{req_as_curl} \title{Translate an httr2 request to a curl command} \usage{ -req_as_curl(req) +req_as_curl(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 character string containing the curl command. +A string containing the curl command, with class \code{httr2_cmd} so +it prints nicely. } \description{ -Convert an httr2 request object to equivalent curl command line syntax. -This is useful for debugging, sharing requests, or converting to other tools. +Convert an httr2 request object to the equivalent 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{ -\dontrun{ # Basic GET request request("https://httpbin.org/get") |> req_as_curl() @@ -27,11 +32,10 @@ request("https://httpbin.org/post") |> req_body_json(list(name = "value")) |> req_as_curl() -# POST with form data -request("https://httpbin.org/post") |> - req_body_form(name = "value") |> - req_as_curl() -} +# Secrets are redacted by default, but can be revealed +request("https://example.com") |> + req_headers_redacted(Authorization = "secret") |> + req_as_curl(obfuscated = "reveal") } \seealso{ \code{\link[=curl_translate]{curl_translate()}} to translate in the other direction. diff --git a/tests/testthat/_snaps/req-as-curl.md b/tests/testthat/_snaps/req-as-curl.md index f7dd1ff79..31a2fc55b 100644 --- a/tests/testthat/_snaps/req-as-curl.md +++ b/tests/testthat/_snaps/req-as-curl.md @@ -93,8 +93,9 @@ Code req_as_curl(req_options(request("https://hb.cran.dev/get"), timeout = 30, verbose = TRUE, ssl_verifypeer = FALSE)) - Message - ! Unable to translate option "ssl_verifypeer" + Condition + Warning: + Can't translate option "ssl_verifypeer". Output curl --max-time 30 \ --verbose \ @@ -116,7 +117,16 @@ req_as_curl(req_headers(request("https://hb.cran.dev/get"), Authorization = obfuscated( "ZdYJeG8zwISodg0nu4UxBhs"))) Output - curl -H "Authorization: ZdYJeG8zwISodg0nu4UxBhs" \ + curl -H "Authorization: " \ + "https://hb.cran.dev/get" + +# req_as_curl() can reveal obfuscated values + + Code + req_as_curl(req_headers_redacted(request("https://hb.cran.dev/get"), + Authorization = "secret-token"), obfuscated = "reveal") + Output + curl -H "Authorization: secret-token" \ "https://hb.cran.dev/get" # req_as_curl() works with obfuscated values in JSON body @@ -127,7 +137,7 @@ Output curl -X POST \ -H "Content-Type: application/json" \ - -d '{"username":"test","password":"y"}' \ + -d '{"username":"test","password":""}' \ "https://hb.cran.dev/post" # req_as_curl() works with obfuscated values in form body @@ -138,7 +148,7 @@ Output curl -X POST \ -H "Content-Type: application/x-www-form-urlencoded" \ - -d "username=test&password=y" \ + -d "username=test&password=" \ "https://hb.cran.dev/post" # req_as_curl() works with complex requests @@ -152,7 +162,7 @@ Output curl -X POST \ -H "Accept: application/vnd.github.v3+json" \ - -H "Authorization: ZdYJeG8zwISodg0nu4UxBhs" \ + -H "Authorization: " \ -H "User-Agent: MyApp/1.0" \ --max-time 60 \ -H "Content-Type: application/json" \ @@ -174,10 +184,58 @@ Error in `req_as_curl()`: ! `req` must be an HTTP request object, not the string "not a request". -# req_options_as_curl() translates known options and warns about others +# req_as_curl() reads raw bodies from stdin + + Code + req_as_curl(req_body_raw(request("https://hb.cran.dev/post"), charToRaw( + "test data"), type = "text/plain")) + Output + curl -X POST \ + -H "Content-Type: text/plain" \ + --data-binary "@-" \ + "https://hb.cran.dev/post" + +# an explicit Content-Type header isn't duplicated by the body + + Code + req_as_curl(req_body_raw(req_headers(request("https://hb.cran.dev/post"), + `Content-Type` = "application/json"), "{}")) + Output + curl -X POST \ + -H "Content-Type: application/json" \ + -d "{}" \ + "https://hb.cran.dev/post" + +# req_options_as_curl() translates each known option + + Code + cat(req_options_as_curl(req), sep = "\n") + Output + --max-time 30 + --connect-timeout 5 + --proxy http://proxy.example.com + --user-agent "agent" + --referer "http://referer.example.com" + --location + --verbose + --cookie-jar "jar.txt" + --cookie "file.txt" + +# req_options_as_curl() warns about untranslatable options Code out <- req_options_as_curl(req) - Message - ! Unable to translate option "ssl_verifypeer" + Condition + Warning: + Can't translate option "ssl_verifypeer". + +# curl_command() formats zero, one, and many arguments + + Code + cat(curl_command(c("-X POST", "-H \"Accept: text/plain\""), + "https://example.com")) + Output + curl -X POST \ + -H "Accept: text/plain" \ + "https://example.com" diff --git a/tests/testthat/test-req-as-curl.R b/tests/testthat/test-req-as-curl.R index 2fb261e75..51e0a7608 100644 --- a/tests/testthat/test-req-as-curl.R +++ b/tests/testthat/test-req-as-curl.R @@ -123,6 +123,14 @@ test_that("req_as_curl() works with obfuscated values in headers", { }) }) +test_that("req_as_curl() can reveal obfuscated values", { + expect_snapshot({ + request("https://hb.cran.dev/get") |> + req_headers_redacted(Authorization = "secret-token") |> + req_as_curl(obfuscated = "reveal") + }) +}) + test_that("req_as_curl() works with obfuscated values in JSON body", { expect_snapshot({ request("https://hb.cran.dev/post") |> @@ -177,21 +185,84 @@ test_that("req_as_curl() validates input", { }) }) -test_that("req_options_as_curl() translates known options and warns about others", { +test_that("req_as_curl() reads raw bodies from stdin", { + expect_snapshot({ + request("https://hb.cran.dev/post") |> + req_body_raw(charToRaw("test data"), type = "text/plain") |> + req_as_curl() + }) +}) + +test_that("an explicit Content-Type header isn't duplicated by the body", { + expect_snapshot({ + request("https://hb.cran.dev/post") |> + req_headers("Content-Type" = "application/json") |> + req_body_raw("{}") |> + req_as_curl() + }) +}) + +test_that("req_method_as_curl() only sets the method when it's not GET", { + expect_null(req_method_as_curl(request("https://example.com"))) + expect_equal( + req_method_as_curl(req_method(request("https://example.com"), "DELETE")), + "-X DELETE" + ) +}) + +test_that("req_headers_as_curl() drops missing headers and reveals secrets", { + expect_null(req_headers_as_curl(request("https://example.com"))) + + req <- request("https://example.com") |> + req_headers_redacted(Authorization = "secret") + expect_equal( + req_headers_as_curl(req, "redact"), + '-H "Authorization: "' + ) + expect_equal( + req_headers_as_curl(req, "reveal"), + '-H "Authorization: secret"' + ) +}) + +test_that("req_options_as_curl() translates each known option", { req <- request("https://example.com") |> req_options( timeout = 30, + connecttimeout = 5, + proxy = "http://proxy.example.com", + useragent = "agent", + referer = "http://referer.example.com", followlocation = TRUE, - verbose = FALSE + verbose = TRUE, + cookiejar = "jar.txt", + cookiefile = "file.txt" ) - # followlocation = TRUE produces a flag; verbose = FALSE produces nothing - expect_equal( - req_options_as_curl(req), - c("--max-time 30", "--location") - ) + expect_snapshot(cat(req_options_as_curl(req), sep = "\n")) +}) +test_that("req_options_as_curl() drops disabled flags", { + req <- request("https://example.com") |> + req_options(followlocation = FALSE, verbose = FALSE) + expect_null(req_options_as_curl(req)) +}) + +test_that("req_options_as_curl() warns about untranslatable options", { req <- request("https://example.com") |> req_options(ssl_verifypeer = FALSE) expect_snapshot(out <- req_options_as_curl(req)) expect_null(out) }) + +test_that("curl_command() formats zero, one, and many arguments", { + expect_equal( + curl_command(NULL, "https://example.com"), + 'curl "https://example.com"' + ) + expect_snapshot( + cat(curl_command( + c("-X POST", '-H "Accept: text/plain"'), + "https://example.com" + )) + ) +}) From e2fb4f009d45a6711e9748e44b1e0b879c6da338 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Sun, 21 Jun 2026 18:28:25 -0500 Subject: [PATCH 09/31] Url first; only quote as needed --- R/req-as-curl.R | 25 ++---- tests/testthat/_snaps/req-as-curl.md | 120 ++++++++++++--------------- tests/testthat/test-req-as-curl.R | 26 +++--- 3 files changed, 77 insertions(+), 94 deletions(-) diff --git a/R/req-as-curl.R b/R/req-as-curl.R index 0f239f491..3a87baa8d 100644 --- a/R/req-as-curl.R +++ b/R/req-as-curl.R @@ -29,18 +29,21 @@ req_as_curl <- function(req, obfuscated = c("redact", "reveal")) { obfuscated <- arg_match(obfuscated) args <- c( + dquote(req_get_url(req)), req_method_as_curl(req), req_headers_as_curl(req, obfuscated), req_options_as_curl(req), req_body_as_curl(req, obfuscated) ) - out <- curl_command(args, req_get_url(req)) + 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") } req_method_as_curl <- function(req) { method <- req_get_method(req) - # curl uses GET by default, so it only needs to be requested explicitly if (method == "GET") { return(NULL) } @@ -145,19 +148,9 @@ curl_body_data <- function(body, type) { ) } -# Assemble curl arguments into a command, placing each argument on its own -# line continued with a trailing backslash, e.g. -# curl -X POST \ -# -H "Accept: application/json" \ -# "https://example.com" -curl_command <- function(args, url) { - args <- c(args, dquote(url)) - - indent <- c("", rep(" ", length(args) - 1)) - backslash <- c(rep(" \\", length(args) - 1), "") - paste0("curl ", paste0(indent, args, backslash, collapse = "\n")) -} - +# Wrap a string in double quotes, but only when it contains a character that +# the shell would otherwise interpret, like a space or the `?` and `&` of a +# query string. dquote <- function(x) { - paste0('"', x, '"') + ifelse(grepl("[^A-Za-z0-9._~:/@%+=,-]", x), paste0('"', x, '"'), x) } diff --git a/tests/testthat/_snaps/req-as-curl.md b/tests/testthat/_snaps/req-as-curl.md index 31a2fc55b..c591740e5 100644 --- a/tests/testthat/_snaps/req-as-curl.md +++ b/tests/testthat/_snaps/req-as-curl.md @@ -3,15 +3,15 @@ Code req_as_curl(request("https://hb.cran.dev/get")) Output - curl "https://hb.cran.dev/get" + curl https://hb.cran.dev/get # req_as_curl() works with POST methods Code req_as_curl(req_method(request("https://hb.cran.dev/post"), "POST")) Output - curl -X POST \ - "https://hb.cran.dev/post" + curl https://hb.cran.dev/post \ + -X POST # req_as_curl() works with headers @@ -19,9 +19,9 @@ req_as_curl(req_headers(request("https://hb.cran.dev/get"), Accept = "application/json", `User-Agent` = "httr2/1.0")) Output - curl -H "Accept: application/json" \ - -H "User-Agent: httr2/1.0" \ - "https://hb.cran.dev/get" + curl https://hb.cran.dev/get \ + -H "Accept: application/json" \ + -H "User-Agent: httr2/1.0" # req_as_curl() works with JSON bodies @@ -29,10 +29,10 @@ req_as_curl(req_body_json(request("https://hb.cran.dev/post"), list(name = "test", value = 123))) Output - curl -X POST \ + curl https://hb.cran.dev/post \ + -X POST \ -H "Content-Type: application/json" \ - -d '{"name":"test","value":123}' \ - "https://hb.cran.dev/post" + -d '{"name":"test","value":123}' # req_as_curl() works with form bodies @@ -40,10 +40,10 @@ req_as_curl(req_body_form(request("https://hb.cran.dev/post"), name = "test", value = "123")) Output - curl -X POST \ + curl https://hb.cran.dev/post \ + -X POST \ -H "Content-Type: application/x-www-form-urlencoded" \ - -d "name=test&value=123" \ - "https://hb.cran.dev/post" + -d "name=test&value=123" # req_as_curl() works with multipart bodies @@ -51,10 +51,10 @@ req_as_curl(req_body_multipart(request("https://hb.cran.dev/post"), name = "test", value = "123")) Output - curl -X POST \ - -F "name=test" \ - -F "value=123" \ - "https://hb.cran.dev/post" + curl https://hb.cran.dev/post \ + -X POST \ + -F name=test \ + -F value=123 # req_as_curl() works with string bodies @@ -62,20 +62,20 @@ req_as_curl(req_body_raw(request("https://hb.cran.dev/post"), "test data", type = "text/plain")) Output - curl -X POST \ + curl https://hb.cran.dev/post \ + -X POST \ -H "Content-Type: text/plain" \ - -d "test data" \ - "https://hb.cran.dev/post" + -d "test data" # req_as_curl() works with file bodies Code req_as_curl(req_body_file(request("https://hb.cran.dev/post"), path, type = "text/plain")) Output - curl -X POST \ + curl https://hb.cran.dev/post \ + -X POST \ -H "Content-Type: text/plain" \ - --data-binary "@" \ - "https://hb.cran.dev/post" + --data-binary @ # req_as_curl() works with custom content types @@ -83,10 +83,10 @@ req_as_curl(req_body_json(request("https://hb.cran.dev/post"), list(test = "data"), type = "application/vnd.api+json")) Output - curl -X POST \ + curl https://hb.cran.dev/post \ + -X POST \ -H "Content-Type: application/vnd.api+json" \ - -d '{"test":"data"}' \ - "https://hb.cran.dev/post" + -d '{"test":"data"}' # req_as_curl() works with options @@ -97,9 +97,9 @@ Warning: Can't translate option "ssl_verifypeer". Output - curl --max-time 30 \ - --verbose \ - "https://hb.cran.dev/get" + curl https://hb.cran.dev/get \ + --max-time 30 \ + --verbose # req_as_curl() works with cookies @@ -107,9 +107,9 @@ req_as_curl(req_options(request("https://hb.cran.dev/cookies"), cookiejar = cookie_file, cookiefile = cookie_file)) Output - curl --cookie-jar "" \ - --cookie "" \ - "https://hb.cran.dev/cookies" + curl https://hb.cran.dev/cookies \ + --cookie-jar \ + --cookie # req_as_curl() works with obfuscated values in headers @@ -117,8 +117,8 @@ req_as_curl(req_headers(request("https://hb.cran.dev/get"), Authorization = obfuscated( "ZdYJeG8zwISodg0nu4UxBhs"))) Output - curl -H "Authorization: " \ - "https://hb.cran.dev/get" + curl https://hb.cran.dev/get \ + -H "Authorization: " # req_as_curl() can reveal obfuscated values @@ -126,8 +126,8 @@ req_as_curl(req_headers_redacted(request("https://hb.cran.dev/get"), Authorization = "secret-token"), obfuscated = "reveal") Output - curl -H "Authorization: secret-token" \ - "https://hb.cran.dev/get" + curl https://hb.cran.dev/get \ + -H "Authorization: secret-token" # req_as_curl() works with obfuscated values in JSON body @@ -135,10 +135,10 @@ req_as_curl(req_body_json(request("https://hb.cran.dev/post"), list(username = "test", password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs")))) Output - curl -X POST \ + curl https://hb.cran.dev/post \ + -X POST \ -H "Content-Type: application/json" \ - -d '{"username":"test","password":""}' \ - "https://hb.cran.dev/post" + -d '{"username":"test","password":""}' # req_as_curl() works with obfuscated values in form body @@ -146,10 +146,10 @@ req_as_curl(req_body_form(request("https://hb.cran.dev/post"), username = "test", password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs"))) Output - curl -X POST \ + curl https://hb.cran.dev/post \ + -X POST \ -H "Content-Type: application/x-www-form-urlencoded" \ - -d "username=test&password=" \ - "https://hb.cran.dev/post" + -d "username=test&password=" # req_as_curl() works with complex requests @@ -160,21 +160,21 @@ list(name = "test-repo", description = "A test repository", private = TRUE)), timeout = 60)) Output - curl -X POST \ + curl https://api.github.com/user/repos \ + -X POST \ -H "Accept: application/vnd.github.v3+json" \ -H "Authorization: " \ -H "User-Agent: MyApp/1.0" \ --max-time 60 \ -H "Content-Type: application/json" \ - -d '{"name":"test-repo","description":"A test repository","private":true}' \ - "https://api.github.com/user/repos" + -d '{"name":"test-repo","description":"A test repository","private":true}' # req_as_curl() works with simple requests (single line) Code req_as_curl(request("https://hb.cran.dev/get")) Output - curl "https://hb.cran.dev/get" + curl https://hb.cran.dev/get # req_as_curl() validates input @@ -190,10 +190,10 @@ req_as_curl(req_body_raw(request("https://hb.cran.dev/post"), charToRaw( "test data"), type = "text/plain")) Output - curl -X POST \ + curl https://hb.cran.dev/post \ + -X POST \ -H "Content-Type: text/plain" \ - --data-binary "@-" \ - "https://hb.cran.dev/post" + --data-binary @- # an explicit Content-Type header isn't duplicated by the body @@ -201,10 +201,10 @@ req_as_curl(req_body_raw(req_headers(request("https://hb.cran.dev/post"), `Content-Type` = "application/json"), "{}")) Output - curl -X POST \ + curl https://hb.cran.dev/post \ + -X POST \ -H "Content-Type: application/json" \ - -d "{}" \ - "https://hb.cran.dev/post" + -d "{}" # req_options_as_curl() translates each known option @@ -214,12 +214,12 @@ --max-time 30 --connect-timeout 5 --proxy http://proxy.example.com - --user-agent "agent" - --referer "http://referer.example.com" + --user-agent agent + --referer http://referer.example.com --location --verbose - --cookie-jar "jar.txt" - --cookie "file.txt" + --cookie-jar jar.txt + --cookie file.txt # req_options_as_curl() warns about untranslatable options @@ -229,13 +229,3 @@ Warning: Can't translate option "ssl_verifypeer". -# curl_command() formats zero, one, and many arguments - - Code - cat(curl_command(c("-X POST", "-H \"Accept: text/plain\""), - "https://example.com")) - Output - curl -X POST \ - -H "Accept: text/plain" \ - "https://example.com" - diff --git a/tests/testthat/test-req-as-curl.R b/tests/testthat/test-req-as-curl.R index 51e0a7608..31e9f1005 100644 --- a/tests/testthat/test-req-as-curl.R +++ b/tests/testthat/test-req-as-curl.R @@ -202,6 +202,19 @@ test_that("an explicit Content-Type header isn't duplicated by the body", { }) }) +test_that("req_as_curl() quotes the URL only when needed", { + # a plain URL is left unquoted + expect_equal( + as.character(req_as_curl(request("https://example.com/get"))), + "curl https://example.com/get" + ) + # a query string contains shell metacharacters, so it's quoted + expect_equal( + as.character(req_as_curl(request("https://example.com?a=1&b=2"))), + 'curl "https://example.com?a=1&b=2"' + ) +}) + test_that("req_method_as_curl() only sets the method when it's not GET", { expect_null(req_method_as_curl(request("https://example.com"))) expect_equal( @@ -253,16 +266,3 @@ test_that("req_options_as_curl() warns about untranslatable options", { expect_snapshot(out <- req_options_as_curl(req)) expect_null(out) }) - -test_that("curl_command() formats zero, one, and many arguments", { - expect_equal( - curl_command(NULL, "https://example.com"), - 'curl "https://example.com"' - ) - expect_snapshot( - cat(curl_command( - c("-X POST", '-H "Accept: text/plain"'), - "https://example.com" - )) - ) -}) From 260c015ffdafc56f4d7a8f1ea2e02162f724342e Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Sun, 21 Jun 2026 18:32:05 -0500 Subject: [PATCH 10/31] `POST` is implied --- R/req-as-curl.R | 14 ++++++++------ tests/testthat/_snaps/req-as-curl.md | 11 ----------- tests/testthat/test-req-as-curl.R | 17 +++++++++++++---- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/R/req-as-curl.R b/R/req-as-curl.R index 3a87baa8d..77db44e76 100644 --- a/R/req-as-curl.R +++ b/R/req-as-curl.R @@ -28,12 +28,13 @@ req_as_curl <- function(req, obfuscated = c("redact", "reveal")) { check_request(req) obfuscated <- arg_match(obfuscated) + body <- req_body_as_curl(req, obfuscated) args <- c( dquote(req_get_url(req)), - req_method_as_curl(req), + req_method_as_curl(req, has_body = !is.null(body)), req_headers_as_curl(req, obfuscated), req_options_as_curl(req), - req_body_as_curl(req, obfuscated) + body ) indent <- c("", rep(" ", length(args) - 1)) backslash <- c(rep(" \\", length(args) - 1), "") @@ -42,12 +43,13 @@ req_as_curl <- function(req, obfuscated = c("redact", "reveal")) { structure(out, class = "httr2_cmd") } -req_method_as_curl <- function(req) { +req_method_as_curl <- function(req, has_body = FALSE) { method <- req_get_method(req) - if (method == "GET") { - return(NULL) + if (method == "GET" || (method == "POST" && has_body)) { + NULL + } else { + paste0("-X ", method) } - paste0("-X ", method) } req_headers_as_curl <- function(req, obfuscated = c("redact", "reveal")) { diff --git a/tests/testthat/_snaps/req-as-curl.md b/tests/testthat/_snaps/req-as-curl.md index c591740e5..1a7d1efa3 100644 --- a/tests/testthat/_snaps/req-as-curl.md +++ b/tests/testthat/_snaps/req-as-curl.md @@ -30,7 +30,6 @@ value = 123))) Output curl https://hb.cran.dev/post \ - -X POST \ -H "Content-Type: application/json" \ -d '{"name":"test","value":123}' @@ -41,7 +40,6 @@ value = "123")) Output curl https://hb.cran.dev/post \ - -X POST \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "name=test&value=123" @@ -52,7 +50,6 @@ value = "123")) Output curl https://hb.cran.dev/post \ - -X POST \ -F name=test \ -F value=123 @@ -63,7 +60,6 @@ type = "text/plain")) Output curl https://hb.cran.dev/post \ - -X POST \ -H "Content-Type: text/plain" \ -d "test data" @@ -73,7 +69,6 @@ req_as_curl(req_body_file(request("https://hb.cran.dev/post"), path, type = "text/plain")) Output curl https://hb.cran.dev/post \ - -X POST \ -H "Content-Type: text/plain" \ --data-binary @ @@ -84,7 +79,6 @@ type = "application/vnd.api+json")) Output curl https://hb.cran.dev/post \ - -X POST \ -H "Content-Type: application/vnd.api+json" \ -d '{"test":"data"}' @@ -136,7 +130,6 @@ password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs")))) Output curl https://hb.cran.dev/post \ - -X POST \ -H "Content-Type: application/json" \ -d '{"username":"test","password":""}' @@ -147,7 +140,6 @@ password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs"))) Output curl https://hb.cran.dev/post \ - -X POST \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=test&password=" @@ -161,7 +153,6 @@ timeout = 60)) Output curl https://api.github.com/user/repos \ - -X POST \ -H "Accept: application/vnd.github.v3+json" \ -H "Authorization: " \ -H "User-Agent: MyApp/1.0" \ @@ -191,7 +182,6 @@ "test data"), type = "text/plain")) Output curl https://hb.cran.dev/post \ - -X POST \ -H "Content-Type: text/plain" \ --data-binary @- @@ -202,7 +192,6 @@ `Content-Type` = "application/json"), "{}")) Output curl https://hb.cran.dev/post \ - -X POST \ -H "Content-Type: application/json" \ -d "{}" diff --git a/tests/testthat/test-req-as-curl.R b/tests/testthat/test-req-as-curl.R index 31e9f1005..21eb35fc4 100644 --- a/tests/testthat/test-req-as-curl.R +++ b/tests/testthat/test-req-as-curl.R @@ -215,11 +215,20 @@ test_that("req_as_curl() quotes the URL only when needed", { ) }) -test_that("req_method_as_curl() only sets the method when it's not GET", { - expect_null(req_method_as_curl(request("https://example.com"))) +test_that("req_method_as_curl() 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(req_method_as_curl(req)) + expect_null(req_method_as_curl(req_method(req, "POST"), has_body = TRUE)) + + # a body-less POST and other methods need -X + expect_equal(req_method_as_curl(req_method(req, "POST")), "-X POST") + expect_equal(req_method_as_curl(req_method(req, "DELETE")), "-X DELETE") + # a body alone implies POST, but not PUT/DELETE/etc. expect_equal( - req_method_as_curl(req_method(request("https://example.com"), "DELETE")), - "-X DELETE" + req_method_as_curl(req_method(req, "PUT"), has_body = TRUE), + "-X PUT" ) }) From 5683825337c08050877e18d7b5f254b60dc09ad0 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Sun, 21 Jun 2026 18:34:07 -0500 Subject: [PATCH 11/31] Special case head --- R/req-as-curl.R | 2 ++ tests/testthat/test-req-as-curl.R | 3 +++ 2 files changed, 5 insertions(+) diff --git a/R/req-as-curl.R b/R/req-as-curl.R index 77db44e76..109beba03 100644 --- a/R/req-as-curl.R +++ b/R/req-as-curl.R @@ -47,6 +47,8 @@ req_method_as_curl <- 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("-X ", method) } diff --git a/tests/testthat/test-req-as-curl.R b/tests/testthat/test-req-as-curl.R index 21eb35fc4..934f19a4c 100644 --- a/tests/testthat/test-req-as-curl.R +++ b/tests/testthat/test-req-as-curl.R @@ -222,6 +222,9 @@ test_that("req_method_as_curl() only sets the method when curl can't infer it", expect_null(req_method_as_curl(req)) expect_null(req_method_as_curl(req_method(req, "POST"), has_body = TRUE)) + # HEAD has its own flag + expect_equal(req_method_as_curl(req_method(req, "HEAD")), "--head") + # a body-less POST and other methods need -X expect_equal(req_method_as_curl(req_method(req, "POST")), "-X POST") expect_equal(req_method_as_curl(req_method(req, "DELETE")), "-X DELETE") From 7679d3977cbefe6bc75a88e9c55385e9b5f934ca Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Sun, 21 Jun 2026 18:34:52 -0500 Subject: [PATCH 12/31] Drop more redundant comments --- R/req-as-curl.R | 3 --- 1 file changed, 3 deletions(-) diff --git a/R/req-as-curl.R b/R/req-as-curl.R index 109beba03..c9d26af40 100644 --- a/R/req-as-curl.R +++ b/R/req-as-curl.R @@ -152,9 +152,6 @@ curl_body_data <- function(body, type) { ) } -# Wrap a string in double quotes, but only when it contains a character that -# the shell would otherwise interpret, like a space or the `?` and `&` of a -# query string. dquote <- function(x) { ifelse(grepl("[^A-Za-z0-9._~:/@%+=,-]", x), paste0('"', x, '"'), x) } From 1c140bd15c977ebd9c4b139177d061a8da5756e2 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 22 Jun 2026 07:13:18 -0500 Subject: [PATCH 13/31] Polish options --- R/req-as-curl.R | 58 +++++++++++++++++----------- tests/testthat/_snaps/req-as-curl.md | 26 +++++++++---- tests/testthat/test-req-as-curl.R | 30 ++++++++++++-- 3 files changed, 79 insertions(+), 35 deletions(-) diff --git a/R/req-as-curl.R b/R/req-as-curl.R index c9d26af40..7cfb5613d 100644 --- a/R/req-as-curl.R +++ b/R/req-as-curl.R @@ -67,21 +67,28 @@ req_headers_as_curl <- function(req, obfuscated = c("redact", "reveal")) { req_options_as_curl <- function(req) { options <- req$options - # There's no programmatic mapping between libcurl's option names and the - # curl command line flags, so each supported option is translated by hand. - # TODO: replace with a `req_get_options()` introspection helper. known_options <- c( - "timeout", - "connecttimeout", - "proxy", - "useragent", - "referer", + "timeout_ms", # req_timeout() + "connecttimeout", # req_timeout() + "proxy", # req_proxy() + "proxyport", # req_proxy() + "proxyuserpwd", # req_proxy() + "useragent", # req_user_agent() "followlocation", - "verbose", - "cookiejar", - "cookiefile" + "verbose", # req_verbose() + "cookiejar", # req_cookie_preserve() + "cookiefile", # req_cookie_preserve() + "cookie" # req_cookies_set() ) - unknown <- setdiff(names(options), known_options) + # 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() + ) + unknown <- setdiff(names(options), c(known_options, ignored_options)) if (length(unknown) > 0) { cli::cli_warn("Can't translate option{?s} {.val {unknown}}.") } @@ -90,16 +97,22 @@ req_options_as_curl <- function(req) { value <- options[[name]] switch( name, - timeout = paste0("--max-time ", value), # req_timeout() + timeout_ms = paste0("--max-time ", value / 1000), connecttimeout = paste0("--connect-timeout ", value), - proxy = paste0("--proxy ", value), # req_proxy() - useragent = paste0("--user-agent ", dquote(value)), # req_user_agent() - referer = paste0("--referer ", dquote(value)), - followlocation = if (value) "--location", # httr2 follows redirects - verbose = if (value) "--verbose", # req_verbose() - # req_cookie_preserve() and req_cookies_set() + proxy = { + host <- value + if (!is.null(options$proxyport)) { + host <- paste0(host, ":", options$proxyport) + } + paste0("--proxy ", dquote(host)) + }, + proxyuserpwd = paste0("--proxy-user ", dquote(value)), + useragent = paste0("--user-agent ", dquote(value)), + followlocation = if (value) "--location", + verbose = if (value) "--verbose", cookiejar = paste0("--cookie-jar ", dquote(value)), - cookiefile = paste0("--cookie ", dquote(value)) + cookiefile = paste0("--cookie ", dquote(value)), + cookie = paste0("--cookie ", dquote(value)) ) }) unlist(args) @@ -117,8 +130,7 @@ req_body_as_curl <- function(req, obfuscated = c("redact", "reveal")) { c(curl_content_type(req, type), curl_body_data(body, type)) } -# Emit a `Content-Type` header for the body, unless one is already set as a -# request header (in which case it's emitted by `req_headers_as_curl()`). +# 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) @@ -140,7 +152,7 @@ curl_body_data <- function(body, type) { switch( type, string = paste0("-d ", dquote(gsub('"', '\\"', body))), - # raw bodies come from a connection, so read the data from stdin + # raw bodies are read from stdin raw = paste0("--data-binary ", dquote("@-")), file = paste0("--data-binary ", dquote(paste0("@", body))), json = paste0("-d '", jsonlite::toJSON(body, auto_unbox = TRUE), "'"), diff --git a/tests/testthat/_snaps/req-as-curl.md b/tests/testthat/_snaps/req-as-curl.md index 1a7d1efa3..e48e9e2ca 100644 --- a/tests/testthat/_snaps/req-as-curl.md +++ b/tests/testthat/_snaps/req-as-curl.md @@ -85,14 +85,13 @@ # req_as_curl() works with options Code - req_as_curl(req_options(request("https://hb.cran.dev/get"), timeout = 30, - verbose = TRUE, ssl_verifypeer = FALSE)) + req_as_curl(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 \ - --max-time 30 \ --verbose # req_as_curl() works with cookies @@ -146,17 +145,15 @@ # req_as_curl() works with complex requests Code - req_as_curl(req_options(req_body_json(req_headers(req_method(request( + req_as_curl(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)), - timeout = 60)) + list(name = "test-repo", description = "A test repository", private = TRUE))) Output curl https://api.github.com/user/repos \ -H "Accept: application/vnd.github.v3+json" \ -H "Authorization: " \ -H "User-Agent: MyApp/1.0" \ - --max-time 60 \ -H "Content-Type: application/json" \ -d '{"name":"test-repo","description":"A test repository","private":true}' @@ -204,12 +201,25 @@ --connect-timeout 5 --proxy http://proxy.example.com --user-agent agent - --referer http://referer.example.com --location --verbose --cookie-jar jar.txt --cookie file.txt +# req_options_as_curl() translates options set by httr2 functions + + Code + cat(req_options_as_curl(req), sep = "\n") + Output + --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 + # req_options_as_curl() warns about untranslatable options Code diff --git a/tests/testthat/test-req-as-curl.R b/tests/testthat/test-req-as-curl.R index 934f19a4c..bab318c60 100644 --- a/tests/testthat/test-req-as-curl.R +++ b/tests/testthat/test-req-as-curl.R @@ -89,7 +89,7 @@ test_that("req_as_curl() works with custom content types", { test_that("req_as_curl() works with options", { expect_snapshot({ request("https://hb.cran.dev/get") |> - req_options(timeout = 30, verbose = TRUE, ssl_verifypeer = FALSE) |> + req_options(verbose = TRUE, ssl_verifypeer = FALSE) |> req_as_curl() }) }) @@ -167,7 +167,6 @@ test_that("req_as_curl() works with complex requests", { description = "A test repository", private = TRUE )) |> - req_options(timeout = 60) |> req_as_curl() }) }) @@ -253,11 +252,10 @@ test_that("req_headers_as_curl() drops missing headers and reveals secrets", { test_that("req_options_as_curl() translates each known option", { req <- request("https://example.com") |> req_options( - timeout = 30, + timeout_ms = 30000, connecttimeout = 5, proxy = "http://proxy.example.com", useragent = "agent", - referer = "http://referer.example.com", followlocation = TRUE, verbose = TRUE, cookiejar = "jar.txt", @@ -266,6 +264,30 @@ test_that("req_options_as_curl() translates each known option", { expect_snapshot(cat(req_options_as_curl(req), sep = "\n")) }) +test_that("req_options_as_curl() 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(req_options_as_curl(req), sep = "\n")) +}) + +test_that("req_options_as_curl() ignores options with no curl equivalent", { + # req_verbose() also sets a debugfunction; req_progress() sets callbacks + req <- request("https://example.com") |> + req_verbose() |> + req_progress() + expect_no_warning(out <- req_options_as_curl(req)) + expect_equal(out, "--verbose") +}) + test_that("req_options_as_curl() drops disabled flags", { req <- request("https://example.com") |> req_options(followlocation = FALSE, verbose = FALSE) From a325250809f2b35e60aa03b79780b7f112041859 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 22 Jun 2026 07:27:07 -0500 Subject: [PATCH 14/31] Handle followlocation properly --- R/req-as-curl.R | 7 ++++-- tests/testthat/_snaps/req-as-curl.md | 35 +++++++++++++++++++++------- tests/testthat/test-req-as-curl.R | 33 +++++++++++++++++--------- 3 files changed, 54 insertions(+), 21 deletions(-) diff --git a/R/req-as-curl.R b/R/req-as-curl.R index 7cfb5613d..15aa459a9 100644 --- a/R/req-as-curl.R +++ b/R/req-as-curl.R @@ -108,14 +108,17 @@ req_options_as_curl <- function(req) { }, proxyuserpwd = paste0("--proxy-user ", dquote(value)), useragent = paste0("--user-agent ", dquote(value)), - followlocation = if (value) "--location", verbose = if (value) "--verbose", cookiejar = paste0("--cookie-jar ", dquote(value)), cookiefile = paste0("--cookie ", dquote(value)), cookie = paste0("--cookie ", dquote(value)) ) }) - unlist(args) + + # httr2 follows redirects by default, but command line curl doesn't + follow <- if (!isFALSE(options$followlocation)) "--location" + + c(follow, unlist(args)) } req_body_as_curl <- function(req, obfuscated = c("redact", "reveal")) { diff --git a/tests/testthat/_snaps/req-as-curl.md b/tests/testthat/_snaps/req-as-curl.md index e48e9e2ca..16ced8021 100644 --- a/tests/testthat/_snaps/req-as-curl.md +++ b/tests/testthat/_snaps/req-as-curl.md @@ -3,7 +3,8 @@ Code req_as_curl(request("https://hb.cran.dev/get")) Output - curl https://hb.cran.dev/get + curl https://hb.cran.dev/get \ + --location # req_as_curl() works with POST methods @@ -11,7 +12,8 @@ req_as_curl(req_method(request("https://hb.cran.dev/post"), "POST")) Output curl https://hb.cran.dev/post \ - -X POST + -X POST \ + --location # req_as_curl() works with headers @@ -21,7 +23,8 @@ Output curl https://hb.cran.dev/get \ -H "Accept: application/json" \ - -H "User-Agent: httr2/1.0" + -H "User-Agent: httr2/1.0" \ + --location # req_as_curl() works with JSON bodies @@ -30,6 +33,7 @@ value = 123))) Output curl https://hb.cran.dev/post \ + --location \ -H "Content-Type: application/json" \ -d '{"name":"test","value":123}' @@ -40,6 +44,7 @@ value = "123")) Output curl https://hb.cran.dev/post \ + --location \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "name=test&value=123" @@ -50,6 +55,7 @@ value = "123")) Output curl https://hb.cran.dev/post \ + --location \ -F name=test \ -F value=123 @@ -60,6 +66,7 @@ type = "text/plain")) Output curl https://hb.cran.dev/post \ + --location \ -H "Content-Type: text/plain" \ -d "test data" @@ -69,6 +76,7 @@ req_as_curl(req_body_file(request("https://hb.cran.dev/post"), path, type = "text/plain")) Output curl https://hb.cran.dev/post \ + --location \ -H "Content-Type: text/plain" \ --data-binary @ @@ -79,6 +87,7 @@ type = "application/vnd.api+json")) Output curl https://hb.cran.dev/post \ + --location \ -H "Content-Type: application/vnd.api+json" \ -d '{"test":"data"}' @@ -92,6 +101,7 @@ Can't translate option "ssl_verifypeer". Output curl https://hb.cran.dev/get \ + --location \ --verbose # req_as_curl() works with cookies @@ -101,6 +111,7 @@ cookiefile = cookie_file)) Output curl https://hb.cran.dev/cookies \ + --location \ --cookie-jar \ --cookie @@ -111,7 +122,8 @@ "ZdYJeG8zwISodg0nu4UxBhs"))) Output curl https://hb.cran.dev/get \ - -H "Authorization: " + -H "Authorization: " \ + --location # req_as_curl() can reveal obfuscated values @@ -120,7 +132,8 @@ Authorization = "secret-token"), obfuscated = "reveal") Output curl https://hb.cran.dev/get \ - -H "Authorization: secret-token" + -H "Authorization: secret-token" \ + --location # req_as_curl() works with obfuscated values in JSON body @@ -129,6 +142,7 @@ password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs")))) Output curl https://hb.cran.dev/post \ + --location \ -H "Content-Type: application/json" \ -d '{"username":"test","password":""}' @@ -139,6 +153,7 @@ password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs"))) Output curl https://hb.cran.dev/post \ + --location \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=test&password=" @@ -154,13 +169,14 @@ -H "Accept: application/vnd.github.v3+json" \ -H "Authorization: " \ -H "User-Agent: MyApp/1.0" \ + --location \ -H "Content-Type: application/json" \ -d '{"name":"test-repo","description":"A test repository","private":true}' -# req_as_curl() works with simple requests (single line) +# req_as_curl() puts a request with no arguments on a single line Code - req_as_curl(request("https://hb.cran.dev/get")) + req_as_curl(req_options(request("https://hb.cran.dev/get"), followlocation = FALSE)) Output curl https://hb.cran.dev/get @@ -179,6 +195,7 @@ "test data"), type = "text/plain")) Output curl https://hb.cran.dev/post \ + --location \ -H "Content-Type: text/plain" \ --data-binary @- @@ -190,6 +207,7 @@ Output curl https://hb.cran.dev/post \ -H "Content-Type: application/json" \ + --location \ -d "{}" # req_options_as_curl() translates each known option @@ -197,11 +215,11 @@ Code cat(req_options_as_curl(req), sep = "\n") Output + --location --max-time 30 --connect-timeout 5 --proxy http://proxy.example.com --user-agent agent - --location --verbose --cookie-jar jar.txt --cookie file.txt @@ -211,6 +229,7 @@ Code cat(req_options_as_curl(req), sep = "\n") Output + --location --max-time 30 --connect-timeout 0 --proxy proxy.example.com:8080 diff --git a/tests/testthat/test-req-as-curl.R b/tests/testthat/test-req-as-curl.R index bab318c60..8f7d761b6 100644 --- a/tests/testthat/test-req-as-curl.R +++ b/tests/testthat/test-req-as-curl.R @@ -171,9 +171,10 @@ test_that("req_as_curl() works with complex requests", { }) }) -test_that("req_as_curl() works with simple requests (single line)", { +test_that("req_as_curl() puts a request with no arguments on a single line", { expect_snapshot({ request("https://hb.cran.dev/get") |> + req_options(followlocation = FALSE) |> req_as_curl() }) }) @@ -201,16 +202,14 @@ test_that("an explicit Content-Type header isn't duplicated by the body", { }) }) -test_that("req_as_curl() quotes the URL only when needed", { - # a plain URL is left unquoted +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( - as.character(req_as_curl(request("https://example.com/get"))), - "curl https://example.com/get" - ) - # a query string contains shell metacharacters, so it's quoted - expect_equal( - as.character(req_as_curl(request("https://example.com?a=1&b=2"))), - 'curl "https://example.com?a=1&b=2"' + dquote("https://example.com?a=1&b=2"), + '"https://example.com?a=1&b=2"' ) }) @@ -279,9 +278,21 @@ test_that("req_options_as_curl() translates options set by httr2 functions", { expect_snapshot(cat(req_options_as_curl(req), sep = "\n")) }) +test_that("req_options_as_curl() follows redirects by default, unlike curl", { + expect_equal( + req_options_as_curl(request("https://example.com")), + "--location" + ) + + req <- request("https://example.com") |> + req_options(followlocation = FALSE) + expect_null(req_options_as_curl(req)) +}) + test_that("req_options_as_curl() 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 <- req_options_as_curl(req)) @@ -296,7 +307,7 @@ test_that("req_options_as_curl() drops disabled flags", { test_that("req_options_as_curl() warns about untranslatable options", { req <- request("https://example.com") |> - req_options(ssl_verifypeer = FALSE) + req_options(followlocation = FALSE, ssl_verifypeer = FALSE) expect_snapshot(out <- req_options_as_curl(req)) expect_null(out) }) From 081970063063c8d700d2ea647f65211447881420 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 22 Jun 2026 07:30:08 -0500 Subject: [PATCH 15/31] Consistently use long form --- R/req-as-curl.R | 17 +++++---- tests/testthat/_snaps/req-as-curl.md | 56 ++++++++++++++-------------- tests/testthat/test-req-as-curl.R | 15 +++++--- 3 files changed, 47 insertions(+), 41 deletions(-) diff --git a/R/req-as-curl.R b/R/req-as-curl.R index 15aa459a9..3e6853d0b 100644 --- a/R/req-as-curl.R +++ b/R/req-as-curl.R @@ -50,7 +50,7 @@ req_method_as_curl <- function(req, has_body = FALSE) { } else if (method == "HEAD") { "--head" } else { - paste0("-X ", method) + paste0("--request ", method) } } @@ -61,7 +61,7 @@ req_headers_as_curl <- function(req, obfuscated = c("redact", "reveal")) { if (is_empty(headers)) { return(NULL) } - paste0("-H ", dquote(paste0(names(headers), ": ", unlist(headers)))) + paste0("--header ", dquote(paste0(names(headers), ": ", unlist(headers)))) } req_options_as_curl <- function(req) { @@ -148,22 +148,25 @@ curl_content_type <- function(req, type) { if (is.null(content_type) || !nzchar(content_type)) { return(NULL) } - paste0("-H ", dquote(paste0("Content-Type: ", content_type))) + paste0("--header ", dquote(paste0("Content-Type: ", content_type))) } curl_body_data <- function(body, type) { switch( type, - string = paste0("-d ", dquote(gsub('"', '\\"', body))), + string = paste0("--data ", dquote(gsub('"', '\\"', body))), # raw bodies are read from stdin raw = paste0("--data-binary ", dquote("@-")), file = paste0("--data-binary ", dquote(paste0("@", body))), - json = paste0("-d '", jsonlite::toJSON(body, auto_unbox = TRUE), "'"), + json = paste0("--data '", jsonlite::toJSON(body, auto_unbox = TRUE), "'"), form = paste0( - "-d ", + "--data ", dquote(paste(names(body), unlist(body), sep = "=", collapse = "&")) ), - multipart = paste0("-F ", dquote(paste0(names(body), "=", unlist(body)))) + multipart = paste0( + "--form ", + dquote(paste0(names(body), "=", unlist(body))) + ) ) } diff --git a/tests/testthat/_snaps/req-as-curl.md b/tests/testthat/_snaps/req-as-curl.md index 16ced8021..245b05930 100644 --- a/tests/testthat/_snaps/req-as-curl.md +++ b/tests/testthat/_snaps/req-as-curl.md @@ -12,7 +12,7 @@ req_as_curl(req_method(request("https://hb.cran.dev/post"), "POST")) Output curl https://hb.cran.dev/post \ - -X POST \ + --request POST \ --location # req_as_curl() works with headers @@ -22,8 +22,8 @@ `User-Agent` = "httr2/1.0")) Output curl https://hb.cran.dev/get \ - -H "Accept: application/json" \ - -H "User-Agent: httr2/1.0" \ + --header "Accept: application/json" \ + --header "User-Agent: httr2/1.0" \ --location # req_as_curl() works with JSON bodies @@ -34,8 +34,8 @@ Output curl https://hb.cran.dev/post \ --location \ - -H "Content-Type: application/json" \ - -d '{"name":"test","value":123}' + --header "Content-Type: application/json" \ + --data '{"name":"test","value":123}' # req_as_curl() works with form bodies @@ -45,8 +45,8 @@ Output curl https://hb.cran.dev/post \ --location \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "name=test&value=123" + --header "Content-Type: application/x-www-form-urlencoded" \ + --data "name=test&value=123" # req_as_curl() works with multipart bodies @@ -56,8 +56,8 @@ Output curl https://hb.cran.dev/post \ --location \ - -F name=test \ - -F value=123 + --form name=test \ + --form value=123 # req_as_curl() works with string bodies @@ -67,8 +67,8 @@ Output curl https://hb.cran.dev/post \ --location \ - -H "Content-Type: text/plain" \ - -d "test data" + --header "Content-Type: text/plain" \ + --data "test data" # req_as_curl() works with file bodies @@ -77,7 +77,7 @@ Output curl https://hb.cran.dev/post \ --location \ - -H "Content-Type: text/plain" \ + --header "Content-Type: text/plain" \ --data-binary @ # req_as_curl() works with custom content types @@ -88,8 +88,8 @@ Output curl https://hb.cran.dev/post \ --location \ - -H "Content-Type: application/vnd.api+json" \ - -d '{"test":"data"}' + --header "Content-Type: application/vnd.api+json" \ + --data '{"test":"data"}' # req_as_curl() works with options @@ -122,7 +122,7 @@ "ZdYJeG8zwISodg0nu4UxBhs"))) Output curl https://hb.cran.dev/get \ - -H "Authorization: " \ + --header "Authorization: " \ --location # req_as_curl() can reveal obfuscated values @@ -132,7 +132,7 @@ Authorization = "secret-token"), obfuscated = "reveal") Output curl https://hb.cran.dev/get \ - -H "Authorization: secret-token" \ + --header "Authorization: secret-token" \ --location # req_as_curl() works with obfuscated values in JSON body @@ -143,8 +143,8 @@ Output curl https://hb.cran.dev/post \ --location \ - -H "Content-Type: application/json" \ - -d '{"username":"test","password":""}' + --header "Content-Type: application/json" \ + --data '{"username":"test","password":""}' # req_as_curl() works with obfuscated values in form body @@ -154,8 +154,8 @@ Output curl https://hb.cran.dev/post \ --location \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "username=test&password=" + --header "Content-Type: application/x-www-form-urlencoded" \ + --data "username=test&password=" # req_as_curl() works with complex requests @@ -166,12 +166,12 @@ list(name = "test-repo", description = "A test repository", private = TRUE))) Output curl https://api.github.com/user/repos \ - -H "Accept: application/vnd.github.v3+json" \ - -H "Authorization: " \ - -H "User-Agent: MyApp/1.0" \ + --header "Accept: application/vnd.github.v3+json" \ + --header "Authorization: " \ + --header "User-Agent: MyApp/1.0" \ --location \ - -H "Content-Type: application/json" \ - -d '{"name":"test-repo","description":"A test repository","private":true}' + --header "Content-Type: application/json" \ + --data '{"name":"test-repo","description":"A test repository","private":true}' # req_as_curl() puts a request with no arguments on a single line @@ -196,7 +196,7 @@ Output curl https://hb.cran.dev/post \ --location \ - -H "Content-Type: text/plain" \ + --header "Content-Type: text/plain" \ --data-binary @- # an explicit Content-Type header isn't duplicated by the body @@ -206,9 +206,9 @@ `Content-Type` = "application/json"), "{}")) Output curl https://hb.cran.dev/post \ - -H "Content-Type: application/json" \ + --header "Content-Type: application/json" \ --location \ - -d "{}" + --data "{}" # req_options_as_curl() translates each known option diff --git a/tests/testthat/test-req-as-curl.R b/tests/testthat/test-req-as-curl.R index 8f7d761b6..572ced3fa 100644 --- a/tests/testthat/test-req-as-curl.R +++ b/tests/testthat/test-req-as-curl.R @@ -223,13 +223,16 @@ test_that("req_method_as_curl() only sets the method when curl can't infer it", # HEAD has its own flag expect_equal(req_method_as_curl(req_method(req, "HEAD")), "--head") - # a body-less POST and other methods need -X - expect_equal(req_method_as_curl(req_method(req, "POST")), "-X POST") - expect_equal(req_method_as_curl(req_method(req, "DELETE")), "-X DELETE") + # a body-less POST and other methods need --request + expect_equal(req_method_as_curl(req_method(req, "POST")), "--request POST") + expect_equal( + req_method_as_curl(req_method(req, "DELETE")), + "--request DELETE" + ) # a body alone implies POST, but not PUT/DELETE/etc. expect_equal( req_method_as_curl(req_method(req, "PUT"), has_body = TRUE), - "-X PUT" + "--request PUT" ) }) @@ -240,11 +243,11 @@ test_that("req_headers_as_curl() drops missing headers and reveals secrets", { req_headers_redacted(Authorization = "secret") expect_equal( req_headers_as_curl(req, "redact"), - '-H "Authorization: "' + '--header "Authorization: "' ) expect_equal( req_headers_as_curl(req, "reveal"), - '-H "Authorization: secret"' + '--header "Authorization: secret"' ) }) From 3464df1b9961e8bd596cb63abf58707fefeaadd8 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 22 Jun 2026 07:35:50 -0500 Subject: [PATCH 16/31] Consistent naming --- R/req-as-curl.R | 16 +++++----- tests/testthat/_snaps/req-as-curl.md | 12 +++---- tests/testthat/test-req-as-curl.R | 48 ++++++++++++++-------------- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/R/req-as-curl.R b/R/req-as-curl.R index 3e6853d0b..fd2a74272 100644 --- a/R/req-as-curl.R +++ b/R/req-as-curl.R @@ -28,12 +28,12 @@ req_as_curl <- function(req, obfuscated = c("redact", "reveal")) { check_request(req) obfuscated <- arg_match(obfuscated) - body <- req_body_as_curl(req, obfuscated) + body <- curl_body(req, obfuscated) args <- c( dquote(req_get_url(req)), - req_method_as_curl(req, has_body = !is.null(body)), - req_headers_as_curl(req, obfuscated), - req_options_as_curl(req), + curl_method(req, has_body = !is.null(body)), + curl_headers(req, obfuscated), + curl_options(req), body ) indent <- c("", rep(" ", length(args) - 1)) @@ -43,7 +43,7 @@ req_as_curl <- function(req, obfuscated = c("redact", "reveal")) { structure(out, class = "httr2_cmd") } -req_method_as_curl <- function(req, has_body = FALSE) { +curl_method <- function(req, has_body = FALSE) { method <- req_get_method(req) if (method == "GET" || (method == "POST" && has_body)) { NULL @@ -54,7 +54,7 @@ req_method_as_curl <- function(req, has_body = FALSE) { } } -req_headers_as_curl <- function(req, obfuscated = c("redact", "reveal")) { +curl_headers <- function(req, obfuscated = c("redact", "reveal")) { obfuscated <- arg_match(obfuscated) headers <- req_get_headers(req, redacted = obfuscated) @@ -64,7 +64,7 @@ req_headers_as_curl <- function(req, obfuscated = c("redact", "reveal")) { paste0("--header ", dquote(paste0(names(headers), ": ", unlist(headers)))) } -req_options_as_curl <- function(req) { +curl_options <- function(req) { options <- req$options known_options <- c( @@ -121,7 +121,7 @@ req_options_as_curl <- function(req) { c(follow, unlist(args)) } -req_body_as_curl <- function(req, obfuscated = c("redact", "reveal")) { +curl_body <- function(req, obfuscated = c("redact", "reveal")) { obfuscated <- arg_match(obfuscated) body <- req_get_body(req, obfuscated = obfuscated) diff --git a/tests/testthat/_snaps/req-as-curl.md b/tests/testthat/_snaps/req-as-curl.md index 245b05930..e9655b0ec 100644 --- a/tests/testthat/_snaps/req-as-curl.md +++ b/tests/testthat/_snaps/req-as-curl.md @@ -210,10 +210,10 @@ --location \ --data "{}" -# req_options_as_curl() translates each known option +# curl_options() translates each known option Code - cat(req_options_as_curl(req), sep = "\n") + cat(curl_options(req), sep = "\n") Output --location --max-time 30 @@ -224,10 +224,10 @@ --cookie-jar jar.txt --cookie file.txt -# req_options_as_curl() translates options set by httr2 functions +# curl_options() translates options set by httr2 functions Code - cat(req_options_as_curl(req), sep = "\n") + cat(curl_options(req), sep = "\n") Output --location --max-time 30 @@ -239,10 +239,10 @@ --cookie cookies.txt --cookie session=abc -# req_options_as_curl() warns about untranslatable options +# curl_options() warns about untranslatable options Code - out <- req_options_as_curl(req) + out <- curl_options(req) Condition Warning: Can't translate option "ssl_verifypeer". diff --git a/tests/testthat/test-req-as-curl.R b/tests/testthat/test-req-as-curl.R index 572ced3fa..3a3d45af5 100644 --- a/tests/testthat/test-req-as-curl.R +++ b/tests/testthat/test-req-as-curl.R @@ -213,45 +213,45 @@ test_that("dquote() quotes only when needed", { ) }) -test_that("req_method_as_curl() only sets the method when curl can't infer it", { +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(req_method_as_curl(req)) - expect_null(req_method_as_curl(req_method(req, "POST"), has_body = TRUE)) + expect_null(curl_method(req)) + expect_null(curl_method(req_method(req, "POST"), has_body = TRUE)) # HEAD has its own flag - expect_equal(req_method_as_curl(req_method(req, "HEAD")), "--head") + expect_equal(curl_method(req_method(req, "HEAD")), "--head") # a body-less POST and other methods need --request - expect_equal(req_method_as_curl(req_method(req, "POST")), "--request POST") + expect_equal(curl_method(req_method(req, "POST")), "--request POST") expect_equal( - req_method_as_curl(req_method(req, "DELETE")), + curl_method(req_method(req, "DELETE")), "--request DELETE" ) # a body alone implies POST, but not PUT/DELETE/etc. expect_equal( - req_method_as_curl(req_method(req, "PUT"), has_body = TRUE), + curl_method(req_method(req, "PUT"), has_body = TRUE), "--request PUT" ) }) -test_that("req_headers_as_curl() drops missing headers and reveals secrets", { - expect_null(req_headers_as_curl(request("https://example.com"))) +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( - req_headers_as_curl(req, "redact"), + curl_headers(req, "redact"), '--header "Authorization: "' ) expect_equal( - req_headers_as_curl(req, "reveal"), + curl_headers(req, "reveal"), '--header "Authorization: secret"' ) }) -test_that("req_options_as_curl() translates each known option", { +test_that("curl_options() translates each known option", { req <- request("https://example.com") |> req_options( timeout_ms = 30000, @@ -263,10 +263,10 @@ test_that("req_options_as_curl() translates each known option", { cookiejar = "jar.txt", cookiefile = "file.txt" ) - expect_snapshot(cat(req_options_as_curl(req), sep = "\n")) + expect_snapshot(cat(curl_options(req), sep = "\n")) }) -test_that("req_options_as_curl() translates options set by httr2 functions", { +test_that("curl_options() translates options set by httr2 functions", { req <- request("https://example.com") |> req_timeout(30) |> req_proxy( @@ -278,39 +278,39 @@ test_that("req_options_as_curl() translates options set by httr2 functions", { req_user_agent("agent") |> req_cookie_preserve("cookies.txt") |> req_cookies_set(session = "abc") - expect_snapshot(cat(req_options_as_curl(req), sep = "\n")) + expect_snapshot(cat(curl_options(req), sep = "\n")) }) -test_that("req_options_as_curl() follows redirects by default, unlike curl", { +test_that("curl_options() follows redirects by default, unlike curl", { expect_equal( - req_options_as_curl(request("https://example.com")), + curl_options(request("https://example.com")), "--location" ) req <- request("https://example.com") |> req_options(followlocation = FALSE) - expect_null(req_options_as_curl(req)) + expect_null(curl_options(req)) }) -test_that("req_options_as_curl() ignores options with no curl equivalent", { +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 <- req_options_as_curl(req)) + expect_no_warning(out <- curl_options(req)) expect_equal(out, "--verbose") }) -test_that("req_options_as_curl() drops disabled flags", { +test_that("curl_options() drops disabled flags", { req <- request("https://example.com") |> req_options(followlocation = FALSE, verbose = FALSE) - expect_null(req_options_as_curl(req)) + expect_null(curl_options(req)) }) -test_that("req_options_as_curl() warns about untranslatable options", { +test_that("curl_options() warns about untranslatable options", { req <- request("https://example.com") |> req_options(followlocation = FALSE, ssl_verifypeer = FALSE) - expect_snapshot(out <- req_options_as_curl(req)) + expect_snapshot(out <- curl_options(req)) expect_null(out) }) From 5a9eb2af218836aa16ec1f7b6806c5dbfded62b2 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 22 Jun 2026 07:40:45 -0500 Subject: [PATCH 17/31] Correct raw data handling --- R/req-as-curl.R | 19 +++++++++++++++++-- tests/testthat/_snaps/req-as-curl.md | 10 +++++----- tests/testthat/test-req-as-curl.R | 14 ++++++++++++-- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/R/req-as-curl.R b/R/req-as-curl.R index fd2a74272..24f11fc59 100644 --- a/R/req-as-curl.R +++ b/R/req-as-curl.R @@ -155,8 +155,7 @@ curl_body_data <- function(body, type) { switch( type, string = paste0("--data ", dquote(gsub('"', '\\"', body))), - # raw bodies are read from stdin - raw = paste0("--data-binary ", dquote("@-")), + raw = paste0("--data-raw ", escape_bytes(body)), file = paste0("--data-binary ", dquote(paste0("@", body))), json = paste0("--data '", jsonlite::toJSON(body, auto_unbox = TRUE), "'"), form = paste0( @@ -173,3 +172,19 @@ curl_body_data <- function(body, type) { dquote <- function(x) { ifelse(grepl("[^A-Za-z0-9._~:/@%+=,-]", x), paste0('"', x, '"'), x) } + +# Encode raw bytes as an ANSI-C quoted string ($'...'), which the shell decodes +# to the exact bytes; printable ASCII stays readable, the rest becomes \xNN +escape_bytes <- function(bytes) { + ints <- as.integer(bytes) + out <- sprintf("\\x%02x", ints) + printable <- ints >= 0x20 & ints <= 0x7e & ints != 0x27 & ints != 0x5c + out[printable] <- vapply( + ints[printable], + function(i) { + rawToChar(as.raw(i)) + }, + character(1) + ) + paste0("$'", paste(out, collapse = ""), "'") +} diff --git a/tests/testthat/_snaps/req-as-curl.md b/tests/testthat/_snaps/req-as-curl.md index e9655b0ec..db237d904 100644 --- a/tests/testthat/_snaps/req-as-curl.md +++ b/tests/testthat/_snaps/req-as-curl.md @@ -188,16 +188,16 @@ Error in `req_as_curl()`: ! `req` must be an HTTP request object, not the string "not a request". -# req_as_curl() reads raw bodies from stdin +# req_as_curl() encodes raw bodies as binary Code - req_as_curl(req_body_raw(request("https://hb.cran.dev/post"), charToRaw( - "test data"), type = "text/plain")) + req_as_curl(req_body_raw(request("https://hb.cran.dev/post"), as.raw(c(0, 104, + 105, 255)), type = "application/octet-stream")) Output curl https://hb.cran.dev/post \ --location \ - --header "Content-Type: text/plain" \ - --data-binary @- + --header "Content-Type: application/octet-stream" \ + --data-raw $'\x00hi\xff' # an explicit Content-Type header isn't duplicated by the body diff --git a/tests/testthat/test-req-as-curl.R b/tests/testthat/test-req-as-curl.R index 3a3d45af5..d17929b53 100644 --- a/tests/testthat/test-req-as-curl.R +++ b/tests/testthat/test-req-as-curl.R @@ -185,10 +185,13 @@ test_that("req_as_curl() validates input", { }) }) -test_that("req_as_curl() reads raw bodies from stdin", { +test_that("req_as_curl() encodes raw bodies as binary", { expect_snapshot({ request("https://hb.cran.dev/post") |> - req_body_raw(charToRaw("test data"), type = "text/plain") |> + req_body_raw( + as.raw(c(0x00, 0x68, 0x69, 0xff)), + type = "application/octet-stream" + ) |> req_as_curl() }) }) @@ -213,6 +216,13 @@ test_that("dquote() quotes only when needed", { ) }) +test_that("escape_bytes() keeps printable ascii but hex-escapes the rest", { + expect_equal(escape_bytes(charToRaw("ok")), "$'ok'") + expect_equal(escape_bytes(as.raw(c(0x00, 0x41, 0xff))), "$'\\x00A\\xff'") + # quotes and backslashes are hex-escaped so they're safe inside $'...' + expect_equal(escape_bytes(charToRaw("a'b\\c")), "$'a\\x27b\\x5cc'") +}) + test_that("curl_method() only sets the method when curl can't infer it", { req <- request("https://example.com") From afc43537136715381a5d862441cef0025225a24b Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 22 Jun 2026 09:00:25 -0500 Subject: [PATCH 18/31] Use shell-safe quoting in `req_as_curl()` --- R/req-as-curl.R | 13 +++++++-- tests/testthat/_snaps/req-as-curl.md | 42 ++++++++++++++-------------- tests/testthat/test-req-as-curl.R | 25 ++++++++++++++--- 3 files changed, 52 insertions(+), 28 deletions(-) diff --git a/R/req-as-curl.R b/R/req-as-curl.R index 24f11fc59..94ed367ac 100644 --- a/R/req-as-curl.R +++ b/R/req-as-curl.R @@ -154,10 +154,13 @@ curl_content_type <- function(req, type) { curl_body_data <- function(body, type) { switch( type, - string = paste0("--data ", dquote(gsub('"', '\\"', body))), + string = paste0("--data ", dquote(body)), raw = paste0("--data-raw ", escape_bytes(body)), file = paste0("--data-binary ", dquote(paste0("@", body))), - json = paste0("--data '", jsonlite::toJSON(body, auto_unbox = TRUE), "'"), + json = paste0( + "--data ", + dquote(jsonlite::toJSON(body, auto_unbox = TRUE)) + ), form = paste0( "--data ", dquote(paste(names(body), unlist(body), sep = "=", collapse = "&")) @@ -170,7 +173,11 @@ curl_body_data <- function(body, type) { } dquote <- function(x) { - ifelse(grepl("[^A-Za-z0-9._~:/@%+=,-]", x), paste0('"', x, '"'), x) + ifelse( + grepl("[^A-Za-z0-9._~:/@%+=,-]", x), + paste0("'", gsub("'", "'\"'\"'", x, fixed = TRUE), "'"), + x + ) } # Encode raw bytes as an ANSI-C quoted string ($'...'), which the shell decodes diff --git a/tests/testthat/_snaps/req-as-curl.md b/tests/testthat/_snaps/req-as-curl.md index db237d904..9ec5f327d 100644 --- a/tests/testthat/_snaps/req-as-curl.md +++ b/tests/testthat/_snaps/req-as-curl.md @@ -22,8 +22,8 @@ `User-Agent` = "httr2/1.0")) Output curl https://hb.cran.dev/get \ - --header "Accept: application/json" \ - --header "User-Agent: httr2/1.0" \ + --header 'Accept: application/json' \ + --header 'User-Agent: httr2/1.0' \ --location # req_as_curl() works with JSON bodies @@ -34,7 +34,7 @@ Output curl https://hb.cran.dev/post \ --location \ - --header "Content-Type: application/json" \ + --header 'Content-Type: application/json' \ --data '{"name":"test","value":123}' # req_as_curl() works with form bodies @@ -45,8 +45,8 @@ Output curl https://hb.cran.dev/post \ --location \ - --header "Content-Type: application/x-www-form-urlencoded" \ - --data "name=test&value=123" + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data 'name=test&value=123' # req_as_curl() works with multipart bodies @@ -67,8 +67,8 @@ Output curl https://hb.cran.dev/post \ --location \ - --header "Content-Type: text/plain" \ - --data "test data" + --header 'Content-Type: text/plain' \ + --data 'test data' # req_as_curl() works with file bodies @@ -77,7 +77,7 @@ Output curl https://hb.cran.dev/post \ --location \ - --header "Content-Type: text/plain" \ + --header 'Content-Type: text/plain' \ --data-binary @ # req_as_curl() works with custom content types @@ -88,7 +88,7 @@ Output curl https://hb.cran.dev/post \ --location \ - --header "Content-Type: application/vnd.api+json" \ + --header 'Content-Type: application/vnd.api+json' \ --data '{"test":"data"}' # req_as_curl() works with options @@ -122,7 +122,7 @@ "ZdYJeG8zwISodg0nu4UxBhs"))) Output curl https://hb.cran.dev/get \ - --header "Authorization: " \ + --header 'Authorization: ' \ --location # req_as_curl() can reveal obfuscated values @@ -132,7 +132,7 @@ Authorization = "secret-token"), obfuscated = "reveal") Output curl https://hb.cran.dev/get \ - --header "Authorization: secret-token" \ + --header 'Authorization: secret-token' \ --location # req_as_curl() works with obfuscated values in JSON body @@ -143,7 +143,7 @@ Output curl https://hb.cran.dev/post \ --location \ - --header "Content-Type: application/json" \ + --header 'Content-Type: application/json' \ --data '{"username":"test","password":""}' # req_as_curl() works with obfuscated values in form body @@ -154,8 +154,8 @@ Output curl https://hb.cran.dev/post \ --location \ - --header "Content-Type: application/x-www-form-urlencoded" \ - --data "username=test&password=" + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data 'username=test&password=' # req_as_curl() works with complex requests @@ -166,11 +166,11 @@ 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 'Accept: application/vnd.github.v3+json' \ + --header 'Authorization: ' \ + --header 'User-Agent: MyApp/1.0' \ --location \ - --header "Content-Type: application/json" \ + --header 'Content-Type: application/json' \ --data '{"name":"test-repo","description":"A test repository","private":true}' # req_as_curl() puts a request with no arguments on a single line @@ -196,7 +196,7 @@ Output curl https://hb.cran.dev/post \ --location \ - --header "Content-Type: application/octet-stream" \ + --header 'Content-Type: application/octet-stream' \ --data-raw $'\x00hi\xff' # an explicit Content-Type header isn't duplicated by the body @@ -206,9 +206,9 @@ `Content-Type` = "application/json"), "{}")) Output curl https://hb.cran.dev/post \ - --header "Content-Type: application/json" \ + --header 'Content-Type: application/json' \ --location \ - --data "{}" + --data '{}' # curl_options() translates each known option diff --git a/tests/testthat/test-req-as-curl.R b/tests/testthat/test-req-as-curl.R index d17929b53..e51adfc72 100644 --- a/tests/testthat/test-req-as-curl.R +++ b/tests/testthat/test-req-as-curl.R @@ -209,10 +209,27 @@ 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("a b"), "'a b'") expect_equal( dquote("https://example.com?a=1&b=2"), - '"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\"}'" ) }) @@ -253,11 +270,11 @@ test_that("curl_headers() drops missing headers and reveals secrets", { req_headers_redacted(Authorization = "secret") expect_equal( curl_headers(req, "redact"), - '--header "Authorization: "' + "--header 'Authorization: '" ) expect_equal( curl_headers(req, "reveal"), - '--header "Authorization: secret"' + "--header 'Authorization: secret'" ) }) From 89b6001452d23657d011dec62049e02d82840527 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 22 Jun 2026 09:01:40 -0500 Subject: [PATCH 19/31] Render structured bodies correctly in `req_as_curl()` --- R/req-as-curl.R | 48 +++++++++++++++++++++++----- tests/testthat/_snaps/req-as-curl.md | 6 ++-- tests/testthat/test-req-as-curl.R | 41 ++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 11 deletions(-) diff --git a/R/req-as-curl.R b/R/req-as-curl.R index 94ed367ac..37c1a03f0 100644 --- a/R/req-as-curl.R +++ b/R/req-as-curl.R @@ -130,7 +130,10 @@ curl_body <- function(req, obfuscated = c("redact", "reveal")) { } type <- req_get_body_type(req) - c(curl_content_type(req, type), curl_body_data(body, type)) + 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 @@ -151,7 +154,7 @@ curl_content_type <- function(req, type) { paste0("--header ", dquote(paste0("Content-Type: ", content_type))) } -curl_body_data <- function(body, type) { +curl_body_data <- function(body, type, params = list(auto_unbox = TRUE)) { switch( type, string = paste0("--data ", dquote(body)), @@ -159,19 +162,48 @@ curl_body_data <- function(body, type) { file = paste0("--data-binary ", dquote(paste0("@", body))), json = paste0( "--data ", - dquote(jsonlite::toJSON(body, auto_unbox = TRUE)) + dquote(exec(jsonlite::toJSON, body, !!!params)) ), form = paste0( "--data ", - dquote(paste(names(body), unlist(body), sep = "=", collapse = "&")) + dquote(url_query_build(body)) ), - multipart = paste0( - "--form ", - dquote(paste0(names(body), "=", unlist(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), diff --git a/tests/testthat/_snaps/req-as-curl.md b/tests/testthat/_snaps/req-as-curl.md index 9ec5f327d..38b7023e2 100644 --- a/tests/testthat/_snaps/req-as-curl.md +++ b/tests/testthat/_snaps/req-as-curl.md @@ -56,8 +56,8 @@ Output curl https://hb.cran.dev/post \ --location \ - --form name=test \ - --form value=123 + --form-string name=test \ + --form-string value=123 # req_as_curl() works with string bodies @@ -155,7 +155,7 @@ curl https://hb.cran.dev/post \ --location \ --header 'Content-Type: application/x-www-form-urlencoded' \ - --data 'username=test&password=' + --data 'username=test&password=%3CREDACTED%3E' # req_as_curl() works with complex requests diff --git a/tests/testthat/test-req-as-curl.R b/tests/testthat/test-req-as-curl.R index e51adfc72..c3cf58b75 100644 --- a/tests/testthat/test-req-as-curl.R +++ b/tests/testthat/test-req-as-curl.R @@ -233,6 +233,47 @@ test_that("curl_body_data() safely quotes strings and JSON", { ) }) +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 <- tempfile() + 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_equal( + curl_body_data(body, "multipart"), + c( + "--form-string 'text=@literal;value'", + paste0( + "--form 'file=@\"", + body$file$path, + "\";type=text/plain;filename=\"name.txt\"'" + ), + "--form 'data=\"a b\";type=text/plain'" + ) + ) +}) + test_that("escape_bytes() keeps printable ascii but hex-escapes the rest", { expect_equal(escape_bytes(charToRaw("ok")), "$'ok'") expect_equal(escape_bytes(as.raw(c(0x00, 0x41, 0xff))), "$'\\x00A\\xff'") From 369ac4b29381d2efbd428f81096ed527d1cf63e9 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 22 Jun 2026 09:02:20 -0500 Subject: [PATCH 20/31] Preserve raw request bodies with base64 --- R/req-as-curl.R | 33 +++++++++++++--------------- tests/testthat/_snaps/req-as-curl.md | 4 ++-- tests/testthat/test-req-as-curl.R | 14 +++++++----- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/R/req-as-curl.R b/R/req-as-curl.R index 37c1a03f0..dafbd5f21 100644 --- a/R/req-as-curl.R +++ b/R/req-as-curl.R @@ -38,7 +38,11 @@ req_as_curl <- function(req, obfuscated = c("redact", "reveal")) { ) indent <- c("", rep(" ", length(args) - 1)) backslash <- c(rep(" \\", length(args) - 1), "") - out <- paste0("curl ", paste0(indent, args, backslash, collapse = "\n")) + out <- paste0( + curl_body_input(req), + "curl ", + paste0(indent, args, backslash, collapse = "\n") + ) structure(out, class = "httr2_cmd") } @@ -158,7 +162,7 @@ curl_body_data <- function(body, type, params = list(auto_unbox = TRUE)) { switch( type, string = paste0("--data ", dquote(body)), - raw = paste0("--data-raw ", escape_bytes(body)), + raw = "--data-binary @-", file = paste0("--data-binary ", dquote(paste0("@", body))), json = paste0( "--data ", @@ -204,6 +208,15 @@ curl_form_quote <- function(x) { paste0('"', gsub('(["\\\\])', "\\\\\\1", x), '"') } +curl_body_input <- function(req) { + if (req_get_body_type(req) != "raw") { + return("") + } + + encoded <- openssl::base64_encode(req_get_body(req)) + paste0("printf %s ", dquote(encoded), " | base64 --decode | ") +} + dquote <- function(x) { ifelse( grepl("[^A-Za-z0-9._~:/@%+=,-]", x), @@ -211,19 +224,3 @@ dquote <- function(x) { x ) } - -# Encode raw bytes as an ANSI-C quoted string ($'...'), which the shell decodes -# to the exact bytes; printable ASCII stays readable, the rest becomes \xNN -escape_bytes <- function(bytes) { - ints <- as.integer(bytes) - out <- sprintf("\\x%02x", ints) - printable <- ints >= 0x20 & ints <= 0x7e & ints != 0x27 & ints != 0x5c - out[printable] <- vapply( - ints[printable], - function(i) { - rawToChar(as.raw(i)) - }, - character(1) - ) - paste0("$'", paste(out, collapse = ""), "'") -} diff --git a/tests/testthat/_snaps/req-as-curl.md b/tests/testthat/_snaps/req-as-curl.md index 38b7023e2..89e831692 100644 --- a/tests/testthat/_snaps/req-as-curl.md +++ b/tests/testthat/_snaps/req-as-curl.md @@ -194,10 +194,10 @@ req_as_curl(req_body_raw(request("https://hb.cran.dev/post"), as.raw(c(0, 104, 105, 255)), type = "application/octet-stream")) Output - curl https://hb.cran.dev/post \ + printf %s AGhp/w== | base64 --decode | curl https://hb.cran.dev/post \ --location \ --header 'Content-Type: application/octet-stream' \ - --data-raw $'\x00hi\xff' + --data-binary @- # an explicit Content-Type header isn't duplicated by the body diff --git a/tests/testthat/test-req-as-curl.R b/tests/testthat/test-req-as-curl.R index c3cf58b75..6b852c40e 100644 --- a/tests/testthat/test-req-as-curl.R +++ b/tests/testthat/test-req-as-curl.R @@ -274,11 +274,15 @@ test_that("curl_body_data() translates multipart values", { ) }) -test_that("escape_bytes() keeps printable ascii but hex-escapes the rest", { - expect_equal(escape_bytes(charToRaw("ok")), "$'ok'") - expect_equal(escape_bytes(as.raw(c(0x00, 0x41, 0xff))), "$'\\x00A\\xff'") - # quotes and backslashes are hex-escaped so they're safe inside $'...' - expect_equal(escape_bytes(charToRaw("a'b\\c")), "$'a\\x27b\\x5cc'") +test_that("curl_body_input() base64 encodes raw bodies", { + req <- request("https://example.com") |> + req_body_raw(as.raw(c(0x00, 0x41, 0xff))) + + expect_equal( + curl_body_input(req), + "printf %s AEH/ | base64 --decode | " + ) + expect_equal(curl_body_data(req_get_body(req), "raw"), "--data-binary @-") }) test_that("curl_method() only sets the method when curl can't infer it", { From a1d804d4cee443f491a2fffe38825f5c601d946a Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 22 Jun 2026 09:03:26 -0500 Subject: [PATCH 21/31] Translate prepared requests in `req_as_curl()` --- R/req-as-curl.R | 13 +++++++- tests/testthat/_snaps/req-as-curl.md | 49 +++++++++++++++++++--------- tests/testthat/test-req-as-curl.R | 22 +++++++++++++ 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/R/req-as-curl.R b/R/req-as-curl.R index dafbd5f21..1174d1c48 100644 --- a/R/req-as-curl.R +++ b/R/req-as-curl.R @@ -28,6 +28,9 @@ req_as_curl <- 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)), @@ -90,7 +93,15 @@ curl_options <- function(req) { "xferinfofunction", # req_progress() "noprogress", # req_progress() "proxyauth", # req_proxy() - "forbid_reuse" # req_verbose_test() + "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) { diff --git a/tests/testthat/_snaps/req-as-curl.md b/tests/testthat/_snaps/req-as-curl.md index 89e831692..48945f2fa 100644 --- a/tests/testthat/_snaps/req-as-curl.md +++ b/tests/testthat/_snaps/req-as-curl.md @@ -4,7 +4,8 @@ req_as_curl(request("https://hb.cran.dev/get")) Output curl https://hb.cran.dev/get \ - --location + --location \ + --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' # req_as_curl() works with POST methods @@ -13,7 +14,8 @@ Output curl https://hb.cran.dev/post \ --request POST \ - --location + --location \ + --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' # req_as_curl() works with headers @@ -24,7 +26,8 @@ curl https://hb.cran.dev/get \ --header 'Accept: application/json' \ --header 'User-Agent: httr2/1.0' \ - --location + --location \ + --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' # req_as_curl() works with JSON bodies @@ -33,8 +36,9 @@ value = 123))) Output curl https://hb.cran.dev/post \ - --location \ --header 'Content-Type: application/json' \ + --location \ + --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ --data '{"name":"test","value":123}' # req_as_curl() works with form bodies @@ -45,6 +49,7 @@ Output curl https://hb.cran.dev/post \ --location \ + --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data 'name=test&value=123' @@ -56,6 +61,7 @@ Output curl https://hb.cran.dev/post \ --location \ + --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ --form-string name=test \ --form-string value=123 @@ -66,8 +72,9 @@ type = "text/plain")) Output curl https://hb.cran.dev/post \ - --location \ --header 'Content-Type: text/plain' \ + --location \ + --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ --data 'test data' # req_as_curl() works with file bodies @@ -76,8 +83,9 @@ req_as_curl(req_body_file(request("https://hb.cran.dev/post"), path, type = "text/plain")) Output curl https://hb.cran.dev/post \ - --location \ --header 'Content-Type: text/plain' \ + --location \ + --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ --data-binary @ # req_as_curl() works with custom content types @@ -87,8 +95,9 @@ type = "application/vnd.api+json")) Output curl https://hb.cran.dev/post \ - --location \ --header 'Content-Type: application/vnd.api+json' \ + --location \ + --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ --data '{"test":"data"}' # req_as_curl() works with options @@ -102,7 +111,8 @@ Output curl https://hb.cran.dev/get \ --location \ - --verbose + --verbose \ + --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' # req_as_curl() works with cookies @@ -113,7 +123,8 @@ curl https://hb.cran.dev/cookies \ --location \ --cookie-jar \ - --cookie + --cookie \ + --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' # req_as_curl() works with obfuscated values in headers @@ -123,7 +134,8 @@ Output curl https://hb.cran.dev/get \ --header 'Authorization: ' \ - --location + --location \ + --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' # req_as_curl() can reveal obfuscated values @@ -133,7 +145,8 @@ Output curl https://hb.cran.dev/get \ --header 'Authorization: secret-token' \ - --location + --location \ + --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' # req_as_curl() works with obfuscated values in JSON body @@ -142,8 +155,9 @@ password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs")))) Output curl https://hb.cran.dev/post \ - --location \ --header 'Content-Type: application/json' \ + --location \ + --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ --data '{"username":"test","password":""}' # req_as_curl() works with obfuscated values in form body @@ -154,6 +168,7 @@ Output curl https://hb.cran.dev/post \ --location \ + --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data 'username=test&password=%3CREDACTED%3E' @@ -169,8 +184,9 @@ --header 'Accept: application/vnd.github.v3+json' \ --header 'Authorization: ' \ --header 'User-Agent: MyApp/1.0' \ - --location \ --header 'Content-Type: application/json' \ + --location \ + --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ --data '{"name":"test-repo","description":"A test repository","private":true}' # req_as_curl() puts a request with no arguments on a single line @@ -178,7 +194,8 @@ Code req_as_curl(req_options(request("https://hb.cran.dev/get"), followlocation = FALSE)) Output - curl https://hb.cran.dev/get + curl https://hb.cran.dev/get \ + --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' # req_as_curl() validates input @@ -195,8 +212,9 @@ 105, 255)), type = "application/octet-stream")) Output printf %s AGhp/w== | base64 --decode | curl https://hb.cran.dev/post \ - --location \ --header 'Content-Type: application/octet-stream' \ + --location \ + --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ --data-binary @- # an explicit Content-Type header isn't duplicated by the body @@ -208,6 +226,7 @@ curl https://hb.cran.dev/post \ --header 'Content-Type: application/json' \ --location \ + --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ --data '{}' # curl_options() translates each known option diff --git a/tests/testthat/test-req-as-curl.R b/tests/testthat/test-req-as-curl.R index 6b852c40e..ebd75eaf5 100644 --- a/tests/testthat/test-req-as-curl.R +++ b/tests/testthat/test-req-as-curl.R @@ -185,6 +185,28 @@ test_that("req_as_curl() validates input", { }) }) +test_that("req_as_curl() prepares and signs requests", { + req <- request("https://example.com") |> + req_auth_sign( + function(req, cache) { + req_headers_redacted(req, Authorization = "signed") + }, + params = list(), + cache = NULL + ) + + expect_equal( + as.character(req_as_curl(req)), + paste( + "curl https://example.com \\", + " --header 'Authorization: ' \\", + " --location \\", + paste0(" --user-agent ", dquote(default_user_agent())), + sep = "\n" + ) + ) +}) + test_that("req_as_curl() encodes raw bodies as binary", { expect_snapshot({ request("https://hb.cran.dev/post") |> From f9e92f40f72d3dc7d2895166f9076ebf4cc96a06 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 22 Jun 2026 09:05:41 -0500 Subject: [PATCH 22/31] Translate common curl options in `req_as_curl()` --- R/req-as-curl.R | 79 ++++++++++++++++++++++++++-- tests/testthat/_snaps/req-as-curl.md | 24 +++++++-- tests/testthat/test-req-as-curl.R | 35 +++++++++++- 3 files changed, 128 insertions(+), 10 deletions(-) diff --git a/R/req-as-curl.R b/R/req-as-curl.R index 1174d1c48..5bb1a3498 100644 --- a/R/req-as-curl.R +++ b/R/req-as-curl.R @@ -85,14 +85,31 @@ curl_options <- function(req) { "verbose", # req_verbose() "cookiejar", # req_cookie_preserve() "cookiefile", # req_cookie_preserve() - "cookie" # req_cookies_set() + "cookie", # req_cookies_set() + "proxyauth", + "ssl_verifypeer", + "ssl_verifyhost", + "ssl_verifystatus", + "cainfo", + "capath", + "sslcert", + "sslkey", + "keypasswd", + "pinnedpublickey", + "userpwd", + "httpauth", + "failonerror", + "maxredirs", + "interface", + "low_speed_limit", + "low_speed_time", + "accept_encoding" ) # 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() @@ -122,18 +139,72 @@ curl_options <- function(req) { paste0("--proxy ", dquote(host)) }, proxyuserpwd = paste0("--proxy-user ", dquote(value)), + proxyauth = curl_auth_option(value, proxy = TRUE), 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)) + cookie = paste0("--cookie ", dquote(value)), + ssl_verifypeer = , + ssl_verifyhost = NULL, + ssl_verifystatus = if (value) "--cert-status", + cainfo = paste0("--cacert ", dquote(value)), + capath = paste0("--capath ", dquote(value)), + sslcert = paste0("--cert ", dquote(value)), + sslkey = paste0("--key ", dquote(value)), + keypasswd = paste0("--pass ", dquote(value)), + pinnedpublickey = paste0("--pinnedpubkey ", dquote(value)), + userpwd = paste0("--user ", dquote(value)), + httpauth = curl_auth_option(value), + failonerror = if (value) "--fail", + maxredirs = paste0("--max-redirs ", value), + interface = paste0("--interface ", dquote(value)), + low_speed_limit = paste0("--speed-limit ", value), + low_speed_time = paste0("--speed-time ", value), + accept_encoding = c( + if (nzchar(value)) { + paste0("--header ", dquote(paste0("Accept-Encoding: ", value))) + }, + "--compressed" + ) ) }) # httr2 follows redirects by default, but command line curl doesn't follow <- if (!isFALSE(options$followlocation)) "--location" + insecure <- if ( + curl_option_false(options$ssl_verifypeer) || + curl_option_false(options$ssl_verifyhost) + ) { + "--insecure" + } + + c(follow, insecure, unlist(args)) +} + +curl_option_false <- function(value) { + !is.null(value) && + length(value) == 1 && + !is.na(value) && + !as.logical(value) +} + +curl_auth_option <- function(value, proxy = FALSE) { + flags <- c( + `1` = "basic", + `2` = "digest", + `4` = "negotiate", + `8` = "ntlm", + `16` = "digest", + `-17` = "anyauth" + ) + auth <- unname(flags[as.character(value)]) + if (length(auth) != 1 || is.na(auth)) { + cli::cli_warn("Can't translate authentication bitmask {.val {value}}.") + return(NULL) + } - c(follow, unlist(args)) + paste0("--", if (proxy) "proxy-", auth) } curl_body <- function(req, obfuscated = c("redact", "reveal")) { diff --git a/tests/testthat/_snaps/req-as-curl.md b/tests/testthat/_snaps/req-as-curl.md index 48945f2fa..3cbfc69ce 100644 --- a/tests/testthat/_snaps/req-as-curl.md +++ b/tests/testthat/_snaps/req-as-curl.md @@ -105,12 +105,10 @@ Code req_as_curl(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 \ + --insecure \ --verbose \ --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' @@ -235,6 +233,7 @@ cat(curl_options(req), sep = "\n") Output --location + --insecure --max-time 30 --connect-timeout 5 --proxy http://proxy.example.com @@ -242,6 +241,22 @@ --verbose --cookie-jar jar.txt --cookie file.txt + --cert-status + --cacert ca.pem + --capath certs + --cert client.pem + --key client.key + --pass secret + --pinnedpubkey sha256//key + --user user:password + --digest + --fail + --max-redirs 5 + --interface eth0 + --speed-limit 100 + --speed-time 10 + --header 'Accept-Encoding: gzip' + --compressed # curl_options() translates options set by httr2 functions @@ -253,6 +268,7 @@ --connect-timeout 0 --proxy proxy.example.com:8080 --proxy-user u:p + --proxy-basic --user-agent agent --cookie-jar cookies.txt --cookie cookies.txt @@ -264,5 +280,5 @@ out <- curl_options(req) Condition Warning: - Can't translate option "ssl_verifypeer". + Can't translate option "fresh_connect". diff --git a/tests/testthat/test-req-as-curl.R b/tests/testthat/test-req-as-curl.R index ebd75eaf5..31feb0031 100644 --- a/tests/testthat/test-req-as-curl.R +++ b/tests/testthat/test-req-as-curl.R @@ -355,7 +355,23 @@ test_that("curl_options() translates each known option", { followlocation = TRUE, verbose = TRUE, cookiejar = "jar.txt", - cookiefile = "file.txt" + cookiefile = "file.txt", + ssl_verifypeer = FALSE, + ssl_verifystatus = TRUE, + cainfo = "ca.pem", + capath = "certs", + sslcert = "client.pem", + sslkey = "client.key", + keypasswd = "secret", + pinnedpublickey = "sha256//key", + userpwd = "user:password", + httpauth = auth_flags("digest"), + failonerror = TRUE, + maxredirs = 5, + interface = "eth0", + low_speed_limit = 100, + low_speed_time = 10, + accept_encoding = "gzip" ) expect_snapshot(cat(curl_options(req), sep = "\n")) }) @@ -404,7 +420,22 @@ test_that("curl_options() drops disabled flags", { test_that("curl_options() warns about untranslatable options", { req <- request("https://example.com") |> - req_options(followlocation = FALSE, ssl_verifypeer = FALSE) + req_options(followlocation = FALSE, fresh_connect = TRUE) expect_snapshot(out <- curl_options(req)) expect_null(out) }) + +test_that("curl_options() translates authentication options", { + req <- request("https://example.com") |> + req_proxy( + "proxy.example.com", + username = "user", + password = "password", + auth = "digest" + ) |> + req_options(userpwd = "user:password", httpauth = auth_flags("ntlm")) + + options <- paste(curl_options(req), collapse = "\n") + expect_match(options, "--proxy-digest", fixed = TRUE) + expect_match(options, "--ntlm", fixed = TRUE) +}) From b9bb9f19ae8b966808334180046594d51b6be121 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 22 Jun 2026 09:06:37 -0500 Subject: [PATCH 23/31] Document `req_as_curl()` as approximate --- NEWS.md | 1 + R/req-as-curl.R | 2 +- man/req_as_curl.Rd | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/NEWS.md b/NEWS.md index 430d71e96..f98dfed78 100644 --- a/NEWS.md +++ b/NEWS.md @@ -7,6 +7,7 @@ * `oauth_flow_auth_code()` now correctly uses the same redirect URI for both authorization and token requests when using the default localhost redirect URL (@pedrobtz, #829). * New `oauth_server_metadata()` discovers an OAuth/OpenID Connect issuer's endpoints from its `.well-known` metadata document (#845). * `req_auth_aws_v4()` now correctly signs URLs containing encoded slashes (`%2F`) in path segments, such as ARNs in AWS Bedrock API paths (@thisisnic, #842). +* New `req_as_curl()` translates an httr2 request into an approximate curl command, with safe shell quoting and support for structured and binary bodies, request preparation and signing, and common curl options. * `req_body_form()` now creates a valid empty request body when no parameters are provided (@arcresu, #836). * `req_body_form()` and `req_url_query()` no longer error with "C stack usage is too close to the limit" when given very long string values (#805). * `req_cache()` no longer errors when a request is first performed with `path` then later without it (#840). diff --git a/R/req-as-curl.R b/R/req-as-curl.R index 5bb1a3498..1a740d0a8 100644 --- a/R/req-as-curl.R +++ b/R/req-as-curl.R @@ -1,6 +1,6 @@ #' Translate an httr2 request to a curl command #' -#' Convert an httr2 request object to the equivalent curl command line call. +#' Convert an 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. #' diff --git a/man/req_as_curl.Rd b/man/req_as_curl.Rd index 6f401f62c..ed7423379 100644 --- a/man/req_as_curl.Rd +++ b/man/req_as_curl.Rd @@ -18,7 +18,7 @@ A string containing the curl command, with class \code{httr2_cmd} so it prints nicely. } \description{ -Convert an httr2 request object to the equivalent curl command line call. +Convert an 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. } From 494ccc6a9605ddabeda3589ce66d6e7e826a597f Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 22 Jun 2026 09:44:38 -0500 Subject: [PATCH 24/31] Revert "Translate common curl options in `req_as_curl()`" This reverts commit f9e92f40f72d3dc7d2895166f9076ebf4cc96a06. --- NEWS.md | 2 +- R/req-as-curl.R | 79 ++-------------------------- tests/testthat/_snaps/req-as-curl.md | 24 ++------- tests/testthat/test-req-as-curl.R | 70 +++++++----------------- 4 files changed, 29 insertions(+), 146 deletions(-) diff --git a/NEWS.md b/NEWS.md index f98dfed78..d2e261f61 100644 --- a/NEWS.md +++ b/NEWS.md @@ -7,7 +7,7 @@ * `oauth_flow_auth_code()` now correctly uses the same redirect URI for both authorization and token requests when using the default localhost redirect URL (@pedrobtz, #829). * New `oauth_server_metadata()` discovers an OAuth/OpenID Connect issuer's endpoints from its `.well-known` metadata document (#845). * `req_auth_aws_v4()` now correctly signs URLs containing encoded slashes (`%2F`) in path segments, such as ARNs in AWS Bedrock API paths (@thisisnic, #842). -* New `req_as_curl()` translates an httr2 request into an approximate curl command, with safe shell quoting and support for structured and binary bodies, request preparation and signing, and common curl options. +* New `req_as_curl()` translates an httr2 request into an approximate curl command, with safe shell quoting and support for structured and binary bodies, request preparation, and signing. * `req_body_form()` now creates a valid empty request body when no parameters are provided (@arcresu, #836). * `req_body_form()` and `req_url_query()` no longer error with "C stack usage is too close to the limit" when given very long string values (#805). * `req_cache()` no longer errors when a request is first performed with `path` then later without it (#840). diff --git a/R/req-as-curl.R b/R/req-as-curl.R index 1a740d0a8..2267d5400 100644 --- a/R/req-as-curl.R +++ b/R/req-as-curl.R @@ -85,31 +85,14 @@ curl_options <- function(req) { "verbose", # req_verbose() "cookiejar", # req_cookie_preserve() "cookiefile", # req_cookie_preserve() - "cookie", # req_cookies_set() - "proxyauth", - "ssl_verifypeer", - "ssl_verifyhost", - "ssl_verifystatus", - "cainfo", - "capath", - "sslcert", - "sslkey", - "keypasswd", - "pinnedpublickey", - "userpwd", - "httpauth", - "failonerror", - "maxredirs", - "interface", - "low_speed_limit", - "low_speed_time", - "accept_encoding" + "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() @@ -139,72 +122,18 @@ curl_options <- function(req) { paste0("--proxy ", dquote(host)) }, proxyuserpwd = paste0("--proxy-user ", dquote(value)), - proxyauth = curl_auth_option(value, proxy = TRUE), 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)), - ssl_verifypeer = , - ssl_verifyhost = NULL, - ssl_verifystatus = if (value) "--cert-status", - cainfo = paste0("--cacert ", dquote(value)), - capath = paste0("--capath ", dquote(value)), - sslcert = paste0("--cert ", dquote(value)), - sslkey = paste0("--key ", dquote(value)), - keypasswd = paste0("--pass ", dquote(value)), - pinnedpublickey = paste0("--pinnedpubkey ", dquote(value)), - userpwd = paste0("--user ", dquote(value)), - httpauth = curl_auth_option(value), - failonerror = if (value) "--fail", - maxredirs = paste0("--max-redirs ", value), - interface = paste0("--interface ", dquote(value)), - low_speed_limit = paste0("--speed-limit ", value), - low_speed_time = paste0("--speed-time ", value), - accept_encoding = c( - if (nzchar(value)) { - paste0("--header ", dquote(paste0("Accept-Encoding: ", value))) - }, - "--compressed" - ) + cookie = paste0("--cookie ", dquote(value)) ) }) # httr2 follows redirects by default, but command line curl doesn't follow <- if (!isFALSE(options$followlocation)) "--location" - insecure <- if ( - curl_option_false(options$ssl_verifypeer) || - curl_option_false(options$ssl_verifyhost) - ) { - "--insecure" - } - - c(follow, insecure, unlist(args)) -} - -curl_option_false <- function(value) { - !is.null(value) && - length(value) == 1 && - !is.na(value) && - !as.logical(value) -} - -curl_auth_option <- function(value, proxy = FALSE) { - flags <- c( - `1` = "basic", - `2` = "digest", - `4` = "negotiate", - `8` = "ntlm", - `16` = "digest", - `-17` = "anyauth" - ) - auth <- unname(flags[as.character(value)]) - if (length(auth) != 1 || is.na(auth)) { - cli::cli_warn("Can't translate authentication bitmask {.val {value}}.") - return(NULL) - } - paste0("--", if (proxy) "proxy-", auth) + c(follow, unlist(args)) } curl_body <- function(req, obfuscated = c("redact", "reveal")) { diff --git a/tests/testthat/_snaps/req-as-curl.md b/tests/testthat/_snaps/req-as-curl.md index 3cbfc69ce..48945f2fa 100644 --- a/tests/testthat/_snaps/req-as-curl.md +++ b/tests/testthat/_snaps/req-as-curl.md @@ -105,10 +105,12 @@ Code req_as_curl(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 \ - --insecure \ --verbose \ --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' @@ -233,7 +235,6 @@ cat(curl_options(req), sep = "\n") Output --location - --insecure --max-time 30 --connect-timeout 5 --proxy http://proxy.example.com @@ -241,22 +242,6 @@ --verbose --cookie-jar jar.txt --cookie file.txt - --cert-status - --cacert ca.pem - --capath certs - --cert client.pem - --key client.key - --pass secret - --pinnedpubkey sha256//key - --user user:password - --digest - --fail - --max-redirs 5 - --interface eth0 - --speed-limit 100 - --speed-time 10 - --header 'Accept-Encoding: gzip' - --compressed # curl_options() translates options set by httr2 functions @@ -268,7 +253,6 @@ --connect-timeout 0 --proxy proxy.example.com:8080 --proxy-user u:p - --proxy-basic --user-agent agent --cookie-jar cookies.txt --cookie cookies.txt @@ -280,5 +264,5 @@ out <- curl_options(req) Condition Warning: - Can't translate option "fresh_connect". + Can't translate option "ssl_verifypeer". diff --git a/tests/testthat/test-req-as-curl.R b/tests/testthat/test-req-as-curl.R index 31feb0031..23d408d82 100644 --- a/tests/testthat/test-req-as-curl.R +++ b/tests/testthat/test-req-as-curl.R @@ -185,26 +185,27 @@ test_that("req_as_curl() validates input", { }) }) -test_that("req_as_curl() prepares and signs requests", { - req <- request("https://example.com") |> - req_auth_sign( - function(req, cache) { - req_headers_redacted(req, Authorization = "signed") - }, - params = list(), - cache = NULL +test_that("req_as_curl() 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" ) - expect_equal( - as.character(req_as_curl(req)), - paste( - "curl https://example.com \\", - " --header 'Authorization: ' \\", - " --location \\", - paste0(" --user-agent ", dquote(default_user_agent())), - sep = "\n" - ) + command <- as.character(req_as_curl(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("req_as_curl() encodes raw bodies as binary", { @@ -355,23 +356,7 @@ test_that("curl_options() translates each known option", { followlocation = TRUE, verbose = TRUE, cookiejar = "jar.txt", - cookiefile = "file.txt", - ssl_verifypeer = FALSE, - ssl_verifystatus = TRUE, - cainfo = "ca.pem", - capath = "certs", - sslcert = "client.pem", - sslkey = "client.key", - keypasswd = "secret", - pinnedpublickey = "sha256//key", - userpwd = "user:password", - httpauth = auth_flags("digest"), - failonerror = TRUE, - maxredirs = 5, - interface = "eth0", - low_speed_limit = 100, - low_speed_time = 10, - accept_encoding = "gzip" + cookiefile = "file.txt" ) expect_snapshot(cat(curl_options(req), sep = "\n")) }) @@ -420,22 +405,7 @@ test_that("curl_options() drops disabled flags", { test_that("curl_options() warns about untranslatable options", { req <- request("https://example.com") |> - req_options(followlocation = FALSE, fresh_connect = TRUE) + req_options(followlocation = FALSE, ssl_verifypeer = FALSE) expect_snapshot(out <- curl_options(req)) expect_null(out) }) - -test_that("curl_options() translates authentication options", { - req <- request("https://example.com") |> - req_proxy( - "proxy.example.com", - username = "user", - password = "password", - auth = "digest" - ) |> - req_options(userpwd = "user:password", httpauth = auth_flags("ntlm")) - - options <- paste(curl_options(req), collapse = "\n") - expect_match(options, "--proxy-digest", fixed = TRUE) - expect_match(options, "--ntlm", fixed = TRUE) -}) From 59e54257f67694946a9805c2bba5091fbecdf1c1 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 22 Jun 2026 09:50:06 -0500 Subject: [PATCH 25/31] Style --- R/req-as-curl.R | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/R/req-as-curl.R b/R/req-as-curl.R index 2267d5400..825639934 100644 --- a/R/req-as-curl.R +++ b/R/req-as-curl.R @@ -114,13 +114,10 @@ curl_options <- function(req) { name, timeout_ms = paste0("--max-time ", value / 1000), connecttimeout = paste0("--connect-timeout ", value), - proxy = { - host <- value - if (!is.null(options$proxyport)) { - host <- paste0(host, ":", options$proxyport) - } - paste0("--proxy ", dquote(host)) - }, + 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", @@ -175,14 +172,8 @@ curl_body_data <- function(body, type, params = list(auto_unbox = TRUE)) { string = paste0("--data ", dquote(body)), raw = "--data-binary @-", file = paste0("--data-binary ", dquote(paste0("@", body))), - json = paste0( - "--data ", - dquote(exec(jsonlite::toJSON, body, !!!params)) - ), - form = paste0( - "--data ", - dquote(url_query_build(body)) - ), + json = paste0("--data ", dquote(exec(jsonlite::toJSON, body, !!!params))), + form = paste0("--data ", dquote(url_query_build(body))), multipart = curl_body_multipart(body) ) } From f3b3930d99d7851c7669c1f11a9272ce7fea9a4b Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 22 Jun 2026 09:53:07 -0500 Subject: [PATCH 26/31] Drop raw body support --- R/req-as-curl.R | 19 ++++--------------- tests/testthat/_snaps/req-as-curl.md | 14 +++++--------- tests/testthat/test-req-as-curl.R | 24 ++++-------------------- 3 files changed, 13 insertions(+), 44 deletions(-) diff --git a/R/req-as-curl.R b/R/req-as-curl.R index 825639934..de9fca240 100644 --- a/R/req-as-curl.R +++ b/R/req-as-curl.R @@ -41,11 +41,7 @@ req_as_curl <- function(req, obfuscated = c("redact", "reveal")) { ) indent <- c("", rep(" ", length(args) - 1)) backslash <- c(rep(" \\", length(args) - 1), "") - out <- paste0( - curl_body_input(req), - "curl ", - paste0(indent, args, backslash, collapse = "\n") - ) + out <- paste0("curl ", paste0(indent, args, backslash, collapse = "\n")) structure(out, class = "httr2_cmd") } @@ -141,6 +137,9 @@ curl_body <- function(req, obfuscated = c("redact", "reveal")) { 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), @@ -170,7 +169,6 @@ curl_body_data <- function(body, type, params = list(auto_unbox = TRUE)) { switch( type, string = paste0("--data ", dquote(body)), - raw = "--data-binary @-", file = paste0("--data-binary ", dquote(paste0("@", body))), json = paste0("--data ", dquote(exec(jsonlite::toJSON, body, !!!params))), form = paste0("--data ", dquote(url_query_build(body))), @@ -210,15 +208,6 @@ curl_form_quote <- function(x) { paste0('"', gsub('(["\\\\])', "\\\\\\1", x), '"') } -curl_body_input <- function(req) { - if (req_get_body_type(req) != "raw") { - return("") - } - - encoded <- openssl::base64_encode(req_get_body(req)) - paste0("printf %s ", dquote(encoded), " | base64 --decode | ") -} - dquote <- function(x) { ifelse( grepl("[^A-Za-z0-9._~:/@%+=,-]", x), diff --git a/tests/testthat/_snaps/req-as-curl.md b/tests/testthat/_snaps/req-as-curl.md index 48945f2fa..b633cf8db 100644 --- a/tests/testthat/_snaps/req-as-curl.md +++ b/tests/testthat/_snaps/req-as-curl.md @@ -205,17 +205,13 @@ Error in `req_as_curl()`: ! `req` must be an HTTP request object, not the string "not a request". -# req_as_curl() encodes raw bodies as binary +# req_as_curl() errors for raw bodies Code - req_as_curl(req_body_raw(request("https://hb.cran.dev/post"), as.raw(c(0, 104, - 105, 255)), type = "application/octet-stream")) - Output - printf %s AGhp/w== | base64 --decode | curl https://hb.cran.dev/post \ - --header 'Content-Type: application/octet-stream' \ - --location \ - --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ - --data-binary @- + req_as_curl(req) + Condition + Error: + ! Can't translate a request with a raw body. # an explicit Content-Type header isn't duplicated by the body diff --git a/tests/testthat/test-req-as-curl.R b/tests/testthat/test-req-as-curl.R index 23d408d82..6ba09711a 100644 --- a/tests/testthat/test-req-as-curl.R +++ b/tests/testthat/test-req-as-curl.R @@ -208,15 +208,10 @@ test_that("req_as_curl() signs AWS requests", { expect_match(command, "--header 'x-amz-date: [0-9]{8}T[0-9]{6}Z'") }) -test_that("req_as_curl() encodes raw bodies as binary", { - expect_snapshot({ - request("https://hb.cran.dev/post") |> - req_body_raw( - as.raw(c(0x00, 0x68, 0x69, 0xff)), - type = "application/octet-stream" - ) |> - req_as_curl() - }) +test_that("req_as_curl() errors for raw bodies", { + req <- request("https://hb.cran.dev/post") |> + req_body_raw(as.raw(c(0x00, 0x68, 0x69, 0xff))) + expect_snapshot(req_as_curl(req), error = TRUE) }) test_that("an explicit Content-Type header isn't duplicated by the body", { @@ -297,17 +292,6 @@ test_that("curl_body_data() translates multipart values", { ) }) -test_that("curl_body_input() base64 encodes raw bodies", { - req <- request("https://example.com") |> - req_body_raw(as.raw(c(0x00, 0x41, 0xff))) - - expect_equal( - curl_body_input(req), - "printf %s AEH/ | base64 --decode | " - ) - expect_equal(curl_body_data(req_get_body(req), "raw"), "--data-binary @-") -}) - test_that("curl_method() only sets the method when curl can't infer it", { req <- request("https://example.com") From 8792a8365e3b57a11449b2ad54a6f2710e697ebd Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 22 Jun 2026 10:25:15 -0500 Subject: [PATCH 27/31] Simplify user agent --- tests/testthat/_snaps/req-as-curl.md | 36 ++++++++++++++-------------- tests/testthat/helper.R | 12 ++++++++++ tests/testthat/test-req-as-curl.R | 18 ++++++++++++++ 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/tests/testthat/_snaps/req-as-curl.md b/tests/testthat/_snaps/req-as-curl.md index b633cf8db..3460db7a0 100644 --- a/tests/testthat/_snaps/req-as-curl.md +++ b/tests/testthat/_snaps/req-as-curl.md @@ -5,7 +5,7 @@ Output curl https://hb.cran.dev/get \ --location \ - --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' + --user-agent httr2 # req_as_curl() works with POST methods @@ -15,7 +15,7 @@ curl https://hb.cran.dev/post \ --request POST \ --location \ - --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' + --user-agent httr2 # req_as_curl() works with headers @@ -27,7 +27,7 @@ --header 'Accept: application/json' \ --header 'User-Agent: httr2/1.0' \ --location \ - --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' + --user-agent httr2 # req_as_curl() works with JSON bodies @@ -38,7 +38,7 @@ curl https://hb.cran.dev/post \ --header 'Content-Type: application/json' \ --location \ - --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ + --user-agent httr2 \ --data '{"name":"test","value":123}' # req_as_curl() works with form bodies @@ -49,7 +49,7 @@ Output curl https://hb.cran.dev/post \ --location \ - --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ + --user-agent httr2 \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data 'name=test&value=123' @@ -61,7 +61,7 @@ Output curl https://hb.cran.dev/post \ --location \ - --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ + --user-agent httr2 \ --form-string name=test \ --form-string value=123 @@ -74,7 +74,7 @@ curl https://hb.cran.dev/post \ --header 'Content-Type: text/plain' \ --location \ - --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ + --user-agent httr2 \ --data 'test data' # req_as_curl() works with file bodies @@ -85,7 +85,7 @@ curl https://hb.cran.dev/post \ --header 'Content-Type: text/plain' \ --location \ - --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ + --user-agent httr2 \ --data-binary @ # req_as_curl() works with custom content types @@ -97,7 +97,7 @@ curl https://hb.cran.dev/post \ --header 'Content-Type: application/vnd.api+json' \ --location \ - --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ + --user-agent httr2 \ --data '{"test":"data"}' # req_as_curl() works with options @@ -112,7 +112,7 @@ curl https://hb.cran.dev/get \ --location \ --verbose \ - --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' + --user-agent httr2 # req_as_curl() works with cookies @@ -124,7 +124,7 @@ --location \ --cookie-jar \ --cookie \ - --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' + --user-agent httr2 # req_as_curl() works with obfuscated values in headers @@ -135,7 +135,7 @@ curl https://hb.cran.dev/get \ --header 'Authorization: ' \ --location \ - --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' + --user-agent httr2 # req_as_curl() can reveal obfuscated values @@ -146,7 +146,7 @@ curl https://hb.cran.dev/get \ --header 'Authorization: secret-token' \ --location \ - --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' + --user-agent httr2 # req_as_curl() works with obfuscated values in JSON body @@ -157,7 +157,7 @@ curl https://hb.cran.dev/post \ --header 'Content-Type: application/json' \ --location \ - --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ + --user-agent httr2 \ --data '{"username":"test","password":""}' # req_as_curl() works with obfuscated values in form body @@ -168,7 +168,7 @@ Output curl https://hb.cran.dev/post \ --location \ - --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ + --user-agent httr2 \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data 'username=test&password=%3CREDACTED%3E' @@ -186,7 +186,7 @@ --header 'User-Agent: MyApp/1.0' \ --header 'Content-Type: application/json' \ --location \ - --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ + --user-agent httr2 \ --data '{"name":"test-repo","description":"A test repository","private":true}' # req_as_curl() puts a request with no arguments on a single line @@ -195,7 +195,7 @@ req_as_curl(req_options(request("https://hb.cran.dev/get"), followlocation = FALSE)) Output curl https://hb.cran.dev/get \ - --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' + --user-agent httr2 # req_as_curl() validates input @@ -222,7 +222,7 @@ curl https://hb.cran.dev/post \ --header 'Content-Type: application/json' \ --location \ - --user-agent 'httr2/1.2.2.9000 r-curl/7.1.0 libcurl/8.14.1' \ + --user-agent httr2 \ --data '{}' # curl_options() translates each known option 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-req-as-curl.R b/tests/testthat/test-req-as-curl.R index 6ba09711a..8dc0089bc 100644 --- a/tests/testthat/test-req-as-curl.R +++ b/tests/testthat/test-req-as-curl.R @@ -1,4 +1,5 @@ test_that("req_as_curl() works with basic GET requests", { + local_mocked_user_agent() expect_snapshot({ request("https://hb.cran.dev/get") |> req_as_curl() @@ -6,6 +7,7 @@ test_that("req_as_curl() works with basic GET requests", { }) test_that("req_as_curl() works with POST methods", { + local_mocked_user_agent() expect_snapshot({ request("https://hb.cran.dev/post") |> req_method("POST") |> @@ -14,6 +16,7 @@ test_that("req_as_curl() works with POST methods", { }) test_that("req_as_curl() works with headers", { + local_mocked_user_agent() expect_snapshot({ request("https://hb.cran.dev/get") |> req_headers( @@ -25,6 +28,7 @@ test_that("req_as_curl() works with headers", { }) test_that("req_as_curl() works with JSON bodies", { + local_mocked_user_agent() expect_snapshot({ request("https://hb.cran.dev/post") |> req_body_json(list(name = "test", value = 123)) |> @@ -33,6 +37,7 @@ test_that("req_as_curl() works with JSON bodies", { }) test_that("req_as_curl() works with form bodies", { + local_mocked_user_agent() expect_snapshot({ request("https://hb.cran.dev/post") |> req_body_form(name = "test", value = "123") |> @@ -41,6 +46,7 @@ test_that("req_as_curl() works with form bodies", { }) test_that("req_as_curl() works with multipart bodies", { + local_mocked_user_agent() expect_snapshot({ request("https://hb.cran.dev/post") |> req_body_multipart(name = "test", value = "123") |> @@ -49,6 +55,7 @@ test_that("req_as_curl() works with multipart bodies", { }) test_that("req_as_curl() works with string bodies", { + local_mocked_user_agent() expect_snapshot({ request("https://hb.cran.dev/post") |> req_body_raw("test data", type = "text/plain") |> @@ -57,6 +64,7 @@ test_that("req_as_curl() works with string bodies", { }) test_that("req_as_curl() works with file bodies", { + local_mocked_user_agent() path <- tempfile() writeLines("test content", path) @@ -76,6 +84,7 @@ test_that("req_as_curl() works with file bodies", { }) test_that("req_as_curl() works with custom content types", { + local_mocked_user_agent() expect_snapshot({ request("https://hb.cran.dev/post") |> req_body_json( @@ -87,6 +96,7 @@ test_that("req_as_curl() works with custom content types", { }) test_that("req_as_curl() works with options", { + local_mocked_user_agent() expect_snapshot({ request("https://hb.cran.dev/get") |> req_options(verbose = TRUE, ssl_verifypeer = FALSE) |> @@ -95,6 +105,7 @@ test_that("req_as_curl() works with options", { }) test_that("req_as_curl() works with cookies", { + local_mocked_user_agent() cookie_file <- tempfile() # create the tempfile @@ -116,6 +127,7 @@ test_that("req_as_curl() works with cookies", { }) test_that("req_as_curl() works with obfuscated values in headers", { + local_mocked_user_agent() expect_snapshot({ request("https://hb.cran.dev/get") |> req_headers("Authorization" = obfuscated("ZdYJeG8zwISodg0nu4UxBhs")) |> @@ -124,6 +136,7 @@ test_that("req_as_curl() works with obfuscated values in headers", { }) test_that("req_as_curl() can reveal obfuscated values", { + local_mocked_user_agent() expect_snapshot({ request("https://hb.cran.dev/get") |> req_headers_redacted(Authorization = "secret-token") |> @@ -132,6 +145,7 @@ test_that("req_as_curl() can reveal obfuscated values", { }) test_that("req_as_curl() works with obfuscated values in JSON body", { + local_mocked_user_agent() expect_snapshot({ request("https://hb.cran.dev/post") |> req_body_json(list( @@ -143,6 +157,7 @@ test_that("req_as_curl() works with obfuscated values in JSON body", { }) test_that("req_as_curl() works with obfuscated values in form body", { + local_mocked_user_agent() expect_snapshot({ request("https://hb.cran.dev/post") |> req_body_form( @@ -154,6 +169,7 @@ test_that("req_as_curl() works with obfuscated values in form body", { }) test_that("req_as_curl() works with complex requests", { + local_mocked_user_agent() expect_snapshot({ request("https://api.github.com/user/repos") |> req_method("POST") |> @@ -172,6 +188,7 @@ test_that("req_as_curl() works with complex requests", { }) test_that("req_as_curl() 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) |> @@ -215,6 +232,7 @@ test_that("req_as_curl() errors for raw bodies", { }) 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") |> From f94eac4db95b4198be3737f33d0eb10e834a0758 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 22 Jun 2026 15:57:17 -0500 Subject: [PATCH 28/31] Simplfiy test with path --- tests/testthat/_snaps/req-as-curl.md | 9 +++++++++ tests/testthat/test-req-as-curl.R | 17 +++++------------ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/tests/testthat/_snaps/req-as-curl.md b/tests/testthat/_snaps/req-as-curl.md index 3460db7a0..65f7b3f0c 100644 --- a/tests/testthat/_snaps/req-as-curl.md +++ b/tests/testthat/_snaps/req-as-curl.md @@ -225,6 +225,15 @@ --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 diff --git a/tests/testthat/test-req-as-curl.R b/tests/testthat/test-req-as-curl.R index 8dc0089bc..dbf198386 100644 --- a/tests/testthat/test-req-as-curl.R +++ b/tests/testthat/test-req-as-curl.R @@ -288,7 +288,7 @@ test_that("curl_body_data() URL encodes form data", { }) test_that("curl_body_data() translates multipart values", { - path <- tempfile() + path <- file.path(withr::local_tempdir(), "contents") writeLines("contents", path) body <- list( @@ -296,17 +296,10 @@ test_that("curl_body_data() translates multipart values", { file = curl::form_file(path, type = "text/plain", name = "name.txt"), data = curl::form_data("a b", type = "text/plain") ) - expect_equal( - curl_body_data(body, "multipart"), - c( - "--form-string 'text=@literal;value'", - paste0( - "--form 'file=@\"", - body$file$path, - "\";type=text/plain;filename=\"name.txt\"'" - ), - "--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) ) }) From 707849e20c5b5107457d7bd8460a624e7e5d0a06 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 22 Jun 2026 16:38:42 -0500 Subject: [PATCH 29/31] Rename back to `httr2_translate()` --- NAMESPACE | 2 +- NEWS.md | 2 +- R/curl.R | 4 +- R/{req-as-curl.R => httr2-translate.R} | 15 ++-- _pkgdown.yml | 2 +- man/curl_translate.Rd | 4 +- man/{req_as_curl.Rd => httr2_translate.Rd} | 21 +++-- man/req_throttle.Rd | 8 +- .../{req-as-curl.md => httr2-translate.md} | 80 +++++++++--------- ...t-req-as-curl.R => test-httr2-translate.R} | 82 +++++++++---------- 10 files changed, 110 insertions(+), 110 deletions(-) rename R/{req-as-curl.R => httr2-translate.R} (94%) rename man/{req_as_curl.Rd => httr2_translate.Rd} (66%) rename tests/testthat/_snaps/{req-as-curl.md => httr2-translate.md} (68%) rename tests/testthat/{test-req-as-curl.R => test-httr2-translate.R} (83%) diff --git a/NAMESPACE b/NAMESPACE index 2d4d6feac..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) @@ -63,7 +64,6 @@ export(oauth_token) export(oauth_token_cached) export(obfuscate) export(obfuscated) -export(req_as_curl) export(req_auth_aws_v4) export(req_auth_basic) export(req_auth_bearer_token) diff --git a/NEWS.md b/NEWS.md index d2e261f61..ff54681c8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -7,7 +7,7 @@ * `oauth_flow_auth_code()` now correctly uses the same redirect URI for both authorization and token requests when using the default localhost redirect URL (@pedrobtz, #829). * New `oauth_server_metadata()` discovers an OAuth/OpenID Connect issuer's endpoints from its `.well-known` metadata document (#845). * `req_auth_aws_v4()` now correctly signs URLs containing encoded slashes (`%2F`) in path segments, such as ARNs in AWS Bedrock API paths (@thisisnic, #842). -* New `req_as_curl()` translates an httr2 request into an approximate curl command, with safe shell quoting and support for structured and binary bodies, request preparation, and signing. +* New `httr2_translate()` translates an httr2 request into an approximate curl command, with safe shell quoting and support for structured and binary bodies, request preparation, and signing. * `req_body_form()` now creates a valid empty request body when no parameters are provided (@arcresu, #836). * `req_body_form()` and `req_url_query()` no longer error with "C stack usage is too close to the limit" when given very long string values (#805). * `req_cache()` no longer errors when a request is first performed with `path` then later without it (#840). diff --git a/R/curl.R b/R/curl.R index 8bcba7b53..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,7 +24,7 @@ #' was copied from the clipboard, the translation will be copied back #' to the clipboard. #' @export -#' @seealso [req_as_curl()] +#' @seealso [httr2_translate()] #' @examples #' curl_translate("curl http://example.com") #' curl_translate("curl http://example.com -X DELETE") diff --git a/R/req-as-curl.R b/R/httr2-translate.R similarity index 94% rename from R/req-as-curl.R rename to R/httr2-translate.R index de9fca240..3f84f80df 100644 --- a/R/req-as-curl.R +++ b/R/httr2-translate.R @@ -1,30 +1,29 @@ -#' Translate an httr2 request to a curl command +#' Translate a httr2 request to a curl command #' -#' Convert an httr2 request object to an approximate curl command line call. +#' 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, with class `httr2_cmd` so -#' it prints nicely. +#' @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") |> -#' req_as_curl() +#' httr2_translate() #' #' # POST with JSON body #' request("https://httpbin.org/post") |> #' req_body_json(list(name = "value")) |> -#' req_as_curl() +#' httr2_translate() #' #' # Secrets are redacted by default, but can be revealed #' request("https://example.com") |> #' req_headers_redacted(Authorization = "secret") |> -#' req_as_curl(obfuscated = "reveal") -req_as_curl <- function(req, obfuscated = c("redact", "reveal")) { +#' httr2_translate(obfuscated = "reveal") +httr2_translate <- function(req, obfuscated = c("redact", "reveal")) { check_request(req) obfuscated <- arg_match(obfuscated) diff --git a/_pkgdown.yml b/_pkgdown.yml index 79c7abaf1..aaac1698f 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -77,7 +77,7 @@ reference: - title: Miscellaneous helpers contents: - curl_translate - - req_as_curl + - httr2_translate - is_online - title: OAuth diff --git a/man/curl_translate.Rd b/man/curl_translate.Rd index 46ebfc3b9..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) @@ -45,5 +45,5 @@ curl_translate("curl http://example.com --header A:1 --header B:2") curl_translate("curl http://example.com --verbose") } \seealso{ -\code{\link[=req_as_curl]{req_as_curl()}} +\code{\link[=httr2_translate]{httr2_translate()}} } diff --git a/man/req_as_curl.Rd b/man/httr2_translate.Rd similarity index 66% rename from man/req_as_curl.Rd rename to man/httr2_translate.Rd index ed7423379..66dc0812c 100644 --- a/man/req_as_curl.Rd +++ b/man/httr2_translate.Rd @@ -1,10 +1,10 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/req-as-curl.R -\name{req_as_curl} -\alias{req_as_curl} -\title{Translate an httr2 request to a curl command} +% Please edit documentation in R/httr2-translate.R +\name{httr2_translate} +\alias{httr2_translate} +\title{Translate a httr2 request to a curl command} \usage{ -req_as_curl(req, obfuscated = c("redact", "reveal")) +httr2_translate(req, obfuscated = c("redact", "reveal")) } \arguments{ \item{req}{A httr2 \link{request} object.} @@ -14,28 +14,27 @@ This argument control what happens to them: should they be removed, redacted, or revealed.} } \value{ -A string containing the curl command, with class \code{httr2_cmd} so -it prints nicely. +A string containing the curl command. } \description{ -Convert an httr2 request object to an approximate curl command line call. +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") |> - req_as_curl() + httr2_translate() # POST with JSON body request("https://httpbin.org/post") |> req_body_json(list(name = "value")) |> - req_as_curl() + httr2_translate() # Secrets are redacted by default, but can be revealed request("https://example.com") |> req_headers_redacted(Authorization = "secret") |> - req_as_curl(obfuscated = "reveal") + 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/req-as-curl.md b/tests/testthat/_snaps/httr2-translate.md similarity index 68% rename from tests/testthat/_snaps/req-as-curl.md rename to tests/testthat/_snaps/httr2-translate.md index 65f7b3f0c..44a5c7d6e 100644 --- a/tests/testthat/_snaps/req-as-curl.md +++ b/tests/testthat/_snaps/httr2-translate.md @@ -1,26 +1,26 @@ -# req_as_curl() works with basic GET requests +# httr2_translate() works with basic GET requests Code - req_as_curl(request("https://hb.cran.dev/get")) + httr2_translate(request("https://hb.cran.dev/get")) Output curl https://hb.cran.dev/get \ --location \ --user-agent httr2 -# req_as_curl() works with POST methods +# httr2_translate() works with POST methods Code - req_as_curl(req_method(request("https://hb.cran.dev/post"), "POST")) + httr2_translate(req_method(request("https://hb.cran.dev/post"), "POST")) Output curl https://hb.cran.dev/post \ --request POST \ --location \ --user-agent httr2 -# req_as_curl() works with headers +# httr2_translate() works with headers Code - req_as_curl(req_headers(request("https://hb.cran.dev/get"), Accept = "application/json", + 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 \ @@ -29,10 +29,10 @@ --location \ --user-agent httr2 -# req_as_curl() works with JSON bodies +# httr2_translate() works with JSON bodies Code - req_as_curl(req_body_json(request("https://hb.cran.dev/post"), list(name = "test", + httr2_translate(req_body_json(request("https://hb.cran.dev/post"), list(name = "test", value = 123))) Output curl https://hb.cran.dev/post \ @@ -41,10 +41,10 @@ --user-agent httr2 \ --data '{"name":"test","value":123}' -# req_as_curl() works with form bodies +# httr2_translate() works with form bodies Code - req_as_curl(req_body_form(request("https://hb.cran.dev/post"), name = "test", + httr2_translate(req_body_form(request("https://hb.cran.dev/post"), name = "test", value = "123")) Output curl https://hb.cran.dev/post \ @@ -53,10 +53,10 @@ --header 'Content-Type: application/x-www-form-urlencoded' \ --data 'name=test&value=123' -# req_as_curl() works with multipart bodies +# httr2_translate() works with multipart bodies Code - req_as_curl(req_body_multipart(request("https://hb.cran.dev/post"), name = "test", + httr2_translate(req_body_multipart(request("https://hb.cran.dev/post"), name = "test", value = "123")) Output curl https://hb.cran.dev/post \ @@ -65,10 +65,10 @@ --form-string name=test \ --form-string value=123 -# req_as_curl() works with string bodies +# httr2_translate() works with string bodies Code - req_as_curl(req_body_raw(request("https://hb.cran.dev/post"), "test data", + httr2_translate(req_body_raw(request("https://hb.cran.dev/post"), "test data", type = "text/plain")) Output curl https://hb.cran.dev/post \ @@ -77,10 +77,10 @@ --user-agent httr2 \ --data 'test data' -# req_as_curl() works with file bodies +# httr2_translate() works with file bodies Code - req_as_curl(req_body_file(request("https://hb.cran.dev/post"), path, type = "text/plain")) + 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' \ @@ -88,10 +88,10 @@ --user-agent httr2 \ --data-binary @ -# req_as_curl() works with custom content types +# httr2_translate() works with custom content types Code - req_as_curl(req_body_json(request("https://hb.cran.dev/post"), list(test = "data"), + 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 \ @@ -100,10 +100,10 @@ --user-agent httr2 \ --data '{"test":"data"}' -# req_as_curl() works with options +# httr2_translate() works with options Code - req_as_curl(req_options(request("https://hb.cran.dev/get"), verbose = TRUE, + httr2_translate(req_options(request("https://hb.cran.dev/get"), verbose = TRUE, ssl_verifypeer = FALSE)) Condition Warning: @@ -114,10 +114,10 @@ --verbose \ --user-agent httr2 -# req_as_curl() works with cookies +# httr2_translate() works with cookies Code - req_as_curl(req_options(request("https://hb.cran.dev/cookies"), cookiejar = cookie_file, + httr2_translate(req_options(request("https://hb.cran.dev/cookies"), cookiejar = cookie_file, cookiefile = cookie_file)) Output curl https://hb.cran.dev/cookies \ @@ -126,10 +126,10 @@ --cookie \ --user-agent httr2 -# req_as_curl() works with obfuscated values in headers +# httr2_translate() works with obfuscated values in headers Code - req_as_curl(req_headers(request("https://hb.cran.dev/get"), Authorization = obfuscated( + httr2_translate(req_headers(request("https://hb.cran.dev/get"), Authorization = obfuscated( "ZdYJeG8zwISodg0nu4UxBhs"))) Output curl https://hb.cran.dev/get \ @@ -137,10 +137,10 @@ --location \ --user-agent httr2 -# req_as_curl() can reveal obfuscated values +# httr2_translate() can reveal obfuscated values Code - req_as_curl(req_headers_redacted(request("https://hb.cran.dev/get"), + httr2_translate(req_headers_redacted(request("https://hb.cran.dev/get"), Authorization = "secret-token"), obfuscated = "reveal") Output curl https://hb.cran.dev/get \ @@ -148,10 +148,10 @@ --location \ --user-agent httr2 -# req_as_curl() works with obfuscated values in JSON body +# httr2_translate() works with obfuscated values in JSON body Code - req_as_curl(req_body_json(request("https://hb.cran.dev/post"), list(username = "test", + httr2_translate(req_body_json(request("https://hb.cran.dev/post"), list(username = "test", password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs")))) Output curl https://hb.cran.dev/post \ @@ -160,10 +160,10 @@ --user-agent httr2 \ --data '{"username":"test","password":""}' -# req_as_curl() works with obfuscated values in form body +# httr2_translate() works with obfuscated values in form body Code - req_as_curl(req_body_form(request("https://hb.cran.dev/post"), username = "test", + httr2_translate(req_body_form(request("https://hb.cran.dev/post"), username = "test", password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs"))) Output curl https://hb.cran.dev/post \ @@ -172,10 +172,10 @@ --header 'Content-Type: application/x-www-form-urlencoded' \ --data 'username=test&password=%3CREDACTED%3E' -# req_as_curl() works with complex requests +# httr2_translate() works with complex requests Code - req_as_curl(req_body_json(req_headers(req_method(request( + 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))) @@ -189,26 +189,26 @@ --user-agent httr2 \ --data '{"name":"test-repo","description":"A test repository","private":true}' -# req_as_curl() puts a request with no arguments on a single line +# httr2_translate() puts a request with no arguments on a single line Code - req_as_curl(req_options(request("https://hb.cran.dev/get"), followlocation = FALSE)) + httr2_translate(req_options(request("https://hb.cran.dev/get"), followlocation = FALSE)) Output curl https://hb.cran.dev/get \ --user-agent httr2 -# req_as_curl() validates input +# httr2_translate() validates input Code - req_as_curl("not a request") + httr2_translate("not a request") Condition - Error in `req_as_curl()`: + Error in `httr2_translate()`: ! `req` must be an HTTP request object, not the string "not a request". -# req_as_curl() errors for raw bodies +# httr2_translate() errors for raw bodies Code - req_as_curl(req) + httr2_translate(req) Condition Error: ! Can't translate a request with a raw body. @@ -216,7 +216,7 @@ # an explicit Content-Type header isn't duplicated by the body Code - req_as_curl(req_body_raw(req_headers(request("https://hb.cran.dev/post"), + httr2_translate(req_body_raw(req_headers(request("https://hb.cran.dev/post"), `Content-Type` = "application/json"), "{}")) Output curl https://hb.cran.dev/post \ diff --git a/tests/testthat/test-req-as-curl.R b/tests/testthat/test-httr2-translate.R similarity index 83% rename from tests/testthat/test-req-as-curl.R rename to tests/testthat/test-httr2-translate.R index dbf198386..aac8b78cf 100644 --- a/tests/testthat/test-req-as-curl.R +++ b/tests/testthat/test-httr2-translate.R @@ -1,21 +1,21 @@ -test_that("req_as_curl() works with basic GET requests", { +test_that("httr2_translate() works with basic GET requests", { local_mocked_user_agent() expect_snapshot({ request("https://hb.cran.dev/get") |> - req_as_curl() + httr2_translate() }) }) -test_that("req_as_curl() works with POST methods", { +test_that("httr2_translate() works with POST methods", { local_mocked_user_agent() expect_snapshot({ request("https://hb.cran.dev/post") |> req_method("POST") |> - req_as_curl() + httr2_translate() }) }) -test_that("req_as_curl() works with headers", { +test_that("httr2_translate() works with headers", { local_mocked_user_agent() expect_snapshot({ request("https://hb.cran.dev/get") |> @@ -23,47 +23,47 @@ test_that("req_as_curl() works with headers", { "Accept" = "application/json", "User-Agent" = "httr2/1.0" ) |> - req_as_curl() + httr2_translate() }) }) -test_that("req_as_curl() works with JSON bodies", { +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)) |> - req_as_curl() + httr2_translate() }) }) -test_that("req_as_curl() works with form bodies", { +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") |> - req_as_curl() + httr2_translate() }) }) -test_that("req_as_curl() works with multipart bodies", { +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") |> - req_as_curl() + httr2_translate() }) }) -test_that("req_as_curl() works with string bodies", { +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") |> - req_as_curl() + httr2_translate() }) }) -test_that("req_as_curl() works with file bodies", { +test_that("httr2_translate() works with file bodies", { local_mocked_user_agent() path <- tempfile() writeLines("test content", path) @@ -75,7 +75,7 @@ test_that("req_as_curl() works with file bodies", { { request("https://hb.cran.dev/post") |> req_body_file(path, type = "text/plain") |> - req_as_curl() + httr2_translate() }, transform = function(x) { gsub(path, "", x, fixed = TRUE) @@ -83,7 +83,7 @@ test_that("req_as_curl() works with file bodies", { ) }) -test_that("req_as_curl() works with custom content types", { +test_that("httr2_translate() works with custom content types", { local_mocked_user_agent() expect_snapshot({ request("https://hb.cran.dev/post") |> @@ -91,20 +91,20 @@ test_that("req_as_curl() works with custom content types", { list(test = "data"), type = "application/vnd.api+json" ) |> - req_as_curl() + httr2_translate() }) }) -test_that("req_as_curl() works with options", { +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) |> - req_as_curl() + httr2_translate() }) }) -test_that("req_as_curl() works with cookies", { +test_that("httr2_translate() works with cookies", { local_mocked_user_agent() cookie_file <- tempfile() @@ -118,7 +118,7 @@ test_that("req_as_curl() works with cookies", { { request("https://hb.cran.dev/cookies") |> req_options(cookiejar = cookie_file, cookiefile = cookie_file) |> - req_as_curl() + httr2_translate() }, transform = function(x) { gsub(cookie_file, "", x, fixed = TRUE) @@ -126,25 +126,25 @@ test_that("req_as_curl() works with cookies", { ) }) -test_that("req_as_curl() works with obfuscated values in headers", { +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")) |> - req_as_curl() + httr2_translate() }) }) -test_that("req_as_curl() can reveal obfuscated values", { +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") |> - req_as_curl(obfuscated = "reveal") + httr2_translate(obfuscated = "reveal") }) }) -test_that("req_as_curl() works with obfuscated values in JSON body", { +test_that("httr2_translate() works with obfuscated values in JSON body", { local_mocked_user_agent() expect_snapshot({ request("https://hb.cran.dev/post") |> @@ -152,11 +152,11 @@ test_that("req_as_curl() works with obfuscated values in JSON body", { username = "test", password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs") )) |> - req_as_curl() + httr2_translate() }) }) -test_that("req_as_curl() works with obfuscated values in form body", { +test_that("httr2_translate() works with obfuscated values in form body", { local_mocked_user_agent() expect_snapshot({ request("https://hb.cran.dev/post") |> @@ -164,11 +164,11 @@ test_that("req_as_curl() works with obfuscated values in form body", { username = "test", password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs") ) |> - req_as_curl() + httr2_translate() }) }) -test_that("req_as_curl() works with complex requests", { +test_that("httr2_translate() works with complex requests", { local_mocked_user_agent() expect_snapshot({ request("https://api.github.com/user/repos") |> @@ -183,26 +183,26 @@ test_that("req_as_curl() works with complex requests", { description = "A test repository", private = TRUE )) |> - req_as_curl() + httr2_translate() }) }) -test_that("req_as_curl() puts a request with no arguments on a single line", { +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) |> - req_as_curl() + httr2_translate() }) }) -test_that("req_as_curl() validates input", { +test_that("httr2_translate() validates input", { expect_snapshot(error = TRUE, { - req_as_curl("not a request") + httr2_translate("not a request") }) }) -test_that("req_as_curl() signs AWS requests", { +test_that("httr2_translate() signs AWS requests", { req <- request("https://sts.us-east-1.amazonaws.com/") |> req_body_form( Action = "GetCallerIdentity", @@ -213,7 +213,7 @@ test_that("req_as_curl() signs AWS requests", { aws_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" ) - command <- as.character(req_as_curl(req, obfuscated = "reveal")) + command <- as.character(httr2_translate(req, obfuscated = "reveal")) expect_match( command, "Authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/" @@ -225,10 +225,10 @@ test_that("req_as_curl() signs AWS requests", { expect_match(command, "--header 'x-amz-date: [0-9]{8}T[0-9]{6}Z'") }) -test_that("req_as_curl() errors for raw bodies", { +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(req_as_curl(req), error = TRUE) + expect_snapshot(httr2_translate(req), error = TRUE) }) test_that("an explicit Content-Type header isn't duplicated by the body", { @@ -237,7 +237,7 @@ test_that("an explicit Content-Type header isn't duplicated by the body", { request("https://hb.cran.dev/post") |> req_headers("Content-Type" = "application/json") |> req_body_raw("{}") |> - req_as_curl() + httr2_translate() }) }) From 4ced4cb0a26701738bd0ae3d6e5556bc42b5e8c1 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 22 Jun 2026 16:43:27 -0500 Subject: [PATCH 30/31] WTF claude --- tests/testthat/_snaps/httr2-translate.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/testthat/_snaps/httr2-translate.md b/tests/testthat/_snaps/httr2-translate.md index 44a5c7d6e..e3f57e23a 100644 --- a/tests/testthat/_snaps/httr2-translate.md +++ b/tests/testthat/_snaps/httr2-translate.md @@ -151,8 +151,8 @@ # 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")))) + 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' \ From fc968814b2cff1f2c25383325fe73c18baace7ff Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 22 Jun 2026 16:56:27 -0500 Subject: [PATCH 31/31] Better news --- NEWS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index ff54681c8..11ae175d0 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,13 +1,13 @@ # 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). * `oauth_flow_auth_code()` now correctly uses the same redirect URI for both authorization and token requests when using the default localhost redirect URL (@pedrobtz, #829). * New `oauth_server_metadata()` discovers an OAuth/OpenID Connect issuer's endpoints from its `.well-known` metadata document (#845). * `req_auth_aws_v4()` now correctly signs URLs containing encoded slashes (`%2F`) in path segments, such as ARNs in AWS Bedrock API paths (@thisisnic, #842). -* New `httr2_translate()` translates an httr2 request into an approximate curl command, with safe shell quoting and support for structured and binary bodies, request preparation, and signing. * `req_body_form()` now creates a valid empty request body when no parameters are provided (@arcresu, #836). * `req_body_form()` and `req_url_query()` no longer error with "C stack usage is too close to the limit" when given very long string values (#805). * `req_cache()` no longer errors when a request is first performed with `path` then later without it (#840).