Finatra 2.0: the fast, testable Scala services framework that powers Twitter

By Christopher Coco
Wednesday, 9 December 2015

Finatra is a framework for easily building API services on top of Finagle and TwitterServer. We run hundreds of HTTP services, many of which are now running Finatra 2.0. And now, we’re pleased to formally announce the general availability of the new Finatra 2.0 framework to the open source community.

At Twitter, Finagle provides the building blocks for most of the code we write on the JVM. It has long-served as our extensible, protocol-agnostic, highly-scalable RPC framework. Since Finagle itself is a fairly low-level library, we created TwitterServer — which is how we define an application server at Twitter. It provides elegant integration with twitter/util Flags for parameterizing external server configuration, an HTTP admin interface, tracing functionality, lifecycle management, and stats. However, TwitterServer does not provide any routing semantics for your server. Thus we created Finatra 2.0 which builds on-top of both Finagle and TwitterServer, and allows you to create both HTTP and Thrift services in a consistent, testable framework.

What’s new?

Finatra 2.0 represents a complete rewrite of the codebase from v1.x. In this release, we set out to significantly increase the modularity, testability, and performance of the framework. We want to make it easy to work with the codebase as well as intuitive to write really powerful tests for your API.

New features and improvements include:

  • ~50x speed improvement over the previous v1.6 release in many benchmarks
  • Powerful integration testing support (details in the following section)
  • Optional JSR-330 style dependency injection using Google Guice
  • Jackson JSON enhancements supporting required fields, default values, and custom validations
  • Logback MDC-integration with com.twitter.util.Local for contextual logging across Twitter Futures

Finatra builds on top of the features of TwitterServer and Finagle by allowing you to easily define a server and (in the case of an HTTP service) controllers — a service-like abstraction which define and handle endpoints of the server. You can also compose filters either per controller, per route in a controller, or across controllers.

Please take a look at the main documentation for more detailed information.

Testing

One of the big improvements in this release of Finatra is the ability to easily write robust and powerful tests for your services. Finatra provides the following testing features the ability to:

  • start a locally running server, issue requests, and assert responses
  • easily replace class implementations throughout the object graph
  • retrieve classes in the object graph to perform assertions on them
  • write tests without deploying test code to production

At a high-level, the philosophy of testing in Finatra revolves around the following testing definitions:

  • Feature tests — the most powerful tests enabled by Finatra. These tests allow you to verify feature requirements of the service by exercising its external interface. Finatra supports both “black-box” and “white-box” testing against a locally running version of your server. You can selectively swap out certain classes, insert mocks, and perform assertions on internal state. Take a look at an example feature test.
  • Integration tests — similar to feature tests but the entire service is not started. Instead, a list of modules are loaded and then method calls and assertions are performed at the class-level. You can see an example integration test.
  • Unit tests — these are tests of a single class and since constructor injection is used throughout the framework, Finatra stays out of your way.

Getting started

To get started we’ll focus on building an HTTP API for posting and getting Tweets. Our example will use Firebase (a cloud storage provider) as a datastore. The main entry point for creating an HTTP service is the finatra/http project which defines the com.twitter.finatra.http.HttpServer trait.

Let’s start by creating a few view objects — case classes that represent POST/GET requests to our service. We’ll assume that we’re using previously created domain objects: Tweet and TweetId.

case class TweetPostRequest(
 @ Size(min = 1, max = 140) message: String,
 location: Option[TweetLocation],
 nsfw: Boolean = false) {

 def toDomain(id: TweetId) = {
   Tweet(
     id = id,
     text = message,
     location = location map {_.toDomain},
     nsfw = nsfw)
 }
}

case class TweetGetRequest(
 @ RouteParam id: TweetId)

Next, let’s create a simple Controller:

@ Singleton
class TweetsController @ Inject()(
 tweetsService: TweetsService)
 extends Controller {

 post("/tweet") { tweetPostRequest: TweetPostRequest =>
   for {
     savedTweet <− tweetsService.save(tweetPostRequest)
     responseTweet = TweetResponse.fromDomain(savedTweet)
   } yield {
     response
       .created(responseTweet)
       .location(responseTweet.id)
   }
 }

 get("/tweet/:id") { tweetGetRequest: TweetGetRequest =>
   tweetsService.getResponseTweet(tweetGetRequest.id)
 }
}

The TweetsController defines two routes:

GET  /tweet/:id
POST /tweet/

Routes are defined in a Sinatra-style syntax which consists of an HTTP method, a URL matching pattern, and an associated callback function. The callback function can accept either a finagle-http Request or a custom case-class that declaratively represents the request you wish to accept. In addition, the callback can return any type that can be converted into a finagle-http Response.

When Finatra receives an HTTP request, it will scan all registered controllers (in the order they are added) and dispatch the request to the first matching route starting from the top of each controller invoking the route’s associated callback function.

In the TweetsController we handle POST requests using the TweetPostRequest case class which mirrors the structure of the JSON body posted while specifying field validations — in this case ensuring that the message size in the JSON is between 1 and 140 characters.

For handling GET requests, we likewise define a TweetGetRequest case class which parses the “id” route param into a TweetId class.

And now, we’ll construct an actual server:

class TwitterCloneServer extends HttpServer {
 override val modules = Seq(FirebaseHttpClientModule)
 override def jacksonModule = TwitterCloneJacksonModule

