20190127のRailsに関する記事は14件です。

ActiveStorageのvariantを利用して写真にコメントを重ね合わせる

概要

  • 表題まま
  • Imagemagickでできることなら、variantを使うとすっきりかけます

環境

  • Rails 6 beta 1
  • Docker

コード

スクショ

Image from Gyazo

Image from Gyazo

解説

ActiveStorageにはvariantという便利なメソッドがあります。これは、アップロードした画像をリサイズする事ができます。
たとえば、下記のようにimageにアップロードされる場合は、 item.image.variant(resize: "100x100")参考 rails guid) と書くと 100x100の大きさにリサイズできます。
また、variantで処理すると便利なのが processed。 item.vaiant(resize: '100x100').processed と書くと最初は動的にimageをリサイズします。しかし、2回目以降は、1回目のときに画像をキャッシュするので、画像の表示を効率的に行うことができます。(参考

class Item < ApplicationRecord
  has_one_attached :image
end

variantは、ImageMagickができることは何でもできます。具体的には、こちら のページのオプションを組み合わせることでカスタムされたvariantを自作できます。

たとえば、今回は以下の箇所でつかっています。

app/models/item.rb
# frozen_string_literal: true

class Item < ApplicationRecord
# 省略
  def resize_image(width = 500, height = 500)
    variation = ActiveStorage::Variation.new(
      Uploads.resize_to_fit(width: width, height: height, blob: image.blob, message: message)
    )
    ActiveStorage::Variant.new(image.blob, variation)
  end
end

app/models/concerns/uploads.rb
# frozen_string_literal: true

# REF http://www.carlosramireziii.com/what-options-can-be-passed-to-the-active-storage-variant-method.html?utm_source=rubyflow
# REF https://prograils.com/posts/rails-5-2-active-storage-new-approach-to-file-uploads

class Uploads
  class << self
    def jpeg?(blob)
      blob.content_type.include? 'jpeg'
    end

    def optimize
      {
        strip: true
      }
    end

    def optimize_jpeg
      {
        strip: true,
        'sampling-factor' => '4:2:0',
        quality: '85',
        interlace: 'JPEG',
        colorspace: 'sRGB'
      }
    end

    def optimize_hash(blob)
      return optimize_jpeg if jpeg? blob

      optimize
    end

    def resize_to_fit(width: nil, height: nil, blob:, message:)
      n = 5
      pointsize = width / n
      num = n - 1
      fill = 'red'
      gravity = 'north'
      resize = resize(width: width, height: height)
      optimize_hash(blob).merge(resize: resize,
                                combine_options: { font: font, fill: fill, gravity: gravity, pointsize: pointsize,
                                                   draw: "text 0,#{pointsize / 5} '#{new_lined_message(message, num)}' " })
    end

    private

    def new_lined_message(message, length = 4)
      message.scan(/.{1,#{length}}/).join("\n")
    end

    def font
      # https://googlefonts.github.io/japanese/
      # '.fonts/FontopoNIKUKYU.otf'
      # '.fonts/ipaexg.ttf'
      '.fonts/nicomoji-plus_1.11.ttf'
    end

    def resize(width:, height:)
      if width && height
        "#{width}x#{height}"
      elsif width
        "#{width}x"
      else
        "x#{height}"
      end
    end
  end
end

Uploads#resize_to_fit の resize_to_fitがミソです。特にこの箇所が画像処理をしているところです。
fontでフォントを指定して、fillで文字色を規定して、gravityで文字を表示する場所を指定して、pointsizeで文字の大きさを指定します。
また、fontは、ここを見てgoogleのwebからダウンロードして .fonts フォルダに入れました。
さらに、drawを使って画像に描画したい文字をしていします。こちらで機能を照らし合わせるとわかりやすいです。

(resize: resize,
                                combine_options: { font: font, fill: fill, gravity: gravity, pointsize: pointsize,
                                                   draw: "text 0,#{pointsize / 5} '#{new_lined_message(message, num)}' " }

これを実際に使う場合は以下の通り。

<%= image_tag item.resize_image(300, nil).processed %>

参考

以上。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ActiveStorageのvariantを利用して画像に文字を重ね合わせる

概要

  • 表題まま
  • Imagemagickでできることなら、variantを使うとすっきりかけます

環境

  • Rails 6 beta 1
  • Docker

コード

スクショ

Image from Gyazo

Image from Gyazo

解説

ActiveStorageにはvariantという便利なメソッドがあります。これは、アップロードした画像をリサイズする事ができます。
たとえば、下記のようにimageにアップロードされる場合は、 item.image.variant(resize: "100x100")参考 rails guid) と書くと 100x100の大きさにリサイズできます。
また、variantで処理すると便利なのが processed。 item.vaiant(resize: '100x100').processed と書くと最初は動的にimageをリサイズします。しかし、2回目以降は、1回目のときに画像をキャッシュするので、画像の表示を効率的に行うことができます。(参考

class Item < ApplicationRecord
  has_one_attached :image
end

variantは、ImageMagickができることは何でもできます。具体的には、こちら のページのオプションを組み合わせることでカスタムされたvariantを自作できます。

たとえば、今回は以下の箇所でつかっています。

app/models/item.rb
# frozen_string_literal: true

class Item < ApplicationRecord
# 省略
  def resize_image(width = 500, height = 500)
    variation = ActiveStorage::Variation.new(
      Uploads.resize_to_fit(width: width, height: height, blob: image.blob, message: message)
    )
    ActiveStorage::Variant.new(image.blob, variation)
  end
end

app/models/concerns/uploads.rb
# frozen_string_literal: true

# REF http://www.carlosramireziii.com/what-options-can-be-passed-to-the-active-storage-variant-method.html?utm_source=rubyflow
# REF https://prograils.com/posts/rails-5-2-active-storage-new-approach-to-file-uploads

class Uploads
  class << self
    def jpeg?(blob)
      blob.content_type.include? 'jpeg'
    end

    def optimize
      {
        strip: true
      }
    end

    def optimize_jpeg
      {
        strip: true,
        'sampling-factor' => '4:2:0',
        quality: '85',
        interlace: 'JPEG',
        colorspace: 'sRGB'
      }
    end

    def optimize_hash(blob)
      return optimize_jpeg if jpeg? blob

      optimize
    end

    def resize_to_fit(width: nil, height: nil, blob:, message:)
      n = 5
      pointsize = width / n
      num = n - 1
      fill = 'red'
      gravity = 'north'
      resize = resize(width: width, height: height)
      optimize_hash(blob).merge(resize: resize,
                                combine_options: { font: font, fill: fill, gravity: gravity, pointsize: pointsize,
                                                   draw: "text 0,#{pointsize / 5} '#{new_lined_message(message, num)}' " })
    end

    private

    def new_lined_message(message, length = 4)
      message.scan(/.{1,#{length}}/).join("\n")
    end

    def font
      # https://googlefonts.github.io/japanese/
      # '.fonts/FontopoNIKUKYU.otf'
      # '.fonts/ipaexg.ttf'
      '.fonts/nicomoji-plus_1.11.ttf'
    end

    def resize(width:, height:)
      if width && height
        "#{width}x#{height}"
      elsif width
        "#{width}x"
      else
        "x#{height}"
      end
    end
  end
end

Uploads#resize_to_fit の resize_to_fitがミソです。特にこの箇所が画像処理をしているところです。
fontでフォントを指定して、fillで文字色を規定して、gravityで文字を表示する場所を指定して、pointsizeで文字の大きさを指定します。
また、fontは、ここを見てgoogleのwebからダウンロードして .fonts フォルダに入れました。
さらに、drawを使って画像に描画したい文字をしていします。こちらで機能を照らし合わせるとわかりやすいです。

(resize: resize,
                                combine_options: { font: font, fill: fill, gravity: gravity, pointsize: pointsize,
                                                   draw: "text 0,#{pointsize / 5} '#{new_lined_message(message, num)}' " }

これを実際に使う場合は以下の通り。

<%= image_tag item.resize_image(300, nil).processed %>

参考

以上。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails初心者が勉強のためにサービスを作ってみた

初めての投稿になります。
スマホアプリ開発がメインですが、サーバーサイドの勉強のとしてRailsでサービスを作ったのでまとめました。
一通り終えてから一気に書いているため、抜けや誤りがあるかもしれませんがご容赦ください。

概要

どんなもの?

楽天ブックス書籍検索APIを使って書籍検索をしたり、キーワードを登録しておいて日次バッチで検索して結果の更新があったら通知してくれるというもの。
定期的に新しい技術書を探すときに同じキーワードで探しているので、勝手にやってくれると助かるなと思ったので。

実装した機能

  • ユーザー登録/編集
  • ログイン/ログアウト
  • APIキー登録
  • 書籍検索
  • キーワード登録
  • お気に入り
  • 管理画面
  • メール送信
  • バッチ処理(キーワード検索&通知)

開発環境

  • ruby 2.5.1p57
  • rails 5.2.1
  • psql (PostgreSQL) 10.5
  • heroku 7.19.4

セットアップ時はこれが最新だったと思います。

サーバー

Herokuを使用することにしました。
理由はコストがかからないためです(金銭的にも時間的にも)。
Railsの勉強用、かつインフラの勉強がしたいわけではないのでこれで十分でした。

アドオンは下記のものを導入しています。

その他

  • Bootstrap4:画面構築用のフレームワーク

完成画面(一部)

search.png

開発を始める前に

以下の準備を行っておく必要があります。

Mac

開発PCには前述の開発環境をセットアップしておきます。
インストール方法はすでにいろいろ記事がありますので割愛します。

Heroku

Herokuは初めて使うのでアカウントを用意します。
一部のアドオンの利用のため、クレジットカード登録も行います。
基本料金はないので課金されない範囲で使います。

Git

HerokuではGitリポジトリが必要になります。
最初から公開を意識してGitHubに作成します。

API

Rakuten Developersのアカウントを用意し、アプリの登録を行います。

データベース設計

データベースの構成を最初に見ていただくとイメージが湧きやすいと思いますので紹介しておきます。
ちゃんと設計して作成したわけではなく、手探りで作成していくうちに下図のようになりました。

database.png

Railsアプリケーションの作成

ここから開発の作業に入っていきます。
おおむね下記の手順でプロジェクト作成からデプロイまでが行なえます。

  1. GitHubリポジトリ作成/クローン
  2. Railsアプリ作成
  3. Gitコミット/プッシュ
  4. Herokuログイン
  5. Herokuアプリ作成
  6. Herokuへのデプロイ
  7. Herokuアプリを開く

コマンド

# 2. Railsアプリケーションの作成
rails new booksearch-XXXXX —database=postgresql
# 4. ログイン
heroku login
# 5. アプリケーションの作成
heroku create
# 6. デプロイ
git push heroku master
# 7. アプリを開く
heroku open

ここまでくれば、ひとまず空のRailsアプリケーションがデプロイ出来たことになります。
あとは各機能を追加して適宜デプロイしていくだけです。

詳細な実装を紹介する前に、開発でよく使うコマンドを紹介します。
これらは実装の紹介では登場しませんが、実際には何度も使用しました。

Railsコマンド

# データベースの作成
rake db:create

「rails new」の際に作られていると思いますが、ちょくちょく構成を見直して作り直してました。
本来カラム追加でいちから作り直す必要はないはずですが、初版なので作り直してました。

# テーブルの追加
rails db:migrate

モデルが追加されたときに実行するコマンドです。

# データベース削除
rails db:drop:all

いちからやり直したいときに削除します。

# データベースクライアントの起動
rails dbconsole

主にデバッグの際にデータを確認したいときに使います。SQLコマンドは割愛します。

# ルートを確認
rails routes

ルートの設定が怪しいときに確認するものです。

Herokuコマンド

# ローカルで実行する
heroku local

ローカルでサーバーが立ち上がるという点で「rails server」と同じですが、ポートが異なります。
最終的にHerokuにデプロイするのでこちらを使ったほうが良さそう。

# developブランチの内容をデプロイする
git push heroku develop:master

開発中は結構需要あります。当然featureブランチでもプッシュされていればOKです。

# テーブルの作成
heroku run rake db:migrate

これはローカルの場合と同じで、モデルが追加されたら実行する必要があります。
カラム追加時に一旦アドオンごと消して再度追加してこのコマンドを、ということもしばしば。

各機能の実装

それでは各機能の実装を見てきましょう。

APIキー登録

楽天ブックス書籍検索APIを呼び出すためのAPIキーを登録する機構を作ります。
ソースコードが非公開ならベタ書きでも良いのですが、今回は最初からソースコードは公開するつもりだったので外部から設定する形を取ります。
※Herokuの環境変数に設定するという方法も検討したのですが、後述のバッチ処理の際に読み出すことが出来なかったため最初に思いついたこの方法を採っています

Scaffoldでサクッと作ってしまいます。

rails generate scaffold api_key key:string

関連ファイルが一気に作成されたと思います。
テーブルを追加するためのコマンドも実行します。
APIキーを1件登録したいだけなので多少無駄がありますが、とりあえずAPIキーの登録ができるようになりました。
複数件登録できてしまいますが、目をつむります。

書籍検索

いちばん重要な部分です。
まずは、モデルを追加します。

rails generate model book title:string:index genre:string price:integer author:string publisher:string isbn:string caption:string sales_date:string item_url:string largeimage_url:string

一部のNullを許容しないカラムについては、db/migrage/xxx_create_books.rbにて「:null => false」を付与しておきます。
上記の修正を行ってから、先程と同様にテーブル追加のコマンドを実行します。

次に、APIを叩く処理を作ります。
最終的なコードをそのまま載せていますが、通信しているのはsearch()のNet::HTTP.startの部分になります。
ジャンルについては少し加工して使うようにしています。

book_searcher.rb
require 'net/https'

class BookSearcher

  def search(word, page, genre_id)
    if genre_id.present?
      genre = genre_id
    else
      genre = 'null'
    end
    Rails.logger.debug('BookSearcher::search() genre=' + genre)
    params = URI.encode_www_form({applicationId: get_apikey, format: 'json', formatVersion: 2, keyword: word, hits: 20, page: page, booksGenreId: genre, sort: '-releaseDate'})
    uri = URI.parse("https://app.rakuten.co.jp/services/api/BooksTotal/Search/20170404?#{params}")

    response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
      http.open_timeout = 5
      http.read_timeout = 10
      http.get(uri.request_uri)
    end
    begin
      case response
      when Net::HTTPSuccess
        @result = JSON.parse(response.body)
      when Net::HTTPRedirection
        @message = "Redirection: code=#{response.code} message=#{response.message}"
      else
        @message = "HTTP ERROR: code=#{response.code} message=#{response.message}"
      end
    rescue IOError => e
      @message = "e.message"
    rescue TimeoutError => e
      @message = "e.message"
    rescue JSON::ParserError => e
      @message = "e.message"
    rescue => e
      @message = "e.message"
    end
    return @result
  end

  def get_books
    books = Array.new
    @result["Items"].each do |item|
      books << extract_item(item)
    end
    return books
  end

  def get_message
    return @message
  end

  def extract_item(item)
    return Book.new(
      title: item["title"],
      genres: extract_genre(item["booksGenreId"]),
      price: item["itemPrice"],
      author: item["author"],
      publisher: item["publisherName"],
      isbn: item["isbn"],
      caption: item["itemCaption"],
      sales_date: item["salesDate"],
      item_url: item["itemUrl"],
      largeimage_url: item["largeImageUrl"]
    )
  end

  private

  def get_apikey
    return Apikey.first.key
  end

  def extract_genre(param)
    genres = param.split("/")
    result = Array.new
    genres.each do |genre|
      result << genre[0,6]
    end
    return result.uniq
  end
end

次にコントローラを追加します。

rails generate controller search index

コントローラの実装は下記のようになります。

search_controller.rb
class SearchController < ApplicationController

  def index
  end

  def search
    @keywd = params[:keywd]
    if params[:page].present?
      @page = params[:page]
    else
      @page = 1
    end
    searcher = BookSearcher.new
    @result = searcher.search(@keywd, @page, params[:genre])
    @books = searcher.get_books
    @message = searcher.get_message

    respond_to do |format|
      format.html { render 'search/index' }
      format.json { render json: @books, status: :ok }
    end
  end
end

:keywd、:page、:genreがビューから渡されるパラメータです。
ビューについては割愛しますが、検索条件も結果もindex.html.erbで表示するようにしました。
結果は20件表示され、「NEXT」ボタンで2ページ目を読み込むようにしています。

ルートの設定は下記のようにしました。

routes.rb
get 'search/index'
post 'search/books' => 'search#search'

これで書籍検索ができるようになりました。

ユーザー登録/編集

ユーザーのところもScaffoldで作ってしまいます。

rails generate scaffold user username:string password_digest:string token:string role:integer email:string fcm_token:string enable_email_notify:boolean enable_fcm_notify:boolean

これまでと同様に、Nullを許容しないカラムについて「:null => false」を付与し、コマンドでテーブルを作成します。
password_digestについてはブラウザからアクセスされたときのDigest認証用、tokenはAPIからアクセスされたときのToken認証用のものです。

新規登録画面へのリンクは、このあと出てくるログイン画面に張り、編集画面へはマイページと上部のナビゲーションにリンクを張ります。

ログイン/ログアウト

ログインコントローラを追加します。

rails generate controller login

コードは下記のようになります。一般的なサンプルにあるような内容です。

login_controller.rb
class LoginController < ApplicationController
  skip_before_action :check_logined

  def auth
    usr = User.find_by(username: params[:username])
    if usr && usr.authenticate(params[:password]) then
      reset_session
      session[:usr] = usr.id
      redirect_to params[:referer]
    else
      flash.now[:referer] = params[:referer]
      @error = 'ユーザ名/パスワードが間違っています。'
      render 'index'
    end
  end

  def logout
    reset_session
    redirect_to '/'
  end
end

ユーザーごとにデータを管理することになるため、共通処理としてログイン確認をするための処理をapplication_controller.rbに記述しています。
ログイン画面では当然ながらログインチェックしないためにスキップさせています。

application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  before_action :check_logined

  private

  def check_logined
    if session[:usr] then
      begin
        @current_user = User.find(session[:usr])
      rescue ActiveRecord::RecordNotFound
        reset_session
      end
    end
    unless @current_user
      flash[:referer] = request.fullpath
      redirect_to controller: :login, action: :index
    end
  end
end

ルートの設定は下記のようにしました。

routes.rb
get 'login/index'
get 'logout' => 'login#logout'
post 'login/auth'

ここでもビューは省略させていただきます。

お気に入り

一旦、Scaffoldで作ります。

rails generate scaffold favorite user:references book:references

同じく、Nullを許容しないカラムについて「:null => false」を付与し、コマンドでテーブルを作成します。
お気に入りについては、検索結果のリストから登録したいものを登録して、あとは参照・削除ができれば良いので、ビューのところにたくさん作られたファイルはindex.html.erbを残して削除します。

お気に入りの登録処理はSearchControllerに実装します。

search_controller.rb
class SearchController < ApplicationController

  def favorite
    prm = Book.new(book_params)
    book = Book.find_by(title: prm.title)

    # 書籍をDBに登録
    unless book.present?
      if prm.save
        book = prm
      else
        @type = "error"
        @msg = "パラメーターエラー"
        # return
      end
    end

    favorite = Favorite.find_by(user_id: @current_user.id, book_id: book.id)
    if favorite.present?
      @type = "info"
      @msg = "登録済みです"
    else
      # お気に入りを登録する
      fav = Favorite.new(user_id: @current_user.id, book_id: book.id)
      if fav.save
        @type = "success"
        @msg = "登録しました"
      else
        @type = "warning"
        @msg = "登録できませんでした"
      end
    end
  end

  private

  def book_params
    params.permit(:title, :genres, :price, :author, :publisher, :isbn, :caption, :sales_date, :item_url, :largeimage_url)
  end

end

Bookモデルは検索結果を入れるために検索の実装時に作成しましたが、データベースとしては、お気に入り登録の際追加を行います。
これは、お気に入り一覧を表示する際に使うキャッシュとして機能します。
検索の時点で登録することも出来ますが、DBのレコード数が増えすぎないようにと考えるとこのタイミングが最適と考えたためです。

ルートの設定は下記のように追加しています。

routes.rb
get 'favorites' => 'favorites#index'
delete 'favorites/:id' => 'favorites#destroy', as: 'favorite'

Scaffoldで生成したときの「resources :faivorites」を消して追加定義しているのでちょっとイレギュラーな定義になっています。

キーワード登録

定期的に検索して更新があったら通知してもらうための検索キーワードの登録です。
これもScaffoldで作ります。

rails generate scaffold keyword user:references keyword:string genre:string item_count:integer

Nullを許容しないカラムの対応、コマンドでのテーブル作成は同様です。

画面の調整

各種機能をそれぞれ別途作成してきましたが、ここでそれらをつないで画面遷移できるようにします。
また、レイアウトについても多少は見栄えを良くします。
細かくは紹介しませんが、概ね下記の方針で調整しました。

  • 共通のナビゲーションのビューを作成し、すべての画面に適用
  • Bootstrap4を使用して全体の体裁を整える
  • 検索結果をテーブルで表示(お気に入り一覧も同じ体裁)
  • トップページ、マイページを作り画面遷移を整える
  • マイページはログインユーザー自身の情報のみ見える
  • 管理画面は管理者だけアクセスでき、全情報を確認できる

以上まででWebアプリケーションとしては一通り動くものになりました。

ここからはバッチ処理とその結果による通知について紹介します。
登録したキーワードに対して毎日バッチ処理で検索を行って件数に変化があったら通知するというものです。
この機能により新しい技術書の発売をチェックできるはずです。

メール送信

通知をメールで行う機能があるのでメール送信に対応させます。
Railsではメール送信の機能が用意されていて、対応は主に設定になります。
メール送信のサーバーはSend Gridを使います。
Send Gridはアドオンで、導入は無料ですが従量課金のため、クレカ登録が必要になります。

必要な作業は下記のとおりです。実際に送信するのは後述するバッチのところで紹介します。

  1. メール設定を記載(development.rb/production.rb)
  2. メーラーを生成
  3. メーラーの編集(必要なら親クラスも)
  4. メール本文の編集

メール設定は下記のような感じです。

config.action_mailer.raise_delivery_errors = true
config.action_mailer.delivery_method       = :smtp
config.action_mailer.default_url_options   = { host: 'xxxxx.herokuapp.com' }
ActionMailer::Base.smtp_settings           = {
    address:              'smtp.sendgrid.net',
    port:                 '587',
    authentication:       :plain,
    user_name:            ENV['SENDGRID_USERNAME'],
    password:             ENV['SENDGRID_PASSWORD'],
    domain:               'heroku.com',
    enable_starttls_auto: true
}

メーラー生成もrailsのコマンドで行います。

rails g mailer NotificationMailer notify_result_update

必要な関連ファイルが作成されましたね。
メーラーの中身は以下のようにしました。

class NotificationMailer < ApplicationMailer
  def notify_result_update(user, keyword, before_count, after_count)
    @user = user
    @keyword = keyword
    @before_count = before_count
    @after_count = after_count

    mail to: user.email,
         subject: '検索結果が更新されました'
  end
end

メール本文の編集は割愛しますが、HTLM版とテキスト版をそれぞれ編集します。
NotificationMailerのメンバーが参照できるので変更情報を記述します。

バッチ処理

Heroku Schedulerを使います。
アドオン自体は無料なのですが、従量課金のため、クレカ登録していないと使えません。
rakeのタスクを登録する仕組みで、実行のサイクルを、一日ごと、一時間ごと、10分ごとから選べます。

2つのバッチを追加します。

  1. キーワードの一覧をみて、実際に検索してみて、件数が変わってたら通知する(1日ごと)
  2. 定期的にサイトにアクセスする(1時間ごと)

2つ目については、Herokuのスリープを回避するためのものでここでは説明は省きます。
アクセスがないとスリープしてしまい、起こすのに時間がかるので、無料の範囲で日中は起こしておこうというものです。
Herokuは30分アクセスがないとスリープするので、10分ごとでないと半分はスリープ状態ですが、、
「Heroku スリープ」あたりで検索するとそこそこ情報が得られます。

rakeのタスクは、lib/tasksの下にrakeファイルを追加します。

scheduler.rake
desc "This task is called by the Heroku scheduler add-on"
task :notify_books_result_update => :environment do
  puts "notify_books_result_update start.."
  puts "get keywords..."
  keywords = Keyword.all
  keywords.each do |keyword|
    puts "get user..."
    user = User.find(keyword.user_id)
    if !user.enable_email_notify && !user.enable_fcm_notify
      puts "not notify"
      next
    end
    puts "search books..."
    searcher = BookSearcher.new
    result = searcher.search(keyword.keyword, 1, keyword.genre)
    count = result["count"].to_i
    if count == keyword.item_count
      puts "no change"
      next
    end
    if user.enable_email_notify
      puts "send mail..."
      NotificationMailer.notify_result_update(user, keyword.keyword, keyword.item_count, count).deliver
    end
    puts "update database..."
    keyword.item_count = result["count"]
    keyword.save
  end
end

FCMとあるのは、アプリへのプッシュ通知を想定しているためで、未実装です。

以上まででWebサービスとしては一応の完了となります。

Webサービスとしては動くものが出来ましたが、最終的にスマホアプリで通知を受けられるようにしたいので、スマホアプリ用にWebAPIとして機能するようにします。

API化

やることは概ね以下の作業です。

  • ルートになるコントローラを新たに作る
  • 認証の仕組みを新たに作る
  • 必要なAPI用のコントローラを追加
  • ルートの設定も別途行う

API用のルートのコントローラを作成

これまで作成してきたコントローラは、すべて「ActionController::Base」のサブクラスでしたが、APIは「ActionController::API」のサブクラスとなります。
なので、application_controller.rbとは別にapi_controller.rbを作成します。

api_controller.rb
class ApiController < ActionController::API
end

認証の仕組みを作る

先程のApiControllerに認証の仕組みを追加します。
Token認証のために「ActionController::HttpAuthentication::Token::ControllerMethods」をincludeします。

api_controller.rb
class ApiController < ActionController::API
  include ActionController::HttpAuthentication::Token::ControllerMethods

  before_action :authenticate!

  private

  def authenticate!
    authenticate_or_request_with_http_token do |token, options|
      User.find_by(token: token).present?
    end
  end

  def current_user
    @current_user ||= User.find_by(token: request.headers['Authorization'].split[1])
  end
end

ログイン機能は、ユーザー管理用のコントローラを作成しそこに追加します。
Railsのコマンドは使用しません。

users_controller.rb
class Api::V1::UsersController < ApiController
  skip_before_action :authenticate!, only: [:login]

  def login
    @user = User.find_by(username: params[:username])
    if @user && @user.authenticate(params[:password])
      render json: @user
    else
      render json: { errors: ['ログインに失敗しました'] }, status: 401
    end
  end
end

必要なAPI用のコントローラを追加

詳細は割愛しますが、下記のコントローラを作成しました。

  • users_controller.rb:認証、ユーザー管理
  • search_controller.rb:検索
  • keywords_controller.rb:キーワード管理
  • favorites_controller.rb:お気に入り管理

継承関係や作成場所は先程のusers_controller.rbと同様です。
APIなので、処理結果はすべてJsonで返却するようにします。

ルートの設定

ルートもAPI用に別途定義します。

routes.rb
Rails.application.routes.draw do
  # Webアプリ
  (省略)

  # API
  namespace :api, { format: 'json' } do
    namespace :v1 do
      get 'users' => 'users#index'
      get 'users/me' => 'users#me'
      get 'users/:id' => 'users#show'
      post 'users' => 'users#create'
      put 'users/:id' => 'users#update'
      delete 'users/:id' => 'users#destroy'

      post 'users/login'
      get 'search' => 'search#search'

      get 'keywords' => 'keywords#index'
      post 'keywords' => 'keywords#create'
      put 'keywords/:id' => 'keywords#update'
      delete 'keywords/:id' => 'keywords#destroy'

      get 'favorites' => 'favorites#index'
      post 'favorites' => 'favorites#create'
      delete 'favorites/:id' => 'favorites#destroy'
    end
  end
end

今後

API化できたので、アプリを作って連携することを考えています。
アプリにプッシュ通知ができてはじめてサービスとして完成したと言えると考えています。
また、これから発売される書籍の通知登録も比較的容易に実現できそうです。
通知内容については、「変更があったこと」しか通知できていないので、内容が伝えられるようにすべきです。
ただ、あまりやり過ぎると無料の範囲で動かせなくなってしまう恐れも(APIの制限なども考えられる)。。

最後に

書籍のサンプルでよくあるネタ・レベルではありますが、多少は実用的・実践的なものがそこそこ簡単に作れたと思います。
全体的な紹介のため細かいところはかなり端折ってしまいましたが、ビューの部分や実際の実装についてはコードを参照していただければと思います(ツッコミどころは多々あると思います)。
いろいろな記事やサイトを参考にさせていただいたのですが、ちゃんと控えておらず、中途半端に調べて載せるのもかえって誤解を招く恐れもあるので記載していません。すみません。

ソースコートはこちらにあります。
サービスは継続的に運用していく予定はないので非公開です。

長々と失礼いたしました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

RubyでURLエンコーディングする方法

自分のブログの転載記事です。

結論

URLの中で使用する文字列をエスケープするときにはURI.encode_www_formメソッドを使用しよう。

調べたこと

最近、株価を分析するwebアプリを個人開発しています。
その中で、銘柄名でGoogle検索したときの結果をスクレイピングしたくなりました。

銘柄名と証券コードはStringでDBに登録済みなので、簡単に持ってこられます。
ですが持ってきた文字列をそのままクエリストリングに入れてしまうと、うまく検索できないことがありました。

search_url = "https://www.google.co.jp/search?hl=jp&gl=JP&q=日本M&Aセンター"

スクリーンショット 2019-01-27 22.23.02.png
欲しい結果は取得できていますが、検索ワードが"日本M"で途切れています。
"&"が正しくエスケープできていないのかと思い、調べてみたところ、以下の方法でできました。

require 'uri'

query = URI.encode_www_form(q: '日本M&Aセンター')
=> "q=%E6%97%A5%E6%9C%ACM%26A%E3%82%BB%E3%83%B3%E3%82%BF%E3%83%BC"

search_url = "https://www.google.co.jp/search?hl=jp&gl=JP&"
=> "https://www.google.co.jp/search?hl=jp&gl=JP&"

search_url += query
=> "https://www.google.co.jp/search?hl=jp&gl=JP&q=%E6%97%A5%E6%9C%ACM%26A%E3%82%BB%E3%83%B3%E3%82%BF%E3%83%BC"

スクリーンショット 2019-01-27 22.22.31.png
上記のURLを打ち込むと、"&"が正しくエスケープされているのがわかります。他の文字はエスケープ前の文字に戻っていますが。

失敗したケース

ちなみに調べている中で最初に出てきた方法はURI.encodeを使う方法だったのですが、そのやり方では正しくエスケープされませんでした。

require 'uri'

search_url = "https://www.google.co.jp/search?hl=jp&gl=JP&q="
=> "https://www.google.co.jp/search?hl=jp&gl=JP&q="

search_url += "日本M&Aセンター"
=> "https://www.google.co.jp/search?hl=jp&gl=JP&q=日本M&Aセンター"

URI.encode search_url
=> "https://www.google.co.jp/search?hl=jp&gl=JP&q=%E6%97%A5%E6%9C%ACM&A%E3%82%BB%E3%83%B3%E3%82%BF%E3%83%BC"

最終的に生成された文字列を見比べてみると、"&"がエスケープされていないことがわかります。

# "M&A"が"M%26A"になっている。
success_url = "https://www.google.co.jp/search?hl=jp&gl=JP&q=%E6%97%A5%E6%9C%ACM%26A%E3%82%BB%E3%83%B3%E3%82%BF%E3%83%BC"

# "M&A"が"M&A"のまま。
failure_url = "https://www.google.co.jp/search?hl=jp&gl=JP&q=%E6%97%A5%E6%9C%ACM&A%E3%82%BB%E3%83%B3%E3%82%BF%E3%83%BC"

Ruby 2.6.0 リファレンスマニュアルによると、encodeメソッドはobsoleteとのことなので、今後は使用しないほうがよいかもしれません。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Redisでランキングを実装

初めに

Redisでランキングを実装してみました。

rank.png

こちらのサイトを参考にしました。
RailsでRedisを使うメモ ランキングつくったり
http://o.inchiki.jp/obbr/307

設定

デザインはbootstrap
ダミーデータ作成にfakerをつかいました。

あとredisを使うので

gemfileに

gem 'redis'
gem 'bootstrap'
gem 'jquery-rails'
gem 'popper_js'
gem 'tether-rails'
gem 'faker'

こんな感じで書いてbundle installしました。

userモデルを作成しました。

bootstrapの設定fakerでのダミーデータ作成は省略

実装

users_controllerを

users_controller.rb
class UsersController < ApplicationController
  def index
    @ranking_users_data = (REDIS.zrevrangebyscore "users", 10, 0, withscores: true)
  end

  def add_score
    user = User.find params[:id]
    REDIS.zincrby "users", 1, user.id
  end 
end

こんな感じにしました。

REDISに関しては

config/initializers以下にredis.rbを作成して中身に

redis.rb
REDIS = Redis.new(host: "localhost", port: 6379)

と書くことで使えます。

REDIS.zincrby "users", 1, user.id

みたいに書いているところでスコアのカウントをしています。

usersがスコアを計算する大きなまとまりの名前でusersでなくても良いのですが今回はusersと名前を付けています。

そのあとの1が追加する数です

user.idごとにスコアがカウントされていきます。

集められたスコアをソートされた状態で出力するには

REDIS.zrevrangebyscore "users", "+inf", 0, withscores: true

このコードです。
スコアが+inf(最大?)から0まで表示することになっています。

例えばスコアが0から10まで表示したい場合は

REDIS.zrevrangebyscore "users", 10, 0, withscores: true

あくまでスコアなので0から10番目を表示というわけではありません。

自分の場合これだけだと登録されたばかりのuserはredisのusersのランキングの中に入らないので

REDIS.zrevrangebyscore "users", 10, 0, withscores: true

これで表示されませんでした。

なのでuserが作成されたときに1スコアをたして表示されるようにしました。

models/user.rb

user.rb
class User < ApplicationRecord
    after_create{ REDIS.zincrby "users", 1, self.id}
end

routesは

Rails.application.routes.draw do
  get 'users/index'
  post 'users/add_score/:id', to: 'users#add_score'
  # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end

このようにして

viewsは

index.html.erb
<br>
<br>
<br>
<br>
<br>
<div class="container">
    <table class="table table-dark">
        <thead>
            <tr>
            <th scope="col">Ranking</th>
            <th scope="col">Name</th>
            <th scope="col">Count</th>
            <th scope="col">Like Button</th>
            </tr>
        </thead>
        <tbody>
            <% @ranking_users_data.each_with_index do |rank_data,index| %>
                <tr>
                    <th scope="row"><%= index + 1 %></th>
                    <td><%= user_name(rank_data[0]) %></td>
                    <td class="count"><%= rank_data[1].to_i %></td>
                    <td>
                        <%= button_to "/users/add_score/#{rank_data[0]}", class: "like-btn", id: "addBtn",  remote: true do %>
                            <i class="fas fa-heart" style="font-size: 15px;" data-count="<%= index  %>"></i>
                        <% end %>

                    </td>
                </tr>
            <% end %>
        </tbody>
    </table>
</div>


<script>
var allBtn = document.getElementsByClassName('like-btn')
var allCount = document.getElementsByClassName('count')
var btnLength = allBtn.length 
var addLikeCount = function(i) {
    allCount[i].innerHTML = parseInt(allCount[i].innerHTML) + 1
}
for(var i = 0; i < btnLength; i++ ) {
    allBtn[i].addEventListener('click', function(a) {
        let dataCount = this.getElementsByClassName('fas')[0].dataset.count
        addLikeCount(dataCount);
    });
}

</script>

例のごとく自分はjavascriptをerbの中に書いています。

とりあえず見た目はこんな感じになると思います。

rank.png

fontawesomeを使っているので

layoutsのheadの中に

<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.6.3/css/all.css">

を書いて

assets/stylesheets/application.scssに

@import '/*';
@import 'bootstrap';
@import 'tether';

body 
{
    background: #95a5a6;
}

.like-btn
{
    transition: all 0.2s;
    border: none !important;
}

.like-btn:hover
{
    background: rgb(224, 0, 37) !important;
    color: white !important;
    border: none !important;
}

こんな感じにしてあると見た目は同じになると思います。

これでクリックされるたびにスコアが追加されていきます。

ちなみに

redisに追加されているデータをみたい場合は

rails cで

REDIS.keys

とすれば登録されているkeyが出て

REDIS.zrevrangebyscore "users", "+inf", 0, withscores: true

と入力すればuser.idとスコアが二次配列の形で表示されると思います。

おわり:sunny:

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

RailsでRedisを使ってpvカウント

初めに

railsでredisを使ってpv数をカウントする機能を実装してみました。

設定

redisをインストールする

gem 'redis-rails'

bundle install

実装

controllersのconcernsに

count_pv.rbファイルを作って

中に

module CountPv
    extend ActiveSupport::Concern

    def count_pv(action,controller)
        redis = Redis.new
        count = redis.get "count_#{controller}_#{action}"
        redis.set "count_#{controller}_#{action}", count.to_i + 1
        p redis.get "count_#{controller}_#{action}"
    end 
end 

pvカウントしたいコントローラーで

users_controller.rb
class UsersController < ApplicationController
  include CountPv

  def index
    count_pv("index", "users")
  end

  def index_a
    count_pv("index_a","users")
  end 
end

みたいにする。

これでカウントできる。

終わりに

redisの使い方がわからないんですがこれって

redis-railsをbundle installすればredisって勝手に起動するんですかね?

あとログが全くでないのはデフォルトなのかな?

なんのログも出ないけど一応使えてるのが気持ち悪いし起動しなくても使えるからほんとにredis使えてるのか不安になる(笑)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Twitterログイン認証で404(forbidden)や401(Unauthorized)エラーがでたときの対処法

はじめに

TwitterのAPIを使ってTwitterログイン機能を作っているのですが、認証エラーでハマったのでメモ。

今回は
・404(forbidden)
・401(Unauthorized)
の対処法を解説していきます。

401エラーの対処方

Action_Controller__Exception_caught.png

401エラーとは、認証エラーのことで、リクエスト側に問題があるということです。

原因: API keyが間違っていた。
consummer keyconsumer sercretが間違っている可能性があるので、developerページでekeyを間違えて打っていないか確かめて見ましょう。

config/initializers/devise.rb
  config.omniauth :twitter, (consummer key), (consumer sercret)

さて、これが治ると次は403エラーにたどり着きました。

403エラーの対処法

Action_Controller__Exception_caught.png

403エラーは、閲覧が禁止されてると言う意味です。

原因: callbackのurlとlocalhostのurlが一致していなかった。

developer画面に移動し、アプリの詳細画面に移動して、登録したcallbackのurlを確認してみましょう。

Twitter_Developers.png

この赤で囲ったurlの部分と、local環境で使いたいurlが一致しないとエラーを吐いてしまいます。

rails routes | grep callbackで、callbackのurlを確認します。

user_twitter_omniauth_authorize GET|POST /users/auth/twitter(.:format)          omniauth_callbacks#passthru
user_twitter_omniauth_callback GET|POST users/auth/twitter/callback(.:format) omniauth_callbacks#twitter

ここではusers/auth/twitter/callbackに飛ばしたいので、

callback URLはhttp://127.0.0.1:3000/users/auth/twitter/callbackとしました。

と、ここまでで解決する人もいたりいなかったりすると思うんですが、個人的に一部ハマったポイントとしては、起動しているローカル環境のurlと、developerで設定したcallback_urlが完全一致してないと同じように403エラーが発生してしまうところです。

つまり、
callback_url → 
http://127.0.0.1:3000/users/auth/twitter/callback
ローカル環境で立ち上げたurl→
http://localhost:3000/users/auth/twitter/callback
はエラーになります。

正しくは、
callback_url →
http://127.0.0.1:3000/users/auth/twitter/callback
ローカル環境で立ち上げたurl →
http://127.0.0.1:3000/users/auth/twitter/callback
です。

このように、ドメイン名も設定したcallback_urlと一致させるようにしましょう。

確認事項

・keyが間違っていないか?
・developerのcallback_urlが間違っていないか?
・ドメイン名も一致しているか?

ここであげたもの以外にもエラーの原因となるものはたくさんあるかと思います。
単にAPI keyが間違っていただとか、リクエストに問題があることも多いので、焦らずひとつづつ潰していきましょう。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Rails]モデルにScopeを定義し、コントローラーで呼び出す

下記のように同じようなクエリメソッドを繰り返す場合、可読性も保守性も非常に悪いです。そんな時にはモデルにScopeを定義することで同じ記述を一つのメソッドとして使用することができます。

notes_controller.rb
    @manga_notes    = Note.where(category: 0).includes(:user).order("created_at DESC").limit(9)
    @column_notes   = Note.where(category: 1).includes(:user).order("created_at DESC").limit(9)
    @novel_notes    = Note.where(category: 2).includes(:user).order("created_at DESC").limit(9)
    @photo_notes    = Note.where(category: 3).includes(:user).order("created_at DESC").limit(9)
    @sound_notes    = Note.where(category: 4).includes(:user).order("created_at DESC").limit(9)
    @business_notes = Note.where(category: 5).includes(:user).order("created_at DESC").limit(9)

これらをScopeに定義することで一つのメソッドとして定義することができます。

note.rb
    scope :recent, -> { includes(:user).order("created_at DESC").limit(9) }
notes_controller.rb
    @manga_notes     = Note.where(category: 0).recent
    @column_notes    = Note.where(category: 1).recent

モデルで定義したScopeをコントローラーで呼び出しています。

note.rb
    enum category: { マンガ: 0, コラム: 1, 小説: 2, 写真: 3, サウンド: 4, ビジネス: 5, ライフスタイル: 6 }
    scope :search_with_category, ->(category) { where(category: category) }
    scope :recent, -> { includes(:user).order("created_at DESC").limit(9) }
notes_controller.rb
    @manga_notes     = Note.search_with_category(0).recent
    @column_notes    = Note.search_with_category(1).recent
    @novel_notes     = Note.search_with_category(2).recent
    @photo_notes     = Note.search_with_category(3).recent
    @sound_notes     = Note.search_with_category(4).recent
    @business_notes  = Note.search_with_category(5).recent
    @lifestyle_notes = Note.search_with_category(6).recent

上のようにスコープ同士をマージさせて使用することが可能です。また、スコープに引数を渡すことも可能で、上の例ではenumで定義しているカテゴリ番号を引数をして渡すことで、Noteモデルからそのカテゴリを持つレコードを取得します。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

RailsのActionMailerで画像のURLを絶対パスで表示させる

ActionMailerで送信したメールに絶対パスで画像を表示させたい中でハマった。

Mail内で以下のように表示させるために

index.html
 <img src="http://myfullappurl.dev/assets/myimage.png">

development.rbにて以下のようにメーラーを設定

development.rb
config.action_controller.asset_host = 'myfullappurl.dev'
config.action_mailer.asset_host = config.action_controller.asset_host
config.action_mailer.default_url_options = { host: 'myfullappurl.dev' }

しかしmail内でのhtmlでは

index.html
 <img src="//myfullappurl.dev/assets/myimage.png">

とプロトコルが表示されない。railsコードに

asset_url_helper.rb
URI_REGEXP = %r{^[-a-z]+://|^(?:cid|data):|^//}

有効なActionMailer URIを定義する正規表現があるようで

development.rb
config.action_controller.asset_host = 'myfullappurl.dev'
config.action_mailer.asset_host = 'http://myfullappurl.dev'

にしなければならない。

https://codeday.me/bug/20180720/199491.html

https://stackoverflow.com/questions/29887668/how-to-use-image-url-in-rails-to-show-images-in-production

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

DBを使わないRails on DockerのWebアプリの作り方

Problem

とりあえずブレスト」というWebアプリを作ったときに「DBなしでできそう!」となったのだが、docker docsだとpostgreSQLありきたっだのでDBなしで開発を始める手順をまとめておきました。

Simple Conclusion

  • Dockerfileやdocker-compose.ymlからDBに関係しそうなところを削除する
  • rails newする際に"-O"オプションをつける

Details

まずはdocker docsを参考にDBを使わないDockerfileやdocker-compose.ymlの準備する。
最初にmkdir myappでアプリのディレクトリを作成し、cd myappでアプリのディレクトリへ移動しておく。

Dockerfile

myapp/Dockerfile
FROM ruby:2.5
ENV LANG C.UTF-8
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs
WORKDIR /myapp
COPY Gemfile /myapp/Gemfile
COPY Gemfile.lock /myapp/Gemfile.lock
RUN bundle install -j4
COPY . /myapp

ポイントはapt-getの対象からpostgresql-clientをのぞくこと。

docker-compose.yml

myapp/docker-compose.yml
version: '3'
services:
  web:
    container_name: myapp_web
    build:
      context: .
      dockerfile: Dockerfile
    environment:
      TZ: Asia/Tokyo
    command: rails s
    volumes:
      - .:/myapp
    ports:
      - "3000:3000"

こちらはdb serviceをごっそり削除。depends_onするdbを消去するのでweb側のdepends_onも削除。
container_name、dockerfile、TZなどの設定は好みなのでつけなくてもOKです。

Rails new

上で作った"Dockerfile"、"docker-compose.yml"以外はdocker docsにそって"Gemfile"と"Gemfile.lock"を作っておく。
4つのファイルを作ったらrails newをしてWebアプリを作成してdocker imageをbuildしますが、ここのrails newでDatabaseを利用しないことを宣言するために"-O"オプションをつける。
色々設定とかしないといけないんだろうなーと思っていたけど"-O"だけで済むなんてさすがRailsさん。

$ docker-compose run web rails new . -O
$ docker-compose build

このあと、docker docsではDatabaseのあれやこれやの設定をしていますが、無視してdocker containerを立ち上げる。

$ docker-compose up

http://localhost:3000へアクセス!
hello_world_rails.png

Yay! You're on Rails (without Database) !!

Reference

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails |onオプションを使ってバリデーションのタイミングを指定

はじめに

こんにちは、waruby510です。

今回、初めてQiitaへの記事を投稿させていただきます。

最近、色んな方の記事に助けてもらってばかりの状況なので、
備忘録&同じ初学者の力になれる内容 を簡単にですが、記事にしてみました。


内容は、タイトル通りですが、

validatesメソッドのonオプションを使って、バリデーションのタイミングを指定してみた

と言う感じです。

もちろん経験者にとっては、かなりイージーな内容になっています。初心者目線でこの記事を書いていますので、ご了承ください。
また、あまり奥深いところまでは書いていないので、今後更新していければと思います。

<補足>
※初心者の立場で記事を書いています。最低限の下調べと実際に動くかどうかは確認していますが、もし誤った内容を記載していた場合は、ご指摘いただければと思います。

※まだまだ文章を書くことに慣れていません。読みにくいかもしれませんが、どうかお付き合いください。

開発環境

Rails バージョン5.2.2
ruby バージョン2.5.3
※ログイン機能等に関しては、gemのdeviseは使っていません。

アプリケーション詳細

  • ユーザー登録・編集機能 ← 今回の記事の問題箇所
  • 画像投稿、編集、削除機能 & いいね機能

簡単に言うと、インスタのようなアプリです。

発生した問題

ユーザー編集機能を追加した時にある問題に差し掛かりました。
それは、ユーザー編集画面にてパスワード欄を無くしたはずなのに、バリデーションがかかってしまう問題。

参考画像1: ユーザー編集画面にてバリデーション起動
スクリーンショット 2019-01-27 11.32.43.png

仕方なく、編集画面にもパスワード入力欄を設けて対応しましたが、
ユーザー目線で考えたら、使いにくいの一言。

実際、userモデルに対しては、以下のようなバリデーションを追加していました。

user.rb
class User < ApplicationRecord
  #名前とメールアドレスに対するバリデーション
  validates :name, presence: true, length: {maximum: 30}
  validates :email, presence: true, length: {maximum: 255},format: { with: /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i },uniqueness: true

   #略

  #パスワードをハッシュ化
  has_secure_password
  #パスワードに対するバリデーション
  validates :password, presence: true, length: {minimum: 6}

   #略

end

考えられる原因

バリデーションのデフォルトでは、レコードの作成時、更新時。つまり保存が促されるときに実行されるように設定されている。つまり、編集時にパスワードがないとバリデーションがかかってしまいます。
冷静に考えてみると、ただ編集画面のパスワード欄の表示を消しているだけなので、バリデーションがかかるのも当たり前ですよね。笑

考えれる対応策としては、バリデーションのかかるタイミングを指定できれば解決できそう。

実際にやったこと

Userモデルのpasswordカラムに対して、on: :create を追加

user.rb
#on: :create を追加
validates :password, presence: true, length: {minimum: 6}, on: :create


ここでようやくonオプションの登場です。
ここまで、長々と書いてきましたが、たったこれだけです......笑

バリデーションのタイミングを特定して実行したい場合、on: メソッド名とオプションを追加することで、そのアクションの時のみバリデーションを実行することができるみたいです。

今回は、ユーザー登録時のみパスワードのバリデーションを起動させたいので、on: :createとします。

もしメソッド名をon: updateにすれば、編集の更新時のみバリデーションがかかるという仕組みなのです。
個人的には結構使いそうな予感。

今回は、RailsGuideの下記リンクを参考にしました。

参考リンク: RailsGuide

3.4 :on
:onオプションは、バリデーション実行のタイミングを指定します。ビルトインのバリデーションヘルパーは、デフォルトでは保存時に実行されます。これはレコードの作成時および更新時のどちらの場合にも行われます。バリデーションのタイミングを変更したい場合、on: :createを指定すればレコード新規作成時にのみ検証が行われ、on: :updateを指定すればレコードの更新時にのみ検証が行われます。

class Person < ApplicationRecord
# 値が重複していてもemailを更新できる
validates :email, uniqueness: true, on: :create

# 新規レコード作成時に、数字でない年齢表現を使用できる
validates :age, numericality: true, on: :update

# デフォルト (作成時と更新時のどちらの場合にもバリデーションを行なう)
validates :name, presence: true
end

実際に、編集画面の動作を確認してみました。

参考画像2: ユーザー編集画面にてバリデーション回避
スクリーンショット 2019-01-27 12.35.13.png

見事にパスワードに対するバリデーションが回避されています。
少しはユーザー目線の機能に近づけた気がします。
今の自分の良いところは、初心者ということもあり、ユーザー目線に近い状態で開発ができるということ。この目線は今後も大事にしていきたいと思います。

さいごに

結論が呆気なくて、すみませんでした。
自分自身もこれだけ?っていう感じでしたが、とてもいい勉強になりました。

ただ、まだまだ便利な使い方が間違いなくあるはずなので、これから学習していく中で整理していき、また記事にできればと思います。

ここまで読んでくださった方、本当にありがとうございます。

今回は、初めてということもあり、Markdown記法に慣れるために色々な記法を使って書いてみました。そのため、無駄に記事が長くなってしまいましたが、個人的にはかなりいい練習ができたと思います。

この記事が誰かのお役に立てれれば本望です。

以上です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【爆速で習得】Railsでslimを使う方法から基本文法まで

slimは凄い

みなさんはslimを使っていますでしょうか?
slimとは, Ruby製のテンプレートエンジンのことで、
htmlをより簡潔でスマートに書くことができます。

例えば,以下のコード。

*.html
<h1>タイトル</h1>
<div class="content">
  <p>文章</p>
</div>
*.slim
h1 タイトル
.content
  p 文章

どちらの方がスマートなのかは一目瞭然ではないでしょうか?

今回はRubyも含めて説明していきますが、
書き方の特徴と違いとしては以下のようになります。

・<>がいらない
・<%= %> →  =
・<% %> → -
・コメント → /
・id指定 → #
・class指定 → .

例)

html(erb) slim
<p>hoge</p> p hoge
<!-- --> /
class="content" .content
id="content" #content
<%= hoge %> = hoge
<% if %> - if

この記事では,

・slimの導入と設定
・基本文法

についてシンプルに説明していきます。

slimの導入

まずは以下のコマンドでslimを入れてください.

gem install slim

これで拡張子が.html.slimのslimファイルを、みなさんが普段使っている.html.erbとして変換されるようになります。

次にhtml2slimを入れてください。

gem install html2slim

これでhtml.erb → html.slim に変換させることができるので、早速変換してみましょう。

bundle exec erb2slim app/views app/views

依存のerbファイルに加え, slimファイルが追加されたかと思います。

しかし、これではerbファイルが邪魔なので以下のコマンドを打って削除しましょう。

bundle exec erb2slim app/views app/views -d

今後自動的にslimファイルを作成されるようにするためには 、
config/application.rbにあるconfigを以下のようにslimを指定すればOK。

config/application.rb
module App
  class Application < Rails::Application
    config.generators.template_engine = :slim #slimに変更
  end                                                                                                                                                                     
end

このあと、もしフォーマット関係でエラー出た場合は,一旦サーバーを再起動させましょう。

これで準備が整いました。

基本文法

ここからはよく使うslimの文法について紹介します。

見方としては、slimの文法 → htmlならどうなるかという順になってます。
(色々迷いましたが、逆の方が見やすいよという方いましたらコメントください(m_ _m))

htmlタグ

slimの特徴として、<>がいらないということを覚えておきましょう。(使うこともできます)
もちろんそれに伴い、<>で閉じる必要もありません。

*.slim
p 段落
a アンカー
img src="https://avatars1.githubusercontent.com/u/28925778?v=4"
div
*.html
<p>段落</p>
<a>アンカー</a>
<img src="https://avatars1.githubusercontent.com/u/28925778?v=4" />

クラスやidの指定

クラスやidはそれぞれ、., #で表現することができます。

*.slim
dev.content
  p.title タイトル
dev#content
  p#title タイトル
*.html
<dev class="content">
  <p class="title">タイトル</hoge>
</dev>
<dev id="content">
  <p id="title">タイトル</hoge>
</dev>

また、クラスやidは連続して指定できるので、かなり簡潔になることでしょう。

*.slim
dev.content#article
  p.title.text-center タイトル
*.html
<dev class="content" id="article">
  <p class="title text-center">タイトル</hoge>
</dev>

テキストの書き方

テキストは |(パイプ)を使います。

|より後ろはテキストとして扱われるので、タグの横ではなく、タグの下に記述することで見やすくなるというのが最大のメリット。

*.slim
p
  | テキスト
*.html
<p>テキスト</p>

【注意】タグのすぐ後ろに|をいれると、|も文字として認識されてしまうので注意しましょう。

これは、

*.slim
p | テキスト

こうなっちゃいます。

*.html
<p>| テキスト</p>

また、パイプを使えば、文章が長くなった時でも見やすくなります。

p
  |
    テキストテキストテキストテキストテキスト
    テキストテキストテキストテキストテキスト
<p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>

改行したいときは通常と同様、brを使えばOK。

*.slim
p
  |
    テキストテキストテキストテキストテキスト<br>
    テキストテキストテキストテキストテキスト<br>
*.html
<p>テキストテキストテキストテキストテキスト<br>テキストテキストテキストテキストテキスト</p>

brをタグ無しで使いたい!って場合には、以下のように書けます。

*.slim
p
  | テキストテキストテキストテキストテキスト
  br
  | テキストテキストテキストテキストテキスト

br以下は再度|で始めることで、文字として認識させるようにしましょう。

※ 補足
パイプに関して言うと、以下の書き方は上二つが同じで、最後のは改行されません。

*.slim
p
  | テキストテキストテキストテキストテキスト
    テキストテキストテキストテキストテキスト
p
  |
    テキストテキストテキストテキストテキスト
    テキストテキストテキストテキストテキスト
p
  | テキストテキストテキストテキストテキスト
  | テキストテキストテキストテキストテキスト

App.png

いろんな書き方を見るかもしれませんが、タグの直後に書かなくても良いというメリットを生かし、自分の見やすいように書くといいかもしれませんね。

| 以下は文字("")として認識されるということを覚えておきましょう。

ifとかの条件式

ここからは少しRubyが絡みますが、少々お付き合いください(m_ _m)

slimでif文を書くときは、-で囲って上げる必要があります。

- if current_user.nil?
  li 新規登録
- else
  li ログイン
<% if current_user.nil? %>
  <li>新規登録</li>
<% else %>
  <li>ログイン</li>
<% end %>

end入れ忘れる心配がなくて最高ですね(・∀・)

参考: https://qiita.com/nishina555/items/8ed180333fbc8f78e91e

結果をテンプレートに表示したい場合

変数とか出力させたい!ってときは、=を使います。

- sum = 1 + 1
= sum
<% sum = 1 + 1 %>
<%= sum %>

この場合、= から始めた2だけが表示されます。

コメントの書き方

コメントは,/をつけます。

/p テキスト

まとめてコメントアウトしたい!って場合は、以下のようにすると簡単。

ネスとしてる場合は親要素だけ。

/.content
  p 文章

行頭が揃ってる場合は/にネストさせる。

/
  - sum = 1 + 1
  = sum

参考: https://qiita.com/scivola/items/c9a79c13688a3444a3cc

まとめ

<>がいらない
| 以下は文字("")として認識される
・式は-, 出力は=
・コメントは/

初めは独特の書き方に見えるかもしれませんが、慣れるとあまりのスリムさから戻れなくなることでしょう。

特に現場ではslimというフォーマットで書く所が多いと思うので, 初心者こそ慣れておくと良いかもしれませんね( ´ ▽ ` )

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[RSpec] ネストしたルーティングのPOST, PUT(PATCH)のテストが大変だったというお話

こんなコントローラがありました

class CardsController < ApplicationController
  before_action :set_card, only: %i(show edit update destroy)
  before_action :set_list, only: %i(new create)
  def new
    @card = Card.new
  end

  def create
    @card = Card.new(card_params)
    if @card.save
      redirect_to :root
    else
      render :new
    end
  end

  (中略)

  private

  def card_params
    params.require(:card).permit(:title, :memo, :list_id)
  end

  def set_card
    @card = Card.find_by(id: params[:id])
  end

  def set_list
    @list = List.find_by(id: params[:list_id])
  end
end

こんなルーティングを設定しました

  resources :lists,       except: %i(index, show) do
    resources :cards,     except: :index
  end

するとこうなりました

    list_cards POST   /lists/:list_id/cards(.:format)            cards#create
 new_list_card GET    /lists/:list_id/cards/new(.:format)        cards#new
edit_list_card GET    /lists/:list_id/cards/:id/edit(.:format)   cards#edit
     list_card GET    /lists/:list_id/cards/:id(.:format)        cards#show
               PATCH  /lists/:list_id/cards/:id(.:format)        cards#update
               PUT    /lists/:list_id/cards/:id(.:format)        cards#update
               DELETE /lists/:list_id/cards/:id(.:format)        cards#destroy

Request Specを書きました

RSpec.describe "CardsResponses", type: :request do
  describe "cards responses" do
    let(:user)            { create(:user01) }
    let(:list)            { create(:list01, user: user) }
    let(:card)            { create(:card01, list: list) }
    let(:valid_params)    { attributes_for(:card01, list: list, title: "CARD000") }

    (中略)

    describe "#create" do
      it "responds [302]" do
        post list_cards_path(card), params: { card: valid_params }
        expect(response).to have_http_status 302
      end
    end
  end
end

結果

  1) CardsResponses cards responses as a login user #create responds [302]
     Failure/Error: expect(response).to have_http_status 302
       expected the response to have status code 302 but it was 200
     # ./spec/requests/cards_responses_spec.rb:31:in `block (5 levels) in <main>'
     # -e:1:in `<main>'

成功してredirect_to :rootに飛ばされるはずが、
失敗してrender :editされてしまっている。

つまりは、createに失敗している。

対処

createのテストコードにbinding.pryをはさみました

    describe "#create" do
      it "responds [302]" do
        post list_cards_path(card), params: { card: valid_params }
        binding.pry
        expect(response).to have_http_status 302
      end
    end

(仮定1) list_cards_pathに与える変数が間違っている?

letで設定していたのは以下。

    let(:user)            { create(:user01) }
    let(:list)            { create(:list01, user: user) }
    let(:card)            { create(:card01, list: list) }

POST list_cards_path /lists/:list_id/cards(.:format)なので、
パラメータとして、:list_idが求められている。

[3] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>)
> list_cards_path(card)
=> "/lists/1/cards"

あってる?

[4] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>)
> list_cards_path(list, card)
=> "/lists/1/cards.1"

おっとこれはおかしい

[5] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>)
> list_cards_path(list)
=> "/lists/1/cards"

あってる

list has_many cardsの関係なので、お互いのidは関連付けで持ってるから、
どうやらlist、cardどちら指定してもOKみたい。
今回はcardにしておく

(仮定2) 与えてるパラメータが間違っている?

そもそもOKなパラメータって何よ?

cards_controller
  private

  def card_params
    params.require(:card).permit(:title, :memo, :list_id)
  end

title, memo, list_idね。

に対して、僕が設定したパラメータはこんなんでした。

    let(:valid_params)    { attributes_for(:card01, list: list, title: "CARD000") }

factory.rbに登録してあるcard01の関連付けにlistを設定、titleも変えてcard01を呼び出す。

これをpry上で実行すると

[7] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>)
> valid_params
=> {:title=>"CARD000",
 :memo=>"memo01",
 :list=>
  #<List:0x00007f8d4f1c32c8
   id: 1,
   title: "todo01",
   user_id: 1,
   created_at: Sun, 27 Jan 2019 02:24:20 UTC +00:00,
   updated_at: Sun, 27 Jan 2019 02:24:20 UTC +00:00>}

ほーん

require.permitで受け取るパラメータは参照できるのかね?

[8] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>)
> valid_params[:title]
=> "CARD000"

できる

[9] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>)
> valid_params[:memo]
=> "memo01"

できる

[10] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>)
> valid_params[:list_id]
=> nil

