bekkou68 の日記

Gogengo! や IT 技術など。

スマホAPI の Controller 実装でよくつかう HTTPステータスコードの意図別まとめ

はじめに

Railsスマートフォン向けの API をつくっている時にどの HTTPステータスコードを返すか、自分なりの判断基準をまとめてみました。

Controller を実装している時によく返すステータスコードをイメージしています。

200 :ok

  • 以下が正常に行われた場合
    • GET系。action: index, show
    • UPDATE系。action: update
    • DELETE系。action: destroy

201 :created

  • 以下が正常に行われた場合
    • POST系。action: create

400 :bad_request (2015/04/15 更新)

  • 必要なパラメータが存在しない場合
    • 例: 緯度と経度はセットでほしい
  • パラメータが範囲を超えている場合
    • 例: 検索条件のパラメータの範囲がオーバーしている
  • クライアントを普通に使っていれば起こりえないリクエストを受け取ったとき
    • 例: 自分の投稿に返信はできないようにクライアントで制限しているが、リクエストを解析して、自分の投稿に返信するようなデータ不整合を招くリクエストを受け取った

401 :unauthorized

403 :forbidden (2015/08/06 更新)

  • ユーザが退会や BANされている状態で認証を試みた場合

404 :not_found

  • リクエストされたリソースが存在しない場合
  • リクエストされたリソースは存在するがリクエストしたユーザに閲覧権限がなかった場合
    • :unauthorized でもいいが、そもそもリクエストされた URI にリソースがあること自体を伝えたくない場合は :not_found でよい
    • GitHub でも、private リポジトリに閲覧権限なしに URI にアクセスすると :not_found が返ってくる

409 :conflict (2015/08/06 更新)

  • リソースに矛盾がおきそうな場合
    • 一つしか作れないリソースを、すでにあるのにさらに作ろうとした場合
    • ユーザが一意の任意のIDを持てるとして、取得時にすでにそのIDが取得されている場合
    • 削除しようとしたリソースがまだ削除にふさわしくない場合
      • Amazon S3 だとコンテンツを含むバケットを削除しようとすると返る
      • 削除してはいけない状態のリソースを削除しようとする場合

422 :unprocessable_entity

さいごに

だいたいのケースは上記で間に合っている気がしてます。

他にもこういうステータスコード使うよ、このステータスコードはこういうシーンでも使ってるよ、それを表現するにはもっといいステータスコードがあるよ、などありましたら教えてください :)

参考

参考: Restful Webサービス

おまけ: ステータスコードの数字と意味/シンボルの見方

rails console> pp Rack::Utils::HTTP_STATUS_CODES
=> {100=>"Continue",
 101=>"Switching Protocols",
 102=>"Processing",
 200=>"OK",
 201=>"Created",
 202=>"Accepted",
 ...
 511=>"Network Authentication Required"}
rails console> pp Rack::Utils::SYMBOL_TO_STATUS_CODE
=> {:continue=>100,
 :switching_protocols=>101,
 :processing=>102,
 :ok=>200,
 :created=>201,
 :accepted=>202,
 ...
 :network_authentication_required=>511}

コードに数字が書いてあると意味の変換に脳内で時間がかかるので Symbol を使うのが好みです。

Rails で iOS のプッシュ通知を実装する手順

(最終更新日: 2017/08/23)

はじめに

自前でプッシュ通知を実装する手順をまとめます。サーバからクライアントまで。

この記事に書いてあること:

  • プッシュ通知の疎通確認までの手順
  • 個別送信の実装例
  • 複数送信の実装例
  • デプロイ時の工夫
  • Feedback のハンドリング

前提

  • クライアント
  • 対応OS
    • iOS7, iOS8
  • サーバ
  • 単一サービスのプッシュ通知を実装する
    • 複数サービスになると rpush はスレッド数が比例して増大するので向いてないことがある

証明書/プロビジョニングを用意

