Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Dynamic Plot Endpoints #897

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 27 additions & 10 deletions R/openapi-spec.R
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,18 @@ endpointSpecification <- function(routerEndpointEntry, path = routerEndpointEntr
funcParams <- routerEndpointEntry$getFuncParams()
# Get the plumber decoration defined endpoint params
endpointParams <- routerEndpointEntry$getEndpointParams()

# if legacy plumber.staticSerializers is specified then ignore all req serializer
# based parameters
if (getOption("plumber.staticSerializers", default="FALSE") == "TRUE") {
schloerke marked this conversation as resolved.
Show resolved Hide resolved
serializerParams <- list()
} else {
# Get the plumber serializer defined endpoint params
serializerParams <- routerEndpointEntry$getSerializerParams()
}
Comment on lines +21 to +28
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's let the endpoint determine it's serializer params.

This will allow for the endpoint to have full control vs using a globally set flag.

Counter example app: API with two routes: 1 w/ dynamic serializer and 1 w/ static serializer.

Suggested change
# if legacy plumber.staticSerializers is specified then ignore all req serializer
# based parameters
if (getOption("plumber.staticSerializers", default="FALSE") == "TRUE") {
serializerParams <- list()
} else {
# Get the plumber serializer defined endpoint params
serializerParams <- routerEndpointEntry$getSerializerParams()
}
# Get the plumber serializer defined endpoint params
serializerParams <- routerEndpointEntry$getSerializerParams()


for (verb in routerEndpointEntry$verbs) {
params <- parametersSpecification(endpointParams, pathParams, funcParams)
params <- parametersSpecification(endpointParams, pathParams, funcParams, serializerParams)

# If we haven't already documented a path param, we should add it here.
# FIXME: warning("Undocumented path parameters: ", paste0())
Expand Down Expand Up @@ -81,15 +91,15 @@ responsesSpecification <- function(endpts){
#' Extract the OpenAPI parameters Specification from the endpoint
#' paramters.
#' @noRd
parametersSpecification <- function(endpointParams, pathParams, funcParams = NULL){
parametersSpecification <- function(endpointParams, pathParams, funcParams = NULL, serializerParams = NULL){

params <- list(
parameters = list(),
requestBody = NULL
)
inBody <- filterApiTypes("requestBody", "location")
inRaw <- filterApiTypes("binary", "format")
for (p in unique(c(names(endpointParams), pathParams$name, names(funcParams)))) {
for (p in unique(c(names(endpointParams), pathParams$name, names(funcParams), names(serializerParams)))) {

# Dealing with priorities endpointParams > pathParams > funcParams
# For each p, find out which source to trust for :
Expand All @@ -112,27 +122,34 @@ parametersSpecification <- function(endpointParams, pathParams, funcParams = NUL
type <- priorizeProperty(defaultApiType,
pathParams[pathParams$name == p,]$type,
endpointParams[[p]]$type,
funcParams[[p]]$type)
funcParams[[p]]$type,
serializerParams[[p]]$type)
type <- plumberToApiType(type, inPath = TRUE)
isArray <- priorizeProperty(defaultIsArray,
pathParams[pathParams$name == p,]$isArray,
endpointParams[[p]]$isArray,
funcParams[[p]]$isArray)
funcParams[[p]]$isArray,
serializerParams[[p]]$isArray)
} else {
location <- "query"
style <- "form"
explode <- TRUE
type <- priorizeProperty(defaultApiType,
endpointParams[[p]]$type,
funcParams[[p]]$type)
funcParams[[p]]$type,
serializerParams[[p]]$type)
type <- plumberToApiType(type)
isArray <- priorizeProperty(defaultIsArray,
endpointParams[[p]]$isArray,
funcParams[[p]]$isArray)
funcParams[[p]]$isArray,
serializerParams[[p]]$isArray)
required <- priorizeProperty(funcParams[[p]]$required,
endpointParams[[p]]$required)
endpointParams[[p]]$required,
serializerParams[[p]]$required)
}

desc <- endpointParams[[p]]$desc %||% serializerParams[[p]]$desc

# Building OpenAPI Specification
if (type %in% inBody) {
if (length(params$requestBody) == 0L) {
Expand All @@ -143,7 +160,7 @@ parametersSpecification <- function(endpointParams, pathParams, funcParams = NUL
type = type,
format = apiTypesInfo[[type]]$format,
example = funcParams[[p]]$example,
description = endpointParams[[p]]$desc
description = desc
)
if (type %in% inRaw) {
names(params$requestBody$content) <- "multipart/form-data"
Expand All @@ -166,7 +183,7 @@ parametersSpecification <- function(endpointParams, pathParams, funcParams = NUL
} else {
paramList <- list(
name = p,
description = endpointParams[[p]]$desc,
description = desc,
`in` = location,
required = required,
schema = list(
Expand Down
9 changes: 7 additions & 2 deletions R/options_plumber.R
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
#' \item{`plumber.legacyRedirects`}{Plumber will redirect legacy route `/__swagger__/` and
#' `/__swagger__/index.html` to `../__docs__/` and `../__docs__/index.html`. You can disable this
#' by settings this option to `FALSE`. Defaults to `TRUE`}
#' \item{`plumber.staticSerializers`}{Plumber will use fixed serializers and
#' will not interpret e.g. plot_width and plot_height parameters as plot image
#' size instructions}
Comment on lines +44 to +46
Copy link
Collaborator

@schloerke schloerke Jan 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#' \item{`plumber.staticSerializers`}{Plumber will use fixed serializers and
#' will not interpret e.g. plot_width and plot_height parameters as plot image
#' size instructions}
#' \item{`plumber.staticSerializers`}{If `TRUE`, Plumber will use fixed serializers and
#' will not interpret e.g. `plot_width` and `plot_height` parameters as plot image
#' size instructions. Defaults to `FALSE`}

#' }
#'
#' @param ... Ignored. Should be empty
Expand All @@ -64,7 +67,8 @@ options_plumber <- function(
apiPath = getOption("plumber.apiPath"),
maxRequestSize = getOption("plumber.maxRequestSize"),
sharedSecret = getOption("plumber.sharedSecret"),
legacyRedirects = getOption("plumber.legacyRedirects")
legacyRedirects = getOption("plumber.legacyRedirects"),
staticSerializers = getOption("plumber.staticSerializers")
) {
ellipsis::check_dots_empty()

Expand All @@ -86,6 +90,7 @@ options_plumber <- function(
plumber.apiPath = apiPath,
plumber.maxRequestSize = maxRequestSize,
plumber.sharedSecret = sharedSecret,
plumber.legacyRedirects = legacyRedirects
plumber.legacyRedirects = legacyRedirects,
plumber.staticSerializers = staticSerializers
)
}
9 changes: 9 additions & 0 deletions R/plumber-step.R
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ PlumberEndpoint <- R6Class(
},
#' @field params endpoint parameters
params = NA,
#' @field serializer_params serializer parameters
serializer_params = NULL,
Comment on lines +199 to +200
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As much as I don't like it, let's make the camelCase and change to dynamicSerializerParams (or something to hint that it is the dynamic serializer params... dynSerParams?)

Will need to change all usage as well. :-/

#' @field tags endpoint tags
tags = NA,
#' @field parsers step allowed parsers
Expand Down Expand Up @@ -296,6 +298,13 @@ PlumberEndpoint <- R6Class(
}
self$params
},
#' @description retrieve serializer endpoint parameters
getSerializerParams = function() {
if (is.null(self$serializer_params)) {
return(list())
}
return(self$serializer_params)
},
Comment on lines +301 to +307
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#' @description retrieve serializer endpoint parameters
getSerializerParams = function() {
if (is.null(self$serializer_params)) {
return(list())
}
return(self$serializer_params)
},
#' @description retrieve serializer endpoint parameters
getSerializerParams = function() {
self$serializer_params %||% list()
},

# It would not make sense to have `$getPath()` and deprecate `$path`
#' @description Updates `$path` with a sanitized `path` and updates the internal path meta-data
#' @param path Path to set `$path`. If missing a beginning slash, one will be added.
Expand Down
137 changes: 113 additions & 24 deletions R/serializer.R
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ serializer_xml <- function() {
#' @param preexec_hook Function to be run directly before a [PlumberEndpoint] calls its route method.
#' @param postexec_hook Function to be run directly after a [PlumberEndpoint] calls its route method.
#' @param aroundexec_hook Function to be run around a [PlumberEndpoint] call. Must handle a `.next` argument to continue execution. \lifecycle{experimental}
#' @param serializer_params Dynamic serializer parameters. More docs needed here!
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#' @param serializer_params Dynamic serializer parameters. More docs needed here!
#' @param serializer_params Dynamic serializer parameters. For internal use.

#'
#' @export
#' @examples
Expand All @@ -457,7 +458,8 @@ endpoint_serializer <- function(
serializer,
preexec_hook = NULL,
postexec_hook = NULL,
aroundexec_hook = NULL
aroundexec_hook = NULL,
serializer_params = list()
) {

stopifnot(is.function(serializer))
Expand All @@ -466,7 +468,8 @@ endpoint_serializer <- function(
serializer = serializer,
preexec_hook = preexec_hook,
postexec_hook = postexec_hook,
aroundexec_hook = aroundexec_hook
aroundexec_hook = aroundexec_hook,
serializer_params = serializer_params
),
class = "plumber_endpoint_serializer"
)
Expand All @@ -475,6 +478,7 @@ endpoint_serializer <- function(
self_set_serializer <- function(self, serializer) {
if (inherits(serializer, "plumber_endpoint_serializer")) {
self$serializer <- serializer$serializer
self$serializer_params <- serializer$serializer_params
if (!is.null(serializer$preexec_hook)) {
self$registerHook("preexec", serializer$preexec_hook)
}
Expand All @@ -497,26 +501,37 @@ self_set_serializer <- function(self, serializer) {
#' The graphics device `dev_on` function will receive any arguments supplied to the serializer in addition to `filename`.
#' `filename` points to the temporary file name that should be used when saving content.
#' @param dev_off Function to turn off the graphics device. Defaults to [grDevices::dev.off()]
#' @param serializer_params More docs needed here
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO

#'
#' @export
serializer_device <- function(type, dev_on, dev_off = grDevices::dev.off) {
serializer_device <- function(type, dev_on, dev_off = grDevices::dev.off, serializer_params = list()) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use a rlang::missing_arg() as a placeholder and handle it if it is missing via the option value.

Suggested change
serializer_device <- function(type, dev_on, dev_off = grDevices::dev.off, serializer_params = list()) {
serializer_device <- function(type, dev_on, dev_off = grDevices::dev.off, serializer_params = missing_arg()) {

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use a rlang::missing_arg() as a placeholder and handle it if it is missing via the option value.

Suggested change
serializer_device <- function(type, dev_on, dev_off = grDevices::dev.off, serializer_params = list()) {
serializer_device <- function(type, dev_on, dev_off = grDevices::dev.off, serializer_params = missing_arg()) {


stopifnot(!missing(type))

stopifnot(!missing(dev_on))
stopifnot(is.function(dev_on))
stopifnot(length(formals(dev_on)) > 0)
if (!any(c("filename", "...") %in% names(formals(dev_on)))) {
stop("`dev_on` must contain an arugment called `filename` or have `...`")
stop("`dev_on` must contain an argument called `filename` or have `...`")
}

dev_requires_req <- "req" %in% names(formals(dev_on))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding validations on dev_on()

** Untested

Suggested change
dev_requires_req <- "req" %in% names(formals(dev_on))
dev_on_arg_names <- names(formals(dev_on))
dev_on_requires_req <- "req" %in% dev_on_arg_names
# Make sure extra args exist
if (dev_on_requires_req) {
dots_pos <- which("..." %in% dev_on_arg_names
if (length(dots_pos) == 0) stop("`dev_on()` must contain arguments `...` if using `req`")
req_pos <- which("..." %in% dev_on_arg_names
if (!isTRUE(dots_pos < req_pos)) {
stop("`dev_on()` must have arguments `...` before `req=`)
}
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for all the immediate feedback...

Given how much there is, it might be an idea to make and edit these changes in an editor and to add some unit tests as we go?

If it helps, we can hold off on this (and the other PRs) until RStudio have capacity to invest the time? (That might also help me negotiate some wriggle room with my work pressures too!)


stopifnot(is.function(dev_off))

endpoint_serializer(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handling the serializer_params

** Untested

Suggested change
endpoint_serializer(
serializer_params <- rlang::maybe_missing(serializer_params, {
# Legacy support for static serializer parameters
if (isTRUE(getOption("plumber.staticSerializers", default=FALSE)) return(list())
# All known parameters for devices
serializer_param_list()
})
endpoint_serializer(

serializer = serializer_content_type(type),
aroundexec_hook = function(..., .next) {
tmpfile <- tempfile()

dev_on(filename = tmpfile)
if (dev_requires_req) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Using larger name from above suggestion)

Suggested change
if (dev_requires_req) {
if (dev_on_requires_req) {

dyn_args <- list(...)
req <- dyn_args[["req"]]
dev_on(filename = tmpfile, req = req)
} else {
dev_on(filename = tmpfile)
}

device_id <- dev.cur()
dev_off_once <- once(function() dev_off(device_id))

Expand Down Expand Up @@ -558,68 +573,142 @@ serializer_device <- function(type, dev_on, dev_off = grDevices::dev.off) {
} else {
success(result)
}
}
},
serializer_params = serializer_params
)
}

serializer_param_width <- function() {
list(plot_width = list(desc="Width of plot image in units", type="number", required=FALSE, isArray=FALSE))
}
serializer_param_height <- function() {
list(plot_height = list(desc="Height of plot image in units", type="number", required=FALSE, isArray=FALSE))
}
serializer_param_res <- function() {
list(plot_res = list(desc="Resolution of plot image", type="number", required=FALSE, isArray=FALSE))
}
serializer_param_units <- function() {
list(plot_units = list(desc="Units of plot image", type="string", required=FALSE, isArray=FALSE))
}
serializer_param_pointsize <- function() {
list(plot_pointsize = list(desc="Point size of plot image - TODO - better desc", type="number", required=FALSE, isArray=FALSE))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO

}
serializer_param_bg <- function() {
list(plot_bg = list(desc="Background colour of plot image", type="string", required=FALSE, isArray=FALSE))
}

serializer_param_list <- function(all_except = character()) {
params <- c(
serializer_param_width(),
serializer_param_height(),
serializer_param_res(),
serializer_param_units(),
serializer_param_pointsize(),
serializer_param_bg()
)

# oh I wish I had purrr!
ignored <- Map(function(x) {
params[[x]] <<- NULL
}, all_except)
Comment on lines +611 to +613
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might work?

** Untested

Suggested change
ignored <- Map(function(x) {
params[[x]] <<- NULL
}, all_except)
params[all_except] <- NULL


params
}


serialize_dimensions_args_preparer <- function(req, ...) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matching cosmetic change below

Suggested change
serialize_dimensions_args_preparer <- function(req, ...) {
serialize_dev_args_preparer <- function(req, ...) {


# if legacy plumber.staticSerializers is specified then ignore all req
# based parameters
if (getOption("staticSerializers", default="FALSE") == "TRUE") {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same change as above

Suggested change
if (getOption("staticSerializers", default="FALSE") == "TRUE") {
if (isTRUE(getOption("plumber.staticSerializers", default=FALSE))) {

return(list(...))
}

as_numeric_nullable <- function(preferred, alternative) {
option <- preferred %||% alternative
if (is.null(option)) {
NULL
} else {
as.numeric(option)
}
Comment on lines +629 to +633
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cosmetic

Suggested change
if (is.null(option)) {
NULL
} else {
as.numeric(option)
}
if (is.null(option)) return(NULL)
as.numeric(option)

}

doc_args <- list(...)
doc_args$width <- as_numeric_nullable(req$args$plot_width, doc_args$width)
doc_args$height <- as_numeric_nullable(req$args$plot_height, doc_args$height)
doc_args$units <- req$args$plot_units %||% doc_args$units
doc_args$res <- as_numeric_nullable(req$args$plot_res, doc_args$res)
doc_args$pointsize <- as_numeric_nullable(req$args$plot_pointsize, doc_args$pointsize)
doc_args$bg <- req$args$plot_bg %||% doc_args$bg
Comment on lines +637 to +642
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is your motivation to use plot_* for the arg names?
Why not pass them through directly?

My interpretation:

  • Minimal param clashing with plot_*
  • Confusion of two differently named parameters
    • Params in @serializer png list(width=200)
    • Params in http://....../plot_route?plot_width=200

If there isn't too much motivation for using plot_* names, I'd like to keep the consistent arg names when possible. (Knowing there could be param name clashing with the regular route params)

Copy link
Author

@slodge slodge Jan 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As well as avoiding clashes, my main motivation behind plot_ names was to make it easier to distinguish the plot parameters from the business logic - e.g. in https://.../palmerPenguins?min_flipper_length=200&min_bill_length=40&plot_width=400&plot_height=500

The naming also makes the business logic vs plot parameter separation obvious in the Swagger UI (maybe some more ordering there might also help?)

e.g. it's obvious here which params are business vs which are plot:
image

Asides:

  • initially I was wondering about using e.g. plot.width to try to make the plot parameters look like a separate object in the REST url (but couldn't spot any docs on that in https://swagger.io/docs/specification/describing-parameters/)
  • also wondering whether we should allow HTTP header insertion of these parameters (not sure that's very RESTFUL though)


return(doc_args)
}

serializer_image_dev_on_func <- function(grFunc, ...) {
function(filename, req) {
dimension_args <- serialize_dimensions_args_preparer(req, ...)
rlang::exec(grFunc, filename, !!!dimension_args)
Comment on lines +649 to +650
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cosmetic

Suggested change
dimension_args <- serialize_dimensions_args_preparer(req, ...)
rlang::exec(grFunc, filename, !!!dimension_args)
dev_args <- serialize_dev_args_preparer(req, ...)
rlang::exec(grFunc, filename, !!!dev_args)

}
}


#' @describeIn serializers JPEG image serializer. See also: [grDevices::jpeg()]
#' @export
serializer_jpeg <- function(..., type = "image/jpeg") {
serializer_device(
type = type,
dev_on = function(filename) {
grDevices::jpeg(filename, ...)
}
dev_on = serializer_image_dev_on_func(grDevices::jpeg, ...),
serializer_params = serializer_param_list()
)
}
Comment on lines 657 to 663
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should allow users to determine if the dynamic params are used as the serializer level.

Maybe something like:

get_serializer_params <- function(dynamic_params) {
  has_dynamic_params <- 
    rlang::maybe_missing(dynamic_params, {
      # Legacy support for static serializer parameters
      if (isTRUE(getOption("plumber.staticSerializers", default=FALSE)) return(FALSE)
      # Support dynamic params
      TRUE
    })
  if (!has_dynamic_params) return(list())
  serializer_param_list()
}
serializer_jpeg <- function(..., type = "image/jpeg", dynamic_params = missing_arg()) {
  serializer_device(
    type = type,
    dev_on = serializer_image_dev_on_func(grDevices::jpeg, ...),
    serializer_params = get_serializer_params(dynamic_params)
  )
}

Will need to document the dynamic_params parameter.


#' @describeIn serializers PNG image serializer. See also: [grDevices::png()]
#' @export
serializer_png <- function(..., type = "image/png") {
serializer_device(
type = type,
dev_on = function(filename) {
grDevices::png(filename, ...)
}
dev_on = serializer_image_dev_on_func(grDevices::png, ...),
serializer_params = serializer_param_list()
)
}

#' @describeIn serializers SVG image serializer. See also: [grDevices::svg()]
#' @export
serializer_svg <- function(..., type = "image/svg+xml") {
serializer_device(
type = type,
dev_on = function(filename) {
grDevices::svg(filename, ...)
}
dev_on = serializer_image_dev_on_func(grDevices::svg, ...),
serializer_params = serializer_param_list(all_except = c("res"))
)
}

#' @describeIn serializers BMP image serializer. See also: [grDevices::bmp()]
#' @export
serializer_bmp <- function(..., type = "image/bmp") {
serializer_device(
type = type,
dev_on = function(filename) {
grDevices::bmp(filename, ...)
}
dev_on = serializer_image_dev_on_func(grDevices::bmp, ...),
serializer_params = serializer_param_list()
)
}

#' @describeIn serializers TIFF image serializer. See also: [grDevices::tiff()]
#' @export
serializer_tiff <- function(..., type = "image/tiff") {
serializer_device(
type = type,
dev_on = function(filename) {
grDevices::tiff(filename, ...)
}
dev_on = serializer_image_dev_on_func(grDevices::tiff, ...),
serializer_params = serializer_param_list()
)
}

#' @describeIn serializers PDF image serializer. See also: [grDevices::pdf()]
#' @export
serializer_pdf <- function(..., type = "application/pdf") {
serializer_device(
type = type,
dev_on = function(filename) {
grDevices::pdf(filename, ...)
}
dev_on = serializer_image_dev_on_func(grDevices::pdf, ...),
serializer_params = serializer_param_list(all_except = c("pointsize", "res", "units"))
)
}

Expand Down
Loading