rguides

REST APIs with plumber

You have an R function. You want a REST API. The plumber package turns one into the other with five lines of comments: a #* annotation, a verb, a path, and a handler. This guide walks through the mechanics of a plumber.R file: decorators, typed path parameters, serializers, filters, and error handling. With these in place, you can scaffold a working API and call it from a browser or curl within a few minutes.

Install plumber and create your first endpoint

plumber lives on CRAN. Install it the usual way:

install.packages("plumber")

Create a file called plumber.R in your project root. The .R extension matters for syntax highlighting, but the filename itself is a convention; plumber::pr() accepts any path.

# plumber.R

#* Echo the parameter that was sent in
#* @param msg The message to echo back.
#* @get /echo
function(msg = "") {
  list(msg = paste0("The message is: '", msg, "'"))
}

Three lines of comments above the function turn it into a route. The @get /echo line declares the verb and the path, @param msg documents the query string parameter that comes in from the URL, and the first #* line is the OpenAPI summary. With the file saved, run the API from R:

pr <- plumber::pr("plumber.R")
pr |> plumber::pr_run(port = 8000)

pr_run() defaults to host 0.0.0.0 and port 8000, which matches the URL we want to hit. Once the server starts it prints a log line saying it is listening, and you can verify the route with curl:

curl "http://localhost:8000/echo?msg=hi"
# {"msg":["The message is: 'hi'"]}

That boxed ["..."] is the default JSON behaviour for length-1 vectors, a gotcha worth knowing about, covered in the serializers section below.

How plumber annotations work

A plumber annotation is a roxygen2-style comment that begins with #* or #'. The #* form is the convention because it doesn’t collide with package documentation in the same file.

There are four kinds:

  1. Global API metadata: #* @apiTitle, @apiDescription, @apiVersion, @apiContact, @apiLicense, @apiTOS, @apiTag. These feed the generated OpenAPI spec.
  2. Block (endpoint) annotations: #* @get /path, @post, @put, @delete, @patch, @head, @options, @use, plus @param, @serializer, @parser, @response, @tag, @preempt. These attach to the function that follows.
  3. Filter annotations: #* @filter <name> declares a request filter that runs before endpoints.
  4. Static file annotations: #* @assets ./files/static /static serves a directory of files.

A leading #* line with no @ keyword is the endpoint summary; subsequent such lines become the description in OpenAPI.

Routing and inputs

A path can be static (/hello) or dynamic with typed segments (/user/<id:int>). The * after a parameter name in @param marks it as required.

#* @get /user/<id:int>
#* @serializer unboxedJSON
function(id) {
  list(id = id, name = paste("user", id))
}

Query parameters work the same way, but with one important gotcha: they arrive as character vectors because they come off the URL string. Type conversion is your job, not plumber’s.

#* @get /sum
#* @param a:numeric
#* @param b:numeric
function(a, b) {
  list(sum = as.numeric(a) + as.numeric(b))
}

as.numeric(), as.integer(), as.logical(). Pick whichever matches the declared type. If you skip the conversion, "42" + "10" silently coerces to 52, but "42" * "10" errors. Make it explicit.

Supported type aliases in @param and dynamic routes: bool/boolean/logical, dbl/double/float/numeric, int/integer, chr/str/character/string, list/data.frame/df/object, file/binary. Wrap a type in square brackets ([int]) to accept an array of that type.

The iris plot endpoint from the official quickstart combines a query parameter with a custom serializer:

#* Plot out data from the iris dataset
#* @param spec If provided, filter the data to only this species (e.g. 'setosa')
#* @get /plot
#* @serializer png
function(spec) {
  myData <- iris
  title <- "All Species"
  if (!missing(spec)) {
    title <- paste0("Only the '", spec, "' Species")
    myData <- subset(iris, Species == spec)
  }
  plot(myData$Sepal.Length, myData$Petal.Length,
       main = title, xlab = "Sepal Length", ylab = "Petal Length")
}

curl http://localhost:8000/plot returns a PNG. ?spec=setosa filters the data first.

Serializers

A serializer controls how the return value of your handler becomes bytes on the wire. The default is JSON via jsonlite::toJSON(). Switch with @serializer <name>.

The most useful built-in aliases:

SerializerContent-TypeUnderlying call
json (default)application/jsonjsonlite::toJSON()
unboxedJSONapplication/jsonjsonlite::toJSON(auto_unbox = TRUE)
texttext/plainas.character()
csvtext/csvreadr::format_csv()
png, jpeg, svgimage/...grDevices::png(), etc.
htmlwidgettext/html; charset=utf-8htmlwidgets::saveWidget()
contentType <type>user-suppliedno serialization, just the header

