diff --git a/R/aaa-shared_params.R b/R/aaa-shared_params.R index f8f16ce..579ab85 100644 --- a/R/aaa-shared_params.R +++ b/R/aaa-shared_params.R @@ -29,15 +29,20 @@ #' @param check_type (`length-1 logical`) Whether to check that the response has #' the expected content type. Set to `FALSE` if the response is not #' specifically tagged as the proper type. +#' @param cookie (`list` or `NULL`) An optional list of cookies to set on the +#' request using [httr2::req_cookies_set()]. `NULL` elements are removed. #' @param existing_user_agent (`length-1 character`, optional) An existing user #' agent, such as the value of `req$options$useragent` in a [httr2::request()] #' object. +#' @param header (`list` or `NULL`) An optional list of headers to add to the +#' request using [httr2::req_headers()]. A `NULL` value for an individual +#' header will explicitly remove that header if it was previously set. +#' @param location (`length-1 character`) Where the API key should be passed. +#' One of `"header"` (default), `"query"`, or `"cookie"`. #' @param method (`length-1 character`, optional) If the method is something #' other than `GET` or `POST`, supply it. Case is ignored. #' @param mime_type (`length-1 character`) The mime type of any files present in #' the body. Some APIs allow you to leave this as NULL for them to guess. -#' @param location (`length-1 character`) Where the API key should be passed. -#' One of `"header"` (default), `"query"`, or `"cookie"`. #' @param name (`length-1 character`) The name of a package or other thing to #' add to or remove from the user agent string. #' @param pagination_fn (`function`) A function that takes the previous response diff --git a/R/req_modify.R b/R/req_modify.R index 693706e..8c43afc 100644 --- a/R/req_modify.R +++ b/R/req_modify.R @@ -22,6 +22,8 @@ req_modify <- function( body = NULL, mime_type = NULL, method = NULL, + header = NULL, + cookie = NULL, call = rlang::caller_env() ) { rlang::check_dots_empty() @@ -29,6 +31,10 @@ req_modify <- function( req <- .req_query_flatten(req, query) req <- .req_body_auto(req, body, mime_type, call = call) req <- .req_method_apply(req, method, call = call) + if (length(header)) { + req <- rlang::inject(httr2::req_headers(req, !!!header)) + } + req <- .req_cookies_flatten(req, cookie) return(.as_nectar_request(req)) } @@ -57,6 +63,16 @@ req_modify <- function( rlang::inject(httr2::req_url_query(req, !!!query)) } +#' Add non-empty cookie elements to a request +#' +#' @inheritParams .shared-params +#' @inherit .shared-request return +#' @keywords internal +.req_cookies_flatten <- function(req, cookie) { + cookie <- purrr::discard(cookie, is.null) + rlang::inject(httr2::req_cookies_set(req, !!!cookie)) +} + #' Add a method if it is supplied #' #' [httr2::req_method()] errors if `method` is `NULL`, rather than using the diff --git a/R/req_prepare.R b/R/req_prepare.R index f00991e..a12d894 100644 --- a/R/req_prepare.R +++ b/R/req_prepare.R @@ -25,6 +25,8 @@ req_prepare <- function( body = NULL, mime_type = NULL, method = NULL, + header = NULL, + cookie = NULL, additional_user_agent = NULL, auth = NULL, tidy_policy = tidy_policy_body_auto(), @@ -44,6 +46,8 @@ req_prepare <- function( body = body, mime_type = mime_type, method = method, + header = header, + cookie = cookie, call = call ) auth <- .as_nectar_auth(auth, call = call) diff --git a/man/dot-req_cookies_flatten.Rd b/man/dot-req_cookies_flatten.Rd new file mode 100644 index 0000000..4871a8f --- /dev/null +++ b/man/dot-req_cookies_flatten.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/req_modify.R +\name{.req_cookies_flatten} +\alias{.req_cookies_flatten} +\title{Add non-empty cookie elements to a request} +\usage{ +.req_cookies_flatten(req, cookie) +} +\arguments{ +\item{req}{(\code{httr2_request}) A \code{\link[httr2:request]{httr2::request()}} object.} + +\item{cookie}{(\code{list} or \code{NULL}) An optional list of cookies to set on the +request using \code{\link[httr2:req_cookies_set]{httr2::req_cookies_set()}}. \code{NULL} elements are removed.} +} +\value{ +A \code{\link[httr2:request]{httr2::request()}} object with additional class \code{nectar_request}. +} +\description{ +Add non-empty cookie elements to a request +} +\keyword{internal} diff --git a/man/dot-shared-params.Rd b/man/dot-shared-params.Rd index c709c06..7275819 100644 --- a/man/dot-shared-params.Rd +++ b/man/dot-shared-params.Rd @@ -39,19 +39,26 @@ other functions.} the expected content type. Set to \code{FALSE} if the response is not specifically tagged as the proper type.} +\item{cookie}{(\code{list} or \code{NULL}) An optional list of cookies to set on the +request using \code{\link[httr2:req_cookies_set]{httr2::req_cookies_set()}}. \code{NULL} elements are removed.} + \item{existing_user_agent}{(\verb{length-1 character}, optional) An existing user agent, such as the value of \code{req$options$useragent} in a \code{\link[httr2:request]{httr2::request()}} object.} +\item{header}{(\code{list} or \code{NULL}) An optional list of headers to add to the +request using \code{\link[httr2:req_headers]{httr2::req_headers()}}. A \code{NULL} value for an individual +header will explicitly remove that header if it was previously set.} + +\item{location}{(\verb{length-1 character}) Where the API key should be passed. +One of \code{"header"} (default), \code{"query"}, or \code{"cookie"}.} + \item{method}{(\verb{length-1 character}, optional) If the method is something other than \code{GET} or \code{POST}, supply it. Case is ignored.} \item{mime_type}{(\verb{length-1 character}) The mime type of any files present in the body. Some APIs allow you to leave this as NULL for them to guess.} -\item{location}{(\verb{length-1 character}) Where the API key should be passed. -One of \code{"header"} (default), \code{"query"}, or \code{"cookie"}.} - \item{name}{(\verb{length-1 character}) The name of a package or other thing to add to or remove from the user agent string.} diff --git a/man/req_modify.Rd b/man/req_modify.Rd index 5d0ebde..713eb25 100644 --- a/man/req_modify.Rd +++ b/man/req_modify.Rd @@ -12,6 +12,8 @@ req_modify( body = NULL, mime_type = NULL, method = NULL, + header = NULL, + cookie = NULL, call = rlang::caller_env() ) } @@ -40,6 +42,13 @@ the body. Some APIs allow you to leave this as NULL for them to guess.} \item{method}{(\verb{length-1 character}, optional) If the method is something other than \code{GET} or \code{POST}, supply it. Case is ignored.} +\item{header}{(\code{list} or \code{NULL}) An optional list of headers to add to the +request using \code{\link[httr2:req_headers]{httr2::req_headers()}}. A \code{NULL} value for an individual +header will explicitly remove that header if it was previously set.} + +\item{cookie}{(\code{list} or \code{NULL}) An optional list of cookies to set on the +request using \code{\link[httr2:req_cookies_set]{httr2::req_cookies_set()}}. \code{NULL} elements are removed.} + \item{call}{(\code{environment}) The environment from which a function was called, e.g. \code{\link[rlang:caller_env]{rlang::caller_env()}} (the default). The environment will be mentioned in error messages as the source of the error. This argument is particularly diff --git a/man/req_prepare.Rd b/man/req_prepare.Rd index 4631de1..569f493 100644 --- a/man/req_prepare.Rd +++ b/man/req_prepare.Rd @@ -12,6 +12,8 @@ req_prepare( body = NULL, mime_type = NULL, method = NULL, + header = NULL, + cookie = NULL, additional_user_agent = NULL, auth = NULL, tidy_policy = tidy_policy_body_auto(), @@ -46,6 +48,13 @@ the body. Some APIs allow you to leave this as NULL for them to guess.} \item{method}{(\verb{length-1 character}, optional) If the method is something other than \code{GET} or \code{POST}, supply it. Case is ignored.} +\item{header}{(\code{list} or \code{NULL}) An optional list of headers to add to the +request using \code{\link[httr2:req_headers]{httr2::req_headers()}}. A \code{NULL} value for an individual +header will explicitly remove that header if it was previously set.} + +\item{cookie}{(\code{list} or \code{NULL}) An optional list of cookies to set on the +request using \code{\link[httr2:req_cookies_set]{httr2::req_cookies_set()}}. \code{NULL} elements are removed.} + \item{additional_user_agent}{(\verb{length-1 character}) A string to identify where a request is coming from. We automatically include information about your package and nectar, but use this to provide additional details. diff --git a/tests/testthat/_snaps/req_modify.md b/tests/testthat/_snaps/req_modify.md new file mode 100644 index 0000000..1b857aa --- /dev/null +++ b/tests/testthat/_snaps/req_modify.md @@ -0,0 +1,25 @@ +# req_modify() handles bodies with paths + + Code + test_result <- req_modify(req_base, body = list(foo = "bar", baz = fs::path( + test_path("fixtures", "img-test.png")))) + test_result$body + Output + $data + $data$foo + Form data of length 5 (type: application/json) + + $data$baz + Form file: img-test.png + + + $type + [1] "multipart" + + $content_type + NULL + + $params + list() + + diff --git a/tests/testthat/_snaps/req_prepare.md b/tests/testthat/_snaps/req_prepare.md index 3c81f53..20fc196 100644 --- a/tests/testthat/_snaps/req_prepare.md +++ b/tests/testthat/_snaps/req_prepare.md @@ -23,6 +23,28 @@ list() +# req_prepare() errors for unsupported tidy policy objects (#86) + + Code + (expect_pkg_error_classes(req_prepare(base_url = "https://example.com", + tidy_policy = "not_tidy_policy"), "nectar", "unsupported_tidy_policy_class")) + Output + + Error: + ! `not_tidy_policy` must be `NULL` or a . + x `not_tidy_policy` is a string. + +# req_prepare() errors for unsupported auth objects (#81) + + Code + (expect_pkg_error_classes(req_prepare(base_url = "https://example.com", auth = "not_auth"), + "nectar", "unsupported_auth_class")) + Output + + Error: + ! `not_auth` must be `NULL` or a . + x `not_auth` is a string. + # .as_nectar_request() fails gracefully for non-reqs Code diff --git a/tests/testthat/test-req_modify.R b/tests/testthat/test-req_modify.R new file mode 100644 index 0000000..4d7b919 --- /dev/null +++ b/tests/testthat/test-req_modify.R @@ -0,0 +1,195 @@ +# req_modify() ----------------------------------------------------------------- + +test_that("req_modify() returns an unmodified request when no args are given", { + req_base <- req_init("https://example.com") + test_result <- req_modify(req_base) + expect_s3_class(test_result, "nectar_request") + expect_identical(test_result$url, "https://example.com/") +}) + +test_that("req_modify() errors for unexpected dots", { + req_base <- req_init("https://example.com") + expect_error( + { + req_modify(req_base, unexpected = "arg") + }, + class = "rlib_error_dots_nonempty" + ) +}) + +test_that("req_modify() deals with paths", { + req_base <- req_init("https://example.com") + test_result <- req_modify(req_base, path = "foo/bar") + expect_identical(test_result$url, "https://example.com/foo/bar") + + test_result <- req_modify( + req_base, + path = list("foo/{bar}", bar = "baz") + ) + expect_identical(test_result$url, "https://example.com/foo/baz") +}) + +test_that("req_modify() uses query parameters", { + req_base <- req_init("https://example.com") + test_result <- req_modify( + req_base, + query = list(foo = "bar", baz = "qux") + ) + expect_identical( + url_normalize(test_result$url), + "https://example.com/?foo=bar&baz=qux" + ) +}) + +test_that("req_modify() uses the .multi arg", { + req_base <- req_init("https://example.com") + test_result <- req_modify( + req_base, + query = list( + foo = "bar", + baz = c("qux", "quux"), + .multi = "comma" + ) + ) + expect_identical( + url_normalize(test_result$url), + "https://example.com/?foo=bar&baz=qux%2Cquux" + ) +}) + +test_that("req_modify() removes empty query parameters", { + req_base <- req_init("https://example.com") + test_result <- req_modify( + req_base, + query = list(foo = NULL, bar = "baz") + ) + expect_identical( + url_normalize(test_result$url), + "https://example.com/?bar=baz" + ) +}) + +test_that("req_modify() uses body parameters", { + req_base <- req_init("https://example.com") + test_result <- req_modify( + req_base, + body = list(foo = "bar", baz = "qux") + ) + expect_identical( + test_result$body$data, + list(foo = "bar", baz = "qux") + ) +}) + +test_that("req_modify() handles bodies with paths", { + req_base <- req_init("https://example.com") + expect_snapshot({ + test_result <- req_modify( + req_base, + body = list( + foo = "bar", + baz = fs::path(test_path("fixtures", "img-test.png")) + ) + ) + test_result$body + }) + expect_identical(test_result$body$type, "multipart") +}) + +test_that("req_modify() applies methods", { + req_base <- req_init("https://example.com") + test_result <- req_modify(req_base, method = "PATCH") + expect_identical(test_result$method, "PATCH") + + test_result <- req_modify(req_base) + expect_null(test_result$method) + + test_result <- req_modify(req_base, body = list(a = 1)) + expect_null(test_result$method) +}) + +test_that("req_modify() applies headers (#92)", { + req_base <- req_init("https://example.com") + test_result <- req_modify( + req_base, + header = list( + `X-Custom-Header` = "value1", + `X-Another-Header` = "value2" + ) + ) + expect_in("X-Custom-Header", names(test_result$headers)) + expect_in("X-Another-Header", names(test_result$headers)) + expect_identical(test_result$headers[["X-Custom-Header"]], "value1") + expect_identical(test_result$headers[["X-Another-Header"]], "value2") +}) + +test_that("req_modify() uses NULL headers to remove previously-set headers (#92)", { + req_base <- httr2::req_headers( + req_init("https://example.com"), + `X-Remove-Me` = "old-value" + ) + test_result <- req_modify( + req_base, + header = list( + `X-Custom-Header` = "value1", + `X-Remove-Me` = NULL + ) + ) + expect_in("X-Custom-Header", names(test_result$headers)) + expect_false("X-Remove-Me" %in% names(test_result$headers)) +}) + +test_that("req_modify() applies cookies (#92)", { + req_base <- req_init("https://example.com") + test_result <- req_modify( + req_base, + cookie = list(session_id = "abc123", user_pref = "dark_mode") + ) + expect_true(grepl("session_id=abc123", test_result$options$cookie)) + expect_true(grepl("user_pref=dark_mode", test_result$options$cookie)) +}) + +test_that("req_modify() removes NULL cookies (#92)", { + req_base <- req_init("https://example.com") + test_result <- req_modify( + req_base, + cookie = list(session_id = "abc123", empty_cookie = NULL) + ) + expect_true(grepl("session_id=abc123", test_result$options$cookie)) + expect_false(grepl("empty_cookie", test_result$options$cookie)) +}) + +# .prepare_body() -------------------------------------------------------------- + +test_that(".prepare_body() returns empty list for NULL body", { + result <- .prepare_body(NULL) + expect_length(result, 0) +}) + +test_that(".prepare_body() assigns json class for non-path body", { + result <- .prepare_body(list(foo = "bar")) + expect_s3_class(result, "json") +}) + +test_that(".prepare_body() assigns multipart class when body contains fs_path", { + result <- .prepare_body( + list( + foo = "bar", + baz = fs::path(test_path("fixtures", "img-test.png")) + ) + ) + expect_s3_class(result, "multipart") +}) + +# .prepare_body_part() --------------------------------------------------------- + +test_that(".prepare_body_part() wraps fs_path as form_file", { + path <- fs::path(test_path("fixtures", "img-test.png")) + result <- .prepare_body_part(path) + expect_s3_class(result, "form_file") +}) + +test_that(".prepare_body_part() wraps non-path as form_data", { + result <- .prepare_body_part(list(x = 1)) + expect_s3_class(result, "form_data") +}) diff --git a/tests/testthat/test-req_prepare.R b/tests/testthat/test-req_prepare.R index b319b1f..c29c14a 100644 --- a/tests/testthat/test-req_prepare.R +++ b/tests/testthat/test-req_prepare.R @@ -156,12 +156,12 @@ test_that("req_prepare() applies prepared tidying (#86)", { }) test_that("req_prepare() errors for unsupported tidy policy objects (#86)", { - expect_error( + expect_nectar_error_snapshot( req_prepare( base_url = "https://example.com", tidy_policy = "not_tidy_policy" ), - class = "nectar-error-unsupported_tidy_policy_class" + "unsupported_tidy_policy_class" ) }) @@ -176,13 +176,63 @@ test_that("req_prepare() applies prepared auth (#81)", { ) }) +test_that("req_prepare() applies headers (#92)", { + test_result <- req_prepare( + base_url = "https://example.com", + header = list( + `X-Custom-Header` = "value1", + `X-Another-Header` = "value2" + ) + ) + expect_in("X-Custom-Header", names(test_result$headers)) + expect_in("X-Another-Header", names(test_result$headers)) + expect_identical(test_result$headers[["X-Custom-Header"]], "value1") + expect_identical(test_result$headers[["X-Another-Header"]], "value2") +}) + +test_that("req_prepare() uses NULL headers to remove previously-set headers (#92)", { + test_result <- req_prepare( + base_url = "https://example.com", + header = list( + `X-Custom-Header` = "value1", + `X-Null-Header` = NULL + ) + ) + expect_in("X-Custom-Header", names(test_result$headers)) + expect_false("X-Null-Header" %in% names(test_result$headers)) +}) + +test_that("req_prepare() applies cookies (#92)", { + test_result <- req_prepare( + base_url = "https://example.com", + cookie = list( + session_id = "abc123", + user_pref = "dark_mode" + ) + ) + expect_true(grepl("session_id=abc123", test_result$options$cookie)) + expect_true(grepl("user_pref=dark_mode", test_result$options$cookie)) +}) + +test_that("req_prepare() removes NULL cookies (#92)", { + test_result <- req_prepare( + base_url = "https://example.com", + cookie = list( + session_id = "abc123", + empty_cookie = NULL + ) + ) + expect_true(grepl("session_id=abc123", test_result$options$cookie)) + expect_false(grepl("empty_cookie", test_result$options$cookie)) +}) + test_that("req_prepare() errors for unsupported auth objects (#81)", { - expect_error( + expect_nectar_error_snapshot( req_prepare( base_url = "https://example.com", auth = "not_auth" ), - class = "nectar-error-unsupported_auth_class" + "unsupported_auth_class" ) }) diff --git a/tests/testthat/test-resp_body_csv.R b/tests/testthat/test-resp_body_csv.R index 86c1155..4a9a2b1 100644 --- a/tests/testthat/test-resp_body_csv.R +++ b/tests/testthat/test-resp_body_csv.R @@ -16,7 +16,8 @@ test_that("resp_body_csv fails gracefully for bad data (#40)", { ) expect_error( resp_body_csv(resp), - "Unexpected content type" + "Unexpected content type", + class = "rlang_error" ) }) @@ -38,6 +39,7 @@ test_that("resp_body_tsv fails gracefully for bad data (#40)", { ) expect_error( resp_body_tsv(resp), - "Unexpected content type" + "Unexpected content type", + class = "rlang_error" ) })