A Simple Prediction Web Service Using the New fiery Package

[`fiery`](https://github.com/thomasp85/fiery) is a new `Rook`/`httuv`-based R web server in town created by @thomasp85 that aims to fill the gap between raw http & websockets and Shiny with a flexible framework for handling requests and serving up responses.

The intent of this post is to provide a quick-start to using it setup a prediction API service.

We’ll be using the _super complex model_ described in the first example of the `predict.lm` manual page and save the fitted model out so we can load it up in the web server and use it for predicting values from inputs.

set.seed(1492)
x <- rnorm(15)
y <- x + rnorm(15)
fit <- lm(y ~ x)
saveRDS(fit, "model.rds")

The code is annotated, but the gist is to:

– Fire up the server (NOTE: it puts itself on `0.0.0.0` _by default_ so *CHANGE THIS* until you’re ready for production)
– Load the saved model
– Setup the routing for the requests
– Send back the model as JSON (since it’s an API vs something meant for humans)

Here’s the code (jump past it for more info):

suppressPackageStartupMessages(library(fiery))
suppressPackageStartupMessages(library(utils))
suppressPackageStartupMessages(library(jsonlite))
suppressPackageStartupMessages(library(shiny))

app <- Fire$new()

# This is absolutely necessary unless you're deliberately trying
# to expose the service to the entire network you are on which
# you probably don't want to do until in test / stage / prod

app$host <- "127.0.0.1"
app$port <- 9123 # completely arbitrary selection, make it whatevs

model <- NULL

# When the app starts, we'll load the model we saved. This
# particular one is just the first example on ?predict.lm.
# This doesn't have to be global, per se, but this makes
# for a quick example of how to setup an model API server

app$on("start", function(server, ...) {
  message(sprintf("Running on %s:%s", app$host, app$port))
  model <<- readRDS("model.rds")
  message("Model loaded")
})

# when the request comes in, route it properly. this will
# be *much* nicer with Thomas' `routr` plugin, but you can
# get up and running now this way until it's fully documented
# and on CRAN.
#
# 3 routes:
#
# if "/" then return an empty HTML page
# if "/info" give some data about the server (just for example purposes)
# if "/predict?val=##" run the value through the model
#
# No error checking or anything as this is (again) a simple
# example

app$on('request', function(server, id, request, ...) {

  response <- list(
    status = 200L,
    headers = list('Content-Type'='text/html'),
    body = ""
  )

  # this helps us see what the path is
  path <- get("PATH_INFO", envir=request)
  if (path == "/info") {

    # Build a list of all the request headers so we can 
    # regurgitate them

    out <- sapply(grep("^[A-Z_0-9]+", names(request), value=TRUE), function(x) {
      sprintf("%s: %s", x, get(x, envir=request))
    })
    out <- paste0(out, collapse="\n")

    response$body <- sprintf("<pre>Connection Id: %s\n\n%s</pre>", id, out)

  } else if (grepl("^/predict", path)) {

    # this gets the query string; we're expecting val=##
    # but aren't going to do any error checking here.
    # You also would want to ensure there is nothing 
    # malicious in the query string.
    query  <- get("QUERY_STRING", envir=request)

    # handy helper function from the Shiny folks
    input <- shiny::parseQueryString(query)

    message(sprintf("Input: %s", input$val))

    # run the prediction and add the input var value to the list
    res <- predict(model, data.frame(x=as.numeric(input$val)), se.fit = TRUE)
    res$INPUT <- input$val

    # we want to return JSON
    response$headers <- list("Content-Type"="application/json")
    response$body <- jsonlite::toJSON(res, auto_unbox=TRUE, pretty=TRUE)

  }

  response

})

# don't fire off a browser call
app$ignite(showcase=FALSE)

Assuming you’ve saved that as `modelserver.r`, you can fire that up in R/RStudio-proper or on the command-line with `Rscript modelserver.r` (also assuming the fitted model RDS file is in the same directory which is prbly not a good idea for production as well).

You can either enter something like `http://127.0.0.1:9123/predict?val=-1.5` into your browser to see the JSON result there ore use `cURL`:

$ curl http://127.0.0.1:9123/predict?val=-1.5
{
  "fit": -0.8545,
  "se.fit": 0.5116,
  "df": 13,
  "residual.scale": 1.1088,
  "INPUT": "-1.5"
}

or even `httr`:

httr::content(httr::GET("http://127.0.0.1:9123/predict?val=-1.5"))
$fit
[1] -0.8545

$se.fit
[1] 0.5116

$df
[1] 13

$residual.scale
[1] 1.1088

$INPUT
[1] "-1.5"

Try hitting `http://127.0.0.1:9123/` and `http://127.0.0.1:9123/info` in similar ways to see what you get.

Keep a watchful eye on [`routr`](https://github.com/thomasp85/routr) as it will make setting up API servers in R much easier than this. So far I’m finding `fiery` a nice middle-ground between writing raw `httuv` servers, abusing Shiny (since it’s really meant for UX work) or dealing with the slightly more complex `opencpu` package for turning R into a web request handling engine.

Ideally, one would put this behind a security-aware reverse proxy for both safety (you can add some web application firewall-ish rules) and load balancing, but for in-house/local testing, this is a super quick way to publish your models for wider use. Depending on the adoption rate of `fiery`, I’ll create some future posts that deal with the complexities of security and performance, along with how to put this all into something like Docker for rapid, controlled deployments.

Cover image from Data-Driven Security
Amazon Author Page

14 Comments A Simple Prediction Web Service Using the New fiery Package

  1. Thomas Lin Pedersen

    Thanks for the write-up. A minor comment is that there is a dedicated data-store (just an environment) that can be accessed with the setdata and getdata methods. In that way you don’t have to “pollute” the global environment.

    Reply
  2. Pingback: When A Fire Starts to Burn – Fiery 1.0 released | A bunch of data

  3. Marc

    Hi there! I love that post and it has helped me many times. But in the last one, building that code on an ec2 instance left me that error:

    Error in get(“PATHINFO”, envir = request) : object ‘PATHINFO’ not found

    Don’t know why, I tried to get the path info by many different ways, and algo using package combinations, but it’s a difficult answer to me. I’d love some help for somewhere to look or even for a suitable resolve.

    Thank you in advence

    Reply
  4. Thomas Lin Pedersen

    It’s pretty simple really. Every Fire instance has a set_data() and get_data() method that acts as a simple keystore. In the future it will probably expand to support session info, etc

    Reply

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.