Endpoints with Shiny

John Coene

john@opifex.org

I’m a software engineer, founder of Opifex

john-coene.com/talks/shinyconf

Let’s start

Shiny

How shiny works?

The (very) brief version.

The client makes an initial GET request to the server() which responds with the initial ui.

It then establishes a webSocket connection which is used for (almost) all subsequent communication between the client and the server().


Shiny = Single Page Application = 1 request

Endpoints?

What do we mean by “endpoints”?

Just like plumber (with caveats) where we open endpoints

#* Echo back the input
#* @param msg The message to echo
#* @get /endpoint
function(msg="") {
  list(msg = paste0("The message is: '", msg, "'"))
}


It’s an /endpoint we can make a request to.

We can do the same within Shiny!

In the wild

You will find real-world examples of the use of shiny endpoints on Github.

Endpoints

How?

registerDataObj

We create an endpoint with a method on the myterious session object.

endpoint <- session$registerDataObj(
  name, 
  data, 
  filterFunc
)


It’s not a memorable method name and the arguments may be confusing.

Let’s unpack this.

Arguments

What do they do?

endpoint <- session$registerDataObj(
  name, 
  data, 
  filterFunc
)
  1. filterFunc A function that accepts a request and the data and returns a response.
  2. data - An object, passed to filterFunc.
  3. name the path, e.g.: name = "dataset" => /datasets~ish

Return value

It returns the full path.

This is because it is dynamically created for that very session.

session$registerDataObj("data", ...) does not create a /data path but rather /1638f...872/data where the hash is referring to the session.

This can feel awkward but makes sense within Shiny.

filterFunc (1) - Request

The request

Accepts data and request.

filter_fn <- \(data, req) {
  # must return a response
}
  • req is an environment.
  • Metadata on the request, e.g.:
    • Headers
    • Query
    • etc.

filterFunc (2) - Response

The response

filter_fn <- \(data, req) {
  shiny::httpResponse(
    status = 200L, 
    content_type = "text/html; charset=UTF-8", 
    content = "", 
    headers = list()
  )
}
  • status - Status code of request (e.g.: 404)
  • content_type - Type of content
  • content - Response body
  • headers - Additional headers

filterFunc (3) - Simple Examples

HTML

filter_fn <- \(data, req) {
  shiny::httpResponse(
    status = 200L,
    content_type = "text/html",
    content = "<h1>Hello, endpoints!</h1>"
  )
}

JSON

filter_fn <- \(data, req) {
  shiny::httpResponse(
    status = 200L,
    content_type = "application/json",
    content = jsonlite::toJSON(data)
  )
}

filterFunc (4) - Example

The following more substantial example uses the data and the request.

filter_fn <- \(data, req) {
  # GET "/1638f...872/data?col=mpg"
  query <- shiny::parseQueryString(req$QUERY_STRING)

  json <- jsonlite::toJSON(data[[query$col]])

  shiny::httpResponse(
    status = 200L,
    content_type = "application/json",
    content = json
  )
}

GET on /1638f...872/data?col=mpg.

Client-side

Easy to use

From the client we can now call the API, it’s as easy as.

fetch('/1638f...872/data?col=mpg')
  .then(response => response.json())
  .then(data => {
    // do something with the data
  });


Could be translated to R with:

httr::GET('/1638f...872/data?col=mpg') |> 
  httr::content() |> 
  do_sth_with_data()

Why use endpoints?

  1. Error handling
  2. Code readability
  3. Different model = different solution

Error handling

Easier to handle errors.

fetch('/1638f...872/data?col=mpg')
  .then(response => {
    console.log(response.status);

    if(!response.ok)
      throw new Error(response.status);

    response.json();
  })
  .then(data => {
    // so something with the data
  })
  .catch(error => {
    // handle error
  });

Code Readability (1)

Using the WebSocket

$('.btn').on('click', (e) => {
  Shiny.setInputValue('myInput', data);
});

So something with the data and send a response.

observeEvent(input$myInput, {
  session$sendCustomMessage("myResponse", data)
})

Handle the response client-side

Shiny.addCustomMessageHandler('myResponse', (msg) => {
  // do something with the response
});

Code Readability (2)

Using endpoints

Client-side

$('.btn').on('click', (e) => {
  fetch('/1638f...872/data?col=mpg')
    .then(response => response.json())
    .then(data => {
      // do something with the data
    });
});

Server-side

filter_fn <- \(data, req) {
  query <- parseQueryString(
    req$QUERY_STRING
  )

  json <- jsonlite::toJSON(
    data[[query$col]]
  )

  shiny::httpResponse(
    # ...
  )
}

Code Readability (3)

  • Fails loudly
    • Data structure
    • ID management
  • Less back-and-forth

Clearer code, easier to debug, and maintain.

Performances

Microscopic performance differences between request-response and WebSocket.

The request-response model forces the developer to think differently about a problem.


WebSocket ~ show/hide

vs.

Request-response ~ render when needed

Limitation

Used (in Shiny) to retrieving data from the server, not to update the state of the server.

We’re making GET requests.

We don’t have access to the session.

Thank you