最近、Golang (+echo
) で REST API サーバを開発する機会があったのですが、テストを書いたら API ドキュメントを自動生成するような仕組みを作るために試行錯誤したのでメモです。
方針
- API ドキュメントの生成にはtest2docを利用
- テストを実行すると API Blueprint 形式でファイルを自動生成してくれそう
- 該当するメソッドの上にコメントを書くことで最低限の説明は記述できそう
- README には
gorilla/mux
とjulienschmidt/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 ドキュメント生成
gulp
とaglio
を導入して、テストで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 ドキュメントの自動生成が実現できました。
サンプルコードを一応こちらにあげておきますので、何かしらのお役に立てれば幸いです。