roxygen

The approach described in vignette("porcelain") allows maximum flexibility, but porcelain also allows a less programmatic, more declarative approach via custom roxygen tags. This approach returns to the original expressiveness of plumber, but with the type-checking and testability of porcelain.

Prerequisites

You must first update your DESCRIPTION file to to add the line

Roxygen: list(roclets = c("rd", "namespace", "porcelain::porcelain_roclet"))

or if you are using roxygen-with-markdown

Roxygen: list(markdown = TRUE, roclets = c("rd", "namespace", "porcelain::porcelain_roclet"))

The important part is the porcelain::porcelain_roclet entry in “roclets” which will activate the @porcelain tag that we will use.

Declaring a simple endpoint

Consider our simple add function from vignette("porcelain"), which takes numeric query arguments and returns a number. We can write

#' @porcelain
#'   GET / => json("numeric")
#'   query a :: numeric
#'   query b :: numeric
add <- function(a, b) {
  jsonlite::unbox(a + b)
}

api <- function(validate = FALSE) {
  api <- porcelain::porcelain$new(validate = validate)
  api$include_package_endpoints()
  api
}

Then running devtools::document() (or similar) will generate the file R/porcelain.R with a hook function that contains code that will be included when running the include_package_endpoints method.

The basic syntax

The basic tag parts required are

#' @porcelain <METHOD> <path> => <returning>

This string can be split across many lines and additional whitespace is ignored, so for the simple example above, we could equivalently write

#' @porcelain
#'   GET /add/<a:int>/<b:int> =>
#'     json("numeric")

The arguments are all passed to porcelain::porcelain_endpoint; the method and path are passed directly as method and path. The => symbol exists to read “returning” and help mark the end of the path.

The “returning” argument is transformed before being passed through. In the example above json("numeric") became porcelain::porcelain_returning_json("numeric")

We translate with the rule:

  • json: porcelain::porcelain_returning_json
  • binary: porcelain::porcelain_returning_binary
  • generic porcelain::porcelain_returning

If if no arguments are provided we perform a call with no arguments (e.g., GET /path => json would use porcelain::porcelain_returning_json()). If arguments are given, then we will attempt to auto-quote these.

Adding inputs

The above function used query parameters to provide inputs to the target function; you can specify query, body and bound state this way (path parameters remain in the path, as before).

All input parameters have the format

#' <location> <name> :: <description>

where

  • <location> is one of query, body or state
  • <name> is the name of the argument in the target function that you will send the input to
  • <description> is one or more arguments that describes the input further

The interface here is a little different to the underlying porcelain functions (porcelain::porcelain_input_query, porcelain::porcelain_input_body and porcelain::porcelain_state) in that every input parameter is given individually even if you might treat these together in the functions. For example, for two input queries we used two lines.

For query inputs, the description will be one of the valid types for porcelain::porcelain_input_query; logical, integer, numeric or string, for example an endpoint accepting two query inputs:

#' @porcelain
#'   POST /path => json(OutputSchema)
#'   query a :: numeric
#'   query b :: integer
f <- function(a, b) {
  # implementation
}

For a json body it is likely that you will want to add a schema

#' @porcelain
#'   POST /path => json(OutputSchema)
#'   query a :: numeric
#'   query b :: integer
#'   body data :: json(InputSchema)
f <- function(a, b, data) {
  ...
}

For a binary body you can optionally specify the incoming mime type, for example

#'   body data :: binary(application/zip)

would refer to a zip input.

No special markup is required for path parameters, include these using plumber’s syntax

#' @porcelain
#'   POST /path/<a>/<b:int> => json(OutputSchema)
f <- function(a, b) {
  ...
}

Binding state

Finally consider binding state into the API. We need to do this where we have some mutable state in the API that endpoints (typically POST or DELETE) will modify. Examples include database connections or queues.

Suppose that you have an endpoint that will count the number of times that it has been accessed, returning that number. We might use a counter like this:

counter <- R6::R6Class(
  "counter",
  private = list(n = 0),
  public = list(
    value = function() {
      private$n
    },
    increase = function() {
      private$n <- private$n + 1
      private$n
    }))

