Skip to content

Commit 32451b3

Browse files
authored
Clarify body types (#738)
General refactoring of code and tests in prepation for #718. It should now be much clearer what sorts of body types are permissible. Includes a few small improvements to error messages which I noticed while adding additional tests to get to 100% test coverage.
1 parent cbbd151 commit 32451b3

File tree

9 files changed

+263
-174
lines changed

9 files changed

+263
-174
lines changed

NEWS.md

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

3+
* `req_body_json_modify()` can now be used on a request with an empty body.
34
* `resp_timing()` exposes timing information about the request measured by libcurl (@arcresu, #725).
45
* `req_url_query()` now re-calculates n lengths when using `.multi = "explode"` to avoid select/recycling issues (@Kevanness, #719).
56

R/req-body.R

Lines changed: 90 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
#' Adding a body to a request will automatically switch the method to POST.
1212
#'
1313
#' @inheritParams req_perform
14-
#' @param type MIME content type. Will be ignored if you have manually set
15-
#' a `Content-Type` header.
14+
#' @param type MIME content type. The default, `""`, will not emit a
15+
#' `Content-Type` header. Ignored if you have set a `Content-Type` header
16+
#' with [req_headers()].
1617
#' @returns A modified HTTP [request].
1718
#' @examples
1819
#' req <- request(example_url()) |>
@@ -53,36 +54,33 @@ NULL
5354
#' @export
5455
#' @rdname req_body
5556
#' @param body A literal string or raw vector to send as body.
56-
req_body_raw <- function(req, body, type = NULL) {
57+
req_body_raw <- function(req, body, type = "") {
5758
check_request(req)
58-
if (!is.raw(body) && !is_string(body)) {
59+
check_string(type)
60+
61+
if (is.raw(body)) {
62+
req_body(req, data = body, type = "raw", content_type = type)
63+
} else if (is_string(body)) {
64+
req_body(req, data = body, type = "string", content_type = type)
65+
} else {
5966
cli::cli_abort("{.arg body} must be a raw vector or string.")
6067
}
61-
62-
req_body(
63-
req,
64-
data = body,
65-
type = "raw",
66-
content_type = type %||% ""
67-
)
6868
}
6969

7070
#' @export
7171
#' @rdname req_body
7272
#' @param path Path to file to upload.
73-
req_body_file <- function(req, path, type = NULL) {
73+
req_body_file <- function(req, path, type = "") {
7474
check_request(req)
75+
check_string(path)
7576
if (!file.exists(path)) {
76-
cli::cli_abort("{.arg path} ({.path {path}}) does not exist.")
77+
cli::cli_abort("Can't find file {.path {path}}.")
78+
} else if (dir.exists(path)) {
79+
cli::cli_abort("{.arg path} must be a file, not a directory.")
7780
}
81+
check_string(type)
7882

79-
# Need to override default content-type "application/x-www-form-urlencoded"
80-
req_body(
81-
req,
82-
data = new_path(path),
83-
type = "raw-file",
84-
content_type = type %||% ""
85-
)
83+
req_body(req, data = path, type = "file", content_type = type)
8684
}
8785

8886
#' @export
@@ -126,11 +124,11 @@ req_body_json <- function(
126124
#' @rdname req_body
127125
req_body_json_modify <- function(req, ...) {
128126
check_request(req)
129-
if (req$body$type != "json") {
130-
cli::cli_abort("Can only be used after {.fn req_body_json")
127+
if (!req_body_type(req) %in% c("empty", "json")) {
128+
cli::cli_abort("Can only be used after {.fn req_body_json}.")
131129
}
132130

133-
req$body$data <- utils::modifyList(req$body$data, list2(...))
131+
req$body$data <- utils::modifyList(req$body$data %||% list(), list2(...))
134132
req
135133
}
136134

@@ -159,12 +157,7 @@ req_body_form <- function(
159157

160158
dots <- multi_dots(..., .multi = .multi)
161159
data <- modify_list(.req$body$data, !!!dots)
162-
req_body(
163-
.req,
164-
data = data,
165-
type = "form",
166-
content_type = "application/x-www-form-urlencoded"
167-
)
160+
req_body(.req, data = data, type = "form")
168161
}
169162

170163
#' @export
@@ -174,12 +167,7 @@ req_body_multipart <- function(.req, ...) {
174167

175168
data <- modify_list(.req$body$data, ...)
176169
# data must be character, raw, curl::form_file, or curl::form_data
177-
req_body(
178-
.req,
179-
data = data,
180-
type = "multipart",
181-
content_type = NULL
182-
)
170+
req_body(.req, data = data, type = "multipart")
183171
}
184172

185173
# General structure -------------------------------------------------------
@@ -188,10 +176,12 @@ req_body <- function(
188176
req,
189177
data,
190178
type,
191-
content_type,
179+
content_type = NULL,
192180
params = list(),
193181
error_call = parent.frame()
194182
) {
183+
arg_match(type, c("raw", "string", "file", "json", "form", "multipart"))
184+
195185
if (!is.null(req$body) && req$body$type != type) {
196186
cli::cli_abort(
197187
c(
@@ -211,94 +201,83 @@ req_body <- function(
211201
req
212202
}
213203

214-
req_body_info <- function(req) {
215-
if (is.null(req$body)) {
216-
"empty"
217-
} else {
218-
data <- req$body$data
219-
if (is.raw(data)) {
220-
glue("{length(data)} bytes of raw data")
221-
} else if (is_string(data)) {
222-
glue("a string")
223-
} else if (is_path(data)) {
224-
glue("path '{data}'")
225-
} else if (is.list(data)) {
226-
glue("{req$body$type} encoded data")
227-
} else {
228-
"invalid"
229-
}
230-
}
204+
req_body_type <- function(req) {
205+
req$body$type %||% "empty"
231206
}
232207

208+
req_body_info <- function(req) {
209+
switch(
210+
req_body_type(req),
211+
empty = "empty",
212+
raw = glue("a {length(req$body$data)} byte raw vector"),
213+
string = "a string",
214+
file = glue("a path '{req$body$data}'"),
215+
json = "JSON data",
216+
form = "form data",
217+
multipart = "multipart data"
218+
)
219+
}
233220
req_body_get <- function(req) {
234-
if (is.null(req$body)) {
235-
return("")
236-
}
237221
switch(
238-
req$body$type,
222+
req_body_type(req),
223+
empty = NULL,
239224
raw = req$body$data,
240-
form = {
241-
data <- unobfuscate(req$body$data)
242-
url_query_build(data)
243-
},
244-
json = exec(jsonlite::toJSON, req$body$data, !!!req$body$params),
245-
cli::cli_abort("Unsupported request body type {.str {req$body$type}}.")
225+
string = req$body$data,
226+
file = readBin(req$body$data, "raw", n = file.size(req$body$data)),
227+
json = unclass(exec(jsonlite::toJSON, req$body$data, !!!req$body$params)),
228+
form = url_query_build(unobfuscate(req$body$data)),
229+
multipart = {
230+
# This is a bit clumsy because it requires a real request, which is
231+
# currently a bit slow and requires httpuv. But better than nothing.
232+
# Details at https://github.com/jeroen/curl/issues/388
233+
handle <- req_handle(req_body_apply(req))
234+
echo <- curl::curl_echo(handle, progress = FALSE)
235+
rawToChar(echo$body)
236+
}
246237
)
247238
}
248239

249240
req_body_apply <- function(req) {
250-
if (is.null(req$body)) {
251-
return(req)
252-
}
253-
254-
data <- req$body$data
255-
type <- req$body$type
256-
257-
if (type == "raw-file") {
258-
size <- file.info(data)$size
259-
# Only open connection if needed
260-
delayedAssign("con", file(data, "rb"))
261-
262-
req <- req_policies(
263-
req,
264-
done = function() close(con)
265-
)
266-
req <- req_options(
267-
req,
268-
post = TRUE,
269-
readfunction = function(nbytes, ...) readBin(con, "raw", nbytes),
270-
seekfunction = function(offset, ...) seek(con, where = offset),
271-
postfieldsize_large = size
272-
)
273-
} else if (type == "raw") {
274-
req <- req_body_apply_raw(req, data)
275-
} else if (type == "json") {
276-
req <- req_body_apply_raw(req, req_body_get(req))
277-
} else if (type == "multipart") {
278-
data <- unobfuscate(data)
279-
req$fields <- data
280-
} else if (type == "form") {
281-
req <- req_body_apply_raw(req, req_body_get(req))
282-
} else {
283-
cli::cli_abort("Unsupported request body {.arg type}.", .internal = TRUE)
284-
}
241+
req <- switch(
242+
req_body_type(req),
243+
empty = req,
244+
raw = req_body_apply_raw(req, req$body$data),
245+
string = req_body_apply_string(req, req$body$data),
246+
file = req_body_apply_connection(req, req$body$data),
247+
json = req_body_apply_string(req, req_body_get(req)),
248+
form = req_body_apply_string(req, req_body_get(req)),
249+
multipart = req_body_apply_multipart(req, req$body$data),
250+
)
285251

286-
# Respect existing Content-Type if set
287-
type_idx <- match("content-type", tolower(names(req$headers)))
288-
if (!is.na(type_idx)) {
289-
content_type <- req$headers[[type_idx]]
290-
req$headers <- req$headers[-type_idx]
291-
} else {
292-
content_type <- req$body$content_type
252+
# Set Content-Type if not already set
253+
if (!is.null(req$body$content_type) && is.null(req$headers$`Content-Type`)) {
254+
req <- req_headers(req, `Content-Type` = req$body$content_type)
293255
}
294-
req <- req_headers(req, `Content-Type` = content_type)
295256

296257
req
297258
}
259+
req_body_apply_raw <- function(req, data) {
260+
req_options(req, post = TRUE, postfieldsize = length(data), postfields = data)
261+
}
262+
req_body_apply_string <- function(req, data) {
263+
req_body_apply_raw(req, charToRaw(enc2utf8(data)))
264+
}
265+
req_body_apply_connection <- function(req, data) {
266+
size <- file.info(data)$size
267+
# Only open connection if needed
268+
delayedAssign("con", file(data, "rb"))
298269

299-
req_body_apply_raw <- function(req, body) {
300-
if (is_string(body)) {
301-
body <- charToRaw(enc2utf8(body))
302-
}
303-
req_options(req, post = TRUE, postfieldsize = length(body), postfields = body)
270+
req <- req_policies(req, done = function() close(con))
271+
req <- req_options(
272+
req,
273+
post = TRUE,
274+
readfunction = function(nbytes, ...) readBin(con, "raw", nbytes),
275+
seekfunction = function(offset, ...) seek(con, where = offset),
276+
postfieldsize_large = size
277+
)
278+
req
279+
}
280+
req_body_apply_multipart <- function(req, data) {
281+
req$fields <- unobfuscate(req$body$data)
282+
req
304283
}

R/req-dry-run.R

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ req_dry_run <- function(
8282
invisible(list(
8383
method = resp$method,
8484
path = resp$path,
85+
body = resp$body,
8586
headers = as.list(resp$headers)
8687
))
8788
}

man/req_body.Rd

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

tests/testthat/_snaps/req-body.md

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,37 @@
1-
# errors if file doesn't exist
1+
# can't change body type
2+
3+
Code
4+
req %>% req_body_json(list(x = 1))
5+
Condition
6+
Error in `req_body_json()`:
7+
! Can't change body type from raw to json.
8+
i You must use only one type of `req_body_*()` per request.
9+
10+
# can't send anything else
211

312
Code
4-
req_body_file(request_test(), "doesntexist", type = "text/plain")
13+
req_body_raw(req, 1)
14+
Condition
15+
Error in `req_body_raw()`:
16+
! `body` must be a raw vector or string.
17+
18+
# errors on invalid input
19+
20+
Code
21+
req_body_file(request_test(), 1)
522
Condition
623
Error in `req_body_file()`:
7-
! `path` ('doesntexist') does not exist.
24+
! `path` must be a single string, not the number 1.
25+
Code
26+
req_body_file(request_test(), "doesntexist")
27+
Condition
28+
Error in `req_body_file()`:
29+
! Can't find file 'doesntexist'.
30+
Code
31+
req_body_file(request_test(), ".")
32+
Condition
33+
Error in `req_body_file()`:
34+
! `path` must be a file, not a directory.
835

936
# non-json type errors
1037

@@ -15,12 +42,26 @@
1542
! Unexpected content type "application/xml".
1643
* Expecting type "application/json" or suffix "json".
1744

18-
# can't change body type
45+
# can't modify non-json data
1946

2047
Code
21-
req %>% req_body_json(list(x = 1))
48+
req_body_json_modify(req, a = 1)
2249
Condition
23-
Error in `req_body_json()`:
24-
! Can't change body type from raw to json.
25-
i You must use only one type of `req_body_*()` per request.
50+
Error in `req_body_json_modify()`:
51+
! Can only be used after `req_body_json()`.
52+
53+
# can send named elements as multipart
54+
55+
Code
56+
cat(req_body_get(req))
57+
Output
58+
---{id}
59+
Content-Disposition: form-data; name="a"
60+
61+
1
62+
---{id}
63+
Content-Disposition: form-data; name="b"
64+
65+
2
66+
---{id}
2667

0 commit comments

Comments
 (0)