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:
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:
At a high-level, the philosophy of testing in Finatra revolves around the following testing definitions:
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:
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: [email protected].
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.
Did someone say … cookies?
X and its partners use cookies to provide you with a better, safer and
faster service and to support our business. Some cookies are necessary to use
our services, improve our services, and make sure they work properly.
Show more about your choices.