 override def configureHttp(router: HttpRouter): Unit = {
   router
     .filter[CommonFilters]
     .add[TweetsController]
 }

 override def warmup() {
   run[TwitterCloneWarmup]()
 }
}

 

Our server is composed of the one controller with a common set of filters. More generically, a server can be thought of as a collection of controllers (or services) composed with filters. Additionally, the server can define what modules to use and how to map exceptions. Modules are mechanism to help you inject Guice-managed components into your application. They can be useful for constructing instances that rely on some type of external configuration which can be set via a com.twitter.app.Flag.

And finally we’ll write a FeatureTest — note, we could definitely start with a feature test but for the purpose of introducing the concepts in a concise order, we’ve saved this part (the best) for last.

class TwitterCloneFeatureTest extends FeatureTest with Mockito with HttpTest {

 override val server = new EmbeddedHttpServer(new TwitterCloneServer)

 @ Bind val firebaseClient = smartMock[FirebaseClient]
 @ Bind val idService = smartMock[IdService]

 /* Mock GET Request performed in TwitterCloneWarmup */
 firebaseClient.get("/tweets/123.json")(manifest[TweetResponse]) returns Future(None)

 "tweet creation" in {
   idService.getId returns Future(TweetId("123"))

   val savedStatus = TweetResponse(
     id = TweetId("123"),
     message = "Hello FinagleCon",
     location = Some(TweetLocation(37.7821120598956, -122.400612831116)),
     nsfw = false)

   firebaseClient.put("/tweets/123.json", savedStatus) returns Future.Unit
   firebaseClient.get("/tweets/123.json")(manifest[TweetResponse]) returns Future(Option(savedStatus))
   firebaseClient.get("/tweets/124.json")(manifest[TweetResponse]) returns Future(None)
   firebaseClient.get("/tweets/125.json")(manifest[TweetResponse]) returns Future(None)

   val result = server.httpPost(
     path = "/tweet",
     postBody = """
       {
         "message": "Hello FinagleCon",
         "location": {
           "lat": "37.7821120598956",
           "long": "-122.400612831116"
         },
         "nsfw": false
       }""",
     andExpect = Created,
     withJsonBody = """
       {
         "id": "123",
         "message": "Hello FinagleCon",
         "location": {
           "lat": "37.7821120598956",
           "long": "-122.400612831116"
         },
         "nsfw": false
       }""")

   server.httpGetJson[TweetResponse](
     path = result.location.get,
     andExpect = Ok,
     withJsonBody = result.contentString)
 }

 "Post bad tweet" in {
   server.httpPost(
     path = "/tweet",
     postBody = """
       {
         "message": "",
         "location": {
           "lat": "9999"
         },
         "nsfw": "abc"
       }""",
     andExpect = BadRequest,
     withJsonBody = """
       {
         "errors" : [
           "message: size [0] is not between 1 and 140",
           "location.lat: [9999.0] is not between -85 and 85",
           "location.long: field is required",
           "nsfw: 'abc' is not a valid boolean"
         ]
       }
       """)
 }
}

 

In the test, we first create an embedded server. This is an actual instance of the server under test (running locally on ephemeral ports) at which we’ll issue requests and assert expected responses.

A quick note here — you do not have to use Guice when using Finatra. You can create a server, route to controllers, and apply filters all without using any dependency injection. However, you won’t be able to take full advantage of all of the testing features that Finatra offers. Having Guice manage the object-graph allows us to selectively replace instances in the graph on an per-test basis, giving us a lot of flexibility in terms of defining or restricting the surface area of the test.

Next you’ll see that we bind different implementations to the FirebaseClient and IdService types. Here you see the power of object-graph manipulation. In creating the server in production, the “real” versions of these classes are instantiated. In the test, however, we replace FirebaseClient and IdService with mock instantiations to which we hold references in order to mock responses to expected method calls.

Finally, we test specific features of the service:

  • create a new Tweet
  • verify we can read the newly created Tweet back from the service and
  • what happens when we post an “invalid” Tweet to the service and assert that we get back an expected error

We recommend taking a look at the full Twitter Clone example project on GitHub for more information.

We also have a Typesafe Activator seed template that is available for quickly getting a new Finatra project started.

Future work

We’re excited about the future of the Finatra framework and are actively working on new features such as improving the Thrift server support. Stay tuned! In the interim you can checkout our public backlog or browse our issues list.

Getting involved

Finatra is an open source project that welcomes contributions from the greater community. We’re thankful for the many people who have already contributed and if you’re interested, please read the contributing guidelines.

For support, follow and/or Tweet at our @finatra account, post questions to the Gitter chat room, or email the finatra-users Google group: finatra-users@googlegroups.com.

Acknowledgements

We would like to thank Steve Cosenza, Christopher Coco, Jason Carey, Eugene Ma, Nikolaj Nielsen, and Alex Leong. Additionally, we’d like to thank Christopher Burnett (@twoism) and Julio Capote (@capotej), originators of Finatra v1 for graciously letting us tinker with their creation. Many thanks also to the Twitter OSS group — particularly former members Chris Aniszczyk (@cra) and Travis Brown (@travisbrown) for all of their help in getting Finatra 2.0 open-sourced. And lastly, we would like to thank the Finatra community for all their contributions.