twin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
+