無印吉澤

Site Reliability Engineering(SRE)、ソフトウェア開発、クラウドコンピューティングなどについて、吉澤が調べたり試したことを書いていくブログです。

GNU social (OStatus) 自体の仕様に関する情報源まとめ

f:id:muziyoshiz:20170430143245p:plain

(上のロゴは、何故か スペイン語版の Wikipedia にだけあった。本当に公式のロゴ?)

はじめに

Mastodon 大人気ですね。

僕もとりあえず mstdn.jppawoo.net にアカウントを取ってお互いにフォローし、どれくらいの時間差でトゥートが伝達されるのか観察したり、GitHub に公開されているソースコードを少し読んだりしました。

「Mastodon は分散型だ」とか「GNU social と互換性がある」という話を聞いて、一体どんなプロトコルなんだろう……と気になって少し調べたのですが、GNU Social 自体がかなり古いものらしく、ドキュメントを探すのにも苦労しました。

そこで、自分で探した範囲で、原典に近いと思われるドキュメントのリンク集を作っておきます。新しい情報が見つかったら、随時更新します。

プロトコル同士の関係

以下のドキュメントに書かれている、OStatus に関連するプロトコルをまとめると、次のようになります。

  • Atom と RSS フィードを、サーバ間の共通言語として使う
  • Webfinger を使って、サブスクライブしたい相手の Link-based Resource Descriptor Discovery (LRDD) ドキュメントを探す
    • Webfinger を使う前に、meta-data で Webfinger 用の URI を取得する。
  • サーバ間でフィードのアップデートを購読し、プッシュ配信を受けるために PubSubHubbub を使う
  • PubSubHubbub の機能不足を補うために、Atom の拡張(Activity Streams, Portable Contacts, Salmon)を使う
    • フィードが表す social activity を表現するために、Activity Streams を使う(例えば、フォローのときは "follow" verb を使う)
    • プロフィール情報を提供するために Portable Contacts を使う
    • リプライを送るために Salmon を使う

GNU social

OStatus は、GNU social にマージされた StatusNet に端を発するプロトコルです。そこで、まずは GNU social のドキュメントを当たりました。

  • GNU social - Wikipedia

    • 結論から言うと、GNU social そのものについては情報が失われていて、Wikipedia に載っている以上の情報はコードを読むしかないのかもしれない。
  • GNU social の公式サイト

    • 情報量はあまりない。
    • What is GNU social? に、GNU social が2010年に PHP スクリプトの集合として始まったこと、その後 StatusNet とコードベースが共有されたこと、2013年に Free Social project とマージされたことなどが書かれている。
    • FAQ にある "Why are you using PHP? Ruby/Python/Perl/A GUI in Visual Basic would be better!" の答えにちょっと笑った。
  • GNU social の GitLab

    • 最新版は 1.2.x 系。リリースノートもタグもないため、詳細はよくわからず。
    • 開発は継続されているが、Contributors のグラフ を見る限り、当初の開発者はほとんど手を引いている。
    • doc-src ディレクトリ にドキュメントがあるが、ざっと見た限り、クライアント-サーバ間通信の情報しかない。サーバ間通信の情報は見当たらなかった。
  • GNU social の GitHub

    • 上記の GitLab のミラー?
  • The Unofficial GNU Social documentation!

    • 基本的に GNU social のインストール方法についてのマニュアル。
    • Protocol Overview というページがあったので、これは!と思ってクリックしたら "GNU social runs primarily on voodoo magic. If anybody knows better please advise." としか書かれてなかった。
    • ですよね。

OStatus

  • OStatus - Wikipedia

    • OStatus の歴史と概要、OStatus を採用するソフトウェア一覧あり。
    • このページでは関連するプロトコルとして Atom, Activity Streams, PubSubHubbub, Salmon, Webfinger の名前が挙げられている。
    • "Standards Work" の節に、「pump.io で使われているプロトコルを元にした "ActivityPub" と呼ばれる新しい標準があり、これが OStatus の後継になりうるとされてきた」という記述がある。
  • OStatus Community Group

    • W3C のサイトにある Wiki。情報量は決して多くないが、OStatus に関連するプロトコルについての説明がある。
    • (see spec) と書かれた部分がリンクになっており、そこに OStatus 1.0 Draft 2 の仕様がある。
  • OStatus Community Group - Workflow

    • HTTPリクエスト/レスポンス例を伴った具体例。もしかしたら、これが OStatus のシーケンスに関する、最も詳しいドキュメントかもしれない。
    • ただし、これは OStatus 1.0 Draft 1(リンク切れ)ベースらしいので、Draft 2 とは多少違う可能性がある。
  • OStatus 1.0 Draft 2 の仕様

    • 2010年8月公開。
    • 全体で7ページと、仕様書としては短い。詳細を他の仕様書に譲っているからだが、参照先のドキュメントが古いため、一部はすでに見つからなくなっている。
    • 用語の定義があり、OStatus を調べる人には、それだけでも有用かもしれない。
    • OStatus では、プライベートメッセージングやソーシャルグラフは対象外、と書かれている。
    • "12. Usage scenario" に利用シナリオが書かれている。

meta-data (Web Host Metadata)

これは mstdn.jp で簡単に試すことができます。例えば、https://mstdn.jp/.well-known/host-meta にアクセスすると、以下が返されます。

> curl "https://mstdn.jp/.well-known/host-meta"
<?xml version="1.0"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
  <Link rel="lrdd" type="application/xrd+xml" template="https://mstdn.jp/.well-known/webfinger?resource={uri}"/>
</XRD>

