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

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

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ドキュメントの自動生成が実現できました。

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

参照

GolangでEnumをフィールドに持つstructをいい感じにjsonエンコード / デコードする

最近、GolangEnumをフィールドに持つstructをいい感じにjsonエンコード / デコードしたい衝動に駆られた事があったので、その時のメモです。

普通にjson.Marshalする

例えば、以下のような Userのstructを定義したとする。

type UserRole int

const (
    _ UserRole = iota
    Consumer
    Admin
)

func (r UserRole) String() string {
    switch r {
    case Consumer:
        return "consumer"
    case Admin:
        return "admin"
    default:
        return "unknown"
    }
}

type User struct {
    Id   string   `json:"id"`
    Role UserRole `json:"role"`
}

こいつを普通にjson.Marshalしてみる。

func main() {
    u := &User{Id: "abcde", Role: Admin}
    b, err := json.Marshal(u)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(b)) // {"id":"abcde","role":2} 
}

これが{"id":"abcde","role":"admin"}のようなjsonになってほしかったのだけど、ただjson.Marshalしただけでは{"id":"abcde","role":2}という文字列が得られてツラい思いをした。

EnumのMarshalJSONメソッドを定義する

type Marshaler interface {
        MarshalJSON() ([]byte, error)
}

というinterfaceがあって、MarshalJSONを実装しておけばいい感じにjsonエンコードしてくれそうだということがわかったので試してみる。

// 上述のコードにこれを追加
func (r UserRole) MarshalJSON() ([]byte, error) {
    return json.Marshal(r.String())
}

今度はちゃんと{"id":"abcde","role":"admin"}が得られた。

ただしMarshalJSONを実装しただけでは、json文字列を再度Userにしたい時に、 json: cannot unmarshal string into Go value of type main.UserRoleのようなエラーが出てまたツラい思いをする。

func main() {
    u := &User{Id: "abcde", Role: Admin}
    b, err := json.Marshal(u)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(b)) // {"id":"abcde","role":2} 
    
    var u2 User
    err = json.Unmarshal(b, &u2)
    if err != nil {
        log.Fatal(err) // json: cannot unmarshal string into Go value of type main.UserRole
    }
    fmt.Println(u2)
}

これに関してはMarshalJSONの時と同様にUnmarshalJSONを実装しておけばよさそう。

func (r *UserRole) UnmarshalJSON(data []byte) error {
    var s string
    if err := json.Unmarshal(data, &s); err != nil {
        return fmt.Errorf("data should be a string, got %s", data)
    }
    
    var ur UserRole
    switch s {
    case "consumer":
        ur = Consumer
    case "admin":
        ur = Admin
    default:
        return fmt.Errorf("invalid UserRole %s", s)

    }
    *r = ur
    return nil
}

これで、ちゃんとjsonから{abcde admin}というUserのstructが得られる。

jsonenumsを使う

最初は上記のように頑張って自分でMarshalJSON, UnmarshalJSONを定義していたが、もう少し調べてみたらjsonenumsというライブラリを使えばこれらの作業を自動化してくれるっぽい。というわけで最終的にこれを使い倒す形で落ち着いた。

プロジェクト構成を以下のようにしたとすると、modelsディレクトリ以下でjsonenums -type=UserRoleとするだけでuserrole_jsonenums.goを自動生成してくれる。

.
├── main.go
└── models
    ├── models.go
    └── userrole_jsonenums.go // jsonenumsが生成してくれる

最終的にコードは以下のようになった。

// models.go

package models

type UserRole int

const (
    _ UserRole = iota
    Consumer
    Admin
)

func (r UserRole) String() string {
    switch r {
    case Consumer:
        return "consumer"
    case Admin:
        return "admin"
    default:
        return "unknown"
    }
}

type User struct {
    Id   string   `json:"id"`
    Role UserRole `json:"role"`
}
// userrole_jsonenums.go

// generated by jsonenums -type=UserRole; DO NOT EDIT

package models

import (
    "encoding/json"
    "fmt"
)

var (
    _UserRoleNameToValue = map[string]UserRole{
        "Consumer": Consumer,
        "Admin":    Admin,
    }

    _UserRoleValueToName = map[UserRole]string{
        Consumer: "Consumer",
        Admin:    "Admin",
    }
)

func init() {
    var v UserRole
    if _, ok := interface{}(v).(fmt.Stringer); ok {
        _UserRoleNameToValue = map[string]UserRole{
            interface{}(Consumer).(fmt.Stringer).String(): Consumer,
            interface{}(Admin).(fmt.Stringer).String():    Admin,
        }
    }
}

// MarshalJSON is generated so UserRole satisfies json.Marshaler.
func (r UserRole) MarshalJSON() ([]byte, error) {
    if s, ok := interface{}(r).(fmt.Stringer); ok {
        return json.Marshal(s.String())
    }
    s, ok := _UserRoleValueToName[r]
    if !ok {
        return nil, fmt.Errorf("invalid UserRole: %d", r)
    }
    return json.Marshal(s)
}

