├── .github ├── .gitignore └── workflows │ ├── pkgdown.yaml │ └── R-CMD-check.yaml ├── vignettes ├── .gitignore ├── files │ ├── swagger.png │ ├── aggregate.png │ ├── warnings.png │ ├── rstudio-project.png │ ├── swagger-predict.png │ ├── calculated-field.png │ └── tableau-usage-guide.png ├── tableau-developer-guide.Rmd ├── introduction.Rmd ├── publishing-extensions.Rmd └── r-developer-guide.Rmd ├── LICENSE ├── R ├── plumbertableau.R ├── info.R ├── utils-pipe.R ├── zzz.R ├── rsc_filter.R ├── error_handler.R ├── test-helpers.R ├── new-rstudio-project.R ├── mock_tableau_request.R ├── tableau_extension.R ├── validate_request.R ├── utils.R ├── client.R ├── openapi.R ├── setup_guide.R ├── reroute.R ├── user_guide.R ├── messages.R └── tableau_handler.R ├── tests ├── testthat │ ├── test-info.R │ ├── test-validate_request.R │ ├── test-openapi.R │ ├── test-user_guide.R │ ├── test-rsc_filter.R │ ├── test-error_handler.R │ ├── test-mock_tableau_request.R │ ├── test-reroute.R │ ├── test-tableau_extension.R │ ├── test-tableau_handler.R │ ├── test-utils.R │ └── _snaps │ │ └── user_guide.md └── testthat.R ├── man ├── figures │ ├── calculated-field.png │ └── logo.svg ├── pipe.Rd ├── check_route.Rd ├── tableau_extension.Rd ├── plumbertableau-package.Rd ├── mock_tableau_request.Rd ├── arg_spec.Rd ├── tableau_invoke.Rd └── tableau_handler.Rd ├── inst ├── rstudio │ └── templates │ │ └── project │ │ ├── plumbertableau.png │ │ └── new-rstudio-project.dcf ├── plumber │ ├── mounts │ │ └── plumber.R │ ├── programmatic │ │ └── plumber.R │ ├── loess │ │ └── plumber.R │ ├── capitalize │ │ └── plumber.R │ └── stringutils │ │ └── plumber.R ├── www │ └── styles.css └── template │ └── index.html ├── .gitignore ├── NEWS.md ├── .Rbuildignore ├── codecov.yml ├── NAMESPACE ├── plumbertableau.Rproj ├── cran-comments.md ├── pkgdown └── _pkgdown.yml ├── LICENSE.md ├── DESCRIPTION ├── README.Rmd └── README.md /.github/.gitignore: -------------------------------------------------------------------------------- 1 | *.html 2 | -------------------------------------------------------------------------------- /vignettes/.gitignore: -------------------------------------------------------------------------------- 1 | *.html 2 | *.R 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | YEAR: 2021 2 | COPYRIGHT HOLDER: RStudio, PBC 3 | -------------------------------------------------------------------------------- /R/plumbertableau.R: -------------------------------------------------------------------------------- 1 | #' @keywords internal 2 | #' @import stringi 3 | "_PACKAGE" 4 | -------------------------------------------------------------------------------- /tests/testthat/test-info.R: -------------------------------------------------------------------------------- 1 | test_that("multiplication works", { 2 | expect_equal(2 * 2, 4) 3 | }) 4 | -------------------------------------------------------------------------------- /tests/testthat.R: -------------------------------------------------------------------------------- 1 | library(testthat) 2 | library(plumbertableau) 3 | 4 | test_check("plumbertableau") 5 | -------------------------------------------------------------------------------- /vignettes/files/swagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/plumbertableau/HEAD/vignettes/files/swagger.png -------------------------------------------------------------------------------- /tests/testthat/test-validate_request.R: -------------------------------------------------------------------------------- 1 | test_that("multiplication works", { 2 | expect_equal(2 * 2, 4) 3 | }) 4 | -------------------------------------------------------------------------------- /vignettes/files/aggregate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/plumbertableau/HEAD/vignettes/files/aggregate.png -------------------------------------------------------------------------------- /vignettes/files/warnings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/plumbertableau/HEAD/vignettes/files/warnings.png -------------------------------------------------------------------------------- /man/figures/calculated-field.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/plumbertableau/HEAD/man/figures/calculated-field.png -------------------------------------------------------------------------------- /vignettes/files/rstudio-project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/plumbertableau/HEAD/vignettes/files/rstudio-project.png -------------------------------------------------------------------------------- /vignettes/files/swagger-predict.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/plumbertableau/HEAD/vignettes/files/swagger-predict.png -------------------------------------------------------------------------------- /vignettes/files/calculated-field.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/plumbertableau/HEAD/vignettes/files/calculated-field.png -------------------------------------------------------------------------------- /vignettes/files/tableau-usage-guide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/plumbertableau/HEAD/vignettes/files/tableau-usage-guide.png -------------------------------------------------------------------------------- /inst/rstudio/templates/project/plumbertableau.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/plumbertableau/HEAD/inst/rstudio/templates/project/plumbertableau.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .Rproj.user 2 | .Rhistory 3 | .RData 4 | .Ruserdata 5 | docs 6 | inst/doc 7 | scratch 8 | .Renviron 9 | rsconnect/ 10 | notes.md 11 | /doc/ 12 | /Meta/ 13 | .DS_Store 14 | -------------------------------------------------------------------------------- /inst/rstudio/templates/project/new-rstudio-project.dcf: -------------------------------------------------------------------------------- 1 | Title: New Tableau Extension (plumbertableau) Project 2 | Binding: newRStudioProject 3 | Subtitle: Create a new Tableau Extension using plumbertableau 4 | OpenFiles: plumber.R 5 | Icon: plumbertableau.png 6 | -------------------------------------------------------------------------------- /NEWS.md: -------------------------------------------------------------------------------- 1 | # plumbertableau 0.1.1 2 | 3 | * Added an FAQ list to README. 4 | 5 | * Adapted tests to the latest version of the **markdown** package. 6 | 7 | * Fixed a CRAN check NOTE. 8 | 9 | # plumbertableau 0.1.0 10 | 11 | * Initial package release 12 | -------------------------------------------------------------------------------- /R/info.R: -------------------------------------------------------------------------------- 1 | info <- function() { 2 | list( 3 | description = "Plumber Tableau API", 4 | creation_time = 0, 5 | name = "Local Plumber API", 6 | versions = list( 7 | v1 = list( 8 | features = NULL 9 | ) 10 | ) 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /R/utils-pipe.R: -------------------------------------------------------------------------------- 1 | #' Pipe operator 2 | #' 3 | #' See \code{magrittr::\link[magrittr:pipe]{\%>\%}} for details. 4 | #' 5 | #' @name %>% 6 | #' @rdname pipe 7 | #' @keywords internal 8 | #' @export 9 | #' @importFrom magrittr %>% 10 | #' @usage lhs \%>\% rhs 11 | NULL 12 | -------------------------------------------------------------------------------- /.Rbuildignore: -------------------------------------------------------------------------------- 1 | ^.*\.Rproj$ 2 | ^\.Rproj\.user$ 3 | ^LICENSE\.md$ 4 | ^_pkgdown\.yml$ 5 | ^docs$ 6 | ^pkgdown$ 7 | ^\.github$ 8 | ^scratch$ 9 | /rsconnect$ 10 | ^notes\.md$ 11 | ^\.Renviron$ 12 | ^codecov\.yml$ 13 | ^README\.Rmd$ 14 | ^doc$ 15 | ^Meta$ 16 | ^cran-comments\.md$ 17 | ^CRAN-RELEASE$ 18 | -------------------------------------------------------------------------------- /inst/plumber/mounts/plumber.R: -------------------------------------------------------------------------------- 1 | library(plumber) 2 | library(plumbertableau) 3 | 4 | pr_bar <- pr() %>% 5 | pr_get("/bar", function() "bar", parser = "json") 6 | 7 | #* @plumber 8 | function(pr) { 9 | pr %>% 10 | pr_mount("/foo", pr_bar) 11 | } 12 | 13 | #* @plumber 14 | tableau_extension 15 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | 3 | coverage: 4 | status: 5 | project: 6 | default: 7 | target: auto 8 | threshold: 1% 9 | informational: true 10 | patch: 11 | default: 12 | target: auto 13 | threshold: 1% 14 | informational: true 15 | -------------------------------------------------------------------------------- /man/pipe.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/utils-pipe.R 3 | \name{\%>\%} 4 | \alias{\%>\%} 5 | \title{Pipe operator} 6 | \usage{ 7 | lhs \%>\% rhs 8 | } 9 | \description{ 10 | See \code{magrittr::\link[magrittr:pipe]{\%>\%}} for details. 11 | } 12 | \keyword{internal} 13 | -------------------------------------------------------------------------------- /NAMESPACE: -------------------------------------------------------------------------------- 1 | # Generated by roxygen2: do not edit by hand 2 | 3 | export("%>%") 4 | export(arg_spec) 5 | export(mock_tableau_request) 6 | export(return_spec) 7 | export(tableau_extension) 8 | export(tableau_handler) 9 | export(tableau_invoke) 10 | import(stringi) 11 | importFrom(htmltools,tags) 12 | importFrom(magrittr,"%>%") 13 | importFrom(stats,setNames) 14 | -------------------------------------------------------------------------------- /tests/testthat/test-openapi.R: -------------------------------------------------------------------------------- 1 | test_that("OpenAPI specification works", { 2 | pr <- plumber::plumb(pr_path()) 3 | spec <- pr$getApiSpec() 4 | for (path in spec$paths) { 5 | if (!is.null(path$post)) { 6 | expect_match(path$post$requestBody$description, "^]*>Tableau Request\n+