!!!!!!!!!!!!!!

できない…

(仮定2のつづき) 関連付けをlist: listで指定したのが間違っていた?

てなわけで、valid_parmas[:list_id]で参照できるように修正してみる。

(修正前)

    let(:valid_params)    { attributes_for(:card01, list: list, title: "CARD000") }

(修正後)

    let(:valid_params)    { attributes_for(:card01, list_id: list.id, title: "CARD000") }

結果

[1] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>)
> valid_params
=> {:title=>"CARD000", :memo=>"memo01", :list_id=>1}

お!ちょっと変わった。
:list_idもしっかり設定されてる。

でも本当に参照できる?

[2] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>)
> valid_params[:title]
=> "CARD000"

できる

[3] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>)
> valid_params[:memo]
=> "memo01"

できる



肝心のlist_idは?



[4] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>)
> valid_params[:list_id]
=> 1



できる!!!!!!!

てなわけで

pryを終了

Finished in 1.26 seconds (files took 0.38429 seconds to load)
50 examples, 0 failures, 2 pending

やっと通りました〜

結論

POST, PUT, PATCH用のパラメータを設定するとき、
コントローラのpermit.requireで指定しているパラメータを
(pry上で)きちんと参照できる形で設定しないと
弾かれてしまうので注意しないといけない



