読者です 読者をやめる 読者になる 読者になる

一から勉強させてください( ̄ω ̄;)

最下級エンジニアが日々の学びをアウトプットしていくだけのブログです。

Golang + echoなREST APIサーバで、テスト実行時に自動でAPIドキュメントを生成できるようにする

最近、Golang (+echo) でREST APIサーバを開発する機会があったのですが、テストを書いたらAPIドキュメントを自動生成するような仕組みを作るために試行錯誤したのでメモです。

方針

  • APIドキュメントの生成にはtest2docを利用
    • テストを実行するとAPI Blueprint形式でファイルを自動生成してくれそう
    • 該当するメソッドの上にコメントを書くことで最低限の説明は記述できそう
    • READMEには gorilla/muxjulienschmidt/httprouterのサンプルしか載っておらず echoでうまく動くかは試してみるしかなさそう
  • テストから生成された .apibファイルをaglioみたいなツールにかませばHTMLファイルとしてAPIドキュメントができそう

プロジェクト構成

github.com/danimal141/rest-api-sampleという名前で実装していく。とりあえずユーザー一覧を返すようなエンドポイント /api/v1/usersを実装して、APIドキュメントを自動生成する方法を考える。

余談だが、Golangのパッケージ依存管理にdepを使ってみたので、それ関連のファイルも混ざっている。

.
├── Gopkg.lock
├── Gopkg.toml
├── api
│   ├── all.apib
│   ├── router
│   │   └── router.go
│   ├── v1
│   │   ├── users.go
│   │   ├── users_test.go
│   │   ├── init_test.go
├── doc
├── gulpfile.js
├── main.go
├── models
│   ├── users.go
├── node_modules
├── package.json
└── vendor

APIサーバ実装

まずはAPIサーバをざっと実装してみる。

models/users.go

package models

import "fmt"

type User struct {
    Id       int
    UserName string
}

func SampleUsers() []User {
    users := make([]User, 0, 10)
    for i := 0; i < 10; i++ {
        users = append(users, User{Id: i, UserName: fmt.Sprint("testuser", i)})
    }
    return users
}

ユーザーのStructを定義。サンプル実装なのでDBに保存等はしていない。

api/v1/users.go

package v1

import (
    "errors"
    "fmt"
    "net/http"
    "strconv"

    "github.com/danimal141/rest-api-sample/models"
    "github.com/labstack/echo"
)

type paginationParams struct {
    Pagination string `query:"pagination"`
}

/*

### Query parameter

key        |value  |description
----------:|------:|----------------------------
pagination |false  |ページネーション機能は未実装なのでfalseが必須

*/
func UsersIndex(c echo.Context) error {
    if err := validatePaginationParams(c); err != nil {
        return err
    }
    return c.JSON(http.StatusOK, models.SampleUsers())
}

func UsersShow(c echo.Context) error {
    users := models.SampleUsers()
    id, err := strconv.Atoi(c.Param("user_id"))
    if err != nil {
        return err
    }
    if id > len(users)-1 {
        err := fmt.Errorf("user_id=%d is not found", id)
        return echo.NewHTTPError(http.StatusNotFound, err.Error())
    }
    return c.JSON(http.StatusOK, users[id])
}

func validatePaginationParams(c echo.Context) error {
    p := new(paginationParams)
    if err := c.Bind(p); err != nil {
        return err
    }
    if p.Pagination != "false" {
        err := errors.New("pagination must be false, because pagination is not supported yet")
        return echo.NewHTTPError(http.StatusBadRequest, err.Error())
    }
    return nil
}

ユーザー一覧情報を返すUserIndexとユーザー詳細情報を返すUsersShowを定義。

後でどのようにAPIドキュメント反映されるかを確認するため、一覧はページネーションが未実装であることを確認する?pagination=falseが必須であるとする。メソッドの上にコメントをつけているのも後でドキュメントに反映するためである。

api/router/router.go

package router

import (
    "github.com/danimal141/rest-api-sample/api/v1"
    "github.com/labstack/echo"
)

func NewRouter() *echo.Echo {
    e := echo.New()
    e1 := e.Group("/api/v1")
    e1.GET("/users", v1.UsersIndex)
    e1.GET("/users/:user_id", v1.UsersShow)
    return e
}

ルーティングの定義。

main.go

package main

import "github.com/danimal141/rest-api-sample/api/router"

func main() {
    r := router.NewRouter()
    r.Logger.Fatal(r.Start(":8080"))
}

これで go run main.goしてlocalhost:8080/api/v1/users/1などを確認するとJSONが返却されるはずである。

