diff --git a/DESCRIPTION b/DESCRIPTION index 6207204..30a7c0d 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -37,7 +37,8 @@ Suggests: xml2, vcr, httr, - httr2 + httr2, + diffobj RoxygenNote: 7.3.2 X-schema.org-applicationCategory: Web X-schema.org-keywords: http, https, API, web-services, curl, mock, mocking, fakeweb, http-mocking, testing, testing-tools, tdd diff --git a/NAMESPACE b/NAMESPACE index 1e93f9b..daa9675 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -34,11 +34,14 @@ export(excluding) export(httr2_mock) export(httr_mock) export(including) +export(last_request) +export(last_stub) export(mock_file) export(pluck_body) export(remove_request_stub) export(request_registry) export(request_registry_clear) +export(stub_body_diff) export(stub_registry) export(stub_registry_clear) export(stub_request) @@ -66,5 +69,6 @@ importFrom(magrittr,"%>%") importFrom(rlang,abort) importFrom(rlang,check_installed) importFrom(rlang,is_empty) +importFrom(rlang,is_list) importFrom(rlang,is_na) importFrom(rlang,is_null) diff --git a/R/RequestPattern.R b/R/RequestPattern.R index 5f1a990..0aaeedb 100644 --- a/R/RequestPattern.R +++ b/R/RequestPattern.R @@ -467,6 +467,9 @@ BodyPattern <- R6::R6Class( if (!inherits(pattern, "list")) { return(FALSE) } + if (!rlang::is_list(body)) { + return(FALSE) + } pattern_char <- rapply(pattern, as.character, how = "replace") body_char <- rapply(body, as.character, how = "replace") @@ -496,6 +499,7 @@ BodyPattern <- R6::R6Class( }, body_as_hash = function(body, content_type) { if (inherits(body, "form_file")) body <- unclass(body) + if (is_empty(content_type)) content_type <- "" bctype <- BODY_FORMATS[[content_type]] %||% "" if (bctype == "json") { jsonlite::fromJSON(body, FALSE) diff --git a/R/adapter.R b/R/adapter.R index 584c8f2..a986be6 100644 --- a/R/adapter.R +++ b/R/adapter.R @@ -197,25 +197,29 @@ Adapter <- R6::R6Class("Adapter", } # no stubs found and net connect not allowed - STOP - x <- "Real HTTP connections are disabled.\nUnregistered request:\n " - y <- "\n\nYou can stub this request with the following snippet:\n\n " - z <- "\n\nregistered request stubs:\n\n" - msgx <- paste(x, request_signature$to_s()) + x <- c("Real HTTP connections are disabled.", "!" = "Unregistered request:") + y <- "\nYou can stub this request with the following snippet:\n" + z <- "\nregistered request stubs:\n" + # msgx <- paste(x, request_signature$to_s()) + msgx <- c(x, "i" = request_signature$to_s()) msgy <- "" if (webmockr_conf_env$show_stubbing_instructions) { msgy <- paste(y, private$make_stub_request_code(request_signature)) } + msgz <- "" if (length(webmockr_stub_registry$request_stubs)) { msgz <- paste( z, paste0(vapply(webmockr_stub_registry$request_stubs, function(z) z$to_s(), ""), collapse = "\n ") ) - } else { - msgz <- "" + } + msg_diff <- "" + if (webmockr_conf_env$show_body_diff) { + msg_diff <- private$make_body_diff(request_signature) } ending <- "\n============================================================" - abort(paste0(msgx, msgy, msgz, ending)) + abort(c(msgx, msgy, msgz, msg_diff, ending)) } return(resp) @@ -377,6 +381,23 @@ Adapter <- R6::R6Class("Adapter", } return(response) + }, + + make_body_diff = function(request_signature) { + check_installed("diffobj") + prefix <- "\n\nBody diff:" + stubs <- webmockr_stub_registry$request_stubs + comps <- lapply(stubs, \(stub) { + diffobj::diffObj(stub$body, request_signature$body) + }) + num_diffs <- vapply(comps, \(w) attr(w@diffs, "meta")$diffs[2], 1) + if (length(stubs) > 1) { + num_diffs_msg <- "diffs: >1 stub found, showing diff with least differences" + diff_to_show <- comps[which.min(num_diffs)][[1]] + c(prefix, "i" = num_diffs_msg, as.character(diff_to_show)) + } else { + c(prefix, as.character(comps[[1]])) + } } ) diff --git a/R/last.R b/R/last.R new file mode 100644 index 0000000..8bf8d8d --- /dev/null +++ b/R/last.R @@ -0,0 +1,50 @@ +#' Get the last HTTP request made +#' +#' @export +#' @return `NULL` if no requests registered; otherwise the last +#' registered request made as a `RequestSignature` class +#' @examplesIf interactive() +#' # no requests +#' request_registry_clear() +#' last_request() +#' +#' # a request is found +#' enable() +#' stub_request("head", "https://nytimes.com") +#' library(crul) +#' crul::ok("https://nytimes.com") +#' last_request() +#' +#' # cleanup +#' request_registry_clear() +#' stub_registry_clear() +last_request <- function() { + last(webmockr_request_registry$request_signatures$hash)$sig +} + +#' Get the last stub created +#' +#' @export +#' @return `NULL` if no stubs found; otherwise the last stub created +#' as a `StubbedRequest` class +#' @examplesIf interactive() +#' # no requests +#' stub_registry_clear() +#' last_stub() +#' +#' # a stub is found +#' stub_request("head", "https://nytimes.com") +#' last_stub() +#' +#' stub_request("post", "https://nytimes.com/stories") +#' last_stub() +#' +#' # cleanup +#' stub_registry_clear() +last_stub <- function() { + tmp <- last(webmockr_stub_registry$request_stubs) + if (rlang::is_empty(tmp)) { + return(NULL) + } + tmp +} diff --git a/R/stub_body_diff.R b/R/stub_body_diff.R new file mode 100644 index 0000000..c7ebcd9 --- /dev/null +++ b/R/stub_body_diff.R @@ -0,0 +1,50 @@ +#' Get a diff of a stub request body and a request body from an http request +#' +#' @export +#' @param stub object of class `StubbedRequest`. required. default is to +#' call [last_stub()], which gets the last stub created +#' @param request object of class `RequestSignature`. required. default is to +#' call [last_request()], which gets the last stub created +#' @return object of class `Diff` from the \pkg{diffobj} package +#' @details If either `stub` or `request` are `NULL`, this function will +#' return an error message. You may not intentionally pass in a `NULL`, but +#' the return value of [last_stub()] and [last_request()] when there's +#' nothing found is `NULL`. +#' @examplesIf interactive() +#' # stops with error if no stub and request +#' request_registry_clear() +#' stub_registry_clear() +#' stub_body_diff() +#' +#' # Gives diff when there's a stub and request found - however, no request body +#' stub_request("get", "https://hb.opencpu.org/get") +#' enable() +#' library(crul) +#' HttpClient$new("https://hb.opencpu.org")$get(path = "get") +#' stub_body_diff() +#' +#' # Gives diff when there's a stub and request found - with request body +#' stub_request("post", "https://hb.opencpu.org/post") %>% +#' wi_th(body = list(apple = "green")) +#' enable() +#' library(crul) +#' HttpClient$new("https://hb.opencpu.org")$post( +#' path = "post", body = list(apple = "red")) +#' stub_body_diff() +#' +#' # Gives diff when there's a stub and request found - with request body +#' stub_request("post", "https://hb.opencpu.org/post") %>% +#' wi_th(body = "the quick brown fox") +#' HttpClient$new("https://hb.opencpu.org")$post( +#' path = "post", body = "the quick black fox") +#' stub_body_diff() +stub_body_diff <- function(stub = last_stub(), request = last_request()) { + check_installed("diffobj") + if (is_empty(stub) || is_empty(request)) { + abort(c("`stub` and/or `request` are NULL or otherwise empty", + "see `?stub_body_diff`")) + } + assert(stub, "StubbedRequest") + assert(request, "RequestSignature") + diffobj::diffObj(stub$body, request$body) +} diff --git a/R/webmockr-opts.R b/R/webmockr-opts.R index 92aec8a..0481c3a 100644 --- a/R/webmockr-opts.R +++ b/R/webmockr-opts.R @@ -7,6 +7,8 @@ #' all others are not allowed) #' @param show_stubbing_instructions (logical) Default: `TRUE`. If `FALSE`, #' stubbing instructions are not shown +#' @param show_body_diff (logical) Default: `FALSE`. If `TRUE` show's +#' a diff of the stub's request body and the http request body #' @param uri (character) a URI/URL as a character string - to determine #' whether or not it is allowed #' @@ -35,18 +37,23 @@ #' webmockr_disable_net_connect(allow = "google.com") #' ### is a specific URI allowed? #' webmockr_net_connect_allowed("google.com") +#' +#' # show body diff +#' webmockr_configure(show_body_diff = TRUE) #' } webmockr_configure <- function( allow_net_connect = FALSE, allow_localhost = FALSE, allow = NULL, - show_stubbing_instructions = TRUE) { + show_stubbing_instructions = TRUE, + show_body_diff = FALSE) { opts <- list( allow_net_connect = allow_net_connect, allow_localhost = allow_localhost, allow = allow, - show_stubbing_instructions = show_stubbing_instructions + show_stubbing_instructions = show_stubbing_instructions, + show_body_diff = show_body_diff ) for (i in seq_along(opts)) { assign(names(opts)[i], opts[[i]], envir = webmockr_conf_env) @@ -126,6 +133,8 @@ print.webmockr_config <- function(x, ...) { cat(paste0(" allow: ", x$allow %||% ""), sep = "\n") cat(paste0(" show_stubbing_instructions: ", x$show_stubbing_instructions), sep = "\n") + cat(paste0(" show_body_diff: ", x$show_body_diff), + sep = "\n") } webmockr_conf_env <- new.env() diff --git a/R/webmockr-package.R b/R/webmockr-package.R index 48fdf2b..79be6b6 100644 --- a/R/webmockr-package.R +++ b/R/webmockr-package.R @@ -22,6 +22,6 @@ #' @importFrom fauxpas HTTPRequestTimeout #' @importFrom crul mock #' @importFrom base64enc base64encode -#' @importFrom rlang abort check_installed +#' @importFrom rlang abort check_installed is_list ## usethis namespace: end NULL diff --git a/man/last_request.Rd b/man/last_request.Rd new file mode 100644 index 0000000..ecd346f --- /dev/null +++ b/man/last_request.Rd @@ -0,0 +1,33 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/last.R +\name{last_request} +\alias{last_request} +\title{Get the last HTTP request made} +\usage{ +last_request() +} +\value{ +\code{NULL} if no requests registered; otherwise the last +registered request made as a \code{RequestSignature} class +} +\description{ +Get the last HTTP request made +} +\examples{ +\dontshow{if (interactive()) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf} +# no requests +request_registry_clear() +last_request() + +# a request is found +enable() +stub_request("head", "https://nytimes.com") +library(crul) +crul::ok("https://nytimes.com") +last_request() + +# cleanup +request_registry_clear() +stub_registry_clear() +\dontshow{\}) # examplesIf} +} diff --git a/man/last_stub.Rd b/man/last_stub.Rd new file mode 100644 index 0000000..c097ada --- /dev/null +++ b/man/last_stub.Rd @@ -0,0 +1,32 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/last.R +\name{last_stub} +\alias{last_stub} +\title{Get the last stub created} +\usage{ +last_stub() +} +\value{ +\code{NULL} if no stubs found; otherwise the last stub created +as a \code{StubbedRequest} class +} +\description{ +Get the last stub created +} +\examples{ +\dontshow{if (interactive()) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf} +# no requests +stub_registry_clear() +last_stub() + +# a stub is found +stub_request("head", "https://nytimes.com") +last_stub() + +stub_request("post", "https://nytimes.com/stories") +last_stub() + +# cleanup +stub_registry_clear() +\dontshow{\}) # examplesIf} +} diff --git a/man/stub_body_diff.Rd b/man/stub_body_diff.Rd new file mode 100644 index 0000000..21b0bb8 --- /dev/null +++ b/man/stub_body_diff.Rd @@ -0,0 +1,58 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/stub_body_diff.R +\name{stub_body_diff} +\alias{stub_body_diff} +\title{Get a diff of a stub request body and a request body from an http request} +\usage{ +stub_body_diff(stub = last_stub(), request = last_request()) +} +\arguments{ +\item{stub}{object of class \code{StubbedRequest}. required. default is to +call \code{\link[=last_stub]{last_stub()}}, which gets the last stub created} + +\item{request}{object of class \code{RequestSignature}. required. default is to +call \code{\link[=last_request]{last_request()}}, which gets the last stub created} +} +\value{ +object of class \code{Diff} from the \pkg{diffobj} package +} +\description{ +Get a diff of a stub request body and a request body from an http request +} +\details{ +If either \code{stub} or \code{request} are \code{NULL}, this function will +return an error message. You may not intentionally pass in a \code{NULL}, but +the return value of \code{\link[=last_stub]{last_stub()}} and \code{\link[=last_request]{last_request()}} when there's +nothing found is \code{NULL}. +} +\examples{ +\dontshow{if (interactive()) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf} +# stops with error if no stub and request +request_registry_clear() +stub_registry_clear() +stub_body_diff() + +# Gives diff when there's a stub and request found - however, no request body +stub_request("get", "https://hb.opencpu.org/get") +enable() +library(crul) +HttpClient$new("https://hb.opencpu.org")$get(path = "get") +stub_body_diff() + +# Gives diff when there's a stub and request found - with request body +stub_request("post", "https://hb.opencpu.org/post") \%>\% + wi_th(body = list(apple = "green")) +enable() +library(crul) +HttpClient$new("https://hb.opencpu.org")$post( + path = "post", body = list(apple = "red")) +stub_body_diff() + +# Gives diff when there's a stub and request found - with request body +stub_request("post", "https://hb.opencpu.org/post") \%>\% + wi_th(body = "the quick brown fox") +HttpClient$new("https://hb.opencpu.org")$post( + path = "post", body = "the quick black fox") +stub_body_diff() +\dontshow{\}) # examplesIf} +} diff --git a/man/webmockr_configure.Rd b/man/webmockr_configure.Rd index 621bdce..951e239 100644 --- a/man/webmockr_configure.Rd +++ b/man/webmockr_configure.Rd @@ -13,7 +13,8 @@ webmockr_configure( allow_net_connect = FALSE, allow_localhost = FALSE, allow = NULL, - show_stubbing_instructions = TRUE + show_stubbing_instructions = TRUE, + show_body_diff = FALSE ) webmockr_configure_reset() @@ -37,6 +38,9 @@ all others are not allowed)} \item{show_stubbing_instructions}{(logical) Default: \code{TRUE}. If \code{FALSE}, stubbing instructions are not shown} +\item{show_body_diff}{(logical) Default: \code{FALSE}. If \code{TRUE} show's +a diff of the stub's request body and the http request body} + \item{uri}{(character) a URI/URL as a character string - to determine whether or not it is allowed} } @@ -71,5 +75,8 @@ webmockr_net_connect_allowed() webmockr_disable_net_connect(allow = "google.com") ### is a specific URI allowed? webmockr_net_connect_allowed("google.com") + +# show body diff +webmockr_configure(show_body_diff = TRUE) } } diff --git a/tests/testthat/test-CrulAdapter.R b/tests/testthat/test-CrulAdapter.R index 3baeee4..6df8a7b 100644 --- a/tests/testthat/test-CrulAdapter.R +++ b/tests/testthat/test-CrulAdapter.R @@ -83,7 +83,7 @@ test_that("CrulAdapter works", { unloadNamespace("vcr") expect_error( res$handle_request(crul_obj), - "Real HTTP connections are disabled.\nUnregistered request:\n GET: http://localhost:9000/get\n\nYou can stub this request with the following snippet:\n\n stub_request\\('get', uri = 'http://localhost:9000/get'\\)\n============================================================" + "Real HTTP connections are disabled.\n\033\\[33m!\033\\[39m Unregistered request" ) invisible(stub_request("get", "http://localhost:9000/get")) diff --git a/tests/testthat/test-Httr2Adapter.R b/tests/testthat/test-Httr2Adapter.R index 9289358..b4d394d 100644 --- a/tests/testthat/test-Httr2Adapter.R +++ b/tests/testthat/test-Httr2Adapter.R @@ -116,7 +116,7 @@ test_that("Httr2Adapter works", { unloadNamespace("vcr") expect_error( res$handle_request(httr2_obj), - sprintf("Real HTTP connections are disabled.\nUnregistered request:\n GET: %s", hb("/get")) + "Real HTTP connections are disabled.\n\033\\[33m!\033\\[39m Unregistered request" ) invisible(stub_request("get", hb("/get"))) diff --git a/tests/testthat/test-HttrAdapter.R b/tests/testthat/test-HttrAdapter.R index 025fb4e..d3cb348 100644 --- a/tests/testthat/test-HttrAdapter.R +++ b/tests/testthat/test-HttrAdapter.R @@ -167,7 +167,7 @@ test_that("HttrAdapter works", { unloadNamespace("vcr") expect_error( res$handle_request(httr_obj), - sprintf("Real HTTP connections are disabled.\nUnregistered request:\n GET: %s", hb("/get")) + "Real HTTP connections are disabled.\n\033\\[33m!\033\\[39m Unregistered request" ) invisible(stub_request("get", hb("/get"))) diff --git a/tests/testthat/test-last_request.R b/tests/testthat/test-last_request.R new file mode 100644 index 0000000..fa79a49 --- /dev/null +++ b/tests/testthat/test-last_request.R @@ -0,0 +1,16 @@ +test_that("last_request works when no requests found", { + request_registry_clear() + + expect_null(last_request()) +}) + +test_that("last_request works when requests are found", { + request_registry_clear() + + enable() + stub_request("head", "https://nytimes.com") + crul::ok("https://nytimes.com") + last_request() + + expect_s3_class(last_request(), "RequestSignature") +}) diff --git a/tests/testthat/test-last_stub.R b/tests/testthat/test-last_stub.R new file mode 100644 index 0000000..f988e18 --- /dev/null +++ b/tests/testthat/test-last_stub.R @@ -0,0 +1,13 @@ +test_that("last_stub works when no stubs found", { + stub_registry_clear() + + expect_null(last_stub()) +}) + +test_that("last_stub works when stubs are found", { + stub_registry_clear() + + stub_request("head", "https://nytimes.com") + + expect_s3_class(last_stub(), "StubbedRequest") +}) diff --git a/tests/testthat/test-stub_body_diff.R b/tests/testthat/test-stub_body_diff.R new file mode 100644 index 0000000..6223d27 --- /dev/null +++ b/tests/testthat/test-stub_body_diff.R @@ -0,0 +1,61 @@ +test_that("stub_body_diff throws error when no stubs OR requests found", { + request_registry_clear() + stub_registry_clear() + + expect_error(stub_body_diff()) +}) + +test_that("stub_body_diff throws error when a stub is found but a request is not found", { + request_registry_clear() + stub_registry_clear() + + stub_request("get", "https://hb.opencpu.org/get") + + expect_error(stub_body_diff()) +}) + +test_that("stub_body_diff throws error when no stub is found but a request is found", { + request_registry_clear() + stub_registry_clear() + + crul::ok("https://nytimes.com") + + expect_error(stub_body_diff()) +}) + +test_that("stub_body_diff works when both stub AND request are found, no diff found", { + request_registry_clear() + stub_registry_clear() + + enable() + stub_request("head", "https://nytimes.com") + crul::ok("https://nytimes.com") + + body_diff <- stub_body_diff() + expect_s4_class(body_diff, "Diff") + expect_equal(attr(body_diff@diffs, "meta")$diffs[2], 0) +}) + +### WRITE THE TEST FOR A DIFFERENCE FOND +test_that("stub_body_diff works when both stub AND request are found, & there's a diff", { + request_registry_clear() + stub_registry_clear() + + enable() + stub_request("post", "https://hb.opencpu.org/post") %>% + wi_th(body = list(apple = "green")) + + library(crul) + expect_error( + HttpClient$new("https://hb.opencpu.org")$post( + path = "post", body = list(apple = "red")), + "disabled" + ) + + body_diff <- stub_body_diff() + expect_s4_class(body_diff, "Diff") + expect_gt(attr(body_diff@diffs, "meta")$diffs[2], 0) +}) + +request_registry_clear() +stub_registry_clear() diff --git a/tests/testthat/test-zutils.R b/tests/testthat/test-zutils.R index a9aadf7..bb37dc1 100644 --- a/tests/testthat/test-zutils.R +++ b/tests/testthat/test-zutils.R @@ -114,6 +114,7 @@ test_that("webmockr_allow_net_connect", { context("config options: show_stubbing_instructions") test_that("show_stubbing_instructions", { + enable() x = crul::HttpClient$new("https://hb.opencpu.org/get") # DO show stubbing instructions @@ -128,6 +129,7 @@ test_that("show_stubbing_instructions", { # reset to default webmockr_configure(show_stubbing_instructions = TRUE) + disable() }) context("util fxns: webmockr_configuration") @@ -135,8 +137,8 @@ test_that("webmockr_configuration", { expect_is(webmockr_configuration(), "webmockr_config") expect_named( webmockr_configuration(), - c('show_stubbing_instructions', 'allow', 'allow_net_connect', - 'allow_localhost') + c('show_stubbing_instructions', 'show_body_diff', 'allow', + 'allow_net_connect', 'allow_localhost') ) # errors when an argument passed