⚙️

Play Framework + tapirで型安全なエンドポイントを定義

2024/09/20に公開

概要

tapirはエンドポイントとビジネスロジックを分離して定義することができるScalaのライブラリで、型安全なAPIを簡潔に構築することができます。
主に以下のようなメリットがあります。

  • Scalaのコードでエンドポイントの定義ができるためコードの共通化・再利用がしやすい
  • エンドポイントの型を定義することによりAPIの仕様を明確にできる
  • エンドポイントの型からSwaggerドキュメントを生成する事により、APIのドキュメント化が容易にできる

今回はPlay Frameworkとtapirを使って、エンドポイントの定義と簡単な認証処理を実装をしてみます。

環境設定

  • Scala 3.3.3
  • Play Framework 3.0.5
  • Java 11
  • tapir 1.11.1

サンプルコード

今回紹介するコードはこちらのGitHubから確認することができます。
※ 記事執筆後にコードを変更したため、GitHub上のコードと内容が異なる場合があります。

簡単な実装

まずは簡単な例を見ていきましょう。
ここではGET /api/todo/1でTodoの情報を受取る事ができるエンドポイントを作成していきます。
※ 必要そうなところだけ解説を加えています。

ライブラリのインポート

tapir-coreに加えて、Play Frameworkとデフォルトのjson型を使うために、tapir-play-servertapir-json-playをインポートします。

build.sbt
  "com.softwaremill.sttp.tapir" %% "tapir-core" % tapirVersion,
  "com.softwaremill.sttp.tapir" %% "tapir-play-server" % tapirVersion,
  "com.softwaremill.sttp.tapir" %% "tapir-json-play" % tapirVersion,

エンドポイント: 実装

はじめにエンドポイントのURLやパラメータ返り値の型などを定義していきます。

val findTodoEndpoint: PublicEndpoint[Long, JsValueNotFound, JsValueTodo, Any] =
  endpoint
    .get
    .in("api" / "todo" / path[Long]("id"))
    .out(jsonBody[JsValueTodo])
    .errorOut(jsonBody[JsValueNotFound])

エンドポイント: 解説

tapirではEndpoint[SECURITY_INPUT, INPUT, ERROR_OUTPUT, OUTPUT, R]の型を使って、エンドポイントの定義を行います。
それぞれの型の意味は以下のとおりです。

  • SECURITY_INPUT: 認証機能を実装する際に必要なパラメータの型
  • INPUT: クエリパラメータやjsonなど、クライアントから送信されたデータの型
  • ERROR_OUTPUT: エラー時に返す値の型
  • OUTPUT: 成功時に返す値の型
  • R: websocketなどのストリーミングを使用する際のパラメータ

認証機能などがない場合は、PublicEndpointを使用することができます。
上のコード例でもこちらの型を使いました。

type PublicEndpoint[I, E, O, -R] = Endpoint[Unit, I, E, O, R]

そして、このEndpointに対して、エンドポイントのpathやリクエストの属性(GET, POST, PUT, DELETE)、返り値の型やエラーが発生したときに返す型などを定義することができます。

Endpoints.scala
endpoint
  // GETでのアクセス
  .get
  // "api/todo/:id"というパス
  .in("api" / "todo" / path[Long]("id"))
  // 成功時にはJsValueTodoを返す
  .out(jsonBody[JsValueTodo])
  // 失敗時にはJsValueNotFoundを返す
  .errorOut(jsonBody[JsValueNotFound])

ロジックの実装

次にコントローラーの実装をします。
こちらの解説は省略します。

TodoController.scala
class TodoController @Inject() (
    val controllerComponents: ControllerComponents,
    todoQueryService:         TodoQueryService,
)(using ExecutionContext)
    extends BaseController:

  def get(id: Long): Future[Either[writes.JsValueNotFound, writes.JsValueTodo]] =
    for todoResult <- todoQueryService.get(id)
    yield todoResult
      .map(todo => writes.JsValueTodo(todo))
      .left
      .map(error => writes.JsValueNotFound(message = error.resource))

エンドポイントとロジックの紐づけ

Endpointに定義されているserverLogicなどのメソッドを使うことで、ここまでで定義をしたエンドポイントとビジネスロジックの紐づけを行うことができます。

Endpoints.scala
val endpoints: List[ServerEndpoint[Any, Future]] = List(
    findTodoEndpoint
      .serverLogic((id: Long) => todoController.get(id)),
  )

serverLogic以外にも、常に成功となる場合や常にエラーを返すエンドポイントを表現するためのメソッドが提供されています。
参考: https://tapir.softwaremill.com/en/latest/server/logic.html

サーバーとPlayServerInterpreter: 実装

最後にPlay Frameworkサーバーの設定と上で定義したエンドポイント+ロジックをPlay Frameworkのルートに変換する処理を実装していきます。

