Defining HTTP Controllers

We now want to add the following controller to the server definition:

import ExampleService
import com.twitter.finagle.http.Request
import com.twitter.finatra.http.Controller
import javax.inject.Inject

class ExampleController @Inject()(
  exampleService: ExampleService
) extends Controller {

  get("/ping") { request: Request =>
    "pong"
  }

  get("/name") { request: Request =>
    response.ok.body("Bob")
  }

  post("/foo") { request: Request =>
    exampleService.do(request)
    "bar"
  }
}

The server can now be defined with the controller as follows:

import DoEverythingModule
import ExampleController
import com.twitter.finatra.http.routing.HttpRouter
import com.twitter.finatra.http.{Controller, HttpServer}

object ExampleServerMain extends ExampleServer

class ExampleServer extends HttpServer {

  override val modules = Seq(
    DoEverythingModule)

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

Here we are adding by type allowing the framework to handle class instantiation.

Controllers and Routing

Routes are defined in a Sinatra-style syntax which consists of an HTTP method (or any), a URL matching pattern and an associated callback function. The callback function can accept a c.t.finagle.http.Request, a custom request case class (which declaratively represents the parsed request body as JSON), or a type parsed by a registered Message Body Reader. See HTTP Requests for more information.

If you use any instead of specifying an HTTP method, that means that it will match on all HTTP methods, provided the pattern matches to the URL. any has special ordering rules, so that it has lower precedence than routes where the HTTP method is specified. This ensures that you can’t accidentally clobber a more specific route by registering them in the wrong order.

The callback can return any type that can be converted into a c.t.finagle.http.Response. See HTTP Responses for more information.

Important

Controller route callback functions MUST specify an input type of either a c.t.finagle.http.Request, a custom request case class or a type parsed by a registered Message Body Reader.

Failure to specify a correct input type in the callback function will prevent your server from starting properly. See the Http Requests section for more information.

Route Ordering

When Finatra receives an HTTP request, it will first check whether there is a constant route which matches the path exactly. If there is, it will dispatch the request to that route. If there isn’t, 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 then invoking the matching route’s associated callback function.

That is, routes are matched in the order they are added to the c.t.finatra.http.routing.HttpRouter. Thus if you are creating routes with overlapping URIs it is recommended to list the routes in order starting with the “most specific” to the least specific. Although you don’t need to do this for constant routes, we encourage you to do it anyway to make it easier to reason about.

Important

The one exception to this case is controllers that are registered with an any route, which will be applied only if there is no other matching route with that HTTP method. This is true even if they are registered later. Constant routes for any work the same way, where they will be applied only after all routes with that HTTP method are checked.

In general, however, it is recommended to that you follow REST conventions if possible, i.e., when deciding which routes to group into a particular controller, group routes related to a single resource into one controller.

Per-Route Stats

The per-route stats recording provided by Finatra in the c.t.finatra.http.filters.StatsFilter works best when the above convention is followed.

import com.twitter.finagle.http.Request
import com.twitter.finatra.http.Controller

class GroupsController extends Controller {
  get("/groups/:id") { request: Request =>
    ???
  }

  post("/groups") { request: Request =>
    ???
  }

  delete("/groups/:id") { request: Request =>
    ???
  }
}

yields the following stats:

route/groups_id/GET/...
route/groups/POST/...
route/groups_id/DELETE/...

Alternatively, each route can be assigned a name which will then be used to create stat names.

import com.twitter.finagle.http.Request
import com.twitter.finatra.http.Controller

class GroupsController extends Controller {
  get("/groups/:id", name = "group_by_id") { request: Request =>
    ???
  }

  post("/groups", name = "create_group") { request: Request =>
    ???
  }