Developer Center から以下のファイルを取得します。

  • プッシュ通知用の証明書
    • Identifier > App IDs > IDを選択
    • ※証明書はつくる度に CSR は別のものにすること。同じものを使いまわすと前のプッシュ通知が届かなくなることがあるらしい
    • 証明書のつくりかた
  • プロビジョニング
    • Provisioning Profiles から適宜選択
    • 証明書を変えたりプッシュ通知を enable にしたら作りなおして登録するのを忘れずに

プロビジョニングと APNS環境の対応は以下のとおりです。必要に応じて準備してください。

  • development のプロビジョニングでビルドすると APNS環境は sandbox となる
  • distribution (adhoc/appstore) のプロビジョニングでビルドすると APNS環境は production となる

異なる APNS環境のデバイストークンを DB に混在させないように注意しましょう。

クライアントを実装

プッシュ通知を送るにはデバイストークンを発行する必要があります。デバイストークンはプッシュ通知を送る時の識別子です。

ためしに起動時に発行してみましょう。

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    [self registerPushNotificationToken];

    // 省略
}

- (void)registerPushNotificationToken
{

    UIApplication *sharedApplication = [UIApplication sharedApplication];
    if ([sharedApplication respondsToSelector:@selector(registerUserNotificationSettings:)]) {
        // iOS 8
        UIUserNotificationSettings *userNotificationSettings = [UIUserNotificationSettings settingsForTypes:UIUserNotificationTypeAlert | UIUserNotificationTypeBadge | UIUserNotificationTypeSound categories:nil];
        [sharedApplication registerUserNotificationSettings:userNotificationSettings];
        [sharedApplication registerForRemoteNotifications];
    } else {
        // iOS 7
        [sharedApplication registerForRemoteNotificationTypes:(UIRemoteNotificationTypeBadge | UIRemoteNotificationTypeSound | UIRemoteNotificationTypeAlert)];
    }
}

/* デバイストークン発行成功 */
- (void)application:(UIApplication*)app didRegisterForRemoteNotificationsWithDeviceToken:(NSData*)deviceToken
{
    NSLog(@"deviceToken: %@", deviceToken);

    NSString *deviceTokenString = [[[[deviceToken description]
                                     stringByReplacingOccurrencesOfString:@"<" withString:@""]
                                    stringByReplacingOccurrencesOfString:@">" withString:@""]
                                   stringByReplacingOccurrencesOfString:@" " withString:@""];

    // サーバにデバイストークンを送信
    //
    // ローカルに前回のデバイストークンを保存してあるなら、比較して同じだったら送信する必要はない。余計なリクエストはしない
}

/* デバイストークン発行失敗 */
- (void)application:(UIApplication*)app didFailToRegisterForRemoteNotificationsWithError:(NSError*)error
{
    // ハンドルが必要なら処理を書く
}

バイストークンはバックアップをインストールしたときやOSの再インストール時などに変わることがあります。一度送信したらその後送信しない、と実装すると変わった拍子にプッシュ通知が届かなくなってしまいます。デバイストークンが変わったら適宜サーバへ送りましょう。

参考: Is the device token as unique as the device ID?

バイストークンは実機のみで発行できます。シミュレータだとデバイストークンを発行できないので注意しましょう。ログを出すなどしてデバイストークンが発行できていることを確認してください。

サーバに鍵を持たせる

プッシュ通知を送るためにはサーバが正しい送信元だと証明しなければなりません。そのために先ほど証明書から鍵を書き出してサーバに配置する必要があります。

以下の手順で鍵を書き出します。

  • キーチェーンアクセスを起動します
  • 分類> 自分の証明書 > “Apple <Development|Production> IOS Push Services: ” の証明書を選択
  • 右クリックして書き出すを選択し、p12 として保存します
    • このとき「種類」が「証明書」のところで書き出しをします。それにより証明書と秘密鍵を含めたものが書き出されます
    • 「種類」が「秘密鍵」のところで書き出すとプッシュ通知が飛ばないので注意しましょう
    • また「種類」の「秘密鍵」が存在することも確認しましょう。存在しないとプッシュ通知は飛びません。p12 を別ファイルとして持っているならダブルクリックすると入ります