ApiRouter.scala
class ApiRouter @Inject() (
    todoEndpoints: Endpoints
)(using
    Materializer,
    ExecutionContext
) extends SimpleRouter:

  override def routes: Routes = {
    todoRoute
  }

  private val playServerOptions = PlayServerOptions.default
  private val interpreter = PlayServerInterpreter(playServerOptions)

  private val todoRoute = interpreter.toRoutes(todoEndpoints.endpoints)

サーバーとPlayServerInterpreter: 解説

override def routes: Routes = {
  todoRoute
}

Play FrameworkのRouterとtapirを統合するため、Play FrameworkのSIRD(Scala Interpolated Routing DSL)を使います。
以下のように文字列補間を使ってルーティングを表現することができます。

val router = Router.from {
  case GET(p"/hello/$to") =>
    Action {
      Results.Ok(s"Hello $to")
    }
}

参考: https://www.playframework.com/documentation/2.8.x/ScalaSirdRouter#Binding-sird-Router

private val playServerOptions = PlayServerOptions.default
private val interpreter = PlayServerInterpreter(playServerOptions)

ここでPlayServerの設定とInterpreterの設定をしています。

コード例ではデフォルトの設定にしていますが、PlayServerOptionsではさまざまな設定をすることができます。

例えば、例外が発生した際に特定のjsonレスポンスを返す設定は以下のように行います。

ApiRouter.scala
  private val exceptionHandler: ExceptionHandler[Future] =
    ExceptionHandler.pure[Future] { ctx =>
      Some(
        ValuedEndpointOutput[JsValueInternalServerError](jsonBody[JsValueInternalServerError],
        JsValueInternalServerError(s"Internal Server Error: ${ctx.e}"))
      )
    }

  private val commonPlayServerOption: PlayServerOptions = PlayServerOptions
    .customiseInterceptors()
    .exceptionHandler(exceptionHandler)
    .options

  private val interpreter = PlayServerInterpreter(commonPlayServerOption)
  private val todoRoute = interpreter.toRoutes(todoEndpoints.endpoints)

最後にInterpreterを使って、Play Frameworkで使用できるルートに変換をします。

それでは実際にアクセスして動きを確認してみましょう。

データベースの初期値では以下のデータを登録しています。

02_insert_data.sql
INSERT INTO `todo` (`title`, `description`, `is_done`) VALUES
('家事', '家の掃除', FALSE),
('買い物', '食料品の買い出し', FALSE)
;

curlを使ってアクセスしてみましょう。

curl \
localhost:9000/api/todo/1

# {"id":1,"title":"家事","description":"家の掃除","isDone":false}

ちゃんとデータが取得できていることが確認できます。

ここまでの実装でGET /api/todo/1からTodoの情報を受取る事ができるエンドポイントを定義することができました。
記述量は少し多くなっていますが、内部ロジックを知らなくてもエンドポイント定義を見るだけで、どのような振る舞いをするのかが一目で分かるようになっています。

Swagger UIでエンドポイント定義の確認

tapirでは簡単にOpenAPIのドキュメントを生成することができます。
今回はSwagger UIを使ってエンドポイントの定義を確認していきたいと思います。

まず必要なライブラリをインポートします。

build.sbt
  "com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle" % tapirVersion,
  "com.softwaremill.sttp.tapir" %% "tapir-swagger-ui" % tapirVersion,

次にSwagger用のエンドポイントの定義します。

ApiRouter.scala
  private val swaggerEndpoints = SwaggerInterpreter()
    .fromServerEndpoints[Future](todoEndpoints.endpoints, "play-tapir-sandbox", "1.0")
  private val swaggerRoute = interpreter.toRoutes(swaggerEndpoints)

そして、Routesに追加することによって、Swagger UIを確認することができます(デフォルトのパスは/docsになっています)。

ApiRouter.scala
  override def routes: Routes = {
    swaggerRoute
      .orElse(todoRoute)
  }

tagやsummary、descriptionなども簡単に定義ができます。

ApiRouter.scala
  private val findTodoEndpoint: SecureEndpointsType.SecureEndpoint[Long, writes.JsValueError, writes.JsValueTodo] =
    baseEndpoint.get
      .in(path[Long]("id"))
      .out(jsonBody[writes.JsValueTodo])
      .errorOutVariantPrepend(
        oneOfVariant(statusCode(StatusCode.NotFound).and(jsonBody[writes.JsValueNotFound]))
      )
      .tag("Todo")
      .summary("Todoの取得")
      .description("""
          |idを用いてTodoの取得を行う。
          |Todoが存在しない場合は404が返される。
         |""".stripMargin)

簡単な認証機能の実装

tapirではいくつかの認証方式に対応するための機能が組み込まれていて、APIキー、ベーシック認証、Bearerトークンなどの一般的な認証を実装することができます。
参考: https://tapir.softwaremill.com/en/latest/endpoint/security.html

今回はBearerトークンを利用した認証を実装してみたいと思います。
※ ここからはコメントで必要な部分の解説をします。

Bearerを利用して認証機能を実装する

