Play Framework + tapirで型安全なエンドポイントを定義
概要
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-server
とtapir-json-play
をインポートします。
"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)、返り値の型やエラーが発生したときに返す型などを定義することができます。
endpoint
// GETでのアクセス
.get
// "api/todo/:id"というパス
.in("api" / "todo" / path[Long]("id"))
// 成功時にはJsValueTodoを返す
.out(jsonBody[JsValueTodo])
// 失敗時にはJsValueNotFoundを返す
.errorOut(jsonBody[JsValueNotFound])
ロジックの実装
次にコントローラーの実装をします。
こちらの解説は省略します。
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
などのメソッドを使うことで、ここまでで定義をしたエンドポイントとビジネスロジックの紐づけを行うことができます。
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のルートに変換する処理を実装していきます。
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レスポンスを返す設定は以下のように行います。
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で使用できるルートに変換をします。
それでは実際にアクセスして動きを確認してみましょう。
データベースの初期値では以下のデータを登録しています。
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を使ってエンドポイントの定義を確認していきたいと思います。
まず必要なライブラリをインポートします。
"com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle" % tapirVersion,
"com.softwaremill.sttp.tapir" %% "tapir-swagger-ui" % tapirVersion,
次にSwagger用のエンドポイントの定義します。
private val swaggerEndpoints = SwaggerInterpreter()
.fromServerEndpoints[Future](todoEndpoints.endpoints, "play-tapir-sandbox", "1.0")
private val swaggerRoute = interpreter.toRoutes(swaggerEndpoints)
そして、Routesに追加することによって、Swagger UIを確認することができます(デフォルトのパスは/docs
になっています)。
override def routes: Routes = {
swaggerRoute
.orElse(todoRoute)
}
tagやsummary、descriptionなども簡単に定義ができます。
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を利用して認証機能を実装する
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)
認証の処理を以下のように定義します。
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を取得するエンドポイントに認証処理を加えていきます。
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
の型を定義するのが手間なので、上のコードでは型エイリアスを使っています。
type SecureEndpoint[I, E, O] = PartialServerEndpoint[String, UserContext, I, E, O, Any, Future]
エンドポイントとロジックの紐づけ処理の変更
.serverLogic
の処理を少し変更します。
findTodoEndpoint
.serverLogic((context: UserContext) => (id: Long) => todoController.get(id))
pathの共通化
最後に/api/
のパスを共通化してみます。
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を、ぜひ試してみてください。
Discussion