Compare commits

...

12 Commits

Author SHA1 Message Date
Joe Cheng
417f7f7236 Clean up correctly after API caller leaves 2017-01-12 23:56:10 -05:00
Joe Cheng
c1d92c2767 API: Log errors; automatically print() ggplot2 2017-01-12 23:56:10 -05:00
Joe Cheng
3d2f677e2f Accept input via JSON POST 2017-01-12 23:56:10 -05:00
Joe Cheng
b15cc6cbc0 API parameters get parsed using JSON semantics
This gives us more accurate data types (numbers as ints/reals instead of strings)

Also adds serveRaw (raw vectors)
2017-01-12 23:56:10 -05:00
Joe Cheng
4fbb8a436c Import utils::write.csv 2017-01-12 23:56:10 -05:00
Joe Cheng
308b41b8e8 Improve result handling
- Arbitrary httpResponse can now be returned
- WebSocket API conn doesn't force everything to JSON
2017-01-12 23:56:10 -05:00
Joe Cheng
516d0cd2ca Squelch messages 2017-01-12 23:56:10 -05:00
Joe Cheng
f9d8217f90 Better API API :) 2017-01-12 23:56:10 -05:00
Joe Cheng
58ad213a6f Support plots 2017-01-12 23:56:10 -05:00
Joe Cheng
6ef5c7728e Fix shared secret check 2017-01-12 23:56:10 -05:00
Joe Cheng
58a5fe9a84 API prototype 2017-01-12 23:56:10 -05:00
Winston Chang
c633c8b7dd Update URL 2017-01-12 23:56:10 -05:00
8 changed files with 419 additions and 20 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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)
}
)

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -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.")
}

View File

@@ -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

View File

@@ -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
}