かとじゅんの技術日誌

技術の話をするところ

akka-httpにswagger2.xを組み込む方法

akka-httpswagger2.xを組み込む方法を以下に示します。

※DIコンテナであるAirframeを使っていますが、もちろん必須ではありません。適宜読み替えてくだだい。

ライブラリの依存関係

swagger-akka-httpを追加します。javax.ws.rs-apiはアノテーションを利用するために追加します。akka-http-corsは必要に応じて追加してください。

libraryDependencies ++= Seq(
"com.typesafe.akka"            %% "akka-http"         % "10.1.5",
"com.github.swagger-akka-http" %% "swagger-akka-http" % "2.0.0"
"javax.ws.rs"                  % "javax.ws.rs-api"    % "2.0.1"
"ch.megard"                    %% "akka-http-cors"    % "0.3.0"
// ...
)

コントローラの例

コントローラに相当するクラスにアクションを作って、そのメソッドにアノテーションを割り当てます。別にコントローラを定義しなくとも、エンドポイントごとにRoute定義が分かれていて、アノテーションが付与できればよいです。

アノテーションの使い方は、Swagger 2.X Annotationsを読んでください。

package spetstore.interface.api.controller

import java.time.ZonedDateTime

import akka.http.scaladsl.server.{Directives, Route}
import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport._
import io.circe.generic.auto._
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.media.{Content, Schema}
import io.swagger.v3.oas.annotations.parameters.RequestBody
import io.swagger.v3.oas.annotations.responses.ApiResponse
import javax.ws.rs._
import monix.eval.Task
import monix.execution.Scheduler
import org.hashids.Hashids
import org.sisioh.baseunits.scala.money.Money
import spetstore.domain.model.basic.StatusType
import spetstore.domain.model.item._
import spetstore.interface.api.model.{CreateItemRequest, CreateItemResponse, CreateItemResponseBody}
import spetstore.interface.generator.jdbc.ItemIdGeneratorOnJDBC
import spetstore.interface.repository.ItemRepository
import wvlet.airframe._

import scala.concurrent.Future

@Path("/items")
@Consumes(Array("application/json"))
@Produces(Array("application/json"))
trait ItemController extends Directives {

  private val itemRepository: ItemRepository[Task]         = bind[ItemRepository[Task]]

  private val itemIdGeneratorOnJDBC: ItemIdGeneratorOnJDBC = bind[ItemIdGeneratorOnJDBC]

  private val hashids = bind[Hashids]

  def route: Route = create

  private def convertToAggregate(id: ItemId, request: CreateItemRequest): Item = Item(
    id = id,
    status = StatusType.Active,
    name = ItemName(request.name),
    description = request.description.map(ItemDescription),
    categories = Categories(request.categories),
    price = Price(Money.yens(request.price)),
    createdAt = ZonedDateTime.now(),
    updatedAt = None
  )

  @POST
  @Operation(
    summary = "Create item",
    description = "Create Item",
    requestBody =
      new RequestBody(content = Array(new Content(schema = new Schema(implementation = classOf[CreateItemRequest])))),
    responses = Array(
      new ApiResponse(responseCode = "200",
                      description = "Create response",
                      content = Array(new Content(schema = new Schema(implementation = classOf[CreateItemResponse])))),
      new ApiResponse(responseCode = "500", description = "Internal server error")
    )
  )
  def create: Route = path("items") {
    post {
      extractActorSystem { implicit system =>
        implicit val scheduler: Scheduler = Scheduler(system.dispatcher)
        entity(as[CreateItemRequest]) { request =>
          val future: Future[CreateItemResponse] = (for {
            itemId <- itemIdGeneratorOnJDBC.generateId()
            _      <- itemRepository.store(convertToAggregate(itemId, request))
          } yield CreateItemResponse(Right(CreateItemResponseBody(hashids.encode(itemId.value))))).runAsync
          onSuccess(future) { result =>
            complete(result)
          }
        }
      }
    }
  }

  // ...

}

swagger-ui

swagger-uidistsrc/main/resource/swaggerとしてコピーしてください。

SwaggerHttpService

次にSwaggerHttpServiceの実装を用意します。

package spetstore.interface.api

import com.github.swagger.akka.SwaggerHttpService
import com.github.swagger.akka.model.Info

class SwaggerDocService(hostName: String, port: Int, val apiClasses: Set[Class[_]]) extends SwaggerHttpService {
  override val host                = s"127.0.0.1:$port" //the url of your api, not swagger's json endpoint
  override val apiDocsPath         = "api-docs" //where you want the swagger-json endpoint exposed
  override val info                = Info() //provides license and other description details
  override val unwantedDefinitions = Seq("Function1", "Function1RequestContextFutureRouteResult")
}

routeの設定

akka-httpのRouteは以下を参考にしてください。SwaggerDocServiceとコントローラをrouteに加えます。また、CORSが必要なら、"ch.megard" %% "akka-http-cors" % "0.3.0" を使うとよいと思います。

