2012年5月2日水曜日

API:jsonをテストする〜Rspec〜

前に作ったAPIのテストを作ってなかったのでそこのテストを作ります。
テストはRspecを使うのでまずは「test」フォルダを削除します。
次にGemfileを変更します。

Gemfile
group :development, :test do
  gem 'thin', :platforms => :ruby
  gem 'annotate', :git => 'git://github.com/ctran/annotate_models.git'

  gem 'rspec-rails'
  gem 'launchy'
  gem 'capybara'
  gem 'database_cleaner'
  gem 'factory_girl_rails', '~> 1.7.0'
end

thinはwindowsだと動かないのでdevelopmentだけにしました。
annotateはmodelにdbの内容を書き出してくれる便利なやつです。
さぁこれで準備OK!あとはコマンドでRspecの準備をします。

$ bundle install
もともとインストールあるので今回はやってませんが通常は以下の手順で行います。
$ gem install rspec
$ rails g rspec:install
      create  .rspec
      create  spec
      create  spec/spec_helper.rb

これでrspecフォルダが出来ました。 次にspecフォルダの下に「support」フォルダを作成し「integrration_test_helper.rb」を作成し以下の内容にします。
module IntegrationTestHelper
  def debug
    save_and_open_page
  end

  def dump
    puts page.body
  end
end
まずはテスト用データベースを作成します。
FactoryGirl.define do
  factory :announcement do
    sequence(:subject) { |n| "タイトル%02d" % n }
    sequence(:content) { |n| "お知らせ本文内容%02d" % n }
  end
end
作成方法は「FactoryGirl」で行います。spec下に「factories」フォルダを作成します。
そしてその下に「announcements.rb」を以下の様に作成します。

今回テストするのはコントローラーなので「controllers/api」フォルダを作ります。
そして中にannouncements_controller_spec.rbを以下の様に作成します。
# encoding: utf-8

require 'spec_helper'

describe Api::AnnouncementsController do
  render_views

  include IntegrationTestHelper #テストヘルパーを呼んでいます
  let(:announcement) { FactoryGirl.create(:announcement) }
   # FactoryGirlの情報をここに持ってきます。

  before do
    announcement
  end

  describe "#index (json)" do
    it "jsonデータを返す" do
      get :index, :format => "json"
      arr = JSON.parse(response.body)
    # 一度arrに入れてruby型式にしてjsonは配列で、かつ配列が1つだけなので
    [0]としてshouldで表示させます。

      arr[0]["id"].should == announcement.id
      arr[0]["subject"].should == announcement.subject
      arr[0]["content"].should == announcement.content
    end
  end
end
これを通したいと思います。
$ rake db:test:prepare
$ rake spec

~/.rvm/rubies/ruby-1.9.3-p0/bin/ruby -S rspec ./spec/controllers/api/announcements_controller_spec.rb
.

Finished in 0.25023 seconds
1 example, 0 failures

無事に通りました。「arr」に一度「JSON.parse(responce.body)」で代入することが重要ポイントの様ですね。

APNs:APNsの実装-Ruby on Rails

今回APNsを実装するサービスは初めてだったので四苦八苦しました。
以下のサイトを参考にして構築しました。
【iPhone】Push Notificationの実装方法

作成するのはモデルです。
notification_provider.rbを作成します。
# coding: ascii-8bit

require "openssl"
require "socket"

class NotificationProvider
  include ActionView::Helpers::JavaScriptHelper

  HOST = case Rails.env
    # when "test"        then "gateway.sandbox.push.apple.com"
    when "development" then "gateway.sandbox.push.apple.com"
    when "production"  then "gateway.push.apple.com"
    end
  PORT = 2195

  CERT_FILE = Rails.root.join("config/鍵名.cert")
  RSA_KEY_FILE = Rails.root.join("config/鍵名.key")

  attr_reader :ssl
以上の様にします。
「CERT_FILE」と「RSA_KEY_FILE」が出てきたのでこちらを作成します。
まずは作成した鍵(pem)を/configの直下に置きます。
そしてその鍵内上の
「-----BEGIN CERTIFICATE-----」から「-----END CERTIFICATE-----」を全て
をコピーして「〜.cert」として作りpem鍵と同じ場所に置きます。
同じくその下の
「-----BEGIN RSA PRIVATE KEY-----」から「-----END RSA PRIVATE KEY-----」
を全てコピーして「〜.key」として作りpem鍵と同じ場所に置きます。
これで準備OK!さぁ作りましょう〜

