diff --git a/DESCRIPTION b/DESCRIPTION index c280b7b..58e425a 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,14 +1,14 @@ Package: tower -Title: Easy Middle Ware Library for Shiny +Title: Easy Middle Ware Library for 'shiny' Version: 0.1.0 Authors@R: c(person(given = "ixpantia, SRL", role = "cph", email = "hola@ixpantia.com"), person("Andres", "Quintero", , "andres@ixpantia.com", role = c("aut", "cre"))) -Description: The best way to implement middle ware for Shiny Applications. Tower - is designed to make implementing behavior on top of Shiny easy with a layering - model for incoming HTTP requests and server sessions. Tower is a very minimal +Description: The best way to implement middle ware for 'shiny' Applications. 'tower' + is designed to make implementing behavior on top of 'shiny' easy with a layering + model for incoming HTTP requests and server sessions. 'tower' is a very minimal package with little overhead, it is mainly meant for other package developers to implement new behavior. License: MIT + file LICENSE @@ -16,9 +16,13 @@ Encoding: UTF-8 Roxygen: list(markdown = TRUE) RoxygenNote: 7.2.3 Imports: - compiler + compiler, + glue, + purrr, + stringr, + curl, + jsonlite Suggests: testthat (>= 3.0.0), - shiny, - stringr + shiny Config/testthat/edition: 3 diff --git a/NAMESPACE b/NAMESPACE index 5d8ae4b..61b6619 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,8 +1,27 @@ # Generated by roxygen2: do not edit by hand S3method(print,tower) +export(add_body) +export(add_body_json) +export(add_cookie) +export(add_delete_route) +export(add_get_route) export(add_http_layer) +export(add_patch_route) +export(add_post_route) +export(add_put_route) +export(add_route) export(add_server_layer) export(app_into_parts) +export(build_http_cookie) +export(build_response) export(build_tower) export(create_tower) +export(req_body_form) +export(req_body_json) +export(req_cookies) +export(req_query) +export(response_builder) +export(set_content_type) +export(set_header) +export(set_status) diff --git a/R/http_helpers.R b/R/http_helpers.R new file mode 100644 index 0000000..48bc9d3 --- /dev/null +++ b/R/http_helpers.R @@ -0,0 +1,203 @@ +#' @title Add an HTTP layer to a tower +#' @description Adds an HTTP layer to a tower +#' @param tower A tower object +#' @param method A string containing the HTTP method to match +#' @param path A string containing the path to match +#' @param handler A function to call when the layer is matched +#' @return A tower object with the layer added +#' @export +add_route <- function(tower, method = "GET", path, handler) { + handler <- compiler::cmpfun(handler) + route_handler <- compiler::cmpfun(function(req) { + if (req$REQUEST_METHOD == method && req$PATH_INFO == path) { + handler(req) + } + }) + return(add_http_layer(tower, route_handler)) +} + +#' @title Add a GET route +#' @description Adds a GET route to a tower +#' @param tower A tower object +#' @param path A string containing the path to match +#' @param handler A function to call when the route is matched +#' @return A tower object with the route added +#' @export +add_get_route <- function(tower, path, handler) { + add_route(tower, "GET", path, handler) +} + +#' @title Add a POST route +#' @description Adds a POST route to a tower +#' @param tower A tower object +#' @param path A string containing the path to match +#' @param handler A function to call when the route is matched +#' @return A tower object with the route added +#' @export +add_post_route <- function(tower, path, handler) { + add_route(tower, "POST", path, handler) +} + +#' @title Add a PUT route +#' @description Adds a PUT route to a tower +#' @param tower A tower object +#' @param path A string containing the path to match +#' @param handler A function to call when the route is matched +#' @return A tower object with the route added +#' @export +add_put_route <- function(tower, path, handler) { + add_route(tower, "PUT", path, handler) +} + +#' @title Add a DELETE route +#' @description Adds a DELETE route to a tower +#' @param tower A tower object +#' @param path A string containing the path to match +#' @param handler A function to call when the route is matched +#' @return A tower object with the route added +#' @export +add_delete_route <- function(tower, path, handler) { + add_route(tower, "DELETE", path, handler) +} + +#' @title Add a PATCH route +#' @description Adds a PATCH route to a tower +#' @param tower A tower object +#' @param path A string containing the path to match +#' @param handler A function to call when the route is matched +#' @return A tower object with the route added +#' @export +add_patch_route <- function(tower, path, handler) { + add_route(tower, "PATCH", path, handler) +} + +#' @title Extract the request body from a JSON request +#' @description Extracts the request body from a JSON request +#' @param req A request object +#' @param ... Additional arguments to pass to \code{\link[jsonlite]{fromJSON}} +#' when parsing the request body. This will only be used the first time the +#' request body is parsed. Subsequent calls will return the cached result. +#' @return A list containing the request body +#' @export +req_body_json <- function(req, ...) { + if (!is.null(req[[".parsed.body.json"]])) { + return(req[[".parsed.body.json"]]) + } + body <- tryCatch( + expr = jsonlite::fromJSON( + req$rook.input$read_lines(), + ... + ), + error = function(e) { + list() + } + ) + req[[".parsed.body.json"]] <- body + return(body) +} + +#' @title Extract form data from a request +#' @description Extracts form data from a request +#' @param req A request object +#' @return A list containing the form data +#' @export +req_body_form <- function(req) { + if (!is.null(req[[".parsed.body.form"]])) { + return(req[[".parsed.body.form"]]) + } + form <- tryCatch( + expr = shiny::parseQueryString(req[["rook.input"]]$read_lines()), + error = function(e) { + print(e) + list() + } + ) + req[[".parsed.body.form"]] <- form + return(form) +} + +#' @title Extract query parameters from a request +#' @description Extracts query parameters from a request +#' @param req A request object +#' @return A list containing the query parameters +#' @export +req_query <- function(req) { + if (!is.null(req[[".parsed.query"]])) { + return(req[[".parsed.query"]]) + } + query <- tryCatch( + expr = shiny::parseQueryString(req$QUERY_STRING), + error = function(e) { + list() + } + ) + req[[".parsed.query"]] <- query + return(query) +} + +#' @keywords internal +split_cookie_pair <- function(.x) { + stringr::str_split(.x, "=", n = 2) +} + +#' @keywords internal +cookie_unescape <- function(.x) { + .x[2] <- curl::curl_unescape(.x[2]) + stats::setNames(.x[2], .x[1]) +} + +#' @title Parse cookies +#' @description Parses cookies from a string +#' +#' @param x A string containing the cookies +#' +#' @return A list containing the cookies +#' @keywords internal +parse_cookies <- function(x) { + if (is.null(x)) { + return(list()) + } + cookie_pairs <- stringr::str_split(x, "; ") + cookie_pairs <- purrr::map(cookie_pairs, split_cookie_pair)[[1]] + cookie_pairs <- purrr::map(cookie_pairs, cookie_unescape) + cookie_pairs <- purrr::flatten(cookie_pairs) + return(cookie_pairs) +} + +#' @keywords internal +cookie_to_header <- function(.x, .y) { + list( + "Set-Cookie" = build_http_cookie(.y, .x) + ) +} + +#' @title Build a cookie +#' @description Builds an HttpOnly cookie from a key and value +#' +#' @param key A string containing the cookie key +#' @param value A string containing the cookie value +#' +#' @return A string containing the cookie +#' @export +build_http_cookie <- function(key, value) { + glue::glue("{key}={value}; path=/; SameSite=Lax; HttpOnly") +} + +#' @title Extract cookies from a request +#' @description Extracts cookies from a request +#' @param req A request object +#' @return A list containing the cookies +#' @export +req_cookies <- function(req) { + if (!is.null(req[[".parsed.cookies"]])) { + return(req[[".parsed.cookies"]]) + } + cookies <- tryCatch( + expr = parse_cookies(req$HTTP_COOKIE), + error = function(e) { + list() + } + ) + req[[".parsed.cookies"]] <- cookies + return(cookies) +} diff --git a/R/response_builder.R b/R/response_builder.R new file mode 100644 index 0000000..b986ca0 --- /dev/null +++ b/R/response_builder.R @@ -0,0 +1,161 @@ +#' @title Create a response builder +#' @description Creates a response builder +#' @return A response builder object +#' @export +response_builder <- function() { + resp <- new.env(parent = emptyenv()) + resp$status <- 200 + resp$headers <- list() + resp$content_type <- NULL + resp$cookies <- list() + resp$body <- NULL + structure( + resp, + class = "response_builder" + ) +} + +#' @title Set a header on a response +#' @description Sets or adds a header to a response +#' @param res A response builder object +#' @param name The name of the header +#' @param value The value of the header +#' @return The response builder object +#' @export +set_header <- function(res, name, value) { + res$headers[[name]] <- value + return(invisible(res)) +} + +#' @title Set the status of a response +#' @description Sets the status of a response +#' @param res A response builder object +#' @param status The status to set +#' @return The response builder object +#' @export +set_status <- function(res, status) { + res$status <- status + return(invisible(res)) +} + +#' @title Add a cookie to a response +#' @description Adds a cookie to a response +#' @param res A response builder object +#' @param name The name of the cookie +#' @param value The value of the cookie +#' @return The response builder object +#' @export +add_cookie <- function(res, name, value) { + res$cookies[[name]] <- value + return(invisible(res)) +} + +#' @title Set the content type of a response +#' @description Sets the content type of a response +#' @param res A response builder object +#' @param content_type The content type to set +#' @return The response builder object +#' @export +set_content_type <- function(res, content_type) { + res$content_type <- content_type + return(invisible(res)) +} + +#' @keywords internal +detect_content_type <- function(body) { + UseMethod("detect_content_type") +} + +#' @keywords internal +detect_content_type.default <- function(body) { + return("application/json") +} + +#' @keywords internal +detect_content_type.shiny.tag <- function(body) { + return("text/html") +} + +#' @keywords internal +detect_content_type.shiny.tag.list <- function(body) { + return("text/html") +} + +#' @keywords internal +detect_content_type.list <- function(body) { + return("application/json") +} + +#' @keywords internal +detect_content_type.raw <- function(body) { + return("application/octet-stream") +} + +#' @keywords internal +detect_content_type.character <- function(body) { + return("text/plain") +} + +#' @keywords internal +serialize_body <- function(body, content_type) { + switch( + content_type, + "text/html" = as.character(body), + "text/plain" = as.character(body), + "application/json" = as.character(jsonlite::toJSON(body)), + "application/octet-stream" = as.raw(body) + ) +} + +#' @title Add a body to a response +#' @description Adds a body to a response, if no content type is set, it will be detected +#' @param res A response builder object +#' @param body The body to add +#' @return The response builder object +#' @export +add_body <- function(res, body) { + if (is.null(res$content_type)) { + set_content_type(res, detect_content_type(body)) + } + res$body <- body + return(invisible(res)) +} + +#' @title Add a body to a response as JSON +#' @description Adds a body to a response as JSON +#' @param res A response builder object +#' @param body The body to add +#' @return The response builder object +#' @export +add_body_json <- function(res, body) { + set_content_type(res, "application/json") + res$body <- body + return(invisible(res)) +} + +#' @title Build a response +#' @description Builds a response +#' @param res A response builder object +#' @return A response object +#' @export +build_response <- function(res) { + content_type <- ifelse( + is.null(res$content_type), + "text/plain", + res$content_type + ) + body <- serialize_body(res$body, content_type) + status <- res$status + headers <- res$headers + cookies <- res$cookies |> + purrr::imap(cookie_to_header) |> + purrr::flatten() + headers <- append(headers, cookies) + + shiny::httpResponse( + status = status, + headers = headers, + content_type = content_type, + content = body + ) +} diff --git a/R/service.R b/R/service.R index efbce45..cc7a146 100644 --- a/R/service.R +++ b/R/service.R @@ -3,7 +3,7 @@ compiler_options <- list(optimize = 3L) #' @title Create a new tower #' @description Create a new tower to build upon. -#' @param app A shiny app object +#' @param app A 'shiny' app object #' @return A new tower #' @export create_tower <- function(app) { @@ -31,7 +31,7 @@ print.tower <- function(x, ...) { #' @title Add an HTTP layer to a tower #' @description Add an HTTP layer to a tower. This layer -#' will be called before the Shiny app's httpHandler. +#' will be called before the 'shiny' app's httpHandler. #' @param tower A tower #' @param layer A function that takes a request and returns either #' a response or NULL. NULL indicates that the layer did not @@ -50,7 +50,7 @@ add_http_layer <- function(tower, layer) { #' @title Add a server layer to a tower #' @description Add a server layer to a tower. This layer -#' will run before the Shiny app's server function. This +#' will run before the 'shiny' app's server function. This #' is useful for adding custom logic to the server function #' without modifying the original server function. #' @param tower A tower @@ -112,11 +112,11 @@ build_server <- function(tower) { } -#' @title Build a Shiny app from a tower -#' @description Build a Shiny app from a tower. This will create -#' a new Shiny app with the specified layers added. +#' @title Build a 'shiny' app from a tower +#' @description Build a 'shiny' app from a tower. This will create +#' a new 'shiny' app with the specified layers added. #' @param tower A tower -#' @return A Shiny app object +#' @return A 'shiny' app object that can be started #' @export build_tower <- function(tower) { app <- tower$app diff --git a/README.md b/README.md index 605dc38..b82aad0 100644 --- a/README.md +++ b/README.md @@ -37,34 +37,28 @@ library(shiny) library(tower) # Counter environment -COUNTER <- new.env() -COUNTER$counter <- 0 +global_counter <- new.env() +global_counter$count <- 0 # Middleware to increment the counter increment_counter <- function(req) { - if (req$PATH_INFO == "/increment") { - COUNTER$counter <- COUNTER$counter + 1 - return( - httpResponse( - 200, - "text/plain", - paste("Counter is now", COUNTER$counter) - ) - ) - } + global_counter$count <- global_counter$count + 1 + response_builder() |> + add_body(paste("Counter is now", global_counter$count)) |> + build_response() } -# A very empty Shiny app (not necessary for the demo) +# A very empty Shiny app (not necesarry for the demo) ui <- fluidPage() server <- function(input, output, session) {} shinyApp(ui, server) |> create_tower() |> - add_http_layer(increment_counter) |> + add_get_route("/counter", increment_counter) |> build_tower() ``` -If you run the code above and visit the route `/increment` in your browser, +If you run the code above and visit the route `/counter` in your browser, you will see the counter increment every time you visit the route. ## How it works diff --git a/inst/examples/counter/app.R b/inst/examples/counter/app.R index 8a801df..f088905 100644 --- a/inst/examples/counter/app.R +++ b/inst/examples/counter/app.R @@ -2,21 +2,15 @@ library(shiny) library(tower) # Counter environment -COUNTER <- new.env() -COUNTER$counter <- 0 +global_counter <- new.env() +global_counter$count <- 0 # Middleware to increment the counter increment_counter <- function(req) { - if (req$PATH_INFO == "/increment") { - COUNTER$counter <- COUNTER$counter + 1 - return( - httpResponse( - 200, - "text/plain", - paste("Counter is now", COUNTER$counter) - ) - ) - } + global_counter$count <- global_counter$count + 1 + response_builder() |> + add_body(paste("Counter is now", global_counter$count)) |> + build_response() } # A very empty Shiny app (not necesarry for the demo) @@ -25,5 +19,5 @@ server <- function(input, output, session) {} shinyApp(ui, server) |> create_tower() |> - add_http_layer(increment_counter) |> + add_get_route("/counter", increment_counter) |> build_tower() diff --git a/inst/examples/json_api/app.R b/inst/examples/json_api/app.R new file mode 100644 index 0000000..83e917a --- /dev/null +++ b/inst/examples/json_api/app.R @@ -0,0 +1,45 @@ +library(tower) +library(shiny) +library(dplyr) + +filter_iris <- function(req) { + species <- req_query(req)$species + if (is.null(species)) { + return( + response_builder() |> + set_status(400) |> + add_body("species parameter is required") |> + build_response() + ) + } + response_data <- iris |> + dplyr::filter(Species == species) + response_builder() |> + set_status(200) |> + add_body_json(response_data) |> + build_response() +} + +ui <- fluidPage( + titlePanel("Iris data"), + sidebarLayout( + sidebarPanel( + selectInput("species", "Species", choices = unique(iris$Species)) + ), + mainPanel( + tableOutput("table") + ) + ) +) + +server <- function(input, output, session) { + output$table <- renderTable({ + iris |> + dplyr::filter(Species == input$species) + }) +} + +shinyApp(ui, server) |> + create_tower() |> + add_get_route("/iris", filter_iris) |> + build_tower() diff --git a/inst/examples/modify_req/app.R b/inst/examples/modify_req/app.R new file mode 100644 index 0000000..f81134c --- /dev/null +++ b/inst/examples/modify_req/app.R @@ -0,0 +1,22 @@ +library(shiny) +library(tower) + + +ui <- fluidPage() +server <- function(input, output) { } + +shinyApp(ui, server) |> + create_tower() |> + add_http_layer(function(req) { + req$NEW_DATA <- "new data" + return(NULL) + }) |> + add_http_layer(function(req) { + req$NEW_DATA <- paste0(req$NEW_DATA, " and more data") + return(NULL) + }) |> + add_http_layer(function(req) { + print(req$NEW_DATA) + return(NULL) + }) |> + build_tower() diff --git a/man/add_body.Rd b/man/add_body.Rd new file mode 100644 index 0000000..7cc4bf5 --- /dev/null +++ b/man/add_body.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/response_builder.R +\name{add_body} +\alias{add_body} +\title{Add a body to a response} +\usage{ +add_body(res, body) +} +\arguments{ +\item{res}{A response builder object} + +\item{body}{The body to add} +} +\value{ +The response builder object +} +\description{ +Adds a body to a response, if no content type is set, it will be detected +} diff --git a/man/add_body_json.Rd b/man/add_body_json.Rd new file mode 100644 index 0000000..0c85a84 --- /dev/null +++ b/man/add_body_json.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/response_builder.R +\name{add_body_json} +\alias{add_body_json} +\title{Add a body to a response as JSON} +\usage{ +add_body_json(res, body) +} +\arguments{ +\item{res}{A response builder object} + +\item{body}{The body to add} +} +\value{ +The response builder object +} +\description{ +Adds a body to a response as JSON +} diff --git a/man/add_cookie.Rd b/man/add_cookie.Rd new file mode 100644 index 0000000..8d73f25 --- /dev/null +++ b/man/add_cookie.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/response_builder.R +\name{add_cookie} +\alias{add_cookie} +\title{Add a cookie to a response} +\usage{ +add_cookie(res, name, value) +} +\arguments{ +\item{res}{A response builder object} + +\item{name}{The name of the cookie} + +\item{value}{The value of the cookie} +} +\value{ +The response builder object +} +\description{ +Adds a cookie to a response +} diff --git a/man/add_delete_route.Rd b/man/add_delete_route.Rd new file mode 100644 index 0000000..ae71fec --- /dev/null +++ b/man/add_delete_route.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/http_helpers.R +\name{add_delete_route} +\alias{add_delete_route} +\title{Add a DELETE route} +\usage{ +add_delete_route(tower, path, handler) +} +\arguments{ +\item{tower}{A tower object} + +\item{path}{A string containing the path to match} + +\item{handler}{A function to call when the route is matched} +} +\value{ +A tower object with the route added +} +\description{ +Adds a DELETE route to a tower +} diff --git a/man/add_get_route.Rd b/man/add_get_route.Rd new file mode 100644 index 0000000..591cf30 --- /dev/null +++ b/man/add_get_route.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/http_helpers.R +\name{add_get_route} +\alias{add_get_route} +\title{Add a GET route} +\usage{ +add_get_route(tower, path, handler) +} +\arguments{ +\item{tower}{A tower object} + +\item{path}{A string containing the path to match} + +\item{handler}{A function to call when the route is matched} +} +\value{ +A tower object with the route added +} +\description{ +Adds a GET route to a tower +} diff --git a/man/add_http_layer.Rd b/man/add_http_layer.Rd index 8ac786f..6b67491 100644 --- a/man/add_http_layer.Rd +++ b/man/add_http_layer.Rd @@ -20,5 +20,5 @@ The tower with the layer added } \description{ Add an HTTP layer to a tower. This layer -will be called before the Shiny app's httpHandler. +will be called before the 'shiny' app's httpHandler. } diff --git a/man/add_patch_route.Rd b/man/add_patch_route.Rd new file mode 100644 index 0000000..02ab140 --- /dev/null +++ b/man/add_patch_route.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/http_helpers.R +\name{add_patch_route} +\alias{add_patch_route} +\title{Add a PATCH route} +\usage{ +add_patch_route(tower, path, handler) +} +\arguments{ +\item{tower}{A tower object} + +\item{path}{A string containing the path to match} + +\item{handler}{A function to call when the route is matched} +} +\value{ +A tower object with the route added +} +\description{ +Adds a PATCH route to a tower +} diff --git a/man/add_post_route.Rd b/man/add_post_route.Rd new file mode 100644 index 0000000..7ef6354 --- /dev/null +++ b/man/add_post_route.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/http_helpers.R +\name{add_post_route} +\alias{add_post_route} +\title{Add a POST route} +\usage{ +add_post_route(tower, path, handler) +} +\arguments{ +\item{tower}{A tower object} + +\item{path}{A string containing the path to match} + +\item{handler}{A function to call when the route is matched} +} +\value{ +A tower object with the route added +} +\description{ +Adds a POST route to a tower +} diff --git a/man/add_put_route.Rd b/man/add_put_route.Rd new file mode 100644 index 0000000..e9115ce --- /dev/null +++ b/man/add_put_route.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/http_helpers.R +\name{add_put_route} +\alias{add_put_route} +\title{Add a PUT route} +\usage{ +add_put_route(tower, path, handler) +} +\arguments{ +\item{tower}{A tower object} + +\item{path}{A string containing the path to match} + +\item{handler}{A function to call when the route is matched} +} +\value{ +A tower object with the route added +} +\description{ +Adds a PUT route to a tower +} diff --git a/man/add_route.Rd b/man/add_route.Rd new file mode 100644 index 0000000..c4e493f --- /dev/null +++ b/man/add_route.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/http_helpers.R +\name{add_route} +\alias{add_route} +\title{Add an HTTP layer to a tower} +\usage{ +add_route(tower, method = "GET", path, handler) +} +\arguments{ +\item{tower}{A tower object} + +\item{method}{A string containing the HTTP method to match} + +\item{path}{A string containing the path to match} + +\item{handler}{A function to call when the layer is matched} +} +\value{ +A tower object with the layer added +} +\description{ +Adds an HTTP layer to a tower +} diff --git a/man/add_server_layer.Rd b/man/add_server_layer.Rd index 9551845..0d1a056 100644 --- a/man/add_server_layer.Rd +++ b/man/add_server_layer.Rd @@ -19,7 +19,7 @@ The tower with the layer added } \description{ Add a server layer to a tower. This layer -will run before the Shiny app's server function. This +will run before the 'shiny' app's server function. This is useful for adding custom logic to the server function without modifying the original server function. } diff --git a/man/build_http_cookie.Rd b/man/build_http_cookie.Rd new file mode 100644 index 0000000..fc31a06 --- /dev/null +++ b/man/build_http_cookie.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/http_helpers.R +\name{build_http_cookie} +\alias{build_http_cookie} +\title{Build a cookie} +\usage{ +build_http_cookie(key, value) +} +\arguments{ +\item{key}{A string containing the cookie key} + +\item{value}{A string containing the cookie value} +} +\value{ +A string containing the cookie +} +\description{ +Builds an HttpOnly cookie from a key and value +} diff --git a/man/build_response.Rd b/man/build_response.Rd new file mode 100644 index 0000000..5a61f31 --- /dev/null +++ b/man/build_response.Rd @@ -0,0 +1,17 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/response_builder.R +\name{build_response} +\alias{build_response} +\title{Build a response} +\usage{ +build_response(res) +} +\arguments{ +\item{res}{A response builder object} +} +\value{ +A response object +} +\description{ +Builds a response +} diff --git a/man/build_tower.Rd b/man/build_tower.Rd index 493a4c8..10c0c9d 100644 --- a/man/build_tower.Rd +++ b/man/build_tower.Rd @@ -2,7 +2,7 @@ % Please edit documentation in R/service.R \name{build_tower} \alias{build_tower} -\title{Build a Shiny app from a tower} +\title{Build a 'shiny' app from a tower} \usage{ build_tower(tower) } @@ -10,9 +10,9 @@ build_tower(tower) \item{tower}{A tower} } \value{ -A Shiny app object +A 'shiny' app object that can be started } \description{ -Build a Shiny app from a tower. This will create -a new Shiny app with the specified layers added. +Build a 'shiny' app from a tower. This will create +a new 'shiny' app with the specified layers added. } diff --git a/man/create_tower.Rd b/man/create_tower.Rd index 2e0acc0..a680bb9 100644 --- a/man/create_tower.Rd +++ b/man/create_tower.Rd @@ -7,7 +7,7 @@ create_tower(app) } \arguments{ -\item{app}{A shiny app object} +\item{app}{A 'shiny' app object} } \value{ A new tower diff --git a/man/parse_cookies.Rd b/man/parse_cookies.Rd new file mode 100644 index 0000000..ea6df0f --- /dev/null +++ b/man/parse_cookies.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/http_helpers.R +\name{parse_cookies} +\alias{parse_cookies} +\title{Parse cookies} +\usage{ +parse_cookies(x) +} +\arguments{ +\item{x}{A string containing the cookies} +} +\value{ +A list containing the cookies +} +\description{ +Parses cookies from a string +} +\keyword{internal} diff --git a/man/req_body_form.Rd b/man/req_body_form.Rd new file mode 100644 index 0000000..4046c92 --- /dev/null +++ b/man/req_body_form.Rd @@ -0,0 +1,17 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/http_helpers.R +\name{req_body_form} +\alias{req_body_form} +\title{Extract form data from a request} +\usage{ +req_body_form(req) +} +\arguments{ +\item{req}{A request object} +} +\value{ +A list containing the form data +} +\description{ +Extracts form data from a request +} diff --git a/man/req_body_json.Rd b/man/req_body_json.Rd new file mode 100644 index 0000000..4986fd0 --- /dev/null +++ b/man/req_body_json.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/http_helpers.R +\name{req_body_json} +\alias{req_body_json} +\title{Extract the request body from a JSON request} +\usage{ +req_body_json(req, ...) +} +\arguments{ +\item{req}{A request object} + +\item{...}{Additional arguments to pass to \code{\link[jsonlite]{fromJSON}} +when parsing the request body. This will only be used the first time the +request body is parsed. Subsequent calls will return the cached result.} +} +\value{ +A list containing the request body +} +\description{ +Extracts the request body from a JSON request +} diff --git a/man/req_cookies.Rd b/man/req_cookies.Rd new file mode 100644 index 0000000..a95af05 --- /dev/null +++ b/man/req_cookies.Rd @@ -0,0 +1,17 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/http_helpers.R +\name{req_cookies} +\alias{req_cookies} +\title{Extract cookies from a request} +\usage{ +req_cookies(req) +} +\arguments{ +\item{req}{A request object} +} +\value{ +A list containing the cookies +} +\description{ +Extracts cookies from a request +} diff --git a/man/req_query.Rd b/man/req_query.Rd new file mode 100644 index 0000000..03157bd --- /dev/null +++ b/man/req_query.Rd @@ -0,0 +1,17 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/http_helpers.R +\name{req_query} +\alias{req_query} +\title{Extract query parameters from a request} +\usage{ +req_query(req) +} +\arguments{ +\item{req}{A request object} +} +\value{ +A list containing the query parameters +} +\description{ +Extracts query parameters from a request +} diff --git a/man/response_builder.Rd b/man/response_builder.Rd new file mode 100644 index 0000000..193f78a --- /dev/null +++ b/man/response_builder.Rd @@ -0,0 +1,14 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/response_builder.R +\name{response_builder} +\alias{response_builder} +\title{Create a response builder} +\usage{ +response_builder() +} +\value{ +A response builder object +} +\description{ +Creates a response builder +} diff --git a/man/set_content_type.Rd b/man/set_content_type.Rd new file mode 100644 index 0000000..66b0f71 --- /dev/null +++ b/man/set_content_type.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/response_builder.R +\name{set_content_type} +\alias{set_content_type} +\title{Set the content type of a response} +\usage{ +set_content_type(res, content_type) +} +\arguments{ +\item{res}{A response builder object} + +\item{content_type}{The content type to set} +} +\value{ +The response builder object +} +\description{ +Sets the content type of a response +} diff --git a/man/set_header.Rd b/man/set_header.Rd new file mode 100644 index 0000000..a088d54 --- /dev/null +++ b/man/set_header.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/response_builder.R +\name{set_header} +\alias{set_header} +\title{Set a header on a response} +\usage{ +set_header(res, name, value) +} +\arguments{ +\item{res}{A response builder object} + +\item{name}{The name of the header} + +\item{value}{The value of the header} +} +\value{ +The response builder object +} +\description{ +Sets or adds a header to a response +} diff --git a/man/set_status.Rd b/man/set_status.Rd new file mode 100644 index 0000000..58955d7 --- /dev/null +++ b/man/set_status.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/response_builder.R +\name{set_status} +\alias{set_status} +\title{Set the status of a response} +\usage{ +set_status(res, status) +} +\arguments{ +\item{res}{A response builder object} + +\item{status}{The status to set} +} +\value{ +The response builder object +} +\description{ +Sets the status of a response +} diff --git a/tests/testthat/test-http_helpers.R b/tests/testthat/test-http_helpers.R new file mode 100644 index 0000000..e9e971d --- /dev/null +++ b/tests/testthat/test-http_helpers.R @@ -0,0 +1,430 @@ +test_that("can add an arbitraty route", { + + app <- shiny::shinyApp( + ui = shiny::fluidPage(), + server = function(input, output, session) {} + ) + + app <- app |> + tower::create_tower() |> + tower::add_route("GET", "/hello", function(req) { + shiny::httpResponse( + status = 200, + content_type = "text/plain", + content = "Hello, world!" + ) + }) |> + tower::build_tower() + + request <- list( + PATH_INFO = "/hello", + REQUEST_METHOD = "GET" + ) + + parts <- app |> + tower::app_into_parts() + + response <- parts$ui(request) + + expect_equal(response$status, 200) + expect_equal(response$content_type, "text/plain") + expect_equal(response$content, "Hello, world!") + +}) + +test_that("can add a get route", { + + app <- shiny::shinyApp( + ui = shiny::fluidPage(), + server = function(input, output, session) {} + ) + + app <- app |> + tower::create_tower() |> + tower::add_get_route("/hello", function(req) { + shiny::httpResponse( + status = 200, + content_type = "text/plain", + content = "Hello, world!" + ) + }) |> + tower::build_tower() + + request <- list( + PATH_INFO = "/hello", + REQUEST_METHOD = "GET" + ) + + parts <- app |> + tower::app_into_parts() + + response <- parts$ui(request) + + expect_equal(response$status, 200) + expect_equal(response$content_type, "text/plain") + expect_equal(response$content, "Hello, world!") + +}) + +test_that("can add a post route", { + + app <- shiny::shinyApp( + ui = shiny::fluidPage(), + server = function(input, output, session) {} + ) + + app <- app |> + tower::create_tower() |> + tower::add_post_route("/hello", function(req) { + shiny::httpResponse( + status = 200, + content_type = "text/plain", + content = "Hello, world!" + ) + }) |> + tower::build_tower() + + request <- list( + PATH_INFO = "/hello", + REQUEST_METHOD = "POST" + ) + + parts <- app |> + tower::app_into_parts() + + response <- parts$ui(request) + + expect_equal(response$status, 200) + expect_equal(response$content_type, "text/plain") + expect_equal(response$content, "Hello, world!") + +}) + +test_that("can add a put route", { + + app <- shiny::shinyApp( + ui = shiny::fluidPage(), + server = function(input, output, session) {} + ) + + app <- app |> + tower::create_tower() |> + tower::add_put_route("/hello", function(req) { + shiny::httpResponse( + status = 200, + content_type = "text/plain", + content = "Hello, world!" + ) + }) |> + tower::build_tower() + + request <- list( + PATH_INFO = "/hello", + REQUEST_METHOD = "PUT" + ) + + parts <- app |> + tower::app_into_parts() + + response <- parts$ui(request) + + expect_equal(response$status, 200) + expect_equal(response$content_type, "text/plain") + expect_equal(response$content, "Hello, world!") + +}) + +test_that("can add a delete route", { + + app <- shiny::shinyApp( + ui = shiny::fluidPage(), + server = function(input, output, session) {} + ) + + app <- app |> + tower::create_tower() |> + tower::add_delete_route("/hello", function(req) { + shiny::httpResponse( + status = 200, + content_type = "text/plain", + content = "Hello, world!" + ) + }) |> + tower::build_tower() + + request <- list( + PATH_INFO = "/hello", + REQUEST_METHOD = "DELETE" + ) + + parts <- app |> + tower::app_into_parts() + + response <- parts$ui(request) + + expect_equal(response$status, 200) + expect_equal(response$content_type, "text/plain") + expect_equal(response$content, "Hello, world!") + +}) + +test_that("can add a patch route", { + + app <- shiny::shinyApp( + ui = shiny::fluidPage(), + server = function(input, output, session) {} + ) + + app <- app |> + tower::create_tower() |> + tower::add_patch_route("/hello", function(req) { + shiny::httpResponse( + status = 200, + content_type = "text/plain", + content = "Hello, world!" + ) + }) |> + tower::build_tower() + + request <- list( + PATH_INFO = "/hello", + REQUEST_METHOD = "PATCH" + ) + + parts <- app |> + tower::app_into_parts() + + response <- parts$ui(request) + + expect_equal(response$status, 200) + expect_equal(response$content_type, "text/plain") + expect_equal(response$content, "Hello, world!") + +}) + +create_dummy_rook_input <- function(content) { + list( + read_lines = function() { + return(content) + } + ) +} + +test_that("can extract req body as a json", { + + app <- shiny::shinyApp( + ui = shiny::fluidPage(), + server = function(input, output, session) {} + ) + + app <- app |> + tower::create_tower() |> + tower::add_post_route("/hello", function(req) { + shiny::httpResponse( + status = 200, + content_type = "application/json", + content = tower::req_body_json(req)$secret + ) + }) |> + tower::build_tower() + + request <- new.env() + request$PATH_INFO <- "/hello" + request$REQUEST_METHOD <- "POST" + request$CONTENT_TYPE <- "application/json" + request$rook.input <- create_dummy_rook_input('{"secret": "Hello, world!"}') + + parts <- app |> + tower::app_into_parts() + + response <- parts$ui(request) + + expect_equal(response$status, 200) + expect_equal(response$content_type, "application/json") + expect_equal(response$content, "Hello, world!") + +}) + +test_that("can extract req body as a json on multiple layers", { + + app <- shiny::shinyApp( + ui = shiny::fluidPage(), + server = function(input, output, session) {} + ) + + app <- app |> + tower::create_tower() |> + tower::add_http_layer(function(req) { + body <- tower::req_body_json(req) + testthat::expect_equal(body$secret, "Hello, world!") + return(NULL) + }) |> + tower::add_post_route("/hello", function(req) { + shiny::httpResponse( + status = 200, + content_type = "application/json", + content = tower::req_body_json(req)$secret + ) + }) |> + tower::build_tower() + + request <- new.env() + request$PATH_INFO <- "/hello" + request$REQUEST_METHOD <- "POST" + request$CONTENT_TYPE <- "application/json" + request$rook.input <- create_dummy_rook_input('{"secret": "Hello, world!"}') + + parts <- app |> + tower::app_into_parts() + + response <- parts$ui(request) + + expect_equal(response$status, 200) + expect_equal(response$content_type, "application/json") + expect_equal(response$content, "Hello, world!") + +}) + +test_that("can extract req body form", { + + app <- shiny::shinyApp( + ui = shiny::fluidPage(), + server = function(input, output, session) {} + ) + + app <- app |> + tower::create_tower() |> + tower::add_post_route("/hello", function(req) { + shiny::httpResponse( + status = 200, + content_type = "application/json", + content = tower::req_body_form(req)$secret + ) + }) |> + tower::build_tower() + + request <- new.env() + request$PATH_INFO <- "/hello" + request$REQUEST_METHOD <- "POST" + request$CONTENT_TYPE <- "application/x-www-form-urlencoded" + request$rook.input <- create_dummy_rook_input("say=Hello&to=World&secret=Hello%2C%20world%21") + + parts <- app |> + tower::app_into_parts() + + response <- parts$ui(request) + + expect_equal(response$status, 200) + expect_equal(response$content_type, "application/json") + expect_equal(response$content, "Hello, world!") + +}) + +test_that("can extract req body form on many layers", { + + app <- shiny::shinyApp( + ui = shiny::fluidPage(), + server = function(input, output, session) {} + ) + + app <- app |> + tower::create_tower() |> + tower::add_http_layer(function(req) { + body <- tower::req_body_form(req) + testthat::expect_equal(body$secret, "Hello, world!") + return(NULL) + }) |> + tower::add_post_route("/hello", function(req) { + shiny::httpResponse( + status = 200, + content_type = "application/json", + content = tower::req_body_form(req)$secret + ) + }) |> + tower::build_tower() + + request <- new.env() + request$PATH_INFO <- "/hello" + request$REQUEST_METHOD <- "POST" + request$CONTENT_TYPE <- "application/x-www-form-urlencoded" + request$rook.input <- create_dummy_rook_input("say=Hello&to=World&secret=Hello%2C%20world%21") + + parts <- app |> + tower::app_into_parts() + + response <- parts$ui(request) + + expect_equal(response$status, 200) + expect_equal(response$content_type, "application/json") + expect_equal(response$content, "Hello, world!") + +}) + +test_that("can extract req query", { + app <- shiny::shinyApp( + ui = shiny::fluidPage(), + server = function(input, output, session) {} + ) + + app <- app |> + tower::create_tower() |> + tower::add_get_route("/hello", function(req) { + shiny::httpResponse( + status = 200, + content_type = "application/json", + content = tower::req_query(req)$secret + ) + }) |> + tower::build_tower() + + request <- new.env() + request$PATH_INFO <- "/hello" + request$REQUEST_METHOD <- "GET" + request$CONTENT_TYPE <- "application/x-www-form-urlencoded" + request$QUERY_STRING <- "say=Hello&to=World&secret=Hello%2C%20world%21" + + parts <- app |> + tower::app_into_parts() + + response <- parts$ui(request) + + expect_equal(response$status, 200) + expect_equal(response$content_type, "application/json") + expect_equal(response$content, "Hello, world!") + + +}) + +test_that("can extract req http cookies", { + app <- shiny::shinyApp( + ui = shiny::fluidPage(), + server = function(input, output, session) {} + ) + + app <- app |> + tower::create_tower() |> + tower::add_get_route("/hello", function(req) { + shiny::httpResponse( + status = 200, + content_type = "application/json", + content = tower::req_cookies(req)$secret + ) + }) |> + tower::build_tower() + + request <- new.env() + request$PATH_INFO <- "/hello" + request$REQUEST_METHOD <- "GET" + request$CONTENT_TYPE <- "application/x-www-form-urlencoded" + request$HTTP_COOKIE <- "say=Hello; to=World; secret=Hello%2C%20world%21" + + parts <- app |> + tower::app_into_parts() + + response <- parts$ui(request) + + expect_equal(response$status, 200) + expect_equal(response$content_type, "application/json") + expect_equal(response$content, "Hello, world!") + +}) diff --git a/tests/testthat/test-response-builder.R b/tests/testthat/test-response-builder.R new file mode 100644 index 0000000..e6a1936 --- /dev/null +++ b/tests/testthat/test-response-builder.R @@ -0,0 +1,137 @@ +test_that("can build an empty response", { + response <- tower::response_builder() |> + tower::build_response() + expect_equal(response$status, 200) + expect_equal(response$content_type, "text/plain") + expect_equal(response$content, character(0)) +}) + +test_that("can build a response with a status", { + response <- tower::response_builder() |> + tower::set_status(404) |> + tower::build_response() + expect_equal(response$status, 404) + expect_equal(response$content_type, "text/plain") + expect_equal(response$content, character(0)) +}) + +test_that("can build a response with a content type", { + response <- tower::response_builder() |> + tower::set_content_type("application/json") |> + tower::build_response() + expect_equal(response$status, 200) + expect_equal(response$content_type, "application/json") + expect_equal(response$content, "{}") +}) + +test_that("can build a response with a body", { + response <- tower::response_builder() |> + tower::add_body("Hello, world!") |> + tower::build_response() + expect_equal(response$status, 200) + expect_equal(response$content_type, "text/plain") + expect_equal(response$content, "Hello, world!") +}) + +test_that("can build a response with a body and content type", { + response <- tower::response_builder() |> + tower::set_content_type("text/html") |> + tower::add_body("

