From aa7ecd148b1fe90827ce1f71a5181af4810cecde Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 28 Sep 2022 11:24:29 -0400 Subject: [PATCH 01/18] Static routes should return a forward if a path isn't found --- R/plumber-static.R | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/R/plumber-static.R b/R/plumber-static.R index 8d8073d5..7fe40a31 100644 --- a/R/plumber-static.R +++ b/R/plumber-static.R @@ -62,9 +62,10 @@ PlumberStatic <- R6Class( path <- httpuv::decodeURIComponent(path) abs.path <- resolve_path(direc, path) if (is.null(abs.path)) { - # TODO: Should this be inherited from a parent router? - val <- private$notFoundHandler(req=req, res=res) - return(val) + # If the file doesn't exist and isn't mounted, + # the 404 handler will be called anyways. + # So, we can always `forward()` here when the file isn't found. + return(forward()) } ext <- tools::file_ext(abs.path) From 90090a5f0d583bf0326e1185795fea2c3802dd4c Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 28 Sep 2022 11:25:30 -0400 Subject: [PATCH 02/18] Add a `routeNotFound()` sentinel value and `is` helper --- R/plumber-step.R | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/R/plumber-step.R b/R/plumber-step.R index 400b4cd0..38b8971c 100644 --- a/R/plumber-step.R +++ b/R/plumber-step.R @@ -20,6 +20,14 @@ resetForward <- function() { exec$forward <- FALSE } +# Handle mounted routes not being found +routeNotFound <- function() { + structure(list(), class = "plumber_route_not_found") +} +isRouteNotFound <- function(x) { + inherits(x, "plumber_route_not_found") +} + #' plumber step R6 class #' @description an object representing a step in the lifecycle of the treatment #' of a request by a plumber router. From b935b56803e1a078596d6103e4f0a5bc3af68e26 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 28 Sep 2022 11:26:35 -0400 Subject: [PATCH 03/18] All mounts will fall through to the next possible route. Need opt out/in --- R/plumber.R | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/R/plumber.R b/R/plumber.R index 03cefd73..4555a7f1 100644 --- a/R/plumber.R +++ b/R/plumber.R @@ -768,12 +768,31 @@ Plumber <- R6Class( resetForward() # TODO: support globbing? + # Keep track of how many mount levels deep we are. + # Can not use a boolean as a nested mount will unwrap before the parent mount is done. + # Using a counter allows us to know when we are at the top level router (0). + if (is.null(req$`_MOUNT_COUNT`)) { + req$`_MOUNT_COUNT` <- 0 + } + if (nchar(path) >= nchar(mountPath) && substr(path, 0, nchar(mountPath)) == mountPath) { - # This is a prefix match or exact match. Let this router handle. + # This is a prefix match or exact match. Let mount attempt handle. + # Mark that the route is within a mount. Allows for the mount to forward instead of 404. + req$`_MOUNT_COUNT` <- req$`_MOUNT_COUNT` + 1 # First trim the prefix off of the PATH_INFO element + curPathInfo <- req$PATH_INFO req$PATH_INFO <- substr(req$PATH_INFO, nchar(mountPath), nchar(req$PATH_INFO)) - return(private$mnts[[mountPath]]$route(req, res)) + ret <- private$mnts[[mountPath]]$route(req, res) + # Undo path info changes and mark that we are no longer mounted + req$PATH_INFO <- curPathInfo + req$`_MOUNT_COUNT` <- req$`_MOUNT_COUNT` - 1 + if (isRouteNotFound(ret)) { + # Forward to the parent router if mounted router can't handle + return(forward()) + } + # Return the regular value from the mounted router + return(ret) } else { return(forward()) } @@ -825,7 +844,15 @@ Plumber <- R6Class( } # Notify that there is no route found - private$notFoundHandler(req = req, res = res) + mount_count <- req$`_MOUNT_COUNT` + if (!is.null(mount_count) && mount_count > 0) { + # If this is a mounted router, we need to forward to the parent router + # This value is used above when retrieving values from a mount + # Do not change this value without updating the recursive mount code above + return(routeNotFound()) + } else { + private$notFoundHandler(req = req, res = res) + } } steps <- append(steps, list(notFoundStep)) From 1c361116350c6f80213936e4a8efe1899efdf5c6 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 29 Sep 2022 09:27:54 -0400 Subject: [PATCH 04/18] Instead of counter, use flag and restore previous value Hides how many mounts are being used --- R/plumber.R | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/R/plumber.R b/R/plumber.R index 4555a7f1..40bc80f0 100644 --- a/R/plumber.R +++ b/R/plumber.R @@ -778,17 +778,24 @@ Plumber <- R6Class( if (nchar(path) >= nchar(mountPath) && substr(path, 0, nchar(mountPath)) == mountPath) { # This is a prefix match or exact match. Let mount attempt handle. - # Mark that the route is within a mount. Allows for the mount to forward instead of 404. - req$`_MOUNT_COUNT` <- req$`_MOUNT_COUNT` + 1 + # Mark that the route is being handled within a mount. + # Allows for the mount to forward to the next mount instead of 404. + prev_mount_status <- req$`_IS_MOUNT` + req$`_IS_MOUNT` <- TRUE + # First trim the prefix off of the PATH_INFO element - curPathInfo <- req$PATH_INFO + cur_path_info <- req$PATH_INFO req$PATH_INFO <- substr(req$PATH_INFO, nchar(mountPath), nchar(req$PATH_INFO)) + + # Handle route ret <- private$mnts[[mountPath]]$route(req, res) - # Undo path info changes and mark that we are no longer mounted - req$PATH_INFO <- curPathInfo - req$`_MOUNT_COUNT` <- req$`_MOUNT_COUNT` - 1 + + # Undo path info and mount status changes + req$PATH_INFO <- cur_path_info + req$`_IS_MOUNT` <- prev_mount_status + if (isRouteNotFound(ret)) { - # Forward to the parent router if mounted router can't handle + # Forward to the parent router if mounted router can't handle route return(forward()) } # Return the regular value from the mounted router @@ -844,8 +851,8 @@ Plumber <- R6Class( } # Notify that there is no route found - mount_count <- req$`_MOUNT_COUNT` - if (!is.null(mount_count) && mount_count > 0) { + is_mount <- req$`_IS_MOUNT` + if (isTRUE(is_mount)) { # If this is a mounted router, we need to forward to the parent router # This value is used above when retrieving values from a mount # Do not change this value without updating the recursive mount code above From 3f1cc4ce39e0f0822056146b2c4c981b023544c7 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 29 Sep 2022 09:28:30 -0400 Subject: [PATCH 05/18] Test mount falling back to parent mount --- tests/testthat/files/router.R | 14 ++++++++++++++ tests/testthat/test-routing.R | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/tests/testthat/files/router.R b/tests/testthat/files/router.R index a52a543f..ab19e5df 100644 --- a/tests/testthat/files/router.R +++ b/tests/testthat/files/router.R @@ -61,3 +61,17 @@ function(res){ function(){ "dual path" } + +#* @plumber +function(pr) { + mnt_1 <- + pr() %>% + pr_get("/hello", function() "say hello") + mnt_2 <- + pr() %>% + pr_get("/world", function() "say hello world") + + pr %>% + pr_mount("/say", mnt_1) %>% + pr_mount("/say/hello", mnt_2) +} diff --git a/tests/testthat/test-routing.R b/tests/testthat/test-routing.R index 224d0845..956f83f7 100644 --- a/tests/testthat/test-routing.R +++ b/tests/testthat/test-routing.R @@ -20,6 +20,12 @@ test_that("Routing to errors and 404s works", { expect_equal(r$route(make_req("GET", "/path1"), res), "dual path") expect_equal(r$route(make_req("GET", "/path2"), res), "dual path") + ## Mounts fall back to parent router when route is not found + # Mount at `/say` with route `/hello` + expect_equal(r$route(make_req("GET", "/say/hello"), res), "say hello") + # Mount at `/say/hello` with route `/world` + expect_equal(r$route(make_req("GET", "/say/hello/world"), res), "say hello world") + expect_equal(errors, 0) expect_equal(notFounds, 0) From a8565e9b843ad2355b8609c6f944300d46465a65 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 29 Sep 2022 09:30:36 -0400 Subject: [PATCH 06/18] Remove mount count setup --- R/plumber.R | 8 -------- 1 file changed, 8 deletions(-) diff --git a/R/plumber.R b/R/plumber.R index 40bc80f0..e7a65d40 100644 --- a/R/plumber.R +++ b/R/plumber.R @@ -767,14 +767,6 @@ Plumber <- R6Class( function(...) { resetForward() # TODO: support globbing? - - # Keep track of how many mount levels deep we are. - # Can not use a boolean as a nested mount will unwrap before the parent mount is done. - # Using a counter allows us to know when we are at the top level router (0). - if (is.null(req$`_MOUNT_COUNT`)) { - req$`_MOUNT_COUNT` <- 0 - } - if (nchar(path) >= nchar(mountPath) && substr(path, 0, nchar(mountPath)) == mountPath) { # This is a prefix match or exact match. Let mount attempt handle. From 72e2e9af37f6189c1e80803341d87e40c6a302b7 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 5 Oct 2022 12:11:51 -0400 Subject: [PATCH 07/18] Move route not found handling to `$serve()` instead of `$route()`; Allows for top router to handle 404s --- R/plumber.R | 111 +++++++++++++++++++++++----------------------------- 1 file changed, 49 insertions(+), 62 deletions(-) diff --git a/R/plumber.R b/R/plumber.R index e7a65d40..0a3632b3 100644 --- a/R/plumber.R +++ b/R/plumber.R @@ -571,6 +571,54 @@ Plumber <- R6Class( private$runHooks("postroute", list(data = hookEnv, req = req, res = res, value = value)) } + # No endpoint could handle this request. 404 + notFoundStep <- function(...) { + + if (isTRUE(getOption("plumber.trailingSlash", FALSE))) { + # Redirect to the slash route, if it exists + path <- req$PATH_INFO + # If the path does not end in a slash, + if (!grepl("/$", path)) { + new_path <- paste0(path, "/") + # and a route with a slash exists... + if (router_has_route(self, new_path, req$REQUEST_METHOD)) { + + # Temp redirect with same REQUEST_METHOD + # Add on the query string manually. They do not auto transfer + # The POST body will be reissued by caller + new_location <- paste0(new_path, req$QUERY_STRING) + res$status <- 307 + res$setHeader( + name = "Location", + value = new_location + ) + res$serializer <- serializer_unboxed_json() + return( + list(message = "307 - Redirecting with trailing slash") + ) + } + } + } + + # No trailing-slash route exists... + # Try allowed verbs + + if (isTRUE(getOption("plumber.methodNotAllowed", TRUE))) { + # Notify about allowed verbs + if (is_405(req$pr, req$PATH_INFO, req$REQUEST_METHOD)) { + res$status <- 405L + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Allow + res$setHeader("Allow", paste(req$verbsAllowed, collapse = ", ")) + res$serializer <- serializer_unboxed_json() + return(list(error = "405 - Method Not Allowed")) + } + } + + # Notify that there is no route found + private$notFoundHandler(req = req, res = res) + } + steps <- append(steps, list(notFoundStep)) + serializeSteps <- function(value, ...) { if ("PlumberResponse" %in% class(value)) { return(res$toResponse()) @@ -615,6 +663,7 @@ Plumber <- R6Class( prerouteStep, routeStep, postrouteStep, + notFoundStep, serializeSteps ) ) @@ -770,11 +819,6 @@ Plumber <- R6Class( if (nchar(path) >= nchar(mountPath) && substr(path, 0, nchar(mountPath)) == mountPath) { # This is a prefix match or exact match. Let mount attempt handle. - # Mark that the route is being handled within a mount. - # Allows for the mount to forward to the next mount instead of 404. - prev_mount_status <- req$`_IS_MOUNT` - req$`_IS_MOUNT` <- TRUE - # First trim the prefix off of the PATH_INFO element cur_path_info <- req$PATH_INFO req$PATH_INFO <- substr(req$PATH_INFO, nchar(mountPath), nchar(req$PATH_INFO)) @@ -784,7 +828,6 @@ Plumber <- R6Class( # Undo path info and mount status changes req$PATH_INFO <- cur_path_info - req$`_IS_MOUNT` <- prev_mount_status if (isRouteNotFound(ret)) { # Forward to the parent router if mounted router can't handle route @@ -799,62 +842,6 @@ Plumber <- R6Class( }) steps <- append(steps, mountSteps) - # No endpoint could handle this request. 404 - notFoundStep <- function(...) { - - if (isTRUE(getOption("plumber.trailingSlash", FALSE))) { - # Redirect to the slash route, if it exists - path <- req$PATH_INFO - # If the path does not end in a slash, - if (!grepl("/$", path)) { - new_path <- paste0(path, "/") - # and a route with a slash exists... - if (router_has_route(req$pr, new_path, req$REQUEST_METHOD)) { - - # Temp redirect with same REQUEST_METHOD - # Add on the query string manually. They do not auto transfer - # The POST body will be reissued by caller - new_location <- paste0(new_path, req$QUERY_STRING) - res$status <- 307 - res$setHeader( - name = "Location", - value = new_location - ) - res$serializer <- serializer_unboxed_json() - return( - list(message = "307 - Redirecting with trailing slash") - ) - } - } - } - - # No trailing-slash route exists... - # Try allowed verbs - - if (isTRUE(getOption("plumber.methodNotAllowed", TRUE))) { - # Notify about allowed verbs - if (is_405(req$pr, req$PATH_INFO, req$REQUEST_METHOD)) { - res$status <- 405L - # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Allow - res$setHeader("Allow", paste(req$verbsAllowed, collapse = ", ")) - res$serializer <- serializer_unboxed_json() - return(list(error = "405 - Method Not Allowed")) - } - } - - # Notify that there is no route found - is_mount <- req$`_IS_MOUNT` - if (isTRUE(is_mount)) { - # If this is a mounted router, we need to forward to the parent router - # This value is used above when retrieving values from a mount - # Do not change this value without updating the recursive mount code above - return(routeNotFound()) - } else { - private$notFoundHandler(req = req, res = res) - } - } - steps <- append(steps, list(notFoundStep)) - errorHandlerStep <- function(error, ...) { private$errorHandler(req, res, error) } From 8a4bf10903898f8db03061154e909f3ea16696e2 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 5 Oct 2022 17:01:00 -0400 Subject: [PATCH 08/18] Add better return value --- R/plumber-step.R | 2 ++ 1 file changed, 2 insertions(+) diff --git a/R/plumber-step.R b/R/plumber-step.R index 38b8971c..57f43776 100644 --- a/R/plumber-step.R +++ b/R/plumber-step.R @@ -11,6 +11,8 @@ forward_class <- "plumber_forward" forward <- function() { exec <- getCurrentExec() exec$forward <- TRUE + # Currently not used. Would prefer this structure in future versions + structure(list(), class = "plumber_forward") } hasForwarded <- function() { getCurrentExec()$forward From 45aca333c69e01f268384607dcf0e526c44a05ed Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 5 Oct 2022 17:01:59 -0400 Subject: [PATCH 09/18] If a route was handled, do not return 404. --- R/plumber.R | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/R/plumber.R b/R/plumber.R index 0a3632b3..f2cacfbf 100644 --- a/R/plumber.R +++ b/R/plumber.R @@ -572,7 +572,13 @@ Plumber <- R6Class( } # No endpoint could handle this request. 404 - notFoundStep <- function(...) { + notFoundStep <- function(value, ...) { + if (!isRouteNotFound(value)) { + # This router handled the request + # Go to the next step + return(value) + } + if (isTRUE(getOption("plumber.trailingSlash", FALSE))) { # Redirect to the slash route, if it exists @@ -617,7 +623,6 @@ Plumber <- R6Class( # Notify that there is no route found private$notFoundHandler(req = req, res = res) } - steps <- append(steps, list(notFoundStep)) serializeSteps <- function(value, ...) { if ("PlumberResponse" %in% class(value)) { From 532d2255c43d8eb76f7922fc1621adce4fe6e743 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 5 Oct 2022 17:05:31 -0400 Subject: [PATCH 10/18] Only add mount steps if they are used. Allow for async mount route handling --- R/plumber.R | 85 ++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 62 insertions(+), 23 deletions(-) diff --git a/R/plumber.R b/R/plumber.R index f2cacfbf..6f8b326b 100644 --- a/R/plumber.R +++ b/R/plumber.R @@ -815,38 +815,77 @@ Plumber <- R6Class( # If we still haven't found a match, check the un-preempt'd endpoints. steps <- append(steps, list(makeHandleStep("__no-preempt__"))) - # We aren't going to serve this endpoint; see if any mounted routers will - mountSteps <- lapply(names(private$mnts), function(mountPath) { - # (make step function) - function(...) { - resetForward() - # TODO: support globbing? - if (nchar(path) >= nchar(mountPath) && substr(path, 0, nchar(mountPath)) == mountPath) { - # This is a prefix match or exact match. Let mount attempt handle. - # First trim the prefix off of the PATH_INFO element - cur_path_info <- req$PATH_INFO - req$PATH_INFO <- substr(req$PATH_INFO, nchar(mountPath), nchar(req$PATH_INFO)) + ## We aren't going to serve this endpoint; see if any mounted routers will - # Handle route - ret <- private$mnts[[mountPath]]$route(req, res) + # Capture the path info so it can be reset before/after executing the mount + curPathInfo <- req$PATH_INFO - # Undo path info and mount status changes - req$PATH_INFO <- cur_path_info + # Dynamically add mount steps to avoid wasting time on mounts that don't match + mountSteps <- list() + Map( + mountPath = names(private$mnts), + mount = private$mnts, + f = function(mountPath, mount) { + # TODO: support globbing? + mountSupportsPath <- + nchar(path) >= nchar(mountPath) && + substr(path, 0, nchar(mountPath)) == mountPath + if (!mountSupportsPath) { + # This router does not support this path + # Do not add to mountSteps + return() + } - if (isRouteNotFound(ret)) { - # Forward to the parent router if mounted router can't handle route - return(forward()) + mountSteps[[length(mountSteps) + 1]] <<- function(...) { + mountExecStep <- function(...) { + resetForward() # Doesn't hurt to reset here + ## First trim the prefix off of the PATH_INFO element + # Reset the path info for each mount + req$PATH_INFO <- curPathInfo + req$PATH_INFO <- substr(req$PATH_INFO, nchar(mountPath), nchar(req$PATH_INFO)) + + # str(list(mountPath = mountPath, curPathInfo = curPathInfo, req_path_info = req$PATH_INFO)) + # Handle mount asynchronously + private$mnts[[mountPath]]$route(req, res) } - # Return the regular value from the mounted router - return(ret) - } else { - return(forward()) + postMountExecStep <- function(mountRes, ...) { + # Don't know what happened in the mount. Reset again. + resetForward() + + # Reset the req path info + req$PATH_INFO <- curPathInfo + + # Only move on to the next mount if a `routeNotFound()` was returned + if (isRouteNotFound(mountRes)) { + # This router did not support this path + # `forward()` to the next router + return(forward()) + } + + # Regular response, return it + return(mountRes) + } + + runSteps( + NULL, + stop, + list( + mountExecStep, + postMountExecStep + ) + ) } } - }) + ) steps <- append(steps, mountSteps) + routeNotFoundStep <- function(...) { + # If we still haven't found a match, return a routeNotFound object + routeNotFound() + } + steps <- append(steps, list(routeNotFoundStep)) + errorHandlerStep <- function(error, ...) { private$errorHandler(req, res, error) } From 2cc08d1da4993ff1d0ecb4bfc7eaa3a0a2455b84 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 5 Oct 2022 17:05:50 -0400 Subject: [PATCH 11/18] Make sure mounts can be async --- tests/testthat/test-async.R | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/tests/testthat/test-async.R b/tests/testthat/test-async.R index 02a3c2e8..ff580f40 100644 --- a/tests/testthat/test-async.R +++ b/tests/testthat/test-async.R @@ -55,9 +55,10 @@ expect_route_async <- function(x) { } async_router <- function() { - "files/async.R" %>% - test_path() %>% - pr() + root <- pr(test_path("files/async.R")) + mnt <- pr(test_path("files/async.R")) + # Mount a async router at /mnt + pr_mount(root, "/mount", mnt) } serve_route <- function(pr, route) { @@ -78,6 +79,14 @@ test_that("sync works", { expect_route_sync() }) +test_that("sync works", { + async_router() %>% + serve_route("/mount/sync") %>% + expect_not_promise() %>% + get_result() %>% + expect_route_sync() +}) + test_that("async works", { async_router() %>% serve_route("/async") %>% @@ -86,6 +95,14 @@ test_that("async works", { expect_route_async() }) +test_that("async works", { + async_router() %>% + serve_route("/mount/async") %>% + expect_promise() %>% + get_result() %>% + expect_route_async() +}) + context("Promise - hooks") From 885c598ab6f8e92eb4f985b1e2c3cee7e6b819ce Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 5 Oct 2022 17:06:35 -0400 Subject: [PATCH 12/18] Update tests to work with new mount fall through logic --- tests/testthat/test-path-subst.R | 22 +++++++++++++--------- tests/testthat/test-plumber.R | 3 ++- tests/testthat/test-routing.R | 16 +++++++++------- tests/testthat/test-static.R | 3 +-- 4 files changed, 25 insertions(+), 19 deletions(-) diff --git a/tests/testthat/test-path-subst.R b/tests/testthat/test-path-subst.R index a0881508..17a2d5e6 100644 --- a/tests/testthat/test-path-subst.R +++ b/tests/testthat/test-path-subst.R @@ -102,15 +102,19 @@ test_that("integration of path parsing works", { expect_equal(r$route(make_req("GET", "/car/ratio/-1.5"), PlumberResponse$new()), -1.5) expect_equal(r$route(make_req("GET", "/car/ratio/-.5"), PlumberResponse$new()), -.5) expect_equal(r$route(make_req("GET", "/car/ratio/.5"), PlumberResponse$new()), .5) - expect_equal(r$route(make_req("GET", "/car/ratio/a"), PlumberResponse$new()), - list(error = "404 - Resource Not Found")) - expect_equal(r$route(make_req("GET", "/car/ratio/"), PlumberResponse$new()), - list(error = "404 - Resource Not Found")) - expect_equal(r$route(make_req("GET", "/car/ratio/."), PlumberResponse$new()), - list(error = "404 - Resource Not Found")) - expect_equal(r$route(make_req("GET", "/car/sold/true"), PlumberResponse$new()), TRUE) - expect_match(r$call(make_req("POST", "/car/sold/true"))$body, - "405 - Method Not Allowed") + + expect_body <- function(method, path, status, body, auto_unbox = TRUE) { + res <- r$call(make_req(method, path)) + expect_equal(res$status, status) + expect_equal( + res$body, + toJSON(body, auto_unbox = auto_unbox) + ) + } + expect_body("GET", "/car/ratio/", 404L, list(error = "404 - Resource Not Found")) + expect_body("GET", "/car/ratio/.", 404L, list(error = "404 - Resource Not Found")) + expect_body("GET", "/car/sold/true", 200L, TRUE, auto_unbox = FALSE) + expect_body("POST", "/car/sold/true", 405L, list(error = "405 - Method Not Allowed")) }) test_that("multiple variations in path works nicely with function args detection", { diff --git a/tests/testthat/test-plumber.R b/tests/testthat/test-plumber.R index f87a71ae..613071fb 100644 --- a/tests/testthat/test-plumber.R +++ b/tests/testthat/test-plumber.R @@ -225,9 +225,10 @@ test_that("mounts work", { pr$mount("/subpath", sub) + # `/` mount should not stop `/nested/path` from being found res <- PlumberResponse$new() pr$route(make_req("GET", "/nested/path"), res) - expect_equal(res$status, 404) + expect_equal(res$status, 200) val <- pr$route(make_req("GET", "/subpath/nested/path", qs="?a=123"), PlumberResponse$new()) expect_equal(val, "123") diff --git a/tests/testthat/test-routing.R b/tests/testthat/test-routing.R index 956f83f7..17a8d393 100644 --- a/tests/testthat/test-routing.R +++ b/tests/testthat/test-routing.R @@ -11,7 +11,7 @@ test_that("Routing to errors and 404s works", { r$setErrorHandler(function(req, res, err){ errors <<- errors + 1; errRes }) r$set404Handler(function(req, res){ notFounds <<- notFounds + 1; notFoundRes }) - res <- PlumberResponse$new() + res <- PlumberResponse$new(serializer = serializer_identity()) expect_equal(r$route(make_req("GET", "/"), res), "first") expect_equal(r$route(make_req("GET", "/abc"), res), "abc get") @@ -29,13 +29,15 @@ test_that("Routing to errors and 404s works", { expect_equal(errors, 0) expect_equal(notFounds, 0) - nf <- r$route(make_req("GET", "/something-crazy"), res) - expect_equal(res$serializer, serializer_json()) - expect_equal(nf, notFoundRes) + res <- PlumberResponse$new(serializer = serializer_identity()) + nf <- r$serve(make_req("GET", "/something-crazy"), res) + expect_equal(res$serializer, serializer_identity()) + expect_equal(nf$body, notFoundRes) expect_equal(notFounds, 1) - er <- r$route(make_req("GET", "/error"), res) - expect_equal(res$serializer, serializer_json()) - expect_equal(er, errRes) + res <- PlumberResponse$new(serializer = serializer_identity()) + er <- r$serve(make_req("GET", "/error"), res) + expect_equal(res$serializer, serializer_identity()) + expect_equal(er$body, errRes) expect_equal(errors, 1) }) diff --git a/tests/testthat/test-static.R b/tests/testthat/test-static.R index d6ac39dd..f2b725d0 100644 --- a/tests/testthat/test-static.R +++ b/tests/testthat/test-static.R @@ -70,8 +70,7 @@ test_that("static binary file is served", { }) test_that("404s are handled", { - res <- PlumberResponse$new() - pr$route(make_req("GET", "/i-dont-exist"), res) + res <- pr$call(make_req("GET", "/i-dont-exist")) expect_equal(res$status, 404) }) From 387a04167009e0388868ec902618a693a6bd64b4 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 5 Oct 2022 17:12:05 -0400 Subject: [PATCH 13/18] Use the `req$pr` instead of `self` in case code is moved else where --- R/plumber.R | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/R/plumber.R b/R/plumber.R index 6f8b326b..cb75d3d6 100644 --- a/R/plumber.R +++ b/R/plumber.R @@ -587,7 +587,7 @@ Plumber <- R6Class( if (!grepl("/$", path)) { new_path <- paste0(path, "/") # and a route with a slash exists... - if (router_has_route(self, new_path, req$REQUEST_METHOD)) { + if (router_has_route(req$pr, new_path, req$REQUEST_METHOD)) { # Temp redirect with same REQUEST_METHOD # Add on the query string manually. They do not auto transfer @@ -608,7 +608,6 @@ Plumber <- R6Class( # No trailing-slash route exists... # Try allowed verbs - if (isTRUE(getOption("plumber.methodNotAllowed", TRUE))) { # Notify about allowed verbs if (is_405(req$pr, req$PATH_INFO, req$REQUEST_METHOD)) { From a70e056734ae7e364a4abdad5710c1581d76681e Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 5 Oct 2022 17:12:59 -0400 Subject: [PATCH 14/18] Revert "Use the `req$pr` instead of `self` in case code is moved else where" This reverts commit 387a04167009e0388868ec902618a693a6bd64b4. --- R/plumber.R | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/R/plumber.R b/R/plumber.R index cb75d3d6..6f8b326b 100644 --- a/R/plumber.R +++ b/R/plumber.R @@ -587,7 +587,7 @@ Plumber <- R6Class( if (!grepl("/$", path)) { new_path <- paste0(path, "/") # and a route with a slash exists... - if (router_has_route(req$pr, new_path, req$REQUEST_METHOD)) { + if (router_has_route(self, new_path, req$REQUEST_METHOD)) { # Temp redirect with same REQUEST_METHOD # Add on the query string manually. They do not auto transfer @@ -608,6 +608,7 @@ Plumber <- R6Class( # No trailing-slash route exists... # Try allowed verbs + if (isTRUE(getOption("plumber.methodNotAllowed", TRUE))) { # Notify about allowed verbs if (is_405(req$pr, req$PATH_INFO, req$REQUEST_METHOD)) { From ab3973ab5dea002fd6a3af991678cb28e6e68ef7 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 6 Oct 2022 13:32:43 -0400 Subject: [PATCH 15/18] Set default 404 handler to `routeNotFound()`; Keep old 404 handler as last resort in `$serve()` --- R/default-handlers.R | 3 +++ R/plumber-static.R | 5 ++--- R/plumber.R | 17 ++++++++++------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/R/default-handlers.R b/R/default-handlers.R index 3ea03b21..11316fff 100644 --- a/R/default-handlers.R +++ b/R/default-handlers.R @@ -1,3 +1,6 @@ +defaultRouteNotFound <- function(...) { + routeNotFound() +} default404Handler <- function(req, res) { res$status <- 404 res$serializer <- serializer_unboxed_json() diff --git a/R/plumber-static.R b/R/plumber-static.R index 7fe40a31..95d2eca2 100644 --- a/R/plumber-static.R +++ b/R/plumber-static.R @@ -62,9 +62,8 @@ PlumberStatic <- R6Class( path <- httpuv::decodeURIComponent(path) abs.path <- resolve_path(direc, path) if (is.null(abs.path)) { - # If the file doesn't exist and isn't mounted, - # the 404 handler will be called anyways. - # So, we can always `forward()` here when the file isn't found. + # If the file doesn't exist, forward on to the router 404 handler + # (Default 404 handler calls `routeNotFound()` which will move on to next mount). return(forward()) } diff --git a/R/plumber.R b/R/plumber.R index 6f8b326b..9d1a71e1 100644 --- a/R/plumber.R +++ b/R/plumber.R @@ -91,7 +91,7 @@ Plumber <- R6Class( # Default parsers to maintain legacy features self$setParsers(c("json", "form", "text", "octet", "multi")) self$setErrorHandler(defaultErrorHandler()) - self$set404Handler(default404Handler) + self$set404Handler(defaultRouteNotFound) # Allows for fall through to next router self$setDocs(TRUE) private$docs_info$has_not_been_set <- TRUE # set to know if `$setDocs()` has been called before `$run()` private$docs_callback <- rlang::missing_arg() @@ -579,7 +579,7 @@ Plumber <- R6Class( return(value) } - + # Try trailing slash route if (isTRUE(getOption("plumber.trailingSlash", FALSE))) { # Redirect to the slash route, if it exists path <- req$PATH_INFO @@ -608,7 +608,6 @@ Plumber <- R6Class( # No trailing-slash route exists... # Try allowed verbs - if (isTRUE(getOption("plumber.methodNotAllowed", TRUE))) { # Notify about allowed verbs if (is_405(req$pr, req$PATH_INFO, req$REQUEST_METHOD)) { @@ -620,8 +619,11 @@ Plumber <- R6Class( } } - # Notify that there is no route found - private$notFoundHandler(req = req, res = res) + # Handle 404 logic + # At this point, `self$route()` has already tried to call `private$notFoundHandler()` + # Calling `private$notFoundHandler()` again is redundant + # Instead, we can execute the default 404 logic + default404Handler(req = req, res = res) } serializeSteps <- function(value, ...) { @@ -881,8 +883,9 @@ Plumber <- R6Class( steps <- append(steps, mountSteps) routeNotFoundStep <- function(...) { - # If we still haven't found a match, return a routeNotFound object - routeNotFound() + # If we still haven't found a match, call 404 method + # Defaults to `defaultRouteNotFound()` + private$notFoundHandler(req, res) } steps <- append(steps, list(routeNotFoundStep)) From f931f9ecb3851e5b614efb50d59ab31e3b79be4b Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 6 Oct 2022 16:04:57 -0400 Subject: [PATCH 16/18] Add `lastChanceRouteNotFound()` method for when mount 404 method is found. In `$serve()`, move `routeNotFoundStep` before `postRouteStep` In `$route()`, if `404` handler has been set, call `lastChanceRouteNotFound()` --- R/default-handlers.R | 76 ++++++++++++++++++++++++++++++++++----- R/plumber.R | 86 ++++++++++++++++---------------------------- 2 files changed, 99 insertions(+), 63 deletions(-) diff --git a/R/default-handlers.R b/R/default-handlers.R index 11316fff..7660f8ff 100644 --- a/R/default-handlers.R +++ b/R/default-handlers.R @@ -1,11 +1,4 @@ -defaultRouteNotFound <- function(...) { - routeNotFound() -} -default404Handler <- function(req, res) { - res$status <- 404 - res$serializer <- serializer_unboxed_json() - list(error="404 - Resource Not Found") -} + defaultErrorHandler <- function(){ function(req, res, err){ @@ -98,3 +91,70 @@ router_has_route <- function(pr, path_to_find, verb_to_find) { # is verb found? verb_to_find %in% verbs_allowed } + + +default307Handler <- function(req, res, location) { + res$status <- 307 + res$setHeader( + name = "Location", + value = location + ) + res$serializer <- serializer_unboxed_json() + + list(message = "307 - Redirecting with trailing slash") +} + +default404Handler <- function(req, res) { + res$status <- 404 + res$serializer <- serializer_unboxed_json() + list(error="404 - Resource Not Found") +} + +default405Handler <- function(req, res) { + res$status <- 405L + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Allow + res$setHeader("Allow", paste(req$verbsAllowed, collapse = ", ")) + res$serializer <- serializer_unboxed_json() + + list(error = "405 - Method Not Allowed") +} + + +# When we want to end route execution and declare the route can not be handled, +# we check for: +# * trailing slash support (`307` redirect) +# * different verb support (`405` method not allowed) +# Then we return a 404 given `handle404(req, res)` (`404` method not found) +lastChanceRouteNotFound <- function(req, res, pr, handle404 = default404Handler) { + + # Try trailing slash route + if (isTRUE(getOption("plumber.trailingSlash", FALSE))) { + # Redirect to the slash route, if it exists + path <- req$PATH_INFO + # If the path does not end in a slash, + if (!grepl("/$", path)) { + new_path <- paste0(path, "/") + # and a route with a slash exists... + if (router_has_route(pr, new_path, req$REQUEST_METHOD)) { + + # Temp redirect with same REQUEST_METHOD + # Add on the query string manually. They do not auto transfer + # The POST body will be reissued by caller + new_location <- paste0(new_path, req$QUERY_STRING) + return(default307Handler(req, res, new_location)) + } + } + } + + # No trailing-slash route exists... + # Try allowed verbs + if (isTRUE(getOption("plumber.methodNotAllowed", TRUE))) { + # Notify about allowed verbs + if (is_405(pr, req$PATH_INFO, req$REQUEST_METHOD)) { + return(default405Handler(req, res)) + } + } + + # Handle 404 logic + handle404(req = req, res = res) +} diff --git a/R/plumber.R b/R/plumber.R index 9d1a71e1..7a7bca53 100644 --- a/R/plumber.R +++ b/R/plumber.R @@ -91,7 +91,7 @@ Plumber <- R6Class( # Default parsers to maintain legacy features self$setParsers(c("json", "form", "text", "octet", "multi")) self$setErrorHandler(defaultErrorHandler()) - self$set404Handler(defaultRouteNotFound) # Allows for fall through to next router + self$set404Handler(NULL) # Allows for fall through to next router self$setDocs(TRUE) private$docs_info$has_not_been_set <- TRUE # set to know if `$setDocs()` has been called before `$run()` private$docs_callback <- rlang::missing_arg() @@ -567,65 +567,30 @@ Plumber <- R6Class( routeStep <- function(...) { self$route(req, res) } - postrouteStep <- function(value, ...) { - private$runHooks("postroute", list(data = hookEnv, req = req, res = res, value = value)) - } - - # No endpoint could handle this request. 404 - notFoundStep <- function(value, ...) { + # No endpoint could handle this request. 307, 405, 404 + routeNotFoundStep <- function(value, ...) { if (!isRouteNotFound(value)) { # This router handled the request # Go to the next step return(value) } - # Try trailing slash route - if (isTRUE(getOption("plumber.trailingSlash", FALSE))) { - # Redirect to the slash route, if it exists - path <- req$PATH_INFO - # If the path does not end in a slash, - if (!grepl("/$", path)) { - new_path <- paste0(path, "/") - # and a route with a slash exists... - if (router_has_route(self, new_path, req$REQUEST_METHOD)) { - - # Temp redirect with same REQUEST_METHOD - # Add on the query string manually. They do not auto transfer - # The POST body will be reissued by caller - new_location <- paste0(new_path, req$QUERY_STRING) - res$status <- 307 - res$setHeader( - name = "Location", - value = new_location - ) - res$serializer <- serializer_unboxed_json() - return( - list(message = "307 - Redirecting with trailing slash") - ) - } - } - } - - # No trailing-slash route exists... - # Try allowed verbs - if (isTRUE(getOption("plumber.methodNotAllowed", TRUE))) { - # Notify about allowed verbs - if (is_405(req$pr, req$PATH_INFO, req$REQUEST_METHOD)) { - res$status <- 405L - # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Allow - res$setHeader("Allow", paste(req$verbsAllowed, collapse = ", ")) - res$serializer <- serializer_unboxed_json() - return(list(error = "405 - Method Not Allowed")) - } - } + # No endpoint could handle this request. + lastChanceRouteNotFound( + req = req, + res = res, + pr = self, + # `$route()` already had a chance to call `private$notFoundHandler()` + # Using `default404Handler()` as `private$notFoundHandler()` would be missing + handle404 = default404Handler + ) + } - # Handle 404 logic - # At this point, `self$route()` has already tried to call `private$notFoundHandler()` - # Calling `private$notFoundHandler()` again is redundant - # Instead, we can execute the default 404 logic - default404Handler(req = req, res = res) + postrouteStep <- function(value, ...) { + private$runHooks("postroute", list(data = hookEnv, req = req, res = res, value = value)) } + serializeSteps <- function(value, ...) { if ("PlumberResponse" %in% class(value)) { return(res$toResponse()) @@ -669,8 +634,8 @@ Plumber <- R6Class( list( prerouteStep, routeStep, + routeNotFoundStep, postrouteStep, - notFoundStep, serializeSteps ) ) @@ -883,9 +848,20 @@ Plumber <- R6Class( steps <- append(steps, mountSteps) routeNotFoundStep <- function(...) { - # If we still haven't found a match, call 404 method - # Defaults to `defaultRouteNotFound()` - private$notFoundHandler(req, res) + if (is.function(private$notFoundHandler)) { + # Terminate the route handling using the local 404 method + return( + lastChanceRouteNotFound( + req = req, + res = res, + pr = self, + handle404 = private$notFoundHandler + ) + ) + } + + # Let the next mount or `$serve()` handle it + return(routeNotFound()) } steps <- append(steps, list(routeNotFoundStep)) From ab497feacfe7758eb4f437776b651ee2d00987dd Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 6 Oct 2022 16:05:11 -0400 Subject: [PATCH 17/18] Wish we had `finally()` support --- R/plumber.R | 1 + 1 file changed, 1 insertion(+) diff --git a/R/plumber.R b/R/plumber.R index 7a7bca53..c4441698 100644 --- a/R/plumber.R +++ b/R/plumber.R @@ -821,6 +821,7 @@ Plumber <- R6Class( resetForward() # Reset the req path info + # TODO-future; this should really be in a `finally()` after `mountExecStep`. req$PATH_INFO <- curPathInfo # Only move on to the next mount if a `routeNotFound()` was returned From 4ce353833c867a394677bb526574e76c208e9ac9 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 6 Oct 2022 16:11:11 -0400 Subject: [PATCH 18/18] Create route_execution.md --- scripts/route_execution.md | 103 +++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 scripts/route_execution.md diff --git a/scripts/route_execution.md b/scripts/route_execution.md new file mode 100644 index 00000000..d7e2bce8 --- /dev/null +++ b/scripts/route_execution.md @@ -0,0 +1,103 @@ +Thoughts for https://github.com/rstudio/plumber/pull/883 + +# Independent questions +- [ ] Should `post**` hooks be run in reverse order? (Like `on.exit(after = TRUE)`) +- [ ] Should we add support for `afterserve` hooks? + * Would call `later::later(function() { private$runHooks("afterserve") })` + + +# TODO for this PR +- [ ] Remove or relocate this file before merging +- [ ] Merge in #882 logic +- [ ] Be comfortable with the breaking changes below + + +# Known breaking changes +* When adding amount, the previous mount will continue to exist +* If a mount can not handle a request, the next mount will be attempted. + * Previously, if the first matching mount could not handle the request, a 404 was returned. + * To restore previous behavior, set `mount$set404Handler(plumber:::default404Handler)` + + +# Design desires +* Remove `forward()` as a global flag. Instead use a sentinal value that can be returned. (E.g. `next()`) +* Registration system for different return status situation. (E.g. 405, 307, 404 handlers) + + +# Adding a mount + +Current `$mount(path=, router=)`: +* If mount path exists, replace it +* If no mount path exists, append mount to end + +Proposed `$mount(path=, router=, ..., after=)` (with fall through): +* Do not unmount existing path locations +* If `after=NULL`, set `after = length(mounts)` +* Append mount at `after` location + +Proposed `$unmount(path=)`: +* Allow for `path=` to be a Plumber router + + +# Route execution + +Updates: + * Use `routeNotFound()` when `forward()` global flag is overloaded by (possibly) multiple mounts + * Move `405`, `307`, `404` logic to `lastChanceRouteNotFound()` instead of end of `$route()` logic + * `$route()`: Call `lastChanceRouteNotFound(handle404 = private$notFoundHandler)` only if no 404 handler has been set + * `$serve()`: Only on root. Call `lastChanceRouteNotFound(handle404 = default404Handler)` as `$route()` had its chance + * Set default 404 handler to `rlang::missing_arg()` by default + * Overwriting this value will allow per-mount control of 404 handling + * If nothing is done, the original `default404Handler()` will be called + + +* `$call(req)` - Hook for {httpuv} + * Set root router at `req$pr` + * Make `res` + * call `serve(req, res) + +* `$serve(res)` + * Run preroute hooks + * Call `$route(req, res)` + * If current value is `routeNotFound()` + * Set value to `lastChanceRouteNotFound(req, res, pr = self, default404Handler)` + * Run postroute hooks + * Serialize content + * Run `preserialize` hooks + * Run serializer code + * Run `postserialize` hooks + * **Proposal: Run `afterserve` hooks?** + * Runs in a later execution. Users have requested to have a "after the route has returned, run this code" hook + +* `$route(req, res)` + * Try to exec route from `__first__` routes + * If found, return result + * Execute each Filter + * Try to exec route from `__no-preempt__` routes + * If found, return result + * Try each mount... + * If mount path matches request path... + * Run `MOUNT$route(req, res)` + * If _value_ is not `routeNotFound()`... + * Return _value_ + * If 404 handler has been set + * (404 Handling can not be `forward()`ed) + * Return `lastChanceRouteNotFound(req, res, pr = self, default404Handler)` + * Return `routeNotFound()` + +* `lastChanceRouteNotFound(req, res, pr, handle404)` + * If in `307` situation + * Return `default307Handle(req, res, location)` + * If in `405` situation + * Return `default405Handle(req, res)` + * Return `handle404(req, res)` + + +## Assertions +* Mount should be able to return own 404 method +* `307` / `405` logic should work within the mount that it is terminating from + * Ex: If in `mnt1`, `mnt2` can not help find more possible routes +* Routes should be able to `forward()` when matched + * Static file mounts use this +* Should be able to mount multiple routers at the same location +* Docs should be the last mount (reverting `after=0` logic for #882)