Link-based Resource Descriptor Discovery (LRDD)

  • draft-hammer-discovery-06 - LRDD: Link-based Resource Descriptor Discovery
    • LRDD に関する最後の Internet Draft。
    • このプロトコルは、URI が示すリソースにアクセスするための「プロセス」を定義している。host-meta を使う方法は、そのプロセスの一つである。
    • host-meta を使う方法は "5.1. host-meta Document" に記載されている。

Webfinger

これも mstdn.jp で簡単に試すことができます。

LRDD が示すように "application/xrd+xml" を指定して https://mstdn.jp/.well-known/webfinger?resource=acct%3Amuziyoshiz%40mstdn.jp にアクセスすると、XML 形式で返されました。ちなみに、Web ブラウザでアクセスすると、JSON(application/jrd+json)が返されます。

> curl -H "Accept: application/xrd+xml" "https://mstdn.jp/.well-known/webfinger?resource=acct%3Amuziyoshiz%40mstdn.jp"
<?xml version="1.0"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
  <Subject>acct:muziyoshiz@mstdn.jp</Subject>
  <Alias>https://mstdn.jp/@muziyoshiz</Alias>
  <Alias>https://mstdn.jp/users/muziyoshiz</Alias>
  <Link rel="http://webfinger.net/rel/profile-page" type="text/html" href="https://mstdn.jp/@muziyoshiz"/>
  <Link rel="http://schemas.google.com/g/2010#updates-from" type="application/atom+xml" href="https://mstdn.jp/users/muziyoshiz.atom"/>
  <Link rel="salmon" href="https://mstdn.jp/api/salmon/14903"/>
  <Link rel="magic-public-key" href="data:application/magic-public-key,RSA.wOHgmclLfwfGDWfxN1pWfGIwr5GTXbFhJG49yuqrdI6T2WULDvlXUJx3vSIMCiwtkZn-DE9Rhpyse9_69xshlYerke0RvI6OfnvTv20RqFEz0Z65k9W4GTcYKAKu441OzMnY9C3144SiecDpW2noULukzFOMOEY22ON21yQk94QAzJXFt2Hh35ia31uK_JI5NDWGrcl-Rdl8mTHDjhkA4sZC504IInxEpMSxOMMhs75DS_HYYdYuWX-hkGtGEZy5qEfz7HSrSMU8x6e-hwq_ULZ-a5TmIWslJkqWoX_T94gR0hiLPEjpNQpf7R50jB57dltmeo_wKyeETkjxoWBRDw==.AQAB"/>
  <Link rel="http://ostatus.org/schema/1.0/subscribe" template="https://mstdn.jp/authorize_follow?acct={uri}"/>
</XRD>

PubSubHubbub (PuSH)

twitter.com

Activity Streams

Portable Contacts

Portable Contacts は、Google Contacts などでも使われている仕様らしいが、正式な仕様書らしきものが見つからなかった。

Salmon (Salmon Protocol)

The Good Stuff によると、「鮭が上流に泳いでいくように」元のブログサーバにコメントを送り返すためのプロトコルなので、Salmon という名前にしたそうです。OStatus では、これをリプライを返すための仕組みとして使っています。

OStatus が使っている、その他の Atom 拡張

番外:ActivityPub

OStatus の範囲外ですが、ActivityPub についても取り上げておきます。

まとめ

実際にドキュメントを追ってみて、噂に聞く通り、OStatus は非常に雑多な(一部はすでに廃れた)プロトコルの組み合わせで作られていることがわかりました。それぞれのプロトコルが、Mastodon 上でどう実装されているかは、他の人の調査に任せたいと思います。

実際、OStatus について先行して調べていた岡本さんによると、GNU social などの既存実装は、これらのドキュメント通りに実装しても動かないそうです。

twitter.com

OStatus について、5月12日に出る(早い!)マストドン本で岡本さんが解説してくれるらしいので、とりあえず僕はこの本を待とうと思います。

okapies.hateblo.jp

これがマストドンだ!  使い方からインスタンスの作り方まで (NextPublishing)

これがマストドンだ! 使い方からインスタンスの作り方まで (NextPublishing)

Ruby on Rails 5 アプリにあとから API 機能(JWT, CORS 対応)を追加する

f:id:muziyoshiz:20170320163237p:plain

はじめに

https://www.admiral-stats.com/ という URL で、Ruby on Rails 5 で作った Admiral Stats というサービスを動かしています。このサービス自体については、過去の記事 を参照ください。

このサービスに最近 API 機能を追加したので、その方法を紹介します。一通り読んでもらえると、API サーバって割と簡単に作れるんだなーというのがわかると思います。

今回追加した API

API を追加した目的

Admiral Stats は、艦これアーケードというゲームのプレイデータを、ユーザ(提督)から時々アップロードしてもらって、それを時系列に可視化するサービスです。

f:id:muziyoshiz:20160828003101p:plain

プレイデータは、SEGA の公式プレイヤーズサイトから、非公式のエクスポータを使ってエクスポートしてもらいます。そのためのエクスポータも、ブックマークレット版、PowerShell 版、Ruby 版と色々配布しています。

今までは Web ブラウザで Admiral Stats にログインして、「インポート」ページからプレイデータをアップロードしてもらう必要がありました。

f:id:muziyoshiz:20170320163724j:plain:w600

自分で使っていてもこれが面倒だったので、この部分を自動化するために API を作ることにしたわけです。

