🚀

Rails7 API × OpenAPI × Rswag × Docker の環境構築方法

2023/07/28に公開

はじめに

Ruby を使った API 開発に必要な最小限の構成での環境構築方法をまとめてみました。

環境

  • Ruby: 3.2.2
  • Rails: 7.0.6
  • MySQL: 8.0

コード

https://github.com/kzy52/rails-openapi-rswag-docker-example

ライブラリ

active_interaction

https://github.com/AaronLasseigne/active_interaction

  • command パターンでアプリケーション固有のビジネスロジックを実装できます。

Panko Serializers

https://github.com/panko-serializer/panko_serializer

  • API のレスポンス(JSON) の形式を整えて扱いやすくするための gem です。

anyway_config

https://github.com/palkan/anyway_config

  • 設定管理系の gem です。

enumerize

https://github.com/brainspec/enumerize

  • モデルで enum(列挙型) を使う時に便利な gem です。

faker

https://github.com/faker-ruby/faker

  • ダミーデータを手軽に作成できる gem です。

rswag

https://github.com/rswag/rswag

  • テストファーストな API 開発ができる gem です。
  • RSpec から swagger.yml を生成できるようになります。

seed-fu

https://github.com/mbleigh/seed-fu

  • 開発時のデータ作成に使っています。

json-schema_builder

https://github.com/parrish/json-schema_builder

  • rswag-specs でリクエスト、レスポンスデータの共通化に利用しています。

事前準備

$ mkdir demo-api
$ cd demo-api

Gemfile を作成します。

$ bundle init

Rails を有効にします。

Gemfile
# frozen_string_literal: true

 source "https://rubygems.org"

-# gem "rails"
+gem "rails"

Docker のビルドに必要なので空の Gemfile.lock を作成します。

$ touch Gemfile.lock

Docker 用のファイルの作成

Dockerfile
FROM ruby:3.2.2

RUN apt update -qq && apt install -y --no-install-recommends vim

ENV LANG=C.UTF-8

WORKDIR /app
COPY Gemfile /app/Gemfile
COPY Gemfile.lock /app/Gemfile.lock
RUN bundle install # rails new が終わったら削除する

COPY containers/entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000

CMD ["rails", "server", "-b", "0.0.0.0"]
docker-compose.yml
version: '3.4'

services:
  app:
    build: .
    image: demo-api:1.0.0
    command: /bin/sh -c "rm -f tmp/pids/server.pid && rails server -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/app
      - bundle:/usr/local/bundle
    environment:
      - RAILS_ENV=development
      - APP_DEFAULT_URL_HOST=localhost
      - APP_DEFAULT_URL_PORT=3000
      - DB_DATABASE_HOST=mysql
      - DB_DATABASE_USER=root
      - DB_DATABASE_PASSWORD=
    depends_on:
      mysql:
        condition: service_healthy
    links:
      - mysql
    ports:
      - '3000:3000'
    tmpfs:
      - /tmp
    stdin_open: true
    tty: true

  mysql:
    image: mysql:8.0
    platform: linux/amd64
    volumes:
      - ./containers/mysql:/etc/mysql/conf.d/
      - mysql:/var/lib/mysql
    healthcheck:
      test: mysqladmin ping -h mysql -P 3306 -u root
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
    ports:
      - '4306:3306'

volumes:
  bundle:
  mysql:

containers/mysql/my.cnf
[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_bin

[mysql]
default-character-set=utf8mb4

[client]
default-character-set=utf8mb4

Dockerfile 内で指定する Entrypoint 用のシェルファイルを用意します。
Quickstart: Compose and Rails を参考にしました。

containers/entrypoint.sh
#!/bin/bash
set -e

# Remove a potentially pre-existing server.pid for Rails.
rm -f /app/tmp/pids/server.pid

# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"

Rails プロジェクトの作成

$ docker-compose run app rails new . --force --api -d mysql --minimal --skip-test --skip-bundle
  • --force : ファイルが存在する場合に上書きします。
  • --api : API モードで Rails アプリケーションを生成します。
  • -d mysql : MySQL を使用するようにします。
  • --minimal : 最小限の Rails アプリケーションを生成します。
  • --skip-test : RSpec を使用するので Minitest を生成しないようにします。
  • --skip-bundle : 後で Gemfile を変更するのでここでは bundle をスキップします。

Makefile の作成

Makefile
RUN := run --rm
DOCKER_COMPOSE_RUN := docker-compose $(RUN)
args := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))

