くりにっき

フルスタックキュアエンジニアです

ChatWorkMentionTaskでoffline_accessに対応した

前置き

以前ChatWorkMentionTaskというアプリを作った時に

sue445.hatenablog.com

ChatWorkのAPIのリフレッシュトークンの有効期限は2週間なので、リフレッシュトークンが切れる3日前にリマインド用にタスクを作るようにした

って書いたのですが、ChatWork本家でリフレッシュトークンの有効期限が無期限に設定できるようになったということなので小躍りして対応しました。ヒャッホウ!

参考

creators-note.chatwork.com

対応方法

OAuthクライアントの設定に「永続的なAPIアクセスの許可」が増えているのでこいつをチェックして保存

f:id:sue445:20180808003628p:plain

omniauth-chatwork であれば scopeoffline_access を足すだけ。

Rails.application.config.middleware.use OmniAuth::Builder do
  provider :chatwork, ENV["CHATWORK_CLIENT_ID"], ENV["CHATWORK_CLIENT_SECRET"],
           scope: ["users.profile.me:read", "rooms.tasks:write", "rooms.info:read", "offline_access"]
end

https://github.com/sue445/chatwork_mention_task/blob/1be48105ca9c0c43f9a1a1dc98146c4609608e89/config/initializers/omniauth.rb#L1-L4

ChatWorkMentionTaskの本番に適用する前に試したけど、offline_access を有効にした後も既存のアクセストークンやリフレッシュトークンは問題なく使えたのでサクッと変更していいんじゃないかと思います。

ChatWorkMentionTaskの仕様変更について

リフレッシュトークンの有効期限がなくなったので、リフレッシュトークンが切れる3日前のリマインド用にタスク作成はなくします。

(現状リフレッシュトークンが14日間のままのユーザがまだ何人かいるので機能の削除自体はもうちょい先になりますが)

chatwork-ruby v0.10.0をリリースした

github.com

新機能

ファイルアップロードAPIに対応しています http://developer.chatwork.com/ja/endpoint_rooms.html#POST-rooms-room_id-files

こんな感じでファイルがアップロードできます

ChatWork::File.create(room_id: 11111111, file: Faraday::UploadIO.new("/path/to/file.txt", "text/plain"), message: "Test")

ChatWorkのファイルアップロードAPIでハマってたこと

実装自体はサクッと終わったのですが、謎事象で3週間くらいハマってたのでメモ

事象

ファイルアップロードAPI自体は成功するのだが、ファイルがアップロードできていない

こんな感じでAPIは成功してるように見える

[5] pry(main)> @client.create_file(room_id: 11111111, file: Faraday::UploadIO.new("spec/data/upload.txt", "text/plain"), message: "test")
=> {"file_id"=>268750101}

[9] pry(main)> @client.find_file(room_id: 11111111, file_id: 268750101, create_download_url: true)
=> {"file_id"=>268750101,
 "message_id"=>"",
 "filesize"=>424,
 "filename"=>"default.dat",
 "upload_time"=>1531931393,
 "account"=>{"account_id"=>2739132, "name"=>"sue445", "avatar_image_url"=>"https://appdata.chatwork.com/avatar/2170/2170448.rsz.png"},
 "download_url"=>""}

しかし実際にファイルはチャット部屋に投稿されていないし、↑の download_url からファイルもダウンロードできない。

APIを使わずに普通にアップロードしたファイルには全部 message_id がついてたのに、APIからアップロードすると message_id がついていないのもおかしい。

長いので急いでる人は「結論」まで読み飛ばしてください

調査内容1:問題点の切り分け

ChatWorkのAPIが悪いのか、chatwork-ruby の実装が悪いのかの切り分けのために curl でファイル投稿できるか調べることに。

$ curl -s -v -X POST -H "X-ChatWorkToken: ${CHATWORK_API_TOKEN}" -F 'file=@spec/data/upload.txt' "https://api.chatwork.com/v2/rooms/11111111/files"

こういうワンライナーでファイルが投稿できたのでChatWorkのAPIは悪くなさそう(chatwork-rubyの実装が悪い)

調査内容2:ライブラリが悪いのかRubyが悪いのかの切り分け

chatwork-rubyでは faraday を使ってたので、試しにfaradayを使わずにRubyのnet/httpだけでファイルアップロードしてみた

require 'net/https'
require "uri"

file_path = "/path/to/upload.txt"
file = File.open(file_path)
content_type = "text/plain"

data = [
  ["file", file, { filename: File.basename(file_path), content_type: content_type }],
]

