Saturday 10 December 2011

Rails: Simple Web Service for Mob apps

Rest APIs are common and usual in Rails web services. However, I wanted to have my version of simple and custom Rails web service framework for mobile applications, which is easy to understand and highly customisable, as it doesn't rely on any gems or plugins.

Overview:

Ability to maintain API signature outside of api logic.
Easy to add custom actions.
Easier documentation support.
API Parameter check and friendly error responses.
Supports Facebook/Twitter login along with normal signup.
Simple encryption utility to encrypt/decrypt access tokens.
Useful Mongo Extensions.

Let's start integrating......

Basic Requirements:

User model with username, email, extuid(to allow FB/Twitter login)
ApiSession model with user_id, device_id(enable login from multiple devices), session_expires_at

Setting up internal dependencies:

config/initializers/encrytor.rb

# Use command-line openssl enc tool directly to avoid discrepancies
module Encryptor

  OPTS = {
    :algo => 'aes-128-cbc',
    :key  => '6f7e50b79f19f736b93b0efc4b2bcc57',
    :iv   => '6b2c8762aed3240bce1485da86530dc0'
  }

  # takes an base64 encoded n encrypted text and decrypts it to plain string
  def self.decrypt(text64)
    `echo "#{text64}" | openssl enc -d -a | openssl enc -d -#{OPTS[:algo]} -K "#{OPTS[:key]}" -iv "#{OPTS[:iv]}" -nosalt`.strip
  end

  # takes a plain string and returns a base64 encoded n encrypted string
  def self.encrypt(text)
    `echo "#{text}" | openssl enc -e -#{OPTS[:algo]} -K "#{OPTS[:key]}" -iv "#{OPTS[:iv]}" -nosalt | openssl enc -e -a`.strip
  end
end

-----------------------------------------------------
config/initializers/mongo_extensions.rb

module MongoExtensions

  def current_user
    controller.current_user
  end

  def controller
    Thread.current[:current_controller]
  end

  # return current_time in UTC always
  def current_time
    Time.zone.now
  end

  def request_domain
    'http://' + controller.request.env['HTTP_HOST']
  end

  def generate_rand(length = 8)
    SecureRandom.base64(length)
  end

  def perma
    str = "#{self.class}_#{self.id}"
    ActiveSupport::Base64.urlsafe_encode64(str)
  end

  def permalink
    request_domain + '/' + perma
  end

  def created_dt
    dt = self.created_at.to_s(:api_format) if self.respond_to?(:created_at)
    dt || ""
  end

  def my_save(return_bool = false)
    saved = self.save
    saved ? (return_bool || self) : [nil, self.errors.full_messages]
  end

  # utility meth to return 'user' for #<User>
  def klass_s
    self.class.to_s.downcase
  end

  def klass_sym
    klass_s.to_sym
  end
end

-----------------------------------------------------
lib/api_helper.rb

# maintain the signature outside of controller. Mimic of ActionWebService pattern.
# This also controls the params and helps pre-loading only the required params.
# NOTE:: Optional params(Array) has to be specified as the last option within :accepts.
module ApiHelper

  COMMON_RESPONSE_ATTRS  = []
  SIGNATURE_MAP =
  {
    :signin      => { :accepts => [:username, :password, :device_id],
                      :returns => nil },
    :login_check => { :accepts => [],
                      :returns => true },
    :signup      => { :accepts => [:username, :email, :password, [:full_name, :platform, :extuid]],
                      :returns => [:username, :email, :password] },
    :signout     => { :accepts => [],
                      :returns => true },
    :forgot_pass => { :accepts => [:email_or_uname],
                      :returns => true },
    :reset_pass  => { :accepts => [:password, :new_password, :confirm_password],
                      :returns => true },
    :check_token => { :accepts => [],
                      :returns => true }
}

  GUEST_USER_ALLOWED_APIS = [:signin, :signup, :check_token, :popular_photos]
  AUTHLESS_APIS           = [:signin, :signup, :forgot_pass, :check_token, :login_check]

  ERROR_MESSAGE_MAP =
  {
    :user_not_found     => 'User Not Found',
    :session_not_found  => 'Session Not Found',
    :pass_blank         => 'Password cannot be blank.',
    :pass_not_match     => 'Invalid Password. Please try again.',
    :unable_to_login    => 'Invalid Username or Password!',
    :duplicate_signup   => 'User ID Already Taken',
    :param_missing      => 'Parameters mismatch! Please check the api doc.',
    :unable_to_save     => 'Action Not Complete, Try Again Later',
    :token_not_found    => 'Please Signin Again',
    :token_expired      => 'Session expired. Please signin again!',
    :guest_not_allowed  => 'Access restricted for guest users. Please signup.',
    :pass_same_as_new_pass      => 'New password is same as old password.',
    :pass_confirmation_mismatch => 'Passwords Do Not Match!'
  }


  # This was added in a effort to generate dynamic comments in api_controller.
  # But Not sure how to generate dynamic comments.
  def self.accepts_label_for(meth)
    accepts = SIGNATURE_MAP[meth][:accepts].dup
    return 'n/a' if accepts.empty?
    optonal = accepts.pop if accepts.last.is_a?(Array)
    return '(' + optonal.join(', ') + ')' if accepts.empty? # all optional
    return accepts.join(', ') if optonal.nil? # all required
    [accepts, '(' + optonal.join(', ') + ')'].join(', ')
  end

  def self.returns_label_for(meth)
    returns = SIGNATURE_MAP[meth][:returns]
    return 'Token String' if returns.nil? # exceptional case( ex., signin)
    returns.is_a?(Array) ? returns.join(', ') : returns.to_s
  end

  def self.collection_label_for(meth)
    colls = SIGNATURE_MAP[meth][:collection]
    return "" if colls.blank?
    cnt = ''
    colls.each do |k, v|
      cnt << "<p>#{k.to_s} - [#{v.join(', ')}]</p>"
    end
    cnt.html_safe
  end
