Body encoding and decoding

Encoding

Let’s consider an example. We develop an application which calculates factorial of a number:

library(RestRserve)
backend = BackendRserve$new()
application = Application$new()
application$add_get(path = "/factorial", function(.req, .res) {
  x = .req$get_param_query("x")
  x = as.integer(x)
  .res$set_body(factorial(x))
})

Here is how request will be processed:

request = Request$new(
  path = "/factorial", 
  method = "GET", 
  parameters_query = list(x = 10)
)
response = application$process_request(request)
response
#> <RestRserve Response>
#>   status code: 200 OK
#>   content-type: text/plain
#>   <Headers>
#>     Server: RestRserve/0.4.1

Let’s take a closer look to the response object and its body property:

str(response$body)
#>  chr "3628800"

As we can see it is a numeric value. HTTP response body however can’t be an arbitrary R object. It should be something that external systems can understand - either character vector or raw vector. Fortunately application helps to avoid writing boilerplate code to encode the body. Based on the content_type property it can find encode function which will be used to transform body into a http body.

response$content_type
#> [1] "text/plain"
response$encode
#> NULL

Two immediate questions can arise:

  1. Why content_type is equal to text/plain?
    • This is because we can specify default content_type in Application constructor. It is text/plain by default, which means all the responses by default will have text/plain content type.
  2. How does application know how to encode text/plain? Can it encode any arbitrary content type?
    • Application by default is initialized with pre-defined ?EncodeDecodeMiddleware middleware. The logic on how to encode and decode request and response body is controlled by its ContentHandlers property. Out of the box it supports two content types - text/plain and application/json.

For instance app1 and app2 are equal:

encode_decode_middleware = EncodeDecodeMiddleware$new()
app1  = Application$new(middleware = list())
app1$append_middleware(encode_decode_middleware)

app2 = Application$new()

Here is example on how you can get the actual function used for application/json encoding:


FUN = encode_decode_middleware$ContentHandlers$get_encode('application/json')
FUN
#> function(x, unbox = TRUE)  {
#>   res = jsonlite::toJSON(x, dataframe = 'columns', auto_unbox = unbox, null = 'null', na = 'null')
#>   unclass(res)
#> }
#> <bytecode: 0x7fc027ed1bf8>
#> <environment: namespace:RestRserve>

We can manually override application default content-type:

application$add_get(path = "/factorial-json", function(.req, .res) {
  x = as.integer(.req$get_param_query("x"))
  result = factorial(x)
  .res$set_body(list(result = result))
  .res$set_content_type("application/json")
})
request = Request$new(
  path = "/factorial-json", 
  method = "GET", 
  parameters_query = list(x = 10)
)
response = application$process_request(request)
response$body
#> [1] "{\"result\":3628800}"

And here is a little bit more complex example where we store a binary object in the body. We will use R’s native serialization, but one can use protobuf, messagepack, etc.

application$add_get(path = "/factorial-rds", function(.req, .res) {
  x = as.integer(.req$get_param_query("x"))
  result = factorial(x)
  body_rds = serialize(list(result = result), connection = NULL)
  .res$set_body(body_rds)
  .res$set_content_type("application/x-rds")
})

However function above won’t work correctly. Out of the box ContentHndlers doesn’t know anything about application/x-rds:

request = Request$new(
  path = "/factorial-rds", 
  method = "GET", 
  parameters_query = list(x = 10)
)
response = application$process_request(request)
response$body
#> [1] "500 Internal Server Error: can't encode body with content_type = 'application/x-rds'"

In order to resolve problem above we would need to either register application/x-rds content handler with ContentHandlers$set_encode() or manually specify encode function (identity in our case):

application$add_get(path = "/factorial-rds2", function(.req, .res) {
  x = as.integer(.req$get_param_query("x"))
  result = factorial(x)
  body_rds = serialize(list(result = result), connection = NULL)
  .res$set_body(body_rds)
  .res$set_content_type("application/x-rds")
  .res$encode = identity
})

Now the answer is valid:

request = Request$new(
  path = "/factorial-rds2", 
  method = "GET", 
  parameters_query = list(x = 10)
)
response = application$process_request(request)
unserialize(response$body)
#> $result
#> [1] 3628800

Decoding

RestRserve facilitates with parsing incoming request body as well. Consider a service which expects JSON POST requests:

application = Application$new(content_type = "application/json")
application$add_post("/echo", function(.req, .res) {
  .res$set_body(.req$body)
})

request = Request$new(path = "/echo", method = "POST", body = '{"hello":"world"}', content_type = "application/json")
response = application$process_request(request)
response$body
#> [1] "{\"hello\":\"world\"}"

The logic behind decoding is also controlled by ?EncodeDecodeMiddleware and its ContentHandlers property.

Extending encoding and decoding

Here is an example which demonstrates on how to extend ?EncodeDecodeMiddleware to handle additional content types:

encode_decode_middleware = EncodeDecodeMiddleware$new()

encode_decode_middleware$ContentHandlers$set_encode(
  "text/csv", 
  function(x) {
    con = rawConnection(raw(0), "w")
    on.exit(close(con))
    write.csv(x, con, row.names = FALSE)
    rawConnectionValue(con)
  }
)

encode_decode_middleware$ContentHandlers$set_decode(
  "text/csv", 
  function(x) {
    res = try({
      con = textConnection(rawToChar(x), open = "r")
      on.exit(close(con))
      read.csv(con)
    }, silent = TRUE)
    
    if (inherits(res, "try-error")) {
      raise(HTTPError$bad_request(body = attributes(res)$condition$message))
    }
    return(res)
  }
)

Extended middleware needs to be provided to the application constructor:

data(iris)
app = Application$new(middleware = list(encode_decode_middleware))

Now let’s test it:

app$add_get("/iris", FUN = function(.req, .res) {
  .res$set_content_type("text/csv")
  .res$set_body(iris)
})

req = Request$new(path = "/iris", method = "GET")
res = app$process_request(req)

iris_out = read.csv(textConnection(rawToChar(res$body)))
head(iris_out)
#>   Sepal.Length Sepal.Width Petal.Length Petal.Width Species
#> 1          5.1         3.5          1.4         0.2  setosa
#> 2          4.9         3.0          1.4         0.2  setosa
#> 3          4.7         3.2          1.3         0.2  setosa
#> 4          4.6         3.1          1.5         0.2  setosa
#> 5          5.0         3.6          1.4         0.2  setosa
#> 6          5.4         3.9          1.7         0.4  setosa
app$add_post("/in", FUN = function(.req, .res) {
  str(.req$body)
})
req = Request$new(path = "/in", method = "POST", body = res$body, content_type = "text/csv")
app$process_request(req)
#> 'data.frame':    150 obs. of  5 variables:
#>  $ Sepal.Length: num  5.1 4.9 4.7 4.6 5 5.4 4.6 5 4.4 4.9 ...
#>  $ Sepal.Width : num  3.5 3 3.2 3.1 3.6 3.9 3.4 3.4 2.9 3.1 ...
#>  $ Petal.Length: num  1.4 1.4 1.3 1.5 1.4 1.7 1.4 1.5 1.4 1.5 ...
#>  $ Petal.Width : num  0.2 0.2 0.2 0.2 0.2 0.4 0.3 0.2 0.2 0.1 ...
#>  $ Species     : chr  "setosa" "setosa" "setosa" "setosa" ...
#> <RestRserve Response>
#>   status code: 200 OK
#>   content-type: text/plain
#>   <Headers>
#>     Server: RestRserve/0.4.1