url = URI.parse("https://api.chatwork.com/v2/rooms/11111111/files")
req = Net::HTTP::Post.new(url.path)
req.set_form(data, "multipart/form-data")
req["X-ChatWorkToken"] = ENV["CHATWORK_API_TOKEN"]

res = Net::HTTP.start(url.host, url.port, use_ssl: true) do |http|
  http.request(req)
end

file.close

pp res
pp res.body

こういうスクリプトでファイルはアップロードできたので、faraday側の問題だと特定

調査内容3:ファイル投稿に成功するパターンと失敗するパターンでリクエストに差異があるか調べる

ChatWorkのダミーAPIsinatraで作って、そこでリクエストの差異を調べた。

実際に使ってたスクリプトはこんな感じ。

require "sinatra"
require "sinatra/reloader" if development?

get "/" do
  "It works"
end

post "/v2/rooms/:room_id/files" do
  response = ""

  response << "header =====================================\n"
  http_headers = request.env
  http_headers.sort_by{|k,_v| k }.each do |k, v|
    response << "#{k}=#{v}\n"
  end

  response << "body =====================================\n"
  response << request.body.read
  response << "\n"

  response << "params =====================================\n"
  params.sort_by{|k,_v| k }.each do |k, v|
    response << "#{k}=#{v}\n"
  end

  puts response

  {file_id: 11111111}.to_json
end

さっきまで curl -X POST 〜 "https://api.chatwork.com/v2/rooms/11111111/files" してたけど、そのかわりに curl -X POST 〜 "http://localhost:4567/v2/rooms/11111111/files"して実際にどういうリクエストが投げられてるのか目grepして調べた。(chatwork-rubyでもAPIのエンドポイントをlocalhostに差し替えて調べた)

実際のログはこんな感じ。