end

Setting up models: ( I prefer mongo DB with mongoid)

app/models/user.rb

require 'digest/sha1'
class User
  include Mongoid::Document
  include Mongoid::Timestamps::Created
  include MongoExtensions

  field :username, :type => String
  field :full_name, :type => String
  field :email, :type => String
  field :hashed_password, :type => String
  field :salt, :type => String
  field :extuid, :type => String
  field :platform, :type => String, :default => 'default'
  field :admin, :type => Boolean, :default => false

  index :username, :unique => true
  index :email, :unique => true

  validates :email, :username, :presence => true, :uniqueness => true
  validates :password, :presence => true, :on => :create
  validates :username, :length => 5..12, :allow_blank => true
  validates :email, :format => /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i, :allow_blank => true
  validates :password, :length => 8..10, :confirmation => true, :allow_blank => true
  validates :extuid, :presence => true, :if => lambda { ['twitter', 'facebook'].include? self.platform }


  attr_accessor :password, :password_confirmation

  before_create :set_hashed_password

  class << self

    def [](uname)
      self.where(:username => uname).first
    end

    def by_extid(exid)
      self.where(:extuid => exid.to_s).first
    end

    # uname can be username or email
    def login(uname, pass)
      # self.any_of(:username => uname, :email => uname) # Doesn't work
      u = self.where(:username => uname).first
      u ||= self.where(:email => uname).first
      u && (u.pass_match?(pass) ? u : nil)
    end

    def api_login(uname, pass, devic_id)
      u = login(uname, pass)
      return [nil, :unable_to_login] if u.nil? # error
      sess = u.sessions.find_or_initialize_by(:device_id => devic_id)
      sess.activate
    end

    def check_login_for(extuid_token)
      uid = Encryptor.decrypt(extuid_token)
      u = find_by_extuid(uid)
      return [nil, :user_not_found] if u.nil?
      CGI.escape(extuid_token)
    end
  end

  # Signup using FB/Twitter will not carry password. Also handle users
  # signin up more than once(when reinstalling the app) using FB/Twitter, gracefully.
  def api_signup
    resp = check_duplicate_signup
    return resp unless resp.nil?
    self.password ||= self.class.rand_s
    resp = my_save
    return resp if resp.is_a?(Array) # save failed
    #send_welcome_mail!
    resp
  end

  def check_duplicate_signup
    return nil unless self.platform && self.extuid
    u = User.where(:platform => platform, :extuid => extuid).first
    u && [nil, :duplicate_signup]
  end

  def api_reset_pass(pass, npass, cpass)
    return [nil, :pass_blank] if npass.blank?
    return [nil, :pass_not_match] unless pass_match?(pass)
    return [nil, :pass_same_as_new_pass] if pass == npass
    return [nil, :pass_confirmation_mismatch] unless npass == cpass
    self.password = npass
    hash_password && my_save(true)
  end

  def guest?
    self.username == 'guest'
  end

  def hash_password(pass = nil)
    self.salt ||= generate_rand
    pass ||= self.password
    Digest::SHA1.hexdigest(pass, self.salt)
  end

  def pass_match?(pass)
    self.hashed_password == self.hash_password(pass)
  end

private

  def generate_rand(length = 8)
    SecureRandom.base64(length)
  end

  def self.rand_s(length = 8)
    rand(36 ** length).to_s(36)
  end

  def set_hashed_password
    self.hashed_password = self.hash_password
  end
end

-----------------------------------------------------------------

app/models/api_session.rb

