I have not been using JAX-RS and Servlet in a while. We are currently implementing most of our REST API’s on top of Finagle, a RPC System created in the Twitter software forge that runs on Netty. While it is possible to use Finagle directly together with Scala path matching for the routes, I could not find a clever way for self-updating documentation close to the code. Fortunately there is another Twitter project called Finatra, which puts a Sinatra-Flask alike web framework on top of Finagle. Finatra will not only make it easier to define Resources and Routes but also help you with the documentation.
Here is a how you typically define a route in Finatra:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
object ExampleRestApp { | |
class RestController(statsReceiver: StatsReceiver = NullStatsReceiver) extends Controller(statsReceiver) { | |
get("/user/:userId") { request => | |
val userId: Long = request.routeParams.getOrElse("userId", "0").toLong | |
val user = someService.lookupUser(userId) | |
if (user == null) { | |
render.notFound.plain("").toFuture | |
} else { | |
render.body(user).header("Content-Type", "application/json").toFuture | |
} | |
} | |
} | |
} |
For the documentation itself I am using Swagger, which can generate HTML from annotations. Swagger already comes with a bunch of useful annotations. Unfortunately some annotations like a @Path equivalent was missing, so I was forced to use some JSR-311 (JAX-RS) instead, even though we are not using JAX-RS for the API. Here is the evolution of the Finatra controller from above with the Swagger and JSR-311 annotations added. As you can see it was necessary to move the routes from the constructor into separate methods that can be annotated. This makes the Scala code a bit uglier and harder to read, especially if you have a lot of annotations in place. But hey, you will love the outcome.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
object ExampleRestApp { | |
@Api(value = "/", description = "Docs for our example controller") | |
@Path("/") | |
class RestController(statsReceiver: StatsReceiver = NullStatsReceiver) extends Controller(statsReceiver) { | |
get_user_id() | |
@ApiOperation(value = "Returns a user in json format.", notes = "Will only return a subset of the attributes.", responseClass = "java.lang.String", httpMethod = "GET") | |
@Path("user/{userId}") | |
@ApiErrors(Array(new ApiError(code = 404, reason = "No such user"))) | |
def get_user_id(@ApiParam(name = "UserId", value = "A valid user id", required = true) env: Long = 0L) { | |
get("/user/:userId") { request => | |
val userId: Long = request.routeParams.getOrElse("userId", "0").toLong | |
val user = someService.lookupUser(userId) | |
if (user == null) { | |
render.notFound.plain("").toFuture | |
} else { | |
render.body(user).header("Content-Type", "application/json").toFuture | |
} | |
} | |
} | |
} | |
} |
The final step is to generate the documentation during our Maven build. We are using the maven-swagger-plugin for that. I even copied and customized the strapdown.html.mustache from the plugin into our project, so that we could tweak the generated documentation and use another Twitter Bootstrap theme instead.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?xml version="1.0" encoding="UTF-8"?> | |
<project xmlns="http://maven.apache.org/POM/4.0.0" | |
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> | |
<modelVersion>4.0.0</modelVersion> | |
<dependencies> | |
// not all shown | |
<dependency> | |
<groupId>com.wordnik</groupId> | |
<artifactId>swagger-annotations_2.10.0</artifactId> | |
<version>1.2.4</version> | |
<scope>compile</scope> | |
</dependency> | |
<dependency> | |
<groupId>javax.ws.rs</groupId> | |
<artifactId>jsr311-api</artifactId> | |
<version>1.1.1</version> | |
<scope>provided</scope> | |
</dependency> | |
</dependencies> | |
<build> | |
<plugins> | |
// not all shown | |
<plugin> | |
<groupId>com.github.kongchen</groupId> | |
<artifactId>swagger-maven-plugin</artifactId> | |
<version>1.1-SNAPSHOT</version> | |
<configuration> | |
<apiSources> | |
<apiSource> | |
<locations>your.scala.package.here;</locations> | |
<apiVersion>${project.version}</apiVersion> | |
<basePath>http://autoreplaced.com</basePath> | |
<outputTemplate>${basedir}/doc/modified-strapdown.html.mustache</outputTemplate> | |
<outputPath>${project.build.outputDirectory}/docs.html</outputPath> | |
<withFormatSuffix>false</withFormatSuffix> | |
</apiSource> | |
</apiSources> | |
</configuration> | |
<executions> | |
<execution> | |
<phase>compile</phase> | |
<goals> | |
<goal>generate</goal> | |
</goals> | |
</execution> | |
</executions> | |
</plugin> | |
</plugins> | |
</build> | |
</project> |
The outcome will be a generated docs.html file in the target folder of your build. The docs.html will contain autoreplaced.com as path - which was specified in the maven-swagger-plugin. I normally replace “autoreplaced.com” with JavaScript (something that can easily be done if you use your own Mustache template). Also it is nice to have Finatra render the docs.html file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
object ExampleRestApp { | |
@Api(value = "/", description = "Docs for our example controller") | |
@Path("/") | |
class RestController(statsReceiver: StatsReceiver = NullStatsReceiver) extends Controller(statsReceiver) { | |
get("/docs") { request => | |
val content = Source.fromURL(getClass.getResource("/docs.html")).mkString | |
render.html(content).toFuture | |
} | |
get_user_id() | |
@ApiOperation(value = "Returns a user in json format.", notes = "Will only return a subset of the attributes.", responseClass = "java.lang.String", httpMethod = "GET") | |
@Path("user/{userId}") | |
@ApiErrors(Array(new ApiError(code = 404, reason = "No such user"))) | |
def get_user_id(@ApiParam(name = "UserId", value = "A valid user id", required = true) env: Long = 0L) { | |
get("/user/:userId") { request => | |
val userId: Long = request.routeParams.getOrElse("userId", "0").toLong | |
val user = someService.lookupUser(userId) | |
if (user == null) { | |
render.notFound.plain("").toFuture | |
} else { | |
render.body(user).header("Content-Type", "application/json").toFuture | |
} | |
} | |
} | |
} | |
} |