mirror of
https://github.com/rstudio/shiny.git
synced 2026-01-11 07:58:11 -05:00
Compare commits
12 Commits
wch-module
...
feature/ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
417f7f7236 | ||
|
|
c1d92c2767 | ||
|
|
3d2f677e2f | ||
|
|
b15cc6cbc0 | ||
|
|
4fbb8a436c | ||
|
|
308b41b8e8 | ||
|
|
516d0cd2ca | ||
|
|
f9d8217f90 | ||
|
|
58ad213a6f | ||
|
|
6ef5c7728e | ||
|
|
58a5fe9a84 | ||
|
|
c633c8b7dd |
12
NAMESPACE
12
NAMESPACE
@@ -2,18 +2,24 @@
|
||||
|
||||
S3method("$",reactivevalues)
|
||||
S3method("$",session_proxy)
|
||||
S3method("$",shinyapi)
|
||||
S3method("$",shinyoutput)
|
||||
S3method("$<-",reactivevalues)
|
||||
S3method("$<-",session_proxy)
|
||||
S3method("$<-",shinyapi)
|
||||
S3method("$<-",shinyoutput)
|
||||
S3method("[",reactivevalues)
|
||||
S3method("[",shinyapi)
|
||||
S3method("[",shinyoutput)
|
||||
S3method("[<-",reactivevalues)
|
||||
S3method("[<-",shinyapi)
|
||||
S3method("[<-",shinyoutput)
|
||||
S3method("[[",reactivevalues)
|
||||
S3method("[[",session_proxy)
|
||||
S3method("[[",shinyapi)
|
||||
S3method("[[",shinyoutput)
|
||||
S3method("[[<-",reactivevalues)
|
||||
S3method("[[<-",shinyapi)
|
||||
S3method("[[<-",shinyoutput)
|
||||
S3method("names<-",reactivevalues)
|
||||
S3method(as.list,reactivevalues)
|
||||
@@ -194,6 +200,11 @@ export(runUrl)
|
||||
export(safeError)
|
||||
export(selectInput)
|
||||
export(selectizeInput)
|
||||
export(serveCSV)
|
||||
export(serveJSON)
|
||||
export(servePlot)
|
||||
export(serveRaw)
|
||||
export(serveText)
|
||||
export(serverInfo)
|
||||
export(setBookmarkExclude)
|
||||
export(setProgress)
|
||||
@@ -267,3 +278,4 @@ import(httpuv)
|
||||
import(methods)
|
||||
import(mime)
|
||||
import(xtable)
|
||||
importFrom(utils,write.csv)
|
||||
|
||||
2
NEWS.md
2
NEWS.md
@@ -7,7 +7,7 @@ Here are some highlights from this release. For more details, see the full chang
|
||||
|
||||
## Support for testing Shiny applications
|
||||
|
||||
Shiny now supports automated testing of applications, with the [shinytest](https://github.com/MangoTheCat/shinytest) package. Shinytest has not yet been released on CRAN, but will be soon. ([#18](https://github.com/rstudio/shiny/issues/18), [#1464](https://github.com/rstudio/shiny/pull/1464))
|
||||
Shiny now supports automated testing of applications, with the [shinytest](https://github.com/rstudio/shinytest) package. Shinytest has not yet been released on CRAN, but will be soon. ([#18](https://github.com/rstudio/shiny/issues/18), [#1464](https://github.com/rstudio/shiny/pull/1464))
|
||||
|
||||
## Debounce/throttle reactives
|
||||
|
||||
|
||||
@@ -342,26 +342,10 @@ RestoreContext <- R6Class("RestoreContext",
|
||||
}
|
||||
|
||||
|
||||
inputs <- parseQueryString(inputStr, nested = TRUE)
|
||||
values <- parseQueryString(valueStr, nested = TRUE)
|
||||
inputs <- parseQueryStringJSON(inputStr, nested = TRUE)
|
||||
values <- parseQueryStringJSON(valueStr, nested = TRUE)
|
||||
|
||||
valuesFromJSON <- function(vals) {
|
||||
mapply(names(vals), vals, SIMPLIFY = FALSE,
|
||||
FUN = function(name, value) {
|
||||
tryCatch(
|
||||
jsonlite::fromJSON(value),
|
||||
error = function(e) {
|
||||
stop("Failed to parse URL parameter \"", name, "\"")
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
inputs <- valuesFromJSON(inputs)
|
||||
self$input <- RestoreInputSet$new(inputs)
|
||||
|
||||
values <- valuesFromJSON(values)
|
||||
self$values <- list2env2(values, self$values)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -41,3 +41,229 @@ sessionHandler <- function(req) {
|
||||
shinysession$handleRequest(subreq)
|
||||
})
|
||||
}
|
||||
|
||||
apiHandler <- function(serverFuncSource) {
|
||||
function(req) {
|
||||
path <- req$PATH_INFO
|
||||
if (is.null(path))
|
||||
return(NULL)
|
||||
|
||||
matches <- regmatches(path, regexec('^/api/(.*)$', path))
|
||||
if (length(matches[[1]]) == 0)
|
||||
return(NULL)
|
||||
|
||||
apiName <- matches[[1]][2]
|
||||
|
||||
sharedSecret <- getOption('shiny.sharedSecret')
|
||||
if (!is.null(sharedSecret)
|
||||
&& !identical(sharedSecret, req$HTTP_SHINY_SHARED_SECRET)) {
|
||||
stop("Incorrect shared secret")
|
||||
}
|
||||
|
||||
if (!is.null(getOption("shiny.observer.error", NULL))) {
|
||||
warning(
|
||||
call. = FALSE,
|
||||
"options(shiny.observer.error) is no longer supported; please unset it!"
|
||||
)
|
||||
stopApp()
|
||||
}
|
||||
|
||||
# need to give a fake websocket to the session
|
||||
ws <- list(
|
||||
request = req,
|
||||
sendMessage = function(...) {
|
||||
#print(list(...))
|
||||
}
|
||||
)
|
||||
|
||||
# Accept JSON query string and/or JSON body as input values
|
||||
inputVals <- c(
|
||||
parseQueryStringJSON(req$QUERY_STRING),
|
||||
parseJSONBody(req)
|
||||
)
|
||||
|
||||
shinysession <- ShinySession$new(ws)
|
||||
on.exit({
|
||||
try({
|
||||
# Clean up the session. Very important, so that observers
|
||||
# and such don't hang around, and to let memory get gc'd.
|
||||
shinysession$wsClosed()
|
||||
appsByToken$remove(shinysession$token)
|
||||
})
|
||||
}, add = TRUE)
|
||||
appsByToken$set(shinysession$token, shinysession)
|
||||
shinysession$setShowcase(.globals$showcaseDefault)
|
||||
|
||||
serverFunc <- withReactiveDomain(NULL, serverFuncSource())
|
||||
|
||||
tryCatch({
|
||||
withReactiveDomain(shinysession, {
|
||||
shinysession$manageInputs(inputVals)
|
||||
do.call(serverFunc, argsForServerFunc(serverFunc, shinysession))
|
||||
result <- NULL
|
||||
shinysession$enableApi(apiName, function(value) {
|
||||
result <<- try(withLogErrors(value), silent = TRUE)
|
||||
})
|
||||
flushReact()
|
||||
resultToResponse(result)
|
||||
})
|
||||
}, error = function(e) {
|
||||
return(httpResponse(
|
||||
status=500,
|
||||
content=htmlEscape(conditionMessage(e))
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
apiWsHandler <- function(serverFuncSource) {
|
||||
function(ws) {
|
||||
path <- ws$request$PATH_INFO
|
||||
if (is.null(path))
|
||||
return(NULL)
|
||||
|
||||
matches <- regmatches(path, regexec('^/api/(.*)$', path))
|
||||
if (length(matches[[1]]) == 0)
|
||||
return(NULL)
|
||||
|
||||
apiName <- matches[[1]][2]
|
||||
|
||||
sharedSecret <- getOption('shiny.sharedSecret')
|
||||
if (!is.null(sharedSecret)
|
||||
&& !identical(sharedSecret, ws$request$HTTP_SHINY_SHARED_SECRET)) {
|
||||
ws$close()
|
||||
return(TRUE)
|
||||
}
|
||||
|
||||
if (!is.null(getOption("shiny.observer.error", NULL))) {
|
||||
warning(
|
||||
call. = FALSE,
|
||||
"options(shiny.observer.error) is no longer supported; please unset it!"
|
||||
)
|
||||
stopApp()
|
||||
}
|
||||
|
||||
inputVals <- parseQueryStringJSON(ws$request$QUERY_STRING)
|
||||
|
||||
# Give a fake websocket to suppress messages from session
|
||||
shinysession <- ShinySession$new(list(
|
||||
request = ws$request,
|
||||
sendMessage = function(...) {
|
||||
#print(list(...))
|
||||
}
|
||||
))
|
||||
appsByToken$set(shinysession$token, shinysession)
|
||||
shinysession$setShowcase(.globals$showcaseDefault)
|
||||
|
||||
serverFunc <- withReactiveDomain(NULL, serverFuncSource())
|
||||
|
||||
tryCatch({
|
||||
withReactiveDomain(shinysession, {
|
||||
shinysession$manageInputs(inputVals)
|
||||
do.call(serverFunc, argsForServerFunc(serverFunc, shinysession))
|
||||
shinysession$enableApi(apiName, function(value) {
|
||||
resp <- resultToResponse(value)
|
||||
if (resp$status != 200L) {
|
||||
warning("Error: ", responseToContent(resp))
|
||||
ws$close()
|
||||
} else {
|
||||
content <- responseToContent(resp)
|
||||
if (grepl("^image/", resp$content_type)) {
|
||||
content <- paste0("data:", resp$content_type, ";base64,",
|
||||
httpuv::rawToBase64(content))
|
||||
}
|
||||
try(ws$send(content), silent=TRUE)
|
||||
}
|
||||
})
|
||||
flushReact()
|
||||
})
|
||||
}, error = function(e) {
|
||||
ws$close()
|
||||
})
|
||||
|
||||
ws$onClose(function() {
|
||||
# Clean up the session. Very important, so that observers
|
||||
# and such don't hang around, and to let memory get gc'd.
|
||||
shinysession$wsClosed()
|
||||
appsByToken$remove(shinysession$token)
|
||||
})
|
||||
|
||||
# TODO: What to do on ws$onMessage?
|
||||
}
|
||||
}
|
||||
|
||||
parseJSONBody <- function(req) {
|
||||
if (identical(req[["REQUEST_METHOD"]], "POST")) {
|
||||
if (isTRUE(grepl(perl=TRUE, "^(text|application)/json(;\\s*charset\\s*=\\s*utf-8)?$", req[["HTTP_CONTENT_TYPE"]]))) {
|
||||
tmp <- file("", "w+b")
|
||||
on.exit(close(tmp))
|
||||
|
||||
input_file <- req[["rook.input"]]
|
||||
while (TRUE) {
|
||||
chunk <- input_file$read(8192L)
|
||||
if (length(chunk) == 0)
|
||||
break
|
||||
writeBin(chunk, tmp)
|
||||
}
|
||||
|
||||
return(jsonlite::fromJSON(tmp))
|
||||
}
|
||||
|
||||
if (is.null(req[["HTTP_CONTENT_TYPE"]])) {
|
||||
if (!is.null(req[["rook.input"]]) && length(req[["rook.input"]]$read(1L)) > 0) {
|
||||
stop("Invalid POST request (body provided without content type)")
|
||||
}
|
||||
return()
|
||||
}
|
||||
|
||||
stop("Invalid POST request (content type not supported)")
|
||||
}
|
||||
}
|
||||
|
||||
resultToResponse <- function(result) {
|
||||
if (inherits(result, "httpResponse")) {
|
||||
return(result)
|
||||
} else if (inherits(result, "try-error")) {
|
||||
return(httpResponse(
|
||||
status=500,
|
||||
content_type="text/plain",
|
||||
content=conditionMessage(attr(result, "condition"))
|
||||
))
|
||||
} else if (!is.null(attr(result, "content.type"))) {
|
||||
return(httpResponse(
|
||||
status=200L,
|
||||
content_type=attr(result, "content.type"),
|
||||
content=result
|
||||
))
|
||||
} else {
|
||||
return(httpResponse(
|
||||
status=200L,
|
||||
content_type="application/json",
|
||||
content=toJSON(result, pretty=TRUE)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
responseToContent <- function(result) {
|
||||
ct <- result$content_type
|
||||
textMode <- grepl("^text/", ct) || ct == "application/json" ||
|
||||
grepl("^application/xml($|\\+)", ct)
|
||||
|
||||
# TODO: Make sure text is UTF-8
|
||||
|
||||
if ("file" %in% names(result$content)) {
|
||||
filename <- result$content$file
|
||||
if ("owned" %in% names(result$content) && result$content$owned) {
|
||||
on.exit(unlink(filename), add = TRUE)
|
||||
}
|
||||
if (textMode)
|
||||
return(paste(readLines(filename), collapse = "\n"))
|
||||
else
|
||||
return(readBin(filename, raw(), file.info(filename)$size))
|
||||
} else {
|
||||
if (textMode)
|
||||
return(paste(result$content, collapse = "\n"))
|
||||
else
|
||||
return(result$content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,7 @@ createAppHandlers <- function(httpHandlers, serverFuncSource) {
|
||||
appHandlers <- list(
|
||||
http = joinHandlers(c(
|
||||
sessionHandler,
|
||||
apiHandler(serverFuncSource),
|
||||
httpHandlers,
|
||||
sys.www.root,
|
||||
resourcePathHandler,
|
||||
@@ -200,6 +201,11 @@ createAppHandlers <- function(httpHandlers, serverFuncSource) {
|
||||
return(TRUE)
|
||||
}
|
||||
|
||||
if (grepl("^/api/", ws$request$PATH_INFO)) {
|
||||
apiWsHandler(serverFuncSource)(ws)
|
||||
return(TRUE)
|
||||
}
|
||||
|
||||
if (!is.null(getOption("shiny.observer.error", NULL))) {
|
||||
warning(
|
||||
call. = FALSE,
|
||||
|
||||
61
R/shiny.R
61
R/shiny.R
@@ -405,6 +405,7 @@ ShinySession <- R6Class(
|
||||
fileUploadContext = 'FileUploadContext',
|
||||
.input = 'ANY', # Internal ReactiveValues object for normal input sent from client
|
||||
.clientData = 'ANY', # Internal ReactiveValues object for other data sent from the client
|
||||
apiObservers = list(),
|
||||
busyCount = 0L, # Number of observer callbacks that are pending. When 0, we are idle
|
||||
closedCallbacks = 'Callbacks',
|
||||
flushCallbacks = 'Callbacks',
|
||||
@@ -689,6 +690,7 @@ ShinySession <- R6Class(
|
||||
progressStack = 'Stack', # Stack of progress objects
|
||||
input = 'reactivevalues', # Externally-usable S3 wrapper object for .input
|
||||
output = 'ANY', # Externally-usable S3 wrapper object for .outputs
|
||||
api = 'ANY', # Externally-usable S3 wrapper object for APIs
|
||||
clientData = 'reactivevalues', # Externally-usable S3 wrapper object for .clientData
|
||||
token = 'character', # Used to identify this instance in URLs
|
||||
files = 'Map', # For keeping track of files sent to client
|
||||
@@ -725,6 +727,7 @@ ShinySession <- R6Class(
|
||||
.setLabel(self$clientData, 'clientData')
|
||||
|
||||
self$output <- .createOutputWriter(self)
|
||||
self$api <- .createApiWriter(self)
|
||||
|
||||
self$token <- createUniqueId(16)
|
||||
private$.outputs <- list()
|
||||
@@ -1632,6 +1635,19 @@ ShinySession <- R6Class(
|
||||
workerId(),
|
||||
URLencode(createUniqueId(8), TRUE)))
|
||||
},
|
||||
registerApi = function(name, func) {
|
||||
private$apiObservers[[name]] <- func
|
||||
},
|
||||
enableApi = function(name, callback) {
|
||||
rexpr <- private$apiObservers[[name]]
|
||||
if (is.null(rexpr)) {
|
||||
stop("API not found")
|
||||
}
|
||||
|
||||
observe({
|
||||
callback(..stacktraceon..(rexpr()))
|
||||
}, ..stacktraceon = FALSE)
|
||||
},
|
||||
# This function suspends observers for hidden outputs and resumes observers
|
||||
# for un-hidden outputs.
|
||||
manageHiddenOutputs = function() {
|
||||
@@ -1809,7 +1825,6 @@ outputOptions <- function(x, name, ...) {
|
||||
.subset2(x, 'impl')$outputOptions(name, ...)
|
||||
}
|
||||
|
||||
|
||||
#' Add callbacks for Shiny session events
|
||||
#'
|
||||
#' These functions are for registering callbacks on Shiny session events.
|
||||
@@ -1865,3 +1880,47 @@ flushAllSessions <- function() {
|
||||
NULL
|
||||
})
|
||||
}
|
||||
|
||||
.createApiWriter <- function(shinysession, ns = identity) {
|
||||
structure(list(impl=shinysession, ns=ns), class='shinyapi')
|
||||
}
|
||||
|
||||
#' @export
|
||||
`$<-.shinyapi` <- function(x, name, value) {
|
||||
name <- .subset2(x, 'ns')(name)
|
||||
|
||||
label <- deparse(substitute(value))
|
||||
if (length(substitute(value)) > 1) {
|
||||
# value is an object consisting of a call and its arguments. Here we want
|
||||
# to find the source references for the first argument (if there are
|
||||
# arguments), which generally corresponds to the reactive expression--
|
||||
# e.g. in renderTable({ x }), { x } is the expression to trace.
|
||||
attr(label, "srcref") <- srcrefFromShinyCall(substitute(value)[[2]])
|
||||
srcref <- attr(substitute(value)[[2]], "srcref")
|
||||
if (length(srcref) > 0)
|
||||
attr(label, "srcfile") <- srcFileOfRef(srcref[[1]])
|
||||
}
|
||||
.subset2(x, 'impl')$registerApi(name, value)
|
||||
return(invisible(x))
|
||||
}
|
||||
|
||||
#' @export
|
||||
`[[<-.shinyapi` <- `$<-.shinyapi`
|
||||
|
||||
#' @export
|
||||
`$.shinyapi` <- function(x, name) {
|
||||
stop("Reading objects from shinyapi object not allowed.")
|
||||
}
|
||||
|
||||
#' @export
|
||||
`[[.shinyapi` <- `$.shinyapi`
|
||||
|
||||
#' @export
|
||||
`[.shinyapi` <- function(values, name) {
|
||||
stop("Single-bracket indexing of shinyapi object is not allowed.")
|
||||
}
|
||||
|
||||
#' @export
|
||||
`[<-.shinyapi` <- function(values, name, value) {
|
||||
stop("Single-bracket indexing of shinyapi object is not allowed.")
|
||||
}
|
||||
|
||||
@@ -354,6 +354,98 @@ renderUI <- function(expr, env=parent.frame(), quoted=FALSE,
|
||||
markRenderFunction(uiOutput, renderFunc, outputArgs = outputArgs)
|
||||
}
|
||||
|
||||
#' @export
|
||||
serveJSON <- function(expr, env=parent.frame(), quoted=FALSE) {
|
||||
installExprFunction(expr, "func", env, quoted)
|
||||
function() {
|
||||
structure(
|
||||
toJSON(func(), pretty = TRUE),
|
||||
content.type = "application/json"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#' @export
|
||||
servePlot <- function(expr, env=parent.frame(), quoted=FALSE,
|
||||
defaultWidth = 600, defaultHeight = 400) {
|
||||
|
||||
if (!is.function(defaultWidth))
|
||||
defaultWidth <- valueToFunc(defaultWidth)
|
||||
if (!is.function(defaultHeight))
|
||||
defaultHeight <- valueToFunc(defaultHeight)
|
||||
|
||||
installExprFunction(expr, "func", env, quoted)
|
||||
function() {
|
||||
input <- getDefaultReactiveDomain()$input
|
||||
w <- if (!is.null(input$`plot-width`)) as.numeric(input$`plot-width`) else defaultWidth()
|
||||
h <- if (!is.null(input$`plot-height`)) as.numeric(input$`plot-height`) else defaultHeight()
|
||||
|
||||
pngfile <- plotPNG(function() {
|
||||
result <- withVisible(func())
|
||||
if (result$visible) {
|
||||
# Use capture.output to squelch printing to the actual console; we
|
||||
# are only interested in plot output
|
||||
utils::capture.output({
|
||||
# The value needs to be printed just in case it's an object that
|
||||
# requires printing to generate plot output, similar to ggplot2. But
|
||||
# for base graphics, it would already have been rendered when func was
|
||||
# called above, and the print should have no effect.
|
||||
print(result$value)
|
||||
})
|
||||
}
|
||||
}, width = w, height = h)
|
||||
|
||||
structure(
|
||||
list(file = pngfile, owned = TRUE),
|
||||
content.type = "image/png"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#' @importFrom utils write.csv
|
||||
#' @export
|
||||
serveCSV <- function(expr, env=parent.frame(), quoted=FALSE, row.names=FALSE) {
|
||||
installExprFunction(expr, "func", env, quoted)
|
||||
function() {
|
||||
tmp <- tempfile(".csv")
|
||||
write.csv(func(), tmp, row.names=row.names)
|
||||
structure(
|
||||
list(file = tmp, owned = TRUE),
|
||||
content.type = "text/csv"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#' @export
|
||||
serveText <- function(expr, env=parent.frame(), quoted=FALSE) {
|
||||
installExprFunction(expr, "func", env, quoted)
|
||||
function() {
|
||||
structure(
|
||||
paste(func(), collapse = "\n"),
|
||||
content.type = "text/plain"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#' @export
|
||||
serveRaw <- function(expr, env=parent.frame(), quoted=FALSE, contentType) {
|
||||
|
||||
if (!is.function(contentType))
|
||||
contentType <- valueToFunc(contentType)
|
||||
|
||||
installExprFunction(expr, "func", env, quoted)
|
||||
function() {
|
||||
bytes <- func()
|
||||
if (!is.raw(bytes)) {
|
||||
stop("serveRaw expects raw vector data")
|
||||
}
|
||||
structure(
|
||||
bytes,
|
||||
content.type = contentType()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#' File Downloads
|
||||
#'
|
||||
#' Allows content from the Shiny application to be made available to the user as
|
||||
|
||||
20
R/utils.R
20
R/utils.R
@@ -576,6 +576,20 @@ parseQueryString <- function(str, nested = FALSE) {
|
||||
res
|
||||
}
|
||||
|
||||
parseQueryStringJSON <- function(str, nested = FALSE) {
|
||||
vals <- parseQueryString(str, nested)
|
||||
mapply(names(vals), vals, SIMPLIFY = FALSE,
|
||||
FUN = function(name, value) {
|
||||
tryCatch(
|
||||
jsonlite::fromJSON(value),
|
||||
error = function(e) {
|
||||
stop("Failed to parse URL parameter \"", name, "\"")
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
# Assign value to the bottom element of the list x using recursive indices idx
|
||||
assignNestedList <- function(x = list(), idx, value) {
|
||||
for (i in seq_along(idx)) {
|
||||
@@ -1585,3 +1599,9 @@ Mutable <- R6Class("Mutable",
|
||||
get = function() { private$value }
|
||||
)
|
||||
)
|
||||
|
||||
# Turn a value into a no-arg function that returns that value
|
||||
valueToFunc <- function(val) {
|
||||
force(val)
|
||||
function() val
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user