おわり

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【ruby】「DBリソース」と「AWS_S3上の関連するディレクトリ」を一緒に削除する実装(+Rspecテスト)

はじめに

S3上のディレクトリ削除処理とDBリソースと一緒に削除する実装について、色々勉強になったので記事にしておきます。

Tl;Dl;

  • S3上のファイルの削除は以下の通り。
    • 以下のようにbatch_deleteを使うと指定のディレクトリ配下のファイル、ディレクトリを削除できる
class User < ActiveRecord
  def delete_all_s3_contents!
    s3 = Aws::S3::Resource.new(client: s3_client)
    objects = s3.bucket("bucket_name").objects(prefix: "users/#{self.id}")
    objects.batch_delete!
  end
end
  • DBリソースとS3上の削除処理を一緒に行う場合は、以下のようにtransactionを貼れば、どちらかの処理が失敗した時にDBリソース削除はロールバックされるので、データの整合性が担保できる。
class User < ActiveRecord
  def destroy_with_associating_resources
    ActiveRecord::Base.transaction do
      self.destroy!
      self.delete_all_s3_objects!
    end
    true
  rescue => e
    Rails.logger.error(e)
    false
  end
end

ここから少し詳しく説明

説明したいことは、上述のコードだけで説明終了なのですが、もうちょっと詳しい説明を書きたいと思います。