class ApiSession
  include Mongoid::Document
  include Mongoid::Timestamps::Created
  include MongoExtensions

  field :device_id, :type => String
  field :auth_token, :type => String
  field :expires_at, :type => Time

  index :device_id
  index [:auth_token, :device_id]

  SESSION_EXPIRY_TIME = 4.weeks

  belongs_to :user, :index => true

  validates :user_id, :device_id, :presence => true

  def self.[](token, devic_id)
    where(:auth_token => token, :device_id => devic_id).first
  end

  def activate
    self.auth_token = generate_rand(16)
    self.expires_at = current_time + SESSION_EXPIRY_TIME
    self.save ? CGI.escape(token_str) : [nil, :unable_to_save]
  end

  def deactivate
    self.auth_token = nil
    self.expires_at = current_time
    self.save || [nil, :unable_to_save]
  end

  def active?
    self.expires_at > current_time
  end

private
  def token_str
    self.auth_token + '||' + self.device_id
  end

end

Setting up controllers:

app/controllers/api_base_controller.rb

require 'api_helper'
class ApiBaseController < ActionController::Base

  before_filter :set_current_controller
  before_filter :verify_api_params
  before_filter :load_api_params
  before_filter :load_user_and_check_sess_expiry, :except => ApiHelper::AUTHLESS_APIS
  before_filter :restrict_guest_users, :except => ApiHelper::GUEST_USER_ALLOWED_APIS

  def current_user
    @current_user ||= if @extuid_token
      User.find_by_extuid(@extuid_token)
    else
      sess = @current_session || current_session
      return nil if sess.nil?
      sess.user
    end
  end

protected
  def current_session
    return nil if @auth_token.nil?
    token_str = URI.unescape(@auth_token) # CGI.unescape won't work if auth_token is unescaped already
    token, devic_id = token_str.split('||')
    @current_session ||= ApiSession[token, devic_id]
  end

  def reset_current_user_and_session
    @current_user = nil
    @current_session = nil
  end

  def render_response(result, status = true, error = nil)
    resp = formatted_response(result, status, error)
    respond_to do |format|
      format.json { render :json => resp.as_json }
      format.html { render :text => resp.as_json.inspect }
    end
  end

  def load_api_params
    pms = [:auth_token, :extuid_token] # default req param
    pms += current_api_signature_map[:accepts].flatten
    pms.compact.each { |p| instance_variable_set("@#{p.to_s}", params[p]) }
    @extuid_token ||= get_extuid_token
  end

  def current_api_accepts_map
    accepts = current_api_signature_map[:accepts].flatten
    accepts.inject({}) { |hsh, key| hsh.update(key => instance_variable_get("@#{key.to_s}") ) }
  end

  def current_api_accepts_map_with_user
    current_api_accepts_map.merge(:user_id => current_user.id)
  end

  def current_api_signature_map
    ApiHelper::SIGNATURE_MAP[current_api]
  end

  def current_api_req_params
    pms = current_api_signature_map[:accepts].dup
    pms.pop if pms.last.is_a?(Array) # filter optional params
    pms
  end

private

  # extuid_token is derived from auth_token, when it doesn't include "||"
  # uses encryptor plugin. Check initializers for decryption options used.
  def get_extuid_token
    return nil if @auth_token.nil? || URI.unescape(@auth_token).include?("||")
    token = URI.unescape(@auth_token)
    @auth_token = nil # force this to nil
    Encryptor.decrypt(token)
  end

  def set_current_controller
    Thread.current[:current_controller] = self
    @current_user = nil # make sure to reset current_user for every request
  end

  def current_api
    params[:action].to_sym
  end

  def verify_api_params
    param_keys = params.keys.collect(&:to_sym)
    missing_params = current_api_req_params - param_keys
    if missing_params.any?
      msg = "Required params missing - #{missing_params.join(", ")}"
      render_response(nil, false, msg)
    end
  end

  def load_user_and_check_sess_expiry
    render_response(nil, false, :token_not_found) && return unless current_user
    render_response(nil, false, :token_expired) unless current_session.nil? || current_session.active?
  end

  def restrict_guest_users
    return true unless current_user
    render_response(nil, false, :guest_not_allowed) if current_user.guest?
  end

  def formatted_response(result, status = true, error = nil)
    resp = { :response => formatted_result(result), :status => formatted_status(status) }
    resp.update(:errors => formatted_error(error)) unless status
    add_common_attrs_to_resp(resp)
  end

  def formatted_result(result = nil)
    return "" if result.nil?
    returns = current_api_signature_map[:returns]
    returns.is_a?(Array) ? current_api_result_map(result, returns) : result
  end

  # generates the response array, recursively - if one of response attr is a collection by itself.
  def current_api_result_map(result, returns)
    result_types = [Array, Mongoid::Relations::Targets::Enumerable]
    return result.collect { |res| current_api_result_map(res, returns) } if result_types.include?(result.class)
    returns_with_conditional(result, returns).inject({}) do |hsh, meth|
      val = result.send(meth)
      val = current_api_result_map(val, current_api_signature_map[meth] ) if result_types.include?(val.class)
      hsh.update(meth => (val.nil? ? '' : val)) # send '' instead of nil
    end
  end

  # to add attrs like my_notifications_count in api response, based on basic conditions
  # For now, all attr are called on current_user. This might change in future
  def add_common_attrs_to_resp(resp_map)
    return resp_map if current_user.nil?
    ApiHelper::COMMON_RESPONSE_ATTRS.each do |attr|
      next if current_api == attr # avoid duplicates
      resp_map.update(attr => @current_user.send(attr))
    end
    resp_map
  end

  # some apis(ex.,my_notifications) include attrs on response dynamically and conditionally.
  def returns_with_conditional(result, returns)
    cond_ret_opts = current_api_signature_map[:conditionally_return]
    return returns if cond_ret_opts.nil?
    cond_meth = cond_ret_opts[:if]
    returns += cond_ret_opts[:attrs] if result.send(cond_meth)
    returns
  end

  def formatted_status(status)
    status ? "Success" : "Failure"
  end

  def formatted_error(error)
    case error.class.to_s
    when 'Symbol'
      ApiHelper::ERROR_MESSAGE_MAP[error]
    when 'Array' # AR errors
      error.join('||')
    else # custom string/empty
      error.to_s
    end
  end