  delete("/groups/:id", name = "delete_group") { request: Request =>
    ???
  }
}

yields:

route/group_by_id/GET/...
route/create_group/POST/...
route/delete_group/DELETE/...

Route Matching Patterns:

Named Parameters

Route patterns may include named parameters. E.g., a defined variable in the route path:

import com.twitter.finagle.http.Request

get("/users/:id") { request: Request =>
  "You looked up " + request.params("id")
}

In the above example, :id is considered a “named parameter” of the route and will capture the value in its position in the incoming request URI.

As shown, the incoming value from the request can be obtained from the request parameters map, e.g. request.params(“id”).

For example, both of the following requests will match the above defined route:

GET /users/1234
GET /users/5678

Which would produce responses like the following:

===========================================================================
HTTP GET /users/1234
[Header]    Host -> 127.0.0.1:57866
===========================================================================
[Status]    Status(200)
[Header]    Content-Type -> text/plain; charset=utf-8
[Header]    Server -> Finatra
[Header]    Date -> Tue, 31 Jan 2017 00:00:00 GMT
[Header]    Content-Length -> 18
You looked up 1234

===========================================================================
HTTP GET /users/5678
[Header]    Host -> 127.0.0.1:57866
[Status]    Status(200)
[Header]    Content-Type -> text/plain; charset=utf-8
[Header]    Server -> Finatra
[Header]    Date -> Tue, 31 Jan 2017 00:00:00 GMT
[Header]    Content-Length -> 18
You looked up 5678

As request.params(“id”) would capture 1234 in the first request and 5678 in the second.

Important

Both query params and route params are stored in the parameters map of the request. If a route parameter and a query parameter have the same name, the route parameter always wins.

Therefore, you should ensure your route parameter names do not collide with any query parameter names that you plan to read from the request.

Constant Routes

A “constant route” is any defined route which does not specify a named parameter in its route path. Routing is optimized to do a simple lookup against a “constant route” map whereas named parameter routes are tried in their defined order for a route which will handle the request.

Wildcard Parameter

Routes can also contain the wildcard pattern as a named parameter, :*. The wildcard can only appear once at the end of a pattern and it will capture all text in its place.

For example,

import com.twitter.finagle.http.Request

get("/files/:*") { request: Request =>
  request.params("*")
}

Given a request:

GET  /files/abc/123/foo.txt

would produce a response:

===========================================================================
HTTP GET /files/abc/123/foo.txt
[Header]    Host -> 127.0.0.1:58540
===========================================================================
[Status]    Status(200)
[Header]    Content-Type -> text/plain; charset=utf-8
[Header]    Server -> Finatra
[Header]    Date -> Tue, 31 Jan 2017 00:00:00 GMT
[Header]    Content-Length -> 15
abc/123/foo.txt

The wildcard named parameter matches everything in its position. In this case: abc/123/foo.txt.

Regular Expressions

Regular expressions are no longer allowed in string defined paths (since v2).

Route Prefixes

Finatra provides a simple DSL for adding a common prefix to a set of routes within a Controller. For instance, if you have a group of routes within a controller that should all have a common prefix you can define them by making use of the c.t.finatra.http.RouteDSL#prefix function available in any subclass of c.t.finatra.http.Controller, e.g.,

import com.twitter.finagle.http.Request
import com.twitter.finatra.http.Controller

class MyController extends Controller {

  // regular route
  get("/foo") { request: Request =>
    "Hello, world!"
  }

