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
.
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.
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 tag parts required are
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.
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 furtherThe 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
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:
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
.
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.
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
:
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)
})
}