今回説明する際の例として、 ユーザー削除機能 を考えます。そして、そのユーザー機能の1つとして写真投稿があり、その写真はS3上に保存しているとします。

その前提でユーザー削除機能を実装する場合、 DB内のユーザー情報に加えて、指定ユーザーに関連するS3上のファイルを一緒に削除することが必要 になります。

この記事は このユーザー削除+S3上のファイル削除を考える話 となります。

ユーザーとS3上のファイルアップロード先の関係

ユーザーが投稿した写真は、AWSのS3上の /bucket_name/contents/users/:id にアップロードするとします。:idはユーザーのID(数字)です。

そのため、例えばIDは1のユーザーを削除する場合、S3上の /bucket_name/contents/users/1/パスのディレクトリをファイルを含めて削除することを意味します。

実装

以上の説明を踏まえて、作成した実装が以下の通りです。

class User < ApplicationRecord

  def delete_all_s3_contents!
    s3 = Aws::S3::Resource.new(client: s3_client)
    objects = s3.bucket("bucket_name").objects(prefix: "contents/users/#{self.id}")
    objects.batch_delete!
  end

  def destroy_with_associating_resources
    ActiveRecord::Base.transaction do
      self.destroy!
      self.delete_all_s3_objects!
    end
    true
  rescue => e
    Rails.logger.error(e)
    false
  end

  private
  def s3_client
    @s3_client ||= Aws::S3::Client.new(
      access_key_id: "ACCESS_KEY_ID",
      secret_access_key: "SECRET_ACCESS_KEY",
      region: "ap-northeast-1",
    )
  end
