Skip to content
Browse files

twitter's twin

  • Loading branch information...
0 parents commit bd8a5f463dbf8a2016f141c7f9d764271a1cda78 @mislav committed Nov 29, 2010
Showing with 441 additions and 0 deletions.
  1. +2 −0 .gitignore
  2. +6 −0 Gemfile
  3. +20 −0 Gemfile.lock
  4. +210 −0 lib/twin.rb
  5. +141 −0 lib/twin/resources.rb
  6. +58 −0 test/app.rb
  7. +4 −0 test/config.ru
  8. 0 test/public/.gitkeep
  9. 0 test/tmp/.gitkeep
2 .gitignore
@@ -0,0 +1,2 @@
+test/tmp/*
+!test/tmp/.gitkeep
6 Gemfile
@@ -0,0 +1,6 @@
+source :rubygems
+
+gem 'sinatra'
+gem 'activesupport'
+gem 'i18n'
+gem 'builder'
20 Gemfile.lock
@@ -0,0 +1,20 @@
+GEM
+ remote: http://rubygems.org/
+ specs:
+ activesupport (3.0.3)
+ builder (2.1.2)
+ i18n (0.4.2)
+ rack (1.2.1)
+ sinatra (1.1.0)
+ rack (~> 1.1)
+ tilt (~> 1.1)
+ tilt (1.1)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ activesupport
+ builder
+ i18n
+ sinatra
210 lib/twin.rb
@@ -0,0 +1,210 @@
+require 'rack/request'
+require 'active_support/core_ext/hash/conversions'
+require 'active_support/core_ext/hash/keys'
+require 'active_support/core_ext/hash/indifferent_access'
+require 'active_support/json'
+require 'active_support/core_ext/object/to_query'
+require 'digest/md5'
+
+class Twin
+ X_CASCADE = 'X-Cascade'.freeze
+ PASS = 'pass'.freeze
+ PATH_INFO = 'PATH_INFO'.freeze
+ AUTHORIZATION = 'HTTP_AUTHORIZATION'.freeze
+
+ attr_accessor :request, :format, :captures, :content_type, :current_user
+
+ class << self
+ attr_accessor :resources
+ end
+
+ self.resources = []
+
+ def self.resource(path, &block)
+ reg = %r{^/(?:1/)?#{path}(?:\.(\w+))?$}
+ self.resources << [reg, block]
+ end
+
+ DEFAULT_OPTIONS = { :model => 'TwinAdapter' }
+
+ def initialize(app, options = {})
+ @app = app
+ @options = DEFAULT_OPTIONS.merge options
+ end
+
+ def call(env)
+ path = normalize_path(env[PATH_INFO])
+ matches = nil
+
+ if path != '/' and found = recognize_resource(path)
+ block, matches = found
+
+ # TODO: bail out early if authentication failed
+ twin_token = env[AUTHORIZATION] =~ / oauth_token="(.+?)"/ && $1
+ authenticated_user = twin_token && model.find_by_twin_token(twin_token)
+
+ clone = self.dup
+ clone.request = Rack::Request.new env
+ clone.captures = matches[1..-1]
+ clone.format = clone.captures.pop
+ clone.current_user = authenticated_user
+
+ clone.perform block
+ else
+ # [404, {'Content-Type' => 'text/plain', X_CASCADE => PASS}, ['Not Found']]
+ @app.call(env)
+ end
+ end
+
+ def recognize_resource(path)
+ matches = nil
+ pair = self.class.resources.find { |reg, block| matches = path.match(reg) }
+ pair && [pair[1], matches]
+ end
+
+ def perform(block)
+ response = instance_eval &block
+ generate_response response
+ end
+
+ # x_auth_mode => "client_auth"
+ resource 'oauth/access_token' do
+ if user = model.authenticate(params['x_auth_username'], params['x_auth_password'])
+ user_hash = normalize_user(user)
+ token = user_hash[:twin_token] || model.twin_token(user)
+
+ self.content_type = 'application/x-www-form-urlencoded'
+
+ { :oauth_token => token,
+ :oauth_token_secret => 'useless',
+ :user_id => user_hash[:id],
+ :screen_name => user_hash[:screen_name],
+ :x_auth_expires => 0
+ }
+ # later sent back as: oauth_token="..."
+ else
+ [400, {'Content-Type' => 'text/plain'}, ['Bad credentials']]
+ end
+ end
+
+ protected
+
+ class Response
+ def initialize(name, object)
+ @name = name
+ @object = object
+ end
+
+ def to_xml
+ @object.to_xml(:root => @name, :dasherize => false, :skip_types => true)
+ end
+
+ def to_json
+ @object.to_json
+ end
+ end
+
+ def respond_with(name, values)
+ Response.new(name, values)
+ end
+
+ def params
+ request.params
+ end
+
+ def model
+ @model ||= constantize(@options[:model])
+ end
+
+ def not_found
+ [404, {'Content-Type' => 'text/plain'}, ['Not found']]
+ end
+
+ def normalize_statuses(statuses)
+ statuses.map do |status|
+ hash = convert_twin_hash(status)
+ hash[:user] = normalize_user(hash[:user])
+ DEFAULT_STATUS_PARAMS.merge hash
+ end
+ end
+
+ def normalize_user(user)
+ hash = convert_twin_hash(user)
+
+ if hash[:email] and not hash[:profile_image_url]
+ # large avatar for iPhone with Retina display
+ hash[:profile_image_url] = gravatar(hash.delete(:email), 96, 'identicon')
+ end
+
+ DEFAULT_USER_INFO.merge hash
+ end
+
+ def convert_twin_hash(object)
+ if Hash === object then object
+ elsif object.respond_to? :to_twin_hash
+ object.to_twin_hash
+ elsif object.respond_to? :attributes
+ object.attributes
+ else
+ object.to_hash
+ end.symbolize_keys
+ end
+
+ def gravatar(email, size = 48, default = nil)
+ gravatar_id = Digest::MD5.hexdigest email.downcase
+ url = "http://www.gravatar.com/avatar/#{gravatar_id}?size=#{size}"
+ url << "&default=#{default}" if default
+ return url
+ end
+
+ private
+
+ def content_type_from_format(format)
+ case format
+ when 'xml' then 'application/xml'
+ when 'json' then 'application/x-json'
+ end
+ end
+
+ def serialize_body(body)
+ if String === body
+ body
+ else
+ case self.content_type
+ when 'application/xml' then body.to_xml
+ when 'application/x-json' then body.to_json
+ when 'application/x-www-form-urlencoded' then body.to_query
+ else
+ raise "unrecognized content type: #{self.content_type.inspect} (format: #{self.format})"
+ end
+ end
+ end
+
+ def generate_response(response)
+ if Array === response then response
+ else
+ self.content_type ||= content_type_from_format(self.format)
+ [200, {'Content-Type' => self.content_type}, [serialize_body(response)]]
+ end
+ end
+
+ # Strips off trailing slash and ensures there is a leading slash.
+ def normalize_path(path)
+ path = "/#{path}"
+ path.squeeze!('/')
+ path.sub!(%r{/+\Z}, '')
+ path = '/' if path == ''
+ path
+ end
+
+ def constantize(name)
+ if Module === name then name
+ elsif name.to_s.respond_to? :constantize
+ name.to_s.constantize
+ else
+ Object.const_get name
+ end
+ end
+end
+
+require 'twin/resources'
141 lib/twin/resources.rb
@@ -0,0 +1,141 @@
+class Twin
+ resource 'statuses/home_timeline' do
+ statuses = self.model.statuses(params.with_indifferent_access)
+ respond_with('statuses', normalize_statuses(statuses))
+ end
+
+ resource 'users/show' do
+ user = if params['screen_name']
+ self.model.find_by_username params['screen_name']
+ elsif params['user_id']
+ self.model.find_by_id params['user_id']
+ end
+
+ if user
+ respond_with('user', normalize_user(user))
+ else
+ not_found
+ end
+ end
+
+ resource 'account/verify_credentials' do
+ respond_with('user', normalize_user(current_user))
+ end
+
+ resource 'friendships/show' do
+ source_id = params['source_id']
+ target_id = params['target_id']
+
+ respond_with('relationship', {
+ "target" => {
+ "followed_by" => true,
+ "following" => false,
+ "id_str" => target_id.to_s,
+ "id" => target_id.to_i,
+ "screen_name" => ""
+ },
+ "source" => {
+ "blocking" => nil,
+ "want_retweets" => true,
+ "followed_by" => false,
+ "following" => true,
+ "id_str" => source_id.to_s,
+ "id" => source_id.to_i,
+ "screen_name" => "",
+ "marked_spam" => nil,
+ "all_replies" => nil,
+ "notifications_enabled" => nil
+ }
+ })
+ end
+
+ resource 'statuses/(?:replies|mentions)' do
+ respond_with('statuses', [])
+ end
+
+ resource '(\w+)/lists(/subscriptions)?' do
+ respond_with('lists_list', {:lists => []})
+ end
+
+ resource 'direct_messages(/sent)?' do
+ respond_with('direct-messages', [])
+ end
+
+ resource 'account/rate_limit_status' do
+ reset_time = Time.now + (60 * 60 * 24)
+ respond_with(nil, {
+ 'remaining-hits' => 100, 'hourly-limit' => 100,
+ 'reset-time' => reset_time, 'reset-time-in-seconds' => reset_time.to_i
+ })
+ end
+
+ resource 'saved_searches' do
+ respond_with('saved_searches', [])
+ end
+
+ DEFAULT_STATUS_PARAMS = {
+ :id => nil,
+ :text => "",
+ :user => nil,
+ :created_at => "Mon Jan 01 00:00:00 +0000 1900",
+ :source => "web",
+ :coordinates => nil,
+ :truncated => false,
+ :favorited => false,
+ :contributors => nil,
+ :annotations => nil,
+ :geo => nil,
+ :place => nil,
+ :in_reply_to_screen_name => nil,
+ :in_reply_to_user_id => nil,
+ :in_reply_to_status_id => nil
+ }
+
+ DEFAULT_USER_INFO = {
+ :id => nil,
+ :screen_name => nil,
+ :name => "",
+ :description => "",
+ :profile_image_url => nil,
+ :url => nil,
+ :location => nil,
+ :created_at => "Mon Jan 01 00:00:00 +0000 1900",
+ :profile_sidebar_fill_color => "ffffff",
+ :profile_background_tile => false,
+ :profile_sidebar_border_color => "ffffff",
+ :profile_link_color => "8b8b9c",
+ :profile_use_background_image => false,
+ :profile_background_image_url => nil,
+ :profile_background_color => "FFFFFF",
+ :profile_text_color => "000000",
+ :follow_request_sent => false,
+ :contributors_enabled => false,
+ :favourites_count => 0,
+ :lang => "en",
+ :followers_count => 0,
+ :protected => false,
+ :geo_enabled => false,
+ :utc_offset => 0,
+ :verified => false,
+ :time_zone => "London",
+ :notifications => false,
+ :statuses_count => 0,
+ :friends_count => 0,
+ :following => true
+ }
+
+ # direct_message
+ # :id
+ # :sender_id
+ # :text
+ # :recipient_id
+ # :created_at
+ # :sender_screen_name
+ # :recipient_screen_name
+ # :sender
+ # :recipient
+end
+
+# POST /1/account/apple_push_destinations.xml
+# POST /1/account/apple_push_destinations/destroy.xml
+# GET /1/account/settings.xml
58 test/app.rb
@@ -0,0 +1,58 @@
+# encoding: utf-8
+require 'sinatra'
+require 'twin'
+
+unless settings.run?
+ set :logging, false
+ use Rack::CommonLogger, File.open(File.join(settings.root, 'request.log'), 'a')
+end
+
+use Twin, :model => 'Adapter'
+
+USERS = [
+ { :id => 1, :screen_name => 'mislav', :name => 'Mislav Marohnić', :email => 'mislav.marohnic@gmail.com'},
+ { :id => 2, :screen_name => 'veganstraightedge', :name => 'Shane Becker', :email => 'veganstraightedge@gmail.com'}
+ ]
+
+STATUSES = [
+ { :id => 1, :text => 'Hello there! What a weird test', :user => USERS[0] },
+ { :id => 2, :text => 'The world needs this.', :user => USERS[1] }
+ ]
+
+module Adapter
+ def self.authenticate(username, password)
+ username == password and find_by_username(username)
+ end
+
+ def self.twin_token(user)
+ user[:email]
+ end
+
+ def self.statuses(params)
+ STATUSES
+ end
+
+ def self.find_by_twin_token(token)
+ find_by_key(:email, token)
+ end
+
+ def self.find_by_id(value)
+ find_by_key(:id, value)
+ end
+
+ def self.find_by_username(value)
+ find_by_key(:screen_name, value)
+ end
+
+ def self.find_by_key(key, value)
+ USERS.find { |user| user[key] == value }
+ end
+end
+
+get '/' do
+ "Hello from test app"
+end
+
+error 404 do
+ 'No match'
+end
4 test/config.ru
@@ -0,0 +1,4 @@
+$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
+require 'rubygems'
+require 'app'
+run Sinatra::Application
0 test/public/.gitkeep
No changes.
0 test/tmp/.gitkeep
No changes.

0 comments on commit bd8a5f4

Please sign in to comment.
Something went wrong with that request. Please try again.