Rubyで爆速API (Twitter, Slack, Git, Facebook)

CSVからAPIクライアントをつくります

**参考**

- [Github API リファレンス](https://developer.github.com/v3/)
  - [Githubアクセストークン](https://github.com/settings/tokens)
- [Slack API リファレンス](https://api.slack.com/methods)
  - [Slackアクセストークン](https://api.slack.com/methods/api.test/test)
- [Twitter API リファレンス](https://dev.twitter.com/rest/public)
  - [Twitterアクセストークン](https://apps.twitter.com/)
- [Facebook API リファレンス](https://developers.facebook.com/docs/graph-api/reference/)
  - [Facebookアクセストークン](https://developers.facebook.com/tools/accesstoken/)

**謝辞**
 - メタプログラミングの勉強になりました。
 - いろいろなAPIを復習できたり、本当にありがとうございます。

## 使用したメタプログラミング
- 動的ディスパッチ
- デリゲート
- メソッドミッシング
- オープンクラス
- オーバーライド

結果として、CSVファイルはDSLとして機能します
CSVがコードとして機能し、そのコード自体がAPIドキュメントの役割を果たします

# 実装
## APIの仕様を、CSVに定義する

こちらのAPI仕様を参考にしました

- [Github API](https://developer.github.com/v3/)
- [Slack API](https://api.slack.com/methods)
- [Twitter API](https://dev.twitter.com/rest/public)
- [Facebook API](https://developers.facebook.com/docs/graph-api/reference/)

このようにファイルをつくります。
これらのファイルを後述するプログラムがAPIの仕様として認識し、APIクライアントを生成することになります

```csv:git
get,/repos/:owner/:repo/issues/:number,[]
get,/repos/:owner/:repo/events,[]
```

```csv:slack
get,channels.list,[]
get,users.getPresence,[]

```

```csv:twitter
get,friends/list.json,[]
```

```csv:facebook
get,:user_id/friendlists,[]
get,:user_id/photos,[]
get,:user_id,[fields]
```

**補足**

- ファイル名はクラス名のダウンケース
- 本当はもっといっぱいAPIのエンドポイントがあるのですが、時間の関係で省略しました。
  - このリストをつくるためのクローラを実装中なので、のちほど記事にしたいと思います。

## APIごとのエンドポイントを設定

エンドポイントと、アクセストークンなどのデフォルトパラメータはここで打ち込みます。
アクセストークンなどは、こちらから取得できます。bashなどに設定してください

 - [Twitterアクセストークン](https://apps.twitter.com/)
 - [Slackアクセストークン](https://api.slack.com/methods/api.test/test)
 - [Githubアクセストークン](https://github.com/settings/tokens)
 - [Facebookアクセストークン](https://developers.facebook.com/tools/accesstoken/)

```rb:clients.rb
class Git < Api::Client
  def endpoint;       'https://api.github.com/'; end
  def default_params; { access_token: ENV['GIT_TOKEN'] }; end
end

class Slack < Api::Client
  def endpoint;       'https://slack.com/api/'; end
  def default_params; { token: ENV['SLACK_TOKEN'] }; end
end

class Twitter < Api::Client
  def endpoint;       'https://api.twitter.com/1.1/'; end
  def default_params;
    # credentialsというキーにはOAuth認証を使うための特別な意味をもたせました
    {
      credentials: {
        :consumer_key    => ENV['TWITTER_COUSUMER_KEY'],
        :consumer_secret => ENV['TWITTER_CONSUMER_SECRET'],
        :token           => ENV['TWITTER_TOKEN'],
        :token_secret    => ENV['TWITTER_TOKEN_SECRET'],
      }
    }
  end
end

class Facebook < Api::Client
  def endpoint;       'https://graph.facebook.com/v2.7/'; end
  def default_params;
    { 
      :access_token    => ENV['FACEBOOK_TOKEN']
    }
  end
end
```

**補足**

 - Twitterに関してはツイートするためにOAuth認証が必要だったので、
 - `credentials`というパラメータを設定するとOAuth認証に切り替わるようにしました

## APIクライアントを動的に定義

FaradayというAPIクライアントのGemを使っています。

他にも色々gemがはいっていますので、下記のコマンドでインストールしてください

```bash:コンソール(黒い画面)
gem install faraday
gem install faraday_middleware
gem install simple_oauth
gem install oauth
```

```rb:api.rb
require 'open-uri'
require 'json'
require 'faraday'
require 'faraday_middleware'
require 'simple_oauth'
require 'active_support/core_ext/class/subclasses'
require 'csv'
require 'OAuth'

class Hash
  # Hashにメソッドチェーンっぽくアクセスしたいので書きました
  def method_missing(name)
    return self[name].extend Hash if key? name
    self.each { |k, v| return v if k.to_s.to_sym == name }
    super.method_missing(name)
  end
end

module Api
  class Client
    # api一覧が入る配列
    attr_accessor :apis
    # APIの定義を構造体にまとめます
    SPEC = Struct.new(:request_type, :path, :params) do
      def method_name
        path.gsub(/\.json$/,'').split(/\W/).grep(/\S/).join('_')
      end

      # gitのURLの:repoとか:ownerをパラメータとして取り出す
      def path_params
        path.split('/').select{ |s| s[0] == ':' }.map { |s| s.delete(s[0]) }
      end
    
      # パラメータを使ってURLを置換する
      def absolute_url(optional_params)
        path_params.each do |path_param|
          params_value = (optional_params[path_param] || optional_params[path_param.to_sym]).to_s
          if params_value.nil?
            puts "Params Not Found: #{path_param}"
          else
            path.gsub!(':' + path_param, params_value)
            params.delete(path_param.to_s)
          end
        end
        path
      end
    end

    # APIメソッドの中身
    def define_api(api, params)
      puts "Params require: #{api.path_params}\nParams optional: #{params}"
      puts "#{api.request_type} #{api.absolute_url(params)}"
      send(api.request_type, api.absolute_url(params), params)
    end

    # このクラスがSlackとかTwitterに継承された時にフックされる(klassはサブクラスのSlackとかTwitterクラス)
    def self.inherited(klass)
      # Slack => slackにダウンケースして、Api.slackというメソッドでSlack.newされるようにエイリアス
      klass_name = klass.to_s.downcase
      Api.define_singleton_method klass_name do
        _sub_class_object_ = klass.new
        # _sub_class_object_に対して、CSVを読み込み定義されたAPIをメソッドにする
        api_list = CSV.read("#{Dir.pwd}/#{klass_name}")
        api_list.map{ |m| SPEC.new(*m) }.each do |api|
          # 使えるAPIメソッド名がApi.slack.apisで表示するため挿入
          _sub_class_object_.apis << api.method_name
          # サブクラスのインスタンスにAPIメソッドを定義する
          klass.send(:define_method, api.method_name) do |params = {}|
            define_api(api, params)
          end
        end
        _sub_class_object_
      end
    end

    # 主にFaradayの初期化
    def initialize(endpoint = endpoint())
      @apis = []
      @conn = Faraday.new(:url => endpoint) do |faraday|
        request = default_params.key?(:credentials) ? [:oauth, default_params[:credentials]] : [:url_encoded]
        faraday.request  *request
        faraday.adapter  Faraday.default_adapter
      end
    end
     
    # リクエストをさばく
    def request(method, url, ops = {})
      ops = default_params.merge!(ops)
      res = @conn.send(method, url) do |req|
        req.params = ops
        req.headers['Content-Type'] = 'application/json'
        req.options.timeout = 5
        req.options.open_timeout = 5
      end
      json_to_hash res.body
    rescue => e
      res || e
    end

    def get(url, ops = {});    request(:get, url, ops);    end
    def post(url, ops = {});   request(:post, url, ops);   end
    def put(url, ops = {});    request(:put, url, ops);    end
    def patch(url, ops = {});  request(:patch, url, ops);  end
    def delete(url, ops = {}); request(:delete, url, ops); end
    def json_to_hash(json_str); JSON.parse(json_str); end
  end
end
```

# 使い方

```rb
# それぞれのAPIクライアントのインスタンス作成
Api.slack
Api.git
Api.twitter
Api.facebook
Api.slack.apis # Slackでリクエストできるメソッドを表示
=> ["channels_list", "users_getPresence"]

# Slackにのチャンネル一覧を取得
Api.slack.channels_list => "レスポンスはハッシュ"
```

---

## コンソールで実行

```rb
Api.slack
=> #<Slack:0x007ffa4308f468 @apis=["channels_list", "users_getPresence"], @conn=#<Faraday::Connection:0x007ffa4308f0a8 @parallel_manager=nil, @headers={"User-Agent"=>"Faraday v0.9.2"}, @params={}, @options=#<Faraday::RequestOptions (empty)>, @ssl=#<Faraday::SSLOptions (empty)>, @default_parallel_manager=nil, @builder=#<Faraday::RackBuilder:0x007ffa4308eb58 @handlers=[Faraday::Request::UrlEncoded, Faraday::Adapter::NetHttp]>, @url_prefix=#<URI::HTTPS https://slack.com/api/>, @proxy=nil>>

Api.slack.channels_list
get channels.list
=> {"ok"=>true, "channels"=>[{"id"=>"C29GZBRML", "name"=>"general", "is_channel"=>true, "created"=>1473343855, "creator"=>"U29GZBJFQ", "is_archived"=>false, "is_general"=>true, "is_member"=>true, "members"=>["U29GZBJFQ", "U29RJ3P7G"], "topic"=>{"value"=>"Company-wide announcements and work-based matters", "creator"=>"", "last_set"=>0}, "purpose"=>{"value"=>"This channel is for team-wide communication and announcements. All team members are in this channel.", "creator"=>"", "last_set"=>0}, "num_members"=>2}, {"id"=>"C29HG89DY", "name"=>"random", "is_channel"=>true, "created"=>1473343855, "creator"=>"U29GZBJFQ", "is_archived"=>false, "is_general"=>false, "is_member"=>true, "members"=>["U29GZBJFQ", "U29RJ3P7G"], "topic"=>{"value"=>"Non-work banter and water cooler conversation", "creator"=>"", "last_set"=>0}, "purpose"=>{"value"=>"A place for non-work-related flimflam, faffing, hodge-podge or jibber-jabber you'd prefer to keep out of more focused work-related channels.", "creator"=>"", "last_set"=>0}, "num_members"=>2}]}
```

```rb
Api.slack.channels_list.channels.first.members
=> ["U29GZBJFQ", "U29RJ3P7G"]

Api.slack.users_getPresence(user_id: "U29GZBJFQ")
=> {"ok"=>true, "presence"=>"active", "online"=>true, "auto_away"=>false, "manual_away"=>false, "connection_count"=>1, "last_activity"=>1474713399}
```

# Futeare work

- APIの仕様変更をフックして、
- CSVのAPI定義が常に最新になれば便利
- そのためのクローラをつくろうとおもいます