最終的に作った API の仕様

  • GET /api/v1/import/file_types
    • Admiral Stats がインポート可能なファイル種別のリストを返す。
    • エクスポータによっては、現在の Admiral Stats がまだサポートしていないプレイデータもエクスポートする。この API を使うことで、未サポートのデータをアップロードしなくて済むようにする。
  • POST /api/v1/import/:file_type/:timestamp
    • ボディ部に含まれる JSON(プレイデータ)をパースし、データベースに登録する。
    • :file_type/api/v1/import/file_types が返すファイル種別のいずれか。
    • :timestamp%Y%m%d_%H%M%S 形式。日本にしか無いゲームのためのツールなので、問答無用で JST で解釈する。

この API の呼び出しは、後述するトークンで認証します。例えば、curl で呼び出す場合、Authorization ヘッダにトークンを入れて、以下のように呼び出します(このトークンは架空のものなので、実際に叩くと失敗します)。

happyturn% curl -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiaWF0IjoxNDg5MDY1Nzg1fQ.q-SlF1tPKyCiNBOzdbQ2JLSqSr7d380WbKxZp818xeo" \
> -i -X GET https://www.admiral-stats.com/api/v1/import/file_types
HTTP/1.1 200 OK
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Type: application/json; charset=utf-8
ETag: W/"289acc0adafeb819ebf2fca179abb2a7"
Cache-Control: max-age=0, private, must-revalidate
X-Request-Id: 553b2151-562e-47f7-b46f-c0a41384ed5a
X-Runtime: 0.044076
Vary: Origin
Transfer-Encoding: chunked

["Personal_basicInfo","TcBook_info","CharacterList_info","Event_info"]

使い方

API トークンの発行

ユーザは、まずは API トークンを発行します。Admiral Stats にログインすると、「API トークンの設定」というメニューが表示されるので、ここで新しいトークンを発行できます。

もしトークンが漏れた場合は、「トークンの再発行」ボタンを押すと新しいトークンが発行されて、古いトークンは使えなくなります。このような失効処理は、JWT の機能では実現できないので、後述するテーブルを使って実現しました。

f:id:muziyoshiz:20170320164221p:plain:w600

エクスポータの設定

次に、このトークンをエクスポータに設定します。この設定はブックマークレット版が一番簡単で、Admiral Stats が生成するブックマークレットに、そのユーザのトークンが自動的に埋め込まれるようになります。

f:id:muziyoshiz:20170320164428p:plain:w600

これをブックマークバーにドラッグアンドドロップすると、以下のようなブックマークレットが登録されます。

javascript:(function(u,t,b){var%20s=document.createElement('script');s.charset='UTF-8';s.id='admiral-stats-exporter';s.setAttribute('data-token',t);s.setAttribute('data-skip-backup',b);s.src=u;document.body.appendChild(s)})('https://www.admiral-stats.com/bookmarklets/exporter.js','eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiaWF0IjoxNDg5MDY1Nzg1fQ.q-SlF1tPKyCiNBOzdbQ2JLSqSr7d380WbKxZp818xeo','true');

ちなみに、PowerShell 版の場合は起動時に表示されるプロンプトでトークンを書く、Ruby 版の場合は設定ファイル config.yaml にトークンを書く、という方法で設定できます。

エクスポータの実行

あとは、従来通りに、ブックマークレットを SEGA 公式サイトで実行すると、エクスポートから自動アップロードまで行われます。

ただ、ブックマークレット版は、非同期の JavaScript しか書けない関係上、「エクスポートから自動アップロードのどこかが失敗したらエラーを出す」ということがうまくいきません(無理矢理書くことはできるのかもしれませんが……)。

その対策として、Admiral Stats に「API ログの確認」というメニューを作り、ここを見てもらえれば自動アップロードが成功したかどうかを確認できるようにしました。ちなみに、PowerShell 版と Ruby 版では、実行に失敗するとエラーメッセージが表示されます。

f:id:muziyoshiz:20170320164603p:plain:w600

以上が、API の簡単な使い方の解説でした。詳細は Admiral Stats の使い方 をご参照ください。

JSON Web Token (JWT) への対応

まずは、API のための認証を簡略化するための、トークン発行・検証機能を実装します。このトークンとして、今回は JWT を採用しました。

JWT とは?

JWT とは、認証トークンとして使うために、JSON 形式のデータを Base64 エンコードして署名を付けた文字列のことです。

JWT に関する情報は https://jwt.io/ にまとまっています。また、IETF の RFC 7519 - JSON Web Token (JWT) として仕様策定されています。ちなみに、この RFC によると、JWT の発音は「ジョット」らしいです。

The suggested pronunciation of JWT is the same as the English word “jot”.

JWT は、3個の Base64 文字列を、ドット(.)で繋いだ文字列です。https://jwt.io/ に表示されているサンプルを、試しに irb でデコードしてみます。

irb(main):001:0> require 'base64'
=> true
irb(main):002:0> Base64.decode64('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')
=> "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"
irb(main):003:0> Base64.decode64('eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9')
=> "{\"sub\":\"1234567890\",\"name\":\"John Doe\",\"admin\":true}"
irb(main):004:0> Base64.decode64('TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ')
=> "L\x95@\xF7\x93\xAB3\xB16p\x16\x9B\xDFDL\x1E\xB1\xC3pG\xF1\x8E\x86\x19\x81\xE1N4X{\x1E\x04"

JWT では、特に暗号化されているわけではないのがわかると思います。また、3個目の Base64 文字列は署名なので、JSON にはなりません。署名の検証は、後述する gem で行えます。

また、トークンを検証する際には「有効期限が切れていないか調べる」とか「必要な権限を持っているか調べる」とか、よく行う作業があると思います。