This is a mock Tableau request.") 7 | } 8 | } 9 | }) 10 | -------------------------------------------------------------------------------- /tests/testthat/test-user_guide.R: -------------------------------------------------------------------------------- 1 | # This test uses a snapshot test. 2 | # (https://testthat.r-lib.org/articles/snapshotting.html) 3 | # If it fails, but the user guide is displaying correctly, update it with 4 | # > snapshot_accept("user_guide") 5 | test_that("user_guide", { 6 | pr <- plumber::plumb(pr_path()) 7 | expect_snapshot(cat(render_user_guide("", pr))) 8 | }) 9 | -------------------------------------------------------------------------------- /R/zzz.R: -------------------------------------------------------------------------------- 1 | .onLoad <- function(libname, pkgname) { 2 | # Debugging 3 | debugme::debugme() 4 | 5 | # Options 6 | op <- options() 7 | op.plumbertableau <- list( 8 | plumbertableau.warnings = TRUE 9 | ) 10 | 11 | toset <- !(names(op.plumbertableau) %in% names(op)) 12 | if(any(toset)) options(op.plumbertableau[toset]) 13 | 14 | invisible() 15 | } 16 | -------------------------------------------------------------------------------- /tests/testthat/test-rsc_filter.R: -------------------------------------------------------------------------------- 1 | req <- make_req( 2 | verb = "POST", 3 | path = "/loess", 4 | postBody = encode_payload(script = "concat", letters, .toJSON_args = NULL, raw = FALSE), 5 | HTTP_X_RS_CORRELATION_ID = 123456 6 | ) 7 | 8 | test_that("rsc_filter() correctly modifies requests", { 9 | rsc_filter(req) 10 | expect_equal(req$HTTP_X_CORRELATION_ID, 123456) 11 | }) 12 | -------------------------------------------------------------------------------- /R/rsc_filter.R: -------------------------------------------------------------------------------- 1 | # A plumber filter for dealing with various details related to RStudio Connect 2 | rsc_filter <- function(req, res) { 3 | # Request ID - RStudio Connect sends X-RS-CORRELATION-ID, but we will use the 4 | # generic X-CORRELATION-ID 5 | if (!rlang::is_null(req$HTTP_X_RS_CORRELATION_ID)) { 6 | req$HTTP_X_CORRELATION_ID <- req$HTTP_X_RS_CORRELATION_ID 7 | } 8 | 9 | plumber::forward() 10 | } 11 | -------------------------------------------------------------------------------- /R/error_handler.R: -------------------------------------------------------------------------------- 1 | # Gracefully handle Plumber errors so they are effectively communicated back to 2 | # Tableau 3 | # @param req Plumber \code{req} object 4 | # @param res Plumber \code{res} object 5 | # @param err An error object passed from Plumber 6 | error_handler <- function(req, res, err) { 7 | res$status <- 500 8 | list( 9 | message = jsonlite::unbox("Server Error"), 10 | info = jsonlite::unbox(conditionMessage(err)) 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /tests/testthat/test-error_handler.R: -------------------------------------------------------------------------------- 1 | test_that("error_handler returns correct object", { 2 | req <- as.environment(list()) 3 | res <- as.environment(list()) 4 | err <- rlang::catch_cnd(stop("This is an error")) 5 | handled_err <- error_handler(req = req, res = res, err = err) 6 | expect_equal(handled_err, list( 7 | message = jsonlite::unbox("Server Error"), 8 | info = jsonlite::unbox("This is an error") 9 | )) 10 | expect_equal(res$status, 500) 11 | }) 12 | -------------------------------------------------------------------------------- /plumbertableau.Rproj: -------------------------------------------------------------------------------- 1 | Version: 1.0 2 | 3 | RestoreWorkspace: Default 4 | SaveWorkspace: Default 5 | AlwaysSaveHistory: Default 6 | 7 | EnableCodeIndexing: Yes 8 | UseSpacesForTab: Yes 9 | NumSpacesForTab: 2 10 | Encoding: UTF-8 11 | 12 | RnwWeave: Sweave 13 | LaTeX: pdfLaTeX 14 | 15 | AutoAppendNewline: Yes 16 | StripTrailingWhitespace: Yes 17 | 18 | BuildType: Package 19 | PackageUseDevtools: Yes 20 | PackageInstallArgs: --no-multiarch --with-keep.source 21 | PackageRoxygenize: rd,collate,namespace 22 | -------------------------------------------------------------------------------- /man/check_route.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/utils.R 3 | \name{check_route} 4 | \alias{check_route} 5 | \title{Checks a Plumber route for Tableau compliance} 6 | \usage{ 7 | check_route(route) 8 | } 9 | \arguments{ 10 | \item{route}{A plumber route} 11 | } 12 | \value{ 13 | Provides warnings based on features of \code{route} 14 | } 15 | \description{ 16 | Checks a route to ensure that it accepts POST requests and uses the default JSON parser and serializer. 17 | } 18 | \keyword{internal} 19 | -------------------------------------------------------------------------------- /inst/plumber/programmatic/plumber.R: -------------------------------------------------------------------------------- 1 | library(plumber) 2 | library(plumbertableau) 3 | 4 | #* @plumber 5 | function(pr) { 6 | pr %>% 7 | pr_post("/capitalize", tableau_handler( 8 | args = list( 9 | str_value = arg_spec( 10 | type = "character", 11 | desc = "String(s) to be capitalized" 12 | ) 13 | ), 14 | return = return_spec( 15 | type = "character", 16 | desc = "Capitalized string(s)" 17 | ), 18 | func = function(str_value) { 19 | toupper(str_value) 20 | } 21 | )) %>% 22 | tableau_extension() 23 | } 24 | -------------------------------------------------------------------------------- /inst/plumber/loess/plumber.R: -------------------------------------------------------------------------------- 1 | library(plumber) 2 | library(plumbertableau) 3 | 4 | #* @apiTitle Loess Smoothing 5 | #* @apiDescription Loess smoothing for Tableau 6 | 7 | #* Fit a loess curve to the inputs and return the curve values 8 | #* @param alpha Degree of smoothing 9 | #* @tableauArg x:integer X values for fitting 10 | #* @tableauArg y:numeric Y values for fitting 11 | #* @tableauReturn numeric Fitted loess values 12 | #* @post /predict 13 | function(x, y, alpha = 0.75) { 14 | alpha <- as.numeric(alpha) 15 | l_out <- loess(y ~ x, span = alpha) 16 | predict(l_out, data.frame(x, y)) 17 | } 18 | 19 | #* @plumber 20 | tableau_extension 21 | -------------------------------------------------------------------------------- /R/test-helpers.R: -------------------------------------------------------------------------------- 1 | pr_path <- function() system.file("plumber/stringutils/plumber.R", package = "plumbertableau") 2 | 3 | make_req <- function(verb = "GET", path = "/", qs="", body="", args = c(), pr = NULL, ...){ 4 | req <- as.environment(list(...)) 5 | req$REQUEST_METHOD <- toupper(verb) 6 | req$PATH_INFO <- path 7 | req$QUERY_STRING <- qs 8 | 9 | if (is.character(body)) { 10 | body <- charToRaw(body) 11 | } 12 | stopifnot(is.raw(body)) 13 | req$rook.input <- list(read_lines = function(){ rawToChar(body) }, 14 | read = function(){ body }, 15 | rewind = function(){ length(body) }) 16 | req$bodyRaw <- body 17 | req$pr <- pr 18 | req 19 | } 20 | -------------------------------------------------------------------------------- /tests/testthat/test-mock_tableau_request.R: -------------------------------------------------------------------------------- 1 | test_that("mock_tableau_request() works", { 2 | json <- jsonlite::prettify(jsonlite::toJSON(list( 3 | script = jsonlite::unbox("/foo"), 4 | data = list( 5 | `_arg1` = letters, 6 | `_arg2` = 1:length(letters) 7 | ) 8 | ))) 9 | 10 | expect_identical(mock_tableau_request("/foo", list(x = letters, y = 1:length(letters))), 11 | json) 12 | 13 | expect_error(mock_tableau_request("/foo", list(x = 1:3, y = 1:5)), 14 | "^All entries in data must be of equal length.$") 15 | 16 | expect_error(mock_tableau_request("/foo", "this is not a list"), 17 | "data must be a list or data.frame object") 18 | }) 19 | -------------------------------------------------------------------------------- /cran-comments.md: -------------------------------------------------------------------------------- 1 | ## Resubmission 2 | This is a resubmission. In this version I have: 3 | 4 | * Removed `error_handler.Rd` since `error_handler()` is an internal function 5 | * Added `/value` fields to the following: 6 | - `arg_spec.Rd` 7 | - `tableau_handler.Rd` 8 | 9 | ## Test environments 10 | * local OS X installation, R 4.1.0 11 | * Microsoft Windows Server 2019, R 4.1.0 (GH Actions) 12 | * Mac OS X 10.15.7, R 4.1.0 (GH Actions) 13 | * Ubuntu 20.04.2, R 4.1.0 (GH Actions) 14 | * Ubuntu 20.04.2, r-devel (GH Actions) 15 | * win-builder (devel and release) 16 | * RHub, default CRAN platforms 17 | 18 | ## R CMD check results 19 | 20 | 0 errors | 0 warnings | 1 notes 21 | 22 | * This is an initial release. 23 | 24 | ## Downstream dependencies 25 | There are currently no downstream dependencies for this package 26 | -------------------------------------------------------------------------------- /pkgdown/_pkgdown.yml: -------------------------------------------------------------------------------- 1 | url: https://rstudio.github.io/plumbertableau/ 2 | 3 | articles: 4 | - title: Main articles 5 | navbar: ~ 6 | contents: 7 | - introduction 8 | - r-developer-guide 9 | - publishing-extensions 10 | - tableau-developer-guide 11 | 12 | reference: 13 | - title: "Plumber modifier" 14 | desc: > 15 | Main function for modifying an existing Plumber router into a Tableau 16 | analytics extension 17 | - contents: 18 | - tableau_extension 19 | - title: "Programmatic usage" 20 | desc: "Functions for programmatically creating plumbertableau extensions" 21 | - contents: 22 | - arg_spec 23 | - return_spec 24 | - tableau_handler 25 | - title: "Testing" 26 | desc: "Functions for testing plumbertableau extensions locally" 27 | - contents: 28 | - mock_tableau_request 29 | - tableau_invoke 30 | -------------------------------------------------------------------------------- /tests/testthat/test-reroute.R: -------------------------------------------------------------------------------- 1 | test_that("evaluate requests are rewritten correctly", { 2 | evaluate_req <- make_req( 3 | verb = "POST", 4 | path = "/evaluate", 5 | postBody = encode_payload(script = "concat", letters, .toJSON_args = NULL, raw = FALSE) 6 | ) 7 | reroute(evaluate_req) 8 | 9 | expect_equal(evaluate_req$PATH_INFO, "/concat") 10 | }) 11 | 12 | test_that("info requests return the info endpoint", { 13 | info_req <- make_req( 14 | verb = "GET", 15 | path = "/info", 16 | ) 17 | expect_type(reroute(info_req), "list") 18 | expect_equal(reroute(info_req)$description, "Plumber Tableau API") 19 | }) 20 | 21 | test_that("requests with 'script': 'return int(1)' return 1L", { 22 | python_int1_req <- make_req( 23 | verb = "POST", 24 | path = "/evaluate", 25 | postBody = encode_payload(script = "return int(1)", .toJSON_args = NULL, raw = FALSE) 26 | ) 27 | expect_equal(reroute(python_int1_req), 1L) 28 | }) 29 | -------------------------------------------------------------------------------- /inst/plumber/capitalize/plumber.R: -------------------------------------------------------------------------------- 1 | # 2 | # This is a Tableau Extension built using plumbertableau. 3 | # 4 | # Find out more about building Tableau Extensions with plumbertableau here: 5 | # 6 | # https://rstudio.github.io/plumbertableau/ 7 | # 8 | 9 | library(plumber) 10 | library(plumbertableau) 11 | 12 | #* @apiTitle String utilities 13 | #* @apiDescription Simple functions for mutating strings 14 | 15 | #* Capitalize incoming text 16 | #* @tableauArg str_value:[character] Strings to be capitalized 17 | #* @tableauReturn [character] A capitalized string(s) 18 | #* @post /capitalize 19 | function(str_value) { 20 | toupper(str_value) 21 | } 22 | 23 | # The Plumber router modifier tableau_extension is required. This object is a 24 | # function that acts as a plumber router modifier. For more details, see the 25 | # Plumber documentation: 26 | # https://www.rplumber.io/articles/annotations.html#plumber-router-modifier 27 | #* @plumber 28 | tableau_extension 29 | -------------------------------------------------------------------------------- /R/new-rstudio-project.R: -------------------------------------------------------------------------------- 1 | # This function is invoked when creating a new plumbertableau project in the 2 | # RStudio IDE. The function will be called when the user invokes the 3 | # New Project wizard using the project template defined in the file at: 4 | # 5 | # inst/rstudio/templates/project/new-rstudio-project.dcf 6 | 7 | # The new project template mechanism is documented at: 8 | # https://rstudio.github.io/rstudio-extensions/rstudio_project_templates.html 9 | 10 | newRStudioProject <- function(path, ...) { 11 | 12 | # ensure path exists 13 | dir.create(path, recursive = TRUE, showWarnings = FALSE) 14 | 15 | # copy 'resources' folder to path 16 | resources <- system.file("plumber", "capitalize", 17 | package = "plumbertableau", mustWork = TRUE) 18 | 19 | files <- list.files(resources, recursive = TRUE, include.dirs = FALSE) 20 | source <- file.path(resources, files) 21 | target <- file.path(path, files) 22 | file.copy(source, target) 23 | } 24 | -------------------------------------------------------------------------------- /man/tableau_extension.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/tableau_extension.R 3 | \name{tableau_extension} 4 | \alias{tableau_extension} 5 | \title{Modify a Plumber router to function as a Tableau Analytics Extension} 6 | \usage{ 7 | tableau_extension 8 | 9 | tableau_extension(pr) 10 | } 11 | \arguments{ 12 | \item{pr}{A plumber router} 13 | } 14 | \value{ 15 | A modified plumber router that functions as a Tableau Analytics 16 | Extension 17 | } 18 | \description{ 19 | Most of the time, you won't call this function directly. Instead, you'll 20 | place it at the end of a Plumber router, under a \verb{#* @plumber} annotation, 21 | with no trailing parentheses or arguments. This tells Plumber to use the 22 | function to modify the router object. 23 | } 24 | \examples{ 25 | \dontrun{ 26 | library(plumber) 27 | library(plumbertableau) 28 | 29 | #* Capitalize incoming text 30 | #* @post /capitalize 31 | function(req, res) { 32 | dat <- req$body$data 33 | toupper(dat) 34 | } 35 | 36 | #* @plumber 37 | tableau_extension 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2021 RStudio, PBC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/testthat/test-tableau_extension.R: -------------------------------------------------------------------------------- 1 | test_that("stringutils example works", { 2 | 3 | expect_identical( 4 | tableau_invoke(pr_path(), "/lowercase", "HELLO"), 5 | "hello" 6 | ) 7 | 8 | expect_identical( 9 | tableau_invoke(pr_path(), "/concat", letters, LETTERS), 10 | paste0(letters, " ", LETTERS) 11 | ) 12 | 13 | expect_identical( 14 | tableau_invoke(pr_path(), "/concat?sep=-", letters, LETTERS), 15 | paste0(letters, "-", LETTERS) 16 | ) 17 | 18 | expect_identical( 19 | tableau_invoke(pr_path(), "/stringify", 1:10), 20 | as.character(1:10) 21 | ) 22 | 23 | expect_identical( 24 | tableau_invoke(pr_path(), "/stringify", c(TRUE, FALSE, NA, TRUE)), 25 | c("true", "false", NA, "true") 26 | ) 27 | 28 | # 404 29 | expect_error(tableau_invoke(pr_path(), "/blah", .quiet = TRUE)) 30 | # Too few args 31 | expect_error(tableau_invoke(pr_path(), "/concat", letters, .quiet = TRUE)) 32 | expect_error(tableau_invoke(pr_path(), "/stringify", .quiet = TRUE)) 33 | # Too many args 34 | expect_error(tableau_invoke(pr_path(), "/concat", letters, letters, letters, .quiet = TRUE)) 35 | # Incorrect data type 36 | expect_error(tableau_invoke(pr_path(), "/concat", letters, seq_along(letters), .quiet = TRUE)) 37 | }) 38 | -------------------------------------------------------------------------------- /inst/plumber/stringutils/plumber.R: -------------------------------------------------------------------------------- 1 | library(plumber) 2 | library(plumbertableau) 3 | 4 | #* @apiTitle String utilities 5 | #* @apiDescription Simple functions for mutating strings 6 | 7 | #* Lowercase incoming text 8 | #* @param unicode:boolean Whether unicode logic should be used 9 | #* @tableauArg str_value:[character] Strings to be converted to lowercase 10 | #* @tableauReturn [character] A lowercase string 11 | #* @post /lowercase 12 | function(str_value, unicode = FALSE) { 13 | tolower(str_value) 14 | } 15 | 16 | #* Concatenate 17 | #* @post /concat 18 | #* @param sep:str Separator value to use 19 | #* @tableauArg arg1:[character] One or more string values 20 | #* @tableauArg arg2:[character] One or more string values to concatenate to `arg1` 21 | #* @tableauReturn [character] arg1 and arg2 concatenated together 22 | function(arg1, arg2, sep = " ") { 23 | paste(arg1, arg2, sep = sep) 24 | } 25 | 26 | #* Convert to string 27 | #* @post /stringify 28 | #* @tableauArg value:[any] One or more values of any data type 29 | #* @tableauReturn [character] The data, converted to string 30 | function(value) { 31 | if (is.logical(value)) { 32 | ifelse(value, "true", "false") 33 | } else { 34 | as.character(value) 35 | } 36 | } 37 | 38 | #* @plumber 39 | tableau_extension 40 | -------------------------------------------------------------------------------- /man/plumbertableau-package.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/plumbertableau.R 3 | \docType{package} 4 | \name{plumbertableau-package} 5 | \alias{plumbertableau} 6 | \alias{plumbertableau-package} 7 | \title{plumbertableau: Turn 'Plumber' APIs into 'Tableau' Extensions} 8 | \description{ 9 | Build 'Plumber' APIs that can be used in 'Tableau' workbooks. 10 | Annotations in R comments allow APIs to conform to the 'Tableau Analytics 11 | Extension' specification, so that R code can be used to power 'Tableau' 12 | workbooks. 13 | } 14 | \seealso{ 15 | Useful links: 16 | \itemize{ 17 | \item \url{https://rstudio.github.io/plumbertableau/} 18 | \item \url{https://github.com/rstudio/plumbertableau} 19 | \item Report bugs at \url{https://github.com/rstudio/plumbertableau/issues} 20 | } 21 | 22 | } 23 | \author{ 24 | \strong{Maintainer}: James Blair \email{james@rstudio.com} 25 | 26 | Authors: 27 | \itemize{ 28 | \item Joe Cheng \email{joe@rstudio.com} 29 | \item Toph Allen \email{toph@rstudio.com} 30 | \item Bill Sager \email{bill.sager@rstudio.com} 31 | } 32 | 33 | Other contributors: 34 | \itemize{ 35 | \item RStudio [copyright holder, funder] 36 | \item Tableau [copyright holder] 37 | } 38 | 39 | } 40 | \keyword{internal} 41 | -------------------------------------------------------------------------------- /man/mock_tableau_request.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/mock_tableau_request.R 3 | \name{mock_tableau_request} 4 | \alias{mock_tableau_request} 5 | \title{Create a mock JSON request that mimics the request structure of Tableau} 6 | \usage{ 7 | mock_tableau_request(script, data, ...) 8 | } 9 | \arguments{ 10 | \item{script}{String indicating the path to the endpoint to be called} 11 | 12 | \item{data}{A list or dataframe that is serialized to JSON} 13 | 14 | \item{...}{Additional arguments passed to \code{jsonlite::toJSON()}} 15 | } 16 | \value{ 17 | A JSON object that can be passed to a Tableau endpoint 18 | } 19 | \description{ 20 | \code{mock_tableau_request()} creates a JSON object formatted like a request from 21 | Tableau. The JSON object it returns can be pasted directly into the "Try it 22 | out" field in the Swagger documentation for an endpoint to test its 23 | functionality. 24 | } 25 | \details{ 26 | Behind the scenes, Tableau sends all requests to the \verb{/evaluate} endpoint. 27 | Each request is a JSON object containing two items: \code{script} and \code{data}. 28 | plumbertableau uses \code{script} to specify an individual endpoint to call, and 29 | passes the arguments in \code{data} on to the function at that endpoint. 30 | } 31 | \examples{ 32 | mock_tableau_request("/loess/predict", mtcars[,c("hp", "mpg")]) 33 | 34 | } 35 | -------------------------------------------------------------------------------- /man/arg_spec.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/tableau_handler.R 3 | \name{arg_spec} 4 | \alias{arg_spec} 5 | \alias{return_spec} 6 | \title{Describe expected args and return values} 7 | \usage{ 8 | arg_spec( 9 | type = c("character", "integer", "logical", "numeric"), 10 | desc = "", 11 | optional = grepl("\\\\?$", type) 12 | ) 13 | 14 | return_spec(type = c("character", "integer", "logical", "numeric"), desc = "") 15 | } 16 | \arguments{ 17 | \item{type}{A string indicating the data type that is required for this 18 | argument.} 19 | 20 | \item{desc}{A human-readable description of the argument. Used to generate 21 | documentation.} 22 | 23 | \item{optional}{If \code{TRUE}, then this argument need not be present in a 24 | request. Defaults to \code{TRUE} if \code{type} ends with a \code{"?"} character.} 25 | } 26 | \value{ 27 | A \code{tableau_arg_spec} object, which is a list containing details about 28 | the Tableau argument expectations 29 | 30 | A \code{tableau_return_spec} object, which is a list containing details 31 | about the values expected to be returned to Tableau 32 | } 33 | \description{ 34 | \code{arg_spec()} and \code{return_spec()} are used to create arguments for 35 | \code{\link[=tableau_handler]{tableau_handler()}}. They describe the data type of the arg or return value, 36 | and can return a human-readable description that can be used to generate 37 | documentation. 38 | } 39 | -------------------------------------------------------------------------------- /DESCRIPTION: -------------------------------------------------------------------------------- 1 | Package: plumbertableau 2 | Type: Package 3 | Title: Turn 'Plumber' APIs into 'Tableau' Extensions 4 | Version: 0.1.1 5 | Authors@R: c( 6 | person("James", "Blair", email = "james@rstudio.com", role = c("aut", "cre")), 7 | person("Joe", "Cheng", email = "joe@rstudio.com", role = c("aut")), 8 | person("Toph", "Allen", email = "toph@rstudio.com", role = "aut"), 9 | person("Bill", "Sager", email = "bill.sager@rstudio.com", role = "aut"), 10 | person("RStudio", role = c("cph", "fnd")), 11 | person("Tableau", role = c("cph")) 12 | ) 13 | Description: Build 'Plumber' APIs that can be used in 'Tableau' workbooks. 14 | Annotations in R comments allow APIs to conform to the 'Tableau Analytics 15 | Extension' specification, so that R code can be used to power 'Tableau' 16 | workbooks. 17 | License: MIT + file LICENSE 18 | URL: https://rstudio.github.io/plumbertableau/, https://github.com/rstudio/plumbertableau 19 | BugReports: https://github.com/rstudio/plumbertableau/issues 20 | Encoding: UTF-8 21 | Depends: 22 | R (>= 3.0.0) 23 | Imports: 24 | plumber (>= 1.1.0), 25 | magrittr, 26 | curl, 27 | httpuv, 28 | jsonlite, 29 | later, 30 | promises, 31 | rlang, 32 | htmltools, 33 | debugme, 34 | stringi, 35 | markdown, 36 | urltools, 37 | utils, 38 | httr, 39 | knitr 40 | RoxygenNote: 7.1.1 41 | Suggests: 42 | testthat (>= 3.0.0), 43 | rmarkdown, 44 | covr 45 | Config/testthat/edition: 3 46 | VignetteBuilder: knitr 47 | Roxygen: list(markdown = TRUE) 48 | -------------------------------------------------------------------------------- /tests/testthat/test-tableau_handler.R: -------------------------------------------------------------------------------- 1 | library(plumber) 2 | 3 | test_that("tableau_handler warns on missing function params", { 4 | args <- list(foo = arg_spec("character"), bar = arg_spec("character")) 5 | 6 | expect_warning(tableau_handler(args = args, return = return_spec(), func = function() {})) 7 | expect_warning(tableau_handler(args = args, return = return_spec(), func = function(foo) {})) 8 | expect_warning(tableau_handler(args = args, return = return_spec(), func = function(bar) {})) 9 | expect_warning(tableau_handler(args = args, return = return_spec(), func = function(foo, bar) {}), NA) 10 | expect_warning(tableau_handler(args = args, return = return_spec(), func = function(...) {}), NA) 11 | 12 | expect_warning(tableau_handler(args = args, return = return_spec(), func = function(req, res) {})) 13 | expect_warning(tableau_handler(args = args, return = return_spec(), func = function(req, res, foo) {})) 14 | expect_warning(tableau_handler(args = args, return = return_spec(), func = function(req, res, bar) {})) 15 | expect_warning(tableau_handler(args = args, return = return_spec(), func = function(req, res, foo, bar) {}), NA) 16 | expect_warning(tableau_handler(args = args, return = return_spec(), func = function(req, res, ...) {}), NA) 17 | }) 18 | 19 | test_that("Infer tableau handler throws an error when Tableau args and return types aren't provided", { 20 | expect_error( 21 | pr() %>% 22 | pr_post("/foo", function() "foo") %>% 23 | tableau_extension(), 24 | regexp = "^Tableau argument and return data types" 25 | ) 26 | }) 27 | -------------------------------------------------------------------------------- /man/tableau_invoke.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/client.R 3 | \name{tableau_invoke} 4 | \alias{tableau_invoke} 5 | \title{Programatically invoke a Tableau extension function} 6 | \usage{ 7 | tableau_invoke(pr, script, ..., .toJSON_args = NULL, .quiet = FALSE) 8 | } 9 | \arguments{ 10 | \item{pr}{Either a \link{tableau_extension} style Plumber router object, or, the 11 | filename of a plumber.R that implements a Tableau extension.} 12 | 13 | \item{script}{The script string that identifies the plumber route to invoke. 14 | (Equivalent to the first argument to \code{SCRIPT_STR}, et al., in Tableau.) URL 15 | query parameters are allowed.} 16 | 17 | \item{...}{Zero or more unnamed arguments to be passed to the script.} 18 | 19 | \item{.toJSON_args}{Additional options that should be passed to 20 | \code{\link[jsonlite:fromJSON]{jsonlite::toJSON()}} when the \code{...} arguments are serialized; for example, 21 | \code{pretty = TRUE} or \code{digits = 8}.} 22 | 23 | \item{.quiet}{If \code{TRUE}, do not print response bodies when errors occur.} 24 | } 25 | \value{ 26 | The object that was returned from the request, JSON-decoded using 27 | \code{jsonlite::parse_json}. 28 | } 29 | \description{ 30 | Simulates invoking a Tableau extension function from a Tableau calculated 31 | field \verb{SCRIPT_*} call. Intended for unit testing of plumbertableau extensions. 32 | } 33 | \examples{ 34 | pr_path <- system.file("plumber/stringutils/plumber.R", 35 | package = "plumbertableau") 36 | 37 | tableau_invoke(pr_path, "/lowercase", LETTERS[1:5]) 38 | 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/pkgdown.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | - master 6 | 7 | name: pkgdown 8 | 9 | jobs: 10 | pkgdown: 11 | runs-on: macOS-latest 12 | env: 13 | GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - uses: r-lib/actions/setup-r@v1 18 | 19 | - uses: r-lib/actions/setup-pandoc@v1 20 | 21 | - name: Query dependencies 22 | run: | 23 | install.packages('remotes') 24 | saveRDS(remotes::dev_package_deps(dependencies = TRUE), ".github/depends.Rds", version = 2) 25 | writeLines(sprintf("R-%i.%i", getRversion()$major, getRversion()$minor), ".github/R-version") 26 | shell: Rscript {0} 27 | 28 | - name: Cache R packages 29 | uses: actions/cache@v2 30 | with: 31 | path: ${{ env.R_LIBS_USER }} 32 | key: ${{ runner.os }}-${{ hashFiles('.github/R-version') }}-1-${{ hashFiles('.github/depends.Rds') }} 33 | restore-keys: ${{ runner.os }}-${{ hashFiles('.github/R-version') }}-1- 34 | 35 | - name: Install dependencies 36 | run: | 37 | remotes::install_deps(dependencies = TRUE) 38 | install.packages("pkgdown", type = "binary") 39 | shell: Rscript {0} 40 | 41 | - name: Install package 42 | run: R CMD INSTALL . 43 | 44 | - name: Deploy package 45 | run: | 46 | git config --local user.email "actions@github.com" 47 | git config --local user.name "GitHub Actions" 48 | Rscript -e 'pkgdown::deploy_to_branch(new_process = FALSE)' 49 | -------------------------------------------------------------------------------- /tests/testthat/test-utils.R: -------------------------------------------------------------------------------- 1 | test_that("check_route() warnings fire as expected", { 2 | w <- capture_warnings(plumber::plumb(system.file("plumber/mounts/plumber.R", package = "plumbertableau"))) 3 | expect_match(w[1], "^Tableau endpoints must accept POST requests. /bar does not respond to POST requests.$") 4 | expect_match(w[2], "^Route /bar includes a user specified parser. plumbertableau automatically sets the appropriate parser for Tableau requests. There is no need to specify a parser.$") 5 | expect_match(w[3], "^Route /bar includes a user specified serializer. plumbertableau automatically sets the appropriate serializer for Tableau requests. There is no need to specify a serializer.$") 6 | }) 7 | 8 | test_that("write_log_message() includes required fields", { 9 | req <- as.environment(list( 10 | "HTTP_X_CORRELATION_ID" = "correlation_id_str", 11 | "REQUEST_METHOD" = "POST", 12 | "PATH_INFO" = "path_info_str", 13 | "postBody" = "postBody_str" 14 | )) 15 | msg <- "msg_str" 16 | log_msg <- write_log_message(req, NULL, msg) 17 | expect_true(all(stri_detect_fixed(log_msg, c(req$HTTP_X_CORRELATION_ID, req$REQUEST_METHOD, req$PATH_INFO, req$postBody, msg)))) 18 | }) 19 | 20 | 21 | test_that("write_log_message() does not include postBody if body_log field is present in request", { 22 | req <- as.environment(list( 23 | "HTTP_X_CORRELATION_ID" = "correlation_id_str", 24 | "REQUEST_METHOD" = "POST", 25 | "PATH_INFO" = "path_info_str", 26 | "body_log" = TRUE, 27 | "postBody" = "postBody_str" 28 | )) 29 | msg <- "msg_str" 30 | log_msg <- write_log_message(req, NULL, msg) 31 | expect_false(stri_detect_fixed(log_msg, req$postBody)) 32 | }) 33 | -------------------------------------------------------------------------------- /man/tableau_handler.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/tableau_handler.R 3 | \name{tableau_handler} 4 | \alias{tableau_handler} 5 | \title{Create a Tableau-compliant handler for a function} 6 | \usage{ 7 | tableau_handler(args, return, func) 8 | } 9 | \arguments{ 10 | \item{args}{A named list describing the arguments that are expected from 11 | valid Tableau requests. The names in the named list can be any unique 12 | variable names. The values in the named list must each be either a string 13 | indicating the expected data type for that argument (\code{"character"}, 14 | \code{"logical"}, \code{"numeric"}, or \code{"integer"}); or better yet, a specification 15 | object created by \code{\link[=arg_spec]{arg_spec()}}. If an argument should be considered 16 | optional, then its data type should be followed by \verb{?}, like \code{"numeric?"}.} 17 | 18 | \item{return}{A string indicating the data type that will be returned from 19 | \code{func} (\code{"character"}, \code{"logical"}, \code{"numeric"}, or \code{"integer"}); or, a 20 | specification object created by \code{\link[=return_spec]{return_spec()}}.} 21 | 22 | \item{func}{A function to be used as the handler function. Code in the body 23 | of the function will automatically be able to access Tableau request args 24 | simply by referring to their names in \code{args}; see the example below.} 25 | } 26 | \value{ 27 | A \code{tableau_handler} object that is a validated version of the 28 | provided \code{func} with additional attributes describing the expected arguments 29 | and return values 30 | } 31 | \description{ 32 | Creates an object that can translate arguments from Tableau to R, and return 33 | values from R to Tableau. 34 | } 35 | -------------------------------------------------------------------------------- /R/mock_tableau_request.R: -------------------------------------------------------------------------------- 1 | #' Create a mock JSON request that mimics the request structure of Tableau 2 | #' 3 | #' `mock_tableau_request()` creates a JSON object formatted like a request from 4 | #' Tableau. The JSON object it returns can be pasted directly into the "Try it 5 | #' out" field in the Swagger documentation for an endpoint to test its 6 | #' functionality. 7 | #' 8 | #' Behind the scenes, Tableau sends all requests to the `/evaluate` endpoint. 9 | #' Each request is a JSON object containing two items: `script` and `data`. 10 | #' plumbertableau uses `script` to specify an individual endpoint to call, and 11 | #' passes the arguments in `data` on to the function at that endpoint. 12 | #' 13 | #' @param script String indicating the path to the endpoint to be called 14 | #' @param data A list or dataframe that is serialized to JSON 15 | #' @param ... Additional arguments passed to \code{jsonlite::toJSON()} 16 | #' 17 | #' @return A JSON object that can be passed to a Tableau endpoint 18 | #' 19 | #' @examples 20 | #' mock_tableau_request("/loess/predict", mtcars[,c("hp", "mpg")]) 21 | #' 22 | #' @export 23 | mock_tableau_request <- function(script, data, ...) { 24 | # Verify data is a list 25 | if (!is.list(data)) stop("data must be a list or data.frame object.", call. = FALSE) 26 | data <- as.list(data) 27 | 28 | # Verify every entry of data is of the same length - Tableau will only submit 29 | # data where every array is of the same length 30 | list_lengths <- unlist(lapply(data, length)) 31 | if(!all(list_lengths == list_lengths[1])) stop("All entries in data must be of equal length.", call. = FALSE) 32 | 33 | names(data) <- paste0("_arg", 1:length(data)) 34 | jsonlite::prettify( 35 | jsonlite::toJSON(list( 36 | script = jsonlite::unbox(script), 37 | data = data 38 | ), 39 | ...) 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/R-CMD-check.yaml: -------------------------------------------------------------------------------- 1 | # For help debugging build failures open an issue on the RStudio community with the 'github-actions' tag. 2 | # https://community.rstudio.com/new-topic?category=Package%20development&tags=github-actions 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | pull_request: 9 | branches: 10 | - main 11 | - master 12 | 13 | name: R-CMD-check 14 | 15 | jobs: 16 | R-CMD-check: 17 | runs-on: ${{ matrix.config.os }} 18 | 19 | name: ${{ matrix.config.os }} (${{ matrix.config.r }}) 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | config: 25 | - {os: windows-latest, r: 'release'} 26 | - {os: macOS-latest, r: 'release'} 27 | - {os: ubuntu-latest, r: 'release'} 28 | - {os: ubuntu-latest, r: 'devel', http-user-agent: 'release'} 29 | 30 | env: 31 | R_REMOTES_NO_ERRORS_FROM_WARNINGS: true 32 | GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} 33 | 34 | steps: 35 | - uses: actions/checkout@v3 36 | 37 | - uses: r-lib/actions/setup-r@v2 38 | with: 39 | r-version: ${{ matrix.config.r }} 40 | http-user-agent: ${{ matrix.config.http-user-agent }} 41 | use-public-rspm: true 42 | 43 | - uses: r-lib/actions/setup-pandoc@v2 44 | 45 | - uses: r-lib/actions/setup-r-dependencies@v2 46 | with: 47 | extra-packages: any::rcmdcheck 48 | needs: check 49 | 50 | - uses: r-lib/actions/check-r-package@v2 51 | with: 52 | upload-snapshots: true 53 | 54 | - name: Upload check results 55 | if: failure() 56 | uses: actions/upload-artifact@main 57 | with: 58 | name: ${{ runner.os }}-r${{ matrix.config.r }}-results 59 | path: check 60 | 61 | - name: Test coverage 62 | if: success() && runner.os == 'Linux' && matrix.config.r == 'release' 63 | run: | 64 | pak::pkg_install('covr') 65 | covr::codecov() 66 | shell: Rscript {0} 67 | -------------------------------------------------------------------------------- /R/tableau_extension.R: -------------------------------------------------------------------------------- 1 | #' Modify a Plumber router to function as a Tableau Analytics Extension 2 | #' 3 | #' Most of the time, you won't call this function directly. Instead, you'll 4 | #' place it at the end of a Plumber router, under a `#* @plumber` annotation, 5 | #' with no trailing parentheses or arguments. This tells Plumber to use the 6 | #' function to modify the router object. 7 | #' 8 | #' @usage tableau_extension 9 | #' @usage tableau_extension(pr) 10 | #' 11 | #' @param pr A plumber router 12 | #' 13 | #' @return A modified plumber router that functions as a Tableau Analytics 14 | #' Extension 15 | #' 16 | #' @examples 17 | #' \dontrun{ 18 | #' library(plumber) 19 | #' library(plumbertableau) 20 | #' 21 | #' #* Capitalize incoming text 22 | #' #* @post /capitalize 23 | #' function(req, res) { 24 | #' dat <- req$body$data 25 | #' toupper(dat) 26 | #' } 27 | #' 28 | #' #* @plumber 29 | #' tableau_extension 30 | #' } 31 | #' 32 | #' @export 33 | tableau_extension <- function(pr) { 34 | # Print info message to the console 35 | message(info_message()) 36 | 37 | warnings <- getOption("plumbertableau.warnings", default = FALSE) 38 | if (warnings) { 39 | lapply(pr$routes, function(route) { 40 | check_route(route) 41 | }) 42 | } 43 | 44 | # Infer Tableau handler information 45 | lapply(pr$endpoints, function(routes) { 46 | # Modify route in place 47 | lapply(routes, function(route) { 48 | route$.__enclos_env__$private$func <- infer_tableau_handler(route) 49 | }) 50 | }) 51 | 52 | pr %>% 53 | plumber::pr_get("/", create_user_guide(pr), serializer = plumber::serializer_html()) %>% 54 | plumber::pr_get("/setup", create_setup_instructions(pr), serializer = plumber::serializer_html()) %>% 55 | plumber::pr_static("/__plumbertableau_assets__", 56 | system.file("www", package = "plumbertableau", mustWork = TRUE)) %>% 57 | plumber::pr_filter("rsc_filter", rsc_filter) %>% 58 | plumber::pr_filter("reroute", reroute) %>% 59 | plumber::pr_hooks(list( 60 | preroute = preroute_hook, 61 | postroute = postroute_hook 62 | )) %>% 63 | plumber::pr_set_api_spec(tableau_openapi(pr)) %>% 64 | plumber::pr_set_error(error_handler) %>% 65 | plumber::pr_set_parsers("json") %>% 66 | plumber::pr_set_serializer(plumber::serializer_unboxed_json()) 67 | } 68 | -------------------------------------------------------------------------------- /vignettes/tableau-developer-guide.Rmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Using plumbertableau Extensions in Tableau" 3 | output: rmarkdown::html_vignette 4 | vignette: > 5 | %\VignetteIndexEntry{Using plumbertableau Extensions in Tableau} 6 | %\VignetteEngine{knitr::rmarkdown} 7 | %\VignetteEncoding{UTF-8} 8 | --- 9 | 10 | ```{r, include = FALSE} 11 | knitr::opts_chunk$set( 12 | collapse = TRUE, 13 | comment = "#>" 14 | ) 15 | ``` 16 | 17 | Before you use plumbertableau extensions in Tableau, you'll need to configure your extension server in Tableau. See **["Publishing plumbertableau Extensions to RStudio Connect"](publishing-extensions.html)** for detailed instructions on setting up Tableau and RStudio Connect to work together. 18 | 19 | plumbertableau extensions are used within calculated fields in Tableau, with calls to [Tableau's `SCRIPT_*` functions](https://help.tableau.com/current/pro/desktop/en-us/functions_functions_tablecalculation.htm#scriptbool). 20 | 21 | ## Viewing Info on an Extension 22 | 23 | plumbertableau automatically generates a documentation page with example calls for each endpoint in the extension. These are what you see when you view the extension on RStudio Connect. 24 | 25 | ![](files/tableau-usage-guide.png){width=75%} 26 | 27 | This page includes the following information about the extension: 28 | 29 | - The API's name and description 30 | - Links to other important pieces of documentation, including instructions for configuring Tableau, and the OpenAPI (Swagger) documentation for the extension 31 | 32 | For each endpoint, it includes: 33 | 34 | - A usage example calling Tableau's appropriate `SCRIPT_*` command, ready for use in a Tableau workbook 35 | - Names, types, and descriptions for Tableau arguments, URL parameters, and return data returned 36 | 37 | ## Calling an Extension Endpoint in Tableau 38 | 39 | You can copy and paste the usage example (the `SCRIPT_*` command) into a calculated field in Tableau (it generates the correct URL), and replace the argument placeholders with actual values from the Tableau workbook. 40 | 41 | ![Using a plumbertableau extension in a calculated field](files/calculated-field.png){width=75%} 42 | 43 | ### Working with Tableau Data 44 | 45 | We've found that a few practices in Tableau ensure that the data you pass to a plumbertableau extension is sent correctly. 46 | 47 | - You must turn off "Aggregate Measures" under the "Analysis" menu for Tableau to pass the correct values to the extension. If this setting is on, Tableau will send aggregated data to the extension, which may cause inaccuracies in computations. 48 | - With this value off, calculated fields don't allow you to pass raw values directly to an extension. Those values must be wrapped in an aggregating function. Since we've turned "Aggregate Measures" off, these functions won't actually aggregate the data. We've had success using `ATTR([VALUE_NAME])`. 49 | -------------------------------------------------------------------------------- /R/validate_request.R: -------------------------------------------------------------------------------- 1 | # Called on a specific request before a user-defined function is called. This 2 | # takes place in the function generated in tableau_handler(). 3 | validate_request <- function(req, args, return) { 4 | # Not for any particular reason 5 | force(req) 6 | force(args) 7 | force(return) 8 | 9 | optionals <- vapply(args, function(x) x[["optional"]], logical(1)) 10 | opt_idx <- which(optionals) 11 | req_idx <- which(!optionals) 12 | min_possible_args <- length(req_idx) 13 | max_possible_args <- length(args) 14 | 15 | val <- args[seq_len(min(max_possible_args, max(min_possible_args, length(req$body$data))))] 16 | 17 | dat <- req$body$data 18 | 19 | # Check that the same number of values is provided as what is expected 20 | if (length(val) != length(dat)) { 21 | err <- paste0(req$PATH_INFO, 22 | " expected ", 23 | if (min_possible_args == max_possible_args) { 24 | min_possible_args 25 | } else { 26 | paste0("between ", min_possible_args, " and ", max_possible_args) 27 | }, 28 | " arguments but instead received ", 29 | length(dat)) 30 | stop(err, call. = FALSE) 31 | } 32 | 33 | # Check to make sure data types match 34 | dat_types <- lapply(dat, class) 35 | expected_types <- vapply(val, function(arg_spec) { 36 | if (inherits(arg_spec, "tableau_arg_spec")) { 37 | arg_spec[["type"]] 38 | } else if (is.character(arg_spec)) { 39 | arg_spec 40 | } else { 41 | stop("Unexpected arg_spec type: ", class(arg_spec)[[1]]) 42 | } 43 | }, character(1)) 44 | 45 | mismatch <- !check_types(dat_types, expected_types) 46 | if (any(mismatch)) { 47 | err <- paste0("Mismatched data types found in ", 48 | req$PATH_INFO, 49 | ": ", 50 | paste0("\n - Argument ", 51 | which(mismatch), 52 | " (", 53 | names(val)[mismatch], 54 | ") is type '", 55 | unlist(dat_types[mismatch]), 56 | "' but type '", 57 | expected_types[mismatch], 58 | "' was expected", 59 | collapse = "") 60 | ) 61 | stop(err, call. = FALSE) 62 | } 63 | 64 | # Assign dat with names provided 65 | names(dat) <- names(val) 66 | 67 | # Add missing optionals, with NULL values 68 | missing_arg_names <- utils::tail(names(args), -length(val)) 69 | missing_args <- stats::setNames( 70 | rep_len(list(NULL), length(missing_arg_names)), 71 | missing_arg_names) 72 | dat <- c(dat, missing_args) 73 | 74 | # Return the renamed data as a list 75 | dat 76 | } 77 | 78 | check_types <- function(actual_types, expected_types) { 79 | ifelse(expected_types == "any", 80 | rep_len(TRUE, length(expected_types)), 81 | actual_types == expected_types 82 | ) 83 | } 84 | -------------------------------------------------------------------------------- /R/utils.R: -------------------------------------------------------------------------------- 1 | #' Checks a Plumber route for Tableau compliance 2 | #' 3 | #' Checks a route to ensure that it accepts POST requests and uses the default JSON parser and serializer. 4 | #' 5 | #' @param route A plumber route 6 | #' 7 | #' @return Provides warnings based on features of \code{route} 8 | #' @keywords internal 9 | check_route <- function(route) { 10 | # Recursively work through mounted / nested routes 11 | if ("Plumber" %in% class(route)) { 12 | lapply(route$routes, check_route) 13 | } else if (is.list(route)) { 14 | lapply(route, check_route) 15 | } else { 16 | # Check for POST endpoints 17 | if (!("POST" %in% route$verbs)) { 18 | warning( 19 | paste0("Tableau endpoints must accept POST requests. ", 20 | route$path, 21 | " does not respond to POST requests."), 22 | call. = FALSE, immediate. = TRUE) 23 | } 24 | 25 | # Check for default (JSON) parser 26 | if (!is.null(route$parsers)) { 27 | warning( 28 | paste0("Route ", 29 | route$path, 30 | " includes a user specified parser. plumbertableau automatically sets the appropriate parser for Tableau requests. There is no need to specify a parser."), 31 | call. = FALSE, immediate. = TRUE) 32 | } 33 | 34 | # Check for default (JSON) serializer 35 | if (!is.null(route$serializer)) { 36 | warning( 37 | paste0("Route ", 38 | route$path, 39 | " includes a user specified serializer. plumbertableau automatically sets the appropriate serializer for Tableau requests. There is no need to specify a serializer."), 40 | call. = FALSE) 41 | } 42 | } 43 | } 44 | 45 | 46 | write_log_message <- function(req, res, msg = NULL) { 47 | # Desired behavior: 48 | # - Include Correlation ID in every log entry 49 | # - Only log the request body once for each request 50 | log_msg <- paste0( 51 | "[", 52 | Sys.time(), 53 | "] ", 54 | ifelse(rlang::is_null(req$HTTP_X_CORRELATION_ID), "", paste0("(", req$HTTP_X_CORRELATION_ID, ") ")), 55 | req$REQUEST_METHOD, 56 | " ", 57 | req$PATH_INFO, 58 | ifelse(rlang::is_null(msg), "", paste0(" - ", msg)), 59 | ifelse(rlang::is_null(req$body_log) && req$postBody != "", paste0(" - ", req$postBody), "") 60 | ) 61 | 62 | req$body_log <- TRUE 63 | 64 | log_msg 65 | } 66 | 67 | 68 | # Utilities for capturing endpoint execution time 69 | preroute_hook <- function(data, req, res) { 70 | # Capture execution start time 71 | data$start_time <- Sys.time() 72 | } 73 | 74 | postroute_hook <- function(data, req, res) { 75 | time_diff <- round(abs(as.numeric(difftime(Sys.time(), data$start_time, units = "secs"))), 4) 76 | "!DEBUG `write_log_message(req, res, paste('Request executed in', time_diff, 'seconds'))`" 77 | } 78 | 79 | 80 | check_rstudio_connect <- function() { 81 | # Return TRUE if running in a traditional RStudio Connect environment 82 | env_vars <- Sys.getenv() 83 | Sys.getenv("RSTUDIO_PRODUCT") == "CONNECT" | # This is only a valid check on recent RSC versions 84 | "RSTUDIO_CONNECT_HASTE" %in% names(env_vars) | 85 | getwd() == "/opt/rstudio-connect/mnt/app" | 86 | Sys.getenv("LOGNAME") == "rstudio-connect" | 87 | Sys.getenv("R_CONFIG_ACTIVE") == "rsconnect" | 88 | Sys.getenv("TMPDIR") == "/opt/rstudio-connect/mnt/tmp" | 89 | grepl("^/opt/rstudio-connect/mnt/tmp", Sys.getenv("R_SESSION_TMPDIR")) 90 | } 91 | -------------------------------------------------------------------------------- /vignettes/introduction.Rmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Introduction" 3 | output: rmarkdown::html_vignette 4 | vignette: > 5 | %\VignetteIndexEntry{Introduction} 6 | %\VignetteEngine{knitr::rmarkdown} 7 | %\VignetteEncoding{UTF-8} 8 | --- 9 | 10 | ```{r, include = FALSE} 11 | knitr::opts_chunk$set( 12 | collapse = TRUE, 13 | comment = "#>" 14 | ) 15 | 16 | knitr::read_chunk(path = "../inst/plumber/capitalize/plumber.R", 17 | from = 9, 18 | labels = "capitalize") 19 | ``` 20 | 21 | [Tableau](https://www.tableau.com) is a leading visual analytics platform that lets its users investigate, understand, and report on data. R is a programming language for statistics, data analysis, and visualization. 22 | 23 | plumbertableau lets you call external R code from Tableau workbooks via [Tableau Analytics Extensions](https://tableau.github.io/analytics-extensions-api/). You achieve this by writing a plumbertableau extension, which is a [Plumber](https://www.rplumber.io/) API with some extra annotations. plumbertableau uses these annotations to correctly serve requests from Tableau, as well as dynamically generate documentation, copy-and-pasteable Tableau code, and setup instructions. 24 | 25 | plumbertableau extensions are most easily used with [RStudio Connect](https://posit.co/products/enterprise/connect/), which lets you host and manage any number of Tableau extensions along with other content types. 26 | 27 | ## A Simple Example 28 | 29 | The following code is a simple plumbertableau extension. It can receive text from Tableau and returns that text capitalized. 30 | 31 | ```{r capitalize, eval = FALSE} 32 | ``` 33 | 34 | The core of this extension is a very simple R function that capitalizes text. It's surrounded by Plumber "annotations" which describe the web service. plumbertableau introduces new annotations in addition to what Plumber already provides. To learn or more information on these, see the guide to **[Writing plumbertableau Extensions in R](r-developer-guide.html)**. 35 | 36 | Before you use the extension in Tableau, Tableau needs to be able to access it. You can find instructions on how to do that, and information on publishing extensions to RStudio Connect, in **[Publishing plumbertableau Extensions to RStudio Connect](publishing-extensions.html)**. This document will walk you through publishing extensions to RStudio Connect and setting up a Connect server as an extension in Tableau. 37 | 38 | plumbertableau extensions are used in Tableau's *calculated fields*. Let's imagine we've published our extension to RStudio Connect and have given it the custom URL `stringutils`. To use our `capitalize` extension, we'd type the following into a Tableau calculated field, or just copy and paste it from the automatically generated code samples. (In real usage, you'll probably replace `"Hello World"` with references to Tableau data.) 39 | 40 | ``` 41 | SCRIPT_STR("/stringutils/capitalize", "Hello World") 42 | ``` 43 | 44 | Tableau will send a request to the server you configured, and the server will send it on to the extension you named in the first argument (in this case, `/stringutils/capitalize`). All the other arguments are data sent to the extension. For more information on using published extensions in Tableau, see the guide to **[Using plumbertableau Extensions in Tableau](tableau-developer-guide.html)**. 45 | 46 | ## Learn More 47 | 48 | - [Writing plumbertableau Extensions in R](r-developer-guide.html) 49 | - [Publishing plumbertableau Extensions to RStudio Connect](publishing-extensions.html) 50 | - [Using plumbertableau Extensions in Tableau](tableau-developer-guide.html) 51 | -------------------------------------------------------------------------------- /R/client.R: -------------------------------------------------------------------------------- 1 | #' Programatically invoke a Tableau extension function 2 | #' 3 | #' Simulates invoking a Tableau extension function from a Tableau calculated 4 | #' field `SCRIPT_*` call. Intended for unit testing of plumbertableau extensions. 5 | #' 6 | #' @param pr Either a [tableau_extension] style Plumber router object, or, the 7 | #' filename of a plumber.R that implements a Tableau extension. 8 | #' @param script The script string that identifies the plumber route to invoke. 9 | #' (Equivalent to the first argument to `SCRIPT_STR`, et al., in Tableau.) URL 10 | #' query parameters are allowed. 11 | #' @param ... Zero or more unnamed arguments to be passed to the script. 12 | #' @param .toJSON_args Additional options that should be passed to 13 | #' [jsonlite::toJSON()] when the `...` arguments are serialized; for example, 14 | #' `pretty = TRUE` or `digits = 8`. 15 | #' @param .quiet If `TRUE`, do not print response bodies when errors occur. 16 | #' 17 | #' @return The object that was returned from the request, JSON-decoded using 18 | #' `jsonlite::parse_json`. 19 | #' 20 | #' @examples 21 | #' pr_path <- system.file("plumber/stringutils/plumber.R", 22 | #' package = "plumbertableau") 23 | #' 24 | #' tableau_invoke(pr_path, "/lowercase", LETTERS[1:5]) 25 | #' 26 | #' @export 27 | tableau_invoke <- function(pr, script, ..., .toJSON_args = NULL, .quiet = FALSE) { 28 | # Prevents Swagger UI from launching--sometimes. (It doesn't work when pr is 29 | # an actual router object, since you need to have these set at the time of 30 | # Plumber router initialization.) 31 | op <- options(plumber.docs.callback = NULL, plumber.swagger.url = NULL) 32 | on.exit(options(op), add = TRUE) 33 | 34 | if (is.character(pr)) { 35 | pr <- plumber::plumb(pr) 36 | } 37 | payload <- encode_payload(script, ..., .toJSON_args = .toJSON_args) 38 | 39 | port <- httpuv::randomPort() 40 | 41 | url <- paste0("http://localhost:", port, "/evaluate") 42 | 43 | result <- NULL 44 | is_error <- FALSE 45 | later_handle <- later::later(~{ 46 | h <- curl::new_handle( 47 | url = url, 48 | post = TRUE, 49 | postfieldsize = length(payload), 50 | postfields = payload 51 | ) 52 | curl::handle_setheaders(h, "Content-Type" = "application/json") 53 | 54 | p <- promises::then(curl_async(h), ~{ 55 | resp <- . 56 | if (!isTRUE(resp$status_code == 200)) { 57 | if (!isTRUE(.quiet)) { 58 | message(rawToChar(resp$content)) 59 | } 60 | stop("Unexpected status code: ", resp$status_code) 61 | } 62 | if (!identical(resp$type, "application/json")) { 63 | stop("Response had unexpected content type: ", resp$type) 64 | } 65 | 66 | result <<- jsonlite::parse_json(rawToChar(resp$content), simplifyVector = TRUE) 67 | is_error <<- FALSE 68 | }) 69 | p <- promises::catch(p, ~{ 70 | result <<- . 71 | is_error <<- TRUE 72 | }) 73 | p <- promises::finally(p, ~{ 74 | # Make pr$run() return 75 | httpuv::interrupt() 76 | }) 77 | }) 78 | # In case of error 79 | on.exit(later_handle(), add = TRUE) 80 | 81 | pr$run(port = port, quiet = TRUE, swaggerCallback = NULL) 82 | 83 | if (is_error) { 84 | stop(result) 85 | } else { 86 | result 87 | } 88 | } 89 | 90 | encode_payload <- function(script, ..., .toJSON_args, raw = TRUE) { 91 | data <- rlang::list2(...) 92 | if (!is.null(names(data))) { 93 | stop("tableau_invoke requires ... arguments to be unnamed") 94 | } 95 | data <- lapply(data, I) 96 | if (length(data) > 0) { 97 | names(data) <- paste0("_arg", seq_len(length(data))) 98 | } 99 | payload <- list( 100 | data = data, 101 | script = jsonlite::unbox(script) 102 | ) 103 | 104 | json <- do.call(jsonlite::toJSON, c(list(x = payload, na = "null"), .toJSON_args)) 105 | json <- enc2utf8(json) 106 | if (raw == TRUE) { 107 | charToRaw(json) 108 | } else { 109 | json 110 | } 111 | } 112 | 113 | curl_async <- function(handle, polling_interval = 0.1) { 114 | p <- promises::promise(function(resolve, reject) { 115 | curl::multi_add(handle, done = resolve, fail = reject) 116 | }) 117 | 118 | p <- promises::finally(p, ~{ 119 | stopifnot(!is.null(later_handle)) 120 | later_handle() 121 | }) 122 | 123 | later_handle <- NULL 124 | poll <- function() { 125 | curl::multi_run(timeout = 0, poll = TRUE) 126 | later_handle <<- later::later(poll, polling_interval) 127 | } 128 | poll() 129 | 130 | stopifnot(!is.null(later_handle)) 131 | 132 | p 133 | } 134 | -------------------------------------------------------------------------------- /inst/www/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --link-color: #007bff; 3 | } 4 | 5 | body { 6 | font-family: sans-serif; 7 | background-color: #DDD; 8 | } 9 | 10 | .container { 11 | max-width: 860px; 12 | margin: 0 auto; 13 | background-color: white; 14 | padding: 0; 15 | border: 1px solid #888; 16 | box-shadow: rgba(0, 0, 0, 0.22) 0 2rem 8rem; 17 | } 18 | 19 | .warning { 20 | background-color: #cc3300; 21 | padding: 1rem; 22 | margin: 0; 23 | } 24 | 25 | main { 26 | background-color: white; 27 | } 28 | 29 | .padded-fully { 30 | padding: 1rem; 31 | } 32 | 33 | .padded-flat-top { 34 | padding: 0 1rem; 35 | } 36 | 37 | header { 38 | background-color: #4c83b6; 39 | color: white; 40 | display: flex; 41 | } 42 | 43 | .route { 44 | margin-bottom: 3rem; 45 | margin-left: 2rem; 46 | max-width: 1000px; 47 | } 48 | 49 | .route h3 { 50 | margin-left: -2rem; 51 | font-family: monospace; 52 | } 53 | 54 | 55 | table.items { 56 | border-spacing: 1px; 57 | border-collapse: collapse; 58 | width: 100%; 59 | font-size: 0.85em; 60 | } 61 | 62 | table.items th { 63 | text-align: left; 64 | } 65 | 66 | table.items th, table.items td { 67 | padding: 3px; 68 | border: 1px solid #EEE; 69 | } 70 | 71 | td.item-name { 72 | width: 25%; 73 | } 74 | 75 | td.item-type { 76 | width: 15%; 77 | } 78 | 79 | td.item-desc { 80 | width: 60%; 81 | } 82 | 83 | a.permalink { 84 | display: none; 85 | } 86 | 87 | :hover > a.permalink { 88 | display: inline; 89 | position: relative; 90 | top: 0.125em; 91 | text-decoration: none; 92 | font-size: 1.25em; 93 | line-height: 0; 94 | color: var(--link-color); 95 | } 96 | 97 | .button { 98 | background-color: black; 99 | border: none; 100 | color: white; 101 | padding: 10px 20px; 102 | text-align: center; 103 | text-decoration: none; 104 | display: inline-block; 105 | margin: 4px 2px; 106 | cursor: pointer; 107 | border-radius: 16px; 108 | } 109 | 110 | .button:hover { 111 | background-color: #81a8cb; 112 | border: #4c83b6; 113 | } 114 | 115 | .title { 116 | margin: 0; 117 | text-align: left; 118 | } 119 | 120 | .subtitle { 121 | margin: 0; 122 | background: aliceblue; 123 | padding: 1rem; 124 | } 125 | 126 | li { 127 | padding: 0.75rem; 128 | } 129 | 130 | .values { 131 | background-color: lightgray; 132 | padding: 0.75rem; 133 | } 134 | 135 | .emphasized { 136 | font-weight: bold; 137 | } 138 | 139 | .italic { 140 | font-style: italic; 141 | } 142 | 143 | .main-menu:hover,.nav.main-menu.expanded { 144 | width:250px; 145 | overflow:visible; 146 | } 147 | 148 | .main-menu { 149 | background:white; 150 | border-right:1px solid #e5e5e5; 151 | top:0; 152 | bottom:0; 153 | height:100%; 154 | left:0; 155 | width:50px; 156 | overflow:hidden; 157 | -webkit-transition:width .05s linear; 158 | transition:width .05s linear; 159 | -webkit-transform:translateZ(0) scale(1,1); 160 | z-index:1000; 161 | } 162 | 163 | .main-menu>ul { 164 | margin:7px 0; 165 | } 166 | 167 | .main-menu li { 168 | position:relative; 169 | display:block; 170 | width:250px; 171 | } 172 | 173 | .main-menu li>a { 174 | display:table; 175 | border-collapse:collapse; 176 | border-spacing:0; 177 | color:black; 178 | font-family: arial; 179 | font-size: 14px; 180 | text-decoration:none; 181 | -webkit-transform:translateZ(0) scale(1,1); 182 | -webkit-transition:all .1s linear; 183 | transition:all .1s linear; 184 | 185 | } 186 | 187 | .main-menu .nav-icon { 188 | position:relative; 189 | display:table-cell; 190 | width:60px; 191 | height:36px; 192 | text-align:center; 193 | vertical-align:middle; 194 | font-size:18px; 195 | } 196 | 197 | .main-menu .nav-text { 198 | position:relative; 199 | display:table-cell; 200 | vertical-align:middle; 201 | padding: 5px; 202 | width:190px; 203 | font-family: sans-serif; 204 | } 205 | 206 | .main-menu>ul.logout { 207 | position:absolute; 208 | left:0; 209 | bottom:0; 210 | } 211 | 212 | .no-touch .scrollable.hover { 213 | overflow-y:hidden; 214 | } 215 | 216 | .no-touch .scrollable.hover:hover { 217 | overflow-y:auto; 218 | overflow:visible; 219 | } 220 | 221 | a:hover,a:focus { 222 | text-decoration:none; 223 | } 224 | 225 | .nav { 226 | -webkit-user-select:none; 227 | -moz-user-select:none; 228 | -ms-user-select:none; 229 | -o-user-select:none; 230 | user-select:none; 231 | } 232 | 233 | .nav ul,.nav li { 234 | outline:0; 235 | margin:0; 236 | padding:0; 237 | } 238 | 239 | .main-menu li:hover>a,.nav.main-menu li.active>a,.dropdown-menu>li>a:hover,.dropdown-menu>li>a:focus,.dropdown-menu>.active>a,.dropdown-menu>.active>a:hover,.dropdown-menu>.active>a:focus,.no-touch .dashboard-page .nav.dashboard-menu ul li:hover a,.dashboard-page .nav.dashboard-menu ul li.active a { 240 | color:#fff; 241 | background-color:#5fa2db; 242 | } 243 | 244 | .menuitem { 245 | min-width: 40px; 246 | padding: 0.5rem; 247 | background-color: white; 248 | } 249 | -------------------------------------------------------------------------------- /R/openapi.R: -------------------------------------------------------------------------------- 1 | # This is a function that takes a router, and returns a function that operates 2 | # on the OpenAPI spec that's generated for that router. We then call 3 | # plumber::pr_set_api_spec() on the function that this returns. 4 | tableau_openapi <- function(pr) { 5 | function(spec) { 6 | route_info <- extract_route_info(pr) 7 | spec_paths <- names(spec$paths) 8 | # Identify Tableau routes from route_info (these are the only routes from route_info) 9 | tableau_routes <- unlist(lapply(route_info, function(route) route[["path"]])) 10 | 11 | for (path in spec_paths) { 12 | if (path %in% tableau_routes) { 13 | spec$paths[[path]][["post"]][["requestBody"]] <- build_tableau_spec(route_info[tableau_routes == path][[1]]) 14 | } 15 | } 16 | 17 | # Remove paths from spec so they don't show in UI 18 | spec$paths[["/"]] <- NULL 19 | spec$paths[["/setup"]] <- NULL 20 | spec$paths[["/user"]] <- NULL 21 | spec$paths[["/help"]] <- NULL 22 | 23 | # We have different consumers of the description, so we'll split them apart.. 24 | spec$info$user_description <- paste0( 25 | "### Description\n", 26 | spec$info$description, 27 | sep = "\n" 28 | ) 29 | 30 | # Provide additional context in the description field for the OpenAPI documentation. 31 | warnings <- warning_message() 32 | if (!rlang::is_null(warnings)) { 33 | spec$info$description <- paste0( 34 | "### Warnings", 35 | "\n", 36 | "#### The following item(s) need to be resolved before your API will be accessible from Tableau:", 37 | "\n\n---\n\n", 38 | warnings, 39 | sep = "\n" 40 | ) 41 | } else { 42 | spec$info$description <- paste0( 43 | "### Description\n", 44 | spec$info$description, 45 | "\n", 46 | "#### Use the following links to setup and use your Tableau Analytics Extension.", 47 | "\n", 48 | "* [Use your analytics extension from Tableau](../)", 49 | "\n", 50 | "* [Configure Tableau to use your analytics extension](../setup)", 51 | "\n", 52 | "* [Read up on plumbertableau, Tableau, and RStudio Connect](../help)", 53 | sep = "\n" 54 | ) 55 | } 56 | 57 | # Return OAS as a list 58 | spec 59 | } 60 | } 61 | 62 | build_tableau_spec <- function(route_attrs) { 63 | # Extract expected arguments supplied in Tableau request 64 | args <- route_attrs$arg_spec 65 | # Convert the argument descriptions to be OAS compliant 66 | arg_list <- lapply(names(args), function(arg_name) { 67 | list( 68 | type = "array", 69 | description = ifelse(args[[arg_name]]$desc == "", arg_name, paste0(arg_name, ": ", args[[arg_name]]$desc)), 70 | items = list( 71 | type = json_type(args[[arg_name]]$type) 72 | ) 73 | ) 74 | }) 75 | 76 | names(arg_list) <- paste0("_arg", 1:length(arg_list)) 77 | 78 | # Create argument map detailing how Tableau arguments relate to the arguments 79 | # defined for the plumbertableau extension function 80 | arg_map <- Reduce(rbind, lapply(args, as.data.frame)) 81 | arg_map$`arg name` <- names(args) 82 | arg_map$`tableau name` <- names(arg_list) 83 | arg_map <- arg_map[,c("arg name", "tableau name", "type", "desc", "optional")] 84 | names(arg_map) <- c("Arg name", "Tableau name", "Type", "Description", "Optional") 85 | 86 | list(description = paste0( 87 | markdown::markdownToHTML( 88 | text = "### Tableau Request 89 | This is a mock Tableau request. Tableau sends a JSON request formatted like the following JSON. Tableau doesn't provide named arguments and instead assigns each argument `_arg1`, `_arg2`, ... , `_argN`. 90 | 91 | The provided JSON is a simple template based on the described arguments and return values. A more comprehensive mock request can be generated with `mock_tableau_request()`.", 92 | fragment.only = TRUE), 93 | knitr::kable(arg_map, format = "html") 94 | ), 95 | required = TRUE, 96 | content = list( 97 | `application/json` = list( 98 | schema = list( 99 | type = "object", 100 | required = c("script", "data"), 101 | properties = list( 102 | script = list( 103 | type = "string", 104 | description = "Path to desired endpoint", 105 | example = route_attrs$path 106 | ), 107 | data = list( 108 | type = "object", 109 | required = as.list(names(arg_list)[unlist(lapply(args, function(arg) !arg$optional))]), 110 | properties = arg_list 111 | ) 112 | ) 113 | ) 114 | ) 115 | ) 116 | ) 117 | } 118 | 119 | json_type <- function(type) { 120 | switch( 121 | type, 122 | character = "string", 123 | integer = "number", 124 | numeric = "number", 125 | logical = "boolean", 126 | double = "number" 127 | ) 128 | } 129 | -------------------------------------------------------------------------------- /R/setup_guide.R: -------------------------------------------------------------------------------- 1 | globalVariables(names(htmltools::tags)) 2 | 3 | create_setup_instructions <- function(pr) { 4 | cached_instructions <- NULL 5 | 6 | function(req, res) { 7 | "!DEBUG `write_log_message(req, res, 'Generating Tableau Setup Instructions')" 8 | if (is.null(cached_instructions)) { 9 | # Caching works b/c R is restarted when the vanity path changes on RStudio Connect 10 | cached_instructions <<- render_setup_instructions(req$content_path, pr) 11 | } 12 | 13 | cached_instructions 14 | } 15 | } 16 | 17 | render_setup_instructions <- function(path, pr) { 18 | connect_server <- Sys.getenv("CONNECT_SERVER") 19 | server_domain <- urltools::domain(connect_server) 20 | server_port <- urltools::port(connect_server) 21 | warnings <- warning_message() 22 | 23 | if (rlang::is_na(server_port)) { 24 | server_scheme <- urltools::scheme(connect_server) 25 | if (rlang::is_na(server_scheme)) { 26 | # Do nothing 27 | } else if (server_scheme == "http") { 28 | server_port <- 80 29 | } else if (server_scheme == "https") 30 | server_port <- 443 31 | } 32 | apiSpec <- pr$getApiSpec() 33 | desc <- markdown::markdownToHTML(text = apiSpec$info$user_description, 34 | fragment.only = TRUE) 35 | title <- apiSpec$info$title 36 | version <- apiSpec$info$version 37 | body_content <- "body_content not generated" 38 | 39 | title_desc <- htmltools::tagList( 40 | tags$h1( 41 | class="padded-fully title", 42 | title, 43 | if (!is.null(version)) paste0("(v", version, ")") 44 | ), 45 | tags$div(class = "api-desc", 46 | tags$div( 47 | class="padded-flat-top", 48 | htmltools::HTML(desc) 49 | ) 50 | ) 51 | ) 52 | 53 | if (!rlang::is_null(warnings)) { 54 | body_content <- htmltools::tagList( 55 | tags$h3( 56 | class="warning", 57 | "Warning: The following item(s) need to be resolved before your API will be accessible from Tableau!" 58 | ), 59 | tags$div( 60 | class="padded-flat-top", 61 | htmltools::HTML(markdown::markdownToHTML(text = warnings, fragment.only = TRUE)) 62 | ) 63 | ) 64 | } else { 65 | body_content <- htmltools::tagList( 66 | tags$h3( 67 | class="subtitle", 68 | "Configure Tableau to access this extension" 69 | ), 70 | tags$div( 71 | class="padded-flat-top", 72 | tags$h3("If you are using Tableau Server or Tableau Online:"), 73 | tags$ol( 74 | tags$li("Using an administrative account, login to Tableau Server/Online"), 75 | tags$li("Navigate to Settings, then Extensions"), 76 | tags$li("Under the heading 'Analytics Extensions', select 'Enable analytics extension for site"), 77 | tags$li("Create a new connection and select the connection type of 'Analytics Extensions API'"), 78 | tags$li("Select if you want to use SSL"), 79 | tags$li("Enter the information for your RStudio Connect Server:"), 80 | tags$div( 81 | class="values", 82 | tags$div( 83 | tags$span(class="emphasized", "Host:"), 84 | server_domain 85 | ), 86 | tags$div( 87 | tags$span(class="emphasized", "Port:"), 88 | server_port 89 | ) 90 | ), 91 | tags$li("Select 'Sign in with a username and password' and enter the credentials:"), 92 | tags$div( 93 | class="values", 94 | tags$div( 95 | tags$span(class="emphasized", "Username:"), 96 | "rstudio-connect" 97 | ), 98 | tags$div( 99 | tags$span(class="emphasized", "Password:"), 100 | tags$span(class="italic", "any valid API key from RStudio Connect") 101 | ) 102 | ), 103 | tags$li("Create / Save changes") 104 | ) 105 | ), 106 | tags$div( 107 | class="padded-flat-top", 108 | tags$h3("If you are using Tableau Desktop:"), 109 | tags$ol( 110 | tags$li("Navigate to Help, Settings and Performance, Manage Analytics Extension Connection..."), 111 | tags$li("Select 'TabPy/External API'"), 112 | tags$li("Enter the information for your RStudio Connect Server:"), 113 | tags$div( 114 | class="values", 115 | tags$div( 116 | tags$span(class="emphasized", "Host:"), 117 | server_domain 118 | ), 119 | tags$div( 120 | tags$span(class="emphasized", "Port:"), 121 | server_port 122 | ) 123 | ), 124 | tags$li("If desired, select 'Sign in with a username and password' and enter the credentials:"), 125 | tags$div( 126 | class="values", 127 | tags$div( 128 | tags$span(class="emphasized", "Username:"), 129 | "rstudio-connect" 130 | ), 131 | tags$div( 132 | tags$span(class="emphasized", "Password:"), 133 | tags$span(class="italic", "any valid API key from RStudio Connect") 134 | ) 135 | ), 136 | tags$li("Select whether to Require SSL"), 137 | tags$li("Save changes") 138 | ) 139 | ) 140 | ) 141 | } 142 | 143 | as.character(htmltools::htmlTemplate( 144 | system.file("template/index.html", package = "plumbertableau", mustWork = TRUE), 145 | title_desc = title_desc, 146 | body_content = body_content 147 | )) 148 | } 149 | -------------------------------------------------------------------------------- /R/reroute.R: -------------------------------------------------------------------------------- 1 | # A filter applied to the Plumber router to rewrite it for Tableau compatibility. 2 | reroute <- function(req, res) { 3 | "!DEBUG `write_log_message(req, res)" 4 | if (req$PATH_INFO == "/info") { 5 | "!DEBUG `write_log_message(req, res, 'Responding to /info request')" 6 | return(info()) 7 | } 8 | if (req$PATH_INFO == "/evaluate") { 9 | body <- jsonlite::fromJSON(req$postBody) 10 | if ("script" %in% names(body)) { 11 | # This satisfies a Tableau requirement 12 | # See https://tableau.github.io/analytics-extensions-api/docs/ae_known_issues.html 13 | if (body$script == "return int(1)") { 14 | return(1L) 15 | } 16 | # Create the new path 17 | new_path <- body$script 18 | if (!startsWith(new_path, "/")) new_path <- paste0("/", new_path) 19 | 20 | new_path_info <- sub("\\?.*", "", new_path) 21 | new_query_string <- sub("^[^?]*", "", new_path) 22 | req$PATH_INFO <- new_path_info 23 | req$QUERY_STRING <- new_query_string 24 | 25 | # Yuck. The queryStringFilter will have already run. 26 | req$argsQuery <- parseQS(new_query_string) 27 | req$args <- c(req$args, req$argsQuery) 28 | "!DEBUG `write_log_message(req, res, paste('Rerouting /evaluate request to', new_path_info))`" 29 | } 30 | } 31 | plumber::forward() 32 | } 33 | 34 | 35 | # Pulled from Plumber package to avoid using a non-exported function. 36 | #' @noRd 37 | parseQS <- function(qs){ 38 | 39 | if (is.null(qs) || length(qs) == 0L || qs == "") { 40 | return(list()) 41 | } 42 | 43 | # Looked into using webutils::parse_query() 44 | # Currently not pursuing `webutils::parse_query` as it does not handle Encoding issues handled below 45 | # (Combining keys are also not handled by `webutils::parse_query`) 46 | 47 | qs <- stri_replace_first_regex(qs, "^[?]", "") 48 | qs <- chartr("+", " ", qs) 49 | 50 | args <- stri_split_fixed(qs, "&", omit_empty = TRUE)[[1L]] 51 | kv <- lapply(args, function(x) { 52 | # returns utf8 strings 53 | httpuv::decodeURIComponent(stri_split_fixed(x, "=", omit_empty = TRUE)[[1]]) 54 | }) 55 | kv <- kv[vapply(kv, length, numeric(1)) == 2] # Ignore incompletes 56 | 57 | if (length(kv) == 0) { 58 | # return a blank list of args if there is nothing to parse 59 | return(list()) 60 | } 61 | 62 | keys <- vapply(kv, `[`, character(1), 1) 63 | kenc <- unique(Encoding(keys)) 64 | if (any(kenc != "unknown")) { 65 | # https://github.com/rstudio/plumber/pull/314#discussion_r239992879 66 | non_ascii <- setdiff(kenc, "unknown") 67 | warning( 68 | "Query string parameter received in non-ASCII encoding. Received: ", 69 | paste0(non_ascii, collapse = ", ") 70 | ) 71 | } 72 | 73 | vals <- lapply(kv, `[`, 2) 74 | names(vals) <- keys 75 | 76 | # If duplicates, combine 77 | combine_keys(vals, type = "query") 78 | } 79 | 80 | 81 | #' @noRd 82 | #' @importFrom stats setNames 83 | combine_keys <- function(obj, type) { 84 | 85 | keys <- names(obj) 86 | unique_keys <- unique(keys) 87 | 88 | # If a query string as the same amount of unique keys as keys, 89 | # then return it as it 90 | # (`"multi"` type objects MUST be processed, regardless if the unique key count is the same) 91 | if ( 92 | length(unique_keys) == length(keys) && 93 | identical(type, "query") 94 | ) { 95 | return(obj) 96 | } 97 | 98 | vals <- unname(obj) 99 | 100 | cleanup_item <- switch( 101 | type, 102 | "query" = 103 | function(x) { 104 | unname(unlist(x)) 105 | }, 106 | "multi" = 107 | function(x) { 108 | if (length(x) == 1) { 109 | part <- x[[1]] 110 | filename <- part$filename 111 | parsed <- part$parsed 112 | 113 | if (!is.null(filename)) { 114 | # list( 115 | # "myfile.json" = list( 116 | # a = 1, b = 2 117 | # ) 118 | # ) 119 | return( 120 | setNames( 121 | list(parsed), 122 | filename 123 | ) 124 | ) 125 | } 126 | # list( 127 | # a = 1, b = 2 128 | # ) 129 | return(parsed) 130 | } 131 | 132 | # length is > 1 133 | 134 | has_a_filename <- FALSE 135 | filenames <- lapply(x, function(part) { 136 | filename <- part$filename 137 | if (is.null(filename)) return("") 138 | has_a_filename <<- TRUE 139 | filename 140 | }) 141 | 142 | parsed_items <- lapply(unname(x), `[[`, "parsed") 143 | 144 | if (!has_a_filename) { 145 | # return as is 146 | return(parsed_items) 147 | } 148 | 149 | return(setNames(parsed_items, filenames)) 150 | }, 151 | stop("unknown type: ", type) 152 | ) 153 | 154 | # equivalent code output, `split` is much faster with larger objects 155 | # Testing on personal machine had a breakpoint around 150 letters as query parameters 156 | ## n <- 150 157 | ## k <- sample(letters, n, replace = TRUE) 158 | ## v <- as.list(sample(1L, n, replace = TRUE)) 159 | ## microbenchmark::microbenchmark( 160 | ## split = { 161 | ## lapply(split(v, k), function(x) unname(unlist(x))) 162 | ## }, 163 | ## not_split = { 164 | ## lapply(unique(k), function(x) { 165 | ## unname(unlist(v[k == x])) 166 | ## }) 167 | ## } 168 | ## ) 169 | vals <- 170 | if (length(unique_keys) > 150) { 171 | lapply(split(vals, keys), function(items) { 172 | cleanup_item(items) 173 | }) 174 | } else { 175 | # n < 150 176 | lapply(unique_keys, function(key) { 177 | cleanup_item(vals[keys == key]) 178 | }) 179 | } 180 | names(vals) <- unique_keys 181 | 182 | vals 183 | } 184 | -------------------------------------------------------------------------------- /README.Rmd: -------------------------------------------------------------------------------- 1 | --- 2 | output: github_document 3 | --- 4 | 5 | 6 | 7 | ```{r setup, include = FALSE} 8 | knitr::opts_chunk$set( 9 | collapse = TRUE, 10 | comment = "#>", 11 | eval = FALSE, 12 | fig.path = "man/figures/README-", 13 | out.width = "100%" 14 | ) 15 | 16 | # R chunks 17 | knitr::read_chunk(path = "inst/plumber/capitalize/plumber.R", 18 | from = 9, 19 | labels = "capitalize") 20 | ``` 21 | 22 | # plumbertableau 23 | 24 | 25 | [![R-CMD-check](https://github.com/rstudio/plumbertableau/workflows/R-CMD-check/badge.svg)](https://github.com/rstudio/plumbertableau/actions) 26 | [![Codecov test coverage](https://codecov.io/gh/rstudio/plumbertableau/branch/main/graph/badge.svg)](https://app.codecov.io/gh/rstudio/plumbertableau?branch=main) 27 | [![CRAN status](https://www.r-pkg.org/badges/version/plumbertableau)](https://CRAN.R-project.org/package=plumbertableau) 28 | 29 | 30 | plumbertableau lets you call external R code in real time from Tableau workbooks via [Tableau Analytics Extensions](https://tableau.github.io/analytics-extensions-api/). You achieve this by writing a plumbertableau extension, which is a [Plumber](https://www.rplumber.io/) API with some extra annotations — comments prefixed with `#*`. 31 | 32 | ```{r capitalize} 33 | ``` 34 | 35 | plumbertableau extensions are used in Tableau's *calculated fields*. Let's imagine we've published our extension to RStudio Connect and have given it the custom URL `stringutils`. To use our `capitalize` extension, we'd type the following into a Tableau calculated field, or just copy and paste it from the automatically generated code samples. (In real usage, you'll probably replace `"Hello World"` with references to Tableau data.) 36 | 37 | ``` 38 | SCRIPT_STR("/stringutils/capitalize", "Hello World") 39 | ``` 40 | 41 | Before you use the extension in Tableau, Tableau needs to be able to access it. plumbertableau integrates seamlessly with [RStudio Connect](https://posit.co/products/enterprise/connect/), a commercial publishing platform that enables R developers to easily publish a variety of R content types. Connect lets you host multiple extensions by ensuring that requests from Tableau are passed to the correct extension. It's also possible to host plumbertableau extensions on your own servers. 42 | 43 | ## Installation 44 | You can install plumbertableau from CRAN or install the latest development version from GitHub. 45 | 46 | ```r 47 | # From CRAN 48 | install.packages("plumbertableau") 49 | 50 | # From GitHub 51 | remotes::install_github("rstudio/plumbertableau") 52 | 53 | library(plumbertableau) 54 | ``` 55 | 56 | ## FAQ 57 | #### I thought Tableau already supports R? 58 | Tableau's current support for R as an analytics extension is built on 59 | [`Rserve`](https://rforge.net/Rserve/index.html). This approach requires 60 | configuring Rserve in a separate environment and then passing R code as plain 61 | text from Tableau calculated fields to be executed by Rserve. 62 | 63 | #### Why would I use this instead of RServe? 64 | The approach suggested here allows specific endpoints to be called, rather than 65 | requiring the Tableau user to write and submit R code in a plain text field from 66 | Tableau. This allows Tableau users to be seperate from the extension developers. 67 | R developers can build extensions that are then used by Tableau developers who 68 | may have no working knowledge of R. 69 | 70 | #### Is RStudio Connect required? 71 | While this package has been designed specifically with RStudio Connect in mind, 72 | it will work independent of RStudio Connect. 73 | 74 | #### What are the advantages of RStudio Connect? 75 | RStudio Connect offers a number of advantages as a deployment platform for 76 | Tableau Analytics Extensions: 77 | 78 | - Tableau workbooks can only be configured to use a single extension endpoint, 79 | which typically limits a workbook to only using one type of extension. RStudio 80 | Connect can host both R and Python based extensions, which means that a single 81 | Tableau workbook can use both R and Python based extensions hosted on RStudio 82 | Connect. 83 | - R developers can develop extensions in their preferred development environment 84 | and then publish to RStudio Connect 85 | - Extensions published to RStudio Connect can be secured to only allow access 86 | from specific accounts 87 | - RStudio Connect natively handles 88 | [R](https://docs.posit.co/connect/admin/r/package-management/) and 89 | [Python](https://docs.posit.co/connect/admin/python/package-management/) 90 | packages, which allows extensions to seemlessly use different versions of 91 | underlying packages without creating conflicts. 92 | - RStudio Connect processes are 93 | [sandboxed](https://docs.posit.co/connect/admin/process-management/#sandboxing), 94 | which limits the scope of impact the extension can have on the underlying OS. 95 | 96 | #### Why can't I just write my own Plumber API to function as an analytics extension? 97 | Tableau Analytics Extensions are configured to reach out to two specific endpoints: 98 | 99 | - `/info`: Information about the extension 100 | - `/evaluate`: Execution endpoint for the extension 101 | 102 | plumbertableau automatically generates the `/info` endpoint and reroutes 103 | requests to `/evaluate` to the endpoint defined in the `script` value of the 104 | request body. This allows multiple endpoints to function as extensions, rather 105 | than relying on a single extension operating under `/evaluate`. These features 106 | are intended to allow the R developer to easily create Tableau Analytics 107 | Extensions as standard Plumber APIs without needing to worry about the lower 108 | level implementation. 109 | 110 | ## Further Reading 111 | 112 | You can read more about plumbertableau at https://rstudio.github.io/plumbertableau/. There, you'll find more detail about writing plumbertableau extensions, publishing them to RStudio Connect, configuring Tableau, and using your extensions in Tableau. 113 | -------------------------------------------------------------------------------- /vignettes/publishing-extensions.Rmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Publishing plumbertableau Extensions to RStudio Connect" 3 | output: rmarkdown::html_vignette 4 | vignette: > 5 | %\VignetteIndexEntry{Publishing plumbertableau Extensions to RStudio Connect} 6 | %\VignetteEngine{knitr::rmarkdown} 7 | %\VignetteEncoding{UTF-8} 8 | --- 9 | 10 | ```{r, include = FALSE} 11 | knitr::opts_chunk$set( 12 | collapse = TRUE, 13 | comment = "#>" 14 | ) 15 | ``` 16 | 17 | ```{r setup, include = FALSE} 18 | library(plumbertableau) 19 | ``` 20 | 21 | [RStudio Connect](https://posit.co/products/enterprise/connect/) is a publishing platform for sharing the work produced by data science teams. It supports content in both R and Python, including Shiny apps and Plumber APIs. You can publish content to RStudio Connect [in a number of ways](https://docs.posit.co/connect/user/publishing/), including 1-click publishing from the RStudio IDE. 22 | 23 | plumbertableau integrates seamlessly with RStudio Connect. You can host any number of plumbertableau extensions on RStudio Connect, and Connect will ensure that requests from Tableau are passed to the correct extension. 24 | 25 | ## Setting up RStudio Connect {#connect} 26 | 27 | ### Requirements {#requirements} 28 | 29 | - Tableau extensions are only supported in recent versions of RStudio Connect. See the [RStudio Connect release notes](https://docs.posit.co/connect/news/) to determine the minimum version with support for Tableau extensions. 30 | - RStudio Connect must be configured with the following settings: 31 | - [`TableauExtension.Enabled`](https://docs.posit.co/connect/admin/appendix/configuration/#TableauExtension) must not be set to `False`. 32 | - [`Server.Address`](https://docs.posit.co/connect/admin/appendix/configuration/#Server.Address) must be configured. 33 | 34 | See the [RStudio Connect Admin Guide](https://docs.posit.co/connect/admin/appendix/configuration/) for more information about configuring Connect. 35 | 36 | Extensions published to an incompatible RStudio Connect server will provide a warning message that detects which of the above criteria are not met. 37 | 38 | ![](files/warnings.png){width="75%"} 39 | 40 | ### Access, Permissions, and Security {#access} 41 | 42 | Your RStudio Connect server must be [configured as an Analytics Extension in Tableau](#tableau). Tableau requires an [API key](https://docs.posit.co/connect/user/api-keys/) to authenticate with RStudio Connect. This API key will be used for all requests from Tableau to RStudio Connect, and the Tableau instance will have access to any content on Connect that the API key's owner can access. 43 | 44 | We recommend creating a "service account" (an account used only by Tableau, not by any individual) on RStudio Connect, and configuring Tableau with an API key for this account. If you name the service account "Tableau", simply add the "Tableau" to the [Access List](https://docs.posit.co/connect/user/content-settings/) for any content it should be able to access. This way, you can more finely control what content Tableau can access without affecting any users. 45 | 46 | ## Configuring RStudio Connect as an Analytics Extension in Tableau {#tableau} 47 | 48 | Before you can use extensions hosted on an RStudio Connect server from Tableau, you'll need to register that server in Tableau's Extensions settings. The steps to configure analytics extensions in Tableau differ slightly depending on whether you're using Tableau Desktop or Tableau Server/Online. 49 | 50 | ### Tableau Server / Tableau Online 51 | 1. Using an administrative account, login to Tableau Server/Online 52 | 2. Navigate to Settings, then Extensions 53 | 3. Under the heading 'Analytics Extensions', select 'Enable analytics extension for site' 54 | 4. Create a new connection and select the connection type of 'Analytics Extensions API' 55 | 5. Select whether you want to use SSL and enter the server Host and Port for your RStudio Connect server. **Please note that due to the way Tableau handles Host and Port, your RStudio Connect server must be listening on the root path of the provided Host value. You cannot use a reverse proxy with RStudio Connect and Tableau.** 56 | 6. Select 'Sign in with a username and password'. The username is 'rstudio-connect' and the password is any valid API key from RStudio Connect 57 | 8. Create / Save changes 58 | 59 | ### Tableau Desktop 60 | 1. Navigate to Help, Settings and Performance, Manage Analytics Extension Connection... 61 | 2. Select 'TabPy/External API' 62 | 3. Set Server and Port to the address and port of the server running the API 63 | 4. If desired, select 'Sign in with a username and password'. The username is 'rstudio-connect' and the password is any valid API key from RStudio Connect 64 | 5. Select whether to Require SSL 65 | 6. Save changes 66 | 67 | If you're using Tableau Desktop, you don't need to use authentication if you're using an extension running locally in R. 68 | 69 | ## Using Custom URLs for plumbertableau Extensions on RStudio Connect {#deploying} 70 | 71 | All content hosted on RStudio Connect receives a random identifier, which is used as part of its default URL. You can optionally [set a custom URL](https://docs.posit.co/connect/user/content-settings/) (called a vanity URL) for any piece of content on RStudio Connect in its control panel. By default, only administrators can assign vanity paths, but RStudio Connect [can be configured](https://docs.posit.co/connect/admin/content-management/) to allow all publishers to assign them. 72 | 73 | We recommend using vanity paths for plumbertableau extensions. You must use a content identifier in calls to Tableau extensions, and a vanity URL makes the difference between `SCRIPT_REAL("/content/c8b1e158-4fab-4d09-9791-8674afba86eb/predict", ...)` and `SCRIPT_REAL("/loess/predict", ...)`. 74 | 75 | This isn't required. You can access extensions published to RStudio Connect by their content identifier or vanity URL. If an extension has a vanity URL, RStudio Connect will prefer that in all of the documentation it generates. 76 | 77 | ## Debugging plumbertableau Extensions on RStudio Connect {#debugging} 78 | 79 | plumbertableau supports debug logging via the [`debugme`](https://github.com/r-lib/debugme) R package. 80 | 81 | To enable debug logging on a deployed extension, [open the "Vars" section of the content control panel](https://docs.posit.co/connect/user/content-settings/) in RStudio Connect. Ensure that an environment variable called `DEBUGME` exists and that it contains the text `plumbertableau`. 82 | 83 | Additional messages will appear in the "Logs" tab of the control panel, with information about the contents and processing of each request the extension receives. 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | # plumbertableau 5 | 6 | 7 | 8 | [![R-CMD-check](https://github.com/rstudio/plumbertableau/workflows/R-CMD-check/badge.svg)](https://github.com/rstudio/plumbertableau/actions) 9 | [![Codecov test 10 | coverage](https://codecov.io/gh/rstudio/plumbertableau/branch/main/graph/badge.svg)](https://app.codecov.io/gh/rstudio/plumbertableau?branch=main) 11 | [![CRAN 12 | status](https://www.r-pkg.org/badges/version/plumbertableau)](https://CRAN.R-project.org/package=plumbertableau) 13 | 14 | 15 | plumbertableau lets you call external R code in real time from Tableau 16 | workbooks via [Tableau Analytics 17 | Extensions](https://tableau.github.io/analytics-extensions-api/). You 18 | achieve this by writing a plumbertableau extension, which is a 19 | [Plumber](https://www.rplumber.io/) API with some extra annotations — 20 | comments prefixed with `#*`. 21 | 22 | ``` r 23 | library(plumber) 24 | library(plumbertableau) 25 | 26 | #* @apiTitle String utilities 27 | #* @apiDescription Simple functions for mutating strings 28 | 29 | #* Capitalize incoming text 30 | #* @tableauArg str_value:[character] Strings to be capitalized 31 | #* @tableauReturn [character] A capitalized string(s) 32 | #* @post /capitalize 33 | function(str_value) { 34 | toupper(str_value) 35 | } 36 | 37 | # The Plumber router modifier tableau_extension is required. This object is a 38 | # function that acts as a plumber router modifier. For more details, see the 39 | # Plumber documentation: 40 | # https://www.rplumber.io/articles/annotations.html#plumber-router-modifier 41 | #* @plumber 42 | tableau_extension 43 | ``` 44 | 45 | plumbertableau extensions are used in Tableau’s *calculated fields*. 46 | Let’s imagine we’ve published our extension to RStudio Connect and have 47 | given it the custom URL `stringutils`. To use our `capitalize` 48 | extension, we’d type the following into a Tableau calculated field, or 49 | just copy and paste it from the automatically generated code samples. 50 | (In real usage, you’ll probably replace `"Hello World"` with references 51 | to Tableau data.) 52 | 53 | SCRIPT_STR("/stringutils/capitalize", "Hello World") 54 | 55 | Before you use the extension in Tableau, Tableau needs to be able to 56 | access it. plumbertableau integrates seamlessly with [RStudio 57 | Connect](https://posit.co/products/enterprise/connect/), a commercial 58 | publishing platform that enables R developers to easily publish a 59 | variety of R content types. Connect lets you host multiple extensions by 60 | ensuring that requests from Tableau are passed to the correct extension. 61 | It’s also possible to host plumbertableau extensions on your own 62 | servers. 63 | 64 | ## Installation 65 | 66 | You can install plumbertableau from CRAN or install the latest 67 | development version from GitHub. 68 | 69 | ``` r 70 | # From CRAN 71 | install.packages("plumbertableau") 72 | 73 | # From GitHub 74 | remotes::install_github("rstudio/plumbertableau") 75 | 76 | library(plumbertableau) 77 | ``` 78 | 79 | ## FAQ 80 | 81 | #### I thought Tableau already supports R? 82 | 83 | Tableau’s current support for R as an analytics extension is built on 84 | [`Rserve`](https://rforge.net/Rserve/index.html). This approach requires 85 | configuring Rserve in a separate environment and then passing R code as 86 | plain text from Tableau calculated fields to be executed by Rserve. 87 | 88 | #### Why would I use this instead of RServe? 89 | 90 | The approach suggested here allows specific endpoints to be called, 91 | rather than requiring the Tableau user to write and submit R code in a 92 | plain text field from Tableau. This allows Tableau users to be seperate 93 | from the extension developers. R developers can build extensions that 94 | are then used by Tableau developers who may have no working knowledge of 95 | R. 96 | 97 | #### Is RStudio Connect required? 98 | 99 | While this package has been designed specifically with RStudio Connect 100 | in mind, it will work independent of RStudio Connect. 101 | 102 | #### What are the advantages of RStudio Connect? 103 | 104 | RStudio Connect offers a number of advantages as a deployment platform 105 | for Tableau Analytics Extensions: 106 | 107 | - Tableau workbooks can only be configured to use a single extension 108 | endpoint, which typically limits a workbook to only using one type 109 | of extension. RStudio Connect can host both R and Python based 110 | extensions, which means that a single Tableau workbook can use both 111 | R and Python based extensions hosted on RStudio Connect. 112 | - R developers can develop extensions in their preferred development 113 | environment and then publish to RStudio Connect 114 | - Extensions published to RStudio Connect can be secured to only allow 115 | access from specific accounts 116 | - RStudio Connect natively handles 117 | [R](https://docs.posit.co/connect/admin/r/package-management/) 118 | and 119 | [Python](https://docs.posit.co/connect/admin/python/package-management/) 120 | packages, which allows extensions to seemlessly use different 121 | versions of underlying packages without creating conflicts. 122 | - RStudio Connect processes are 123 | [sandboxed](https://docs.posit.co/connect/admin/process-management/#sandboxing), 124 | which limits the scope of impact the extension can have on the 125 | underlying OS. 126 | 127 | #### Why can’t I just write my own Plumber API to function as an analytics extension? 128 | 129 | Tableau Analytics Extensions are configured to reach out to two specific 130 | endpoints: 131 | 132 | - `/info`: Information about the extension 133 | - `/evaluate`: Execution endpoint for the extension 134 | 135 | plumbertableau automatically generates the `/info` endpoint and reroutes 136 | requests to `/evaluate` to the endpoint defined in the `script` value of 137 | the request body. This allows multiple endpoints to function as 138 | extensions, rather than relying on a single extension operating under 139 | `/evaluate`. These features are intended to allow the R developer to 140 | easily create Tableau Analytics Extensions as standard Plumber APIs 141 | without needing to worry about the lower level implementation. 142 | 143 | ## Further Reading 144 | 145 | You can read more about plumbertableau at 146 | . There, you’ll find more 147 | detail about writing plumbertableau extensions, publishing them to 148 | RStudio Connect, configuring Tableau, and using your extensions in 149 | Tableau. 150 | -------------------------------------------------------------------------------- /inst/template/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ headContent() }} 7 | 8 | 9 |