end

--------------------------------------------------------
app/controller/api_actions_controller.rb


# API methods. Hit /doc for details.
class ApiActionsController < ApiBaseController

  def signin
    token, error = User.api_login(@username, @password, @device_id)
    reset_current_user_and_session # reset to use the new token
    render_response(token, !token.nil?, error)
  end

  def login_check
    token, error = User.check_login_for(@extuid_token)
    render_response(token, !token.nil?, error)
  end

  def signup
    user, error = User.new(current_api_accepts_map).api_signup
    render_response(user, !user.nil?, error)
  end

  def signout
    resp, error = @extuid_token.nil? ? @current_session.deactivate : true
    render_response(resp, !resp.nil?, error)
  end

  def forgot_pass
    resp, error = User.forgot_pass(@email_or_uname)
    render_response(resp, !resp.nil?, error)
  end

  def reset_pass
    resp, error = @current_user.reset_pass(@password, @new_password, @confirm_password)
    render_response(resp, !resp.nil?, error)
  end

  def check_token
    resp = current_session && current_session.active?
    render_response(resp == true)
  end


 
  def doc
     render 'api_doc', :layout => false
  end
end

------------------------------------
config/routes.rb

match '/api/:action(.:format)', :controller => 'api_actions'

-------------------------------------

app/views/api_actions/api_doc.html.erb

<% require 'api_helper' %>

<!DOCTYPE html>
<html>
  <head>
    <title>Typestry: API Doc</title>
    <style type="text/css">
      p { margin:10px 0; padding:0; }
      .note { font-weight: bold; color: gray; }
      #methods { padding: 10px; border-top: 1px solid black; }
      #methods .name { font-weight: bold; }
      #methods .collection { color: gray; margin-left: 20px; }
      #methods .returns { padding-bottom: 10px; border-bottom: 1px dotted gray; }
    </style>
  </head>

  <body>
    <h3>API Documentation</h3>
    <p>All API methods(except <code>signin, signup, login check</code>) requires <code>auth_token</code> param to identify the user session. User-session(and hence the <code>auth_token</code>) expires in 4weeks by default and user has to be signed in again upon expiry.</p>
    <p><span class="note">Note:</span> For FB/Twitter users, there's no concept of sessions. The state is controlled using encrypted <code>extuid_token</code> - derived from <code>auth_token</code>.</p>
    <p>All API response will include: </p>
    <pre>
      1. response: Actual response of the action as defined below, specific to action, can be empty.
      2. status:   Will be "Success" on success and "Failure" on failure.
      3. errors:   Comma seperated string of errors, if status is "Failure", can be empty on "Success".
    </pre>

    <div id="methods">
      <% ApiHelper::SIGNATURE_MAP.each do |k, v| %>
        <p class='name'>/api/<%= k.to_s %></p>
        <p class='accepts'>Accepts: <%= ApiHelper.accepts_label_for(k) %></p>
        <div class='collection'>
          <%= ApiHelper.collection_label_for(k) %>
        </div>
        <p class='returns'>Returns: <%= ApiHelper.returns_label_for(k) %></p>
      <% end %>
    </div>
  </body>
</html>

--------------------------------
All done. Hit /api/doc to check the doc.

No comments:

Post a Comment