JWT では、よく使う情報を格納するためのキー名が予約されていて、それらは Claim Names と呼ばれます。例えば、トークンの期限切れ時刻を表す “exp” (Expiration Time) という claim があります。この名前に従う必要はないんですが、従っておくと、JWT のライブラリにビルトインされている検証処理を使えて便利です。

JWT を操作するための gem

https://jwt.io/ には、以下の3種類の gem が載っています。このサイトの比較表では、機能の違いは特にありません。

このなかで、jwt は最も star が多く、README がこれを読むだけでも十分 JWT を理解できそうなくらい充実していました。そこで今回は、無難に jwt を選びました。

Rails アプリに組み込む - トークンの発行

Admiral Stats が発行する JWT のペイロードは、以下の形式にしました。(設定ファイルへのコピペが面倒にならないように)トークンをなるべく短くしたかったため、"iat" の claim だけ採用しています。

{ "id": ユーザID, "iat": トークン発行日 }

トークンの鍵は、secret_key_base の値をそのまま使うことにしました。この値は Rails.application.secrets.secret_key_base で参照できるようです。

また、一度発行したトークンをいつでも無効にできるように、トークンの内容を admiral_tokens というテーブルに保存するようにしました。JWT の検証に成功しても、このテーブルに同じトークンが登録されていない場合は「有効期限切れ」と扱うことにしました。

以下は、token_controller.rb 内に実装した、トークンの発行処理です。

    begin
      AdmiralToken.transaction do
        AdmiralToken.where('admiral_id = ?', current_admiral.id).delete_all

        issued_at = Time.now
        token = JWT.encode({ id: current_admiral.id, iat: issued_at.to_i }, Rails.application.secrets.secret_key_base, 'HS256')

        AdmiralToken.create!(
            admiral_id: current_admiral.id,
            token: token,
            issued_at: issued_at
        )
      end
    rescue => e
      logger.error(e)
      @error = "トークンの発行に失敗しました。(原因:#{e.message}"
    end

Rails アプリに組み込む - トークンの検証

検証処理は、application_controller.rb 内の jwt_authenticate メソッドとして実装しました。この実装は An Introduction to Using JWT Authentication in Rails を参考にしました。

  # JWT で認証したユーザ(提督)の情報を返すメソッド
  def jwt_current_admiral
    @jwt_current_admiral ||= Admiral.find(@jwt_admiral_id)
  end

  # Authorization ヘッダに含まれる JWT で認証状態をチェックするためのメソッド
  def jwt_authenticate
    unless jwt_bearer_token
      response.header['WWW-Authenticate'] = 'Bearer realm="Admiral Stats"'
      render json: { errors: [ { message: 'Unauthorized' }]}, status: :unauthorized
      return
    end

    unless jwt_decoded_token
      response.header['WWW-Authenticate'] = 'Bearer realm="Admiral Stats", error="invalid_token"'
      render json: { errors: [ { message: 'Invalid token' } ] }, status: :unauthorized
      return
    end

    unless @jwt_admiral_id
      # 有効期限の検査
      jwt_admiral_id = jwt_decoded_token[0]['id']
      if AdmiralToken.where(admiral_id: jwt_admiral_id, token: jwt_bearer_token).exists?
        @jwt_admiral_id = jwt_admiral_id
      else
        response.header['WWW-Authenticate'] = 'Bearer realm="Admiral Stats", error="invalid_token"'
        render json: { errors: [ { message: 'Expired token' } ] }, status: :unauthorized
      end
    end
  end

  # Authorization ヘッダの Bearer スキームのトークンを返します。
  def jwt_bearer_token
    @jwt_bearer_token ||= if request.headers['Authorization'].present?
                            scheme, token = request.headers['Authorization'].split(' ')
                            (scheme == 'Bearer' ? token : nil)
                          end
  end

  # JWT をデコードした結果を返します。
  def jwt_decoded_token
    begin
      # verify_iat を指定しても、実際には何も起こらない
      # iat を含めない場合も、iat が未来の日付の場合も、エラーは発生しなかった
      @jwt_decoded_token ||= JWT.decode(
          jwt_bearer_token,
          Rails.application.secrets.secret_key_base,
          { :verify_iat => true, :algorithm => 'HS256' }
      )
    rescue JWT::DecodeError, JWT::VerificationError, JWT::InvalidIatError
      # エラーの詳細をクライアントには伝えないため、常に nil を返す
      nil
    end
  end

ちなみに、エラーメッセージは、以下のように出し分けています。

  • Unauthorized: Authorization ヘッダに Bearer トークンが含まれていない
  • Invalid token: JWT の署名検証に失敗した
  • Expired token: JWT の署名検証には成功したが、テーブル上に無かった

このメソッドをAPI のコントローラ(api_import_controller.rb)の before_action に追加すると、トークンでの認証が可能になります。

また、今回は、普通の Rails アプリに、あとから API サーバの機能を追加しているので、API 用のコントローラのみ CSRF 対策を無効化する必要があります。以下は、コントローラのコードの冒頭です。

class ApiImportController < ApplicationController
  include Import

  # API 用のコントローラでは CSRF 対策を無効化する
  skip_before_action :verify_authenticity_token

  before_action :jwt_authenticate

あとは API の機能そのものを実装すれば、API サーバの実装完了です。

Cross-Origin Resource Sharing (CORS) への対応

ただし、この API をブックマークレットから叩こうとすると、Web ブラウザが以下のようなエラーを出して、クロスドメイン接続をブロックしてしまいます。

クロスオリジン要求をブロックしました: 同一生成元ポリシーにより、https://www.admiral-stats.com/api/v1/import/Area_captureInfo/20170308_235926 にあるリモートリソースの読み込みは拒否されます (理由: CORS ヘッダ ‘Access-Control-Allow-Origin’ が足りない)。