Outside of porcelain we can use this like so:

obj <- counter$new()
obj$increase()
#> [1] 1
obj$increase()
#> [1] 2
obj$value()
#> [1] 2

We can write a set of endpoints that that share a counter object like this, and add roxygen comments to configure it:

#' @porcelain
#'   GET /counter/value => json(number)
#'   state obj :: counter
increase <- function(obj) {
  jsonlite::unbox(obj$value())
}

#' @porcelain
#'   POST /counter/increase => json(number)
#'   state obj :: counter
value <- function(obj) {
  jsonlite::unbox(obj$increase())
}

Here we have a pair of endpoints; the first GET /counter/value will return the value of the counter and POST /counter/increase will increase its value by one and return that. We could have other methods like POST /counter/reset to reset the counter, but the key thing is that all endpoints must share the same counter and to do that we must pass it to the api when we create it.

Here we say that doing POST /counter will return a number. The endpoint takes a counter object as above, but that won’t come from the HTTP request - it will be bound into the API, so it might be shared. By adding the roxygen comment

#'   state obj :: counter

we set this up. We need to pass the state through when creating the API:

api <- function(validate = FALSE) {
  state <- list(counter = counter$new())
  api <- porcelain::porcelain$new(validate = validate)
  api$include_package_endpoints(state)
  api
}

Here our api creation function creates a new zeroed counter (you could accept one as an argument of course), puts that into a list with name counter, corresponding to the rhs of the roxygen comment, and passes that through to include_package_endpoints.

Testing

Because the code is generated into the porcelain.R file you need to use porcelain::porcelain_package_endpoint in order to extract the raw endpoint object to take advantage of porcelain’s test helper.

endpoint <- porcelain::porcelain_package_endpoint("mypkg", "GET", "/path")
endpoint$run()

An example

We include a very small complete example

add2
├── DESCRIPTION
├── NAMESPACE
├── R
│   └── api.R
└── inst
    └── schema
        └── numeric.json

As for the simple example in vignette("porcelain") we have written the api into a single file R/api.R but this could be split over as many files as you prefer

#' @porcelain
#'   GET / => json("numeric")
#'   query a :: numeric
#'   query b :: numeric
add <- function(a, b) {
  jsonlite::unbox(a + b)
}

api <- function(validate = FALSE) {
  api <- porcelain::porcelain$new(validate = validate)
  api$include_package_endpoints()
  api
}

Our DESCRIPTION file includes the roxygen2 setup

Package: add
Title: Adds Numbers
Version: 1.0.0
Authors@R: c(person("Rich", "FitzJohn", role = c("aut", "cre"),
                    email = "[email protected]"))
Description: Adds numbers as an HTTP API.
License: CC0
Encoding: UTF-8
Imports: porcelain
Roxygen: list(markdown = TRUE, roclets = c("rd", "namespace", "porcelain::porcelain_roclet"))
RoxygenNote: 7.1.2

We include a small json schema inst/schema/numeric.json:

{
    "$schema": "http://json-schema.org/draft-04/schema#",
    "id": "numeric",
    "type": "number"
}

Running roxygen2::roxygenize(path) on the file will build the interface

roxygen2::roxygenize(path)
#> Setting `RoxygenNote` to "7.3.2"
#> Writing 'NAMESPACE'
#> ℹ Loading add
#> Adding porcelain endpoints:
#> 
#> - GET / (api.R:1)

The contents now include R/porcelain.R (and man, created by roxygen2 but empty)

add2
├── DESCRIPTION
├── NAMESPACE
├── R
│   ├── api.R
│   └── porcelain.R
├── inst
│   └── schema
│       └── numeric.json
└── man

The R/porcelain.R file contains automatically-generated endpoint definitions

# Generated by porcelain: do not edit by hand
`__porcelain__` <- function() {
  list(
    "GET /" = function(state, validate) {
      porcelain::porcelain_endpoint$new(
        "GET",
        "/",
        add,
        porcelain::porcelain_input_query(a = "numeric", b = "numeric"),
        returning = porcelain::porcelain_returning_json("numeric"),
        validate = validate)
    })
}