Saturday 10 December 2011

Rails: Facebook and Twitter connect

Summary of the complete facebook and twitter connect flow in a typical rails 3 application, using fbgraph, twitter_oauth.

Gemfile:

gem 'fbgraph', '1.9.0'
gem 'twitter_oauth', '0.4.3'

config/routes.rb:

  match 'login/:platform'  => 'welcome#login',  :as => :login
  match 'auth/:platform/callback' => 'welcome#auth_callback', :as => :auth_callback

config/social_api.yml:

facebook:
  app_key: fbappkey
  app_secret: fbappsecret
  callback_url: http://yourapp.com/auth/facebook/callback

twitter:
  app_key: twtappkey
  app_secret: twtappsecret
  callback_url: http://yourapp.com/auth/twitter/callback

lib/auth_client.rb

# Interacts with FB/Twitter oauth apis
# using fbgraph and twitter_oauth gems resp.
# Designed to be included to any controller.
module AuthClient
  SOCIAL_API_CREDS_MAP = YAML::load_file File.join(Rails.root, 'config/social_api.yml')

  #FB methods
  def fb_client(reload = false)
    opts = {
      :client_id => fb_config['app_key'],
      :secret_id => fb_config['app_secret'],
      :token => session[:fb_access_token]
    }

    @fb_client = FBGraph::Client.new(opts) if reload
    @fb_client ||= FBGraph::Client.new(opts)
  end

  def fb_authorize
    fb_client.authorization.authorize_url(
      :redirect_uri => fb_config['callback_url'],
      :scope => 'email,publish_stream'
    )
  end

  def fb_get_token(code)
    fb_client.authorization.process_callback(code,
      :redirect_uri => fb_config['callback_url']
    )
  end

  # TODO:: we may need to check the access token validity as well.
  def fb_authorized?
    !session[:fb_access_token].blank?
  end

  def fb_config
    SOCIAL_API_CREDS_MAP['facebook']
  end

  # Twitter methods
  def twt_client
    opts = {
      :consumer_key    => twt_config['app_key'],
      :consumer_secret => twt_config['app_secret'],
      :token => session[:twt_access_token],
      :secret => session[:twt_secret_token]
    }
    @twt_client ||= TwitterOAuth::Client.new(opts)
  end

  def twt_get_token
    twt_client.request_token(
      :oauth_callback => twt_config['callback_url']
    )
  end

  def twt_authorize(verifier)
    twt_client.authorize(
      session[:twt_request_token],
      session[:twt_request_token_secret],
      :oauth_verifier => verifier
    )
  end

  def twt_authorized?
    twt_client.authorized?
  end

  def twt_config
    SOCIAL_API_CREDS_MAP['twitter']
  end
end

app/controller/welcome_controller.rb

require 'auth_client'

class WelcomeController < ApplicationController
  include AuthClient

  def login
    self.send(params[:platform] + "_login")
  end

  def auth_callback
    self.send(params[:platform] + "_callback")
  end


private
  def facebook_login
    if fb_authorized?
      usr_obj = fb_client.selection.me.info!
      check_and_handle_fb_user(usr_obj.data)
    else
      auth_url = fb_authorize
      redirect_to auth_url
    end
  end

  def twitter_login
    if twt_authorized?
      usr_hsh = twt_client.info
      check_and_handle_twt_user(usr_hsh)
    else
      resp = twt_get_token
      session[:twt_request_token] = resp.token
      session[:twt_request_token_secret] = resp.secret
      redirect_to resp.authorize_url
    end
  end

  def twitter_callback
    if params[:denied]
      redirect_to login_url(:default), :alert => 'Unauthorized!'
      return
    end

    # Exchange the request token for an access token.
    resp = twt_authorize params[:oauth_verifier]

    if twt_authorized?
      # Storing the access tokens so we don't have to go back to Twitter again
      # in this session.  We can also consider persisting these details in DB.
      session[:twt_access_token] = resp.token
      session[:twt_secret_token] = resp.secret
      usr_hsh = JSON.parse resp.response.body
      check_and_handle_twt_user(usr_hsh)
    else
      redirect_to login_url(:default), :alert => 'Twitter Auth failed!'
    end
  end

  def facebook_callback
    if params[:error] == "access_denied"
      redirect_to login_url(:default), :alert => 'Unauthorized!'
      return
    end

    token = fb_get_token(params[:code])

    if token
      session[:fb_access_token] = token
      usr_obj = fb_client(true).selection.me.info!
      check_and_handle_fb_user(usr_obj.data)
    else
      redirect_to login_url(:default), :alert => 'Facebook Auth failed!'
    end
  end

  def check_and_handle_twt_user(usr_hsh)
    usr = User.find_by_extuid(usr_hsh['id_str'])
    if usr.nil? # new user
      @user = User.new(
        :username => usr_hsh['screen_name'],
        :email => usr_hsh['email'],
        :full_name => usr_hsh['name'],
        :extuid => usr_hsh['id_str'],
        :description => usr_hsh['description'],
        :website => usr_hsh['url']
      )
      @avatar_url = usr_hsh['profile_image_url']
      render :signup
    else
      session[:user_id] = usr.id
      redirect_to home_url
    end
  end

  def check_and_handle_fb_user(usr_obj)
    usr = User.find_by_extuid(usr_obj.id)
    if usr.nil? # new user
      @user = User.new(
        :username => usr_obj.username,
        :email => usr_obj.email,
        :full_name => usr_obj.name,
        :extuid => usr_obj.id,
        :description => usr_obj.bio,
        :website => usr_obj.link
      )
      @avatar_url = fb_client.selection.me.picture
      render :signup
    else
      session[:user_id] = usr.id
      redirect_to home_url
    end
  end