init:
	@make build
	@make bundle.install
	@make db.create
	@make db.migrate
	@make db.seed

build:
	docker-compose build

rebuild:
	docker-compose build --force-rm --no-cache

up:
	docker-compose up

upd:
	docker-compose up -d

down:
	docker-compose down

attach:
	docker attach (docker-compose ps -q app)

bash:
	${DOCKER_COMPOSE_RUN} app bash

bundle.install:
	${DOCKER_COMPOSE_RUN} app bundle install

bundle.update:
	${DOCKER_COMPOSE_RUN} app bundle update

console:
	${DOCKER_COMPOSE_RUN} app rails c

db.console:
	${DOCKER_COMPOSE_RUN} app rails dbconsole

db.create:
	${DOCKER_COMPOSE_RUN} app rails db:create
	${DOCKER_COMPOSE_RUN} app rails db:create RAILS_ENV=test

db.migrate:
	${DOCKER_COMPOSE_RUN} app rails db:migrate
	${DOCKER_COMPOSE_RUN} app rails db:migrate RAILS_ENV=test

db.seed:
	${DOCKER_COMPOSE_RUN} app rails db:seed

db.reset:
	${DOCKER_COMPOSE_RUN} app rails db:migrate:reset
	@make db.seed

annotate:
	${DOCKER_COMPOSE_RUN} app annotate

rails:
	${DOCKER_COMPOSE_RUN} app rails $(args)

routes:
	${DOCKER_COMPOSE_RUN} app rails routes

rspec:
	${DOCKER_COMPOSE_RUN} -e RAILS_ENV=test app rspec $(args)

rubocop:
	${DOCKER_COMPOSE_RUN} app rubocop $(args)

rubocop.fix:
	${DOCKER_COMPOSE_RUN} app rubocop -a

brakeman:
	${DOCKER_COMPOSE_RUN} app brakeman

down.all:
	if [ -n "`docker ps -q`" ]; then docker kill `docker ps -q`; fi
		docker container prune -f

swagger.create:
	${DOCKER_COMPOSE_RUN} -e RAILS_ENV=test app rspec --format Rswag::Specs::SwaggerFormatter --order defined --pattern 'spec/requests/**/*_spec.rb'

credential.edit:
	${DOCKER_COMPOSE_RUN} -e EDITOR=vim app rails credentials:edit -e $(args)

必要な gem の追加

すべて置き換えます。

Gemfile
# frozen_string_literal: true

source 'https://rubygems.org'

git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby '3.2.2'

gem 'rails', '~> 7.0.5'

gem 'active_interaction', '~> 5.2'
gem 'anyway_config', '~> 2.0'
gem 'enumerize'
gem 'mysql2', '~> 0.5'
gem 'panko_serializer'
gem 'puma', '~> 5.0'
gem 'rack-cors'
gem 'rswag'
gem 'seed-fu', '~> 2.3'
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]

group :development, :test do
  gem 'bullet'
  gem 'debug', platforms: %i[mri mingw x64_mingw]
  gem 'factory_bot_rails'
  gem 'faker'
  gem 'gimei'
end

group :production, :staging do
  gem 'aws-healthcheck'
end

group :development do
  gem 'annotate'
  gem 'brakeman'
  gem 'rubocop', require: false
  gem 'rubocop-performance'
  gem 'rubocop-rails'
  gem 'rubocop-rspec'
  gem 'solargraph'
end

group :test do
  gem 'json-schema_builder'
  gem 'rspec-rails'
end
$ make bundle.install

RuboCop の設定ファイルの追加

.rubocop.yml
require:
  - rubocop-performance
  - rubocop-rails
  - rubocop-rspec

AllCops:
  NewCops: enable
  DisplayCopNames: true
  TargetRubyVersion: 3.2
  Include:
    - '**/*.rake'
    - '**/*.rb'
    - 'Gemfile'
  Exclude:
    - 'bin/**/*'
    - 'config/boot.rb'
    - 'config/environment.rb'
    - 'config/environments/**/*'
    - 'config/puma.rb'
    - 'db/schema.rb'
    - 'vendor/**/*'
  SuggestExtensions: false

Rails:
  Enabled: true

############################################################
#################### Style ################################
############################################################
Style/AndOr:
  EnforcedStyle: conditionals

Style/AsciiComments:
  Enabled: false

Style/BarePercentLiterals:
  EnforcedStyle: percent_q

Style/ClassAndModuleChildren:
  Enabled: false

Style/ClassCheck:
  EnforcedStyle: is_a?

Style/Documentation:
  Enabled: false

