Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

protoファイルからAPIドキュメントを自動生成するまでのハンズオン

ヤプリ Advent Calender 2024の5日目の記事です。

はじめに

こんにちは、サーバーサイドエンジニアの中川です。

API開発時にリクエストやレスポンス内容を定義して、フロントやネイティブのエンジニアと合意をとってから開発を進めると思います。
最初に仕様定義してるとはいえ、実装途中で「やっぱりこっちの方がいい」となって双方合意の元API仕様を変更することもよくあると思います。
そうなった時、もし社内ドキュメントなどコードとは別にAPIドキュメントをまとめていた場合はそっちも変更する必要がありますよね。変更内容が少なければすぐに変更できますが、変更量が多い場合はドキュメント更新は後回しにされがちですし、リリース後に別の担当者がAPIドキュメントを更新してくれる保証もないと思います。  

それが積み重なっていくとAPIドキュメントが信用できないものになって負債になってしまいます。そうならないためにコードとAPIドキュメントを同じ仕組みで更新できると嬉しいですよね。
ヤプリではサーバー↔︎フロント間やサーバー↔︎ネイティブ間の通信の一部にgrpc通信を使用しています。
リクエストやレスポンス内容はprotoファイルで定義されているのでこれをそのままAPIドキュメントにできれば最高ですが、ヤプリではprotoファイルのメンテナンスはサーバーサイドエンジニアが行うことが多く、サーバーサイド以外のメンバーにとっては見慣れないことも多いのでそのまま使うのは難しかったりします。
なので、このprotoファイルをベースにわかりやすいドキュメントを自動生成することを目指します。

実は私がこの記事を書く前からすでにヤプリでは CI での自動生成が行われており、PR上でドキュメント用のページが確認できるようになっています。 また、以前関連した記事を別のメンバーが投稿していたりするので、結論から知りたい方はそちらも参考にされるといいと思います。

tech.yappli.io

本記事では上記過去記事も参考にしながら同じ仕組みを順番に作っていきます。

やること

今回用のディレクトリを作っておきます。

$ mkdir protobuf_document && cd protobuf_document

protoファイルの作成

何はともあれprotoファイルを作っていきます。
grpcやprotobufについてはこちらの記事がわかりやすかったので、その辺りから復習したいという方はそちらを参考にされるといいかと思います。
本記事でも以下の記事を参考にしています。

zenn.dev

今回は上記記事を参考に、以下のようなprotoファイルを用意しました。

./protobuf_document
├── api
│   └── v1
│       └── hello.proto

hello.proto

syntax = "proto3";

package api.v1;

import "google/protobuf/empty.proto";

option go_package = "protobuf/grpc";

service HelloService {
  rpc GetHello(google.protobuf.Empty) returns (GetHelloResponse);
}

message GetHelloResponse {
  string message = 1;
}

リクエストはなし、レスポンスにstringのメッセージが1つあるだけのシンプルなファイルです。 このprotoファイルからサーバー側のコードを自動生成していきます。 ヤプリのサーバー側のコードは主に Go が使われているので、ここではGoを使っていきます。
また、2024年現在ヤプリではコードの自動生成は buf を使っているので、同じくbufを使っていきます。 bufへの移行についても以前別のメンバーが記事を書いていたりするので、興味のある方はそちらも参考にしてください。

tech.yappli.io

$ brew install bufbuild/buf/buf
$ buf config init

これでbuf.yamlファイルが生成されたはずです。 そのまま以下のようなbuf.gen.yamlファイルを用意します。

./protobuf_document
├── api
│   └── v1
│       └── hello.proto
├── buf.gen.yaml
└── buf.yaml

buf.gen.yaml

version: v2
plugins:
  - remote: buf.build/protocolbuffers/go:v1.35.2
    out: .
  - remote: buf.build/grpc/go:v1.5.1
    out: .

ここまでできたらbuf generateコマンドを実行します。するとコードが自動生成されて以下のようなディレクトリ構成になっているはずです。

$ buf generate

./protobuf_document
├── api
│   └── v1
│       └── hello.proto
├── buf.gen.yaml
├── buf.yaml
└── protobuf
    └── grpc
        ├── hello.pb.go
        └── hello_grpc.pb.go

これで元となるprotoファイルの用意は終了です。この生成されたコードを使うサーバー側のコードを作っていきます。

サーバー側のコードの作成