保存した p12 を pem に変換します <CERT_NAME> は適宜うめてください。

$ openssl pkcs12 -in <CERT_NAME>.p12 -out <CERT_NAME>.pem -nodes -clcerts

pem を $RAILS_ROOT/certificates/apns 配下に [sandbox|production].pem という名前をつけて配置します。

サーバでデバイストークンを受け取る

トークンを受け取る API を用意しましょう。User モデルが device_token アトリビュートを持つとします。

config/routes.rb

...
namespace :api, defaults: {format: 'json'} do
  namespace :v1 do
    namespace :users do
      resource :device_token, only: [:update]
    end
  end
end
...

app/controllers/api/v1/users/device_tokens_controller.rb

class Api::V1::Users::DeviceTokensController < Api::V1::ApplicationController
  # @param [String] device_token
  def update
    current_user.device_token = params[:device_token]

    ActiveRecord::Base.transaction do
      current_user.save!

      current_user.sweep_same_device_tokens
    end

    render status: :ok
  end
end

app/models/user.rb

class User < ActiveRecord::Base
  # 自分以外の同じデバイストークンを持つユーザがいたら nil で上書き
  # 一つのデバイストークンは複数のユーザで持たないべき
  def sweep_same_device_tokens(updated_time: Time.zone.now)
    User
      .where(device_token: self.device_token)
      .where.not(id: self.id)
      .update_all(['device_token = NULL, updated_at = ?', updated_time])
  end
end

イメージはこんな感じです。sweep の処理があるので API の口は専用で用意したほうがいいと考えています。

さて、クライアントからデバイストークンを受け取りましょう。受け取れたら次のステップです。

まずは開発コンソールからプッシュ通知を送って疎通を確認

rpush という gem を使います。

Gemfile に追加し $ bundle install$ rpush init します。

config/initializer/rpush.rb を適宜設定します。

lib/tasks/rpush.rake に環境をロードするスクリプトを配置します。

require_relative '../../config/environment'

namespace :rpush do
  # USAGE:
  #   () 内は省略可
  #   $ bundle exec rake rpush:install (APNS_ENV=[sandbox|production])
  task install: :environment do |current_task|
    puts "start #{current_task.name}"

    app_name = 'myproject'
    apns_env = ENV['APNS_ENV'] ? ENV['APNS_ENV'] : (Rails.env.production? ? 'production' : 'sandbox')

    if Rpush::Apns::App.where(name: app_name, environment: apns_env).exists?
      puts 'already exists: (Rpush::Apns::App (name:%s environment:%s))' % [app_name, apns_env]

      next # end the task (almost same as method's return)
    end

    certificate_path = Rails.root.join('certificates', 'apns', "#{apns_env}.pem")

    Rpush::Apns::App.create!(
      name: app_name,
      certificate: File.read(certificate_path),
      environment: apns_env,
      password: '',
      connections: 1
    )
    puts "saved: RAILS_ENV=#{Rails.env}, APNS_ENV=#{apns_env}, certificate_path=#{certificate_path}"

    puts "end #{current_task.name}"
  end
end
$ bundle exec rake rpush:install

そしてプッシュ通知を送るためのプロセスも立ち上げます。

$ bundle exec rpush start

ようやく準備が整いました。プッシュ通知を送ってみましょう。

irb> n = Rpush::Apns::Notification.new
irb> n.alert = 'hello apns!'
irb> ...
irb> n.save!

これでクライアントに ‘hello apns!’ という文言でプッシュ通知が届けば OK です!

個別にプッシュ通知を送る実装

送信するメソッドを定義します。