Style/DoubleNegation:
  Enabled: false

Style/FormatString:
  EnforcedStyle: format

Style/FrozenStringLiteralComment:
  SafeAutoCorrect: true

Style/GuardClause:
  Enabled: false
  MinBodyLength: 3

Style/IfUnlessModifier:
  Enabled: false

Style/Lambda:
  EnforcedStyle: literal

Style/RedundantReturn:
  Enabled: false

Style/RedundantSelf:
  Enabled: false

Style/SignalException:
  EnforcedStyle: only_raise

############################################################
###################### Lint ################################
############################################################
Lint/EmptyBlock:
  Exclude:
    - 'spec/models/**/*'
    - 'spec/factories/**/*'

############################################################
###################### Layout ##############################
############################################################
Layout/LineLength:
  Max: 120
  Exclude:
    - 'db/fixtures/**/*'
    - 'spec/**/*'

Layout/SpaceAroundEqualsInParameterDefault:
  EnforcedStyle: space

Layout/SpaceInsideBlockBraces:
  EnforcedStyle: space
  EnforcedStyleForEmptyBraces: no_space

Layout/SpaceInsideHashLiteralBraces:
  EnforcedStyle: space
  EnforcedStyleForEmptyBraces: no_space

Layout/TrailingEmptyLines:
  EnforcedStyle: final_newline

Layout/EmptyLineBetweenDefs:
  AllowAdjacentOneLineDefs: true

Layout/MultilineMethodCallIndentation:
  EnforcedStyle: aligned

############################################################
#################### Naming ################################
############################################################
Naming/VariableNumber:
  EnforcedStyle: snake_case

############################################################
#################### Metrics ###############################
############################################################
Metrics/AbcSize:
  Max: 30
  Exclude:
    - 'db/migrate/**/*'

Metrics/BlockLength:
  CountComments: false
  Max: 25
  Exclude:
    - 'config/routes.rb'
    - 'spec/**/*'

Metrics/BlockNesting:
  Max: 3

Metrics/ClassLength:
  Max: 150
  Exclude:
    - 'spec/**/*'

Metrics/CyclomaticComplexity:
  Max: 6

Metrics/MethodLength:
  Max: 25
  Exclude:
    - 'db/migrate/*.rb'
    - 'spec/schemas/**/*'

Metrics/ModuleLength:
  Max: 100
  Exclude:
    - 'spec/**/*'

Metrics/ParameterLists:
  Max: 5
  CountKeywordArgs: true

Metrics/PerceivedComplexity:
  Max: 9


############################################################
###################### RSpec ###############################
############################################################
RSpec/ContextWording:
  Enabled: false

RSpec/EmptyExampleGroup:
  Exclude:
    - 'spec/models/**/*'

RSpec/ExampleLength:
  Enabled: false

RSpec/MultipleExpectations:
  Enabled: false

RSpec/MultipleMemoizedHelpers:
  Max: 7

RSpec/NamedSubject:
  Enabled: false

RSpec/NestedGroups:
  Max: 6

RSpec/VariableName:
  Exclude:
    - 'spec/requests/**/*'
  EnforcedStyle: snake_case

Panko Serializers 用のファイルの作成

app/serializers/application_serializer.rb
# frozen_string_literal: true

class ApplicationSerializer < Panko::Serializer
end

active_interaction 用のファイルの作成

app/services/application_service.rb
# frozen_string_literal: true

class ApplicationService < ActiveInteraction::Base
end

日本語化用のファイルを追加します。

https://raw.githubusercontent.com/AaronLasseigne/active_interaction/main/lib/active_interaction/locale/ja.yml

config/locales/services/defaults/ja.yml
ja.ymlの中身を貼り付けてください

タイムゾーンの設定

config/application.rb
module App
  class Application < Rails::Application
    ...
    config.api_only = true
+
+   config.time_zone = 'Tokyo'
  end
end

ロケール設定追加

config/initializers/locale.rb
# frozen_string_literal: true

I18n.enforce_available_locales = true
Rails.application.config.i18n.load_path += Dir[Rails.root.join('config/locales/**/*.{rb,yml}').to_s]
Rails.application.config.i18n.available_locales = %i[ja en]
Rails.application.config.i18n.default_locale = :ja

日本語化用のファイルを追加します。

https://raw.githubusercontent.com/svenfuchs/rails-i18n/master/rails/locale/ja.yml

config/locales/ja.yml
ja.ymlの中身を貼り付けてください

不要であれば消しておきます。

$ rm config/locales/en.yml

