Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Opt in to trailing slash 307 redirect. Opt out of 405 logic. #746

Merged
merged 14 commits into from
Jan 5, 2021
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ Imports:
swagger (>= 3.33.0),
schloerke marked this conversation as resolved.
Show resolved Hide resolved
magrittr,
mime,
lifecycle (>= 0.2.0)
lifecycle (>= 0.2.0),
ellipsis (>= 0.3.0)
LazyData: TRUE
ByteCompile: TRUE
Suggests:
Expand Down Expand Up @@ -67,11 +68,11 @@ Collate:
'new-rstudio-project.R'
'openapi-spec.R'
'openapi-types.R'
'options_plumber.R'
'paths.R'
'plumb-block.R'
'plumb-globals.R'
'plumb.R'
'plumber-options.R'
'plumber-response.R'
'plumber-static.R'
'plumber-step.R'
Expand Down
7 changes: 7 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,18 @@ plumber 1.0.0.9999 Development version

* When plumbing a Plumber file and using a Plumber router modifier (`#* @plumber`), an error will be thrown if the original router is not returned. (#738)

* `options_plumber()` now requires that all options are named. If no option name is provided, an error with be thrown. (#746)

### New features

* Added option `plumber.redirect`. This option (which is **disabled** by default) allows routes to be redirected to route definitions with a trailing slash. For example, if a `GET` request is submitted to `/test?a=1` with no `/test` route is defined, but a `GET` `/test/` route definition does exist, then the original request will respond with a `307` to reattempt against `GET` `/test/?a=1`. This option will be _enabled_ by default in a future release. This logic executed for before calling the `404` handler. (#746)

* Added option `plumber.allowMethods`. This option (which is enabled by default) allows for a status of `405` to be returned if an invalid method is used when requesting a valid route. This logic executed for before calling the default `404` handler. (#746)
schloerke marked this conversation as resolved.
Show resolved Hide resolved

* Guess OpenApi response content type from serializer. (@meztez #684)

* Passing `edit = TRUE` to `plumb_api()` will open the API source file. (#699)

* Allow for spaces in `@apiTag` and `@tag` when tag is surrended by single or double quotes. (#685)

* OpenAPI Specification can be set using a file path. (@meztez #696)
Expand Down
16 changes: 7 additions & 9 deletions R/default-handlers.R
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
default404Handler <- function(req, res){
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 = ", "))
return(list(error = "405 - Method Not Allowed"))
}
default404Handler <- function(req, res) {
res$status <- 404
res$serializer <- serializer_unboxed_json()
list(error="404 - Resource Not Found")
Expand All @@ -16,12 +10,14 @@ defaultErrorHandler <- function(){

li <- list()

# always serialize error with unboxed json
res$serializer <- serializer_unboxed_json()

if (res$status == 200L){
# The default is a 200. If that's still set, then we should probably override with a 500.
# It's possible, however, than a handler set a 40x and then wants to use this function to
# render an error, though.
res$status <- 500
res$serializer <- serializer_unboxed_json()
li$error <- "500 - Internal server error"
} else {
li$error <- "Internal error"
Expand Down Expand Up @@ -87,13 +83,15 @@ is_405 <- function(pr, path_to_find, verb_to_find) {
# nothing found, not 405
if (length(verbs_allowed) == 0) return(FALSE)

# is verb excluded?
!(verb_to_find %in% verbs_allowed)
}
router_has_route <- function(pr, path_to_find, verb_to_find) {
verbs_allowed <- allowed_verbs(pr, path_to_find)

# nothing found, not 405
# nothing found, not a route
if (length(verbs_allowed) == 0) return(FALSE)

# is verb found?
verb_to_find %in% verbs_allowed
}
40 changes: 28 additions & 12 deletions R/plumber-options.R → R/options_plumber.R
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
#' \item{`plumber.docs.callback`}{A function. Called with
#' a single parameter corresponding to the visual documentation url after Plumber server is ready. This can be used
#' by RStudio to open the docs when then API is ran from the editor. Defaults to option `NULL`.}
#' \item{`plumber.redirect`}{Logical value which allows the router to redirect any request
#' that has a matching route with a trailing slash. For example, if set to `TRUE` and the
#' GET route `/test/` existed, then a GET request of `/test?a=1` would redirect to
#' `/test/?a=1`. Defaults to `FALSE`. This option will default to `TRUE` in a future release.}
#' \item{`plumber.allowMethods`}{Logical value which allows the router to notify that an
#' unavailable method was requested, but a different request method is allowed. For example,
#' if set to `TRUE` and the GET route `/test` existed, then a POST request of `/test` would
#' receive a 405 status and the allowed methods. Defaults to `TRUE`.}
schloerke marked this conversation as resolved.
Show resolved Hide resolved
#' \item{`plumber.apiURL`}{Server urls for OpenAPI Specification respecting
#' pattern `scheme://host:port/path`. Other `api*` options will be ignored when set.}
#' \item{`plumber.apiScheme`}{Scheme used to build OpenAPI url and server url for
Expand All @@ -34,16 +42,20 @@
#' by settings this option to `FALSE`. Defaults to `TRUE`}
#' }
#'
#' @param port,docs,docs.callback,apiScheme,apiHost,apiPort,apiPath,apiURL,maxRequestSize,sharedSecret,legacyRedirects See details
#' @param ... Ignored. Should be empty
#' @param port,docs,docs.callback,redirect,allowMethods,apiScheme,apiHost,apiPort,apiPath,apiURL,maxRequestSize,sharedSecret,legacyRedirects See details
schloerke marked this conversation as resolved.
Show resolved Hide resolved
#' @return
#' The complete, prior set of [options()] values.
#' If a particular parameter is not supplied, it will return the current value.
#' If no parameters are supplied, all returned values will be the current [options()] values.
#' @export
options_plumber <- function(
...,
port = getOption("plumber.port"),
docs = getOption("plumber.docs"),
docs.callback = getOption("plumber.docs.callback"),
redirect = getOption("plumber.redirect"),
allowMethods = getOption("plumber.allowMethods"),
schloerke marked this conversation as resolved.
Show resolved Hide resolved
apiURL = getOption("plumber.apiURL"),
apiScheme = getOption("plumber.apiScheme"),
apiHost = getOption("plumber.apiHost"),
Expand All @@ -53,17 +65,21 @@ options_plumber <- function(
sharedSecret = getOption("plumber.sharedSecret"),
legacyRedirects = getOption("plumber.legacyRedirects")
) {
ellipsis::check_dots_empty()

options(
plumber.apiScheme = apiScheme,
plumber.apiHost = apiHost,
plumber.apiPort = apiPort,
plumber.apiPath = apiPath,
plumber.apiURL = apiURL,
plumber.maxRequestSize = maxRequestSize,
plumber.port = port,
plumber.sharedSecret = sharedSecret,
plumber.docs = docs,
plumber.docs.callback = docs.callback,
plumber.legacyRedirects = legacyRedirects
plumber.port = port,
plumber.docs = docs,
plumber.docs.callback = docs.callback,
plumber.redirect = redirect,
plumber.allowMethods = allowMethods,
schloerke marked this conversation as resolved.
Show resolved Hide resolved
plumber.apiURL = apiURL,
plumber.apiScheme = apiScheme,
plumber.apiHost = apiHost,
plumber.apiPort = apiPort,
plumber.apiPath = apiPath,
plumber.maxRequestSize = maxRequestSize,
plumber.sharedSecret = sharedSecret,
plumber.legacyRedirects = legacyRedirects
)
}
42 changes: 42 additions & 0 deletions R/plumber.R
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,48 @@ Plumber <- R6Class(

# No endpoint could handle this request. 404
notFoundStep <- function(...) {

if (isTRUE(getOption("plumber.redirect", 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.allowMethods", 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))
Expand Down
17 changes: 15 additions & 2 deletions man/options_plumber.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions tests/testthat/helper-options.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
with_options <- function(opts, code) {
old_opts <- options(opts)
on.exit({
options(old_opts)
}, add = TRUE)

force(code)
}
40 changes: 20 additions & 20 deletions tests/testthat/test-options.R
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
context("Options")

test_that("Options set and get", {
option_value <- getOption("plumber.port")
on.exit({
options(plumber.port = option_value)
}, add = TRUE)
options_plumber(port = FALSE)
expect_false(getOption("plumber.port"))
options_plumber(port = NULL)
expect_null(getOption("plumber.port"))
with_options(list(plumber.port = NULL), {
options_plumber(port = FALSE)
expect_false(getOption("plumber.port"))
options_plumber(port = NULL)
expect_null(getOption("plumber.port"))
})
})

test_that("all options used are `options_plumber()` parameters", {
Expand Down Expand Up @@ -39,7 +37,12 @@ test_that("all options used are `options_plumber()` parameters", {
deprecated_options <- c("plumber.swagger.url")
plumber_options_used <- plumber_options_used[!(plumber_options_used %in% deprecated_options)]
### code to match formals
options_plumber_formals <- paste0("plumber.", sort(names(formals(options_plumber))))
formals_to_match <-
sort(setdiff(
names(formals(options_plumber)),
"..."
))
options_plumber_formals <- paste0("plumber.", formals_to_match)

expect_equal(
plumber_options_used,
Expand All @@ -49,16 +52,13 @@ test_that("all options used are `options_plumber()` parameters", {


test_that("Legacy swagger redirect can be disabled", {
option_value <- getOption("plumber.legacyRedirects")
on.exit({
options(plumber.legacyRedirects = option_value)
}, add = TRUE)
with_options(list(), {
options_plumber(legacyRedirects = TRUE)
redirects <- swagger_redirects()
expect_gt(length(redirects), 0)

options_plumber(legacyRedirects = TRUE)
redirects <- swagger_redirects()
expect_gt(length(redirects), 0)

options_plumber(legacyRedirects = FALSE)
redirects <- swagger_redirects()
expect_equal(length(redirects), 0)
options_plumber(legacyRedirects = FALSE)
redirects <- swagger_redirects()
expect_equal(length(redirects), 0)
})
})
68 changes: 55 additions & 13 deletions tests/testthat/test-plumber.R
Original file line number Diff line number Diff line change
Expand Up @@ -348,25 +348,67 @@ test_that("invalid hooks err", {
})

test_that("handle invokes correctly", {
pr <- pr()
pr$handle("GET", "/trailslash", function(){ "getter" })
pr$handle("POST", "/trailslashp/", function(){ "poster" })
with_options(
list(plumber.redirect = NULL),
schloerke marked this conversation as resolved.
Show resolved Hide resolved
{
pr <- pr()
pr$handle("GET", "/trailslash", function(){ "getter" })
pr$handle("POST", "/trailslashp/", function(){ "poster" })

expect_equal(pr$call(make_req("GET", "/trailslash"))$body, jsonlite::toJSON("getter"))
res <- pr$call(make_req("GET", "/trailslash/")) # With trailing slash
expect_equal(res$status, 404)
res <- pr$call(make_req("POST", "/trailslash")) # Wrong verb
expect_equal(res$status, 405)

expect_equal(pr$call(make_req("POST", "/trailslashp/"))$body, jsonlite::toJSON("poster"))
res <- pr$call(make_req("POST", "/trailslashp")) # w/o trailing slash
expect_equal(res$status, 404)
res <- pr$call(make_req("GET", "/trailslashp/")) # Wrong verb
expect_equal(res$status, 405)
}
)

expect_equal(pr$call(make_req("GET", "/trailslash"))$body, jsonlite::toJSON("getter"))
res <- pr$call(make_req("GET", "/trailslash/")) # With trailing slash
expect_equal(res$status, 404)
res <- pr$call(make_req("POST", "/trailslash")) # Wrong verb
expect_equal(res$status, 405)
})

expect_equal(pr$call(make_req("POST", "/trailslashp/"))$body, jsonlite::toJSON("poster"))
res <- pr$call(make_req("POST", "/trailslashp")) # w/o trailing slash
expect_equal(res$status, 404)
res <- pr$call(make_req("GET", "/trailslashp/")) # Wrong verb
expect_equal(res$status, 405)
test_that("trailing slashes are redirected", {

pr <- pr() %>%
pr_get("/get/", function(a) a) %>%
pr_post("/post/", function(a) a) %>%
pr_mount(
"/mnt",
pr() %>%
pr_get("/", function(a) a)
)

with_options(list(plumber.redirect = FALSE), {
res <- pr$call(make_req("GET", "/get", "?a=1"))
expect_equal(res$status, 404)

res <- pr$call(make_req("POST", "/post", "?a=1"))
expect_equal(res$status, 404)

res <- pr$call(make_req("GET", "/mnt", "?a=1"))
expect_equal(res$status, 404)
})

with_options(list(plumber.redirect = TRUE), {
schloerke marked this conversation as resolved.
Show resolved Hide resolved
res <- pr$call(make_req("GET", "/get", "?a=1"))
expect_equal(res$status, 307)
expect_equal(res$headers$Location, "/get/?a=1")

res <- pr$call(make_req("POST", "/post", "?a=1"))
expect_equal(res$status, 307)
expect_equal(res$headers$Location, "/post/?a=1")

res <- pr$call(make_req("GET", "/mnt", "?a=1"))
expect_equal(res$status, 307)
expect_equal(res$headers$Location, "/mnt/?a=1")
})
})


test_that("No 405 on same path, different verb", {

pr <- pr()
Expand Down
Loading