【デジスマチーム ブログリレー6日目】
こんにちは、デジスマチームに所属している大和です。
タイトルに含まれるOpenAPIという文字から、ChatGPTやGPT-4で話題のOpenAIと見間違えた方もいらっしゃるかもしれませんが、今回はREST APIのスキーマ定義に使用されるOpenAPIの話をします。
私が所属するデジスマチームではデジスマ診療というサービスを開発しており、マイクロサービスアーキテクチャでOpenAPIを利用して開発しています。
なお、システムのアーキテクチャについては以下の記事が詳しいです。
今回はどのような構成でOpenAPIを利用し、スキーマ駆動開発を行っているかについて例を用いて紹介します。
ねらい
デジスマ診療では、OpenAPIによるスキーマ定義を集中して管理するリポジトリを準備しています。
この構成にしているのは次の理由からです。
- REST APIのみでなくマイクロサービス間の通信をすべて定義して型安全にする
- アプリ/Webフロントが利用するGateway API
- インターナルなマイクロサービスAPI
- イベント駆動における、各イベントのJSON形式
- WebSocket通信時のpayloadのJSON形式
- 変更をひとつのPull Requestで確認できるようにするために、サービス全体のAPI定義をモノレポで管理する
- API定義を気軽に確認できるGUIを提供する (Swagger UI)
- Pull Requestのレビュー時にも活用している
- 生成したクライアントライブラリを社内maven/npmレポジトリでバージョン管理する
次から実際のコードを例に説明していきます。
スキーマ定義ファイルの構成
リポジトリ中の構成を一部省略して以下に示します。
- client/
- js/
- kotlin/
- internal/
- shard/
- event/
- .openapi-generator-ignore
- build.gradle.kts
- openapi-generator-config.yml
- event/
- visit-api/
- .openapi-generator-ignore
- build.gradle.kts
- openapi-generator-config.yml
- shard/
- build.gradle.kts
- settings.gradle
- version.gradle.kts
- internal/
- components/
- base/
- GenericError404Response.yml
- Visit.yml
- events/
- patient-api/
- visit-api/
- VisitDetail.yml
- base/
- internal/
- visit-api/
- v1_visits.yml
- v1_visits_visitId.yml
- visit-api-openapi.yml
- visit-api/
- public/
- patient-api/
- patient-api-openapi.yml
- shared/
- event-openapi.yml
- template/
- index.html
- Makefile
各ディレクトリに何が保存されているのかについて次から説明していきます。
APIサーバの定義
各APIサーバに対応する定義は以下の場所に置かれます。
internal/*-openapi.yml
... k8sクラスタ内に閉じているAPIサーバの定義public/*-openapi.yml
... 外からアクセス可能なAPIサーバの定義
一例として internal/visit-api-openapi.yml
の定義を抜粋します。
openapi: 3.0.3 info: title: visit-api description: 内部向け受付API version: "1.0" servers: - url: http://visit-api.default.svc.cluster.local paths: /v1/visits: $ref: "./visit-api/v1_visits.yml" /v1/visits/{visitId}: $ref: "./visit-api/v1_visits_visitId.yml"
APIの各endpointに対してひとつずつファイルを作成する形になっており、HTTP request methodおよびその定義を記述する形になっています。
このファイル中では components/
以下に置かれている、共通化された定義ファイルを読み込んで使用しています。
get: tags: - visits summary: 受診詳細 operationId: getVisit parameters: - in: path name: visitId required: true schema: type: string format: uuid responses: "200": description: 受診一覧 content: application/json: schema: $ref: "../../components/visit-api/VisitDetail.yml" "404": $ref: "../../components/base/GenericError404Response.yml" put: tags: - visits summary: 受診更新 : patch: tags: - visits summary: Visit編集 :
各APIのリポジトリはそれぞれ別に用意されており、その中でこのスキーマ定義を参照してそれぞれ生成されます。
共通化されたファイルの定義
各APIで共有して使用される定義ファイルは compoents/
以下に配置されます。
components/base/*.yml
... 共有されるスキーマ定義components/*-api/*yml
... 各API毎のスキーマの違いを吸収する定義
2つの違いがこれだけではわからないので、具体例を挙げます。
base以下に配置されるものは、そのコンポーネントが最低限持つべきものが定義されています。
以下のvisit (施設への訪問) では、IDや日付、現在の状態、および操作された日付の情報を持っています。
components/base/Visit.yml
type: object required: - id - state - date - createdAt description: 受診(チェックイン, prescriptionTypeは何も希望していないならnull) properties: id: type: string format: uuid state: $ref: "./VisitState.yml" date: type: string format: date createdAt: type: string format: date-time checkedInAt: type: string format: date-time cancelledAt: type: string format: date-time
一方で各APIで使用されるものついては、各APIの目的に応じて値が追加されます。
以下の例では紐付けられた薬局のIDが追加で返ります。
components/visit-api/VisitDetail.yml
allOf: - $ref: "./Visit.yml" - type: object properties: pharmacyId: type: string format: uuid
この構成は、APIサーバおよびAPIクライアントでの生成状況を見て、怪しい名前のファイルが生成されないことを確認しながら配置しています。
イベント駆動用の定義
イベント駆動用のスキーマ定義は以下の場所に置かれています。
components/events/*.yml
shared/event-openapi.yml
components/events/
以下のファイルは他のコンポーネントと同様に type: object
などにより定義されています。
工夫している点として、OpenAPIの使用法からやや逸脱しますが、ダミーのendpointを定義して使用するスキーマ定義を読み込むことにより、イベント駆動用スキーマについてもコードを生成しています。
それに当たるのが shared/event-open-api.yml
であり、以下の様になっています。
openapi: 3.0.3 info: title: events description: イベントのschema version: "1.0" paths: /dummy: get: responses: "200": description: "success" content: application/json: schema: type: object properties: WebsocketPayload: $ref: "../components/events/WebsocketPayload.yml" Event: $ref: "../components/events/Event.yml" PushData: $ref: "../components/events/PushData.yml"
これによりSwagger UI等からもスキーマ定義を確認できるようになります。
イベント以外にもWebSocketのpayloadやプッシュ通知のpayloadについても同様に管理しています。
クライアントコード生成
このリポジトリではKotlinおよびJavaScript用のクライアントライブラリの生成も担っています。
弊社ではMavenやnpm等の社内リポジトリを活用しており、このリポジトリからアップロードすることで各APIから使えるようにしています。
一例としてKotlin用のライブラリをどのように生成しているのかについて紹介します。
基本的にはopenapi-generator-cliコマンドを実行しているだけで、その部分はMakefileで管理しています *1。
.PHONY: help generate-and-publish-kotlin-client generate-and-publish-kotlin-clients .DEFAULT_GOAL := help help: @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' generate-and-publish-kotlin-client: ## publish client docker run --rm -v $$(pwd):/local openapitools/openapi-generator-cli:v5.1.1 generate -i /local/${TARGET}-openapi.yml -g kotlin -o /local/client/kotlin/${TARGET} -c /local/client/kotlin/${TARGET}/openapi-generator-config.yml && (cd client/kotlin/${TARGET} && ./gradlew clean publish) generate-and-publish-kotlin-clients: ## publish all client find -E ./client/kotlin -maxdepth 2 -mindepth 2 -regex '.*(internal|shared).*' \ | cut -sd / -f 4- \ | xargs -P 50 -I{} make generate-and-publish-kotlin-client TARGET={} \ && (cd client/kotlin && ./gradlew publish)
client/kotlin/
以下にある openapi-generator-config.yml
を読み込んで生成します。
例. client/kotlin/internal/visit-api/openapi-generator-config.yml
groupId: com.example artifactId: visit-api-client packageName: com.example.visitapi.client enumPropertyNaming: PascalCase serializationLibrary: jackson
client/kotlin/
以下には共通して使用されるファイルが設置されています。
version.gradle.kts
import java.io.ByteArrayOutputStream version = buildVersion("1.0.0") // https://discuss.gradle.org/t/how-to-run-execute-string-as-a-shell-command-in-kotlin-dsl/32235/10 fun getGitBranchName(): String { val out = ByteArrayOutputStream() project.exec { commandLine = listOf("git", "rev-parse", "--abbrev-ref", "HEAD") standardOutput = out } return String(out.toByteArray()).trim() } fun buildVersion(version: String): String { return if (project.hasProperty("release")) { version } else { val branch = getGitBranchName().replace(Regex("[/_]"), "-") "$version-$branch-SNAPSHOT" } }
client/kotliin/build.gradle.kts
buildscript { repositories { maven { url = uri("https://repo1.maven.org/maven2") } maven("https://plugins.gradle.org/m2/") } } group = "com.example" apply { from("./version.gradle.kts") } repositories { mavenCentral() maven { url = uri("https://repo1.maven.org/maven2") } } plugins { `java-platform` `maven-publish` } dependencies { constraints { api("com.example:event-schema:${project.version}") api("com.example:visit-api-client:${project.version}") } javaPlatform { allowDependencies() } tasks { getByName("publish") { doLast { logger.lifecycle("published version: \n$version") } } }
この中で共通のversionを管理してBOMを生成することで、複数のクライアントライブラリをまとめて更新できるようにしています。
また当初は並列で開発する際に同じversionを上書きしてしまう事故が発生していたため、version内にGitのブランチ名を入れることで衝突しないようにしています。
Swagger UIの活用
最後に、API定義をまとめて確認できるドキュメント生成について紹介します。
こちらもMakefileで管理しているので、前回のMakefileに追加する部分を以下に示します。
TARGET_YAMLS := find . -maxdepth 2 -mindepth 2 -regextype posix-egrep -regex '.*(internal|public|shared).*-openapi\.yml' | sed -e 's|^\./||' | sed 's/-openapi.yml//' SWAGGER_UI_VALUES := $(TARGET_YAMLS) | xargs -n1 echo | xargs -I{} echo '{name:"{}",url:"./{}/openapi.json"}' publish-documents: ## publish documents @$(TARGET_YAMLS) | xargs -n1 echo | xargs -I{} mkdir -p publish/{} @$(TARGET_YAMLS) | xargs -n1 echo | xargs -I{} -P10 openapi-generator-cli generate -i {}-openapi.yml -g openapi -o publish/{} @SWAGGER_UI_VALUES=$$($(SWAGGER_UI_VALUES) | tr '\n' ',' | sed 's/,$$//'); cat template/index.html | sed "s|\$$SWAGGER_UI_VALUES|[$$SWAGGER_UI_VALUES]|" > publish/index.html
ここではswagger-uiを利用できるようにしたHTMLファイルに、JSON形式で生成したスキーマファイルのパスを設定しています。
具体的には template/index.html
の $SWAGGER_UI_VALUES
を [{name:"internal/visit-api",url:"./internal/visit-api/openapi.json"},{name:"shared/event",url:"./shared/event/openapi.json"}]
などに置き換えています。
template/index.html
<!DOCTYPE html> <html> <head> <title>API spec</title> <meta charset="utf-8" /> <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@4.18.1/swagger-ui.css"> <script src="https://unpkg.com/swagger-ui-dist@4.18.1/swagger-ui-bundle.js"></script> <script src="https://unpkg.com/swagger-ui-dist@4.18.1/swagger-ui-standalone-preset.js"></script> <script> window.onload = () => { window.ui = SwaggerUIBundle({ urls: $SWAGGER_UI_VALUES, dom_id: '#swagger-ui', presets: [ SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset, ], layout: "StandaloneLayout", }); }; </script> </head> <body> <div id="swagger-ui"></div> </body> </html>
make publish-documents
を実行することで publish/
に以下のファイルが生成され、それらをHTTPサーバ上に配置することでSwagger UIが利用できるようになります。
- index.html
- internal/
- visit-api/
- README.md
- openapi.json
- visit-api/
- public/
- patient-api/
- README.md
- openapi.json
- patient-api/
- shared/
- event/
- README.md
- openapi.json
- event/
このHTTPサーバにアップロードする処理をCI/CDで行うことで、先にスキーマ定義ファイルだけでPull Requestを作成して、Swagger UI上で確認しながらコードレビューできます。
まとめ
弊チームにおいてOpenAPIによるスキーマ定義を集中管理しているリポジトリについて紹介しました。
この仕組みを整備したことにより、先にスキーマを議論してから開発するスタイルでスムーズに開発できています。
私も以前行っていましたが、APIサーバとクライアントライブラリを同時にメンテナンスするのは大変なので、OpenAPIを活用して楽をしていきましょう。
参考
今回はOpenAPI自体については説明しませんでしたが、もし不明点があれば以下の過去記事を参考にしていただけると幸いです。
We are hiring!
スキーマ駆動開発をこれから始めていきたい方やバリバリ行っている方、特に紹介した方法をもっと改善できるという方は一度お話してみませんか?
もし興味があれば以下からご連絡いただけるとうれしいです!
*1:helpについては次の記事を参照: https://postd.cc/auto-documented-makefile/