10 |
11 | 89 |
90 | {{ title_desc }} 91 |
92 |
93 |
94 | {{ body_content }} 95 |
96 |
97 | 98 | 99 | -------------------------------------------------------------------------------- /R/user_guide.R: -------------------------------------------------------------------------------- 1 | globalVariables(names(htmltools::tags)) 2 | 3 | #' @importFrom htmltools tags 4 | create_user_guide <- function(pr) { 5 | function(req, res) { 6 | "!DEBUG `write_log_message(req, res, 'Generating Tableau Usage Instructions')" 7 | # Parse the endpoint path from header on RStudio Connect 8 | content_path <- NULL 9 | if (!rlang::is_null(req$HTTP_RSTUDIO_CONNECT_APP_BASE_URL)) { 10 | base_url <- req$HTTP_RSTUDIO_CONNECT_APP_BASE_URL 11 | rsc_root <- Sys.getenv("CONNECT_SERVER") 12 | content_path <- gsub(rsc_root, 13 | "", 14 | base_url, 15 | fixed = TRUE 16 | ) 17 | 18 | # If the path requested is not root (/), strip it from the content path 19 | if (req$PATH_INFO != "/") { 20 | content_path <- gsub(req$PATH_INFO, 21 | "", 22 | content_path, 23 | fixed = TRUE 24 | ) 25 | } 26 | 27 | # Ensure path starts with '/' 28 | if (!stringi::stri_startswith(content_path, fixed = "/")) content_path <- paste0("/", content_path) 29 | } 30 | render_user_guide(content_path, pr) 31 | } 32 | } 33 | 34 | render_user_guide <- function(path, pr) { 35 | warnings <- warning_message() 36 | 37 | apiSpec <- pr$getApiSpec() 38 | title <- apiSpec$info$title 39 | version <- apiSpec$info$version 40 | desc <- markdown::markdownToHTML(text = apiSpec$info$user_description, 41 | fragment.only = TRUE) 42 | body_content <- "body_content not generated" 43 | 44 | title_desc <- htmltools::tagList( 45 | tags$h1( 46 | class="padded-fully title", 47 | title, 48 | if (!is.null(version)) paste0("(v", version, ")") 49 | ), 50 | tags$div(class = "api-desc", 51 | tags$div( 52 | class="padded-flat-top", 53 | htmltools::HTML(desc) 54 | ) 55 | ) 56 | ) 57 | 58 | if (!rlang::is_null(warnings)) { 59 | body_content <- htmltools::tagList( 60 | tags$h3( 61 | class="warning", 62 | "Warning: The following item(s) need to be resolved before your API will be accessible from Tableau." 63 | ), 64 | tags$div( 65 | class="padded-flat-top", 66 | htmltools::HTML(markdown::markdownToHTML(text = warnings, fragment.only = TRUE)) 67 | ) 68 | ) 69 | } else { 70 | body_content <- htmltools::tagList( 71 | tags$h3( 72 | class="subtitle", 73 | "Use this extension in Tableau" 74 | ), 75 | tags$div(class = "padded-fully routes", 76 | lapply(extract_route_info(pr, path), render_route_info) 77 | ) 78 | ) 79 | } 80 | 81 | as.character(htmltools::htmlTemplate( 82 | system.file("template/index.html", package = "plumbertableau", mustWork = TRUE), 83 | title_desc = title_desc, 84 | body_content = body_content 85 | )) 86 | } 87 | 88 | render_route_info <- function(route_info) { 89 | htmltools::withTags( 90 | div(class = "route", 91 | h3(id = route_info$path, class = "path", 92 | route_info$path, 93 | a(class = "permalink", href = paste0("#", route_info$path), 94 | "#" 95 | ) 96 | ), 97 | if (any(nzchar(route_info$comments))) { 98 | div(class = "desc", 99 | h4("Description"), 100 | div(route_info$comments) 101 | ) 102 | }, 103 | div(class = "usage", 104 | h4("Usage"), 105 | code(render_usage(route_info)) 106 | ), 107 | if (length(route_info$param_spec) > 0) { 108 | div(class = "params", 109 | h4("Params"), 110 | render_args(route_info$param_spec) 111 | ) 112 | }, 113 | if (length(route_info$arg_spec) > 0) { 114 | div(class = "args", 115 | h4("Arguments"), 116 | render_args(route_info$arg_spec) 117 | ) 118 | }, 119 | if (length(route_info$return_spec$desc) > 0 && any(nzchar(route_info$return_spec$desc))) { 120 | div(class = "return", 121 | h4("Return value"), 122 | table(class = "items", 123 | tr( 124 | th("Type"), 125 | th("Description") 126 | ), 127 | tr( 128 | td(route_info$return_spec$type), 129 | td(route_info$return_spec$desc) 130 | ) 131 | ) 132 | ) 133 | } 134 | ) 135 | ) 136 | } 137 | 138 | render_usage <- function(route_info) { 139 | calling_func <- switch(route_info$return_spec$type, 140 | "logical" = "SCRIPT_BOOL", 141 | "character" = "SCRIPT_STR", 142 | "numeric" = "SCRIPT_REAL", 143 | "integer" = "SCRIPT_INT" 144 | ) 145 | 146 | usage_args <- if (length(route_info$arg_spec) > 0) { 147 | paste0(", ", names(route_info$arg_spec), collapse = "") 148 | } 149 | 150 | query <- "" 151 | if (length(route_info$param_spec) > 0) { 152 | query <- paste0("?", 153 | paste(collapse = "&", 154 | paste0(names(route_info$param_spec), "=<", names(route_info$param_spec), ">") 155 | ) 156 | ) 157 | } 158 | 159 | paste0(calling_func, "(", 160 | "\"", route_info$path, query, "\"", 161 | usage_args, 162 | ")") 163 | } 164 | 165 | render_args <- function(arg_spec) { 166 | htmltools::withTags( 167 | table(class = "items", 168 | tr( 169 | th("Name"), 170 | th("Type"), 171 | th("Description") 172 | ), 173 | mapply(names(arg_spec), arg_spec, FUN = function(nm, spec) { 174 | tr( 175 | td(class = "item-name", code(nm)), 176 | td(class = "item-type", normalize_type_to_tableau(spec$type)), 177 | td(class = "item-desc", 178 | if (spec$optional) em("(Optional)"), 179 | if (any(nzchar(spec$desc))) spec$desc 180 | ) 181 | ) 182 | }, SIMPLIFY = FALSE, USE.NAMES = FALSE) 183 | ) 184 | ) 185 | } 186 | 187 | extract_route_info <- function(pr, path = NULL) { 188 | # Returns a list containing a bunch of attributes about each route. 189 | results <- lapply(pr$endpoints, function(routes) { 190 | lapply(routes, function(route) { 191 | if (rlang::is_null(path)) { 192 | path <- route$path 193 | } else { 194 | path <- ifelse(grepl(paste0("^", path), route$path), route$path, paste0(path, route$path)) 195 | # Remove any potential double // from path 196 | path <- gsub("//", "/", path) 197 | } 198 | 199 | func <- route$getFunc() 200 | arg_spec <- attr(func, "tableau_arg_specs", exact = TRUE) 201 | return_spec <- attr(func, "tableau_return_spec", exact = TRUE) 202 | param_spec <- extract_param_spec(route) 203 | if (!is.null(arg_spec)) { 204 | list(comments = route$comments, path = path, param_spec = param_spec, 205 | arg_spec = arg_spec, return_spec = return_spec) 206 | } else { 207 | NULL 208 | } 209 | }) 210 | }) 211 | results <- unname(results) 212 | results <- unlist(recursive = FALSE, results) 213 | results <- results[vapply(results, Negate(is.null), logical(1))] 214 | results 215 | } 216 | 217 | extract_param_spec <- function(route) { 218 | params <- route$getEndpointParams() 219 | func <- route$getFunc() 220 | defaults <- lapply(as.list(formals(func)), function(x) { 221 | if (is.character(x) || is.numeric(x) || is.integer(x)) { 222 | x 223 | } else if (is.logical(x)) { 224 | x 225 | } else { 226 | NULL 227 | } 228 | }) 229 | 230 | mapply(names(params), params, FUN = function(nm, param) { 231 | param_spec(type = param$type, 232 | desc = param$desc, 233 | optional = !isTRUE(param$required), 234 | default = defaults[[nm]] 235 | ) 236 | }, SIMPLIFY = FALSE, USE.NAMES = TRUE) 237 | } 238 | -------------------------------------------------------------------------------- /tests/testthat/_snaps/user_guide.md: -------------------------------------------------------------------------------- 1 | # user_guide() 2 | 3 | Code 4 | cat(render_user_guide("", pr)) 5 | Output 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 |
18 |