では次にこのAPIのテストを書いてAPI Blueprintファイルを自動生成する仕組みを作ってみる。

テスト

api/v1/init_test.go

package v1_test

import (
    "log"
    "net/http"
    "os"
    "testing"

    "github.com/adams-sarah/test2doc/test"
    "github.com/danimal141/rest-api-sample/api/router"
    "github.com/labstack/echo"
)

var server *test.Server

func TestMain(m *testing.M) {
    var err error
    r := router.NewRouter()
    test.RegisterURLVarExtractor(makeURLVarExtractor(r))
    server, err = test.NewServer(r)
    if err != nil {
        log.Fatal(err.Error())
    }

    // Start test
    code := m.Run()

    // Flush to an apib doc file
    server.Finish()

    // Terminate
    os.Exit(code)
}

func makeURLVarExtractor(e *echo.Echo) func(req *http.Request) map[string]string {
    return func(req *http.Request) map[string]string {
        ctx := e.AcquireContext()
        defer e.ReleaseContext(ctx)
        pnames := ctx.ParamNames()
        if len(pnames) == 0 {
            return nil
        }

        paramsMap := make(map[string]string, len(pnames))
        for _, name := range pnames {
            paramsMap[name] = ctx.Param(name)
        }
        return paramsMap
    }
}

こちらはドキュメント生成に必要な設定等を記述している。

ここで重要なのがvar server *test.Serverで、server.Finish()を呼ぶことでテスト時のリクエスト、レスポンスを元に.apibファイルを生成してくれる。

また test.RegisterURLVarExtractor(makeURLVarExtractor(r))はリクエストのURLに含まれるパラメータ関連の情報を教えてあげるためのもので、これを呼んでおかないとテスト実行時にPanicする。

具体的には /api/v1/users/1というリクエストで/api/v1/users/:user_idのテストをした場合、makeURLVarExtractorの返り値はmap[user_id:1] になる。そして/api/v1/users/{user_id}というエンドポイントのuser_idのExampleは1のような情報がドキュメントに反映される。

api/v1/users_test.go

package v1_test

import (
    "net/http"
    "testing"
)

func TestUsersIndex(t *testing.T) {
    url := server.URL + "/api/v1/users?pagination=false"
    res, err := http.Get(url)
    if err != nil {
        t.Errorf("Expected nil, got %v", err)
    }
    if res.StatusCode != http.StatusOK {
        t.Errorf("Expected status code is %d, got %d", http.StatusOK, res.StatusCode)
    }
}

func TestUsersShow(t *testing.T) {
    url := server.URL + "/api/v1/users/1"
    res, err := http.Get(url)
    if err != nil {
        t.Errorf("Expected nil, got %v", err)
    }
    if res.StatusCode != http.StatusOK {
        t.Errorf("Expected status code is %d, got %d", http.StatusOK, res.StatusCode)
    }
}

今回はエラーケースは省略しているが、例えば /api/v1/users?pagination=trueなどとしてテストすればドキュメントにBadRequestな感じで反映される。

ここまでで go test ./api/v1を実行するとapi/v1.apibが作成されるようになる。

api/all.apib

FORMAT: 1A

<!-- include(./v1/v1.apib) -->

一応all.apibを用意して、将来的に./v2/v2.apibなどを追加できるような構成を意識してみた。

APIドキュメント生成

gulpaglioを導入して、テストでapibが更新されるのをWatchしてHTMLを作成するようにする。

gulpfile.js

const gulp = require('gulp')
const aglio = require('aglio')
const gaglio = require('gulp-aglio')
const rename = require('gulp-rename')
const fs = require('fs')
const includePath = process.cwd() + '/api'
const paths = aglio.collectPathsSync(fs.readFileSync('api/all.apib', {encoding: 'utf8'}), includePath)

gulp.task('build', () =>
  gulp.src('api/all.apib')
    .pipe(gaglio({template: 'default'}))
    .pipe(rename('out.html'))
    .pipe(gulp.dest('doc'))
)

gulp.task('watch', () =>
  gulp.watch(paths, ['build'])
)

gulp.task('default', ['build', 'watch'])

あとはgulpを立ち上げつつ、サーバのテストを実行すればdoc/out.htmlが更新されるようになる。

ちなみに こんな感じのドキュメントが生成される。

まとめ

ほぼtest2docに助けられた感はありますが、テストによるAPIドキュメントの自動生成が実現できました。

サンプルコードを一応こちらにあげておきますので、何かしらのお役に立てれば幸いです。

参照