ActiveModelSerializersのCollectionSerializerを使うときはネストの深さをどこで指定する?

A. renderで渡す

    @res = Hoge.where(name: hoge)
    @items = ActiveModel::Serializer::CollectionSerializer.new(
      @res,
      serializer: HogeSerializer,
      some_values: user_signed_in? && current_user.some_values
    )

    render json: {
      items: @items.page(@page),
      length: @items.size,
    }, include: ['hoge_child_a.hoge_child_child', 'hoge_child_b', 'hoge_child_c'], status: :ok

YouTube埋め込みプレーヤーのあるページでhistoryに同一URLがpushされる

何が起こった?

  • YouTube埋め込みプレーヤーを使っているページで同一のURLがhistoryにpushされていた。つまりページAからYouTube埋め込みプレーヤーのあるページBに遷移し動画の読み込みが完了した後に「前のページに戻る」操作をしてもそこは同じページBであり、二回戻る操作をしなければいけなくなった。
  • React、React Router

なぜ起こった?

  • 以下のようにiframeのsrcにyoutube_video_idを渡しているが、このyoutube_video_idはAPIを叩いて取得した値である。つまり初めはnullで、APIの結果が返ってくると実際の値が入る。
  • 初めは<iframe src={null}>APIの結果が返ってくると<iframe src={"https://..."}>という感じでiframe内のURLが変化している。この変化によって前述の問題が発生していた。
const PageB =() => {
  const { youtube_video_id } = useSelector(state => state.hoge);
  return (
    <div>
        <iframe
          title={"youtube" +youtube_video_id}
          src={"https://www.youtube.com/embed/" + youtube_video_id}
          frameborder="0"
          allowfullscreen
        ></iframe>
    </div>
  );
}

どう直した?

  • つまりは正しいyoutube_video_idが手に入るまではiframe自体を描画しなければいいので以下のようにした。
const PageB = () => {
  const { youtube_video_id } = useSelector(state => state.hoge);
  
  if (youtube_video_id == null) {
    return (
      <div>読み込み中...</div>
    );
  }

  return (
    <div>
        <iframe
          title={"youtube" + youtube_video_id}
          src={"https://www.youtube.com/embed/" + youtube_video_id}
          frameborder="0"
          allowfullscreen
        ></iframe>
    </div>
  );
}

気をつける点

  • これはiframeのsrcがnullからあるURLに変化したから起こったバグであるが、もしPageB -> 他のページ -> PageBという流れで移動したときに一回目のPageBのyoutube_video_idと二回目のPageBのyoutube_video_idが違う場合に注意が必要である。PageBがアンマウントされた時にStore内のyoutube_video_idをnullで初期化しておかないと今回の問題と全く同じ問題が発生する。

参考

stackoverflow.com

 

RailsでItem has many Tagの時にTagに対する条件のANDをしたい

やりたいこと

# Item
  has_many :item_tag_relations, dependent: :destroy
  has_many :tags, through: :item_tag_relations, dependent: :destroy
# ItemTagRelation
  belongs_to :item
  belongs_to :tag
  • Tag name: string

という状況で「"red"と"blue"と"yellow"全てのnameのTagを持ったItem」が欲しいという状況がある。 単純に考えて

names = ['red', 'blue', 'yellow']
tag_ids = Tag.where(name: names).select(:id)
item_ids = ItemTagRelation.where(tag_id: tag_ids).select(:item_id)
@items = Item.where(id: item_ids)

としても「"red"と"blue"と"yellow"のいずれかのnameのTagを持ったA」になってしまい、これは条件のORである。 これを「"red"と"blue"と"yellow"全てのnameのTagを持ったItem」という形で条件のANDにしたかった。

やりかた

names = ['red', 'blue', 'yellow']
item_ids = Item.joins(:tags)
               .where(tags: { name: names })
               .group('items.id')
               .having('count(distinct tags.name) = ?', names.uniq.count)
@items = Item.where(id: item_ids)

やってることは単純で、joinした後に条件をクリアしたTagのnameによる重複を排除した数と条件の数が等しいならそれは全ての条件をクリアしているということなのでそれを利用している。

Heroku, Rails, BigQueryを使って日付毎にログを保存して、APIエンドポイント毎のコール数を知りたい

なぜ?