# モデルを分けるなり、親controller に持たせるなり
def send_notification(device_token:, i18n_key:)
  return nil unless device_token
  return nil unless i18n_key

  n = Rpush::Apns::Notification.new
  n.app = Rpush::Apns::App.find_by_name('myproject')
  n.device_token = device_token
  n.alert = I18n.t(i18n_key)

  n.save!
end

送信したいところで

send_notification(device_token: current_user.device_token, i18n_key: 'errors.notifications.got_message')

と呼びます。

複数にプッシュ通知を送る通知の実装例

ユーザ全員に送るお知らせプッシュ通知などを想定しています。

実装の雰囲気こんな感じです。rake task や管理画面などから送ることが多い印象です。

message = 'プッシュ通知で送りたい文字列。'
sent_users_count = 0 # プッシュ通知を送れたユーザ数。端数は無視していることに注意
batch_size = Settings.apns.batch_size # 100 程度がいいかも。一つの通知に失敗するとすべて fail することに注意

notificatees = User.where('device_token IS NOT NULL')

logger = ActiveSupport::BufferedLogger.new(Rails.root.join('log', 'users_notification.log'))
APNS.host = Settings.apns.host
APNS.pem = Settings.apns.pem

logger.info "\n\n\n\n[#{Time.now}] START Push Notification, number of receivers will be #{notificatees.count}. Message: #{message}"
logger.info "[#{Time.now}] notificatees.to_sql: #{notificatees.to_sql}"

notificatees.find_in_batches(batch_size: batch_size) do |batched_notificatees|
  retry_count = 0
  time = Time.now

  notifications = batched_notificatees.map {|notificatee|
    APNS::Notification.new(notificatee.device_token, alert: message, badge: 1, sound: 'default')
  }

  # 通知の送信
  # APNS の調子次第で Errno::EPIPE が稀に発生するのでリトライ
  # TODO retryable gem でもっと綺麗に書けそう
  begin
    APNS.send_notifications(notifications)
  rescue Errno::EPIPE => e
    logger.error "[#{time}] Failed(##{retry_count + 1}) and retry #{e.class}, #{e.message}\n#{e.backtrace.join("\n")}"

    retry_count += 1

    retry if retry_count < 3

    logger.error "[#{time}] *** RETRY FAILED ***"
  rescue => e
    logger.error "#{e.class}, #{e.message}\n#{e.backtrace.join("\n")}"
  end

  sent_users_count += batch_size
  logger.info "[#{time}] Push was sent to about #{sent_users_count} users."
end

デプロイ時の工夫

capistrano をこんなふうにしておくとラクです。環境をロードしたりプロセスを立ち上げたりしてくれます。

lib/capistrano/tasks/rpush.rake

namespace :rpush do
  task :environment do
    set :rpush_pid, "#{shared_path}/tmp/pids/rpush.pid"
  end

  def start_rpush
    within current_path do
      execute :bundle, :exec, :rpush, :start, "-e #{fetch(:rails_env)}"
    end
  end

  def stop_rpush
    execute :kill, "-s QUIT $(< #{fetch(:rpush_pid)})"

    # XXX: kill時に pidファイルが消えないので消す。もっといい方法あれば知りたい
    #      たとえば unicorn gem のプロセスは、
    #      kill時にプロセスが pidファイルを消してくれるからこういうのを書かなくていい
    execute "rm #{fetch(:rpush_pid)}"
  end

  def reload_rpush
    execute :kill, "-s HUP $(< #{fetch(:rpush_pid)})"
  end

  def rpush_process_exists?
    test("[ -f #{fetch(:rpush_pid)} ]")
  end

  desc 'Install rpush settings to DB'
  task :install => :environment do
    on roles(:app) do
      within release_path do
        with rails_env: fetch(:rails_env) do
          execute :rake, 'rpush:install'
        end
      end
    end
  end

  desc 'Start rpush server'
  task :start => :environment do
    on roles(:app) do
      start_rpush
    end
  end

  desc 'Stop rpush server'
  task :stop => :environment do
    on roles(:app) do
      stop_rpush
    end
  end

  desc 'Restart rpush server'
  task :restart => :environment do
    on roles(:app) do
      if rpush_process_exists?
        reload_rpush
      else
        start_rpush
      end
    end
  end