19 | String utilities 20 | (v1.0.0) 21 |

22 |
23 |

Description

24 | 25 |

This is a Tableau Analytics Extension.

26 | 27 |
28 | 29 |

Simple functions for mutating strings

30 | 31 |

32 | Tableau Setup Instructions 33 |
34 | Open API Documentation 35 |

36 |
37 |
38 |
39 |
40 |
41 |

42 | /lowercase 43 | 44 |

45 |
46 |

Description

47 |
Lowercase incoming text
48 |
49 |
50 |

Usage

51 | SCRIPT_STR("/lowercase?unicode=<unicode>", str_value) 52 |
53 |
54 |

Params

55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 65 | 66 | 70 | 71 |
NameTypeDescription
63 | unicode 64 | boolean 67 | (Optional) 68 | Whether unicode logic should be used 69 |
72 |
73 |
74 |

Arguments

75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 85 | 86 | 87 | 88 |
NameTypeDescription
83 | str_value 84 | stringStrings to be converted to lowercase
89 |
90 |
91 |

Return value

92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 |
TypeDescription
characterA lowercase string
102 |
103 |
104 |
105 |

106 | /concat 107 | 108 |

109 |
110 |

Description

111 |
Concatenate
112 |
113 |
114 |

Usage

115 | SCRIPT_STR("/concat?sep=<sep>", arg1, arg2) 116 |
117 |
118 |