以下のようにgrpcサーバーをlocalhost:8080で動かすためのmain.goを用意します。

./protobuf_document
├── api
│   └── v1
│       └── hello.proto
├── buf.gen.yaml
├── buf.yaml
├── cmd
│   └── server
│       └── main.go
├── go.mod
├── go.sum
└── protobuf
    └── grpc
        ├── hello.pb.go
        └── hello_grpc.pb.go

main.go

package main

import (
    "context"
    "fmt"
    "log"
    "net"
    "os"
    "os/signal"

    hellopb "protobuf_document/protobuf/grpc"

    "google.golang.org/grpc"
    "google.golang.org/grpc/reflection"
    "google.golang.org/protobuf/types/known/emptypb"
)

type myServer struct {
    hellopb.UnimplementedHelloServiceServer
}

func NewMyServer() *myServer {
    return &myServer{}
}

func main() {
    port := 8080
    listner, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
    if err != nil {
        log.Fatalf("error occured err: %v", err)
    }

    s := grpc.NewServer()

    hellopb.RegisterHelloServiceServer(s, NewMyServer())

    reflection.Register(s)

    go func() {
        log.Printf("start gRPC server port: %v", port)
        s.Serve(listner)
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, os.Interrupt)
    <-quit
    log.Println("stopping gRPC server...")
    s.GracefulStop()
}

func (m *myServer) GetHello(ctx context.Context, _ *emptypb.Empty) (*hellopb.GetHelloResponse, error) {
    return &hellopb.GetHelloResponse{
        Message: "Hello World",
    }, nil
}

go getもお忘れなく。

$ go get -u google.golang.org/grpc

さて、ここまでできたら一度動作確認してみます。 ターミナルを2つ用意し、片方でサーバーを起動します。

$ go run cmd/server/main.go
2024/12/04 23:17:24 start gRPC server port: 8080

もう片方で、起動したlocalhost:8080にリクエストを送ってみます。
なんでもいいですが、ここではgrpcurlを使います。

$ grpcurl -plaintext localhost:8080 api.v1.HelloService.GetHello
{
  "message": "Hello World"
}

上記のようにmessageとしてHello Worldが返ってきていれば動作確認完了です。

ドキュメントの追加

さて、リクエストを送るとHello Worldを返すだけの簡単なAPIサーバーができたので、ドキュメントの出力までやっていきます。 今回はGitHub Actionsを使って「プルリクエストが作成されると、GitHub Pagesにドキュメント用のページが出力されること」を目指します。 ドキュメントの生成もbufに任せたいので、buf.gen.yamlにドキュメント用の定義を追加します。

./protobuf_document
├── api
│   └── v1
│       └── hello.proto
├── buf.gen.yaml
├── buf.yaml
├── cmd
│   └── server
│       └── main.go
├── go.mod
├── go.sum
└── protobuf
    └── grpc
        ├── hello.pb.go
        └── hello_grpc.pb.go

buf.gen.yaml

version: v2
plugins:
  - remote: buf.build/protocolbuffers/go:v1.35.2
    out: .
  - remote: buf.build/grpc/go:v1.5.1
    out: .
  - remote: buf.build/grpc-ecosystem/openapiv2:v2.24.0
    out: docs

以下のようにコマンドを叩くと、docs/api/v1/hello.swaggeer.jsonというファイルが生成されるはずです。

$ buf generate

./protobuf_document
├── api
│   └── v1
│       └── hello.proto
├── buf.gen.yaml
├── buf.yaml
├── cmd
│   └── server
│       └── main.go
├── docs
│   └── api
│       └── v1
│           └── hello.swagger.json
├── go.mod
├── go.sum
└── protobuf
    └── grpc
        ├── hello.pb.go
        └── hello_grpc.pb.go

この生成処理と、生成されたJSONからドキュメント用ページを作成する部分をGitHub Actonsに任せます。 事前準備として、GitHub ActionsからGitHub Pagesへの投稿を行うために対象のリポジトリのsettings > pages > Build and deployment の部分の設定を以下のように変えておきます。

GitHub Pages 設定

次に、GitHub Actions上で動作させるためのyamlファイルを用意します。

./protobuf_document
├── .github
│   └── workflows
│       └── deploy-to-github-pages.yml
├── api
│   └── v1
│       └── hello.proto
├── buf.gen.yaml
├── buf.yaml
├── cmd
│   └── server
│       └── main.go
├── docs
│   └── api
│       └── v1
├── go.mod
├── go.sum
└── protobuf
    └── grpc
        ├── hello.pb.go
        └── hello_grpc.pb.go