end

S3上の指定ディレクトリの削除

def delete_all_s3_contents!
  s3 = Aws::S3::Resource.new(client: s3_client)
  objects = s3.bucket("bucket_name").objects(prefix: "contents/users/#{self.id}")
  objects.batch_delete!
end

指定ディレクトリ削除の流れは、まずs3.bucket("bucket_name").objects(prefix: "contents/users/#{self.id}")にて、バケット名bucket_namecontents/users/#{self.id}ディレクトリにある全てのオブジェクト一覧を取得します。

そして、batch_delete!を呼び出し、先ほど取得したオブジェクト一覧をパラメタとして、一括でS3に削除APIをリクエストします。

s3_client はプライベートメソッドとして切り出しています。理由は、その方がテストのモックが簡単になるからです。

private
def s3_client
  @s3_client ||= Aws::S3::Client.new(
    access_key_id: "ACCESS_KEY_ID",
    secret_access_key: "SECRET_ACCESS_KEY",
    region: "ap-northeast-1",
  )
end

また、AWSのS3に対するAPIリクエストを行なった際に、失敗したら例外が吐かれる想定で実装しています。実際のコードで例外送出を行う箇所を見つけられなかったので正確ではないですが、以下のS3のSDKのスタブに関する記事をみる限り、失敗時は例外を吐くと認識しています(間違っていたら、ご指摘ください)
https://docs.aws.amazon.com/ja_jp/sdk-for-ruby/v3/developer-guide/stubbing.html

