Specifying possible responses for OpenAPI/Swagger with Plumber

147 Views Asked by At

I am looking for a way to specify the possible status codes that a route can respond with, besides the default 200 "OK" and 500 "internal server error", with a different content type header, as errors are handled by a handler function hooked into the plumbed API and return "application/json" as opposed to "text/html", similar to the implementation at the bottom of this article: https://unconj.ca/blog/structured-errors-in-plumber-apis.html

I have an API built using the "annotation" style, an example route here:

# Define the Plumber API using annotations
#* @get /predict/<file_id>
#* @param file_id:string ID or name of the RDS file
#* @serializer html

function(res, req, file_id) {
  future::future({
    loaded_model <- list()
        loaded_model <- find_or_download_model(file_id, env_var$model_local_dir, 
        env_var$model_s3_bucket, env_var$model_s3_endpoint, env_var$s3_accesskey, 
        env_var$s3_secretkey)

    return("Success, File Found.")
  })
}

Which works fine. However, when I try to add @response annotations to the route, such as:

# Define the Plumber API using annotations
#* @get /predict/<file_id>
#* @param file_id:string ID or name of the RDS file
#* @serializer html
#* @response 403 Forbidden <== HERE
#* @response 404 Not Found   <== AND HERE

I end up with the following generated API, which doesn't have the desired Content Type of "application/json" for the 403 and 404 responses: API Specification

(Which is annoying, because the default 500 Error Code Handler has the correct content type!)

Can anyone offer any suggestions for possible fixes please?

1

There are 1 best solutions below

0
Jon Harmon On

From some digging, it looks like only the Description field of a response object is parsed from the plumber comment block. However, you can specify more details if you add the path programmatically (or even add onto your definition).

You can still define your main code in plumber.R (or whatever). Here's what I have in plumber.R for this example:

# Define the Plumber API using annotations
#* @get /predict/<file_id>
#* @param file_id:string ID or name of the RDS file
#* @serializer html
function(res, req, file_id) {
  if (file_id == "die") {
    res$status <- 403
    res$body <- jsonlite::toJSON(list(
      status = 403, # Repeated per the blog post
      message = "An error message"
    ))
    res$headers <- list(
      "Content-type" = "application/json"
    )
    return(res)
  }
  return("Success, File Found.")
}

The error stuff here is just so I can see things in action, and raises an important point: The stuff we'll define for the responses is PURELY for the OpenAPI description/swagger doc; it doesn't actually impact the returned response.

Now let's tell the API spec about our responses:

library(plumber)
api <- pr("plumber.R") |> 
  pr_get(
    "/predict/<file_id>", 
    function(res, req, file_id) {
      # This version doesn't actually get called, strangely.
    },
    responses = list(
      "403" = list(
        description = "Forbidden",
        content = list(
          "application/json" = list()
        )
      ),
      "404" = list(
        description = "Not Found",
        content = list(
          "application/json" = list()
        )
      )
    )
  )
api |> 
  pr_run()

Rather surprisingly (to me), the function in the file "wins" over the function in the call to pr_get().

You can clean that up a bit using a function for the error message:

function(res, req, file_id) {
  if (file_id == "die") {
    return(stop_forbidden(res, file_id))
  }
  return("Success, File Found.")
}

stop_forbidden <- function(res, file_id) {
  res$status <- 403
  res$body <- jsonlite::toJSON(list(
    status = 403, # Repeated per the blog post
    message = paste0("Cannot access file ", file_id)
  ))
  res$headers <- list(
    "Content-type" = "application/json"
  )
  return(res)
}

Or, even better, a function that signals a typed error (similar to the thing shown in the blog, but really use that status code), combined with an error handler:

function(res, req, file_id) {
  if (file_id == "die") {
    stop_forbidden(file_id)
  }
  return("Success, File Found.")
}

stop_forbidden <- function(file_id) {
  api_error(
    paste0("Cannot access file ", file_id),
    status = 403
  )
}

api_error <- function(message, status) {
  err <- structure(
    list(message = message, status = status),
    class = c(status, "error", "condition")
  )
  signalCondition(err)
}
api <- pr("plumber.R") |> 
  pr_get(
    "/predict/<file_id>", 
    function(res, req, file_id) {
      # This version doesn't actually get called, strangely.
    },
    responses = list(
      "403" = list(
        description = "Forbidden",
        content = list(
          "application/json" = list()
        )
      ),
      "404" = list(
        description = "Not Found",
        content = list(
          "application/json" = list()
        )
      )
    )
  ) |> 
  pr_set_error(
    function(req, res, err) {
      res$status <- as.integer(class(err)[[1]])
      res$headers <- list("Content-type" = "application/json")
      res$body <- jsonlite::toJSON(unclass(err))
      return(res)
    }
  )
api |> 
  pr_run()

I'll be digging through and finding more details for the book I'm working on, Web APIs with R (which is why I found this question, while searching for detailed examples of responses in plumber definitions).