Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
189 changes: 189 additions & 0 deletions R/httr2-translate.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
#' 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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this branch actually necessary?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch is used so that the output is compatible with curl_translate() if curl is on its own line curl_translate() fails to read the result.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand that but I think if remove the first branch then the results will still be the same.

I'd suggest renaming and refocussing cmd_parts to curl_args (i.e. adding curl and the url at the very end) to make this more clear.

# 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")
}
}
}
3 changes: 2 additions & 1 deletion _pkgdown.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ reference:
- title: Miscellaneous helpers
contents:
- curl_translate
- httr2_translate
- is_online

- title: OAuth
Expand All @@ -88,7 +89,7 @@ reference:
- title: Developer tooling
desc: >
These functions are useful when developing packges that use httr2.

- subtitle: Keeping secrets
contents:
- obfuscate
Expand Down
35 changes: 35 additions & 0 deletions man/httr2_translate.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

135 changes: 135 additions & 0 deletions tests/testthat/_snaps/httr2-translate.md
Original file line number Diff line number Diff line change
@@ -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 \"@<tempfile>\" \\\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 \"<cookie-file>\" \\\n --cookie \"<cookie-file>\" \\\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".

Loading
Loading