R/route.R

#' Single route dispatch
#'
#' @description
#' The `Route` class is used to encapsulate a single URL dispatch, that is,
#' chose a single handler from a range based on a URL path. A handler will be
#' called with a request, response, and keys argument as well as any additional
#' arguments passed on to `dispatch()`.
#'
#' # Method matching
#' A handler is referencing a specific HTTP method (`get`, `post`, etc.) but can
#' also reference `all` to indicate that it should match all types of requests.
#' Handlers referencing `all` have lower precedence than those referencing
#' specific methods, so will only be called if a match is not found within the
#' handlers of the specific method.
#'
#' # Path matching
#' The path will be stripped the query string prior to handler lookup. routr is
#' using the waysign package to match URL paths to the path pattern
#' provided along with the handler. A path pattern consists of zero or more
#' elements separated by `/`, each element can be one of three basic types:
#'
#' * **Fixed:** Is a string literal that will be matched exactly. The pattern
#' `/user/thomas` consists of two fixed elements and will only ever be matched
#' exactly to `/user/thomas`
#' * **Parameterized:** Is a variable that can take the value of any string
#' (not including a `/`). A parameter consist of a `:` followed by a name (made
#' up of alphanumeric characters). The patter `/user/:id` consist of a literal
#' and a parameter and will match to e.g. `/user/thomas` and `user/hana`, but
#' not `user/thomas/settings`. A parameter doesn't have to take up all of an
#' element, it can be a mix of literal and one or more parameters, e.g.
#' `/posts/date-:year-:month-:day` will match to `posts/date-2025-11-05`. If you
#' want to add an alphanumeric literal end to a parameterized element you can
#' separate it by `\\` like `/posts/:title\\post` which will match
#' `/posts/hello_worldpost`. A parameter can be made optional by terminating it
#' with `?`. `/user/:id?` will match both `/user/thomas` and `/user/`. While
#' optional parameters are most useful in the end of a path they can also be in
#' the middle of a pattern, e.g. `/user/:id?/settings` which will match
#' `/user/thomas/settings` and `/user//settings` (note the double slashes)
#' * **Wildcards:** Is like parameters except they can take up multiple
#' elements (i.e. the match to strings that contain `/`). The come in two
#' flavors: one-or-more and zero-or-more. The first uses a `+` and the latter a
#' `*`. These can and should be named be prepending a parameter name to the
#' operator (e.g. `:name+`). The pattern `/user/:id+` will match `/user/thomas`
#' and `user/thomas/settings`, the pattern `user/:id*` will match those two as
#' well and additionally match `/user/`.
#'
#' The syntax allows for multiple patterns matching to the same string, e.g.
#' `/posts/:date`, `/posts/:day-:month-:year`, and `/posts/:remainder+` all
#' matches to `/posts/03-09-2024`. waysign resolves this by always matching to
#' the most specific pattern. Literals are more specific than parameters which
#' are more specific than wildcards. Further, a element consisting of multiple
#' parameters are considered more specific than one consisting of fewer.
#'
#' # Handler calling
#' Handlers are only called for their side-effects and are expected to return
#' either `TRUE` or `FALSE` indicating whether additional routes in a
#' [`RouteStack`] should be called, e.g. if a handler is returning `FALSE` all
#' further processing of the request will be terminated and the response will be
#' passed along in its current state. Thus, the intend of the handlers is to
#' modify the request and response objects, in place.
#'
#' When the handler is called it will be passed in a [request][reqres::Request]
#' object to the `request` argument, a [response][reqres::Response] object to
#' the `response` argument and a list to the `keys` argument. The names of the
#' elements in the `keys` list will match those given in the pattern (excluding
#' the `:`) and the value will be the part it matched to. If wildcards are
#' unnamed they will be named after their index and type, e.g.
#' `/path/+/and/some/more/*` will automatically name the two wildcards `+1` and
#' `*2`. To avoid ambiguity and errors it is recommended to explicitly name
#' wildcards if you intend to use their value for anything. In addition to
#' `request`, `response`, and `keys` any argument passed to the `...` in
#' the `dispatch()` method is also passed into the handler.
#'
#' @usage NULL
#' @format NULL
#'
#' @section Initialization:
#' A new 'Route'-object is initialized using the \code{new()} method on the
#' generator or alternatively by using [route()]:
#'
#' \strong{Usage}
#' \tabular{l}{
#'  \code{route <- Route$new(...)}
#' }
#' \tabular{l}{
#'  \code{route <- route(...)}
#' }
#'
#' @importFrom R6 R6Class
#' @importFrom reqres maybe_request
#' @importFrom stringi stri_match_first
#'
#' @export
#' @rdname Route-class
#'
#' @seealso [RouteStack] for binding multiple routes sequentially
#'
#' @examples
#' # Initialise an empty route
#' route <- Route$new()
#'
#' # Initialise a route with handlers assigned
#' route <- Route$new(
#'   all = list(
#'     '/*' = function(request, response, keys, ...) {
#'       message('Request received')
#'       TRUE
#'     }
#'   )
#' )
#'
#' # Remove it again
#' route$remove_handler('all', '/*')
#'
Route <- R6Class(
  'Route',
  public = list(
    # Methods
    #' @description Create a new Route
    #' @param ... Handlers to add up front. Must be in the form of named lists
    #' where the names corresponds to paths and the elements are the handlers.
    #' The name of the argument itself defines the method to listen on (see
    #' examples)
    #' @param ignore_trailing_slash Logical. Should the trailing slash of a path
    #' be ignored when adding handlers and handling requests. Setting this will
    #' not change the request or the path associated with but just ensure that
    #' both `path/to/resource` and `path/to/resource/` ends up in the same
    #' handler. Because the request is left untouched, setting this to `TRUE`
    #' will not affect further processing by other routes
    #'
    initialize = function(..., ignore_trailing_slash = FALSE) {
      check_bool(ignore_trailing_slash)
      private$ignore_trailing_slash = ignore_trailing_slash
      handlers <- list2(...)
      if (length(handlers) == 0) {
        return()
      }
      check_named(handlers, arg = "...")
      call = current_call()
      lapply(names(handlers), function(method) {
        check_named(handlers[[method]], arg = method, call = call)
        lapply(names(handlers[[method]]), function(path) {
          check_function(
            handlers[[method]][[path]],
            arg = paste0(method, "[[", path, "]]"),
            call = call
          )
          self$add_handler(method, path, handlers[[method]][[path]])
        })
      })
    },
    #' @description Pretty printing of the object
    #' @param ... Ignored
    #'
    print = function(...) {
      n_handlers <- sum(vapply(
        private$routing,
        function(x) length(x$paths()),
        integer(1)
      ))
      cli::cli_text('A route with {n_handlers} handler{?s}')
      if (n_handlers != 0) {
        method_order <- c(http_methods, 'all')
        reg_methods <- names(private$routing)
        map_order <- match(reg_methods, method_order)
        map_order[is.na(map_order)] <- sum(!is.na(map_order)) +
          seq_len(sum(is.na(map_order)))
        method_length <- max(nchar(reg_methods))
        for (i in order(map_order)) {
          paths <- names(private$routing[[reg_methods[i]]]$paths())
          cli::cli_text('{.emph {reg_methods[i]}:}')
          id <- cli::cli_ul()
          for (j in seq_along(paths)) {
            cli::cli_li(paths[j])
          }
          cli::cli_end(id)
        }
      }
      return(invisible(self))
    },
    #' @description Add a handler to the specified method and path. The special
    #' method `'all'` will allow the handler to match all http request methods.
    #' The path is a URL path consisting of strings, parameters (strings
    #' prefixed with `:`), and wildcards (`*`), separated by `/`. A wildcard
    #' will match anything and is thus not restricted to a single path element
    #' (i.e. it will span multiple `/` if possible). The handler must be a
    #' function containing the arguments `request`, `response`, `keys`, and
    #' `...`, and must return either `TRUE` or `FALSE`. The `request` argument
    #' will be a [reqres::Request] object and the `response` argument will be a
    #' [reqres::Response] object matching the current exchange. The `keys`
    #' argument will be a named list with the value of all matched parameters
    #' from the path. Any additional argument passed on to the `dispatch` method
    #' will be avaiable as well. This method will override an existing handler
    #' with the same method and path.
    #' @param method The http method to match the handler to
    #' @param path The URL path to match to
    #' @param handler A handler function
    #' @param reject_missing_methods Should requests to this path that doesn't
    #' have a handler for the specific method automatically be rejected with a
    #' 405 Method Not Allowed response with the correct Allow header informing
    #' the client of the implemented methods. Assigning a handler to `"all"` for
    #' the same path at a later point will overwrite this functionality. Be
    #' aware that setting this to `TRUE` will prevent the request from falling
    #' through to other routes that might have a matching method and path.
    #'
    add_handler = function(
      method,
      path,
      handler,
      reject_missing_methods = FALSE
    ) {
      method <- arg_match0(tolower(method), c(http_methods, "all"))
      check_string(path)
      check_bool(reject_missing_methods)
      path <- sub('\\?.+', '', path, perl = TRUE)
      if (!"..." %in% fn_fmls_names(handler)) {
        fn_fmls(handler) <- c(fn_fmls(handler), "..." = missing_arg())
      }
      path <- private$canonical_path(path)

      private$assign_handler(method, path, handler)

      if (
        reject_missing_methods &&
          method != "all" &&
          is.null(private$handlerMap[["all"]][[path]])
      ) {
        self$add_handler("all", path, function(response, ...) {
          current_methods <- http_methods[vapply(
            http_methods,
            function(method) {
              !is.null(private$routing[[method]]$paths()[[path]], logical(1))
            }
          )]
          response$status <- 405L
          response$set_header(
            "Allow",
            paste0(toupper(current_methods), collapse = ", ")
          )
          FALSE
        })
      }
      invisible(self)
    },
    #' @description Removes the handler assigned to the specified method and
    #' path. If no handler have been assigned it will silently ignore it.
    #' @param method The http method of the handler to remove
    #' @param path The URL path of the handler to remove
    #'
    remove_handler = function(method, path) {
      method <- arg_match0(tolower(method), c(http_methods, "all"))
      if (!is.null(private$routing[[method]])) {
        check_string(path)
        path <- private$canonical_path(path)
        private$routing[[method]]$remove_path(path)
        if (length(private$routing[[method]]$paths()) == 0) {
          private$routing[[method]] <- NULL
          private$IS_EMPTY <- sum(lengths(private$routing)) == 0
        }
      }
      invisible(self)
    },
    #' @description Returns a handler already assigned to the specified method
    #' and path. If no handler have been assigned it will return NULL.
    #' @param method The http method of the handler to find
    #' @param path The URL path of the handler to find
    #'
    get_handler = function(method, path) {
      method <- arg_match0(tolower(method), c(http_methods, "all"))
      if (is.null(private$routing[[method]])) {
        return(NULL)
      }
      check_string(path)
      path <- private$canonical_path(path)
      private$routing[[method]]$paths()[[path]]
    },
    #' @description Allows you to loop through all added handlers and reassings
    #' them at will. A function with the parameters `method`, `path`, and
    #' `handler` must be provided which is responsible for reassigning the
    #' handler given in the arguments. If the function does not reassign the
    #' handler, then the handler is removed.
    #' @param .f A function performing the remapping of each handler
    #'
    remap_handlers = function(.f) {
      check_function_args(.f, c('method', 'path', 'handler'))
      old_map <- private$routing
      private$routing <- list()
      private$IS_EMPTY <- TRUE

      lapply(names(old_map), function(method) {
        paths <- old_map[[method]]$paths()
        lapply(names(paths), function(path) {
          .f(
            method = method,
            path = path,
            handler = paths[[path]]
          )
        })
      })
      invisible(self)
    },
    #' @description Merge another route into this one, adopting all its handlers.
    #' The other route will be empty after the merge.
    #' @param route A Route object
    #' @param use_root Should the root of `route` be prepended to all paths from
    #' the route before adding them
    #'
    merge_route = function(route, use_root = TRUE) {
      if (!inherits(route, "Route")) {
        stop_input_type(
          route,
          cli::cli_fmt(cli::cli_text("a {.cls Route} object"))
        )
      }
      route$remap_handlers(function(method, path, handler) {
        if (use_root) {
          path <- paste0(gsub("^\\^|/$", "", route$root, perl = TRUE), path)
        }
        self$add_handler(method, path, handler)
      })
      invisible(self)
    },
    #' @description Based on a [reqres::Request] object the route will find the
    #' correct handler and call it with the correct arguments. Anything passed
    #' in with `...` will be passed along to the handler.
    #' @param request The request to route
    #' @param ... Additional arguments to the handlers
    #' @param .require_bool_output Should the dispatch enforce a boolean output.
    #' Mainly for internal use.
    #'
    dispatch = function(request, ..., .require_bool_output = TRUE) {
      if (!maybe_request(request)) {
        stop_input_type(
          request,
          cli::cli_fmt(cli::cli_text("a {.cls Request} object"))
        )
      }

      response <- request$respond()

      private$dispatch0(
        request = request,
        response = response,
        ...,
        .require_bool_output = .require_bool_output
      )
    },
    #' @description Method for use by `fiery` when attached as a plugin. Should
    #' not be called directly. This method creates a RouteStack with the route
    #' as the single route and then mounts that to the app. For more flexibility
    #' create the RouteStack manually
    #' @param app The Fire object to attach the router to
    #' @param on_error `r lifecycle::badge('deprecated')` A function for error handling
    #' @param ... Ignored
    #'
    on_attach = function(app, on_error = deprecated(), ...) {
      if (lifecycle::is_present(on_error)) {
        lifecycle::deprecate_soft("0.5.0", "Route$on_attach(on_error)")
      }
      RouteStack$new(route = self)$on_attach(
        app = app,
        ...
      )
    }
  ),
  active = list(
    #' @field root The root of the route. Will be removed from the path of any
    #' request before matching a handler
    root = function(value) {
      if (missing(value)) {
        return(private$ROOT)
      }
      check_string(value)
      private$ROOT <- paste0(
        '^/',
        gsub('(^\\^?/)|(/$)', '', value, perl = TRUE)
      )
      private$HAS_ROOT <- private$ROOT != "^/"
    },
    #' @field name An autogenerated name for the route
    name = function() paste0("single_routr"),
    #' @field empty Is the route empty
    empty = function() {
      sum(lengths(private$routing)) == 0
    }
  ),
  private = list(
    # Data
    routing = list(),
    ROOT = '^/',
    HAS_ROOT = FALSE,
    IS_EMPTY = TRUE,
    ignore_trailing_slash = FALSE,
    # Methods
    assign_handler = function(method, path, handler) {
      method <- tolower(method)
      if (is.null(private$routing[[method]])) {
        private$routing[[method]] <- waysign::signpost()
      }
      private$routing[[method]]$add_path(sub("^/?", "/", path), handler)
      private$IS_EMPTY <- FALSE
    },
    match_url = function(url, method) {
      route <- private$routing[[method]]
      if (is.null(route)) {
        return(NULL)
      }
      if (private$HAS_ROOT) {
        url <- sub(private$ROOT, "", url, perl = TRUE)
      }
      route$find_object(url)
    },
    canonical_path = function(path) {
      if (private$ignore_trailing_slash && path != "/") {
        sub("/$", "", path, perl = TRUE)
      } else {
        path
      }
    },
    dispatch0 = function(request, response, ..., .require_bool_output = TRUE) {
      if (private$IS_EMPTY) {
        return(NOMATCH)
      }

      if (private$HAS_ROOT && !grepl(private$ROOT, request$path)) {
        return(NOMATCH)
      }

      method <- request$method
      path <- private$canonical_path(request$path)
      handler_match <- private$match_url(path, method)
      if (is.null(handler_match)) {
        handler_match <- private$match_url(path, 'all')
        if (is.null(handler_match)) return(NOMATCH)
      }
      handler <- handler_match$object
      keys <- handler_match$params

      with_route_ospan(
        {
          handler(
            request = request,
            response = response,
            keys = keys,
            ...
          )
        },
        path = handler_match$path,
        method = method,
        request = request,
        response = response,
        keys = keys,
        check_output = .require_bool_output
      )
    }
  ),
  lock_objects = TRUE,
  lock_class = TRUE
)

Try the routr package in your browser

Any scripts or data that you put into this service are public.

routr documentation built on Dec. 18, 2025, 5:07 p.m.