Last Updated: February 25, 2016
·
3.603K
· rjz

Parsing Request Content in Finatra

So you're writing an app on Twitter's finatra framework when it hits you: sending formatted data is stupid simple, but where's the parsing?.

Fortunately, it doesn't take much to start parsing request bodies in the controller endpoints that need it.

Writing a parser

For demonstration, let's focus on handling JSON requests. We'll use json4s for parsing, so we'll need to add it to build.sbt:

libraryDependencies += "org.json4s"  %% "json4s-jackson" % "3.2.4"

We'll also need to import a few classes required by the body parser:

import scala.util.Try

// For parsing content-type headers in general
import com.twitter.finagle.http.Request
import com.twitter.finatra.ContentType
import com.twitter.finatra.ContentType._

// For parsing JSON bodies in particular
import org.json4s._
import org.json4s.jackson.JsonMethods.parse

The body parser itself is very simple. When applied, it returns an option containing the parsed contents of the request body or None if the request format is unrecognized. The type of the parsed content is up to the parser; our example parser will use return Some(JValue) when the body is parsed successfully and Some(JNothing) if an error occurs.

object BodyParser {

  private def contentType(request: Request):
    request.contentType
      .map(_.takeWhile(c => c != ';'))
      .flatMap(ContentType(_))
      .getOrElse(new All)
    request.contentType.flatMap(ContentType(_)).getOrElse(new All)

  private def asJson(body: String): Option[JValue] =
    Try(parse(body)).toOption.orElse(Some(JNothing))

  def apply(request: Request): Option[AnyRef] = contentType(request) match {
    case _: Json => asJson(request.contentString)
    case _       => None
  }
}

Extending a route

Let's set up a demonstration route in one of our application's controllers that applies the BodyParser to an incoming request and echo back the response.

In addition to the happy coincidence where everything works out, we'll need to address two potential modes of failure:

  1. if an unparseable Content-type is sent, fail with a 415 ("unsupported media type")
  2. if parsing failed on apparently parseable content, fail with a 400 ("bad request")

Here's the code. To keep it simple, we use shoe-horn the serialized json into a plain response--something we would be very wary of in practice:

post("/echo") { request =>
  BodyParser(request) match {
    case Some(j: JObject) => {
      render.status(200).plain(compact(render(j))).toFuture
    }
    case Some(JNothing) => {
      render.status(400).plain("Invalid JSON").toFuture
    }
    case _ => throw new UnsupportedMediaType
  }
}

Since the invalid content fails via exception, our controller will need to have an appropriate error handler in place. Here's a simple implementation borrowed from finatra's example app:

error { request =>
  request.error match {
    case Some(e:UnsupportedMediaType) =>
      render.status(415).plain("Unsupported Media Type!").toFuture
    case _ =>
      render.status(500).plain("Something went wrong!").toFuture
  }
}

Finally, let's add specs to verify that everything is playing nicely together:

"POST /echo" should "echo valid JSON" in {
  val body = """{"hello":"world"}"""

  post("/echo",
    headers = Map("Content-type" -> "application/json"),
    body    = body)

  response.code should equal(200)
  response.body should equal(body)
}

"POST /echo" should "fail for invalid JSON" in {
  val body = """{"hello":"""

  post("/echo",
    headers = Map("Content-type" -> "application/json"),
    body    = body)

  response.code should equal(400)
}

"POST /echo" should "fail for unknown content-type" in {

  post("/echo",
    headers = Map("Content-type" -> "application/ruby-slippers")
  )

  response.code should equal(415)
}

And that should do it! We're now all set to start handling the contents of incoming requests.

2 Responses
Add your response

Great work! Really useful to me.

However there is one possible improvement. The Content-Type specification allows an extra parameter to be added after the MIME type part, for example "application/json; charset=UTF-8".

So your BodyParser should parse the value of Content-Type header, and pass only the part before ";" to construct ContentType.

over 1 year ago ·

@zihaoyu, great catch! I've updated the tip to only take through the delimiter when it's parsing Content-Type.

over 1 year ago ·