SecureEndpoint.scala
class SecureEndpoints @Inject() (
    authenticationController: AuthenticationController
):

  private val secureEndpointWithBearer: Endpoint[String, Unit, JsValueError, Unit, Any] =
    endpoint
      .securityIn(auth.bearer[String]())
      .errorOut(
        // `oneOf`と`oneOfVariant`を使うことによって、異なるレスポンスタイプを返せるようにしています。
        oneOf[JsValueError](
          // `oneOfDefaultVariant`は`oneOf`を使う際にデフォルトのエラーレスポンスを指定する際に使います。
          oneOfDefaultVariant(statusCode(StatusCode.InternalServerError).and(jsonBody[JsValueInternalServerError])),
          // 認証が失敗した場合のエラーレスポンスを定義しています。
          oneOfVariant(statusCode(StatusCode.Unauthorized).and(jsonBody[JsValueAuthenticationFailed]))
        )
      )

  // `PartialServerEndpoint`はセキュリティ部分とビジネスロジックを分離して定義することができる型です。
  // 詳しくは下記ドキュメントを参照ください。
  // https://tapir.softwaremill.com/en/latest/server/logic.html#re-usable-security-logic
  val authenticationWithBearerEndpoint: PartialServerEndpoint[String, UserContext, Unit, JsValueError, Unit, Any, Future] =
    secureEndpointWithBearer
      // エンドポイントと認証のロジックを紐づける際に、`.serverSecurityLogic`を使うことによって、後続のビジネスロジックを追加できるようにしています。
      .serverSecurityLogic(authenticationController.authenticateWithBearer)

認証の処理を以下のように定義します。

AuthenticationController.scala
class AuthenticationController @Inject() ():

  def authenticateWithBearer(bearer: String): Future[Either[JsValueAuthenticationFailed, UserContext]] =
    Future.successful {
      if (bearer == "hoge") Right(UserContext(1, "John"))
      else Left(JsValueAuthenticationFailed("Authentication Failed"))
    }

ビジネスロジックに認証の処理を加える

Todoを取得するエンドポイントに認証処理を加えていきます。

Endpoints.scala
 private val baseEndpoint: SecureEndpointsType.SecureEndpoint[Unit, writes.JsValueError, Unit] =
    secureEndpoint.authenticationWithBearerEndpoint
      .in("todo")

  private val findTodoEndpoint: SecureEndpointsType.SecureEndpoint[Long, writes.JsValueError, writes.JsValueTodo] =
    baseEndpoint.get
      .in(path[Long]("id"))
      .out(jsonBody[writes.JsValueTodo])
      // こちらの`.errorOutVariantPrepend`を使うことによって、既存のエラーレスポンスに別のエラーの型(エラーバリアント)を追加できるようになります。
      .errorOutVariantPrepend(
        oneOfVariant(statusCode(StatusCode.NotFound).and(jsonBody[writes.JsValueNotFound]))
      )

PartialServerEndpointの型を定義するのが手間なので、上のコードでは型エイリアスを使っています。

SecureEndpointType.scala
type SecureEndpoint[I, E, O] = PartialServerEndpoint[String, UserContext, I, E, O, Any, Future]

エンドポイントとロジックの紐づけ処理の変更

.serverLogicの処理を少し変更します。

Endpoints.scala
    findTodoEndpoint
      .serverLogic((context: UserContext) => (id: Long) => todoController.get(id))

pathの共通化

最後に/api/のパスを共通化してみます。

ApiRouter.scala
  private val endpoints = todoEndpoints.endpoints.map(
    // `prependSecurityIn`を使うことによって、追加の入力を付与します。
    // Routesに変換する前に共通のパスを差し込むことで、エンドポイントの定義を簡潔にすることができます。
    _.prependSecurityIn(List("api").foldLeft(emptyInput: EndpointInput[Unit])(_ / _))
  )

これで簡単な認証機能を実装することができました。

こちらも実際にアクセスしてみましょう。

まずは無効なBearerを付与して認証が失敗することを確認します。

curl \
localhost:9000/api/todo/1 \
-H "Authorization: Bearer foo"

# {"message":"Authentication Failed"}

次に有効なBearerを付与してアクセスしてみます。

curl \
localhost:9000/api/todo/1 \
-H "Authorization: Bearer hoge"

# {"id":1,"title":"家事","description":"家の掃除","isDone":false}

認証が通ったのでデータが取得できていることが確認できました。

まとめ

今回は、Play Frameworkでtapirを利用してエンドポイントを定義し、ロジックの実装や認証を行う方法について解説しました。

tapirを使うことで、エンドポイントとビジネスロジックを切り離して記述できるため、エンドポイントの型定義が明確になり、APIの振る舞いが型安全に保証されるというメリットがありました。さらに、エンドポイントのロジックが分離されることで、再利用性が高まり、エラーハンドリングや認証といった機能の合成が簡単に行えます。

また、tapirはSwagger UI確認できる機能を提供しており、APIのドキュメントを簡単に確認できるのも大きな利点です。

安全かつ柔軟なAPI設計ができるtapirを、ぜひ試してみてください。

nextbeat Tech Blog

Discussion