-
14 |
- 15 | 16 | 27 | 30 | 31 | 32 |
- 33 | 34 | 45 | 48 | 49 | 50 |
- 51 | 52 | 63 | 66 | 67 | 68 |
- 69 | 70 | 81 | 84 | 85 | 86 |
├── .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, "^
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 | {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 | {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 | [](https://github.com/rstudio/plumbertableau/actions)
26 | [](https://app.codecov.io/gh/rstudio/plumbertableau?branch=main)
27 | [](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 | {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 | [](https://github.com/rstudio/plumbertableau/actions)
9 | [](https://app.codecov.io/gh/rstudio/plumbertableau?branch=main)
11 | [](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 |