この同一生成元ポリシー(Same-Origin Policy)は、セキュリティのための機能ですが、今回のような場合には困ります。この接続を許可するには、API サーバ側が CORS に対応する必要があります。

CORS とは?

CORS とは、別ドメインの Web サーバが明示的に許可した場合に限り、あるドメインから別ドメインへのクロスドメイン通信を許可するための、HTTP の仕様です。

別ドメインの Web サーバが明示的に許可した場合だけ使える、という意味では、できることは JSONP(JavaScript の読み込みとしてリクエストを送り、コールバック関数でラップした JSON を返す)と大差ありません。しかし、CORS のほうが、サーバ・クライアント双方の実装を大幅に簡略化できます。

CORS のプロトコルを大まかに説明すると、次のようになります。

  1. ページ内で読み込んだ JavaScript が、別ドメインへの XMLHttpRequest を send する
  2. Web ブラウザが、1 の URL に対して、OPTIONS リクエストを送信する
  3. Web サーバが、Access-Control-Allow-Origin ヘッダなどを含み、ボディが空のレスポンスを返す
  4. 3 に含まれるヘッダが、1 のリクエストを許可している(例:Access-Control-Allow-Origin ヘッダにそのサイトのドメインが含まれている)かどうかを検査する
  5. 4 の検査をパスしたら、Web ブラウザが、1 の URL に対して、実際のリクエスト(GET や POST)を送信する
  6. Web サーバが、Access-Control-Allow-Origin ヘッダなどを含む、本来のレスポンスを返す

CORS に対応するための rack-cors gem

Ruby on Rails で CORS に対応するためには、rack-cors という gem を使います。

実は、Rails 5 から導入された rails new NAME --api でアプリケーションを作ると、この rack-cors を使うための以下の設定が、コメントアウトされた状態で書き込まれます(参考)。

  • Gemfile に gem 'rack-cors'
  • config/initializers/cors.rb に以下の設定

しかし、Admiral Stats は、すでに普通の Rails アプリとして作ってしまっているので、これと同じことを手作業で行います。

今回は以下の内容で cors.rb を作成しました。設定と、実際に返される HTTP ヘッダの対応関係については、コード中に書いたコメントの通りです。

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  # Insert CORS request headers for API responses
  allow do
    # Access-Control-Allow-Origin: (Origin に書かれたものをそのまま返す)
    origins '*'

    # /api 以下の URL に対してのみ、CORS 対応
    # Access-Control-Allow-Methods: GET, POST, OPTIONS
    # Access-Control-Allow-Headers: (OPTIONS に対してのみ Access-Control-Request-Headers に書かれたものをそのまま返す)
    # Access-Control-Max-Age: 3600
    resource '/api/*',
             :methods => [:get, :post, :options],
             :headers => :any,
             :max_age => 3600
  end
end

rack-cors の注意点

rack-cors を導入すると、OPTIONS メソッドに対してレスポンスを返すようになります。そのために routes.rb を変更する必要はありません

ただ、テストコードのなかで、この OPTIONS へのレスポンスをテストしようとしたところ、以下のように RoutingError が出て失敗しました。IntegrationTest から、rack-cors が生成するルートは見えないようです。

ActionController::RoutingError: No route matches [OPTIONS] "/api/v1/import/Personal_basicInfo/20170309_000000"
    test/controllers/api_import_controller_test.rb:233:in `block (2 levels) in <class:ApiImportControllerTest>'
    test/controllers/api_import_controller_test.rb:232:in `block in <class:ApiImportControllerTest>'

一方、routes.rb に書かれている GET や POST については、以下のように Origin ヘッダをつければ、正しく Access-Control-Allow-Origin ヘッダなどが返されました。

  test 'data_types Origin ヘッダがある場合' do
    get '/api/v1/import/file_types', headers: { 'Authorization' => "Bearer #{TOKEN}", 'Origin' => 'https://kancolle-arcade.net'}

    assert_response 200
    assert_equal JSON.generate(
        [
            'Personal_basicInfo',
            'TcBook_info',
            'CharacterList_info',
            'Event_info'
        ]), @response.body

    assert_equal 'https://kancolle-arcade.net', @response.headers['Access-Control-Allow-Origin']
    assert_equal 'GET, POST, OPTIONS', @response.headers['Access-Control-Allow-Methods']
    assert_nil @response.headers['Access-Control-Allow-Headers']
    assert_equal '3600', @response.headers['Access-Control-Max-Age']
    assert_equal 'true', @response.headers['Access-Control-Allow-Credentials']
  end

そのため「OPTIONS についても同様のヘッダが返されるはずだ」と考えて、テストを書くのは諦めました。まあ、すべての API に OPTIONS のテストを書くのは、現実的ではないですしね……。

以上で、ブックマークレット版のエクスポータからも、Admiral Stats の API を叩いて、JSON ファイルをアップロードできるようになりました。

まとめ

今回は、普通の Ruby on Rails 5 アプリに、あとから API 機能を追加するために必要だったことをまとめました。API 機能のために、jwt gem を使ってトークンの発行・検証を行い、rack-cors gem を使って CORS 対応(ブックマークレット対応)を行いました。

実際にやってみて、やり方さえわかれば、割と短時間で実装できるもんだな……と思いました。Admiral Stats のソースコードは GitHub で公開しているので、興味のある方は読んでみてください。

github.com

Elixir のテスティングフレームワーク ExUnit, ESpec の比較

f:id:muziyoshiz:20161122004038p:plain