end

app/models/user.rb:

User model with username, email, full_name, extuid, description, website, avatar_url columns.

I'll leave it upto you to handle the views, as you would prefer.

link_to 'Facebook Connect', login_path(:facebook)
link_to 'Twitter Connect', login_path(:twitter)

will help you get started with the action. Hope it helps!

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.

Friday 9 December 2011

Rails 3: Counter Cache in Mongoid Documents

Currently, mongoid gem doesn't support counter_cache and here's my workaround for that. Offcourse, there might be more better versions available, but just wanted to share mine too :)

In config/initializers/mongoid_counter_cache.rb

module Mongoid
  module CounterCache

    extend ActiveSupport::Concern
    # Enables AR counter_cache mechanism on all cols ending with '_count' in the document.
    # Ex. Usage: in Photo model, add 'include Mongoid::CounterCache', after fields definition.
    # NOTE:: for a column 'likes_count' to work, we should have 'Like' document, with 'photo' association.
    included do
      klass = self.to_s.downcase
      cache_cols = self.fields.keys.select { |col| col.include?('_count') }
      create_meth = "increment_counter_in_#{klass}"
      destroy_meth = "decrement_counter_in_#{klass}"
      cache_cols.each do |col|
        modal = col.to_s.sub(/_count/, '').classify.constantize
        modal.class_eval <<-STR
          after_create '#{create_meth}'.to_sym
          after_destroy '#{destroy_meth}'.to_sym

          def #{create_meth}
            modal = self.#{klass}
            count = modal.#{col} + 1
            modal.update_attribute('#{col}'.to_sym, count)
          end

          def #{destroy_meth}
            modal = self.#{klass}
            count = modal.#{col} - 1
            modal.update_attribute('#{col}'.to_sym, count)
          end
        STR
      end # cache_cols.each
    end # included

  end
end

In app/models/photo.rb

include Mongoid::CounterCache, after the field declarations.

That's it. You are good to go. All columns ending with '_count' will be managed, automatically (provided the regular rails conventions are followed, refer inline comments in the code block, above.)

Wednesday 30 November 2011

Rails3.1- Asset isn't precompiled error

Recently on a production env, I was annoyed with "<Asset> isn't precompiled" error, though it was compiled using rake assets:precompile. I wasn't able to figure out the root cause. However I was able to work around.

I'd assets included on the view, using <content_for>. I removed and required it within the controller specific asset file and things started working. So as far as I can say NEVER require assets within the view, using <content_for> or <provide>. Asset pipeline, chokes for some reason.

Tuesday 29 November 2011

HTML5 File Api- Preview during image upload

File Api is one of the coolest addition with HTML5, though not all major browsers has started supported this(as of this writing). It allows the client to handle basic file validations and image preview before sending it to the server. Here's how I did:

HTML:

<input type="file" name="photo" accept="image/*"  />
<div class="preview"></div>

Jquery:


var MAX_FILE_SIZE = 5242880; // 5MB
var ACCEPT_FILE_TYPE = /image\/(jpg|jpeg|png)/i;

