Open73

はじめてのRails実装日記

Ruby(v3) + Rails(v7) + Docker + MySQL で構築する
OSはWindows

RubyもRailsもDockerもSQLもド素人の状態からスタート

適当に検索して上の方に出てきた記事に従いつつ構築。
このあたりの記事を並行して見つつ進行した

記事に従い、 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

引用: https://qiita.com/zaru/items/cde2c46b6126867a1a64

今回はこれで実行

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」が適切らしい

routes.rb
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でやらなかったけど合ってそう?

routes.rb
Rails.application.routes.draw do
  namespace 'api' do
    namespace 'v1' do
      namespace :blog do
        get 'articles' => 'articles#index'
      end
    end
  end
end

seedsを設定する。
モックデータでもいいけどとりあえずわかりやすくしたいので、MicroCMSの現データから手入力

seeds.rb
# 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を指定する。

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
end

表示できた!

昇順降順とかレスポンスステータスとか諸々調整必要と思われるがとりあえず後回し。

slugを指定して1件だけ記事を表示するようなものを作ってみる。

ルートを追加。1件のみ取得の場合のactionは show となる。

config\routes.rb
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での絞り込みになる。

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
    
+     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を下記のように作成。

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 で自動補正されてしまうので、下記の記述を追加。

.editorconfig
[Makefile]
indent_style = tab
indent_size = 2

これでmakeコマンドが使えるようになった。

rubocopを導入する。
Gemfileに下記のように追加し、 make bundle した。

Gemfile
  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的な汎用テンプレートも無い。
調べてみてもこれ使えばいいよ的な汎用テンプレートみたいのもなかなか出てこなく、触ってみつつ自分にちょうど良い設定を探してる人が多そうな雰囲気。うーん難しい・・・。

とりあえず必要最低限の量のコンフィグにしてみる。

rubocop.yml
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 を登録。そして実行。

Makefile
+ 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

~~~

最終的にこうなった。

rubocop.yml
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してるので)

.rubocop_todo.yml
Layout/EmptyLineAfterGuardClause:
  Exclude:
    - 'bin/bundle'

db/schema.rb も自動生成のものなので削除 & exclude。

.rubocop.yml
AllCops:
  Exclude:
    - 'db/**/*'
    - 'config/**/*'
    - 'script/**/*'
    - 'node_modules/**/*'
    - 'bin/*'
    - '**/Gemfile'
    - 'vendor/**/*'
    - '.git/**/*'
+  - 'db/schema.rb'
  NewCops: enable

今更ながらmakeコマンドに引数渡せないことに気づいて修正。

Makefile
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環境なので無効に。

.rubocop_todo.yml
- Layout/EndOfLine:
-   Exclude:
-     - 'config/routes.rb'
.rubocop_todo.yml
# 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/environments/production.rb
- config.log_tags = [ :request_id ]
+ config.log_tags = [:request_id]
.rubocop_todo.yml
# Offense count: 4
# This cop supports safe auto-correction (--auto-correct).
Layout/SpaceInsidePercentLiteralDelimiters:
  Exclude:
    - 'Gemfile'

%記法の ()() の内側の前後にスペースが入っているとNG。

Gemfile
- gem "tzinfo-data", platforms: %i[ mingw mswin x64_mingw jruby ]
+ gem "tzinfo-data", platforms: %i[mingw mswin x64_mingw jruby]
.rubocop_todo.yml
# 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っていうグローバル変数があるのに再定義しようとして怒られてるらしい。
正直よくわかってないけどとりあえず言う通りに直す

config/environments/production.rb
-    logger           = ActiveSupport::Logger.new(STDOUT)
+    logger           = ActiveSupport::Logger.new($stdout)
.rubocop_todo.yml
# 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'

オブジェクトの書き方が古かったっぽい。