Anyway Config の設定追加

$ make rails g anyway:install

アプリケーション全体の設定

config/configs/app_config.rb
# frozen_string_literal: true

class AppConfig < ApplicationConfig
  attr_config :application_name,
              :secret_key_base,
              :default_url_host,
              :default_url_port
end
config/app.yml
default: &default
  application_name: 'Demo'

development:
  <<: *default

test:
  <<: *default

production:
  <<: *default

データベースの設定

config/configs/db_config.rb
# frozen_string_literal: true

class DbConfig < ApplicationConfig
  attr_config :database_name,
              :database_host,
              :database_user,
              :database_password,
              :database_socket,
              :database_pool
end
config/db.yml
default: &default
  database_name: 'demo'
  database_socket: '/tmp/mysql.sock'
  database_pool: 5

development:
  <<: *default

test:
  <<: *default
  database_name: 'demo_test'

production:
  <<: *default

合わせて config/database.yml と config/application.rb も修正します。

config/database.yml
default: &default
  adapter: mysql2
  encoding: utf8mb4
  charaset: utf8mb4
  host:     <%= DbConfig.database_host %>
  database: <%= DbConfig.database_name %>
  username: <%= DbConfig.database_user %>
  password: <%= DbConfig.database_password %>
  socket:   <%= DbConfig.database_socket %>
  pool:     <%= DbConfig.database_pool %>

development:
  <<: *default

test:
  <<: *default

production:
  <<: *default
config/application.rb
module App
  class Application < Rails::Application
    ...
    config.time_zone = 'Tokyo'
+
+   Rails.application.routes.default_url_options[:host] = AppConfig.default_url_host
+   Rails.application.routes.default_url_options[:port] = AppConfig.default_url_port
  end
end

RSpec の設定追加

$ make rails generate rspec:install

spec/support 配下のファイルがロードされるようにします。

spec/rails_helper.rb
-# Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f }
+Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }

factory_bot の設定追加

spec/support/factory_bot.rb
# frozen_string_literal: true

RSpec.configure do |config|
  config.include FactoryBot::Syntax::Methods

  config.before do
    FactoryBot.reload
  end
end

ジェネレーターの設定変更

config/initializers/generators.rb
# frozen_string_literal: true

Rails.application.config.generators do |g|
  g.test_framework :rspec, fixtures: true
  g.fixture_replacement :factory_bot, dir: 'spec/factories'
end

annotate の設定追加

$ make rails g annotate:install

lib 配下のファイルをロードするようにします。

config/application.rb
module App
  class Application < Rails::Application
    ...
    config.time_zone = 'Tokyo'

+   config.autoload_paths << Rails.root.join('lib')
  end
end

Bullet の設定追加

$ make rails g bullet:install
Enabled bullet in config/environments/development.rb
Would you like to enable bullet in test environment? (y/n) n

Enumerize の設定追加

config/locales/enumerize.ja.yml
ja:
  enumerize:

API 関連の設定

CORS の設定追加

config/initializers/cors.rb
-# Rails.application.config.middleware.insert_before 0, Rack::Cors do
-#   allow do
-#     origins "example.com"
-#
-#     resource "*",
-#       headers: :any,
-#       methods: [:get, :post, :put, :patch, :delete, :options, :head]
-#   end
-# end
+Rails.application.config.middleware.insert_before 0, Rack::Cors do
+  allow do
+    # TODO: ドメインが決まったら設定すること
+    # origins 'example.com'
+
+    resource '*',
+             headers: :any,
+             methods: %i[get post put patch delete options head]
+  end
+end

rswag の設定追加

$ make rails g rswag:install
spec/swagger_helper.rb
# frozen_string_literal: true

require 'rails_helper'
 require 'rails_helper'
+require 'json/schema_builder'
+
+JSON::SchemaBuilder.configure do |opts|
+  opts.validate_schema = true
+  opts.strict = true
+end
+
+Dir[Rails.root.join('spec/schemas/**/*.rb')].each { |file| require file }