function handleFileSelect(evt) {
  var files = evt.target.files; // FileList object
  var form = $(evt.target).closest('form');

  // Loop through the FileList and render image files as thumbnails.
  for (var i = 0, f; f = files[i]; i++) {
    // Only process image files.
    if (!f.type.match(ACCEPT_FILE_TYPE)) {
      showFormError('File is not an image', form);
      continue;
    }

    if (parseInt(f.size) > MAX_FILE_SIZE) {
      showFormError('File size exceeds 5MB.', form);
      continue;
    }

    var reader = new FileReader();
    // Closure to capture the file information.
    reader.onload = (function(theFile) {
      return function(e) {
        // Render thumbnail.
        var img = ['<img src="', e.target.result,
                          '" title="', theFile.name, '"/>'].join('');
        form.find('.preview').prepend(img);
        form.find('.preview').show();
      };
    })(f);

    // Read in the image file as a data URL.
    reader.readAsDataURL(f);
  }
}

function showFormError(msg, form) {
  var errorDiv = document.createElement('div');
  errorDiv.className = 'errors';
  errorDiv.innerHTML = msg;
  form.find('div.errors').remove();
  form.prepend(errorDiv);
}

$('input[type=file]').change(handleFileSelect);

That's it... 

Thursday 24 November 2011

Paperclip: Image or Photos from url

In general, Paperclip accepts file uploaded from local machine. However we can extend it to support image from url (Ex., Saving user avatars from twitter or facebook)

app/models/user.rb

  has_attached_file :avatar, ....
  attr_accessor :avatar_url
  def avatar_url=(img_url)
    io = open(URI.parse(img_url))
    # define original_filename meth on io, dynamically
    def io.original_filename; base_uri.path.split('/').last; end
    self.avatar = (io.original_filename.blank? ? nil : io)
  rescue Exception => ex
    puts ex.message
    Rails.logger.info "Error while parsing avatar: #{ex.message}"
  ensure
    io.close
    @avatar_url = img_url
  end

Glitch : OpenURI.open, returns an StringIO object, which might not be as efficient as a Tempfile. So considering adding this patch.


config/initializers/open_uri_patch.rb

require 'open-uri'

# make OpenURI to use tempfiles instead of io.
# Patched from http://snippets.dzone.com/posts/show/3994
OpenURI::Buffer.module_eval do
  remove_const :StringMax
  const_set :StringMax, 0
end

We are ready now..

Ex Usage :  u = User.new(:avatar_url => 'http://www.facebook.com/profile/pic')

Good luck :)


Wednesday 23 November 2011

Rails: ffmpeg video generation from images

To generate video from a series of images/photos in rails, I use this code snippet:

    # generate the video and store it under the appropriate public path.
    def create_video_from(foto_paths = [], tyype)
      create_tmp_links(foto_paths)
      status = Paperclip.run('ffmpeg', ffmpeg_options)
      remove_tmp_links(foto_paths.length)
      FileUtils.mv('/tmp/output.mp4', Rails.root.to_s + "/public/#{tyype.to_s}_video.mp4")
    end

    def ffmpeg_options
      "-r 1 -f image2 -i '/tmp/img%03d.jpg' /tmp/output.mp4"
    end

    # ffmpeg requires filenames matching a pattern, so create them under /tmp
    def create_tmp_links(foto_paths = [])
      foto_paths.each_with_index do |path, i|
        tmp_path = "/tmp/img%03d.jpg" % (i + 1)
        File.symlink(path, tmp_path)
      end
      true
    end

    # remove those symlinks
    def remove_tmp_links(fotos_cnt = 0)
      fotos_cnt.times do |i|
        tmp_path = "/tmp/img%03d.jpg" % (i + 1)
        File.unlink(tmp_path)
      end
      true
    end

Hope it helps!

Tuesday 22 November 2011

Rails: Aspect fit images

Aspect fitting images with a frame has always been a common requirement across websites that supports image browsing. Here's how I would like to do it in Rails web applications:

app/models/photo.rb

  def aspect_fit(frame_width, frame_height)
    image_width, image_height = self.data_dimension.split('x')
    ratio_frame = frame_width / frame_height
    ratio_image = image_width.to_f / image_height.to_f
    if ratio_image > ratio_frame
      image_width  = frame_width
      image_height = frame_width / ratio_image
    elsif image_height.to_i > frame_height
      image_width = frame_height * ratio_image
      image_height = frame_height
    end
    "width:#{image_width.to_i}px; height:#{image_height.to_i}px;"
  end
Note: self.data_dimension, will return a string of "widthxheight" for a photo. I store it when uploading images. You might also use, Paperclip::Geometry.from_file to get the dimension during run-time.

In Views:

<div class='photo'>
  <img src='<%= @photo.url %>' style='<%= @photo.aspect_fit(500, 500) %>'/>