Hello, world!

") |> + tower::build_response() + expect_equal(response$status, 200) + expect_equal(response$content_type, "text/html") + expect_equal(response$content, "

Hello, world!

") +}) + +test_that("shiny::tags get converted to html", { + response <- tower::response_builder() |> + tower::set_content_type("text/html") |> + tower::add_body(shiny::tags$h1("Hello, world!")) |> + tower::build_response() + expect_equal(response$status, 200) + expect_equal(response$content_type, "text/html") + expect_equal(response$content, "

Hello, world!

") +}) + +test_that("can build a response with a body as JSON", { + response <- tower::response_builder() |> + tower::add_body_json(list(a = 1, b = 2)) |> + tower::build_response() + expect_equal(response$status, 200) + expect_equal(response$content_type, "application/json") + expect_equal(response$content, "{\"a\":[1],\"b\":[2]}") +}) + +test_that("can build a response with a body as JSON and content type", { + response <- tower::response_builder() |> + tower::set_content_type("application/json") |> + tower::add_body_json(list(a = 1, b = 2)) |> + tower::build_response() + expect_equal(response$status, 200) + expect_equal(response$content_type, "application/json") + expect_equal(response$content, "{\"a\":[1],\"b\":[2]}") +}) + +test_that("can build a response with a raw body", { + response <- tower::response_builder() |> + tower::add_body(as.raw(0:255)) |> + tower::build_response() + expect_equal(response$status, 200) + expect_equal(response$content_type, "application/octet-stream") + expect_equal(response$content, as.raw(0:255)) +}) + +test_that("can build a response with a raw body and content type", { + response <- tower::response_builder() |> + tower::set_content_type("application/octet-stream") |> + tower::add_body(as.raw(0:255)) |> + tower::build_response() + expect_equal(response$status, 200) + expect_equal(response$content_type, "application/octet-stream") + expect_equal(response$content, as.raw(0:255)) +}) + +test_that("can build a response with cookies", { + response <- tower::response_builder() |> + tower::add_cookie("name", "value") |> + tower::build_response() + expect_equal(response$status, 200) + expect_equal(response$content_type, "text/plain") + expect_equal(response$content, character(0)) + expect_equal(response$headers$`Set-Cookie`, "name=value; path=/; SameSite=Lax; HttpOnly") +}) + +test_that("can build a response with many cookies", { + response <- tower::response_builder() |> + tower::add_cookie("name", "value") |> + tower::add_cookie("name2", "value2") |> + tower::build_response() + expect_equal(response$status, 200) + expect_equal(response$content_type, "text/plain") + expect_equal(response$content, character(0)) + # Get all headers with name "Set-Cookie" + cookies <- response$headers[names(response$headers) == "Set-Cookie"] + expect_equal(cookies[[1]], "name=value; path=/; SameSite=Lax; HttpOnly") + expect_equal(cookies[[2]], "name2=value2; path=/; SameSite=Lax; HttpOnly") +}) + +test_that("can build a response with a cookie and a body", { + response <- tower::response_builder() |> + tower::add_cookie("name", "value") |> + tower::add_body("Hello, world!") |> + tower::build_response() + expect_equal(response$status, 200) + expect_equal(response$content_type, "text/plain") + expect_equal(response$content, "Hello, world!") + expect_equal(response$headers$`Set-Cookie`, "name=value; path=/; SameSite=Lax; HttpOnly") +}) + +test_that("can add headers to a response", { + response <- tower::response_builder() |> + tower::set_header("X-Test", "test") |> + tower::build_response() + expect_equal(response$status, 200) + expect_equal(response$content_type, "text/plain") + expect_equal(response$content, character(0)) + expect_equal(response$headers$`X-Test`, "test") +})