エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

デジスマチームでのOpenAPI活用方法の紹介

【デジスマチーム ブログリレー6日目】

こんにちは、デジスマチームに所属している大和です。
タイトルに含まれるOpenAPIという文字から、ChatGPTやGPT-4で話題のOpenAIと見間違えた方もいらっしゃるかもしれませんが、今回はREST APIのスキーマ定義に使用されるOpenAPIの話をします。

私が所属するデジスマチームではデジスマ診療というサービスを開発しており、マイクロサービスアーキテクチャでOpenAPIを利用して開発しています。
なお、システムのアーキテクチャについては以下の記事が詳しいです。

www.m3tech.blog

今回はどのような構成で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
        • visit-api/
          • .openapi-generator-ignore
          • build.gradle.kts
          • openapi-generator-config.yml
      • build.gradle.kts
      • settings.gradle
      • version.gradle.kts
  • components/
    • base/
      • GenericError404Response.yml
      • Visit.yml
    • events/
    • patient-api/
    • visit-api/
      • VisitDetail.yml
  • internal/
    • visit-api/
      • v1_visits.yml
      • v1_visits_visitId.yml
    • visit-api-openapi.yml
  • 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/[email protected]/swagger-ui.css">
    <script src="https://unpkg.com/[email protected]/swagger-ui-bundle.js"></script>
    <script src="https://unpkg.com/[email protected]/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
  • public/
    • patient-api/
      • README.md
      • openapi.json
  • shared/
    • event/
      • README.md
      • openapi.json

このHTTPサーバにアップロードする処理をCI/CDで行うことで、先にスキーマ定義ファイルだけでPull Requestを作成して、Swagger UI上で確認しながらコードレビューできます。

API定義をSwagger UIで確認する例

まとめ

弊チームにおいてOpenAPIによるスキーマ定義を集中管理しているリポジトリについて紹介しました。
この仕組みを整備したことにより、先にスキーマを議論してから開発するスタイルでスムーズに開発できています。
私も以前行っていましたが、APIサーバとクライアントライブラリを同時にメンテナンスするのは大変なので、OpenAPIを活用して楽をしていきましょう。

参考

今回はOpenAPI自体については説明しませんでしたが、もし不明点があれば以下の過去記事を参考にしていただけると幸いです。

www.m3tech.blog

We are hiring!

スキーマ駆動開発をこれから始めていきたい方やバリバリ行っている方、特に紹介した方法をもっと改善できるという方は一度お話してみませんか?
もし興味があれば以下からご連絡いただけるとうれしいです!

jobs.m3.com

*1:helpについては次の記事を参照: https://postd.cc/auto-documented-makefile/