twin 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/twin.rb ADDED
@@ -0,0 +1,210 @@
1
+ require 'rack/request'
2
+ require 'active_support/core_ext/hash/conversions'
3
+ require 'active_support/core_ext/hash/keys'
4
+ require 'active_support/core_ext/hash/indifferent_access'
5
+ require 'active_support/json'
6
+ require 'active_support/core_ext/object/to_query'
7
+ require 'digest/md5'
8
+
9
+ class Twin
10
+ X_CASCADE = 'X-Cascade'.freeze
11
+ PASS = 'pass'.freeze
12
+ PATH_INFO = 'PATH_INFO'.freeze
13
+ AUTHORIZATION = 'HTTP_AUTHORIZATION'.freeze
14
+
15
+ attr_accessor :request, :format, :captures, :content_type, :current_user
16
+
17
+ class << self
18
+ attr_accessor :resources
19
+ end
20
+
21
+ self.resources = []
22
+
23
+ def self.resource(path, &block)
24
+ reg = %r{^/(?:1/)?#{path}(?:\.(\w+))?$}
25
+ self.resources << [reg, block]
26
+ end
27
+
28
+ DEFAULT_OPTIONS = { :model => 'TwinAdapter' }
29
+
30
+ def initialize(app, options = {})
31
+ @app = app
32
+ @options = DEFAULT_OPTIONS.merge options
33
+ end
34
+
35
+ def call(env)
36
+ path = normalize_path(env[PATH_INFO])
37
+ matches = nil
38
+
39
+ if path != '/' and found = recognize_resource(path)
40
+ block, matches = found
41
+
42
+ # TODO: bail out early if authentication failed
43
+ twin_token = env[AUTHORIZATION] =~ / oauth_token="(.+?)"/ && $1
44
+ authenticated_user = twin_token && model.find_by_twin_token(twin_token)
45
+
46
+ clone = self.dup
47
+ clone.request = Rack::Request.new env
48
+ clone.captures = matches[1..-1]
49
+ clone.format = clone.captures.pop
50
+ clone.current_user = authenticated_user
51
+
52
+ clone.perform block
53
+ else
54
+ # [404, {'Content-Type' => 'text/plain', X_CASCADE => PASS}, ['Not Found']]
55
+ @app.call(env)
56
+ end
57
+ end
58
+
59
+ def recognize_resource(path)
60
+ matches = nil
61
+ pair = self.class.resources.find { |reg, block| matches = path.match(reg) }
62
+ pair && [pair[1], matches]
63
+ end
64
+
65
+ def perform(block)
66
+ response = instance_eval &block
67
+ generate_response response
68
+ end
69
+
70
+ # x_auth_mode => "client_auth"
71
+ resource 'oauth/access_token' do
72
+ if user = model.authenticate(params['x_auth_username'], params['x_auth_password'])
73
+ user_hash = normalize_user(user)
74
+ token = user_hash[:twin_token] || model.twin_token(user)
75
+
76
+ self.content_type = 'application/x-www-form-urlencoded'
77
+
78
+ { :oauth_token => token,
79
+ :oauth_token_secret => 'useless',
80
+ :user_id => user_hash[:id],
81
+ :screen_name => user_hash[:screen_name],
82
+ :x_auth_expires => 0
83
+ }
84
+ # later sent back as: oauth_token="..."
85
+ else
86
+ [400, {'Content-Type' => 'text/plain'}, ['Bad credentials']]
87
+ end
88
+ end
89
+
90
+ protected
91
+
92
+ class Response
93
+ def initialize(name, object)
94
+ @name = name
95
+ @object = object
96
+ end
97
+
98
+ def to_xml
99
+ @object.to_xml(:root => @name, :dasherize => false, :skip_types => true)
100
+ end
101
+
102
+ def to_json
103
+ @object.to_json
104
+ end
105
+ end
106
+
107
+ def respond_with(name, values)
108
+ Response.new(name, values)
109
+ end
110
+
111
+ def params
112
+ request.params
113
+ end
114
+
115
+ def model
116
+ @model ||= constantize(@options[:model])
117
+ end
118
+
119
+ def not_found
120
+ [404, {'Content-Type' => 'text/plain'}, ['Not found']]
121
+ end
122
+
123
+ def normalize_statuses(statuses)
124
+ statuses.map do |status|
125
+ hash = convert_twin_hash(status)
126
+ hash[:user] = normalize_user(hash[:user])
127
+ DEFAULT_STATUS_PARAMS.merge hash
128
+ end
129
+ end
130
+
131
+ def normalize_user(user)
132
+ hash = convert_twin_hash(user)
133
+
134
+ if hash[:email] and not hash[:profile_image_url]
135
+ # large avatar for iPhone with Retina display
136
+ hash[:profile_image_url] = gravatar(hash.delete(:email), 96, 'identicon')
137
+ end
138
+
139
+ DEFAULT_USER_INFO.merge hash
140
+ end
141
+
142
+ def convert_twin_hash(object)
143
+ if Hash === object then object
144
+ elsif object.respond_to? :to_twin_hash
145
+ object.to_twin_hash
146
+ elsif object.respond_to? :attributes
147
+ object.attributes
148
+ else
149
+ object.to_hash
150
+ end.symbolize_keys
151
+ end
152
+
153
+ def gravatar(email, size = 48, default = nil)
154
+ gravatar_id = Digest::MD5.hexdigest email.downcase
155
+ url = "http://www.gravatar.com/avatar/#{gravatar_id}?size=#{size}"
156
+ url << "&default=#{default}" if default
157
+ return url
158
+ end
159
+
160
+ private
161
+
162
+ def content_type_from_format(format)
163
+ case format
164
+ when 'xml' then 'application/xml'
165
+ when 'json' then 'application/x-json'
166
+ end
167
+ end
168
+
169
+ def serialize_body(body)
170
+ if String === body
171
+ body
172
+ else
173
+ case self.content_type
174
+ when 'application/xml' then body.to_xml
175
+ when 'application/x-json' then body.to_json
176
+ when 'application/x-www-form-urlencoded' then body.to_query
177
+ else
178
+ raise "unrecognized content type: #{self.content_type.inspect} (format: #{self.format})"
179
+ end
180
+ end
181
+ end
182
+
183
+ def generate_response(response)
184
+ if Array === response then response
185
+ else
186
+ self.content_type ||= content_type_from_format(self.format)
187
+ [200, {'Content-Type' => self.content_type}, [serialize_body(response)]]
188
+ end
189
+ end
190
+
191
+ # Strips off trailing slash and ensures there is a leading slash.
192
+ def normalize_path(path)
193
+ path = "/#{path}"
194
+ path.squeeze!('/')
195
+ path.sub!(%r{/+\Z}, '')
196
+ path = '/' if path == ''
197
+ path
198
+ end
199
+
200
+ def constantize(name)
201
+ if Module === name then name
202
+ elsif name.to_s.respond_to? :constantize
203
+ name.to_s.constantize
204
+ else
205
+ Object.const_get name
206
+ end
207
+ end
208
+ end
209
+
210
+ require 'twin/resources'
@@ -0,0 +1,141 @@
1
+ class Twin
2
+ resource 'statuses/home_timeline' do
3
+ statuses = self.model.statuses(params.with_indifferent_access)
4
+ respond_with('statuses', normalize_statuses(statuses))
5
+ end
6
+
7
+ resource 'users/show' do
8
+ user = if params['screen_name']
9
+ self.model.find_by_username params['screen_name']
10
+ elsif params['user_id']
11
+ self.model.find_by_id params['user_id']
12
+ end
13
+
14
+ if user
15
+ respond_with('user', normalize_user(user))
16
+ else
17
+ not_found
18
+ end
19
+ end
20
+
21
+ resource 'account/verify_credentials' do
22
+ respond_with('user', normalize_user(current_user))
23
+ end
24
+
25
+ resource 'friendships/show' do
26
+ source_id = params['source_id']
27
+ target_id = params['target_id']
28
+
29
+ respond_with('relationship', {
30
+ "target" => {
31
+ "followed_by" => true,
32
+ "following" => false,
33
+ "id_str" => target_id.to_s,
34
+ "id" => target_id.to_i,
35
+ "screen_name" => ""
36
+ },
37
+ "source" => {
38
+ "blocking" => nil,
39
+ "want_retweets" => true,
40
+ "followed_by" => false,
41
+ "following" => true,
42
+ "id_str" => source_id.to_s,
43
+ "id" => source_id.to_i,
44
+ "screen_name" => "",
45
+ "marked_spam" => nil,
46
+ "all_replies" => nil,
47
+ "notifications_enabled" => nil
48
+ }
49
+ })
50
+ end
51
+
52
+ resource 'statuses/(?:replies|mentions)' do
53
+ respond_with('statuses', [])
54
+ end
55
+
56
+ resource '(\w+)/lists(/subscriptions)?' do
57
+ respond_with('lists_list', {:lists => []})
58
+ end
59
+
60
+ resource 'direct_messages(/sent)?' do
61
+ respond_with('direct-messages', [])
62
+ end
63
+
64
+ resource 'account/rate_limit_status' do
65
+ reset_time = Time.now + (60 * 60 * 24)
66
+ respond_with(nil, {
67
+ 'remaining-hits' => 100, 'hourly-limit' => 100,
68
+ 'reset-time' => reset_time, 'reset-time-in-seconds' => reset_time.to_i
69
+ })
70
+ end
71
+
72
+ resource 'saved_searches' do
73
+ respond_with('saved_searches', [])
74
+ end
75
+
76
+ DEFAULT_STATUS_PARAMS = {
77
+ :id => nil,
78
+ :text => "",
79
+ :user => nil,
80
+ :created_at => "Mon Jan 01 00:00:00 +0000 1900",
81
+ :source => "web",
82
+ :coordinates => nil,
83
+ :truncated => false,
84
+ :favorited => false,
85
+ :contributors => nil,
86
+ :annotations => nil,
87
+ :geo => nil,
88
+ :place => nil,
89
+ :in_reply_to_screen_name => nil,
90
+ :in_reply_to_user_id => nil,
91
+ :in_reply_to_status_id => nil
92
+ }
93
+
94
+ DEFAULT_USER_INFO = {
95
+ :id => nil,
96
+ :screen_name => nil,
97
+ :name => "",
98
+ :description => "",
99
+ :profile_image_url => nil,
100
+ :url => nil,
101
+ :location => nil,
102
+ :created_at => "Mon Jan 01 00:00:00 +0000 1900",
103
+ :profile_sidebar_fill_color => "ffffff",
104
+ :profile_background_tile => false,
105
+ :profile_sidebar_border_color => "ffffff",
106
+ :profile_link_color => "8b8b9c",
107
+ :profile_use_background_image => false,
108
+ :profile_background_image_url => nil,
109
+ :profile_background_color => "FFFFFF",
110
+ :profile_text_color => "000000",
111
+ :follow_request_sent => false,
112
+ :contributors_enabled => false,
113
+ :favourites_count => 0,
114
+ :lang => "en",
115
+ :followers_count => 0,
116
+ :protected => false,
117
+ :geo_enabled => false,
118
+ :utc_offset => 0,
119
+ :verified => false,
120
+ :time_zone => "London",
121
+ :notifications => false,
122
+ :statuses_count => 0,
123
+ :friends_count => 0,
124
+ :following => true
125
+ }
126
+
127
+ # direct_message
128
+ # :id
129
+ # :sender_id
130
+ # :text
131
+ # :recipient_id
132
+ # :created_at
133
+ # :sender_screen_name
134
+ # :recipient_screen_name
135
+ # :sender
136
+ # :recipient
137
+ end
138
+
139
+ # POST /1/account/apple_push_destinations.xml
140
+ # POST /1/account/apple_push_destinations/destroy.xml
141
+ # GET /1/account/settings.xml
data/test/app.rb ADDED
@@ -0,0 +1,58 @@
1
+ # encoding: utf-8
2
+ require 'sinatra'
3
+ require 'twin'
4
+
5
+ unless settings.run?
6
+ set :logging, false
7
+ use Rack::CommonLogger, File.open(File.join(settings.root, 'request.log'), 'a')
8
+ end
9
+
10
+ use Twin, :model => 'Adapter'
11
+
12
+ USERS = [
13
+ { :id => 1, :screen_name => 'mislav', :name => 'Mislav Marohnić', :email => 'mislav.marohnic@gmail.com'},
14
+ { :id => 2, :screen_name => 'veganstraightedge', :name => 'Shane Becker', :email => 'veganstraightedge@gmail.com'}
15
+ ]
16
+
17
+ STATUSES = [
18
+ { :id => 1, :text => 'Hello there! What a weird test', :user => USERS[0] },
19
+ { :id => 2, :text => 'The world needs this.', :user => USERS[1] }
20
+ ]
21
+
22
+ module Adapter
23
+ def self.authenticate(username, password)
24
+ username == password and find_by_username(username)
25
+ end
26
+
27
+ def self.twin_token(user)
28
+ user[:email]
29
+ end
30
+
31
+ def self.statuses(params)
32
+ STATUSES
33
+ end
34
+
35
+ def self.find_by_twin_token(token)
36
+ find_by_key(:email, token)
37
+ end
38
+
39
+ def self.find_by_id(value)
40
+ find_by_key(:id, value)
41
+ end
42
+
43
+ def self.find_by_username(value)
44
+ find_by_key(:screen_name, value)
45
+ end
46
+
47
+ def self.find_by_key(key, value)
48
+ USERS.find { |user| user[key] == value }
49
+ end
50
+ end
51
+
52
+ get '/' do
53
+ "Hello from test app"
54
+ end
55
+
56
+ error 404 do
57
+ 'No match'
58
+ end
data/test/config.ru ADDED
@@ -0,0 +1,4 @@
1
+ $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
2
+ require 'rubygems'
3
+ require 'app'
4
+ run Sinatra::Application
File without changes
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: twin
3
+ version: !ruby/object:Gem::Version
4
+ hash: 27
5
+ prerelease: false
6
+ segments:
7
+ - 0
8
+ - 1
9
+ - 0
10
+ version: 0.1.0
11
+ platform: ruby
12
+ authors:
13
+ - "Mislav Marohni\xC4\x87"
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2010-11-29 00:00:00 +01:00
19
+ default_executable:
20
+ dependencies:
21
+ - !ruby/object:Gem::Dependency
22
+ name: activesupport
23
+ prerelease: false
24
+ requirement: &id001 !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ hash: 5
30
+ segments:
31
+ - 2
32
+ - 3
33
+ version: "2.3"
34
+ type: :runtime
35
+ version_requirements: *id001
36
+ description: Rack middleware to expose a Twitter-like API from your app.
37
+ email: mislav.marohnic@gmail.com
38
+ executables: []
39
+
40
+ extensions: []
41
+
42
+ extra_rdoc_files: []
43
+
44
+ files:
45
+ - lib/twin/resources.rb
46
+ - lib/twin.rb
47
+ - test/app.rb
48
+ - test/config.ru
49
+ - test/tmp/restart.txt
50
+ has_rdoc: false
51
+ homepage: http://github.com/mislav/twin
52
+ licenses: []
53
+
54
+ post_install_message:
55
+ rdoc_options: []
56
+
57
+ require_paths:
58
+ - lib
59
+ required_ruby_version: !ruby/object:Gem::Requirement
60
+ none: false
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ hash: 3
65
+ segments:
66
+ - 0
67
+ version: "0"
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ none: false
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ hash: 3
74
+ segments:
75
+ - 0
76
+ version: "0"
77
+ requirements: []
78
+
79
+ rubyforge_project:
80
+ rubygems_version: 1.3.7
81
+ signing_key:
82
+ specification_version: 3
83
+ summary: Twitter's twin
84
+ test_files: []
85
+