package spetstore.interface.api

import akka.http.scaladsl.model.{ ContentTypes, HttpEntity, HttpResponse }
import akka.http.scaladsl.server.{ Directives, Route, StandardRoute }
import wvlet.airframe._
import ch.megard.akka.http.cors.scaladsl.CorsDirectives._
import spetstore.interface.api.controller.ItemController

trait Routes extends Directives {

  private lazy val itemController    = bind[ItemController]
  private lazy val swaggerDocService = bind[SwaggerDocService]

  private def index(): StandardRoute = complete(
    HttpResponse(
      entity = HttpEntity(
        ContentTypes.`text/plain(UTF-8)`,
        "Wellcome to API"
      )
    )
  )

  def routes: Route = cors() {
    pathEndOrSingleSlash {
      index()
    } ~ path("swagger") {
      getFromResource("swagger/index.html")
    } ~ getFromResourceDirectory("swagger") ~
    swaggerDocService.routes ~ itemController.route
  }

}

ブートストラップ

akka-httpの起動部分のコードです。

package spetstore.interface.api

import akka.actor.ActorSystem
import akka.http.scaladsl.Http
import akka.http.scaladsl.Http.ServerBinding
import akka.http.scaladsl.settings.ServerSettings
import akka.stream.ActorMaterializer
import wvlet.airframe._

import scala.concurrent.Future
import scala.util.{ Failure, Success }

trait ApiServer {

  implicit val system           = bind[ActorSystem]
  implicit val materializer     = ActorMaterializer()
  implicit val executionContext = system.dispatcher

  private val routes = bind[Routes].routes

  def start(host: String, port: Int, settings: ServerSettings): Future[ServerBinding] = {
    val bindingFuture = Http().bindAndHandle(handler = routes, interface = host, port = port, settings = settings)
    bindingFuture.onComplete {
      case Success(binding) =>
        system.log.info(s"Server online at http://${binding.localAddress.getHostName}:${binding.localAddress.getPort}/")
      case Failure(ex) =>
        system.log.error(ex, "occurred error")
    }
    sys.addShutdownHook {
      bindingFuture
        .flatMap(_.unbind())
        .onComplete { _ =>
          materializer.shutdown()
          system.terminate()
        }
    }
    bindingFuture
  }

}

アプリケーションのブートストラップ部分です。

package spetstore.api

import akka.actor.ActorSystem
import akka.http.scaladsl.settings.ServerSettings
import monix.eval.Task
import org.hashids.Hashids
import slick.basic.DatabaseConfig
import slick.jdbc.JdbcProfile
import spetstore.domain.model.item.ItemId
import spetstore.interface.api.controller.ItemController
import spetstore.interface.api.{ApiServer, Routes, SwaggerDocService}
import spetstore.interface.generator.IdGenerator
import spetstore.interface.generator.jdbc.ItemIdGeneratorOnJDBC
import spetstore.interface.repository.{ItemRepository, ItemRepositoryBySlick}
import wvlet.airframe._

/**
  * http://127.0.0.1:8080/swagger
  */
object Main {

  def main(args: Array[String]): Unit = {
    val parser = new scopt.OptionParser[AppConfig]("spetstore") {
      opt[String]('h', "host").action((x, c) => c.copy(host = x)).text("host")
      opt[Int]('p', "port").action((x, c) => c.copy(port = x)).text("port")
    }
    val system = ActorSystem("spetstore")
    val dbConfig: DatabaseConfig[JdbcProfile] =
      DatabaseConfig.forConfig[JdbcProfile](path = "spetstore.interface.storage.jdbc", system.settings.config)

    parser.parse(args, AppConfig()) match {
      case Some(config) =>
        val design = newDesign
          .bind[Hashids].toInstance(new Hashids(system.settings.config.getString("spetstore.interface.hashids.salt")))
          .bind[ActorSystem].toInstance(system)
          .bind[JdbcProfile].toInstance(dbConfig.profile)
          .bind[JdbcProfile#Backend#Database].toInstance(dbConfig.db)
          .bind[Routes].toSingleton
          .bind[SwaggerDocService].toInstance(
          new SwaggerDocService(config.host, config.port, Set(classOf[ItemController]))
        )
          .bind[ApiServer].toSingleton
          .bind[ItemRepository[Task]].to[ItemRepositoryBySlick]
          .bind[IdGenerator[ItemId]].to[ItemIdGeneratorOnJDBC]
          .bind[ItemController].toSingleton
        design.withSession { session =>
          val system = session.build[ActorSystem]
          session.build[ApiServer].start(config.host, config.port, settings = ServerSettings(system))
        }
      case None =>
        println(parser.usage)
    }
  }
}

まとめ

最低限、考慮すべきこととしては、akka-httpのroute dslはエンドポイントごとに分割してswaggerアノテーションを割り当てれるようにしてください。これ以外はswaggerの一般的な使い方と変わりません。