無印吉澤

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

職場ブログに書いた記事まとめ

2017年9月追記:
私は2017年7月末をもって GMO インターネットを退職しました。これは2017年5月31日当時の情報です。

2015年1月に現職についてから、職場のブログに色々と記事を書いてきました。四半期に1回のペースで、それなりにまとまった内容を書くことを心がけてます。

グループ会社のブログに寄稿することもあって、自分でもどこに何を書いたか思い出せなくなってきたので、自分のブログにまとめておきます。今後、記事が増えたらここに追記します。

DevOps

DevOps 関係の話題は継続的に追っていて、職場でも隙を窺ってツールを導入したりしています。新しいツールの話題に加えて、職場ブログでは(許可を得た上で)個人ブログではなかなか書けない泥臭い話題も書いています。

Embulk

recruit.gmo.jp

recruit.gmo.jp

recruit.gmo.jp

HashiCorp

recruit.gmo.jp

Habitat

recruit.gmo.jp

Ansible

recruit.gmo.jp

recruit.gmo.jp

プログラミング

関数型言語の苦手意識を克服したくて、Elixir を触って記事を書いたりしました。ブログのネタにはしていませんが、最近は Scala も勉強中(何度目かの勉強中)です。

Elixir

recruit.gmo.jp

recruit.gmo.jp

自社サービスの API

recruit.gmo.jp

recruit.gmo.jp

その他(API 設計、Hadoop)

グループ会社の Tech Blog に書いた記事です。Hadoop は業務でもかなり触っているのですが、職場にもっと詳しい Hadoop おじさんがいるので、記事にすることがあんまりなくて……。

techblog.gmo-ap.jp

techblog.gmo-ap.jp

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” もあるようです。そのうち、これらも試してみたいと思います。