</div>

Styles:

#photo { position: relative; height: 500px; width: 500px; background-color: black; }
#photo img { position: absolute; bottom: 0; left: 0; right: 0; top: 0; margin: auto; }

That should be it.

Rails: Simple File upload

Inspired from Paperclip, I always wanted to have a simple file upload solution in Rails, which doesn't require any thumbnail generation. Here it's:

app/models/post.rb

  attr_accessor :data
  FOTO_DIR = File.join(Rails.root, 'public/photos')
  FOTO_PATH = File.join(FOTO_DIR, ':id/:filename')
  ALLOWED_TYPES = ['image/jpg', 'image/jpeg', 'image/png']

  validates :data_filename, :presence => true
  validates :data_size, :inclusion => { :in => 0..(5.megabytes) }, :allow_blank => true
  validates :data_content_type, :inclusion => { :in => ALLOWED_TYPES }, :allow_blank => true

  after_save :save_data_to_file

  def data=(file)
    return nil if file.blank?
    @data = file.path # temp file path
    self.data_filename = file.original_filename.to_s
    self.data_content_type = file.content_type.to_s
    self.data_size = file.size.to_i
    self.data_dimension = get_geometry(file)
  end

  def path
    fpath = FOTO_PATH.dup
    fpath.sub!(/:id/, self.id.to_s)
    fpath.sub!(/:filename/, self.data_filename)
    fpath
  end

  def url
    path.sub("#{Rails.root}/public", "")
  end

private

  def save_data_to_file
    return true if self.data.nil?
    ensure_dir(FOTO_DIR)
    ensure_dir(File.join(FOTO_DIR, self.id.to_s))
    Rails.logger.info "Saving file: #{self.path}"
    FileUtils.cp(self.data, self.path)
    true
  end

  def ensure_dir(dirname = nil)
    raise "directory path cannot be empty" if dirname.nil?
    unless File.exist?(dirname)
      FileUtils.mkdir(dirname)
    end
  end

  def get_geometry(file)
    `identify -format %wx%h #{file.path}`.strip
  end
Issuing Photo.create(:data => params[:photo]) should create the file in filesystem and save the record in DB, where params[:photo] is the file field value.

Its quite easy to make slight changes to this code piece to make it fit for your requirement , rather than writing patches/hacks to any gem.

Monday 21 November 2011

Rails: Basic vim config

Vim supports a whole lot of features, however I've listed the basic config to get started with development in rails.

~/.vimrc
set nocompatible          " We're running Vim, not Vi!
syntax on                 " Enable syntax highlighting
filetype plugin indent on " Enable filetype-specific indenting and plugins

" Load matchit (% to bounce from do to end, etc.)
runtime! macros/matchit.vim

augroup myfiletypes
  " Clear old autocmds in group
  autocmd!
  " autoindent with two spaces, always expand tabs
  autocmd FileType ruby,eruby,yaml set ai sw=2 sts=2 et
augroup END

nmap <leader>rci :%!ruby-code-indenter<cr>
Apart from this, you can also tweek any config under /etc/vim/vimrc , based on your preference.

Friday 18 November 2011

Rails3.1 - Manage CSS/JS with Asset Pipeline

An Ideal way to mange your css/js using sprockets in rails (>~ 3) apps. Asset pipeline is enabled by default from rails 3.1 and it uses 'app/assets' to maintain the asset files. We can also have 'lib/assets' and 'vendor/assets' to host the assets.

These assets are included in the layout using

<%= stylesheet_link_tag    "application" %>
<%= javascript_include_tag "application" %>
and the app/assets/stylesheets/application.css will

  *= require_self
  *= require_tree .
Ideally, we wouldn't want to require the whole tree for stylesheets or javascripts. We only need application and the controller specific assets to be loaded on any given page and so I recommended having the following setup.

Typical application.css
*= require reset-min
*= require_self

In layout:

  <%= stylesheet_link_tag    "application", params[:controller] %>
  <%= javascript_include_tag "application", params[:controller] %>
  <%= yield(:head) %>
Hereby, we load only the common styles/scripts applicable for the app, from application and controller specific styles. yield(:head) is to require custom styles/scripts for a page.

Also, assets from vendor/assets or lib/assets can be required within application or controller specific files.

Note: with this approach, the custom files has to be added manually to the precompile list, for them to be precompiled. config.assets.precompile += %w( *.js *.css ) - should do the trick.

Tuesday 15 November 2011

Setup rhodes environment on Ubuntu

