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......
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
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.
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:
# 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
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