diff --git a/DESCRIPTION b/DESCRIPTION index 951ef56..b2bf8b7 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -20,7 +20,8 @@ Imports: jsonlite, purrr, rlang, - stbl + stbl, + vctrs Suggests: covr, testthat (>= 3.0.0) diff --git a/NAMESPACE b/NAMESPACE index 4659127..435c278 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -7,6 +7,7 @@ S3method(resp_parse,httr2_response) S3method(resp_parse,list) export(call_api) export(compact_nested_list) +export(do_if_defined) export(req_perform_opinionated) export(req_prepare) export(resp_parse) diff --git a/R/aaa_shared.R b/R/aaa_shared.R index c6d85d7..1639197 100644 --- a/R/aaa_shared.R +++ b/R/aaa_shared.R @@ -24,6 +24,8 @@ #' @param response_parser A function to parse the server response (`resp`). #' Defaults to [httr2::resp_body_json()], since JSON responses are common. Set #' this to `NULL` to return the raw response from [httr2::req_perform()]. +#' @param response_parser_args An optional list of arguments to pass to the +#' `response_parser` function (in addition to `resp`). #' @param security_fn A function to use to authenticate the request. By default #' (`NULL`), no authentication is performed. #' @param security_args An optional list of arguments to the `security_fn` diff --git a/R/call.R b/R/call.R index 47f433a..1ccf024 100644 --- a/R/call.R +++ b/R/call.R @@ -8,13 +8,6 @@ #' #' @inheritParams .shared-parameters #' @inheritParams req_perform_opinionated -#' @param response_parser_args An optional list of arguments to pass to the -#' `response_parser` function (in addition to `resp`). -#' @param next_req An optional function that takes the previous response -#' (`resp`) to generate the next request in a call to -#' [httr2::req_perform_iterative()]. This function can usually be generated -#' using one of the iteration helpers described in -#' [httr2::iterate_with_offset()]. #' #' @return The response from the API, parsed by the `response_parser`. #' @export @@ -41,7 +34,7 @@ call_api <- function(base_url, method = method, user_agent = user_agent ) - req <- .req_security_apply(req, security_fn, security_args) + req <- do_if_defined(req, security_fn, !!!security_args) resp <- req_perform_opinionated( req, next_req = next_req, @@ -55,12 +48,3 @@ call_api <- function(base_url, ) return(resp) } - -.req_security_apply <- function(req, security_fn, security_args) { - if (length(security_fn)) { - req <- rlang::inject( - security_fn(req, !!!security_args) - ) - } - return(req) -} diff --git a/R/perform.R b/R/perform.R index e8761bd..e3b2b9b 100644 --- a/R/perform.R +++ b/R/perform.R @@ -7,6 +7,11 @@ #' @inheritParams .shared-parameters #' @inheritParams httr2::req_perform_iterative #' @inheritParams rlang::args_dots_empty +#' @param next_req An optional function that takes the previous response +#' (`resp`) to generate the next request in a call to +#' [httr2::req_perform_iterative()]. This function can usually be generated +#' using one of the iteration helpers described in +#' [httr2::iterate_with_offset()]. #' @param max_reqs The maximum number of separate requests to perform. Passed to #' the max_reqs argument of [httr2::req_perform_iterative()] when `next_req` #' is supplied. The default `2` should likely be changed to `Inf` after you diff --git a/R/req.R b/R/req.R index dc08f2c..a50234f 100644 --- a/R/req.R +++ b/R/req.R @@ -23,7 +23,7 @@ req_prepare <- function(base_url, req <- .req_query_flatten(req, query) req <- .req_body_auto(req, body, mime_type) req <- .req_method_apply(req, method) - req <- .req_user_agent_apply(req, user_agent) + req <- httr2::req_user_agent(req, user_agent) return(req) } @@ -46,10 +46,3 @@ req_prepare <- function(base_url, } return(httr2::req_method(req, method)) } - -.req_user_agent_apply <- function(req, user_agent) { - if (length(user_agent)) { - req <- httr2::req_user_agent(req, user_agent) - } - return(req) -} diff --git a/R/resp.R b/R/resp.R index 103f678..0516f16 100644 --- a/R/resp.R +++ b/R/resp.R @@ -48,22 +48,33 @@ resp_parse.default <- function(resp, resp_parse.httr2_response <- function(resp, ..., response_parser = httr2::resp_body_json) { - if (length(response_parser)) { - # Higher-level calls can include !!!'ed arguments. - dots <- rlang::list2(...) - return(rlang::inject(response_parser(resp, !!!dots))) - } - return(resp) + do_if_defined(resp, response_parser, ...) } #' @export resp_parse.list <- function(resp, ..., response_parser = httr2::resp_body_json) { - httr2::resps_data( + resp_parsed <- .resp_parse_impl(resp, response_parser, ...) + .resp_combine(resp_parsed) +} + +.resp_parse_impl <- function(resp, response_parser, ...) { + # httr2::resps_data concatenates raw vectors, which is almost certainly not + # what users would want. For example, images get combined to be on top of one + # another. + lapply( httr2::resps_successes(resp), - resp_data = function(resp) { - resp_parse(resp, response_parser = response_parser, ...) - } + resp_parse, + response_parser = response_parser, + ... ) } + +.resp_combine <- function(resp_parsed) { + purrr::discard(resp_parsed, is.null) + if (inherits(resp_parsed[[1]], "raw")) { + return(resp_parsed) + } + vctrs::list_unchop(resp_parsed) +} diff --git a/R/security.R b/R/security_api_key.R similarity index 100% rename from R/security.R rename to R/security_api_key.R diff --git a/R/utils.R b/R/utils.R index 08d03e2..a5830d7 100644 --- a/R/utils.R +++ b/R/utils.R @@ -62,3 +62,44 @@ url_path_append <- function(url, ...) { path <- gsub("/+", "/", path) return(sub("/$", "", path)) } + +#' Use a provided function +#' +#' When constructing API calls programmatically, you may encounter situations +#' where an upstream task should indicate which function to apply. For example, +#' one endpoint might use a special security function that isn't used by other +#' endpoints. This function exists to make coding such situations easier. +#' +#' @param x An object to potentially modify, such as an [httr2::request()] +#' object. +#' @param fn A function to apply to `x`. If `fn` is `NULL`, `x` is returned +#' unchanged. +#' @param ... Additional arguments to pass to `fn`. +#' +#' @return The object, potentially modified. +#' @export +#' +#' @examples +#' build_api_req <- function(endpoint, security_fn = NULL, ...) { +#' req <- httr2::request("https://example.com") +#' req <- httr2::req_url_path_append(req, endpoint) +#' do_if_defined(req, security_fn, ...) +#' } +#' +#' # Most endpoints of this API do not require authentication. +#' unsecure_req <- build_api_req("unsecure_endpoint") +#' unsecure_req$headers +#' +#' # But one endpoint requires +#' secure_req <- build_api_req( +#' "secure_endpoint", httr2::req_auth_bearer_token, "secret-token" +#' ) +#' secure_req$headers$Authorization +do_if_defined <- function(x, fn = NULL, ...) { + if (is.function(fn)) { + # Higher-level calls can include !!!'ed arguments. + dots <- rlang::list2(...) + x <- rlang::inject(fn(x, !!!dots)) + } + return(x) +} diff --git a/man/do_if_defined.Rd b/man/do_if_defined.Rd new file mode 100644 index 0000000..403ef89 --- /dev/null +++ b/man/do_if_defined.Rd @@ -0,0 +1,43 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.R +\name{do_if_defined} +\alias{do_if_defined} +\title{Use a provided function} +\usage{ +do_if_defined(x, fn = NULL, ...) +} +\arguments{ +\item{x}{An object to potentially modify, such as an \code{\link[httr2:request]{httr2::request()}} +object.} + +\item{fn}{A function to apply to \code{x}. If \code{fn} is \code{NULL}, \code{x} is returned +unchanged.} + +\item{...}{Additional arguments to pass to \code{fn}.} +} +\value{ +The object, potentially modified. +} +\description{ +When constructing API calls programmatically, you may encounter situations +where an upstream task should indicate which function to apply. For example, +one endpoint might use a special security function that isn't used by other +endpoints. This function exists to make coding such situations easier. +} +\examples{ +build_api_req <- function(endpoint, security_fn = NULL, ...) { + req <- httr2::request("https://example.com") + req <- httr2::req_url_path_append(req, endpoint) + do_if_defined(req, security_fn, ...) +} + +# Most endpoints of this API do not require authentication. +unsecure_req <- build_api_req("unsecure_endpoint") +unsecure_req$headers + +# But one endpoint requires +secure_req <- build_api_req( + "secure_endpoint", httr2::req_auth_bearer_token, "secret-token" +) +secure_req$headers$Authorization +} diff --git a/man/dot-security_api_key_cookie.Rd b/man/dot-security_api_key_cookie.Rd index fb6816f..3c361d0 100644 --- a/man/dot-security_api_key_cookie.Rd +++ b/man/dot-security_api_key_cookie.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/security.R +% Please edit documentation in R/security_api_key.R \name{.security_api_key_cookie} \alias{.security_api_key_cookie} \title{Authenticate with an API key in a cookie} diff --git a/man/dot-security_api_key_header.Rd b/man/dot-security_api_key_header.Rd index 1af2cf8..9ba04bd 100644 --- a/man/dot-security_api_key_header.Rd +++ b/man/dot-security_api_key_header.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/security.R +% Please edit documentation in R/security_api_key.R \name{.security_api_key_header} \alias{.security_api_key_header} \title{Authenticate with an API key in the header of the request} diff --git a/man/dot-shared-parameters.Rd b/man/dot-shared-parameters.Rd index ce594e6..f949cd7 100644 --- a/man/dot-shared-parameters.Rd +++ b/man/dot-shared-parameters.Rd @@ -36,6 +36,9 @@ the request.} Defaults to \code{\link[httr2:resp_body_raw]{httr2::resp_body_json()}}, since JSON responses are common. Set this to \code{NULL} to return the raw response from \code{\link[httr2:req_perform]{httr2::req_perform()}}.} +\item{response_parser_args}{An optional list of arguments to pass to the +\code{response_parser} function (in addition to \code{resp}).} + \item{security_fn}{A function to use to authenticate the request. By default (\code{NULL}), no authentication is performed.} diff --git a/man/req_perform_opinionated.Rd b/man/req_perform_opinionated.Rd index cca7792..ea7dcdf 100644 --- a/man/req_perform_opinionated.Rd +++ b/man/req_perform_opinionated.Rd @@ -17,9 +17,11 @@ req_perform_opinionated( \item{...}{These dots are for future extensions and must be empty.} -\item{next_req}{A function that takes the previous response (\code{resp}) and -request (\code{req}) and returns a \link[httr2]{request} for the next page or \code{NULL} if -the iteration should terminate. See below for more details.} +\item{next_req}{An optional function that takes the previous response +(\code{resp}) to generate the next request in a call to +\code{\link[httr2:req_perform_iterative]{httr2::req_perform_iterative()}}. This function can usually be generated +using one of the iteration helpers described in +\code{\link[httr2:iterate_with_offset]{httr2::iterate_with_offset()}}.} \item{max_reqs}{The maximum number of separate requests to perform. Passed to the max_reqs argument of \code{\link[httr2:req_perform_iterative]{httr2::req_perform_iterative()}} when \code{next_req} diff --git a/man/security_api_key.Rd b/man/security_api_key.Rd index f027817..dd20aea 100644 --- a/man/security_api_key.Rd +++ b/man/security_api_key.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/security.R +% Please edit documentation in R/security_api_key.R \name{security_api_key} \alias{security_api_key} \title{Authenticate with an API key} diff --git a/tests/testthat/fixtures/raw_resps.rds b/tests/testthat/fixtures/raw_resps.rds new file mode 100644 index 0000000..0368228 Binary files /dev/null and b/tests/testthat/fixtures/raw_resps.rds differ diff --git a/tests/testthat/test-resp.R b/tests/testthat/test-resp.R index 3834386..57db4d7 100644 --- a/tests/testthat/test-resp.R +++ b/tests/testthat/test-resp.R @@ -62,3 +62,18 @@ test_that("resp_parse parses lists of httr2_responses", { test_result <- resp_parse(mock_response, response_parser = parser) expect_identical(test_result, 1:6) }) + +test_that("resp_parse works for raw results", { + # reqs <- list( + # httr2::request("https://httr2.r-lib.org/logo.png"), + # httr2::request("https://docs.ropensci.org/magick/logo.png") + # ) + # resps <- httr2::req_perform_sequential(reqs) + # saveRDS(resps, testthat::test_path("fixtures", "raw_resps.rds")) + resps <- readRDS(testthat::test_path("fixtures", "raw_resps.rds")) + test_result <- resp_parse( + resps, + response_parser = httr2::resp_body_raw + ) + expect_equal(length(test_result), length(resps)) +}) diff --git a/tests/testthat/test-security.R b/tests/testthat/test-security_api_key.R similarity index 100% rename from tests/testthat/test-security.R rename to tests/testthat/test-security_api_key.R