この記事はこれは ただの集団 Advent Calendar 2020 の24日目の記事です。
はじめに
皆さん、Schema Driven Development(スキーマ駆動開発)を行っているでしょうか。
スキーマ駆動開発とは
「最初にサービスのスキーマを定義してから、その定義を使い、フロントエンド・サーバサイドが開発を進め、最後に結合する」という開発手法です。
DX Criteria1でもスキーマ駆動開発は推奨されています。
背景
スキーマ駆動開発(OpenAPI)を採用してから、スキーマが違うことによるエラーに悩まされることがなくなったことには大変満足していました。
しかし、ん?と思う場面もありました。
- フロントエンド・サーバーサイドの両方のエンジニアが協力してスキーマの定義を行うが、スキーマ定義がDatabaseに強く依存するため、サーバーサイドの人が主に書いている
- YAMLファイルを手で書いている(Stoplight Studio等を使えばある程度は軽減されるが選択することは必要)
- YAMLファイルからScalaコードを生成しているが2、独自のclassやmethodを使いたい場合にtempleteの作成が必要
Scalaの場合、akka-httpのroutesにアノテーションをつけることで、yamlを生成する機能3がありますが、デバッグが困難など、アノテーション特有の問題をかかえることになります。
正直に言います。YAMLファイルを手書きで書きたくないです。
YAMLを書くのはサーバーサイドの人なのだから、サーバーサイドの言語で書けて、後でドキュメントが生成できればよいのではないでしょうか?
そこで登場するのがtapirです。
tapirとは
関数型プログラミングではプログラムの記述と実行の分離がよく行われている(MonixのTaskやZIOのIOなど)、この考え方をHTTPのエンドポイントにも当てはめたのがtapir(Typed API descRiptions)で、API定義の記述と実行をいい感じに分離してくれます。
sample実装
sample実装として、tapir + http4s + zioで実装しました。
全容はこちらになります。
tapirからはこの組み合わせのライブラリが提供されているので4実装は難しくありません。
API定義の記述と実行の分離
API定義の記述と実行の分離を記述すると下記になります。
APIのinputとoutputを型安全で記述できていることがわかると思います。
// API定義の記述
private val postUser: ZEndpoint[User, ClientError, User] =
endpoint.post
.in("users")
.in(jsonBody[User]) // input body
.out(jsonBody[User]) // output body
.errorOut(httpErrors)
// 実行
private val postUserRoute: URIO[UserServiceEnv, HttpRoutes[Task]] =
postUser
.toRoutesR(postUser =>
UserService
.createUser(postUser)
.mapToClientError
)
routeの合成とドキュメント生成ロジック
routeの合成とドキュメント生成ロジックを記述すると下記になります。
routeの合成はドキュメント生成ロジックには必要ないのですが、routes作成時のsampleとして記述しています。
ドキュメント生成ロジックは
-
.toOpenAPI
でrouteをオブジェクト化 -
.toYaml
で1をyaml化(string) -
new SwaggerHttp4s(userDocs).routes[Task]
でswaggerで表示できるようにしています
// routeの合成
val routes: URIO[UserServiceEnv, HttpRoutes[Task]] = for {
postUserRoute <- postUserRoute
getUserRoute <- getUserRoute
deleteUserRoute <- deleteUserRoute
} yield postUserRoute <+> getUserRoute <+> deleteUserRoute
// routeをOpenAPIのオブジェクトに変更
val docs = List(postUser, getUser, deleteUser).toOpenAPI("User manager", "0.1")
object Server {
// YAML化
private val userDocs = UserRoutes.docs.toYaml
private val appRoutes: URIO[UserServiceEnv with HealthCheck, HttpApp[Task]] =
for {
userRoutes <- UserRoutes.routes
healthCheckRoutes <- HealthCheckRoutes.routes
docsRoutes = new SwaggerHttp4s(userDocs).routes[Task] // SwaggerにYAMLを食わせてエンドポイント化
} yield (userRoutes <+> healthCheckRoutes <+> docsRoutes).orNotFound
// Server起動
val runServer: ZIO[AppEnv, Throwable, Unit] =
for {
app <- appRoutes
svConfig <- Config.httpServerConfig
implicit0(r: Runtime[Clock]) <- ZIO.runtime[Clock]
bec <- blocking.blocking(ZIO.descriptor.map(_.executor.asEC))
_ <-
BlazeServerBuilder[Task](bec)
.bindHttp(svConfig.port.value, svConfig.host.value)
.withoutBanner
.withNio2(true)
.withHttpApp(app)
.serve
.compile
.drain
} yield ()
}
ドキュメント
エンドポイントを叩くと下記のようにswaggerのドキュメント画面になります。
リンクをクリックするとYAMLをダウンロードできます(勿論ロジックを書いて内部に保存しても良いと思います)
あとはこのYAMLからopenapi-generator2を使い、フロントエンド用にTypeScriptを生成すればよいのではないでしょうか。
おわりに
今回の執筆理由は、実際に筆者がスキーマ駆動開発でOpenAPIを採用し、スキーマを頑張って書いているときに自分は一体何をしているんだ?なんでYAML書いているんだ?という考えに至りました。
スキーマを書く作業が一段落したあとに、何かないかなーと調べたときに出会ったのがtapirです。
今後も疑問に思ったことは、解決手段を探し、なければ作成し、日々精進していきたいと思います。