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:
- if an unparseable
Content-type
is sent, fail with a 415 ("unsupported media type") - 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.
Written by RJ Zaworski
Related protips
2 Responses
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.
@zihaoyu, great catch! I've updated the tip to only take
through the delimiter when it's parsing Content-Type.