Custom 404 responses in Finatra

This post is a quick writeup of a problem I had to solve at work today. I’m writing it so I can find the information later, but if you don’t use Finatra, it’s unlikely to be of much interest.

Context

The Catalogue API we’ve built at Wellcome is a Finatra app. It usually returns JSON responses, including for errors – we have a custom error model. A few simplified examples:

$curl "https://api.wellcomecollection.org/catalogue/v2/works?query=fish"
{
  "type": "ResultList",
  "results": [
    {
      "id":  "bqzs9649",
      "title":  "A discourse of fish and fish-ponds, by the Hon. Roger North."
    },
    ...
  ]
}

$curl "https://api.wellcomecollection.org/catalogue/v2/works/bqzs9649"
{
  "id":  "bqzs9649",
  "title":  "A discourse of fish and fish-ponds, by the Hon. Roger North."
}

$curl "https://api.wellcomecollection.org/catalogue/v2/works/doesnotexist"
{
  "errorType": "http",
  "httpStatus": 404,
  "label": "Not Found",
  "description": "Work not found for identifier doesnotexist",
  "type": "Error"
}

The Finatra app is listening for the endpoint /catalogue/v2/works/:id. We’ve written a function that handles all requests to that endpoint, and inside that function, we can return our custom error model if the ID doesn’t exist.

What if you look at a different endpoint?

Over the weekend, I was poking around, and discovered that requests to unhandled endpoints would return an empty 404. For example:

$curl "https://api.wellcomecollection.org/catalogue/"
<404 response with an empty body>

We’d rather this endpoint returned instances of our custom error model. It took me a while to figure out how to do this – the Finatra docs weren’t so helpful – so I’m going to write down how I got it working.

You can see the pull request and tests on our public repo, but I’ll use an example app in this post to make it easier to follow.

Problem statement

Here’s a minimal Finatra app:

import com.twitter.finagle.http.{Request, Response}
import com.twitter.finatra.http.{Controller, HttpServer}
import com.twitter.finatra.http.routing.HttpRouter


class CustomController() extends Controller {
  get("/greeting") { request: Request =>
    response.ok.json(Map("hello" -> "world"))
  }
}


class Server extends HttpServer {
  override def configureHttp(router: HttpRouter) {
    router.add[CustomController]
  }
}


object ServerMain extends Server

This app has a single endpoint that returns a fixed response:

GET /greeting
200 OK
b'{"hello": "world"}'

If you request any other endpoint, you get a 404 with an empty body:

GET /foo/bar
404 Not Found
b''

We want it to return a JSON response {"error": "page not found"}.

What I tried first

I tried a couple of different approaches before I struck on something that worked:

What actually worked

All the Finatra docs suggested that if I wanted to return a custom error response, I had to do so from within a route handler. If only I could write a “catch-all” route handler…

While reading the docs about defining HTTP controllers for the third time, something caught my eye halfway down the page:

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.

This was a lightbulb moment. By adding a route with a wildcard, I’d get a route handler where I could throw a custom error. Here’s what that looks like in the example app:

class CustomController() extends Controller {
  get("/greeting") { request: Request =>
    response.ok.json(Map("hello" -> "world"))
  }

  get("/:*") { request: Request =>
    response.notFound.json(Map(
      "error" -> s"page not found: ${request.uri}"
    ))
  }
}

That passes my tests, and seems to get the behaviour I want – the same 404 response on all requests, whether or not it’s an endpoint used elsewhere.

Because Finatra resolves routes in the order they’re added (so earlier routes have priority), this has to be the last route that’s declared – it masks everything that comes after it.

I’ve put a runnable example on GitHub.