Skip to content

Commit 7d1fbcb

Browse files
authored
Implement httr2_translate() (#797)
Fixes #795
1 parent 58a3a80 commit 7d1fbcb

11 files changed

Lines changed: 962 additions & 5 deletions

File tree

NAMESPACE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export(curl_help)
2525
export(curl_translate)
2626
export(example_github_client)
2727
export(example_url)
28+
export(httr2_translate)
2829
export(is_online)
2930
export(iterate_with_cursor)
3031
export(iterate_with_link_url)

NEWS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# httr2 (development version)
22

33
* Mocked and cached responses now include the originating request in `resp$request`, just like real responses (#841).
4+
* New `httr2_translate()` translates an httr2 request into the equivalent curl command (#795).
45
* `last_response_json()` now works with content-types that end with `+json`, e.g. `application/problem+json` (@cgiachalis, #782).
56
* `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).
67
* `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).

R/curl.R

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#' Translate curl syntax to httr2
1+
#' Translate a curl command to a httr2 request
22
#'
33
#' @description
44
#' The curl command line tool is commonly used to demonstrate HTTP APIs and can
@@ -24,6 +24,7 @@
2424
#' was copied from the clipboard, the translation will be copied back
2525
#' to the clipboard.
2626
#' @export
27+
#' @seealso [httr2_translate()]
2728
#' @examples
2829
#' curl_translate("curl http://example.com")
2930
#' curl_translate("curl http://example.com -X DELETE")

R/httr2-translate.R

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

_pkgdown.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ reference:
7777
- title: Miscellaneous helpers
7878
contents:
7979
- curl_translate
80+
- httr2_translate
8081
- is_online
8182

8283
- title: OAuth

man/curl_translate.Rd

Lines changed: 4 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/httr2_translate.Rd

Lines changed: 41 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/req_throttle.Rd

Lines changed: 5 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)