header =====================================
CONTENT_LENGTH=304
CONTENT_TYPE=multipart/form-data; boundary=------------------------1f157bfa7da824a5
GATEWAY_INTERFACE=CGI/1.1
HTTP_ACCEPT=*/*
HTTP_HOST=localhost:4567
HTTP_USER_AGENT=curl/7.59.0
HTTP_VERSION=HTTP/1.1
HTTP_X_CHATWORKTOKEN=XXXXXXXXXXXXXXXXXXXX
PATH_INFO=/v2/rooms/11111111/files
QUERY_STRING=
REMOTE_ADDR=::1
REMOTE_HOST=::1
REQUEST_METHOD=POST
REQUEST_PATH=/v2/rooms/11111111/files
REQUEST_URI=http://localhost:4567/v2/rooms/11111111/files
SCRIPT_NAME=
SERVER_NAME=localhost
SERVER_PORT=4567
SERVER_PROTOCOL=HTTP/1.1
SERVER_SOFTWARE=WEBrick/1.4.2 (Ruby/2.5.1/2018-03-29)
rack.errors=#<IO:0x00007f8d640a57a0>
rack.hijack?=true
rack.hijack_io=
rack.input=#<StringIO:0x00007f8d649cb938>
rack.logger=#<Logger:0x00007f8d649785f8>
rack.multiprocess=false
rack.multithread=true
rack.request.form_hash={"file"=>{:filename=>"upload.txt", :type=>"text/plain", :name=>"file", :tempfile=>#<Tempfile:/var/folders/mx/mmp8n_lx48v8_fr294_zjggw0000gn/T/RackMultipart20180722-9549-4azpxv.txt>, :head=>"Content-Disposition: form-data; name=\"file\"; filename=\"upload.txt\"\r\nContent-Type: text/plain\r\n"}, "message"=>"Test"}
rack.request.form_input=#<StringIO:0x00007f8d649cb938>
rack.request.query_hash={}
rack.request.query_string=
rack.run_once=false
rack.tempfiles=[#<Tempfile:/var/folders/mx/mmp8n_lx48v8_fr294_zjggw0000gn/T/RackMultipart20180722-9549-4azpxv.txt>]
rack.url_scheme=http
rack.version=[1, 3]
sinatra.commonlogger=true
sinatra.route=POST /v2/rooms/:room_id/files
body =====================================
--------------------------1f157bfa7da824a5
Content-Disposition: form-data; name="file"; filename="upload.txt"
Content-Type: text/plain

Hello ChatWork!

--------------------------1f157bfa7da824a5
Content-Disposition: form-data; name="message"

Test
--------------------------1f157bfa7da824a5--

params =====================================
file={"filename"=>"upload.txt", "type"=>"text/plain", "name"=>"file", "tempfile"=>#<Tempfile:/var/folders/mx/mmp8n_lx48v8_fr294_zjggw0000gn/T/RackMultipart20180722-9549-4azpxv.txt>, "head"=>"Content-Disposition: form-data; name=\"file\"; filename=\"upload.txt\"\r\nContent-Type: text/plain\r\n"}
message=Test
room_id=93207172

実際にはパラメータを色々変えつつログを見比べて調べてます。だいたい10回くらい調べたけど、boundary以外の差異が特になくて\(^o^)/オワタ状態。

調査内容4:faradayでファイルアップロードしてる他の事例を調べる

真っ先に思い浮かんだのはslackのgem

https://github.com/slack-ruby/slack-ruby-client

ファイルアップロード部分やfaradayの設定周りをchatwork-rubyと見比べたけどこれといっておかしなところは無し。

ここまでくるとChatWork APIが微妙に設定がおかしいのではと思い始めてくる。

調査内容5:net/httpのソースを追う

「調査内容2」のソースで req.set_formしないとChatWorkにファイルがアップロードされないのではと思い、Rubyのnet/httpのソースを追うことにした。

https://github.com/ruby/ruby/blob/v2_5_1/lib/net/http/generic_request.rb#L119-L129

  def exec(sock, ver, path)   #:nodoc: internal use only
    if @body
      send_request_with_body sock, ver, path, @body
    elsif @body_stream
      send_request_with_body_stream sock, ver, path, @body_stream
    elsif @body_data
      send_request_with_body_data sock, ver, path, @body_data
    else
      write_header sock, ver, path
    end
  end

req.set_formすると @body_data に値がセットされて send_request_with_body_data側の分岐に入ってmultipart/form-dataのbodyが生成される。しかしfaradayは自分でmultipart/form-dataのbodyを生成して send_request_with_body_stream 側の分岐に入る。

この段階でfaradayが生成したmultipart/form-dataのbodyが微妙におかしいのではとあたりをつけた。

調査段階6:net/httpにブレークポイントを仕込んで調査

ここでようやく問題解決。

ファイルがアップロードできなかった時の分岐のブレークポイント

From: /Users/sue445/.rbenv/versions/2.5.0/lib/ruby/2.5.0/net/http/generic_request.rb @ line 207 Net::HTTPGenericRequest#send_request_with_body_stream:

    191: def send_request_with_body_stream(sock, ver, path, f)
    192:   unless content_length() or chunked?
    193:     raise ArgumentError,
    194:         "Content-Length not given and Transfer-Encoding is not `chunked'"
    195:   end
    196:   supply_default_content_type
    197:   write_header sock, ver, path
    198:   wait_for_continue sock, ver if sock.continue_timeout
    199:   binding.pry
    200:   if chunked?
    201:     chunker = Chunker.new(sock)
    202:     IO.copy_stream(f, chunker)
    203:     chunker.finish
    204:   else
    205:     # copy_stream can sendfile() to sock.io unless we use SSL.
    206:     # If sock.io is an SSLSocket, copy_stream will hit SSL_write()
 => 207:     IO.copy_stream(f, sock.io)
    208:   end
    209: end

[2] pry(#<Net::HTTP::Post::Multipart>)> f.read
=> "-------------RubyMultipartPost\r\nContent-Disposition: form-data; name=\"file\"; filename=\"upload.txt\"\r\nContent-Length: 16\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: binary\r\n\r\nHello ChatWork!\n\r\n-------------RubyMultipartPost--\r\n\r\n"

ファイルがアップロードできた時の分岐のブレークポイント

From: /Users/sue445/.rbenv/versions/2.5.0/lib/ruby/2.5.0/net/http/generic_request.rb @ line 231 Net::HTTPGenericRequest#send_request_with_body_data:

    210: def send_request_with_body_data(sock, ver, path, params)
    211:   binding.pry
    212:   if /\Amultipart\/form-data\z/i !~ self.content_type
    213:     self.content_type = 'application/x-www-form-urlencoded'
    214:     return send_request_with_body(sock, ver, path, URI.encode_www_form(params))
    215:   end
    216:
    217:   opt = @form_option.dup
    218:   require 'securerandom' unless defined?(SecureRandom)
    219:   opt[:boundary] ||= SecureRandom.urlsafe_base64(40)
    220:   self.set_content_type(self.content_type, boundary: opt[:boundary])
    221:   if chunked?
    222:     puts "[send_request_with_body_data] debug 2"
    223:     write_header sock, ver, path
    224:     encode_multipart_form_data(sock, params, opt)
    225:   else
    226:     require 'tempfile'
    227:     file = Tempfile.new('multipart')
    228:     file.binmode
    229:     encode_multipart_form_data(file, params, opt)
    230:     file.rewind
 => 231:     self.content_length = file.size
    232:     write_header sock, ver, path
    233:     IO.copy_stream(file, sock)
    234:     file.close(true)
    235:   end
    236: end

[19] pry(#<Net::HTTP::Post>)> file.read
=> "--dmpgu3OX7ioTGyZ8xoQo0V5RB8CLM-Kr2IOdw4EqWF2sO_hABe0niQ\r\nContent-Disposition: form-data; name=\"file\"; filename=\"upload.txt\"\r\nContent-Type: text/plain\r\n\r\nHello ChatWork!\n\r\n--dmpgu3OX7ioTGyZ8xoQo0V5RB8CLM-Kr2IOdw4EqWF2sO_hABe0niQ--\r\n"

ウーン、bodyの終端の改行の個数がおかしくね??? \r\n\r\n だとファイルアップロードできなくて \r\n だとファイルアップロードできてる。

試しにローカルで下記の改行を1つ削って動かしたらファイルアップロード成功 https://github.com/nicksieger/multipart-post/blob/v2.0.0/lib/parts.rb#L92

勝った!第3部完ッ!(AA略

調査内容3で成功するパターンと失敗するパターンでbodyの中身を比較して差異が無いと判断したけど、末尾の改行の個数が違うのは完全に見逃してた。。。

結論

ファイルをPOSTする時にはmultipart/formdataのbodyの末尾が \r\n にする必要がある。(\r\n\r\n だとファイルがアップロードできない)

モンキーパッチ

multipartのbodyの末尾が \r\n\r\nで問題が起きるのは少なくともChatWorkだけっぽいのでmultipart-postにPR投げてもリジェクトされる可能性が高いし *1、かといってさっきの EpiloguePartオープンクラスで直接書き換えるとchatwork-ruby以外でfaraday使ってた場合にも影響を受けてしまう可能性があったので、別名でクラスを作って差し替えるようにした。

ソース

https://github.com/asonas/chatwork-ruby/blob/v0.10.0/lib/chatwork/multipart.rb

学び

  • デバッグを通してmultipartやnet/http周りの実装が分かった。
  • その気になればRubyの標準ライブラリに対しても binding.pry を仕込めることが分かった。(もうやりたくない)

*1:少なくとも multipart-post のissueに問題それっぽいのが書かれてなかったので誰も困ってない

rubocop-itamaeを作った

itamae のレシピを静的解析するrubocopのプラグインを作りました。

github.com

コンセプト

前職でitamaeのレシピをレビューすることが多々あったのですが、毎回同じことを指摘するのも大変だったのでrubocopのcopにしました。

rubocop-itamae自体はrubocopにしか依存していないので itamae と mitamae の両方のレシピで使えると思います。

下記のようなcopを作っています

Itamae/CdInExecute

execute の中で cd せずに cwd を使え。

# bad
execute 'cd /tmp && rm -rf /tmp/*'

# good
execute 'rm -rf /tmp/*' do
  cwd '/tmp'
end

Itamae/CommandEqualsToName

execute の引数と command で同一のコマンドを書くのはDRYじゃないのでやめろ

# bad
execute 'rm -rf /tmp/*' do
  command 'rm -rf /tmp/*'
end

# good
execute 'rm -rf /tmp/*'

execute 'Remove temporary files' do
  command 'rm -rf /tmp/*'
end

Itamae/NeedlessDefaultAction

デフォルトのaction(package リソースでいえば :install)は省略できる

# bad
package 'git' do
  action :install
end

# good
package 'git' do
end

package 'git'

Itamae/RecipePath

レシピが itamae推奨のディレクトリ構成 に沿っているかチェックする

# bad
default.rb
hoge/web.rb

# good
cookbooks/nginx/default.rb
roles/web.rb

copを作ったメリット

  • 今までレビューで指摘してきた事例に対して Itamae/CdInExecute のような名前をつけることができたのでよかった

今後作りたいcop

Itamae/NodeKeyType

grepabilityの観点からnode のkeyをStringかSymbolかのどっちかに統一しときたい。

途中までは作った *1 んだけど、現状のrubocopだと実装に必要な機能が足りないのでいったん保留 *2

転職エントリ

こちらからは以上です。

*1:最終出社は5/25であとは1ヶ月間有給消化

プリ☆チャンMAPを作ってみた

公式HPの あそべるお店 には住所しか載っていなくて不便なので自分用に作りました *1

drive.google.com

https://drive.google.com/open?id=1e16bKjf_dOTkHEmZLZCqVTTHlVIr3f5l

やってること

詳細は省きますが、店舗リストをスプレッドシートに投入してGoogleマイマップにインポートしているだけです

こんな感じでスプレッドシートを作っておけばいい感じに地図に表示できて便利

f:id:sue445:20180701192554p:plain

マイマップは1レイヤ辺り2000件までしか登録できないので、地区ごとにレイヤーを分けています。(都道府県単位でレイヤーを分けると今度はインポートが不便)

f:id:sue445:20180701192712p:plain

良かったこと

  • データだけ用意しておけば雑に地図を表示できる
    • jsや公開先のサーバいらず
  • 通常Googleマップに表示する時は緯度経度の情報が必要だが、住所だけで地図に表示できる
    • ジオコーディングいらず

苦労したこと

  • GoogleマイマップにはAPIがない
  • インポート元のスプレッドシートが更新されてもマイマップは 更新されない
    • てっきり同期されてるもんだと思っていたら、再反映時は都度レイヤー削除&インポートが必要っぽい
  • スプレッドシートは1枚目のシートしかインポートしてくれない
    • 最初は1枚のスプレッドシートに対して地区(レイヤ)ごとにシートを分けていたんですが、それだと一番左のシートしか登録できなかったので各地区ごとにスプレッドシートを作るようにしました
  • 公式HPに載ってる住所が一部おかしくてそのままインポートするとエラーになる
    • 2000件中20件くらいがエラーになったので手で直しています(´・ω・`)

*1:ソースは非公開です

Trelloで散財リストを管理する

タスク管理ツールとして有名な Trello を散財リストとして運用している知見の紹介です。

僕の散財リスト

百聞は一見にしかずということで僕の散財リストを晒し。

f:id:sue445:20180626234629p:plain

リスト

購入予定

Twitterで買いたいものの新作情報が流れてきたら購入予定リストにカードを追加します。

f:id:sue445:20180626235028p:plain

カードの期限のところに僕は商品の発売日を入力しています。

この時言語設定がEnglishだと日付が分かりづらいので日本語推奨。

Englishだと MM/DD/YYYY

f:id:sue445:20180626235310p:plain

日本語だと YYYY/MM/DD

f:id:sue445:20180626235144p:plain

発売日(期限)順にソートできて便利。

f:id:sue445:20180626235558p:plain

予約済

実際にAmazonとかで予約したらカードを移動します。

f:id:sue445:20180626235748p:plain

「購入予定」と「予約済」でリストがわかれているのは、Twitterとかで情報が出た時点では予約が始まってないことがあるので予約することを忘れないためです。

どのサイトで予約したかをラベルにしとくとAmazonで予約したことを忘れて楽天で再度予約したりすることがなくていいかもしれないですね。*1

f:id:sue445:20180626235810p:plain

購入済

実際に購入したらカードを移動します。

まとめ

一番古いカードを見たら2014年11月だったのでだいたい3年半くらいTrelloで散財リストを運用しているようです。

f:id:sue445:20180627000428p:plain

Trelloを使う前はEvernote辺りを使ってたと思うのですが、予約済なのか購入済なのかのステータス管理が面倒でした。

Trelloを使うことでステータス管理できるようになって効率よくプリキュアに散財できるようになりました

*1:僕はラベル付けることをよく忘れてますが

プリパラを全話見た

きっかけ

4月の新番組の キラッとプリ☆チャン を見始める

www.tv-tokyo.co.jp

  ↓

5月頃映画が上映されたのでなんとなく見に行く

pp-movie.com

  ↓

入場者特典が欲しいので3周鑑賞

  ↓

映画の元ネタを知るためにプリパラ全話視聴

キュアエンジニアがプリパラにハマっていく流れ

1st season

anime.dmkt-sp.jp

anime.dmkt-sp.jp

anime.dmkt-sp.jp

ちなみにこの辺はRubyKaigiの宿でPR書きながら見てたと思います。

2nd season

anime.dmkt-sp.jp

(上記ツイートはブラジルじゃなくてメキシコの勘違い)

anime.dmkt-sp.jp

3rd season

anime.dmkt-sp.jp

anime.dmkt-sp.jp

アイドルタイム

anime.dmkt-sp.jp

anime.dmkt-sp.jp

余談

初めて見たプリキュアの映画がオールスターズNewStageで当時まともに見てたのはスイートとスマイルだけでしたが、その後過去作品全話視聴しています

参考

sue445.hatenablog.com

この後の予定

プリリズとキンプリを見ていこうと思います。

追記

一番好きなユニットはガァルマゲドンです