Adding OpenAPI Specs to Finatra
OpenAPI (A.K.A. Swagger) is an industry standard way to describe HTTP API’s, this can in turn be used to generate documentation, clients to consume the API in many languages and even stub implementations of the server in many languages. The advisability of the last point depends on your view of the Specification driving the Implemetation vs the Implementation driving the Specification.
It is easier to change the specification to fit the program than vice versa.
Alan Perlis
For my part defining your implementation in YAML seems suboptimal and would much rather have my implementation drive my specification. So my interest in OpenAPI is driven by the potential of having a self-documenting API, with the ability for consumers to generate clients in there language of choice.
Swagger + Finatra
Finatra is a Scala services framework built a top Finagle, both built by Twitter. Unfortunately there is not anything built in to support OpenAPI. Luckily Swagger has some excellent Java libraries that provide support for OpenAPI using JAX-RS Annotations and there is a good example of how leverage that in a Scala, with Akka in Swagger Akka HTTP
Let’s start by adding the required dependencies to our build.sbt
libraryDependencies ++= Seq(
...
"com.github.swagger-akka-http" %% "swagger-scala-module" % "2.5.2",
"io.swagger.core.v3" % "swagger-core-jakarta" % swaggerVersion,
"io.swagger.core.v3" % "swagger-annotations-jakarta" % swaggerVersion,
"io.swagger.core.v3" % "swagger-models-jakarta" % swaggerVersion,
"io.swagger.core.v3" % "swagger-jaxrs2-jakarta" % swaggerVersion,
"com.fasterxml.jackson.module" %% "jackson-module-scala" % jacksonVersion,
"com.fasterxml.jackson.dataformat" % "jackson-dataformat-yaml" % jacksonVersion,
...
)
Next let’s create a service class that will read the annotations and generate our OpenAPI
spec.
import io.swagger.v3.jaxrs2.Reader
import io.swagger.v3.oas.integration.SwaggerConfiguration
import io.swagger.v3.oas.models.security.{SecurityRequirement, SecurityScheme}
import io.swagger.v3.oas.models.servers.Server
import io.swagger.v3.oas.models.{Components, ExternalDocumentation, OpenAPI}
import scala.collection.JavaConverters._
import scala.collection.mutable.{ListBuffer, Map => MutableMap}
import scala.util.control.NonFatal
class OpenAPIService(
apiClasses: Set[Class[_]],
host: String = "",
basePath: String = "",
info: Info = Info(),
components: Option[Components] = None,
schemes: List[String] = List("http"),
security: List[SecurityRequirement] = List(),
securitySchemes: Map[String, SecurityScheme] = Map.empty,
externalDocs: Option[ExternalDocumentation] = None,
vendorExtensions: Map[String, Object] = Map.empty,
unwantedDefinitions: Seq[String] = Seq.empty
) {
private val readerConfig = new SwaggerConfiguration()
lazy val openAPI: OpenAPI = filteredOpenAPI
private def removeInitialSlashIfNecessary(path: String): String =
if (path.startsWith("/")) removeInitialSlashIfNecessary(path.substring(1))
else path
private def prependSlashIfNecessary(path: String): String =
if (path.startsWith("/")) path else s"/$path"
private def swaggerConfig: OpenAPI = {
val swagger = new OpenAPI()
swagger.setInfo(info)
components.foreach { c =>
swagger.setComponents(c)
}
val path = removeInitialSlashIfNecessary(basePath)
val hostPath = if (!path.isEmpty()) {
s"${host}/${path}/"
} else {
host
}
schemes.foreach { scheme =>
swagger.addServersItem(
new Server().url(s"${scheme.toLowerCase}://$hostPath")
)
}
if (schemes.isEmpty && !hostPath.isEmpty()) {
swagger.addServersItem(new Server().url(hostPath))
}
securitySchemes.foreach {
case (k: String, v: SecurityScheme) => swagger.schemaRequirement(k, v)
}
swagger.setSecurity(asJavaMutableList(security))
swagger.extensions(asJavaMutableMap(vendorExtensions))
externalDocs.foreach { ed =>
swagger.setExternalDocs(ed)
}
swagger
}
private def reader = new Reader(readerConfig.openAPI(swaggerConfig))
private def asJavaMutableList[T](list: List[T]) = {
(new ListBuffer[T] ++ list).asJava
}
private def asJavaMutableMap[K, V](map: Map[K, V]) = {
(MutableMap.empty[K, V] ++ map).asJava
}
private def filteredOpenAPI: OpenAPI = {
val swagger: OpenAPI = reader.read(apiClasses.asJava)
if (!unwantedDefinitions.isEmpty) {
val filteredSchemas = asJavaMutableMap(
asScala(swagger.getComponents.getSchemas)
.filterKeys(
definitionName => !unwantedDefinitions.contains(definitionName)
)
.toMap
)
swagger.getComponents.setSchemas(filteredSchemas)
}
swagger
}
}
And then a Controller
to serve this file:
import com.google.inject.{Inject, Singleton}
import com.twitter.finatra.http.{Controller}
import com.twitter.finagle.http.Request
@Singleton
class OpenAPIController @Inject()(openAPIService: OpenAPIService, path: String) extends Controller {
get(path) { rq: Request =>
openAPIService.openAPI
}
}
Now let’s bring all together and register our controller with our App:
class PetHTTPServer extends HttpServer with Logging {
...
override def configureHttp(router: HttpRouter): Unit = {
router
...
.add(buildOpenAPI())
...
}
...
private def buildOpenAPI() = {
new OpenAPIController(
new OpenAPIService(
apiClasses = Set(
// Set of Controllers to add to our OpenAPI spec
classOf[PetsController],
),
info = Info(
title = "Pet Store API Documentation",
description = "Progamatically manage a your Pet Store.",
)
),
"/openapi"
)
}
...
}
And that is it, your Finatra API can now describe itself as an OpenAPI spec. Let’s see what that looks like in practice.
case class Pet(id: Option[UUID], name: String, species: String, breed: String)
case class GetPetRQ(
id: UUID
)
case class UpdatePetRQ(id: UUID, name: String, species: String, breed: String) {
def toPet = ???
}
@Path("/pets")
class PetsController extends Controller {
private var pets = Map.empty[UUID, Pet]
createPet()
getPet()
updatePet()
deletePet()
@POST
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
@Operation(
summary = "Create a pet",
description = "Create a pet",
tags = Array("pets"),
requestBody = new RequestBody(
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[Pet])))
),
responses = Array(
new ApiResponse(
content = Array(new Content(schema = new Schema(implementation = classOf[Pet])))
),
new ApiResponse(
responseCode = "400",
description = "Invalid ID supplied"
),
new ApiResponse(responseCode = "404", description = "Pet not found"),
new ApiResponse(
responseCode = "405",
description = "Validation exception"
)
)
)
def createPet() = {
post("/") { rq: Pet =>
val pet = rq.copy(id = Some(UUID.randomUUID()))
pets += (pet.id.get -> pet)
return pet
}
}
@GET
@Path("{id}")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
@Operation(
summary = "Get a pet",
tags = Array("pets"),
parameters = Array(
new Parameter(name = "id", in = ParameterIn.PATH, description = "pet id")
),
responses = Array(
new ApiResponse(
content = Array(new Content(schema = new Schema(implementation = classOf[Pet])))
),
new ApiResponse(
responseCode = "400",
description = "Invalid ID supplied"
),
new ApiResponse(responseCode = "404", description = "Pet not found"),
new ApiResponse(
responseCode = "405",
description = "Validation exception"
)
)
)
def getPet() = {
get("/:id") { rq: GetPetRQ
pets.get(id).get
}
}
@PUT
@Path("{id}")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
@Operation(
summary = "Update a pet",
tags = Array("pets"),
parameters = Array(
new Parameter(name = "id", in = ParameterIn.PATH, description = "pet id")
),
requestBody = new RequestBody(
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[Pet])))
),
responses = Array(
new ApiResponse(
content = Array(new Content(schema = new Schema(implementation = classOf[Pet])))
),
new ApiResponse(
responseCode = "400",
description = "Invalid ID supplied"
),
new ApiResponse(responseCode = "404", description = "Pet not found"),
new ApiResponse(
responseCode = "405",
description = "Validation exception"
)
)
)
def updatePet() = {
put("/:id") { rq: UpdatePetRQ =>
val pet = rq.toPet
pets += (pet.id.get -> pet)
return pet
}
}
@DELETE
@Path("{id}")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
@Operation(
summary = "Create a pet",
tags = Array("pets"),
parameters = Array(
new Parameter(name = "id", in = ParameterIn.PATH, description = "pet id")
),
responses = Array(
new ApiResponse(
responseCode = "200",
description = "Pet has been deleted"
),
new ApiResponse(
responseCode = "400",
description = "Invalid ID supplied"
),
new ApiResponse(responseCode = "404", description = "Pet not found"),
new ApiResponse(
responseCode = "405",
description = "Validation exception"
)
)
)
def deletePet() = {
delete("/") { rq: PetIdRQ =>
pets = pets.removed(id)
}
}
}
Okay that can work but it is kind of hard to read, the majority of the class is spent adding annotations describing the endpoint and what it does is lost in the sea of metadata. Let’s pull all of that metadata out into a Trait
so we can separate what it does from the description of what it needs.
case class Pet(id: Option[UUID], name: String, species: String, breed: String)
case class GetPetRQ(
id: UUID
)
case class UpdatePetRQ(id: UUID, name: String, species: String, breed: String) {
def toPet = ???
}
@Path("/pets")
trait PetsControllerSpec {
@POST
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
@Operation(
summary = "Create a pet",
description = "Create a pet",
tags = Array("pets"),
requestBody = new RequestBody(
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[Pet])))
),
responses = Array(
new ApiResponse(
content = Array(new Content(schema = new Schema(implementation = classOf[Pet])))
),
new ApiResponse(
responseCode = "400",
description = "Invalid ID supplied"
),
new ApiResponse(responseCode = "404", description = "Pet not found"),
new ApiResponse(
responseCode = "405",
description = "Validation exception"
)
)
)
def createPet()
@GET
@Path("{id}")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
@Operation(
summary = "Get a pet",
tags = Array("pets"),
parameters = Array(
new Parameter(name = "id", in = ParameterIn.PATH, description = "pet id")
),
responses = Array(
new ApiResponse(
content = Array(new Content(schema = new Schema(implementation = classOf[Pet])))
),
new ApiResponse(
responseCode = "400",
description = "Invalid ID supplied"
),
new ApiResponse(responseCode = "404", description = "Pet not found"),
new ApiResponse(
responseCode = "405",
description = "Validation exception"
)
)
)
def getPet()
@PUT
@Path("{id}")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
@Operation(
summary = "Update a pet",
tags = Array("pets"),
parameters = Array(
new Parameter(name = "id", in = ParameterIn.PATH, description = "pet id")
),
requestBody = new RequestBody(
required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[Pet])))
),
responses = Array(
new ApiResponse(
content = Array(new Content(schema = new Schema(implementation = classOf[Pet])))
),
new ApiResponse(
responseCode = "400",
description = "Invalid ID supplied"
),
new ApiResponse(responseCode = "404", description = "Pet not found"),
new ApiResponse(
responseCode = "405",
description = "Validation exception"
)
)
)
def updatePet()
@DELETE
@Path("{id}")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
@Operation(
summary = "Create a pet",
tags = Array("pets"),
parameters = Array(
new Parameter(name = "id", in = ParameterIn.PATH, description = "pet id")
),
responses = Array(
new ApiResponse(
responseCode = "200",
description = "Pet has been deleted"
),
new ApiResponse(
responseCode = "400",
description = "Invalid ID supplied"
),
new ApiResponse(responseCode = "404", description = "Pet not found"),
new ApiResponse(
responseCode = "405",
description = "Validation exception"
)
)
)
def deletePet()
}
class PetsController extends Controller with PetsControllerSpec {
private var pets = Map.empty[UUID, Pet]
createPet()
getPet()
updatePet()
deletePet()
override def createPet() = {
post("/") { rq: Pet =>
val pet = rq.copy(id = Some(UUID.randomUUID()))
pets += (pet.id.get -> pet)
return pet
}
}
override def getPet() = {
get("/:id") { rq: GetPetRQ
pets.get(id).get
}
}
override def updatePet() = {
put("/:id") { rq: UpdatePetRQ =>
val pet = rq.toPet
pets += (pet.id.get -> pet)
return pet
}
}
override def deletePet() = {
delete("/") { rq: PetIdRQ =>
pets = pets.removed(id)
}
}
}
Much better, we can now clearly see our business logic while still having our specification in the same file. Driven off the code that actually implements it.