Recently I was setting up rhodes on my ubuntu machine to mobile app development, where I'd to go through multiple references. So I decided to make a write up to serve as one reference for all.

Assumption - You have ruby and rubygems installed.

sudo apt-get install sun-java6-jdk
gem install rhodes
set-rhodes-sdk

Download android sdk from http://developer.android.com/sdk/index.html
untar the downloaded package ( android-sdk-linux)
mv android-sdk-linux /usr/local
cd /usr/local/android-sdk-linux/tools
./android

Download android ndk from http://developer.android.com/sdk/ndk/index.html
untar the downloaded package ( Ex., android-ndk-r7 )
mv android-ndk-r7 /usr/local

Run 'rhodes-setup' and specify JDK, android sdk and ndk paths.
For our case it will look like
JDK path - /usr/lib/jvm/java-6-sun
Android SDK path - /usr/local/android-sdk-linux
NDK path - /usr/local/android-ndk-r7

Generate the application:
rhodes app test_app http://localhost:3000/application
OR create an application in the cloud using rhohub.com (preferred option)

Now 'rake run:android' and have fun :)

Saturday 12 November 2011

Grub 2 - Limit kernel entries

With Grub2 menu.lst has been removed and there's no way to change "#howmany" to limit the number of kernel entries to be displayed on the boot menu. So here's how we do it:

Find this section in /etc/grub.d/10_linux . Note this is not the entire file!. Added sections are in bold dark red:

prepare_boot_cache=

# Added to limit number of Linux kernels displayed.
COUNTER=0
LINUX_KERNELS_DISPLAYED=2
#


while [ "x$list" != "x" ] ; do
linux=`version_find_latest $list`
echo "Found linux image: $linux" >&2
.....
..... < omitted lines >
..... < several lines from the bottom of the file >

list=`echo $list | tr ' ' '\n' | grep -vx $linux | tr '\n' ' '`


# Added to limit number of Linux kernels displayed.
COUNTER=`expr $COUNTER + 1`
if [ $COUNTER -eq $LINUX_KERNELS_DISPLAYED ]; then
list=""
fi
#

done
 Save the file and run 'update-grub'.

Friday 11 November 2011

New Rails 3.1 app

Generate new rails 3.1 app(with mysql db) with the following command:

rails new testapp -d mysql --skip-bundle

--skip-bundle will not run 'bundle install' during app creation.

Thursday 10 November 2011

Truncate file linux

Simple bash script to truncate a huge log, may be log files

cat /dev/null > /var/log/production.log

Mysql - Enable slow query logging

Enabling slow query logs is one of the recommended options to determine long running queries, lock timeout issues and non-indexed queries.

Uncomment the below lines in ~/.my.cnf or /etc/my.cnf under #Logging

#log-output                     = FILE
#slow-query-log                 = 1
#slow-query-log-file            = /var/lib/mysqllogs/slow-log
#long-query-time                = 2
#log-queries-not-using-indexes  = 1

and restart the server.

Use mysqldumpslow to quickly parse through huge log files, as below.

mysqldumpslow /var/lib/mysqllogs/slow-log


Script to convert all tables to InnoDB

Here is a quick shell script to convert all tables in a database to InnoDB. No dependancies other than a command line prompt on a Unix like system and the standard MySQL tools:

for T in `mysql -u root -B -N -e “show tables” test`; do mysql -u root -e “alter table $T type=innodb” test; done

Replace “test” with the target database. This pattern is also great for optimizing or analyzing your MyISAM tables.

Wednesday 9 November 2011

Paperclip fog storage : content_type fix

Add this initializer to set the correct content_type when you use fog storage with paperclip - version <= '2.3.12'

# patch from https://github.com/lisausa/paperclip/commit/e03007da2cedd7c32dbd5d1601d215897e00bc51
module Paperclip
  module Storage
    module Fog

      def flush_writes
        for style, file in @queued_for_write do
          log("saving #{path(style)}")
          directory.files.create(
            :body         => file,
            :key          => path(style),
            :public       => @fog_public,
            :content_type => file.content_type.to_s.strip
          )
        end
        @queued_for_write = {}
      end

    end
  end
end
 Done.

Tuesday 8 November 2011

mysql gem dependencies in Ubuntu

Very Often when we setup a Rails application on a fresh Ubuntu machine, we end up facing error when installing mysql gem, when executing bundle install.


Make sure you have mysql gem dependencies installed, (and offcourse the actual mysql server as well)

sudo apt-get install libmysql-ruby libmysqlclient-dev

Done.