Golang + echoなREST APIサーバで、テスト実行時に自動でAPIドキュメントを生成できるようにする
最近、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ドキュメントの自動生成が実現できました。
サンプルコードを一応こちらにあげておきますので、何かしらのお役に立てれば幸いです。