deploy-to-github-pages.yml

name: Deploy to GitHub Pages

on:
  pull_request:
    branches:
      - master
    types:
      - opened
      - synchronize
      
jobs:
  deploy-to-github-pages:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install buf CLI
        uses: bufbuild/buf-setup-action@v1

      - name: Generate Swagger JSON
        run: |
          buf generate

      - name: Checkout swagger-ui
        uses: actions/checkout@v3
        with:
          repository: swagger-api/swagger-ui
          ref: 'v4.15.0'
          path: swagger-ui

      - name: Inject Swagger static files
        run: cp -n swagger-ui/dist/* docs

      - name: Generate Swagger UI
        uses: Legion2/swagger-ui-action@v1
        with:
          output: swagger-ui
          spec-file: docs/api/v1/hello.swagger.json
          github_token: ${{ secrets.GITHUB_TOKEN }}

      - name: Push gh-pages branch
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./docs

swaggerの静的ファイルが上書きされないように、GitHubで公開されている index.htmlを参考に、docs配下に以下のindex.htmlも加えておきます。

./protobuf_document
├── .github
│   └── workflows
│       └── deploy-to-github-pages.yml
├── api
│   └── v1
│       └── hello.proto
├── buf.gen.yaml
├── buf.yaml
├── cmd
│   └── server
│       └── main.go
├── docs
│   ├── api
│   │   └── v1
│   └── index.html
├── go.mod
├── go.sum
└── protobuf
    └── grpc
        ├── hello.pb.go
        └── hello_grpc.pb.go

docs/index.html

<!-- HTML for static distribution bundle build -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Swagger UI</title>
    <link rel="stylesheet" type="text/css" href="./swagger-ui.css" />
    <link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
    <link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
    <style>
      html
      {
        box-sizing: border-box;
        overflow: -moz-scrollbars-vertical;
        overflow-y: scroll;
      }

      *,
      *:before,
      *:after
      {
        box-sizing: inherit;
      }

      body
      {
        margin:0;
        background: #fafafa;
      }
    </style>
  </head>

  <body>
    <div id="swagger-ui"></div>

    <script src="./swagger-ui-bundle.js" charset="UTF-8"> </script>
    <script src="./swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
    <script>
    window.onload = function() {
      // Begin Swagger UI call region
      const ui = SwaggerUIBundle({
        url: "api/v1/hello.swagger.json",
        dom_id: '#swagger-ui',
        deepLinking: true,
        presets: [
          SwaggerUIBundle.presets.apis,
          SwaggerUIStandalonePreset
        ],
        plugins: [
          SwaggerUIBundle.plugins.DownloadUrl
        ],
        layout: "StandaloneLayout"
      });
      // End Swagger UI call region

      window.ui = ui;
    };
  </script>
  </body>
</html>

ここまでできたら適当なブランチをもう1つ作成し、適当なPRを作成するとワークフローが動作するはずです!

ワークフロー

1つめのワークフローでドキュメントの作成が、2つめのワークフローでドキュメントの公開が行われています!

実際に作成されたドキュメントのページ

taku-0728.github.io

これでドキュメントが自動生成されるようになりました! 今回はわかりやすいようにGitHub Pagesに反映するようにしましたが、静的なHTMLファイルとJSONを読み込んでいるだけなので公開しやすいやり方に変えればOKです!
良いドキュメントライフを!

詰まったこと

本当は生成されるドキュメントのページの見た目をもう少しいい感じにしたく、buf.gen.yamlで使用しているpluginを別のものに変えようとしていました。 使おうとしたpluginがOpenAPI2.0形式で出力するようなのですが、今回GitHub Pagesにデプロイしようとしているのが問題なのか、OpenAPI2.0で出力しようと以下のようなエラーがでてうまくデプロイできなかったです。 今回は別のpluginを使うことで解消しましたが、この辺りは謎が解けていないので解明できたら別記事にしようと思います。

GitHub Pages エラー

あとがき

今回は弊社でも行われているドキュメントの生成についてハンズオン形式でまとめました!
ドキュメント生成に限らず他にも色々な工夫があるので、興味を持った方はぜひカジュアル面談にお越しください!

open.talentio.com