Elixir でのコードを書くにあたり、テスティングフレームワーク ExUnit および ESpec を調べてみました。

ExUnit は Ruby で言うところの Test::Unit で、ESpec は RSpec のようです。調べてみた結果、自分は ExUnit を使うことにしたのですが、せっかくなので調べたことをまとめておきます。

比較表

ExUnit ESpec
インストール方法 Elixir, Phoenix Framework 標準 mix で追加する(espec, espec_phoenix)
テストの書き方 主に assert で真偽チェック expectshould で文章のように書ける
テストのグループ化 describe が使える(Elixir 1.3 以降) context, describe, example_group が使える
実行方法 mix test mix espec
テスト結果 失敗した行番号が含まれる 失敗した行番号が含まれない
テスト名の制約 日本語を使えない 日本語を使える

以下は、この比較表の詳細です。

ExUnit

インストール方法

Elixir の標準に含まれており、mix.exs に何も足さなくても使えます。

また、Phoenix Framework では mix phoenix.new を実行する際に、データベース接続のためのヘルパーや CaseTemplate が自動生成されます。これにより、ExUnit のテストケースを使ったモデルのテストが可能になっています。

テストの書き方

主に assert または refute での真偽チェックとして書く必要があります。

defmodule ElixirSampleTest do
  use ExUnit.Case
  doctest ElixirSample

  test "the truth" do
    assert 1 + 1 == 2
  end
end

標準出力をテストするための ExUnit.CaptureIO やログ出力をテストするための ExUnit.CaptureLog といった便利機能もあります。これらを使う場合も、最終的には assert を呼ぶ必要があります。以下は CaptureLog の使用例です。

  test "example" do
    assert capture_log(fn ->
      Logger.error "log msg"
    end) =~ "log msg"
  end

テストのグループ化

Elixir 1.3 から describe マクロが追加されて、テストのグループ化が簡単になりました。RSpec の describe と同じようにグループ化できます。

以下は ExUnit.Case のドキュメント に記載された例です。

defmodule StringTest do
  use ExUnit.Case, async: true

  describe "String.capitalize/1" do
    test "first grapheme is in uppercase" do
      assert String.capitalize("hello") == "Hello"
    end

    test "converts remaining graphemes to lowercase" do
      assert String.capitalize("HELLO") == "Hello"
    end
  end
end

実行方法

mix test で実行します。

$ mix test
..

Finished in 0.05 seconds
2 tests, 0 failures

Randomized with seed 37255

テスト結果

テストに失敗した場合、失敗したテストの開始行と、assertion に失敗した行の番号を表示します。

例えば、以下のように絶対失敗するテストを書いたとします。

defmodule ElixirSampleTest do
  use ExUnit.Case
  doctest ElixirSample

  test "the truth" do
    assert 1 + 1 == 2
    assert 1 + 1 != 2
  end
end

実行結果はこうなります。

$ mix test
.

  1) test the truth (ElixirSampleTest)
     test/elixir_sample_test.exs:5
     Assertion with != failed, both sides are exactly equal
     code: 1 + 1 != 2
     left: 2
     stacktrace:
       test/elixir_sample_test.exs:7: (test)



Finished in 0.04 seconds
2 tests, 1 failure

Randomized with seed 648187

テスト名の制約

test にも describe にも、日本語名(正確には、アトムに変換できない文字列)を使えないようです。例えば、以下のようにテストを書いたとします。

  test "1足す1が2に一致する" do
    assert 1 + 1 == 2
  end

これを実行すると、以下のようなエラーが返されます。

$ mix test
** (ArgumentError) argument error
    :erlang.binary_to_atom("test 1足す1が2に一致する", :utf8)
    (ex_unit) lib/ex_unit/case.ex:411: ExUnit.Case.register_test/4
    test/elixir_sample_test.exs:5: (module)
    (stdlib) erl_eval.erl:670: :erl_eval.do_apply/6
    (elixir) lib/code.ex:370: Code.require_file/2
    (elixir) lib/kernel/parallel_require.ex:57: anonymous fn/2 in Kernel.ParallelRequire.spawn_requires/5

BDD ではテストメソッド名に日本語を使おう、という意見があったりしますが、ExUnit では難しいようです。

ExUnit の参考ページ

Programming Phoenix: Productive, Reliable, Fast

Programming Phoenix: Productive, Reliable, Fast

ESpec

インストール方法

Elixir 標準ではないので、antonmi/espec のページにあるように mix.exs に追加する必要があります。

def deps do
  ...
  {:espec, "~> 1.3.0", only: :test},
  ...
end

あとはいつもの mix deps.get でインストールしたあとで、以下のコマンドを実行して spec/spec_helper.exs を生成すれば準備 OK です。

$ MIX_ENV=test mix espec.init

Phoenix Framework で ESpec を使う場合は、mix.exs に espec_phoenix を追加します。これにより、モデルによるデータベース接続を伴うテストなどが可能になります。

テストの書き方

expect … to の組み合わせか、should を使って書きます。RSpec だと今は should は推奨されていないようですが、ESpec についてはそういう記載は見当たりませんでした。

defmodule ContextSpec do
  use ESpec

  example_group do
    context "Some context" do
      it do: expect "abc" |> to(match ~r/b/)
    end

    describe "Some another context with opts", focus: true do
      it do: 5 |> should(be_between 4, 6)
    end
  end
end

テストのグループ化

context、describe、example_group が使えます。RSpec に似ていますね。

antonmi/espec に載っている例(下記)では example_group に説明文が付いていないですが、付けることも可能なようです。

