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で初期化しておかないと今回の問題と全く同じ問題が発生する。
参考
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やAUはGoogle Analyticsを見ればわかるが、ページによってはタブの遷移などでURLが割り振られていない動作があり、それはAPIコールの回数を解析することでしか知ることができない。そして解析はSQLを使っていい感じにやりたい。
やりかた : サーバ
参考にさせていただいた以下の記事通りにやればほとんど出来るが一部変更点がある。
変更点
$ 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
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
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のレスポンスに含める内容を決める値)で、カンマ区切りで複数指定できる。
- レスポンス内のキーはスネークケース
参考
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
参考
APIモードのRailsでActive StorageとS3を使ってバックエンド、Reactでフロントエンドを実装したい
作り終わってから思い出して書き始めたので抜けがあるかもしれない
バックエンド(Rails API)
- APIモードでRailsアプリを作ってCORSの設定とかをいい感じにする
- aws-sdk-s3とactive_model_serializersをGemfileに追加してbundle install
/config/storage.ymlにS3使うためにservice、access_key_id、secret_access_key、region、bucketの指定をする qiita.com
$ rails active_storage:install
して$ rails db:migrate
する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じゃなくてローカルに保存したいとかならここで変更)/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
ルーティングを追加
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
- あとは適当に必要な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 }); } };
- 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 }; } };