factory_botでactive_model_serializers用のPORO (Plain-Old Ruby Object)のテストデータを作成する
最近、DB の存在しない Rails プロジェクト下で API を作る機会がありました。
外部から fetch してきたデータから PORO (Plain-Old Ruby Object)を作って、 active_model_serializersでひたすらシリアライズしまくるみたいな感じです。 特にテストまわりで若干の工夫が必要だったので、その時のメモです。
環境
- (なぜか) Rails 6.0.0.beta3
- API モード
- ActiveRecord 使わない
- Rspec
- active_model_serializers 0.10.9
- factory_bot 5.0.2
現時点でまだ本リリースされていない Rails 6 を無駄に使っている。
構成
- app/models
- PORO を定義していく
- app/serializers
- シリアライザを定義していく
- app/controllers
- エンドポイントを定義していく
モデル
サンプルとして URL とサイズの情報を持つImage
モデルを定義してシリアライズするケースを考えてみる。
こちらに書かれているように active_model_serializers では PORO 用に ActiveModelSerializers::Model
を定義してくれているので、シンプルなものであればこれを使って簡単に実装できる。
よりファンキーな実装が求められる状況であっても、 こちらの仕様に沿った自前モデルさえ作れれば問題なくワークすると思う。
# app/models/image.rb class Image < ActiveModelSerializers::Model attributes :url, :size end # app/models/image/size.rb class Image::Size < ActiveModelSerializers::Model attributes :width, :height end
シリアライザ
# app/serializers/image_serializer.rb class ImageSerializer < ActiveModel::Serializer attributes :url, :type has_one :size end # app/serializers/image/size_serializer.rb class Image::SizeSerializer < ActiveModel::Serializer attributes :width, :height end
has_one
みたいにリレーションを定義しておくと、include オプションとかが良い感じに使える。
例えば、 render json: image, include: '*'
とするとsize
が含まれた JSON が返るし、 render json: image, include: ''
とすると size
が含まれない JSON が返る。
コントローラ
コントローラではインスタンスを作って、そいつをrender
メソッドに渡せば active_model_serializers が最適なシリアライザを見つけてシリアライズ -> JSON を返却してくれる。
実際のプロジェクトでは外部からデータを取ってくる実装 (下の fetch_data_somehow
の部分) が一番ツラかったのだが、本題ではないので割愛。
# app/controllers/v1/images_controller.rb module V1 class ImagesController < ApplicationController def show render json: image, include: params[:include] end private def image @image ||= Image.new(image_attrs) end def image_attrs @image_attrs ||= fetch_data_somehow end end end
テストまわり
ActiveRecord
のモデルのテストとかだと factory_bot
を使って
FactoryBot.define do factory :user do name { Faker::Name.name } end end
> user = FactoryBot.create(:user)
みたいにさくっとテストデータを用意できると思う。
ただ今回作成した PORO の場合、create
メソッドを呼んでも保存する場所がない。また、ActiveModelSerializers::Model
はActiveModel
をベースにしているのでnew
のタイミングで attributes
を渡してやらないと attributes = {}
がアサインされてしまって、そのままだと意図通りにシリアライズされない。
# spec/factories/images.rb FactoryBot.define do factory :image do url { Faker::Internet.url } size { build(:image_size) } end end # spec/factories/image/sizes.rb FactoryBot.define do factory :image_size, class: 'Image::Size' do width { rand(100..500) } height { rand(100..500) } end end
> image = FactoryBot.create(:image) => NoMethodError: undefined method `save!`... > image = FactoryBot.build(:image) > image.attributes => {} > image.to_json => "{}"
factory_bot はイニシャライザをオーバライドできる initialize_withや create
をスキップできる skip_createを提供してくれているので、これらをありがたく使わせてもらい解決できた。
# spec/factories/images.rb FactoryBot.define do factory :image do skip_create initalize_with { new(attributes) } url { Faker::Internet.url } size { build(:image_size) } end end # spec/factories/image/sizes.rb FactoryBot.define do factory :image_size, class: 'Image::Size' do skip_create initalize_with { new(attributes) } width { rand(100..500) } height { rand(100..500) } end end
> image = FactoryBot.create(:image) => #<Image:...> `build`と同じ結果になり、エラーは発生しない > image = FactoryBot.build(:image) > image.attributes => { "url" => ..., "size" => ... } > image.to_json => "{\"url\":...,\"size\":...}"
毎回initialize_with
やskip_create
を書くのはだるいので、最終的には以下のような DSL を用意してそいつを使うことにした。
# config/initializers/factory_bot.rb if defined?(FactoryBot) module FactoryBot module Syntax module Default class DSL # Custom DSL for ActiveModelSerializers::Model # Original: https://github.com/thoughtbot/factory_bot/blob/v5.0.2/lib/factory_bot/syntax/default.rb#L15-L26 def serializers_model_factory(name, options = {}, &block) factory = Factory.new(name, options) proxy = FactoryBot::DefinitionProxy.new(factory.definition) if block_given? proxy.instance_eval do skip_create initialize_with { new(attributes) } instance_eval(&block) end end FactoryBot.register_factory(factory) proxy.child_factories.each do |(child_name, child_options, child_block)| parent_factory = child_options.delete(:parent) || name serializers_model_factory(child_name, child_options.merge(parent: parent_factory), &child_block) end end end end end end end
# spec/factories/images.rb FactoryBot.define do serializers_model_factory :image do url { Faker::Internet.url } size { build(:image_size) } end end
あとは以下のようにテストで使える。
# spec/serializers/image_serializer_spec.rb require 'rails_helper' RSpec.describe ImageSerializer, type: :serializer do let(:resource) { ActiveModelSerializers::SerializableResource.new(model, options) } let(:model) { build(:image) } let(:options) { { include: '*' } } describe '#url' do subject { resource.serializable_hash[:url] } it { is_expected.to eq model.url } end ... end
まとめ
PORO かわいい。
参照
既存のGitリポジトリのコミットをベースに新規にGitリポジトリを作成する
たまたまこういうことをする必要があったのでメモです。
(例) すでに運用中の bar リポジトリの既存のコミットを initial commit として、新規に foo リポジトリを作成するケース
$ cd /path/to/bar $ git remote add foo {foo's URL} # Add foo's remote repository $ git checkout -B foo $(git rev-list HEAD --max-parents=0) # Create foo branch from the initial commit of bar $ git merge origin --squash # Checkout all file changes $ git commit --amend -C HEAD $ git push foo HEAD:master
まとめ
git rev-list
知らなかった...
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 ドキュメントの自動生成が実現できました。
サンプルコードを一応こちらにあげておきますので、何かしらのお役に立てれば幸いです。
参照
GolangでEnumをフィールドに持つstructをいい感じにjsonエンコード / デコードする
最近、Golang で Enum をフィールドに持つ 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 でパンくずリストを実装したい人はガンガン使っていけば良いのではないでしょうか。