defmodule ContextSpec do
  use ESpec

  example_group do
    context "Some context" do
      it do: expect "abc" |> to(match ~r/b/)
    end

    describe "Some another context with opts", focus: true do
      it do: 5 |> should(be_between 4, 6)
    end
  end
end

実行方法

MIX_ENV=test mix espec で実行します。ただ、antonmi/espec に従って mix.exs に設定を追加すれば、MIX_ENV=test は省略できるようになります。

$ mix espec
.

    1 examples, 0 failures

    Finished in 0.07 seconds (0.06s on load, 0.01s on specs)

    Randomized with seed 654062

テスト結果

テストに失敗した場合、失敗したテストの開始行を表示します。 assertion に失敗した行の番号は表示してくれません。

ExUnit の場合と同じように、絶対失敗するテストを書いてみます。

defmodule ElixirSampleSpec do
  use ESpec

  it "the truth" do
    expect(1 + 1) |> to(be 2)
    expect(1 + 1) |> to_not(be 2)
  end
end

すると、実行結果はこうなります。test の開始行の番号(4行目)しか出てきません。

$ mix espec
F

    1) ElixirSampleSpec the truth
    /Users/myoshiz/devel/elixir_sample/spec/elixir_sample_spec.exs:4
    Expected `2` not to equals (==) `2`, but it does.

    1 examples, 1 failures

    Finished in 0.11 seconds (0.1s on load, 0.01s on specs)

    Randomized with seed 728628

これくらいの内容なら、テスト結果からどの行かわかりますが、テストあたりの行数が長くなると辛くなってきます。長いテストコードは書くべきでない、という設計思想なんでしょうか。

コマンドライン引数などで、この動作を変更できるのではないかと思ったのですが、方法を見つけられませんでした……。これ、個人的には致命的に不便だと思うんですけど、他の人はどうなんですかね。

テスト名の制約

ESpec の it や describe は、テスト名に日本語を含めても、問題なく動作しました。

ESpec の参考ページ

まとめ

ESpec で書いたテストのほうが読みやすくなるのですが、総合的に考えて、個人的には今後は ExUnit を使うことにしました。

ExUnit は標準で採用されているために導入の手間が少なく、グループ化などの基本的な機能は備えているので、十分かなと。テスト失敗したときに行番号が表示されるなら、ESpec でも良かったんですけどね……。Elixir の練習のために admiral_stats_parseradmiral_stats_parser_ex に移植していて、作業が終わりかけたところでこれに気付いたときは愕然としました。

ちなみに、Elixir には、Ruby における Cucumber 相当の “WhiteBread” や、FactoryGirl 相当の “ExMachina” もあるようです。そのうち、これらも試してみたいと思います。

自作ツールのアイコンをクラウドワークスのコンペ機能で発注してみた

f:id:muziyoshiz:20170212132356p:plain

きっかけ

自作ツール(Admiral Stats)のリリース直後に、サポート用アカウント @admiral_stats を Twitter に用意していたのですが、やり取りが増えてきたので、デフォルトのたまごアイコンをそろそろ卒業したくなってきました。

以前から、アイコンのアイディアは考えていたのですが、良い案が全然浮かばなかったので、この機会にクラウドソーシングサービスを使ってみました。今回の記事は、クラウドワークスでの発注〜デザイン完了までと、使ってみての感想についてのお話です。

クラウドソーシングサービスを探す

まず、どこに発注するかですが、国内だとランサーズとクラウドワークスが有名だと思います。既存の案件をざっと見た感じ、アプリアイコンは1〜2万円くらいが相場なんでしょうか。5千円という案件もありました。

アイコンデザインだけなら、英語圏で頼むことも可能だろうと思い、海外のクラウドソーシングサイトも見てみました。ただ、数があまりにも多いのでよくわからず……。

最近読んだ SOFT SKILLS のなかで「自分のロゴをクラウドソーシングサイトに発注しよう」という話題のなかで取り上げられていた、以下の2社に目を通してみました。

例えば、Fiverr で “icon” で検索すると、「アプリアイコン5ドルで1個、訂正1回まで」みたいな仕事を受けてくれるデザイナーがすぐ見つかりました。これは極端な例としても、さすがに英語圏だと価格が大きく違うようです。

クラウドソーシングサービスを選択する

海外もいいな、と思いつつ、とりあえず希望するデザインを日本語で文章にしてみることに。

  • 「艦これアーケード」というアーケードゲームのプレイデータを管理する Web アプリのアイコンデザイン
  • デザインのモチーフは、操舵輪、歯車、カード、桜吹雪など、艦これアーケード自体のモチーフに近いものから選んでほしい
  • データの蓄積をイメージさせる、他のモチーフと組み合わせても OK
  • favicon としても使いたいので、小さいサイズで表示しても問題ないデザイン
  • 色は、艦これアーケードのロゴと同様の青系、またはアイコンに使われている黄色〜茶系
  • フラットデザインのUIに含めても、違和感のないデザイン

うん……。具体的なアイディアがなにも浮かんでなかったので、曖昧すぎですね。

ここまで書いて「艦これアーケードというゲーム自体や、そのモチーフについて英語で説明するのは面倒すぎるな」と気付いて、国内のサービスへ発注することに決定。

ランサーズとクラウドワークスのどちらにするか、で悩んだんですが、評判で検索してもよくわからず。なんとなく安価な印象があったので、今回はクラウドワークスのほうを選びました*1

仕事を依頼する

アプリのアイコンをどうやって発注するのかわからずに悩んだ末に、「デザイン > キャラクター・アイコン・アニメ > アイコン作成」カテゴリを選択。