The boxed vs unboxed JSON gotcha trips up most new users. By default a length-1 vector becomes a JSON array: list(a = 5) serialises to {"a":[5]}. When your client expects a true scalar, use @serializer unboxedJSON (or wrap the value with jsonlite::unbox(5) on a per-call basis).

Serializer arguments go in a list after the alias. They’re evaluated once at plumb() time, not per request:

#* @serializer png list(width = 800, height = 600)
#* @get /chart
function() plot(1:10)

For per-request image sizes, render manually with grDevices::png() against a tempfile, read the bytes, and return them with the right Content-Type set on res$headers.

Filters and request hooks

A filter is a function that runs before endpoint handlers. It can mutate req, optionally set res, and either call plumber::forward() to continue down the chain or return a response object to short-circuit.

A logger:

#* @filter logger
function(req) {
  cat(as.character(Sys.time()), "-",
      req$REQUEST_METHOD, req$PATH_INFO, "-",
      req$HTTP_USER_AGENT, "@", req$REMOTE_ADDR, "\n")
  plumber::forward()
}

The logger above prints the request line and forwards to the next handler. For something more security-flavoured, an API-key check that short-circuits on a bad or missing key gives you the same pattern, with a reject branch that returns a response and does not call forward():

#* @filter api_key
function(req, res) {
  key <- req$HTTP_X_API_KEY
  if (is.null(key) || key != Sys.getenv("API_KEY")) {
    res$status <- 401
    res$body <- jsonlite::toJSON(list(error = "unauthorized"),
                                 auto_unbox = TRUE)
    res
  } else {
    plumber::forward()
  }
}

The critical detail: when a filter does not call forward(), plumber uses whatever the filter returned and skips the endpoint. The reject branch above returns res directly. That’s how the chain stops.

Filters run in declaration order in the file. The first one that doesn’t forward wins.

Error handling

Two patterns cover most cases. Use stop() when something went wrong inside the handler and the client should see a 500:

#* @get /divide
function(a, b) {
  if (as.numeric(b) == 0) stop("division by zero")
  as.numeric(a) / as.numeric(b)
}

Plumber catches the error, logs it, and returns a 500 with a JSON body. If you’d rather return a 4xx with a friendly message, set res$status and return a list:

#* @get /friendly
function(res) {
  res$status <- 400
  list(error = jsonlite::unbox("Your request did not include a required parameter."))
}

Take res as a parameter when you want to write to it; plumber injects it automatically.

Programmatic router

The annotation workflow is the conventional one, but you can build a router programmatically. Useful when the routes are data-driven or you want to compose them inside a package.

library(plumber)
pr() |>
  pr_get("/hello", function() "Hello!") |>
  pr_post("/sum", function(a, b) as.numeric(a) + as.numeric(b)) |>
  pr_run(port = 8000)

The native pipe |> works. The magrittr pipe %>% is also supported. Use pr_handle() to register a function with custom verb and path:

pr() |> pr_handle("GET", "/health", function() list(status = "ok"))

Deployment

A plumber API is just an R process listening on a port. Common deployment paths:

  • Posit Connect: rsconnect::deployAPI() handles builds, scheduling, and HTTPS.
  • Docker: the rocker/plumber base image bundles a working runtime. Bind to a port, expose it.
  • plumberDeploy: programmatic deployment to DigitalOcean-style VMs.
  • Kubernetes / Shiny Server / a VPS behind nginx: all viable, all require process supervision (systemd, supervisord, etc.).

For container-based deployment, the R and Docker guide covers the practical bits: pinning versions, multi-stage builds, and the perennial timezone-and-locales question.

Common gotchas

A few things that bite everyone eventually:

  • Default boxed JSON. Use @serializer unboxedJSON when clients expect scalars.
  • Query params are strings. Always as.integer() / as.numeric() before arithmetic.
  • Serializer args evaluate once. @serializer png list(width = 800) is set at plumb() time, not per request.
  • Stop the chain by not calling forward(). Filters that should reject must return a response object.
  • #* over #'. Keeps roxygen2 in the same file from getting confused.
  • Swagger UI path. Under some reverse proxies /swagger/ is rewritten away. /__docs__/ is the canonical plumber-internal path and is more reliable.
  • Returning res bypasses serialization. Use @serializer contentType <type> to set a header without serialising the body.

Conclusion

plumber is decorators, pr_run(), and serializers. Drop #* @get /path comments above your functions, pick a serializer that matches the response format you want, and you have an HTTP API. The annotation grammar is small: verbs, @param, @serializer, @filter, @assets. The rest of the package is plumbing (in the literal sense) that wires those comments into a working server.

Build a plumber.R for something you already have (a model, a chart, a data summary), then call it from curl or a browser. Once the local loop works, the deployment options are mostly a question of where you want the process to live.

See Also