// UnmarshalJSON is generated so UserRole satisfies json.Unmarshaler.
func (r *UserRole) UnmarshalJSON(data []byte) error {
    var s string
    if err := json.Unmarshal(data, &s); err != nil {
        return fmt.Errorf("UserRole should be a string, got %s", data)
    }
    v, ok := _UserRoleNameToValue[s]
    if !ok {
        return fmt.Errorf("invalid UserRole %q", s)
    }
    *r = v
    return nil
}
// main.go

package main

import (
    "encoding/json"
    "fmt"
    "log"

    "github.com/danimal141/enum-test/models"
)

func main() {
    u := &models.User{Id: "abcde", Role: models.Admin}
    b, err := json.Marshal(u)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(b)) // {"id":"abcde","role":"admin"} よい
    
    var u2 models.User
    err = json.Unmarshal(b, &u2)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(u2) // {abcde admin} よい
}

まとめ

Enumをフィールドに持つstructのjsonエンコード / デコードはjsonenumsを使うか、自分でMarshalJSON, UnmarshalJSONを定義すればよさそう。 何か他によい方法がありそうでしたらぜひ教えてください。

参考

Railsでパンくずリストを実装するためにbreadcrumbs_on_railsを使ってみた

最近、Railsアプリでパンくずリストをいい感じに実装するために、breadcrumbs_on_railsというGemを使用してみました。

すでにネット上に使ってみた的な記事はちらほら見受けられましたが、自分なりにどのように使ったかなどを書いておきたいと思います。

breadcrumbs_on_railsの基本的な使い方

以下のようにController側でadd_breadcrumbメソッドを呼んでおくと、呼び出した順に@breadcrmbsに要素がセットされるようになっている。

class PostsController
  add_breadcrumb 'ホーム', :root_path
  add_breadcrumb '投稿一覧', :posts_path
  
  def index
    @posts = Post.all
  end
end

後はView側でrender_breadcrumbsメソッドを呼べば、@breadcrumbsの中身をもとにBreadcrumbsOnRails::Breadcrumbs::SimpleBuilderが要素をレンダリングしてくれるようになっている。html_safeもしてくれているし、separatorを渡すこともできる。

body
  = render_breadcrumbs, separator: '>'

でもこのレンダリングは単純にアンカーリンクを生成してERBで吐いているだけなので、やはりマークアップ自体をカスタマイズしたいという欲求が出てくる。

これはBreadcrumbsOnRails::Breadcrumbs::Builderを継承した独自Builderを定義して、そいつのrenderメソッドを実装することで出来そう。

パンくずリストマークアップをカスタマイズする

以下のようなBuilderをconfig/initializers以下に定義して、自分が使いたいテンプレートを使えるようにした。

module BreadcrumbsOnRails
  module Breadcrumbs
    class MyBuilder < Builder
      # 元々protectedで定義されているが、View側で使いたかったのでpublicにしてる
      public :compute_name, :compute_path 

      def render
        # 使いたいテンプレートのパスを指定する
        @context.render '/layouts/breadcrumbs', elements: @elements, builder: self
      end
    end
  end
end
/ Slimです
- if elements.present?
  nav
    ul
      - elements.each do |elem|
        - name = builder.compute_name elem
        - path = builder.compute_path elem
        li
          - if path.present?
            = link_to_unless_current name, path
          - else
            = name

ホームへのリンクなど、ほぼ全ての画面で必要なリンクをApplicationControllerで定義する

今回、例えば投稿詳細画面にホーム < 投稿一覧 < Awesome post!(投稿詳細)みたいなリストが表示されるようなケースを想定していて、この中のホームはホーム画面以外の全画面で必要になることが予想される。

このような処理はとりあえずApplicationControllerにホームリンク用のadd_breadcrumbを書いてしまって、パンくずリストを表示したくない画面のController内で@breadcrmbsを消し去るメソッドを呼ぶようにしてみる。

class ApplicationController < ActionController::Base
  add_breadcrumb 'Home', :root_path
  
  def remove_breadcrumbs
    @breadcrumbs = nil
  end
end
class HomeController < ApplicationController
  before_action :remove_breadcrumbs
end

Symbolで指定できないパスを指定する

root_pathのようなパスではなく、post_path(@post)のようなものを指定したい場合の話。一例として、画面設計は完全に適当だけど、ホーム < 投稿一覧 < Awesome post!(投稿詳細)< コメント一覧みたいなものをコメント一覧画面で表示したいとすると、以下のようにすれば指定できる。

module Posts
  class CommentsController
    add_breadcrumb '投稿一覧', :posts_path
    before_action :add_post_breadcrumb
    add_breadcrumb 'コメント一覧', :post_comments_path
    
    
    def index
      @comments = Comment.all
    end
    
    private
    
    def add_post_breadcrumb
      add_breadcrumb @post.title, view_context.post_path(@post)
    end
  end

まとめ

breadcrumbs_on_railsというGemは実装も小さいし、特に不満なく使えました。

実際「フルスクラッチでパンくず表示リストを表示する仕組みを実装するぞ」ってなったとしても、結局breadcrumbs_on_railsみたいな実装になってしまう気もするので、Railsパンくずリストを実装したい人はガンガン使っていけば良いのではないでしょうか。