Params

119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 129 | 130 | 134 | 135 |
NameTypeDescription
127 | sep 128 | string 131 | (Optional) 132 | Separator value to use 133 |
136 |
137 |
138 |

Arguments

139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 149 | 150 | 151 | 152 | 153 | 156 | 157 | 158 | 159 |
NameTypeDescription
147 | arg1 148 | stringOne or more string values
154 | arg2 155 | stringOne or more string values to concatenate to `arg1`
160 |
161 |
162 |

Return value

163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 |
TypeDescription
characterarg1 and arg2 concatenated together
173 |
174 |
175 |
176 |

177 | /stringify 178 | 179 |

180 |
181 |

Description

182 |
Convert to string
183 |
184 |
185 |

Usage

186 | SCRIPT_STR("/stringify", value) 187 |
188 |
189 |

Arguments

190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 200 | 201 | 202 | 203 |
NameTypeDescription
198 | value 199 | anyOne or more values of any data type
204 |
205 |
206 |

Return value

207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 |
TypeDescription
characterThe data, converted to string
217 |
218 |
219 |
220 |
221 | 222 |
223 | 224 | 225 | 226 | 227 | -------------------------------------------------------------------------------- /R/messages.R: -------------------------------------------------------------------------------- 1 | # Gathers environment data about the extension's execution environment and 2 | # returns a warning message about factors that would prevent the extension 3 | # from running correctly, or receiving and responding to requests from 4 | # Tableau. 5 | warning_message <- function() { 6 | 7 | # If not running on RSC, return NULL, otherwise perform checks; 8 | if (!check_rstudio_connect()) { 9 | return (NULL) 10 | } 11 | 12 | message_contents <- NULL 13 | message_count <- 0 14 | 15 | server <- Sys.getenv("CONNECT_SERVER", NA_character_) 16 | "!DEBUG Environment Variable CONNECT_SERVER = `server`" 17 | 18 | if (is.na(server) || server == "") { 19 | "!DEBUG Problem: CONNECT_SERVER not defined within environment variables" 20 | 21 | message_contents <- paste0( 22 | message_contents, 23 | paste0("### The environment variable *CONNECT_SERVER* is not defined.", 24 | "\n", 25 | "\nPossible Solutions:", 26 | "\n", 27 | "\n- Have your system administrator confirm *Applications.DefaultServerEnv* is enabled and that *Server.Address* has been defined within the *rstudio-connect.gcfg* file on the RStudio Connect server.", 28 | "\n- Use the application settings for your content within the RStudio Connect dashboard to define the *CONNECT_SERVER* environment variable. It should be set to a reachable https or http address for the server." 29 | ), 30 | sep = "\n\n---\n\n" 31 | ) 32 | message_count <- message_count + 1 33 | } else if (is.null(httr::parse_url(server)$scheme)) { 34 | "!DEBUG Problem: Environment Variable CONNECT_SERVER does not specify the protocol (https:// or http://)." 35 | 36 | message_contents <- paste0( 37 | message_contents, 38 | paste0("### Environment Variable *CONNECT_SERVER* (value = *", server, "* ) does not specify the protocol (*https://* or *http://*).", 39 | "\n", 40 | "\nPossible Solutions:", 41 | "\n", 42 | "\n- Have your system administrator confirm that *Server.Address* has been configured with the proper format within the *rstudio-connect.gcfg* file on the RStudio Connect server.", 43 | "\n- Use the application settings for your content within the RStudio Connect dashboard to define the *CONNECT_SERVER* environment variable with the proper protocol." 44 | ), 45 | sep = "\n\n---\n\n" 46 | ) 47 | message_count <- message_count + 1 48 | } 49 | 50 | api_key = Sys.getenv("CONNECT_API_KEY", NA_character_) 51 | # NOTE: Do not output the API KEY value!!! 52 | 53 | if (is.na(api_key) || api_key == "") { 54 | "!DEBUG Problem: CONNECT_API_KEY not defined within environment variables" 55 | 56 | message_contents <- paste0( 57 | message_contents, 58 | paste0("### The environment variable *CONNECT_API_KEY* is not defined.", 59 | "\n", 60 | "\nPossible Solutions:", 61 | "\n", 62 | "\n- Have your administrator enable the *Applications.DefaultAPIKeyEnv* setting within the *rstudio-connect.gcfg* file on the RStudio Connect server.", 63 | "\n- Create an *API KEY* yourself and use the application settings for your content within the RStudio Connect dashboard to define the *CONNECT_API_KEY* variable with the *API KEY* value." 64 | ), 65 | sep = "\n\n---\n\n" 66 | ) 67 | message_count <- message_count + 1 68 | } 69 | 70 | if (message_count > 0) { 71 | return (message_contents) 72 | } 73 | 74 | use_http = Sys.getenv("PLUMBERTABLEAU_USE_HTTP", "FALSE") 75 | "!DEBUG Environment Variable PLUMBERTABLEAU_USE_HTTP = `use_http`" 76 | 77 | # No checks, will just need it for the request. 78 | if (use_http == "TRUE") { 79 | server <- sub("https://", "http://", server) 80 | } 81 | "!DEBUG After possible https downgrade, server URL is now `server`" 82 | 83 | # Get Server Settings endpoint 84 | # Confirm that the server address ends in a / 85 | if (!is.na(server)) { 86 | last_char <- substr(server, nchar(server), nchar(server)) 87 | if (!is.na(last_char) && last_char != "/") { 88 | server <- paste0(server, "/") 89 | } 90 | } 91 | 92 | url <- paste0(server, "__api__/server_settings") 93 | server_settings <- NULL 94 | result <- tryCatch ( 95 | { 96 | "!DEBUG Sending GET request to `url`" 97 | response <- httr::GET( 98 | url, 99 | httr::add_headers(Authorization = paste0("Key ", api_key)), 100 | httr::write_memory() 101 | ) 102 | list(success=TRUE, response=response) 103 | }, 104 | error = function(err) { 105 | "!DEBUG GET response threw an exception: `err`" 106 | 107 | return (list( 108 | success=FALSE, 109 | message=paste0("### API request to ", server, " has failed with error:", 110 | "\n", err, 111 | "\n", 112 | "\nPossible Solutions:", 113 | "\n", 114 | "\n- If you have specified an API_KEY, confirm it is valid.", 115 | "\n- Confirm there is connectivity between the server itself and the address assigned to it: ", server, ".", 116 | "\n- If using HTTPS along with self-signed certificates, you may need to allow the plumbertableau package to use HTTP instead, ", 117 | "by setting the environment variable *PLUMBERTABLEAU_USE_HTTP* to *TRUE* within the RStudio Connect application settings." 118 | ) 119 | )) 120 | } 121 | ) 122 | if (!result$success) { 123 | "!DEBUG Detected that an error has been returned from exception: `result`" 124 | 125 | message_contents <- paste0( 126 | message_contents, 127 | result$message, 128 | sep = "\n\n---\n\n" 129 | ) 130 | message_count <- message_count + 1 131 | } else { 132 | "!DEBUG GET response has returned `httr::http_status(result$response)$category`, `httr::http_status(result$response)$reason`, `httr::http_status(result$response)$message`" 133 | 134 | if (httr::http_error(result$response)) { 135 | message_contents <- paste0( 136 | message_contents, 137 | paste0("### API request to ", server, " failed. ", 138 | "\n- Response: ", httr::http_status(result$response)$reason, ", ", httr::http_status(result$response)$message, 139 | "\n", 140 | "\nPossible Solution:", 141 | "\n", 142 | "\nDiagnose connectivity or access issue." 143 | ), 144 | sep = "\n\n---\n\n" 145 | ) 146 | message_count <- message_count + 1 147 | 148 | } else { 149 | "!DEBUG GET response was successful." 150 | server_settings <- httr::content(result$response, as="parsed") 151 | } 152 | } 153 | 154 | if (message_count > 0) { 155 | return (message_contents) 156 | } 157 | 158 | # Does this installation support Tableau Extensions? 159 | "!DEBUG server_settings$tableau_integration_enabled = `server_settings$tableau_integration_enabled`" 160 | if (is.null(server_settings$tableau_integration_enabled)) { 161 | "!DEBUG Tableau.IntegrationEnabled is not present within server settings. This Connect server does not support the feature (`server_settings$version`)" 162 | 163 | message_contents <- paste0( 164 | message_contents, 165 | paste0("### Tableau Integration Feature Flag is not available on the RStudio Connect server. Current server is version: ", server_settings$version, 166 | "\n", 167 | "\nPossible Solution:", 168 | "\n", 169 | "\nPlease upgrade to the latest version of RStudio Connect" 170 | ), 171 | sep = "\n\n---\n\n" 172 | ) 173 | 174 | } else if (!server_settings$tableau_integration_enabled) { 175 | 176 | message_contents <- paste0( 177 | message_contents, 178 | paste0("### Tableau Integration has been disabled on the RStudio Connect server", 179 | "\n", 180 | "\nPossible Solution:", 181 | "\n", 182 | "\nPlease ask your administrator to set *Tableau.TableauIntegrationEnabled* = *true* within *rstudio-connect.gcfg* file on the RStudio Connect server." 183 | ), 184 | sep = "\n\n---\n\n" 185 | ) 186 | } 187 | message_contents 188 | } 189 | 190 | # Gathers information about the extensions's execution environment and returns 191 | # a message with information on factors that modify the extensions's 192 | # behavior 193 | info_message <- function() { 194 | message_contents <- NULL 195 | 196 | if (stringi::stri_detect(Sys.getenv("DEBUGME"), fixed = "plumbertableau")) { 197 | message_contents <- paste0( 198 | message_contents, 199 | "Verbose logging is on. To disable it please remove the `DEBUGME` environment variable or set it to a value that does not include `plumbertableau`.", 200 | sep = "\n\n" 201 | ) 202 | } else { 203 | message_contents <- paste0( 204 | message_contents, 205 | "Verbose logging is off. To enable it please set the environment variable `DEBUGME` to include `plumbertableau`.", 206 | sep = "\n\n" 207 | ) 208 | } 209 | 210 | message_contents 211 | } 212 | -------------------------------------------------------------------------------- /vignettes/r-developer-guide.Rmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Writing plumbertableau Extensions in R" 3 | output: rmarkdown::html_vignette 4 | vignette: > 5 | %\VignetteIndexEntry{Writing plumbertableau Extensions in R} 6 | %\VignetteEngine{knitr::rmarkdown} 7 | %\VignetteEncoding{UTF-8} 8 | --- 9 | 10 | ```{r, include = FALSE} 11 | knitr::opts_chunk$set( 12 | collapse = TRUE, 13 | comment = "#>" 14 | ) 15 | 16 | # Load package 17 | library(plumbertableau) 18 | 19 | # Set random seed 20 | set.seed(35487) 21 | 22 | # R chunks 23 | knitr::read_chunk(path = "../inst/plumber/loess/plumber.R", 24 | labels = "loess") 25 | ``` 26 | 27 | ## Getting Started {#getting-started} 28 | 29 | This vignette will walk you through writing plumbertableau extensions in R. plumbertableau extensions are Plumber APIs with a few additional pieces, and this vignette assumes some familiarity with Plumber (https://www.rplumber.io/). If you're unfamiliar with Plumber, the [Quickstart guide](https://www.rplumber.io/articles/quickstart.html) gives a good overview of that package. Throughout this guide, we'll use the terms "web service" and "API" (application programming interface) interchangeably. 30 | 31 | You can install the plumbertableau package from CRAN or GitHub. 32 | 33 | ```{r, eval = FALSE} 34 | # from CRAN 35 | install.packages("plumbertableau") 36 | 37 | # from GitHub 38 | remotes::install_github("rstudio/plumbertableau") 39 | ``` 40 | 41 | Once the package is installed, you can start a new RStudio Project with a 42 | template file from the New Project menu inside RStudio: 43 | 44 | ![](files/rstudio-project.png){width=75%} 45 | 46 | The package also comes with several other example extensions: 47 | ```{r} 48 | plumber::available_apis(package = "plumbertableau") 49 | ``` 50 | 51 | These examples can be run and optionally viewed using `plumber::plumb_api()`. 52 | 53 | ## What can plumbertableau Extensions Do? {#capabilities} 54 | 55 | plumbertableau extensions respond to requests from Tableau. In a request, Tableau will provide an [*endpoint*](https://www.rplumber.io/articles/routing-and-input.html?q=endpoint#endpoints-1) — which roughly corresponds to an R function — and will send along one or more *arguments*, which will generally consist of data from Tableau. 56 | 57 | An extension *must* return data that's the same number of observations as the arguments it receives. It can do things like transform text, or use a model to predict Y values from a matrix of X values. A plumbertableau extension can't, say, receive an integer representing the number of elements to sample, and return a vector of that length. 58 | 59 | ## An Example with Loess Regression 60 | 61 | Let's start with some plain R code that uses the `loess()` function to fit a smooth line across some input data and plot the result. 62 | 63 | ```{r fig.width=6.5, fig.height=4.5} 64 | x <- seq(1, 10, length.out = 100) 65 | y <- 1/x^2 + rnorm(length(x), sd = 0.05) 66 | fit <- loess(y ~ x, span = 0.75) 67 | y_fit <- predict(fit, data.frame(x, y)) 68 | 69 | # Plot the data and prediction. 70 | plot(x, y) 71 | lines(x, y_fit) 72 | ``` 73 | 74 | ## The Anatomy of a plumbertableau Extension {#anatomy} 75 | 76 | Here's that same model in a finished plumbertableau extension. 77 | 78 | ```{r loess, eval = FALSE} 79 | ``` 80 | 81 | There are a few parts to our extension which are required to make it work: 82 | 83 | - **Annotations**. Comments beginning with `#*` are [annotations](https://www.rplumber.io/articles/annotations.html), which define aspects of the API for Plumber and plumbertableau. 84 | - **Function definitions** — Each function definition must use annotations to describe the data types for all inputs and the value it returns. 85 | - **The Tableau extension footer** — This contains the `tableau_extension` object with the `@plumber` annotation. This does the work of modifying the Plumber router to be Tableau-compatible and is a required component of all plumbertableau extensions. 86 | 87 | The `@apiTitle` and `@apiDescription` annotations are used to describe the extension in documentation which is generated. 88 | 89 | ### Annotating Function Definitions for plumbertableau 90 | 91 | Let's look again at our un-annotated function definition. 92 | 93 | ```{r, eval = FALSE} 94 | #* Annotate me! 95 | function(x, y, alpha = 0.75) { 96 | alpha <- as.numeric(alpha) 97 | l_out <- loess(y ~ x, span = alpha) 98 | predict(l_out, data.frame(x, y)) 99 | } 100 | ``` 101 | 102 | It accepts three inputs: `x`, `y`, and `alpha`, fits a `loess()` model using this data, and returns the output of the `predict()` function for that model. We need to annotate the function inputs and output so that plumbertableau can translate the data correctly for Tableau and generate proper documentation for the extension. 103 | 104 | **Data provided by Tableau** is described using the `@tableauArg` annotation. These annotations require a name and a type, and can optionally contain a description: `#* @tableauArg Name:Type Description`. In our example: 105 | 106 | ```{r, eval=FALSE} 107 | #* @tableauArg x:integer X values for fitting 108 | #* @tableauArg y:numeric Y values for fitting 109 | ``` 110 | 111 | Here, `x:integer` specifies the `x` argument by name, and tells plumbertableau to expect an integer vector. The string `X values for fitting` will be used to describe this argument in the generated API documentation. If an argument is optional, this can be noted using `?` after identifying the data type. 112 | 113 | ```{r , eval=FALSE} 114 | #* @tableauArg y:numeric? Y values for fitting 115 | 116 | **Data returned to Tableau** is described with `@tableauReturn`. The syntax is similar to `@tableauArg`, without an argument name: `#* @tableauReturn Type Description`. 117 | 118 | ```{r, eval=FALSE} 119 | #* @tableauReturn numeric Fitted loess values 120 | ``` 121 | 122 | plumbertableau will expect `numeric` data to be returned, and will describe it as `Fitted loess values` in our documentation. 123 | 124 | **URL parameters** are arguments described with Plumber's [`@param`](https://www.rplumber.io/articles/annotations.html#endpoint) annotation. These are useful for providing fixed values that don't use data from Tableau objects. In our example the optional `alpha` parameter lets Tableau users control the degree of smoothing used in the `loess` model. 125 | 126 | ```{r, eval=FALSE} 127 | #* @param alpha Degree of smoothing 128 | ``` 129 | 130 | Tableau users can now add `?alpha=0.5` to the end of the extension URL to control the degree of smoothing. 131 | 132 | **The path** to the endpoint where this function is accessed is annotated with `@post`. Tableau only supports requests using the POST method, so all our Tableau-accessible endpoints must be annotated with `@post`. 133 | 134 | ### Data Types 135 | 136 | plumbertableau understands the following R and Tableau data types. 137 | 138 | R type | Tableau type 139 | ------ | ------------ 140 | `character` | `string`, `str` 141 | `logical` | `boolean`, `bool` 142 | `numeric` | `real` 143 | `integer` | `integer`, `int` 144 | 145 | In addition to these described data types, you can use the type `any` to accept any type. 146 | ### Tableau Extension Footer 147 | 148 | ```{r, eval = FALSE} 149 | #* @plumber 150 | tableau_extension 151 | ``` 152 | 153 | In order for a plumbertableau extension to properly respond to Tableau requests, we must put this block at the bottom of our extension. The `@plumber` annotation tells Plumber to use the `tableau_extension` object to modify the router object it uses to handle requests. 154 | 155 | Behind the scenes, this object is a [Plumber Router Modifier](https://www.rplumber.io/articles/annotations.html#plumber-router-modifier), and is compatible with [programmatic usage](https://www.rplumber.io/articles/programmatic-usage.html) in Plumber. 156 | 157 | ## Running and Testing Extensions Locally {#testing} 158 | 159 | plumbertableau uses Plumber's ability to generate [Swagger](https://github.com/rstudio/swagger) documentation for APIs. 160 | 161 | Once you've written an API, it can be tested locally through the Swagger UI. To start the API, click the Run API button in RStudio, or run `plumber::plumb("path/to/plumber.R")$run()` in the R console. Once the API is running, you'll be presented with an interface like the following: 162 | 163 | ![](files/swagger.png){width=75%} 164 | 165 | Click on the `/predict` endpoint to expand it. You can then click "Try it out" to modify the example request, and "Execute" to send it. 166 | 167 | ![](files/swagger-predict.png){width=75%} 168 | 169 | Requests from Tableau consist of a JSON object with two items^[[Tableau Analytics Extensions API Reference](https://tableau.github.io/analytics-extensions-api/docs/ae_api_ref.html#post-evaluate)]. We can see these in the simple mock request that plumbertableau provides following Tableau's specification. 170 | 171 | ```{json} 172 | { 173 | "script": "/predict", 174 | "data": { 175 | "_arg1": [ 176 | 0 177 | ], 178 | "_arg2": [ 179 | 0 180 | ] 181 | } 182 | } 183 | ``` 184 | 185 | - `script`: A string identifying an endpoint. Behind the scenes, Tableau sends all extension requests to the `/evaluate` endpoint, and plumbertableau uses this string value to correctly route the request to a the requested endpoint. 186 | - `data`: An object containing arguments for the function at the endpoint. plumbertableau parses this data and passes it to the R code. Tableau does not allow explicit naming of arguments, instead naming them `_arg1`, `_arg2`, ... ,`_argN`. Arguments are passed to R in the order in which they appear. In this case, `_arg1` will be passed a `x` and `_arg2` will be passed a `y` to the underlying R function. 187 | 188 | To generate a more complex example, we can use `mock_tableau_request()` to create a more full-featured request for use with Swagger. Here, we pass in a data frame with two columns, and `mock_tableau_request()` uses those columns for `_arg` and `_arg2`. 189 | 190 | ```{r} 191 | mock_tableau_request(script = "/predict", 192 | data = mtcars[,c("hp", "mpg")]) 193 | ``` 194 | 195 | Note that because the running API occupies your R session, you'll have to stop running it to use `mock_talbeau_request()`. Once you've generated a mock request, you can run the API again and paste the request into the Swagger documentation to test the API. The data that would be sent back to Tableau appears under "Response body". 196 | 197 | It's also worth noting that the `curl` command used by OpenAPI to submit the request doesn't exactly match the request sent by Tableau, since the `curl` request goes to the actual endpoint (in this case `/predict`) and requests from Tableau will go to `/evaluate` and then be routed to `/predict`. The request response will still be the same. 198 | 199 | ### Debugging Extensions {#debugging} 200 | 201 | plumbertableau supports debug logging via the [`debugme`](https://github.com/r-lib/debugme) R package. 202 | 203 | To turn on debug messages in R, set the `DEBUGME` environment variable to a value containing `plumbertableau` in R before the package is loaded. 204 | 205 | ```r 206 | Sys.setenv(DEBUGME = "plumbertableau") 207 | library(plumbertableau) 208 | ``` 209 | 210 | Additional messages will be logged to the R console while the extension is running, with information about the contents and processing of each request the extension receives. 211 | 212 | ## Deploying Extensions 213 | 214 | Now that we've written our extension, we can deploy it to RStudio Connect and use it from Tableau. 215 | 216 | - To learn how to publish and manage extensions on RStudio Connect, and how to configure Tableau to work with RStudio Connect, see the **[RStudio Connect documentation on Tableau integration](https://docs.posit.co/rsc/integration/tableau/)**. 217 | - For more information about using extensions from Tableau, see **[Using plumbertableau Extensions in Tableau](tableau-developer-guide.html)** 218 | -------------------------------------------------------------------------------- /R/tableau_handler.R: -------------------------------------------------------------------------------- 1 | #' Create a Tableau-compliant handler for a function 2 | #' 3 | #' Creates an object that can translate arguments from Tableau to R, and return 4 | #' values from R to Tableau. 5 | #' 6 | #' @param args A named list describing the arguments that are expected from 7 | #' valid Tableau requests. The names in the named list can be any unique 8 | #' variable names. The values in the named list must each be either a string 9 | #' indicating the expected data type for that argument (`"character"`, 10 | #' `"logical"`, `"numeric"`, or `"integer"`); or better yet, a specification 11 | #' object created by [arg_spec()]. If an argument should be considered 12 | #' optional, then its data type should be followed by `?`, like `"numeric?"`. 13 | #' @param return A string indicating the data type that will be returned from 14 | #' `func` (`"character"`, `"logical"`, `"numeric"`, or `"integer"`); or, a 15 | #' specification object created by [return_spec()]. 16 | #' @param func A function to be used as the handler function. Code in the body 17 | #' of the function will automatically be able to access Tableau request args 18 | #' simply by referring to their names in `args`; see the example below. 19 | #' 20 | #' @return A `tableau_handler` object that is a validated version of the 21 | #' provided `func` with additional attributes describing the expected arguments 22 | #' and return values 23 | #' 24 | #' @export 25 | tableau_handler <- function(args, return, func) { 26 | args <- lapply(args, normalize_argspec) 27 | validate_argspecs(args) 28 | return <- normalize_returnspec(return) 29 | 30 | fargs <- getRelevantArgs(args, func) 31 | unused_args <- setdiff(names(args), names(fargs)) 32 | if (length(unused_args) > 0) { 33 | warning(call. = FALSE, immediate. = TRUE, 34 | "The following Tableau arg(s) were declared, but not included as ", 35 | "function parameters: ", 36 | paste(collapse = ", ", paste0("'", unused_args, "'")) 37 | ) 38 | } 39 | 40 | result <- function(req, res, ...) { 41 | vars <- validate_request(req, args = args, return = return) 42 | fargs <- rlang::list2(req = req, res = res, !!!vars, ...) 43 | fargs <- getRelevantArgs(fargs, func) 44 | do.call(func, fargs) 45 | } 46 | 47 | attr(result, "tableau_arg_specs") <- args 48 | attr(result, "tableau_return_spec") <- return 49 | class(result) <- c("tableau_handler", class(result)) 50 | result 51 | } 52 | 53 | #' Describe expected args and return values 54 | #' 55 | #' `arg_spec()` and `return_spec()` are used to create arguments for 56 | #' [tableau_handler()]. They describe the data type of the arg or return value, 57 | #' and can return a human-readable description that can be used to generate 58 | #' documentation. 59 | #' 60 | #' @param type A string indicating the data type that is required for this 61 | #' argument. 62 | #' @param desc A human-readable description of the argument. Used to generate 63 | #' documentation. 64 | #' @param optional If `TRUE`, then this argument need not be present in a 65 | #' request. Defaults to `TRUE` if `type` ends with a `"?"` character. 66 | #' 67 | #' @return A `tableau_arg_spec` object, which is a list containing details about 68 | #' the Tableau argument expectations 69 | #' 70 | #' 71 | #' @export 72 | arg_spec <- function(type = c("character", "integer", "logical", "numeric"), 73 | desc = "", optional = grepl("\\?$", type)) { 74 | 75 | # We're about to modify `type`, so eval `optional` first 76 | force(optional) 77 | 78 | type <- sub("\\?$", "", type) 79 | 80 | structure(list( 81 | type = normalize_type_to_r(type), 82 | desc = desc, 83 | optional = optional 84 | ), class = c("tableau_arg_spec", "list")) 85 | } 86 | 87 | param_spec <- function(type = c("character", "integer", "logical", "numeric"), 88 | desc = "", optional = grepl("\\?$", type), default = NULL) { 89 | 90 | # We're about to modify `type`, so eval `optional` first 91 | force(optional) 92 | 93 | type <- sub("\\?$", "", type) 94 | 95 | structure(list( 96 | type = normalize_type_to_r(type), 97 | desc = desc, 98 | optional = optional, 99 | default = default 100 | ), class = c("tableau_param_spec", "list")) 101 | } 102 | 103 | #' @rdname arg_spec 104 | #' 105 | #' @return A `tableau_return_spec` object, which is a list containing details 106 | #' about the values expected to be returned to Tableau 107 | #' 108 | #' @export 109 | return_spec <- function(type = c("character", "integer", "logical", "numeric"), 110 | desc = "") { 111 | 112 | type <- match.arg(type) 113 | 114 | structure(list( 115 | type = normalize_type_to_r(type), 116 | desc = desc 117 | ), class = c("tableau_return_spec", "list")) 118 | } 119 | 120 | # Given a route that may have @tab.* comments, create a tableau_handler object. 121 | infer_tableau_handler <- function(route) { 122 | func <- route$getFunc() 123 | if (inherits(func, "tableau_handler")) { 124 | # Already a handler 125 | return(func) 126 | } 127 | 128 | srcref <- attr(func, "srcref", exact = TRUE) 129 | if (is.null(srcref)) { 130 | stop( 131 | call. = FALSE, 132 | "plumbertableau encountered a plumber endpoint with no srcref; try ", 133 | "making sure all endpoint functions are defined directly in the plumber ", 134 | "file" 135 | ) 136 | } 137 | comment_lines_df <- get_comments_from_srcref(srcref) 138 | parsed_comments <- parse_comment_tags(comment_lines_df) 139 | 140 | 141 | # Check to see if Tableau args and return values have been provided 142 | err <- "Tableau argument and return data types must be specified. Please use either #* tableauArg and #* tableauReturn annotations or tableau_handler() to specify Tableau argument and return types." 143 | 144 | if (rlang::is_empty(parsed_comments)) { 145 | stop(err, call. = FALSE) 146 | } else if (!("tableauArg" %in% parsed_comments$tag) | !("tableauReturn" %in% parsed_comments$tag)) { 147 | stop(err, call. = FALSE) 148 | } 149 | 150 | args <- parsed_comments[parsed_comments$tag == c("tableauArg"), c("line", "remainder")] 151 | returns <- parsed_comments[parsed_comments$tag == c("tableauReturn"), c("line", "remainder")] 152 | 153 | args <- parse_args_comment_df(args) 154 | return <- parse_return_comment_df(returns) 155 | 156 | tableau_handler( 157 | args = args, 158 | return = return, 159 | func = func 160 | ) 161 | } 162 | 163 | get_comments_from_srcref <- function(srcref) { 164 | func_start_line <- srcref[[7]] 165 | srcfile <- attr(srcref, "srcfile", exact = TRUE) 166 | lineNum <- func_start_line - 1 167 | file <- getSrcLines(srcfile, 1, lineNum) 168 | 169 | while (lineNum > 0 && (grepl("^#['\\*]", file[lineNum]) || grepl("^\\s*$", file[lineNum]))) { 170 | lineNum <- lineNum - 1 171 | } 172 | line <- seq(from = lineNum + 1, length.out = func_start_line - (lineNum + 1)) 173 | data.frame(line, text = file[line], stringsAsFactors = FALSE) 174 | } 175 | 176 | parse_comment_tags <- function(lines_df) { 177 | lines <- lines_df$text 178 | m <- regexec("^#['\\*]\\s+@([^\\s]+)\\s*(.*)", lines, perl = TRUE) 179 | matches <- regmatches(lines, m) 180 | 181 | tag <- sapply(matches, `[`, i = 2) 182 | remainder <- sapply(matches, `[`, i = 3) 183 | df <- data.frame(line = lines_df$line, tag, remainder, stringsAsFactors = FALSE) 184 | df[!is.na(df$tag),] 185 | } 186 | 187 | # @param comment_df Data frame with `line` and `remainder` columns 188 | parse_args_comment_df <- function(comment_df) { 189 | m <- regexec("^\\s*([a-zA-Z0-9._-]+):([^\\s?]+)(\\?)?\\s+(.*)$", comment_df$remainder, perl = TRUE) 190 | matches <- regmatches(comment_df$remainder, m) 191 | 192 | name <- sapply(matches, `[`, i = 2) 193 | type <- sapply(matches, `[`, i = 3) 194 | type <- sub("^\\[(.+)]$", "\\1", type) 195 | opt <- sapply(matches, `[`, i = 4) 196 | desc <- sapply(matches, `[`, i = 5) 197 | 198 | bad_lines <- comment_df$line[which(is.na(name))] 199 | if (length(bad_lines) > 0) { 200 | stop("Invalid @tableauArgu on line(s) ", paste(bad_lines, collapse = ", ")) 201 | } 202 | 203 | arg_specs <- mapply(name, type, opt, desc, FUN = function(name, type, opt, desc) { 204 | arg_spec(type = type, desc = desc, optional = ifelse(is.na(opt), FALSE, opt == "?")) 205 | }, SIMPLIFY = FALSE, USE.NAMES = TRUE) 206 | 207 | # Make sure @tableauArg names are unique 208 | duplicate_names <- unique(name[which(duplicated(name))]) 209 | if (length(duplicate_names) > 0) { 210 | stop(call. = FALSE, 211 | "Duplicate @tableauArg name(s) detected: ", 212 | paste0(paste0("'", duplicate_names, "'"), collapse = ", "), 213 | ". Names must be unique." 214 | ) 215 | } 216 | 217 | arg_specs 218 | } 219 | 220 | parse_return_comment_df <- function(comment_df) { 221 | m <- regexec("^\\s*([^\\s?]+)\\s+(.*)$", comment_df$remainder, perl = TRUE) 222 | matches <- regmatches(comment_df$remainder, m) 223 | 224 | type <- sapply(matches, `[`, i = 2) 225 | type <- sub("^\\[(.+)]$", "\\1", type) 226 | desc <- sapply(matches, `[`, i = 3) 227 | 228 | return_spec(type = type, desc = desc) 229 | } 230 | 231 | # Copied from Plumber source code 232 | getRelevantArgs <- function(args, func) { 233 | # Extract the names of the arguments this function supports. 234 | fargs <- names(formals(func)) 235 | 236 | if (length(fargs) == 0) { 237 | # no matches 238 | return(list()) 239 | } 240 | 241 | # fast return 242 | # also works with unnamed arguments 243 | if (identical(fargs, "...")) { 244 | return(args) 245 | } 246 | 247 | # If only req and res are found in function definition... 248 | # Only call using the first matches of req and res. 249 | if (all(fargs %in% c("req", "res"))) { 250 | ret <- list() 251 | # using `$` will retrieve the 1st occurance of req,res 252 | # args$req <- req is used within `Plumber$route()` 253 | if ("req" %in% fargs) { 254 | ret$req <- args$req 255 | } 256 | if ("res" %in% fargs) { 257 | ret$res <- args$res 258 | } 259 | return(ret) 260 | } 261 | 262 | # The remaining code MUST work with unnamed arguments 263 | # If there is no `...`, then the unnamed args will not be in `fargs` and will be removed 264 | # If there is `...`, then the unnamed args will not be in `fargs` and will be passed through 265 | 266 | if (!("..." %in% fargs)) { 267 | # Use the named arguments that match, drop the rest. 268 | args <- args[names(args) %in% fargs] 269 | } 270 | 271 | # dedupe matched formals 272 | arg_names <- names(args) 273 | is_farg <- arg_names %in% fargs 274 | # keep only the first matched formal argument (and all other non-`farg` params) 275 | args <- args[(is_farg & !duplicated(arg_names)) | (!is_farg)] 276 | 277 | args 278 | } 279 | 280 | normalize_type_to_r <- function(type = c("character", "string", "str", 281 | "logical", "boolean", "bool", 282 | "numeric", "real", 283 | "integer", "int")) { 284 | switch(type, 285 | "character" =, "string" =, "str" = "character", 286 | "logical" =, "boolean" =, "bool" = "logical", 287 | "numeric" =, "real" = "numeric", 288 | "integer" =, "int" = "integer", 289 | "any" = "any", 290 | stop("Unknown type ", type) 291 | ) 292 | } 293 | 294 | normalize_type_to_tableau <- function(type = c("character", "string", "str", 295 | "logical", "boolean", "bool", 296 | "numeric", "real", 297 | "integer", "int"), abbrev = FALSE) { 298 | 299 | short <- switch(type, 300 | "character" =, "string" =, "str" = "str", 301 | "logical" =, "boolean" =, "bool" = "bool", 302 | "numeric" =, "real" = "real", 303 | "integer" =, "int" = "int", 304 | "any" = "any", 305 | stop("Unknown type ", type) 306 | ) 307 | 308 | if (!abbrev) { 309 | c(str = "string", bool = "boolean", real = "real", int = "integer", any = "any")[short] 310 | } else { 311 | short 312 | } 313 | } 314 | 315 | normalize_argspec <- function(arg) { 316 | if (is.character(arg)) { 317 | arg_spec(arg) 318 | } else { 319 | if (!inherits(arg, "tableau_arg_spec")) { 320 | stop("Invalid argument specification; arg_spec() object or character expected") 321 | } 322 | arg 323 | } 324 | } 325 | 326 | normalize_returnspec <- function(return_obj) { 327 | if (is.character(return_obj)) { 328 | return_spec(return_obj) 329 | } else { 330 | if (!inherits(return_obj, "tableau_return_spec")) { 331 | stop("Invalid return value specification; return_spec() object or character expected") 332 | } 333 | return_obj 334 | } 335 | } 336 | 337 | validate_argspecs <- function(args) { 338 | if (length(args) == 0) { 339 | return(invisible()) 340 | } 341 | 342 | # Enforce all optional parameters coming after required parameters 343 | optionals <- vapply(args, function(x) x[["optional"]], logical(1)) 344 | opt_idx <- which(optionals) 345 | req_idx <- which(!optionals) 346 | if (length(opt_idx) > 0 && length(req_idx) > 0) { 347 | bad_req_idx <- req_idx[req_idx > min(opt_idx)] 348 | if (length(bad_req_idx) > 0) { 349 | stop("Required arg '", names(args)[[bad_req_idx[1]]], "' comes ", 350 | "after optional arg '", names(args)[[min(opt_idx)]], "'. ", 351 | "Required args must always come before any optional args.") 352 | } 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /man/figures/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | --------------------------------------------------------------------------------