seeds.rb
- BlogArticle.create(
-   :slug => 'tsukurimashita',
+  slug: 'tsukurimashita',
   ...
.rubocop_todo.yml
# 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'

該当箇所

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 を変更。

.rubocop.yml
Style/TrailingCommaInHashLiteral:
  EnforcedStyleForMultiline: consistent_comma

Rubyだと有り無しどっちが一般的なんだろう。軽く調べた感じだとよくわからなかった。

.rubocop_todo.yml
# 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'

同じくケツカンマで怒られてるので設定を変更。

.rubocop.yml
+ Style/TrailingCommaInArguments:
+   EnforcedStyleForMultiline: consistent_comma
.rubocop_todo.yml
# 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 メソッドの書き方が冗長らしい。

config/puma.rb
- port ENV.fetch("PORT") { 3000 }
+ port ENV.fetch("PORT", 3000)

(JSのfetchとは全然違う用途だった。非同期的なやつかと思ってしまった。)

.rubocop_todo.yml
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

引用: https://qiita.com/tbpgr/items/61b9da235701df919ae5

今回はcompactで進めてたのでcompactにする。

rubocop.yml
+ Style/ClassAndModuleChildren:
+   EnforcedStyle: compact

インデントも抑えれるし個人的にはcompactのほうが好み。

.rubocop_todo.yml
# 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に必ずコメントをつけないと怒られる。
必須にするほどでも無いと思うので無効に。

rubocop.yml
+ Style/Documentation:
+   Enabled: false
.rubocop_todo.yml
# Offense count: 1
# Configuration parameters: IgnoredMethods.
Metrics/CyclomaticComplexity:
  Max: 9

1つの関数内に処理を詰め込みすぎると怒られる。
この記事がわかりやすかった。

https://gizanbeak.com/post/cyclomatic-complexity
  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かけずとも自分で意識すると思うのと、場合によっては致し方ない場合もある気がする・・・。ので無効に。

rubocop.yml
+ Metrics/CyclomaticComplexity:
+   Enabled: false
.rubocop_todo.yml
# Offense count: 1
# Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods.
Metrics/MethodLength:
  Max: 13

関数の行数制限。
これも自分で意識すれば良いと思うので無効に。

rubocop.yml
+ Metrics/MethodLength:
+   Enabled: false
.rubocop_todo.yml
# Offense count: 1
# Configuration parameters: IgnoredMethods.
Metrics/PerceivedComplexity:
  Max: 9

メソッドの複雑度。同じく無効に。

rubocop.yml
+  Metrics/PerceivedComplexity:
+    Enabled: false
.rubocop_todo.yml
# 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 をつけてもリテラル化できるがこれを省略できるようになるらしい。
ということで各ファイルに追加して完了。

rubocop_todo.yml
# 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ぐらいにしとく。

rubocop.yml
+ Layout/LineLength:
+   Max: 120
rubocop_todo.yml
# Offense count: 109
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline.
# SupportedStyles: single_quotes, double_quotes
Style/StringLiterals:
  Enabled: false

変数とかに " を使うか ' を使うか。今回は ' にする。

rubocop.yml
+ Style/StringLiterals:
+   EnforcedStyle: single_quotes
rubotop_todo.yml
# 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

引用 https://qiita.com/Yinaura/items/9928e32fa9fc9092f24c

english_styleのほうがわかりやすい気がするのでそっちに。

rubocop.yml
+ Style/SpecialGlobalVars:
+   EnforcedStyle: use_english_names
rubocop_todo.yml
# 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っていうのもあるようで見分けも付きにくそうなので。

rubocop.yml
+ Style/SymbolArray:
+   SupportedStyles: percent

これでrubocop_todoの消化は完了。

試しに適当にコントローラ作ってみる。

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が被ってる。必須で自動生成されるので新しく定義は不要だった。
  • キャメルケースではなくスネークケースで統一したい。
schema.rb
  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

マイグレーションファイルを修正。

db/migrate/20220326124922_create_blog_articles.rb
  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 する。
これできれいな形になった。

schema.rb
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つ作成。

swagger/src/index.yml
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ムズい・・・。

docker-compose.yml
  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
swagger/swagger-merger/Dockerfile
FROM node:alpine

RUN npm install -g swagger-merger watch

CMD ["swagger-merger"]

無事にredocが見れることを確認!
swagger/src 以下のファイルを触ると swagger/dist/swagger.yml に反映されることも確認できた。

distファイルはgit管理したくないので.gitignoreしとく。

.gitignore
+ # Merged swagger file
+ /swagger/dist/swagger.yml

ここで紛らわしいのでファイルの命名規則とかをswagger から openapi に統一した。

@openapitools/openapi-generator-cliを使って、TypeScriptの型定義ファイルを作れるようにセットアップしていく。

package.json

  "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を用意

package.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を設定する。

.github/workflows/publish-openapi-ts-package.yml
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のパッケージを作成する。

mainにpushしたときはマイナーバージョン、
それ以外のブランチでpushしたときは 1.0.0-${SHA} のようにバージョンを自動で生成できるようにCIを変える。

いくつかめちゃくちゃ詰まったのでコメントを入れた。特にnpm infoのところは難しかった。

.github/workflows/publish-openapi-ts-package.yml
  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
  

あとはrspecでopenapiの定義が使えるようにできればやりたいことは完了。

まずはgem諸々を導入。

Gemfile
group :development, :test do
  gem 'rspec-rails'
  gem 'factory_bot_rails'
  gem 'committee'
  gem 'committee-rails'
  gem 'faker'
end

設定をhelperに追記。

spec/rails_helper.rb
  # 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を作成。

spec/factories/blog_articles.rb
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

テストを作成。

spec/controllers/api/v1/blog/articles_controller.spec.rb
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を追加。

Gemfile
+ gem 'active_model_serializers'
+ gem 'fast_jsonapi'

serializerを作成。
set_key_transform :camel_lower がキモとなる部分。application_serializerに書いておくと全serializerに反映される。

app/serializers/application_serializer.rb
class ApplicationSerializer < ActiveModel::Serializer
  include FastJsonapi::ObjectSerializer
  set_key_transform :camel_lower
end
app/serializers/blog_article_serializer.rb
class BlogArticleSerializer < ApplicationSerializer
  attributes :id, :slug, :title, :content, :published_at, :created_at, :updated_at
end

controllerもserializerを挟むように修正。

app/serializers/application_serializer.rb
# 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に乗り換えることにする。

新規ファイルを作成。

config/initializers/active_model_serializer.rb
ActiveModelSerializers.config.key_transform = :camel_lower

この1行だけでキャメルケースになるらしい。何と楽ちんな。。

controller、serializerをそれぞれ書き直す。

app/controllers/api/v1/blog/articles_controller.rb
# 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
app/serializers/blog_article_serializer.rb
class BlogArticleSerializer < ActiveModel::Serializer
  attributes :id, :slug, :title, :content, :published_at, :created_at, :updated_at
end

無事camelCaseに!

そしてテストも通るようになった 🎉🎉🎉

spec/requests/api/v1/blog/articles_spec.rb
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 で実行させる。

.github/workflows/rspec-rubocop.yml
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 にしないと動かなかった。

config/database.ci.yml
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

記事の投稿機能を作成する

openapi/src/paths/blog/articles_create.yml
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: "成功"

app/controllers/api/v1/blog/articles_controller.rb
  # 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のところはもうちょっと良い書き方あったりするのかな

spec/requests/api/v1/blog/articles_spec.rb
  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エラーとかが必要そうだけどとりあえず後回し

次はログイン機能的なのを作っていく予定

ログインするとコメントできます