ユーザー削除処理

def destroy_with_associating_resources
  ActiveRecord::Base.transaction do
    self.destroy!
    self.delete_all_s3_objects!
  end
  true
rescue => e
  Rails.logger.error(e)
  false
end  

ユーザー削除(self.destroy!)とS3上のファイル削除(self.delete_all_s3_objects!)の2つに対して、transactionを貼ることで、どちらかが失敗して例外を早出したら、DBロールバックによってリクエスト実行前に戻ります。

ここのポイントは、self.delete_all_s3_objects! を後にすることですね。処理を逆にしてしまうと、S3上のファイル削除後に行われるユーザー削除が失敗した場合に、S3上のファイル削除だけ行われてしまうのでデータの整合性が保つことができなくなります。

テスト

次に、S3削除処理(delete_all_s3_contents!)に対するテストについて説明します。全体の流れは以下の通りです。

describe :delete_all_s3_contents! do

  subject { user.delete_all_s3_contents! }

  let(:user) { User.create(name: "hoge") }
  let(:s3_contents) {
    [
      { key: "contents/users/1/IMAGE.png" },
      { key: "contents/users/1" },
    ]
  }
  let(:client) { Aws::S3::Client.new(stub_responses: true) }

  before do
    client.stub_responses(:list_objects, contents: s3_contents)
    client.stub_responses(:delete_objects)
    allow(user).to receive(:s3_client).and_return(client)
  end

  it "指定のパラメタでS3のAPIへリクエストが行われていること" do  
    expect(client).to receive(:list_objects).
        with({
          bucket: "bucket_name",
          prefix: "contents/#{user.id}",
        }).and_call_original

    expect(client).to receive(:delete_objects).
        with({
          bucket: "bucket_name",
          delete: { objects: s3_contents },
        })

    subject
  end
