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.)