はじめてのRails実装日記
Ruby(v3) + Rails(v7) + Docker + MySQL で構築する
OSはWindows
RubyもRailsもDockerもSQLもド素人の状態からスタート
現在MicroCMSのAPIを使用しているブログ( https://attt.hachiware.cat/ )の、自作ヘッドレスCMSへの移行を目的とする
下記リポジトリで進行
適当に検索して上の方に出てきた記事に従いつつ構築。
このあたりの記事を並行して見つつ進行した
- https://tmasuyama1114.com/docker-compose-rails6-mysql-development/
- https://qiita.com/croquette0212/items/7b99d9339fd773ddf20b
- https://alterbo.jp/blog/ryu3-2106/
記事に従い、 rails db:create
まで進んだところでgemが足りない旨のエラーが発生して詰まったので、
下記コマンドでbundle installしてあげることで解決。
docker-compose run web bundle install
/blog
以下を今回のブログ機能のendpointとしたい。
とりあえず1つcontrollerを作成してみる。
まず、 /blog/articles
で記事一覧を取得できるように実装を進めていくことにする。
まず bin/rails
コマンドをdockerコンテナ内で叩く方法がわからず調べた。
下記のようにすれば良さそう。
`docker-compose run web bin/rails {ほげほげほげ}`
controllerの作成テンプレートは下記
docker-compose run web bin/rails generate controller <ControllerName> <ActionName>
actionNameは下記のうちから選ぶ
- index リソースの一覧を表示する
- new リソースを新規作成する
- create リソースを新規作成して追加(保存)する
- edit リソースを更新するためのフォームを作成する
- show レコードの内容を表示する
- update リソースを更新する
- destroy リソースを削除する
引用: https://qiita.com/morikuma709/items/5b21e9853c9d6ea70295
今回は下記のようにコマンドを叩いた。
ネストする場合は ::
を使うことを覚えた。
docker-compose run web bin/rails generate controller Blog::Articles index
モデルを作成。
docker-compose run web bin/rails generate model {モデル名} {フィールド}:{型} {フィールド}:{型}
指定できる型
- string : 文字列
- text : 長い文字列
- integer : 整数
- float : 浮動小数
- decimal : 精度の高い小数
- datetime : 日時
- timestamp : タイムスタンプ
- time : 時間
- date : 日付
- binary : バイナリデータ
- boolean : Boolean
今回はこれで実行
docker-compose run web bin/rails generate model blogArticle id:string title:string content:text createdAt:datetime updatedAt:datetime publishedAt:datetime
db:migrateしようとしたらidをフィールド名にしたせいでエラーが発生。
you can't redefine the primary key column 'id'. To define a custom primary key, pass { id: false } to create_table.
たぶんid: falseつければいけるけど連番のID無くなりそう。それはそれで欲しいので一旦ファイルを消してから、フィールド名をslugに直して再実行
docker-compose run web bin/rails generate model blogArticle slug:string title:string content:text createdAt:datetime updatedAt:datetime publishedAt:datetime
再度db:migrate。今度は成功
docker-compose run web rails db:migrate
ルーティングを作成
routes.rbを以下のようにする。
2階層目以降の場合は何種類か方法があるが、今回は「namespace」が適切らしい
Rails.application.routes.draw do
namespace :blog do
get 'articles' => 'articles#index'
end
end
URL | ファイル構成 | |
---|---|---|
scope | 指定のパスにしたい | 変えたくない |
namespace | 指定のパスにしたい | 指定のパスにしたい |
module | 変えたくない | 指定のパスにしたい |
引用) https://qiita.com/ryosuketter/items/9240d8c2561b5989f049
とりあえずアクセスできた。
/api/v1
が抜けてることに気づいてここで直した。
route修正後ディレクトリ移動したりClass名の修正した。cliでやらなかったけど合ってそう?
Rails.application.routes.draw do
namespace 'api' do
namespace 'v1' do
namespace :blog do
get 'articles' => 'articles#index'
end
end
end
end
seedsを設定する。
モックデータでもいいけどとりあえずわかりやすくしたいので、MicroCMSの現データから手入力
# This file should contain all the record creation needed to seed the database with its default values.
# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
#
# Examples:
#
# movies = Movie.create([{ name: "Star Wars" }, { name: "Lord of the Rings" }])
# Character.create(name: "Luke", movie: movies.first)
# coding: utf-8
BlogArticle.create(
:slug => 'tsukurimashita',
:createdAt => '2021-05-04T04:30:44.335Z',
:updatedAt => '2021-06-30T05:09:27.336Z',
:publishedAt => '2021-05-04T04:30:44.335Z',
:title => 'サイトを作りました。',
:content => 'ポートフォリオが欲しいと思いつつもずっとROM専していたのですが、ようやく作ってみました。 \n(学生の頃に作ったことがあったのですが、当時Gitとかの使い方が何もわからなかったのと、謎のフリードメインで作っていたこととか前のパソコンが死んだこともあり自然消滅してしまいました。。🗿) \n\n技術系の記事はZennに書いたほうが見てもらえそうなので向こうで書いて、こっちのブログにはどうでも良いようなこととか日常的なことを載せていこうかなーと考えています。 \n(あんまり更新しなそう) \n\nとりあえず今後やってみたいこととして、[Steam Web API](https://partner.steamgames.com/doc/webapi_overview?l=japanese) に興味があるので、これと連携させて趣味のトロコンの記録とかを載せてみたいと思ってます。',
)
BlogArticle.create(
:slug => '202203_design_renewal',
:createdAt => '2022-03-26T06:12:25.151Z',
:updatedAt => '2022-03-26T06:12:25.151Z',
:publishedAt => '2022-03-26T06:12:25.151Z',
:title => 'デザインを一新してみました',
:content => '転職活動も終わってほぼ見られることの無くなったこのポートフォリオですが、久々に見返したらダサい気がしてきてデザインを一新してみました。\n最近仕事ではtailwindを使っていたので、久々にCSSを手書きしたらやっぱり楽しかったです。ただやっぱりtailwindに慣れたせいで不便さも感じるようになってきてしまった。\n趣味で使う程度ならハイブリットに使ってどっちの良いところも使うぐらいがちょうど良いのかなぁと思い始めてきました。\n\n最近はRailsを1から勉強しています。フロントエンド以外ほぼ全く触ってこなかったド素人なのでかなり苦戦中・・・。\nこのブログはmicroCMSのAPIで作られてるんですが、いずれRailsで自作ヘッドレスCMSとして移行しようかなと考えています。\n↓このスクラップで進行中。Zenn便利すぎる・・・。\nhttps://zenn.dev/attt/scraps/1293067756e442\n\nついでに今更ながら1日1コミットを頑張り始めてみました。\nまだ始めて2週間しか経ってないんですが、今のところは結構楽しいです。\n草を生やすのはゲームの収集物コンプと同じ感じの楽しさがあることに気づいたのでなんとか続けれそうな気がします。\n新しい職場になってから周りについていけてない感じがしているので頑張らなければ・・・。',
)
db:seed
で投入。
docker-compose run web rails db:seed
もしおかしくなったら db:migrate:reset
すれば良いっぽい。
docker-compose run web rails db:migrate:reset
とりあえずJSONで記事一覧を表示するだけのものを作ってみる。
render
はviewを指定する記述。今回はviewいらないのでjsonを指定する。
class Api::V1::Blog::ArticlesController < ApplicationController
def index
articles = BlogArticle.all
render json: {
data: articles,
}, status: :ok
end
end
表示できた!
昇順降順とかレスポンスステータスとか諸々調整必要と思われるがとりあえず後回し。
slugを指定して1件だけ記事を表示するようなものを作ってみる。
ルートを追加。1件のみ取得の場合のactionは show
となる。
Rails.application.routes.draw do
namespace 'api' do
namespace 'v1' do
namespace :blog do
get 'articles' => 'articles#index'
+ get 'articles/:slug' => 'articles#show'
end
end
end
end
コントローラーにも少し追加。
find_by
でslugで絞り込む。
※最初 find
でやって失敗した。findの場合はキー指定はできず、idでの絞り込みになる。
class Api::V1::Blog::ArticlesController < ApplicationController
def index
articles = BlogArticle.all
render json: {
data: articles,
}, status: :ok
end
+ def show
+ article = BlogArticle.find_by(slug: params[:slug])
+ render json: {
+ data: article,
+ }, status: :ok
+ end
end
表示できた!!!
ここでいったんビルド環境のリファクタをすることにする。
どんどん進めていっちゃうとあとで直すのがだるそうなので・・・。
dockerコマンド打つのがめんどくいのでMakefileを作成していく。
まずwindows環境なので scoop install make
してmakeコマンドを使えるようにした。
Makefileを下記のように作成。
build:
docker-compose build
up:
docker-compose up
down:
docker-compose down
bundle:
docker-compose exec web bundle install
reset:
docker-compose exec -it web rails db:migrate:reset
docker-compose exec -it web rails db:seed
rspec:
docker-compose exec -it web bundle exec rails rspec
console:
docker-compose exec -it web bundle exec rails console
最初 make: *** [Makefile:10: reset] Error 127
というエラーが出て詰まった。
原因はMakefileのインデントがスペースになっていたため。必ずタブじゃないとNG。
保存時に .editorconfig
で自動補正されてしまうので、下記の記述を追加。
[Makefile]
indent_style = tab
indent_size = 2
これでmakeコマンドが使えるようになった。
rubocopを導入する。
Gemfileに下記のように追加し、 make bundle
した。
group :development do
# Use console on exceptions pages [https://github.com/rails/web-console]
gem "web-console"
+ # rubocop
+ gem 'rubocop', require: false
+ gem 'rubocop-performance', require: false
+ gem 'rubocop-rails', require: false
+ gem 'rubocop-rspec'
--auto-gen-config
コマンド付きでrobocopを走らせる。
docker-compose exec -it web bundle exec rubocop --auto-gen-config
.rubocop.yml
と .rubocop_todo.yml
が生成された。
初期設定だとルールがだいぶ厳しいらしく、eslint-config-standard的な汎用テンプレートも無い。
調べてみてもこれ使えばいいよ的な汎用テンプレートみたいのもなかなか出てこなく、触ってみつつ自分にちょうど良い設定を探してる人が多そうな雰囲気。うーん難しい・・・。
とりあえず必要最低限の量のコンフィグにしてみる。
inherit_from: .rubocop_todo.yml
require:
- rubocop-performance
- rubocop-rails
- rubocop-rspec
AllCops:
Exclude:
- 'db/**/*'
- 'config/**/*'
- 'script/**/*'
- 'node_modules/**/*'
- 'bin/*'
- '**/Gemfile'
- 'vendor/**/*'
- '.git/**/*'
make rubocop
を登録。そして実行。
+ rubocop:
+ docker-compose exec -it web bundle exec rubocop
めっちゃエラー出た。そのとおりに直す。
The following cops were added to RuboCop, but are not configured. Please set Enabled to either `true` or `false` in your `.rubocop.yml` file.
Please also note that you can opt-in to new cops by default by adding this to your config:
AllCops:
NewCops: enable
Gemspec/DateAssignment: # new in 1.10
Enabled: true
Gemspec/RequireMFA: # new in 1.23
~~~
最終的にこうなった。
inherit_from: .rubocop_todo.yml
require:
- rubocop-performance
- rubocop-rails
- rubocop-rspec
AllCops:
Exclude:
- 'db/**/*'
- 'config/**/*'
- 'script/**/*'
- 'node_modules/**/*'
- 'bin/*'
- '**/Gemfile'
- 'vendor/**/*'
- '.git/**/*'
NewCops: enable
Gemspec/DateAssignment: # new in 1.10
Enabled: true
Gemspec/RequireMFA: # new in 1.23
Enabled: true
~~~
やっと実行出来た。
PS D:\Projects\attt-rails> make rubocop
docker-compose exec -it web bundle exec rubocop
Inspecting 17 files
.................
17 files inspected, no offenses detected
このあとは .rubocop_todo.yml
内で一時的にexcludeされているファイルを直していく感じらしい。
.rubocop_todo.yml
の消化していく
下記のように - 'bin/bundle'
が指摘されている箇所がいっぱいあるので削除。rub(rubocop.ymlでexcludeしてるので)
Layout/EmptyLineAfterGuardClause:
Exclude:
- 'bin/bundle'
db/schema.rb
も自動生成のものなので削除 & exclude。
AllCops:
Exclude:
- 'db/**/*'
- 'config/**/*'
- 'script/**/*'
- 'node_modules/**/*'
- 'bin/*'
- '**/Gemfile'
- 'vendor/**/*'
- '.git/**/*'
+ - 'db/schema.rb'
NewCops: enable
今更ながらmakeコマンドに引数渡せないことに気づいて修正。
rspec:
- docker-compose exec -it web bundle exec rails rspec
+ docker-compose exec -it web bundle exec rails rspec ${ARG}
console:
- docker-compose exec -it web bundle exec rails console
+ docker-compose exec -it web bundle exec rails console ${ARG}
rubocop:
- docker-compose exec -it web bundle exec rubocop
+ docker-compose exec -it web bundle exec rubocop ${ARG}
改行コード。windows環境なので無効に。
- Layout/EndOfLine:
- Exclude:
- - 'config/routes.rb'
# Offense count: 2
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBrackets.
# SupportedStyles: space, no_space, compact
# SupportedStylesForEmptyBrackets: space, no_space
Layout/SpaceInsideArrayLiteralBrackets:
Exclude:
- 'config/environments/production.rb'
[]
の内側の前後にスペースが入っているとNG。
- config.log_tags = [ :request_id ]
+ config.log_tags = [:request_id]
# Offense count: 4
# This cop supports safe auto-correction (--auto-correct).
Layout/SpaceInsidePercentLiteralDelimiters:
Exclude:
- 'Gemfile'
%記法の ()
や ()
の内側の前後にスペースが入っているとNG。
- gem "tzinfo-data", platforms: %i[ mingw mswin x64_mingw jruby ]
+ gem "tzinfo-data", platforms: %i[mingw mswin x64_mingw jruby]
# Offense count: 1
# This cop supports safe auto-correction (--auto-correct).
Style/GlobalStdStream:
Exclude:
- 'config/environments/production.rb'
該当箇所
logger = ActiveSupport::Logger.new(STDOUT)
STDOUT/STDERR/STDIN are constants, and while you can actually reassign (possibly to redirect some stream) constants in Ruby, you'll get an interpreter warning if you do so.
STDOUTっていうグローバル変数があるのに再定義しようとして怒られてるらしい。
正直よくわかってないけどとりあえず言う通りに直す
- logger = ActiveSupport::Logger.new(STDOUT)
+ logger = ActiveSupport::Logger.new($stdout)
# Offense count: 12
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: EnforcedStyle, EnforcedShorthandSyntax, UseHashRocketsWithSymbolValues, PreferHashRocketsForNonAlnumEndingSymbols.
# SupportedStyles: ruby19, hash_rockets, no_mixed_keys, ruby19_no_mixed_keys
# SupportedShorthandSyntax: always, never, either
Style/HashSyntax:
Exclude:
- 'db/seeds.rb'
オブジェクトの書き方が古かったっぽい。
- BlogArticle.create(
- :slug => 'tsukurimashita',
+ slug: 'tsukurimashita',
...
# Offense count: 2
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: EnforcedStyleForMultiline.
# SupportedStylesForMultiline: comma, consistent_comma, no_comma
Style/TrailingCommaInHashLiteral:
Exclude:
- 'app/controllers/api/v1/blog/articles_controller.rb'
該当箇所
class Api::V1::Blog::ArticlesController < ApplicationController
def index
articles = BlogArticle.all
render json: {
data: articles,
}, status: :ok
end
data: articles,
のケツカンマ取れと言われてる。
TS使ってる身としては付けたい派なので .rubocop.yml
を変更。
Style/TrailingCommaInHashLiteral:
EnforcedStyleForMultiline: consistent_comma
Rubyだと有り無しどっちが一般的なんだろう。軽く調べた感じだとよくわからなかった。
# Offense count: 2
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: EnforcedStyleForMultiline.
# SupportedStylesForMultiline: comma, consistent_comma, no_comma
Style/TrailingCommaInArguments:
Exclude:
- 'db/seeds.rb'
同じくケツカンマで怒られてるので設定を変更。
+ Style/TrailingCommaInArguments:
+ EnforcedStyleForMultiline: consistent_comma
# Offense count: 2
# This cop supports unsafe auto-correction (--auto-correct-all).
# Configuration parameters: SafeForConstants.
Style/RedundantFetchBlock:
Exclude:
- 'config/puma.rb'
config/puma.rb:7:25: C: [Correctable] Style/RedundantFetchBlock: Use fetch("RAILS_MAX_THREADS", 5) instead of fetch("RAILS_MAX_THREADS") { 5 }.
max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
config/puma.rb:18:10: C: [Correctable] Style/RedundantFetchBlock: Use fetch("PORT", 3000) instead of fetch("PORT") { 3000 }.
port ENV.fetch("PORT") { 3000 }
^^^^^^^^^^^^^^^^^^^^^^
fetch
メソッドの書き方が冗長らしい。
- port ENV.fetch("PORT") { 3000 }
+ port ENV.fetch("PORT", 3000)
(JSのfetchとは全然違う用途だった。非同期的なやつかと思ってしまった。)
Style/ClassAndModuleChildren:
Exclude:
- 'app/controllers/api/v1/blog/articles_controller.rb'
- 'app/helpers/api/v1/blog/articles_helper.rb'
- 'test/channels/application_cable/connection_test.rb'
- 'test/controllers/blog/articles_controller_test.rb'
- 'test/test_helper.rb'
Classの記法を下記の形式のどっちにするか。
# nested
# Hoge
class Hoge
# Hoge::ChildHoge
class ChildHoge
end
end
# compact
# Hoge::ChildHoge
class Hoge::ChildHoge
end
今回はcompactで進めてたのでcompactにする。
+ Style/ClassAndModuleChildren:
+ EnforcedStyle: compact
インデントも抑えれるし個人的にはcompactのほうが好み。
# Offense count: 5
# Configuration parameters: AllowedConstants.
Style/Documentation:
Exclude:
- 'spec/**/*'
- 'test/**/*'
- 'app/helpers/api/v1/blog/articles_helper.rb'
- 'app/helpers/application_helper.rb'
- 'config/application.rb'
- 'db/migrate/20220326124922_create_blog_articles.rb'
classに必ずコメントをつけないと怒られる。
必須にするほどでも無いと思うので無効に。
+ Style/Documentation:
+ Enabled: false
# Offense count: 1
# Configuration parameters: IgnoredMethods.
Metrics/CyclomaticComplexity:
Max: 9
1つの関数内に処理を詰め込みすぎると怒られる。
この記事がわかりやすかった。
def test(num)
return '0' unless num # unless 1
case num
when 1 then '1' # when 2
when 2 then '2' # when 3
when 3 then '3' # when 4
when 4 then '4' # when 5
when 5 then '5' # when 6
when 6 then '6' # when 7
else '10' # else 8
end
end
この辺はlintかけずとも自分で意識すると思うのと、場合によっては致し方ない場合もある気がする・・・。ので無効に。
+ Metrics/CyclomaticComplexity:
+ Enabled: false
# Offense count: 1
# Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods.
Metrics/MethodLength:
Max: 13
関数の行数制限。
これも自分で意識すれば良いと思うので無効に。
+ Metrics/MethodLength:
+ Enabled: false
# Offense count: 1
# Configuration parameters: IgnoredMethods.
Metrics/PerceivedComplexity:
Max: 9
メソッドの複雑度。同じく無効に。
+ Metrics/PerceivedComplexity:
+ Enabled: false
# Offense count: 39
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: EnforcedStyle.
# SupportedStyles: always, always_true, never
Style/FrozenStringLiteralComment:
Enabled: false
# frozen_string_literal: true
を記述することで文字列の変数を変更不可にできる。const的なもの?
.freeze
をつけてもリテラル化できるがこれを省略できるようになるらしい。
ということで各ファイルに追加して完了。
# Offense count: 4
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns.
# URISchemes: http, https
Layout/LineLength:
Max: 603
行数の文字制限。120ぐらいにしとく。
+ Layout/LineLength:
+ Max: 120
# Offense count: 109
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline.
# SupportedStyles: single_quotes, double_quotes
Style/StringLiterals:
Enabled: false
変数とかに "
を使うか '
を使うか。今回は '
にする。
+ Style/StringLiterals:
+ EnforcedStyle: single_quotes
# Offense count: 1
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: RequireEnglish.
# SupportedStyles: use_perl_names, use_english_names
Style/SpecialGlobalVars:
EnforcedStyle: use_perl_names
グローバル変数をperlスタイルで書くか英単語っぽく書くか。
perl => english
$: => $LOAD_PATH
$" => $LOADED_FEATURES
$0 => $PROGRAM_NAME
$! => $ERROR_INFO
$@ => $ERROR_POSITION
$; => $FIELD_SEPARATOR / $FS
$, => $OUTPUT_FIELD_SEPARATOR / $OFS
$/ => $INPUT_RECORD_SEPARATOR / $RS
$\\ => $OUTPUT_RECORD_SEPARATOR / $ORS
$. => $INPUT_LINE_NUMBER / $NR
$_ => $LAST_READ_LINE
$> => $DEFAULT_OUTPUT
$< => $DEFAULT_INPUT
$$ => $PROCESS_ID / $PID
$? => $CHILD_STATUS
$~ => $LAST_MATCH_INFO
$= => $IGNORECASE
$* => $ARGV => / => ARGV
$& => $MATCH
$` => $PREMATCH
$\' => $POSTMATCH
$+ => $LAST_PAREN_MATCH
english_styleのほうがわかりやすい気がするのでそっちに。
+ Style/SpecialGlobalVars:
+ EnforcedStyle: use_english_names
# Offense count: 1
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: .
# SupportedStyles: percent, brackets
Style/SymbolArray:
EnforcedStyle: percent
MinSize: 10
シンボル配列をどっちで書くか。
%i[foo bar baz]
[:foo, :bar, :baz]
ちょっとどっちか良いのかわからないのでデフォルトの%iで書く方にする。
%wっていうのもあるようで見分けも付きにくそうなので。
+ Style/SymbolArray:
+ SupportedStyles: percent
これでrubocop_todoの消化は完了。
今更ながらAPIモードで作成しとけばよかったなぁと思い・・・
下記ページを見ながら移行した。
移行後、viewとかhelper等の使わなそうなファイル群を削除。
試しに適当にコントローラ作ってみる。
docker-compose run web bin/rails generate controller hoge index
[+] Running 1/0
- Container attt-rails-db-1 Running 0.0s
create app/controllers/hoge_controller.rb
route get 'hoge/index'
invoke test_unit
create test/controllers/hoge_controller_test.rb
必要なファイルだけが作成されたので移行成功! 🎉
ここで、前に作成した下記のモデルに気になる点が出てきたので修正
- createdAtとupdatedAtが被ってる。必須で自動生成されるので新しく定義は不要だった。
- キャメルケースではなくスネークケースで統一したい。
create_table "blog_articles", charset: "utf8mb4", force: :cascade do |t|
t.string "slug"
t.string "title"
t.text "content"
t.datetime "createdAt"
t.datetime "updatedAt"
t.datetime "publishedAt"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
マイグレーションファイルを修正。
class CreateBlogArticles < ActiveRecord::Migration[7.0]
def change
create_table :blog_articles do |t|
t.string :slug
t.string :title
t.text :content
- t.datetime :createdAt
- t.datetime :updatedAt
- t.datetime :publishedAt
t.datetime :published_at
t.timestamps
end
end
end
seeds.rb
も合わせて修正後、make reset
する。
これできれいな形になった。
ActiveRecord::Schema[7.0].define(version: 2022_03_26_124922) do
create_table "blog_articles", charset: "utf8mb4", force: :cascade do |t|
t.string "slug"
t.string "title"
t.text "content"
t.datetime "published_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
end
swaggerを使用してAPIドキュメントを作成&rspecに使用する準備をする。
仮でこんな感じのディレクトリ構造にしてみる。
1つのyamlに詰め込むのは嫌なので、swagger-mergerを使い、細かくファイル分割して管理する。
.
└ swagger
├ swagger-merger
│ └ Dockerfile
├ dist
│ ├ .keep (ディレクトリが存在しない状態で動かすとswagger-mergerがバグる・・のでkeepファイルを置いとく)
│ └ swagger.yml (src/index.htmlをswagger-mergerでここに書き出す)
└ src
├ index.yml
├ paths/
│ ~
│ └ endpointの定義ファイル.yml
├ schemas/
└ ~
└ componentの定義ファイル.yml
新規ファイルを2つ作成。
get:
operationId: getBlogArticles
summary: "記事一覧の取得"
responses:
200:
description: "成功"
content:
application/json:
schema:
type: "array"
items:
type: "object"
properties:
slug:
type: "string"
example: "hoge"
description: "URLに使用される文字列"
title:
type: "string"
example: "ほげ"
description: "記事タイトル"
content:
type: "string"
example: "# hoge\nhuga"
description: "記事の内容(markdown記法)"
createdAt:
type: "string"
format: "date-time"
example: "2021-05-04T04:30:44.335Z"
description: "記事作成日時"
updatedAt:
type: "string"
format: "date-time"
example: "2021-05-04T04:30:44.335Z"
description: "記事最終更新日時"
publishedAt:
type: "string"
format: "date-time"
example: "2021-05-04T04:30:44.335Z"
description: "記事公開日時"
docker-composeに設定を追加する。
ブラウザ上で定義書を確認できるようにするredocと、swagger-mergerをnpxで動かす用の設定を追加。
色々な記事を見つつ真似したがどれもうまくいかず、ここで何時間も苦戦した・・・・。volumesの設定がキモでした。
Dockerムズい・・・。
swagger-merger:
build:
context: .
dockerfile: ./swagger/swagger-merger/Dockerfile
command: >
watch 'swagger-merger -i ./swagger/src/index.yml -o ./swagger/dist/swagger.yml' /swagger/src/
volumes:
- ./swagger:/swagger
redoc:
image: redocly/redoc
ports:
- 8081:80
volumes:
- ./swagger/dist:/usr/share/nginx/html/swagger
environment:
SPEC_URL: swagger/swagger.yml
FROM node:alpine
RUN npm install -g swagger-merger watch
CMD ["swagger-merger"]
無事にredocが見れることを確認!
swagger/src 以下のファイルを触ると swagger/dist/swagger.yml に反映されることも確認できた。
distファイルはgit管理したくないので.gitignoreしとく。
+ # Merged swagger file
+ /swagger/dist/swagger.yml
ここで紛らわしいのでファイルの命名規則とかをswagger から openapi に統一した。
@openapitools/openapi-generator-cli
を使って、TypeScriptの型定義ファイルを作れるようにセットアップしていく。
"scripts": {
"openapi-validate": "openapi-generator-cli validate -i ./openapi/dist/openapi.yml",
"openapi-generate": "openapi-generator-cli generate -g typescript-axios -i ./openapi/dist/openapi.yml -o ./openapi-types"
},
"devDependencies": {
"@openapitools/openapi-generator-cli": "^2.4.26"
}
pnpm openapi-generate
で生成!🎉
CI/CDで、Github Packagesにopenapi-generateしたものをpublishして、別プロジェクトでnpm iして使えるようにする。
とりあえずpackages.jsonを用意
{
// 先頭に@{organation}つける
+ "name": "@ttt3pu/attt-rails",
"version": "1.0.0",
"description": "",
// importしたときに読まれるファイル
+ "main": "openapi-types/index.ts",
// 余計なファイルをpublishしてしまわないように設定
+ "files": [
+ "openapi-types"
+ ],
"engines": {
"node": "16.14.0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/ttt3pu/attt-rails.git"
},
"keywords": [],
"author": "attt <ttt3pu@gmail.com>",
"license": "ISC",
"bugs": {
"url": "https://github.com/ttt3pu/attt-rails/issues"
},
"homepage": "https://github.com/ttt3pu/attt-rails#readme",
// これを忘れないように
+ "publishConfig": {
+ "registry":"https://npm.pkg.github.com/"
+ },
"scripts": {
+ "openapi-merge": "swagger-merger -i ./openapi/src/index.yml -o ./openapi/dist/openapi.yml",
"openapi-validate": "openapi-generator-cli validate -i ./openapi/dist/openapi.yml",
"openapi-generate": "openapi-generator-cli generate -g typescript-axios -i ./openapi/dist/openapi.yml -o ./openapi-types"
},
"devDependencies": {
"@openapitools/openapi-generator-cli": "^2.4.26",
"swagger-merger": "^1.5.4"
}
}
Github ActionsでCIを設定する。
name: Publish OpenAPI TypeScript Package
on:
push:
paths:
# - 'openapi/src/**'
- 'package.json'
jobs:
publish:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.14.0]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.number }}
steps:
- uses: actions/checkout@v2
- uses: pnpm/action-setup@v2.0.1
with:
version: 6.20.3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Merge
run: pnpm openapi-merge
- name: Validate
run: pnpm openapi-validate
- name: Generate types
run: pnpm openapi-generate
- name: Set npm config
run: npm config set //npm.pkg.github.com/:_authToken=$GITHUB_TOKEN
# - name: Get current version
# run: |
# CURRENT_VERSION=$(npm view @attt/attt-rails version)
# npm version $CURRENT_VERSION
# - name: Update version on PR
# if: github.ref != 'refs/heads/master'
# run: |
# npm version minor
# npm version prerelease --preid=$PR_NUMBER-$GITHUB_SHA
# if: github.ref == 'refs/heads/master'
# - name: Update version on main branch
# run: npm version minor
- name: Publish
run: npm publish
コメントアウトしてることころは自動でバージョン付ける想定で作っているが、
初回はバージョン拾うところでたぶんコケるのでいったんこれで走らせてv1.0.0のパッケージを作成する。
publishに成功!
mainにpushしたときはマイナーバージョン、
それ以外のブランチでpushしたときは 1.0.0-${SHA}
のようにバージョンを自動で生成できるようにCIを変える。
いくつかめちゃくちゃ詰まったのでコメントを入れた。特にnpm infoのところは難しかった。
name: Publish OpenAPI TypeScript Package
on:
push:
# 一時的に必ず発火するようにしてる
branches:
- 'feature/setup-openapi'
# paths:
# - 'openapi/src/**'
jobs:
publish:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.14.0]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # onイベントがpull requestの時にしか取得できないらしい。ので、SHAだけにした。
- PR_NUMBER: ${{ github.event.number }}
+ GITHUB_SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@v2
- uses: pnpm/action-setup@v2.0.1
with:
version: 6.20.3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Merge
run: pnpm openapi-merge
- name: Validate
run: pnpm openapi-validate
- name: Generate types
run: pnpm openapi-generate
- name: Set npm config
run: |
npm config set //npm.pkg.github.com/:_authToken=$GITHUB_TOKEN
+ # これをやっとかないと次の`npm view~`が実行できない。https://registry.npmjs.orgから取得しようとして404になる。
+ npm config set registry=https://npm.pkg.github.com
- name: Get latest version
run: LATEST_VERSION=$(npm view @ttt3pu/attt-rails version)
- name: Temporarily set version to latest
run: npm version $LATEST_VERSION
+ # これやらないと `npm ERR! Author identity unknown` で怒られる。何故かはよくわからなかったがnpm versionコマンドに必須?
+ - name: Set git config author info
+ run: |
+ git config user.name "GitHub Actions Bot"
+ git config user.email "<>"
- name: Update version on PR
if: github.ref != 'refs/heads/master'
run: |
npm version minor
npm version prerelease --preid=$GITHUB_SHA
- name: Update version on main branch
if: github.ref == 'refs/heads/master'
run: npm version minor
- name: Publish
run: npm publish
無事にCIが通りSHA付きバージョンの生成に成功!
最後にonイベントのところを正しい挙動になるように戻して終わり。
あとはmainブランチにマージしたときに正しく動けばOK。
on:
push:
- branches:
- - 'feature/setup-openapi'
+ paths:
+ - 'openapi/src/**'
あとはrspecでopenapiの定義が使えるようにできればやりたいことは完了。
まずはgem諸々を導入。
group :development, :test do
gem 'rspec-rails'
gem 'factory_bot_rails'
gem 'committee'
gem 'committee-rails'
gem 'faker'
end
設定をhelperに追記。
# FactoryBot.build(:user) → build(:user)
config.include FactoryBot::Syntax::Methods
# OpenAPI + comittee settings
config.add_setting :committee_options
config.committee_options = { :schema_path => Rails.root.join("openapi", "dist", "openapi.yml").to_s }
include ::Committee::Rails::Test::Methods
Factoryを作成。
FactoryBot.define do
factory :blog_article do
slug { Faker::Internet.slug }
created_at { Faker::Time.between(from: DateTime.now - 1, to: DateTime.now) }
updated_at { Faker::Time.between(from: DateTime.now - 1, to: DateTime.now) }
published_at { Faker::Time.between(from: DateTime.now - 1, to: DateTime.now) }
title { Faker::Lorem.word }
content { Faker::Markdown.random }
end
end
テストを作成。
require "rails_helper"
RSpec.describe Api::V1::Blog::ArticlesController, :type => :request do
describe "#index" do
let!(:article) { create_list(:blog_article, 10) }
before do
get '/api/v1/blog/articles'
end
it '記事一覧が取得できる' do
assert_response_schema_confirm(200)
end
end
end
テストが通ることを確認。
make rspec ARG=spec/controllers/api/v1/blog/articles_controller.spec.rb
Finished in 2.2 seconds (files took 29 seconds to load)
1 example, 0 failures
試しにrequiredの返却を1個消してみると
FactoryBot.define do
factory :blog_article do
slug { Faker::Internet.slug }
created_at { Faker::Time.between(from: DateTime.now - 1, to: DateTime.now) }
updated_at { Faker::Time.between(from: DateTime.now - 1, to: DateTime.now) }
published_at { Faker::Time.between(from: DateTime.now - 1, to: DateTime.now) }
title { Faker::Lorem.word }
# content { Faker::Markdown.random }
end
end
F
Failures:
1) Api::V1::Blog::ArticlesController#index 記事一覧が取得できる
Failure/Error: assert_response_schema_confirm(200)
Committee::InvalidResponse:
#/paths/~1api~1v1~1blog~1articles/get/responses/200/content/application~1json/schema/properties/data/items/properties/content does not allow null values
ちゃんとテストしてくれてる!
・・・が、ここで不備を発見。
updated_atなど、keyがsnakecaseになってる。ここはcamelCaseにしたい。
色々方法はありそう?な感じだが、Serializerとfastjson apiを使うのが良さそうなので導入する。
gemを追加。
+ gem 'active_model_serializers'
+ gem 'fast_jsonapi'
serializerを作成。
set_key_transform :camel_lower
がキモとなる部分。application_serializerに書いておくと全serializerに反映される。
class ApplicationSerializer < ActiveModel::Serializer
include FastJsonapi::ObjectSerializer
set_key_transform :camel_lower
end
class BlogArticleSerializer < ApplicationSerializer
attributes :id, :slug, :title, :content, :published_at, :created_at, :updated_at
end
controllerもserializerを挟むように修正。
# frozen_string_literal: true
class Api::V1::Blog::ArticlesController < ApplicationController
def index
articles = BlogArticle.all
render json: BlogArticleSerializer.new(articles).serialized_json
end
def show
article = BlogArticle.find_by(slug: params[:slug])
render json: BlogArticleSerializer.new(article).serialized_json
end
end
無事camelCaseに!
(今回の変更で、serializerがmodelを改変して渡す感じの役割なことを覚えたが、app/model以下のファイル群がそういう役割なのかと思ってた。こいつらは何に使うんだろう。。そのうちわかるようになるんだろうか)
完璧かと思いきやここで問題が発生・・・・
JSON formatになったことで data
を挟むようになったので、rspecが落ちるようになった。。。
Failures:
1) Api::V1::Blog::Articles GET /api/v1/blog/articles 取得できる
Failure/Error: assert_response_schema_confirm(200)
Committee::InvalidResponse:
#/paths/~1blog~1articles/get/responses/200/content/application~1json/schema expected array, but received Hash: {"data"=>[{"id"=>"91", "type"=>"blogArticle", "attributes"=>{"id"=>91, "slug"=>"o
openapi.ymlにいちいちdata挟むような書き方はしないだろうし、committeeの設定でどうにかするしかない?
何時間か調べてるが全くわからず。。。。。
とんちんかんなことしてた。active_model_serializersとfastjson_apiは共存しない。
そしてfastjson_apiとopenapiの共存はもう無理な気がしてきたので、active_model_serializersに乗り換えることにする。
新規ファイルを作成。
ActiveModelSerializers.config.key_transform = :camel_lower
この1行だけでキャメルケースになるらしい。何と楽ちんな。。
controller、serializerをそれぞれ書き直す。
# frozen_string_literal: true
class Api::V1::Blog::ArticlesController < ApplicationController
def index
articles = BlogArticle.all
render json: articles, status: :ok, each_serializer: BlogArticleSerializer
end
def show
article = BlogArticle.find_by(slug: params[:slug])
render json: article, status: :ok, serializer: BlogArticleSerializer
end
end
class BlogArticleSerializer < ActiveModel::Serializer
attributes :id, :slug, :title, :content, :published_at, :created_at, :updated_at
end
無事camelCaseに!
そしてテストも通るようになった 🎉🎉🎉
require 'rails_helper'
RSpec.describe "Api::V1::Blog::Articles", type: :request do
describe "GET /api/v1/blog/articles" do
let!(:articles) { create_list(:blog_article, 10) }
it "取得できる" do
get api_v1_blog_articles_path
assert_response_schema_confirm(200)
end
end
end
docker-compose exec web bundle exec rspec spec/requests/api/v1/blog/articles_spec.rb
.
Finished in 1.7 seconds (files took 11.05 seconds to load)
1 example, 0 failures
これでようやくopenapiの導入は完結。
push時にrspecとrubocopをgithub actions で実行させる。
name: RSpec and RuboCop
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize]
env:
RUBY_VERSION: 3.1.1
jobs:
run_rspec_rubocop:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.14.0]
services:
mysql:
image: mysql:5.7
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: root
MYSQL_ALLOW_EMPTY_PASSWORD: yes
ports:
- 3306:3306
options: --health-cmd "mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 10
steps:
- uses: actions/checkout@v2
- uses: pnpm/action-setup@v2.0.1
with:
version: 6.20.3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Merge
run: pnpm openapi-merge
- name: Validate
run: pnpm openapi-validate
- uses: actions/cache@v1
with:
path: vendor/bundle
key: bundle-${{ hashFiles('**/Gemfile.lock') }}
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ env.RUBY_VERSION }}
bundler-cache: true
- name: Install gem
run: |
gem install bundler
bundle install
- name: set database.yml
run: cp -v config/database.ci.yml config/database.yml
- name: db create
run: bundle exec rails db:create db:schema:load --trace
- name: Rspec
run: bundle exec rspec
- name: RuboCop
run: bundle exec rubocop
めっちゃ嵌ったのがmysqlの構築のところ。
database.ymlをCI用に別で用意しているのと、
- name: set database.yml
run: cp -v config/database.ci.yml config/database.yml
hostを 127.0.0.1
にしないと動かなかった。
default: &default
adapter: mysql2
encoding: utf8mb4
pool: 5
username: root
password: password
host: 127.0.0.1
port: 3306
development:
<<: *default
database: ci_development
test:
<<: *default
database: ci_test
無事にCI動くことを確認!(RuboCopコケまくってるが)
記事の投稿機能を作成する
post:
operationId: postBlogArticlesCreate
summary: "記事の作成"
parameters:
- in: query
name: "isPublished"
required: true
schema:
type: boolean
example: true
description: "公開状態かどうか"
- in: query
name: "slug"
required: true
schema:
type: string
example: "hoge"
description: "URLに使用される文字列"
- in: query
name: "title"
required: true
schema:
type: string
example: "ほげ"
description: "記事タイトル"
- in: query
name: "content"
required: true
schema:
type: string
example: "# hoge\\nhuga"
description: "記事の内容(markdown記法)"
responses:
204:
description: "成功"
# frozen_string_literal: true
class Api::V1::Blog::ArticlesController < ApplicationController
def index
articles = BlogArticle.all
render json: articles, status: :ok, each_serializer: BlogArticleSerializer
end
def show
article = BlogArticle.find_by(slug: params[:slug])
render json: article, status: :ok, serializer: BlogArticleSerializer
end
+ def create
+ post = BlogArticle.new(
+ slug: create_params[:slug],
+ title: create_params[:title],
+ content: ApplicationController.helpers.sanitize(create_params[:content]),
+ published_at: create_params[:isPublished] == 'true' ? Time.current : nil,
+ )
+
+ post.save!
+ end
+
+ private
+
+ def create_params
+ params.permit(:isPublished, :slug, :title, :content)
+ end
end
paramsは params.permit
のような記法を使うことで余計なものを受け取らずに処理できるらしい。
正直よく理解できていないんだけど、とりあえず今のところはお作法として、調べて出てきたものをそのまま真似してみた。
content
のところはフロント側はv-htmlを使ってそのまま出す想定なのでsanitizeを挟んでみた(個人で使用するものなので別にXSS対策はいらないんだけど、一応学習のため)
↓ここは最初 create_params[:isPublished] ? Time.current : nil,
にしてたら通らなくてかなり詰まった。
booleanではなくstringの 'true'
として来てしまうという罠・・・。boolとstrで相互変換できないらしいのでこのように比較するのがベストらしい。・・・ほんとかな。。
published_at: create_params[:isPublished] == 'true' ? Time.current : nil,
テストも書いた。
ちょっと長くなってしまったけどまあこんなもんなんだろうか。
letが便利なのはちょっと理解してきた。
sanitizeのところはもうちょっと良い書き方あったりするのかな
describe 'POST #create' do
let(:xss_example) { '<script>alert("hoge")</script>' }
let(:xss_example_sanitized) { 'alert("hoge")' }
let(:slug) { Faker::Internet.slug }
let(:title) { Faker::Lorem.word }
let(:content) { Faker::Markdown.random }
def create_params(overrides = {})
{
isPublished: true,
slug:,
title:,
content: content + xss_example,
}.merge(overrides)
end
describe '公開ステータスの時' do
start_time = Time.current
before do
post '/api/v1/blog/articles/create', params: create_params
end
it '返却形式とリクエスト形式が正しいこと' do
assert_request_schema_confirm
assert_response_schema_confirm(204)
end
it 'リクエストした内容でBlogArticleが作られていること' do
blog_article = BlogArticle.find_by!(slug:)
expect(blog_article.slug).to eq slug
expect(blog_article.title).to eq title
expect(blog_article.content).to eq content + xss_example_sanitized
expect(blog_article.published_at).to be_between(start_time, Time.current)
end
end
describe '非公開ステータスの時' do
before do
post '/api/v1/blog/articles/create', params: create_params(isPublished: false)
end
it '返却形式とリクエスト形式が正しいこと' do
assert_request_schema_confirm
assert_response_schema_confirm(204)
end
it 'リクエストした内容でBlogArticleが作られていること' do
blog_article = BlogArticle.find_by!(slug:)
expect(blog_article.slug).to eq slug
expect(blog_article.title).to eq title
expect(blog_article.content).to eq content + xss_example_sanitized
expect(blog_article.published_at).to be_nil
end
end
end
あとは400エラーと401エラーとかが必要そうだけどとりあえず後回し
次はログイン機能的なのを作っていく予定