--- title: "Rendering Output" knitr: opts_chunk: collapse: false comment: "#>" vignette: > %\VignetteIndexEntry{Rendering Output} %\VignetteEngine{quarto::html} %\VignetteEncoding{UTF-8} --- ```{r} #| include: false me <- normalizePath( if (Sys.getenv("QUARTO_DOCUMENT_PATH") != "") { Sys.getenv("QUARTO_DOCUMENT_PATH") } else if (file.exists("_helpers.R")) { getwd() } else if (file.exists("vignettes/_helpers.R")) { "vignettes" } else if (file.exists("articles/_helpers.R")) { "articles" } else { "vignettes/articles" }) source(file.path(me, "_helpers.R")) readLines <- function(x) base::readLines(file.path(me, x)) ``` ![](files/images/plumber_output.png){width="45%" style="float:right;"} ## The Response Object {#response-object} The plumber2 response object is, like [the request object](./routing-and-input.html#the-request-object-1), provided by reqres and implemented as an R6 class object. Please consult the [reqres documentation](https://reqres.data-imaginist.com/reference/Response.html) for an in-depth overview of the class and what it can do. The response object is accessible from within a handler if it provides a `response` argument, and since it is based on R6, any change happening to it in the handler will persist. It is not necessary for a handler to interact directly with a response. Returning any "classic" value from a handler function will set the response body to that value, often providing everything needed for that particular handler. The return values that differ from this are: - Returning `NULL` or `Next`: Returning either of these will not modify the response body, but allow the request to be handled by the next route in the stack - Returning `Break`: This will not modify the response and will short-circuit any further handling, returning the response as-is - Returning the `response`: You may return the response from the handler and will not alter it's body further. Like returning `NULL` and `Next` it will allow handling to continue to the next route - Using a graphics serializer: If a graphics serializer is in use the body will be set to the graphic captured by the serializer and any return value from the function is ignored - Returning a `ggplot` object: If a `ggplot` object is returned it will be plotted so that the graphics serializer may capture its output If you wish to make use of some of the more powerful features from reqres you will likely want to interact with the response object directly. Some of these features include, accessing and setting session cookie data, setting headers, or taking full control over content negotiation and serializing. ## Serializers In order to send a response from R to an API client, the object must be "serialized" into some format that the client can understand. JavaScript Object Notation (JSON) is one standard which is commonly used by web APIs. JSON serialization translates R objects like `list(a=123, b="hi!")` to JSON text resembling `{a: 123, b: "hi!"}`. JSON is not appropriate for every situation, however. If you want your API to render an HTML page that might be viewed in a browser, for instance, you will need a different serializer. Likewise, if you want to return an image rendered in R, you likely want to use a standard image format like PNG or JPEG rather than JSON. It is not required to decide on a single serializer up front. A server can provide different representations and a client can prefer specific representations. If a server can provide the result in different ways it will perform something called server-driven content negotiation, where it inspects the request to get the client preferences and then chooses the best serializer based on that. In order to use multiple serializers you provide multiple `@serializer` tags, their order determining the server priority. There are two special values: `none` and `...`. The former instructs plumber2 to not do any serialization at all, leaving it up to the handler to prepare the response body and set the Content-Type header. The latter will insert all non-selected serializers into the list at that position. You could e.g. do this: ``` r #* @serializer yaml #* @serializer ... ``` To make YAML the preferred response format but still have the remaining serializers to fall back on if the client do not understand yaml. By default, plumber2 uses all the registered serializers and performs content negotiation based on those so if you want to ensure a single output type you have to set this explicitly. Another reason to set them explicitly would be to modify their behavior by providing arguments to them: ``` r #* @serializer json{na="string"} ``` ### Standard serializers | Annotation | Content Type | Description/References | |-------------------|--------------------|---------------------------------| | `@serializer json` | `application/json` | Object processed with `jsonlite::toJSON()` | | `@serializer unboxedJSON` | `application/json` | Object processed with `jsonlite::toJSON(auto_unbox=TRUE)` | | `@serializer html` | `text/html; charset=UTF-8` | Character strings passed through directly, Objects of class `shiny.tag` (from htmltools) converted with `as.character()`. Other objects converted to html using `xml2::as_xml_document()` | | `@serializer rds` | `application/rds` | Object processed with `base::serialize()` | | `@serializer csv` | `text/csv` | Object processed with `readr::format_csv()` | | `@serializer tsv` | `text/tab-separated-values` | Object processed with `readr::format_tsv()` | | `@serializer feather` | `application/vnd.apache.arrow.file` | Object processed with `arrow::write_feather()` | | `@serializer parquet` | `application/parquet` | Object processed with `nanoparquet::write_parquet()` | | `@serializer yaml` | `text/x-yaml` | Object processed with `yaml::as_yaml()` | | `@serializer xml` | `text/xml` | Objects processed with `xml2::as_xml_document()` | | `@serializer text` | `text/plain` | Text output processed by `as.character()` | | `@serializer format` | `text/plain` | Text output processed by `format()` | | `@serializer print` | `text/plain` | Text output captured from `print()` | | `@serializer cat` | `text/plain` | Text output captured from `cat()` | | `@serializer htmlwidget` | `text/html; charset=utf-8` | `htmlwidgets::saveWidget()` | | `@serializer geojson` | `application/geo+json` | Objects processed with `geojsonsf::sfc_geojson()` or `geojsonsf::sf_geojson()` | ### Boxed vs Unboxed JSON You may have noticed that JSON API responses generated from Plumber render singular values (or "scalars") as arrays. For instance: ```{r} jsonlite::toJSON(list(a=5)) ``` The value of the `a` element, though it's singular, is still rendered as an array. This may surprise you initially, but this is done to keep the output consistent. While JSON differentiates scalar from vector objects, R does not. This creates ambiguity when serializing an R object to JSON since it is unclear whether a particular element should be rendered as an atomic value or a JSON array. Consider the following API which returns all the letters lexicographically "higher" than the given letter. ```{r} #| eval: false #| code: !expr readLines("files/apis/04-03-letters.R") ``` This is an example of an API that, in some instance, produces a scalar, and in other instances produces a vector. Visiting http://localhost:8080/boxed?letter=U or http://localhost:8080/unboxed?letter=U will return identical responses: ```{r} #| echo: false #| results: asis pa <- api(file.path(me, "files/apis/04-03-letters.R")) req <- fiery::fake_request("http://localhost:8080/boxed?letter=U") res <- pa$test_request(req) code_chunk(res$body, "json") ``` However, http://localhost:8080/boxed?letter=Y will produce: ```{r} #| echo: false #| results: asis req <- fiery::fake_request("http://localhost:8080/boxed?letter=Y") res <- pa$test_request(req) code_chunk(res$body, "json") ``` while http://localhost:8080/unboxed?letter=Y will produce: ```{r} #| echo: false #| results: asis req <- fiery::fake_request("http://localhost:8080/unboxed?letter=Y") res <- pa$test_request(req) code_chunk(res$body, "json") ``` The `/boxed` endpoint, as the name implies, produces "boxed" JSON output in which length-1 vectors are still rendered as an array. Conversely, the `/unboxed` endpoint sets `auto_unbox=TRUE` in its call to `jsonlite::toJSON`, causing length-1 R vectors to be rendered as JSON scalars. While R doesn't distinguish between scalars and vectors, API clients may respond very differently when encountering a JSON array versus an atomic value. You may find that your API clients will not respond gracefully when an object that they expected to be a vector becomes a scalar in one call. For this reason, Plumber inherits the `jsonlite::toJSON` default of setting `auto_unbox=FALSE` which will result in all length-1 vectors still being rendered as JSON arrays. You can configure an endpoint to use the `unboxedJSON` serializer (as shown above) if you want to alter this behavior for a particular endpoint. There are a couple of functions to be aware of around this feature set. If using boxed JSON serialization, `jsonlite::unbox()` can be used to force a length-1 object in R to be presented in JSON as a scalar. If using unboxed JSON serialization, `I()` will cause a length-1 R object to present as a JSON array. ### Graphics serializers Graphics serializers are special because they need to do some setup and teardown around the handler in order to capture graphics output. Because of this they cannot be mixed with the standard serializers. They are omitted when using `...` unless a graphics serializer has been selected explicitly in which case `...` will refer to the remaining graphics serializers and omit the standard ones. | | | | |-------------------|--------------------|---------------------------------| | `@serializer png` | `image/png` | Images created with `ragg::agg_png()` | | `@serializer jpeg` | `image/jpeg` | Images created with `ragg::agg_jpeg()` | | `@serializer tiff` | `image/tiff` | Images created with `ragg::agg_tiff()` | | `@serializer svg` | `image/svg+xml` | Images created with `svglite::svglite()` | | `@serializer bmp` | `image/bmp` | Images created with `bmp()` | | `@serializer pdf` | `application/pdf` | PDF File created with `pdf()` | As with the standard serializers the behaviour of these can be modified by specifying additional arguments to the serializer. Many of these arguments are well-known from using graphics devices in R including `width`, `height`, and `bg` among others. ```{r} #| eval: false #| code: !expr readLines("files/apis/04-04-image.R") ``` Arguments inside the curly braces are evaluated in the same environment as the handler so any R expression will be valid. However, they are evaluated only once, when parsing the file, so it is not possible to provide dynamic serializer settings in this way. If you wish to dynamically size images, you will need render and capture the graphical output yourself and return the contents with the appropriate `Content-Type` header. See the existing image renderers as a model of how to do this. ### Bypassing Serialization {#bypassing-serialization} In some instances it may be desirable to return a value directly from R without serialization. You can do this by settings `@serializer none` which will turn off any automatic serialization by plumber. Consider the following handler: ```{r} #| eval: false #| code: !expr readLines("files/apis/04-01-response.R") ``` The response that is returned from this endpoint would contain the body `Literal text here!` with no `Content-Type` header and without any additional serialization. In a similar vein you can set a `Content-Type` but otherwise leave the body unchanged by providing a mime type to `@serializer`. You can use this annotation when you want more control over the response that you send. ```{r} #| eval: false #| code: !expr readLines("files/apis/04-02-contenttype.R") ``` Running this API and visiting http://localhost:8080/pdf will download the PDF generated from R (or display the PDF natively, if your client supports it). ### Custom serializers While plumber2 comes with serializers that cover most use cases you may want to provide your own. You can do so in two ways. Either by registering your serializer and refering to it by name as you would with any other native serializer, or by specifying it inline. You may want to serve toml files but plumber doesn't (yet) ship with a serializer for this. You could quickly create your own using `blogdown::write_toml()`. Serializers in plumber2 are factory functions that take a range of arguments and return a unary function capable of formatting the response body: ```{r} #| eval: false format_toml <- function(...) { function(x) { blogdown::write_toml(x) } } ``` You could use this directly in a handler with `@serializer application/toml format_toml()` or you could register it: ```{r} #| eval: false register_serializer("toml", format_toml, "application/toml") ``` and use it like any other serializer: `@serializer toml` ## Error Handling Plumber wraps each endpoint invocation so that it can gracefully capture errors. ```{r} #| eval: false #| code: !expr readLines("files/apis/04-05-error.R") ``` If you run this API in interactive mode and visit http://localhost:8080/simple, you'll notice two things: 1. An HTTP response with a status code of `500` ("internal server error") is sent to the client. The response does not give any clues as to the nature of the error 2. The error is printed to the console This means that it is possible for you to intentionally use `stop()` in a handler as a way to communicate a problem to your user. However, since most information is stripped away from the response, it may be preferable to provide a bit more detail to the user. reqres, provides a set of abort calls that works like `stop()` but also carry on information about the status code and message to send to the client. Read more at the [reqres website](https://reqres.data-imaginist.com/reference/abort_http_problem.html). Visiting the second handler we can see it in action: ```{r} #| echo: false #| results: asis pa <- api(file.path(me, "files/apis/04-05-error.R")) req <- fiery::fake_request("http://localhost:8080/friendly") muffle <- capture.output(res <- pa$test_request(req)) code_chunk(res$body, "json") ``` ## Setting Cookies {#setting-cookies} As part of fulfilling a request, a Plumber API can choose to set HTTP cookies on the client. HTTP APIs don't implicitly contain a notion of a "session." Without some additional information, Plumber has no way of ascertaining whether or not two HTTP requests that come in are associated with the same user. Cookies offer a way to commission the client to store some state on your behalf so that selected data can outlive a single HTTP request; the full implications of using cookies to track state in your API are discussed [here](./execution-model.html#state-cookies). The two forms of Plumber cookies -- plain-text and encrypted -- are discussed in the following sections. Before you make cookies an important part of your API's security model, be sure to understand the section on the [security considerations when working with cookies](./security.html#security-cookies). ### Setting Unencrypted Cookies Plumber can both set and receive plaint-text cookies. The API endpoint below will return a random letter, but it remembers your preferences on whether you like capitalized or lower-case letters. ```{r} #| eval: false #| code: !expr readLines("files/apis/06-01-capitalize.R") ``` Since this API is using a `PUT` request to test this API, we'll use `curl` on the command line to test it. (There's nothing about cookies that necessitates `PUT` requests; you could just as easily modify this API to use a `GET` request.) We can start by visiting the `/letter` endpoint and we'll see that the API defaults to a lower-case alphabet. `curl http://localhost:8080/letter` ```{r} #| echo: false #| results: asis pa <- api(file.path(me, "files/apis/06-01-capitalize.R")) req <- fiery::fake_request("http://localhost:8080/letter") res <- pa$test_request(req) code_chunk(res$body, "json") ``` If we send a `PUT` request and specify the `capital` parameter, a cookie will be set on the client which will allow the server to accommodate our preference in future requests. In `curl`, you need to specify a file in which you want to save these cookies using the `-c` option. This is a good reminder that clients handle cookies differently -- some won't support them at all -- so be sure that the clients you intend to support with your API play nicely with cookies if you want to use them. To send a `PUT` request, setting the parameter `capital` to `1`, we could invoke: `curl -c cookies.txt -X PUT --data 'capital=1' "http://localhost:8800/preferences"`. If you print out the `cookies.txt` file, you should now see that it contains a single cookie called `capitalize` with a value of `1`. We can make another `GET` request to `/letter` to see if it accommodates our preferences. But we'll need to tell `curl` to use the cookies file we just created when sending this request using the `-b` switch: `curl -b cookies.txt http://localhost:8080/letter`. You should now see that the API is returning a random capitalized letter. The `set_cookie` method accepts a variety of additional options to customize how the cookie should be handled by the client. By default, cookies are set with a `session` lifetime, meaning that the cookie will persist in the user's browser until the client closes the tab at which point the cookie will be deleted. You can customize this by setting the `expires` or `max_age` parameter in `set_cookie` using either a date or the number of seconds in the future in which this cookie should expire. Other options that can be set on the cookie include `path` (the path on your domain at which the cookie should be installed on the client); `http_only` (controls whether or not the cookie should be accessible to JavaScript running on this domain -- where `TRUE` means that the cookie is HTTP-only, and not accessible from JavaScript); and `secure` (if `TRUE`, instructs the browser to only send the cookie over HTTPS, not insecure HTTP. If you're using cookies to infer any security-sensitive properties (such as to identify a user, or determine what resources this client should have access to), be sure to see the [Security article](./security.html) -- in particular [the section on the security implications of cookies](./security.html#security-cookies). ### Setting Encrypted Cookies {#encrypted-cookies} In addition to storing plain-text cookies, Plumber also supports handling cookies that are encrypted. Encrypted cookies prevent your users from seeing what is stored inside of them and also sign their contents so that users can't modify what is stored. To use this feature, you must explicitly add it to your router after constructing it. For example, you could run the following sequence of commands to create a router that supports encrypted session cookies. ``` r api("myfile.R") %>% api_session_cookie("my_secret_here", "cookie_name", ...) %>% api_run() ``` Once you have used `api_session_cookie()`, you'll be able to use the `request$session` and `response$session` object (they point to the same data) to read and set data that will be transmitted as an encrypted cookie named `cookie_name`. In this example, the key used to encrypt the data is `"my_secret_here"`, which will not work since a 32-bit key is required for security reasons. You can construct a compliant key with `reqres::random_key` and store it securely using the keyring package. Unlike `response$set_header()`, the values attached to the `session` data *are* serialized via `jsonlite`; so you're free to use more complex data structures like lists in your session. However, the deserializing is done "blindly" using `jsonlite::fromJSON()` so you should always verify that type conversion has been done correctly if you are storing ambiguous values (e.g. storing `"5"` (the string) will get deserialized to `5` (the number) in the next request). As an example, we'll store an encrypted cookie that counts how many times this client has visited a particular endpoint: ``` r #* @get /sessionCounter function(request){ count <- 0 if (!is.null(request$session$counter)){ count <- as.numeric(request$session$counter) } request$session$counter <- count + 1 paste0("This is visit #", count) } ``` Again, you would need to setup your api using the `api_session_cookie()` function before this code would work. If you inspect the cookie being set in your browser, you'll find that its value is encrypted by the time it gets to the client. But by the time it arrives in Plumber, your cookie is available as a regular R list and can be read or modified. While session cookies are a great way to store state without setting up any additional facilities for it on your server, you should be aware that the session cookie is transmitted with each request/response and need to be encrypted and decrypted every time (the last part only happens if you try to read from it though). Using it thus adds to the server load so weigh the pros and cons of managing session state with it. ## Documenting responses TBD