WEBページのPVやAUGoogle Analyticsを見ればわかるが、ページによってはタブの遷移などでURLが割り振られていない動作があり、それはAPIコールの回数を解析することでしか知ることができない。そして解析はSQLを使っていい感じにやりたい。

やりかた : サーバ

参考にさせていただいた以下の記事通りにやればほとんど出来るが一部変更点がある。

qiita.com

変更点

  1. $ heroku drains:add https://my-fluentd-app.herokuapp.com/log -a myapp$ heroku drains:add https://my-fluentd-app.herokuapp.com/access_log -a myapp

  2. td-agent.confの一部を以下のように変更した。(pathにクエリパラメータを含めないようにし、テーブル名を変えた)

format /\<.*\>\d (?<strtime>.+)\..* method=(?<method>.+) path=((?<path>[^\?]+)(\?.*)?) host=(?<host>.+) request_id=(?<request_id>.+) fwd=(?<fwd>.+) dyno=(?<dyno>.+) connect=(?<connect>.+) service=(?<service>.+) status=(?<status>.+) bytes=(?<bytes>.+) protocol=https$/

table api_call_%{time_slice}

field_string strtime,path,host,status,method,client,fwd,service,bytes,request_id,dyno,connect

やりかた:BigQuery

SELECT
  path,
  COUNT(*) AS count
FROM
  `hogehoge.fugafuga.api_call_*`
WHERE
  _TABLE_SUFFIX BETWEEN '20191001' AND '20191031'
GROUP BY
  path
LIMIT
  1000

itkr.net

 

 

Rubyでgoogle-api-clientを使ってYouTube Data APIを呼ぶ

使い方例

準備

Gemfile

gem 'google-api-client', '~> 0.30.6'

/config/initializers/youtube.rb

require 'google/apis/youtube_v3'

module YouTube
  class Client
    Service = Google::Apis::YoutubeV3::YouTubeService.new
    Service.key = ENV.fetch('DEVELOPER_KEY')
  end
end

呼び出し