RSpec.configure do |config|
  # Specify a root folder where Swagger JSON files are generated
  # NOTE: If you're using the rswag-api to serve API descriptions, you'll need
  # to ensure that it's configured to serve Swagger from the same folder
  config.swagger_root = Rails.root.join('swagger').to_s
  # Define one or more Swagger documents and provide global metadata for each one
  # When you run the 'rswag:specs:swaggerize' rake task, the complete Swagger will
  # be generated at the provided relative path under swagger_root
  # By default, the operations defined in spec files are added to the first
  # document below. You can override this behavior by adding a swagger_doc tag to the
  # the root example_group in your specs, e.g. describe '...', swagger_doc: 'v2/swagger.json'
  config.swagger_docs = {
    'v1/swagger.yaml' => {
      openapi: '3.0.1',
      info: {
        title: 'API V1',
        version: 'v1'
      },
      paths: {},
      servers: [
        {
          url: 'http://{defaultHost}',
          variables: {
            defaultHost: {
-              default: 'www.example.com'
+              default: 'localhost:3000'
            }
          }
        }
      ]
    }
  }
  # Specify the format of the output Swagger file when running 'rswag:specs:swaggerize'.
  # The swagger_docs configuration option has the filename including format in
  # the key, this may want to be changed to avoid putting yaml in json files.
  # Defaults to json. Accepts ':json' and ':yaml'.
  config.swagger_format = :yaml
end
$ mkdir -p {'spec/schemas','spec/requests','spec/services'}
$ touch {'spec/schemas/.keep','spec/requests/.keep','spec/services/.keep'}

Seed Fu の設定追加

db/seeds.rb
SeedFu.seed
$ mkdir -p db/fixtures/
$ touch db/fixtures/.keep

Credentials の設定

$ make rails secret
$ make credential.edit development

app:
  secret_key_base: 'make rails secret で出力された値を設定'
$ make rails secret
$ make credential.edit test

app:
  secret_key_base: 'make rails secret で出力された値を設定'
$ make rails secret
$ make credential.edit production

app:
  secret_key_base: 'make rails secret で出力された値を設定'

db:
  database_host: ''
  database_user: ''
  database_password: ''

GitHub Actions の設定

.github/workflows/ci.yml
name: CI

on:
  pull_request:
    branches:
      - main
      - feature/*

jobs:
  ci:
    name: 'CI'
    runs-on: ${{ matrix.os }}
    env:
      RAILS_ENV: test
      APP_DEFAULT_URL_HOST: localhost
      APP_DEFAULT_URL_PORT: 3000
      DB_DATABASE_HOST: 127.0.0.1
      DB_DATABASE_USER: root
      DB_DATABASE_PASSWORD: root
      RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
    strategy:
      matrix:
        os: [ubuntu-latest]
        ruby-version: ['3.2.2']
    steps:
      - name: Checkout 🛎
        uses: actions/checkout@master

      - name: Set up Ruby ✨
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: ${{ matrix.ruby-version }}
          bundler-cache: true

      - name: Cache gems 📦
        uses: actions/cache@preview
        with:
          path: vendor/bundle
          key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }}
          restore-keys: |
            ${{ runner.os }}-gem-

      - name: Start MySQL 🚀
        run: sudo /etc/init.d/mysql start

      - name: Setup Project 👔
        run: bin/rails db:prepare

      - name: Install dependencies 💻
        run: |
          gem install bundler --no-document
          bundle config set --local path 'vendor/bundle'
          bundle install --jobs 4

      - name: Run rubocop 🤖
        run: bundle exec rubocop

      - name: Run brakeman ⚠
        run: bundle exec brakeman

      - name: Run tests 🧪
        run: bundle exec rspec ./spec

VSCode の設定

.vscode/extensions.json
{
  "recommendations": [
    "aki77.rails-db-schema",
    "bung87.rails",
    "castwide.solargraph",
    "GitHub.copilot",
    "Hridoy.rails-snippets",
    "KoichiSasada.vscode-rdbg",
    "LoranKloeze.ruby-rubocop-revived",
    "noku.rails-run-spec-vscode",
    "rebornix.ruby"
  ],
  "unwantedRecommendations": []
}
.vscode/settings.json
{
  "[ruby]": {
    "editor.tabSize": 2,
    "editor.formatOnSave": true,
    "editor.defaultFormatter": "LoranKloeze.ruby-rubocop-revived"
  },
  "ruby.intellisense": "rubyLocate",
  "ruby.useLanguageServer": false,
  "ruby.useBundler": false,
  "ruby.format": "rubocop",
  "ruby.lint": {
    "rubocop": {
      "forceExclusion": true
    }
  },
  "ruby.rubocop.useBundler": false,
  "solargraph.useBundler": false
}

DB の作成

$ make db.create

API Doc ファイルの生成

$ make swagger.create

不要なコードの削除

Dockerfile
-RUN bundle install # rails new が終わったら削除する

動作確認

http://localhost:3000/

http://localhost:3000/api-docs/index.html

Tandems Inc. TECH BLOG

Discussion