end

config/deploy.rb

after :migrate, 'rpush:install'

namespace :deploy do
  after :restart, 'rpush:restart'
end

Feedback を受け取って無効なデバイストークンを掃除する

ユーザがアプリを消すなどしてプッシュ通知がとどかないデバイストークンがある。そういったデバイストークンに通知を送るのは帯域がムダ。APNS は通知できなかったデバイストークンを Feedback サーバに保持している。production 環境から定期的に Feedback へ問い合わせて無効なデバイストークンを削除しましょう。

一括で掃除できるメソッドをどこかしらのモデルに定義しておきます。

class << self
  def sweep_invaild_device_tokens(tokens:)
    where(device_token: tokens).each do |user|
      user.update(device_token: nil)
    end
  end
end

それを rpush の initializer で呼びます。ポーリングの秒数も設定しましょう。

config/initializers/rpush.rb

Rpush.configure do |config|
  # Frequency in seconds to check for feedback
  config.feedback_poll = 60

  ...
end

Rpush.reflect do |on|
  on.apns_feedback do |feedback|
    logger = Logger.new(Rails.root.join('log', 'rpush_feedback.log'))
    logger.formatter = Logger::Formatter.new

    tokens = [feedback.device_token].flatten.compact
    logger.info('Start sweeping %d invalid device tokens' % tokens.count)

    if tokens.present?
      tokens.each do |token|
        logger.info(token)
      end

      User.sweep_invaild_device_tokens(tokens: tokens)
    end

    logger.info 'End sweeping invalid device tokens'
  end

  ...
end

メモ

  • 動作確認は production で確認するしかない? sandbox だと on.apns_feedback が kick されていないような気がする。TODO もうちょっと調べる。
  • 100 件をまとめて送信する際に、1 件でも無効なトークンが含まれていると、100 件とも送信失敗になったような気がする。要確認。

環境まわりの考察

ちょっとモヤモヤしていることをダンプしておきます。

  • PROVISIONING=development では IPA を抽出できない。つまり deploygate で配布できない。端末をつないでインストールしかできない。開発時にリモートの人に確認してもらいたい時に困る
    • staging では APNS_ENV=production トークンを使って feedback を受け取らないように rpush を改造。ゴリ押し感
    • rpush で feedback を取るときに取り合いになる。これは gem の設定で off にするか改造するか pull req すれば解決する
    • 申請前なら APNS_ENV=production で確認してもらえないこともないが、それ以降はできない方法
  • RAILS_ENV=staging に APNS_ENV=production のトークンを混ぜたくない理由 (悩ましい・・)
    • 本番じゃないのに本番じゃないトークンはいれたくない
    • ないとは思うが RAILS_ENV=staging から APNS_ENV=production への誤送信が怖い

理想:

  • RAILS_ENV=development,staging, PROVISIONING=development では PUSH_ENV=sandbox
  • RAILS_ENV=production, PROVISIONING=distribution (adhoc/appstore) では PUSH_ENV=production

参考

Rails サーバから Google Analytics API で情報を取得する手順 ーー google-api-ruby-client, OAuth

はじめに

Rails サーバから Google Analytics API をたたいて情報を取得する手順をまとめます。
google/google-api-ruby-client · GitHub を使います。ログインだと Web で認可をもとめられる(後述)ので OAuth を使います。
日にちごとのスクリーンビューを取得するスパイクをつくるのがゴールです。

OAuth に必要な鍵を修得

  • Google Analytics で使用するアカウントで Google Developers Console にアクセス
  • プロジェクトを作成
  • 作成したプロジェクトをクリック
  • APIs & Auth > Credentials を選択
  • Client ID を新規作成。Service account を選択
  • p12 をダウンロード

