diff --git a/NAMESPACE b/NAMESPACE index 7cf5bb43..ef35a344 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -61,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 0f4605ad..8bcba7b5 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/req-as-curl.R b/R/req-as-curl.R new file mode 100644 index 00000000..0c027b52 --- /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/_pkgdown.yml b/_pkgdown.yml index 7ec7f236..1231f0c9 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/man/curl_translate.Rd b/man/curl_translate.Rd index f978854c..46ebfc3b 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/req_as_curl.Rd b/man/req_as_curl.Rd new file mode 100644 index 00000000..006fafcc --- /dev/null +++ b/man/req_as_curl.Rd @@ -0,0 +1,33 @@ +% 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 httr2 request to curl command} +\usage{ +req_as_curl(.req) +} +\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. +} +\seealso{ +\code{\link[=curl_translate]{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() +} +} diff --git a/tests/testthat/_snaps/req-as-curl.md b/tests/testthat/_snaps/req-as-curl.md new file mode 100644 index 00000000..e7350bce --- /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-req-as-curl.R b/tests/testthat/test-req-as-curl.R new file mode 100644 index 00000000..303cae3c --- /dev/null +++ b/tests/testthat/test-req-as-curl.R @@ -0,0 +1,178 @@ +test_that("req_as_curl() works with basic GET requests", { + expect_snapshot({ + request("https://hb.cran.dev/get") |> + req_as_curl() + }) +}) + +test_that("req_as_curl() works with POST methods", { + expect_snapshot({ + request("https://hb.cran.dev/post") |> + req_method("POST") |> + req_as_curl() + }) +}) + +test_that("req_as_curl() works with headers", { + expect_snapshot({ + request("https://hb.cran.dev/get") |> + req_headers( + "Accept" = "application/json", + "User-Agent" = "httr2/1.0" + ) |> + req_as_curl() + }) +}) + +test_that("req_as_curl() works with JSON bodies", { + expect_snapshot({ + request("https://hb.cran.dev/post") |> + req_body_json(list(name = "test", value = 123)) |> + req_as_curl() + }) +}) + +test_that("req_as_curl() works with form bodies", { + expect_snapshot({ + request("https://hb.cran.dev/post") |> + req_body_form(name = "test", value = "123") |> + req_as_curl() + }) +}) + +test_that("req_as_curl() works with multipart bodies", { + expect_snapshot({ + request("https://hb.cran.dev/post") |> + req_body_multipart(name = "test", value = "123") |> + req_as_curl() + }) +}) + +test_that("req_as_curl() works with string bodies", { + expect_snapshot({ + request("https://hb.cran.dev/post") |> + req_body_raw("test data", type = "text/plain") |> + req_as_curl() + }) +}) + +test_that("req_as_curl() works with file bodies", { + path <- tempfile() + writeLines("test content", path) + + # normalize the path + path <- normalizePath(path, winslash = "/") + + expect_snapshot( + { + request("https://hb.cran.dev/post") |> + req_body_file(path, type = "text/plain") |> + req_as_curl() + }, + transform = function(x) { + gsub(path, "", x, fixed = TRUE) + } + ) +}) + +test_that("req_as_curl() works with custom content types", { + expect_snapshot({ + request("https://hb.cran.dev/post") |> + req_body_json( + list(test = "data"), + type = "application/vnd.api+json" + ) |> + req_as_curl() + }) +}) + +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_as_curl() + }) +}) + +test_that("req_as_curl() works with cookies", { + cookie_file <- tempfile() + + # create the tempfile + file.create(cookie_file) + + # normalize the path + cookie_file <- normalizePath(cookie_file, winslash = "/") + + expect_snapshot( + { + request("https://hb.cran.dev/cookies") |> + req_options(cookiejar = cookie_file, cookiefile = cookie_file) |> + req_as_curl() + }, + transform = function(x) { + gsub(cookie_file, "", x, fixed = TRUE) + } + ) +}) + +test_that("req_as_curl() works with obfuscated values in headers", { + expect_snapshot({ + request("https://hb.cran.dev/get") |> + req_headers("Authorization" = obfuscated("ZdYJeG8zwISodg0nu4UxBhs")) |> + req_as_curl() + }) +}) + +test_that("req_as_curl() works with obfuscated values in JSON body", { + expect_snapshot({ + request("https://hb.cran.dev/post") |> + req_body_json(list( + username = "test", + password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs") + )) |> + req_as_curl() + }) +}) + +test_that("req_as_curl() works with obfuscated values in form body", { + expect_snapshot({ + request("https://hb.cran.dev/post") |> + req_body_form( + username = "test", + password = obfuscated("ZdYJeG8zwISodg0nu4UxBhs") + ) |> + req_as_curl() + }) +}) + +test_that("req_as_curl() 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) |> + req_as_curl() + }) +}) + +test_that("req_as_curl() works with simple requests (single line)", { + expect_snapshot({ + request("https://hb.cran.dev/get") |> + req_as_curl() + }) +}) + +test_that("req_as_curl() validates input", { + expect_snapshot(error = TRUE, { + req_as_curl("not a request") + }) +})