続いて同じnotification_provider.rbに追記します。
class << self
    def open
      provider = self.new
      provider.open
      provider
    end
  end

  def open
    return if @ssl
    if HOST
      @socket = TCPSocket.new(HOST, PORT)
      context = OpenSSL::SSL::SSLContext.new("SSLv3")
      context.cert = OpenSSL::X509::Certificate.new(File.read(CERT_FILE))
      context.key  = OpenSSL::PKey::RSA.new(File.read(RSA_KEY_FILE))
      @ssl = OpenSSL::SSL::SSLSocket.new(@socket, context)
      @ssl.connect
    else
      @ssl = DummyServer.new
    end
  end

  def close
    @ssl.close if @ssl
    @socket.close if @socket
    @ssl = @socket = nil
  end

  def closed?
    @ssl ? @ssl.closed? : true
  end

  def message(identifier, expired_at, token, alert)
    alert = j(alert.dup)
    alert.force_encoding("ascii-8bit") if RUBY_VERSION.to_f >= 1.9
    payload = %Q!{ "aps":{ "alert":"#{alert}", "sound":"default" } }!
 
    "\x01" +                       # 常に1
    [identifier].pack("N") +       # 識別子、4バイト
    [expired_at.to_i].pack("N") +  # 期限、4バイト(big endian)
    "\x00\x20" +                   # トークン長、2バイト、常に32
    token +                        # デバイストークン
    [payload.size].pack("n") +     # ペイロード長、2バイト
    payload                        # ペイロード
  end

  def notify(device_token, content,
        expired_at = 1.days.from_now, logger = Rails.logger)
    raise "Provider is not opened!" unless @ssl
 
    request = message(0, expired_at, device_token.token, content)
 
    response = nil
    @ssl.write(request)
    if @ssl.kind_of? OpenSSL::SSL::SSLSocket
      if IO.select([@ssl], nil, nil, 0.5)
        response = @ssl.read(6)
      end
    else
      response = @ssl.read(6)
    end
 
    if response.present?
      arr = response.unpack("ccN") # 1バイト目:8、2バイト目:エラーコード
      if arr[0] == 8
        logger.info("error code: #{arr[1]}, divece token id: #{device_token.id}")
      else
        logger.info("unknown error, divece token id: #{device_token.id}")
      end
      return false
    end
    true
  end
これはもう定型文でいいかもしれません。
さぁあとはcronスクリプトを作成する必要がある。
とりあえずcronスクリプトは置いておいてcronスクリプトを動かすための構築をします。
今回はお知らせを表示するので「announcemnet」を編集します。
def display_status
    { nil => "未送信", "delivered" => "送信済み", "delivering" => "送信中" }[status]
  end

  def not_delivered?
    status.nil?
  end

  def delivered?
    status == "delivered"
  end

  def delivering?
    status == "delivering"
  end

  def update_directly(updates)
    assign_attributes(updates, :without_protection => true)
    Announcement.update_all(updates, :id => self.id)
  end

  def deliver
    return unless not_delivered?
    update_directly(:status => "delivering")
 
    provider = NotificationProvider.open
    success = 0
    failure = 0
    total = DeviceToken.count
    DeviceToken.all.each do |dt|
      if provider.notify(dt, self.content, どれくらい送り続けるかの日数, self)
        success += 1
      else
        failure += 1
      end
      if provider.closed?
        info("APN Server closed unexpectedly!")
        break
      end
    end
    provider.close
    info("total: #{total}, success: #{success}, failure: #{failure}.")
    update_directly(:log => self.log, :status => "delivered")
  end

  def info(text)
    self.log = "" if log.blank?
    self.log += Time.now.strftime("%Y-%m-%d %H:%M:%S ") + text + "\n"
    # cronログを作成している
  end

  class << self
    def deliver_all
      DeviceToken.where("updated_at < ?", デバイストークンの有効期限).delete_all
     # デバイストークンをupdateしていない場合削除される

      announcements = active.not_delivered.past
      count = announcements.count
      announcements.each do |ann|
        ann.deliver
      end
      Rails.logger.info "executed delivered_all, #{count}"
    end
  end
以上の内容でお知らせをAPNsを使ってiPhoneやiPadでのアプリに表示させることが出来る。
ほぼ定型文で保存かな?

APNs:デバイストークンを受け取る

Apple Push Notification Service を今回実装したのでそれのメモです。
iPhoneのアプリ内でalertの様に出るお知らせです。

まずは送る相手をDBに保存するためにデバイストークンを保存します。
マイグレーションの作成です。

~_create_device_tokens.rb
class CreateDeviceTokens < ActiveRecord::Migration
  def up
    create_table :device_tokens do |t|
      t.timestamps
    end
    ActiveRecord::Base.connection.execute(
      "ALTER TABLE device_tokens ADD token VARBINARY(32) NOT NULL;")
  end

  def down
    drop_table :device_tokens
  end
end
device_tokensという名前にしました。

次にルーティングです。端末側へ情報を出すものは全て「namespace」で分けた
「api」に指定します。
routes.rbに以下を追加します。
if Rails.env.development?
   resources :device_tokens, :only => [ :new, :create ]
else
   resources :device_tokens, :only => [ :create ]
end
開発と本番で分けました。
あとはアプリ側からこちらに向けたデバイストークンを拾うだけです。

api/device_tokens_controller.rbを作成します。
class Api::DeviceTokensController < ApplicationController
  protect_from_forgery :except => :create

  def create
    @dt = DeviceToken.find_or_new(params[:token])
    if @dt.save
      render :text => "OK", :content_type => "text/plain"
    else
      render :text => "NG", :content_type => "text/plain",
        :status => 400
    end
  end
end
そして最後にモデルです。
device_token.rbを作成します。
class DeviceToken < ActiveRecord::Base
  validates :token, :presence => true, :length => { :is => 32 }

  class << self
    def find_or_new(token)
      token.force_encoding("ascii-8bit") if RUBY_VERSION.to_f >= 1.9
      dt = find_by_token(token) || new(:token => token)
      dt.updated_at = Time.current
      dt
    end
  end
end
Rubyのバージョンを1.9の時は「force_encoding」を「ascii-8bit」としています。
以上で「post /api/device_tokens?token=〜」というURLで取得できます。