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.