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 +210 -0
- data/lib/twin/resources.rb +141 -0
- data/test/app.rb +58 -0
- data/test/config.ru +4 -0
- data/test/tmp/restart.txt +0 -0
- metadata +85 -0
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
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
|
+
|