鍵は $RAILS_ROOT/certificates/google_analytics.p12 に配置するとします。

ライブラリをインストール

Gemfile に追加します。

gem 'google-api-client' # Google Analytics API から情報を取得するためのクライアント
gem 'signet' # OAuth 認証

インストールします。

$ bundle install

クライアントのモデルを用意

app/models/ga_client.rb を新規作成します。"EDIT" コメントの部分をてきぎ書き換えてください。

require 'google/api_client'

# Google Analytics Client
class GaClient
  def initialize(
    application_name: 'My App Google Analytics', # EDIT
    application_version: '1.0.0' # EDIT
  )
    @client = Google::APIClient.new(
      application_name: application_name,
      application_version: application_version
    )
  end

  def api
    @api ||= @client.discovered_api('analytics', 'v3')
  end

  def signing_key
    return if @signing_key

    keyfile = Rails.root.join('certificates', 'google_analytics.p12')

    # see: http://stackoverflow.com/questions/14525565/not-sufficient-permissions-google-analytics-api-service-account
    # see: http://stackoverflow.com/questions/11534438/what-is-the-google-api-password-for-the-oauth-pkcs-p12-private-key
    passphrase = 'notasecret'

    @signing_key = Google::APIClient::KeyUtils.load_from_pkcs12(keyfile, passphrase)
  end

  def authorize!
    @client.authorization = Signet::OAuth2::Client.new(
      token_credential_uri: 'https://accounts.google.com/o/oauth2/token',
      audience: 'https://accounts.google.com/o/oauth2/token',
      scope: 'https://www.googleapis.com/auth/analytics.readonly',
      issuer: 'xxxx@developer.gserviceaccount.com', # EDIT
      signing_key: signing_key
    )
    @client.authorization.fetch_access_token!
  end

  def daily_screen_view(date:, screen_name:)
    # 管理画面の Admin > VIEW > View Settings に記載されている View ID を指定
    ga_id = 'ga:XXX' # EDIT

    result = @client.execute(
      api_method: api.data.ga.get,
      parameters: {
        ids: ga_id,
        'start-date' => date.to_s,
        'end-date' => date.to_s,
        metrics: 'ga:screenviews',
        filters: "ga:screenName==#{screen_name}",
        dimensions: 'ga:date',
      }
    )
    body = JSON.parse(result.response.body)
    date, screen_view = body['rows'].first
    screen_view
  end
end

クライアントを使って情報を取得

呼び出し側を実装します。「マイページ」というスクリーン名のスクリーンビューを取得します。

client = GaClient.new
client.authorize!
puts client.daily_screen_view(date: Date.today, screen_name: 'マイページ') #=> 1024

今日のスクリーンビューは 1024 だとわかりました!

紆余曲折

情報を取得するまでにいろいろと試行錯誤しました。そのログを残します。

  • Sija/garb · GitHub を見つける
  • ログイン(メールアドレスとパスワードで認証)でやってみよう
  • ローカルで動いた。サーバにデプロイして確認してみよう。おや……動かないぞ……
  • "Error=BadAuthentication Info=WebLoginRequired" とエラーを吐いていた。Webログインが必要……!?
  • 普段つかわない端末からアクセスしたら Google が不審なアクティビティとしてはじくようだ
  • 端末から https://accounts.google.com/displayunlockcaptcha にアクセスして認可すればいいらしい
  • とはいえサーバから Webブラウザは立ちあげられない
  • いろいろ試す
  • ひとまずログインでなく OAuth でやってみよう。長時間かかっても解決しなさそうだったらサーバに proxy を立てて Web認証かな……(やりたくない)
  • garb だと OAuth つかうのしんどそうだった。Web を一度介さないとアクセストークンを取得するのができなさそう
  • 他にやりかたないかな。omniauth はしんどそうだなあ
  • PHP にはクライアントあるのかRuby にもないかな
  • あった!

記事のはじめに戻ります。