f:id:muziyoshiz:20170212132948p:plain

依頼の形式は「プロジェクト形式」と「コンペ形式」から選べるんですが、デザインの具体的なアイディアがなかったので、複数の案が集まることを期待して「コンペ形式」を選択。まあ、結論から言うとあまり多くの案は集まらなかったんですが……。

応募期限は、当日の24:00から、14日後の24:00まで選択可能。今回は14日後を選択。

そして、仕事の内容を入力して、最後に予算の選択です。

f:id:muziyoshiz:20170212133019p:plain

32,400円のスタンダードプランがおすすめ、とありますが、趣味の、広告収入もないアプリでそこまで払う気にもなれず。かといって、カスタムで安くしすぎるのも良い案が集まらなそう(僕が逆の立場なら手を出さなそう)なので、標準のプランで一番安いエコノミーを選択しました。案件を目立つところに表示するためのオプションが色々ありましたが、低予算なのでオプションは無しで。

すべての登録が完了すると、メンバー(受注者)向けに以下のようなページが公開されます。

crowdworks.jp

ちなみにこのページ、募集中は受注者にしか見られないのですが、募集終了後は受注者以外(ログインしていない人)にも全公開されます。それが嫌ならプラス8,000円払うと非公開にできるみたいです。

発注〜デザイン決定までの流れ

1月16日に発注して、デザインが決定するまでの流れは、以下のような感じでした。

日付 できごと 提案人数 提案件数
1/16 1人目のデザイナー(過去の受注実績なし)から、最初のデザイン提案 1 1
1/20 1人目のデザイナーから、別デザインの提案 1 3
1/21 2人目のデザイナー(過去の受注実績20件くらい)から、最初のデザイン提案 2 4
1/22 提案済みのデザインに対するコメントを元に、1〜2人目のデザイナーから、別案の提案あり 2 10
1/24 2人目のデザイナーの案をベースにしようと決めて、更にやりとり 2 13
1/25 2人目のデザイナーに、最終案の微調整を依頼 2 17
1/25 採用する案を決めたので、締切を 1/26 に早めた 2 17
1/26 1人目のデザイナーから、別デザインの提案 2 18
1/26 採用デザイン決定 2 18

最初の4日間は提案してくれたデザイナーが、過去の受注実績のない1名のみだったので、1万円が無駄になるか?と心配だったのですが、あとから受注実績のあるデザイナーが提案してくれて、その方とデザインを微調整して最終決定、という流れでした。最終的には以下のデザインを採用しました。

f:id:muziyoshiz:20170128164643p:plain:w300

後から気づいたのですが、コンペ機能だと「この案にしよう」と思っても即決はできないんですね。終了を翌日の24:00に早めることしかできませんでした。まあ、これはデザイナー側への配慮なんだと思います。

感想

クラウドワークスのコンペ機能で発注してみた感想を三行でまとめると、こんな感じでした。

  • デザインを気軽に頼むには便利
  • 依頼金額を最低ラインにすると、品質の低いデザインにお金だけ取られる可能性が高そう
  • 信頼できるデザイナーがいるなら、同じ額を払ってそっちに頼むほうがいい

もう少し細かい感想は、以下の通りです。

提案人数と提案件数はだいぶ違う

コンペ方式だとデザインが採用されない限りは報酬0なので、デザイナーの視点で考えると、提案件数がすでに多い案件には参加したがらないでしょう。

そう考えると、発注者としては、気に入ったデザインが出てくるまでは提案件数を少なくしておきたいです。しかし、以下の理由でそうもいかない、というのがわかりました。

  • 気に入らなかったデザイナーの追加提案を拒否することはできない
    • この人のデザインは自分には合わないな、と思っても、追加提案は拒否できません
  • デザインの微調整を依頼すると、提案件数が跳ね上がる
    • コンペの途中でも、相手とメッセージをやり取りして、デザインを微調整してもらうのが一般的なようです
    • しかし、微調整してもらったが結局希望に合わなかった、という場合、提案件数が増えただけで新規提案が来にくくなる、というデメリットがあります
  • 提案保証人数を当てにするなら、スタンダードまで上げないと意味がない
    • 提案保証人数1名だと、実績の少ないデザイナーで埋まってしまう
    • 提案保証人数を2名以上にしたいなら、3万の「スタンダード」まで上げる必要がある
    • しかし、そこまでの金額を払うなら、個別に頼んだほうがよくないか?

今回の場合、デザインが大きく異なるのは5件のみで、残り13件はそれの微調整でした。

特定のデザイナーを指定して依頼できるなら、そのほうが双方にとって良い

自分でデザインすることを考えれば、1万で今回のデザインが得られたのには満足しています。

しかし、ある程度実績のあるデザイナーを探して、個別に(クラウドワークスの機能で言うなら「プロジェクト形式」で)提案したほうが、双方にとって良いだろうな、と思いました。まあ、当たり前の話かもしれませんけど……。

先日の 秋葉原IT戦略研究所の勉強会 で、このサークルの同人誌のデザインをしているデザイナーの方にこの話をしてみたところ、

受注側としてはポートフォリオがないと仕事が来ないので、最初のポートフォリオを作る手段としては、デザイナーにとって良い手段なのではないか。

との感想をもらいました。まあ、確かにそうかも。

発注側としては、コンペ形式での実績を見て、良さそうな人に個別で依頼するのがよいのかもしれませんね。次の機会があったら、プロジェクト形式の方を試してみたいと思います。

*1:あとから知ったのですが、後述するエコノミープラン、クラウドワークスは 10,800 円ですが、ランサーズは 21,600 円でした。