  // set of prefixed routes
  prefix("/2") {
    get("/foo") { request: Request =>
      "Hello, world!"
    }

    post("/bar") { request: Request =>
      response.ok
    }
  }
}

This definition would produce the following routes:

GET     /foo
GET     /2/foo
POST    /2/bar

The input to the c.t.finatra.http.RouteDSL#prefix function is a String and how you determine the value of that String is entirely up to you. You could choose to hard code the value like in the above example, or inject it as a parameter to the Controller, e.g., by using a flag or a Binding Annotation that looks for a bound String type in the object graph which would allow you provide it in any manner appropriate for your use case.

For example,

import com.twitter.finagle.http.Request
import com.twitter.finatra.http.Controller
import com.twitter.inject.annotations

class MyController @Inject()(
  @Flag("api.version.prefix") apiVersionPrefix: String, // value from a "api.version.prefix" flag
  @VersionPrefix otherVersionPrefix otherApiVersionPrefix: String // value from a String bound with annotation: @VersionPrefix
) extends Controller {
  ...

  prefix(apiVersionPrefix) {
    get("/foo") { request: Request =>
      ???
    }
  }

  prefix(otherVersionPrefix) {
    get("/bar") { request: Request =>
      ???
    }
  }

Important

  • Routes and Prefixes MUST begin with a forward slash (/).
  • Routes are always added to the c.t.finatra.http.routing.HttpRouter in the order defined in the Controller and are thus matched in this order as well. This remains true even when defined within a prefix block. I.e., the prefix is merely a convenience for adding a common prefix to a set of routes. You should still be aware of the total order in which your routes are defined in a Controller.
  • You can use the c.t.finatra.http.RouteDSL#prefix function multiple times in a Controller with the same or different values.

Trailing Slashes

If you want to ignore trailing slashes on routes such that /groups/1 and groups/1/ are treated to be equivalent, append /? to your route URI, e.g.,

import com.twitter.finagle.http.Request

get("/groups/:id/?") { request: Request =>
  response.ok("response body here")
}

Otherwise, the route as specified is an exact match. E.g., if you define /groups/1 we will only match requests to /groups/1 and not requests to /groups/1/ and vice-versa.

Admin Paths

All TwitterServer-based servers have an HTTP Admin Interface which includes a variety of tools for diagnostics, profiling, and more. This admin interface should not be exposed outside your data center DMZ. The TwitterServer HTTP Admin Interface is a Finagle ListeningStackServer which binds to a port specified by the admin.port flag.

You can choose to only expose API endpoints on this listening server or additionally add an endpoint to the HTTP Admin Interface web UI.

Adding Endpoints

Any Controller route path starting with /admin/finatra/ will be included by default on the server’s admin interface (accessible via the server’s admin port). Other paths can be included on the server’s admin interface by setting admin = true when defining the route.

Important

Any admin route that does not start with /admin/finatra/ MUST be a constant route, e.g., it does not define any named parameters.

import com.twitter.finagle.http.Request

get("/admin/finatra/users/") { request: Request =>
  userDatabase.getAllUsers(
    request.params("cursor"))
}

get("/admin/display/", admin = true) { request: Request =>
  response.ok("response body here")
}

post("/special/route/", admin = true) { request: Request =>
  ???
}

// cannot be added to admin index as it uses a named parameter (:id) in the route path
get("/admin/client/:id", admin = true) { request: Request =>
  response.ok("response body here")
}

TwitterServer Admin UI

Some admin routes can additionally be listed in the TwitterServer HTTP Admin Interface index.

To expose your route in the TwitterServer HTTP Admin Interface index, the route path:

  • MUST be a constant path.
  • MUST start with /admin/.
  • MUST NOT start with /admin/finatra/.
  • MUST be an HTTP method GET or POST route.

When defining the route in a Controller, in addition to setting admin = true you must also provide a RouteIndex, e.g.,

import com.twitter.finagle.http.Request

get("/admin/client_id.json",
  admin = true,
  index = Some(
    RouteIndex(
      alias = "Thrift Client Id",
      group = "Process Info"))) { request: Request =>
  Map("client_id" -> "clientId.1234"))
}

The route will appear in the left-rail of the TwitterServer HTTP Admin Interface under the heading specified by the RouteIndex#group indexed by RouteIndex#alias or the route’s path.

If you do not provide a RouteIndex the route will not appear in the index but is still reachable on the admin interface.

Admin Path Routing

Note: only admin routes which start with /admin/finatra/ will be routed to using the server’s configured HttpRouter. All other admin routes will be routed to by TwitterServer’s AdminHttpServer which only supports exact path matching and thus why only constant routes are allowed.

Therefore any configuration defined on your server’s HttpRouter will thus only apply to admin routes starting with /admin/finatra. And because these routes will use the Finatra RoutingService these routes cannot be included in the TwitterServer HTTP Admin Interface index.