end

前準備

まずbeforeでの前準備の説明です。

まず、APIスタブ用のAWSのS3 clientインスタンスを作成しています。S3Clientはオプションを指定するとスタブができるようになります。

let(:client) { Aws::S3::Client.new(stub_responses: true) }

次に、clientから実行するS3のAPIリクエストのスタブを行います。

before do
  client.stub_responses(:list_objects, contents: s3_contents)
  client.stub_responses(:delete_objects)
  allow(user).to receive(:s3_client).and_return(client)
end

S3のsdkにはstub_responsesというスタブ用のメソッドが用意されています(リファレンス)。

今回はbatch_delete!で指定のオブジェクトの削除を行うのですが、実際にS3側へのリクエストは list_objectsdelete_objectsの2つが行われます。なので、その2つをスタブしてあげれば良いです。

最後に、user インスタンスのプライベートメソッドs3_clientが呼ばれた際に、モック用のS3 clientインスタンスclientを返すよう定義します。これでS3へのリクエストをスタブすることができました。

検証

実際の検証部分は以下の通りです。

it "指定のパラメタでS3のAPIへリクエストが行われていること" do  
  expect(client).to receive(:list_objects).
        with({
          bucket: "bucket_name",
          prefix: "contents/users/#{user.id}",
        }).and_call_original

  expect(client).to receive(:delete_objects).
        with({
          bucket: "bucket_name",
          delete: { objects: s3_contents },
        })
  subject
end

expectwithと使うことでパラメタの検証ができるので、「指定のパラメタでS3へリクエストを送っているか?」を検証しています。

あと、今回初めて知った点で and_call_original です。通常expectで対象となったメソッドはデフォルトではレスポンスを返さなくなります。ただ、今回はlist_objectsのレスポンスを使ってdelete_objects を実行するのでレスポンスがないと以後の検証ができません。なので、and_call_originalを呼ぶことで、ちゃんとレスポンスを返す指定をしています。

おわりに

S3の削除の実装、テストを書きながら、色々勉強になった点をまとめました。今後もテストが書きやすく、わかりやすいコードを書けるように日々精進していきたいです。

参考文献

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む