res = YouTube::Client::Service.list_channels(
  'statistics',
  id: youtube_channel_id
)
res.items.first.statistics.subscriber_count
res = YouTube::Client::Service.list_playlist_items(
  'snippet',
  playlist_id: playlist_id
)
topic_res = YouTube::Client::Service.list_videos(
  'topicDetails',
  id: youtube_video_id
)
  • メソッドの命名規則はおそらく(list | update | insert ...)_(playlist_items | videos | channels ...)という感じになっていて、公式のドキュメント(https://developers.google.com/youtube/v3/docs?hl=ja)のメニューの第一階層(PlaylistItems, Videos, Channels ...)と第二階層(list, update, insert ...)に対応している。
  • メソッドの第一引数はpart(APIのレスポンスに含める内容を決める値)で、カンマ区切りで複数指定できる。
  • レスポンス内のキーはスネークケース

参考

developers.google.com

google-api-ruby-client/service.rb at master · googleapis/google-api-ruby-client · GitHub

google-api-ruby-client/you_tube.rb at master · googleapis/google-api-ruby-client · GitHub

RailsでSerializerを使ってログインしたUserがLikeできるItem列をPaginationしたい

詰まった点

Serializer側で、「ユーザはログインしているか」と「ユーザは過去にLikeしたか」を判断して値を返さなければいけないがActiveModel::Serializer::CollectionSerializerを使ってどうやってやる?

解決策

ActiveModel::Serializer::CollectionSerializerの#initialize(resources, options = {}) のoptions[:item_likes]にログインしてないならfalseをわたし、ログインしているならそのユーザのitem_likesをわたす

モデルはこんな感じ

  • User
    • has_many: item_likes
  • Item
    • has_many: item_likes
  • ItemLike
    • belongs_to: user
    • belongs_to: item

コントローラはこんな感じ

    items_json = ActiveModel::Serializer::CollectionSerializer.new(
      Item.hogehoge.page(@page),
      serializer: ItemSerializer,
      item_likes: user_signed_in? && current_user.item_likes
    ).as_json

    render json: {
      items: items_json,
      length: length
    }, status: :ok

Serializerはこんな感じ

class ItemSerializer < ActiveModel::Serializer
  attributes %i[id is_liked]

  def initialize(object, options = {})
    super
    @item_likes = options[:item_likes]
  end

  def is_liked
    @item_likes && @item_likes.exists?(item_id: object[:id])
  end
end

参考

www.rubydoc.info

qiita.com

APIモードのRailsでActive StorageとS3を使ってバックエンド、Reactでフロントエンドを実装したい

作り終わってから思い出して書き始めたので抜けがあるかもしれない

バックエンド(Rails API

  • APIモードでRailsアプリを作ってCORSの設定とかをいい感じにする
  • aws-sdk-s3とactive_model_serializersをGemfileに追加してbundle install
  • S3にバケットを作ってパブリックアクセス(get)できるようにする。 qiita.com

  • /config/storage.ymlにS3使うためにservice、access_key_id、secret_access_key、region、bucketの指定をする qiita.com

  • $ rails active_storage:installして$ rails db:migrateする

    qiita.com

  • Userモデルを作る

  • /app/models/user.rbにhas_one_attached :avatarを追加する qiita.com

  • /config/environments/development.rbと/config/environments/production.rbと/config/environments/test.rbにconfig.active_storage.service = :amazonを追加(開発環境ではS3じゃなくてローカルに保存したいとかならここで変更)

    railsguides.jp

  • /app/serializers/user_serializer.rbにUserのSerializerを実装する。以下のavatarメソッドはlocalに保存する設定のときは失敗するので要注意。

 #例
 class UserSerializer < ActiveModel::Serializer
  attributes %i[id name avatar]

  def avatar
    object.avatar.attachment.service.send(:object_for, object.avatar.key).public_url if object.avatar.attached?
  end
end

qiita.com

  • ルーティングを追加 post '/users/current/avatar', to: 'users#update_avatar

  • UsersControllerを作ってUserのavatarを更新するためのメソッドを作る

# 例
  def update_avatar
    @user.avatar = params[:avatar]
    if @user.save
      render json: @user, status: :ok
    else
      render json: @user.errors.full_messages, status: :bad
    end
  end

sato-s.github.io

  • あとは適当に必要なAPIを作る。

フロントエンド(React+Redux)

  • アップロードページの例
import React from "react";
import { useDispatch } from "react-redux";
import { upload_user_avatar } from "../../actions/user";

const MyPage = props => {
  const file_input = React.useRef();
  const dispatch = useDispatch();
  const handle_submit = e => {
    e.preventDefault();
    if (
      file_input &&
      file_input.current &&
      file_input.current.files &&
      file_input.current.files.length > 0
    ) {
      const submit_data = new FormData();
      submit_data.append("avatar", file_input.current.files[0]);
      dispatch(upload_user_avatar(submit_data));
    }
  }
  
  return (
    <form onSubmit={handle_submit}>
      <input type="file" ref={file_input} accept="image/*" />
      <input type="submit" />
    </form>
  );
}
  • actionの例
export const UPLOAD_USER_AVATAR_REQUEST = "UPLOAD_USER_AVATAR_REQUEST";
export const UPLOAD_USER_AVATAR_SUCCESS = "UPLOAD_USER_AVATAR_SUCCESS";
export const UPLOAD_USER_AVATAR_FAILURE = "UPLOAD_USER_AVATAR_FAILURE";

export const upload_user_avatar = submit_data => async dispatch => {
  dispatch({ type: UPLOAD_USER_AVATAR_REQUEST });
  setAuthKeys();

  try {
    const res = await axios.post(
      `${config.backend_api_url}/users/current/avatar`,
      submit_data,
      {
        headers: {
          "content-type": "multipart/form-data"
        }
      }
    );
    return dispatch({ type: UPLOAD_USER_AVATAR_SUCCESS, res });
  } catch (error) {
    dispatch({ type: UPLOAD_USER_AVATAR_FAILURE, error });
  }
};

qiita.com

  • reducerの例
import {
  UPLOAD_USER_AVATAR_REQUEST,
  UPLOAD_USER_AVATAR_SUCCESS,
  UPLOAD_USER_AVATAR_FAILURE,
} from "../actions/user";

const initialState = {};

export default (state = initialState, action) => {
  switch (action.type) {
    case UPLOAD_USER_AVATAR_REQUEST:
      return { ...state };
    case UPLOAD_USER_AVATAR_SUCCESS:
      return { ...state, user: action.res.data.user };
    case UPLOAD_USER_AVATAR_FAILURE:
      return { ...state, error: action.error };
    default:
      return { ...state };
  }
};