tent-client 0.0.1 → 0.2.1

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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.travis.yml +1 -3
  3. data/Gemfile +1 -1
  4. data/LICENSE +27 -0
  5. data/README.md +8 -19
  6. data/lib/tent-client.rb +111 -51
  7. data/lib/tent-client/attachment.rb +13 -0
  8. data/lib/tent-client/cycle_http.rb +124 -14
  9. data/lib/tent-client/discovery.rb +59 -27
  10. data/lib/tent-client/faraday/chunked_adapter.rb +100 -0
  11. data/lib/tent-client/faraday/utils.rb +10 -0
  12. data/lib/tent-client/link_header.rb +5 -0
  13. data/lib/tent-client/middleware/authentication.rb +103 -0
  14. data/lib/tent-client/middleware/content_type_header.rb +24 -0
  15. data/lib/tent-client/middleware/encode_json.rb +5 -6
  16. data/lib/tent-client/multipart-post/parts.rb +100 -0
  17. data/lib/tent-client/post.rb +99 -38
  18. data/lib/tent-client/tent_type.rb +77 -0
  19. data/lib/tent-client/version.rb +1 -1
  20. data/spec/cycle_http_spec.rb +213 -0
  21. data/spec/discovery_spec.rb +114 -0
  22. data/spec/{unit/link_header_spec.rb → link_header_spec.rb} +10 -8
  23. data/spec/spec_helper.rb +7 -3
  24. data/spec/support/discovery_link_behaviour.rb +31 -0
  25. data/spec/timestamp_skew_spec.rb +126 -0
  26. data/tent-client.gemspec +11 -6
  27. metadata +75 -91
  28. data/.rspec +0 -1
  29. data/Guardfile +0 -6
  30. data/LICENSE.txt +0 -22
  31. data/lib/tent-client/app.rb +0 -33
  32. data/lib/tent-client/app_authorization.rb +0 -21
  33. data/lib/tent-client/follower.rb +0 -37
  34. data/lib/tent-client/following.rb +0 -31
  35. data/lib/tent-client/group.rb +0 -19
  36. data/lib/tent-client/middleware/accept_header.rb +0 -14
  37. data/lib/tent-client/middleware/mac_auth.rb +0 -50
  38. data/lib/tent-client/post_attachment.rb +0 -11
  39. data/lib/tent-client/profile.rb +0 -17
  40. data/spec/unit/cycle_http_spec.rb +0 -84
  41. data/spec/unit/discovery_spec.rb +0 -46
  42. data/spec/unit/middleware/mac_auth_spec.rb +0 -68
@@ -1,66 +1,127 @@
1
1
  class TentClient
2
2
  class Post
3
- MULTIPART_TYPE = 'multipart/form-data'.freeze
4
- MULTIPART_BOUNDARY = "-----------TentAttachment".freeze
3
+ attr_reader :client, :request_method
4
+ def initialize(client, options = {})
5
+ @client, @request_method = client, options.delete(:request_method)
6
+ end
7
+
8
+ def head
9
+ self.class.new(client, :request_method => :head)
10
+ end
11
+
12
+ def get(entity, post_id, params = {}, &block)
13
+ new_block = proc do |request|
14
+ request.headers['Accept'] = POST_MEDIA_TYPE
15
+ yield(request) if block_given?
16
+ end
17
+
18
+ client.http.send(request_method || :get, :post, { :entity => entity, :post => post_id }.merge(params), &new_block)
19
+ end
5
20
 
6
- def initialize(client)
7
- @client = client
21
+ def get_attachment(entity, post_id, attachment_name, params = {}, &block)
22
+ client.http.send(request_method || :get, :post_attachment, { :entity => entity, :post => post_id, :name => attachment_name }.merge(params), &block)
8
23
  end
9
24
 
10
- def count(params={})
11
- @client.http.get('posts/count', params)
25
+ def delete(entity, post_id, params = {}, &block)
26
+ client.http.delete(:post, { :entity => entity, :post => post_id }.merge(params), &block)
12
27
  end
13
28
 
14
- def list(params = {})
15
- @client.http.get('posts', params)
29
+ def list(params = {}, &block)
30
+ client.http.send(request_method || :get, :posts_feed, params, &block)
16
31
  end
17
32
 
18
- def create(post, options={})
19
- if options[:attachments]
20
- multipart_post(post, options, options.delete(:attachments))
33
+ def create(data, params = {}, options = {}, &block)
34
+ if (Array === (attachments = options.delete(:attachments))) && attachments.any?
35
+ parts = multipart_parts(data, attachments)
36
+ client.http.multipart_request(:post, :new_post, params, parts, &block)
21
37
  else
22
- @client.http.post(options[:url] || 'posts', post)
38
+ client.http.post(:new_post, params, data, &block)
23
39
  end
24
40
  end
25
41
 
26
- def get(id)
27
- @client.http.get("posts/#{id}")
42
+ def update(entity, post_id, data, params = {}, options = {}, &block)
43
+ params = { :entity => entity, :post => post_id }.merge(params)
44
+ if (Array === (attachments = options.delete(:attachments))) && attachments.any?
45
+ parts = multipart_parts(data, attachments, options)
46
+ client.http.multipart_request(:put, :post, params, parts, &block)
47
+ else
48
+ new_block = proc do |request|
49
+ if options.delete(:import)
50
+ request.options['tent.import'] = true
51
+ elsif options.delete(:notification)
52
+ request.options['tent.notification'] = true
53
+ end
54
+ yield(request) if block_given?
55
+ end
56
+
57
+ client.http.put(:post, params, data, &new_block)
58
+ end
28
59
  end
29
60
 
30
- def delete(id)
31
- @client.http.delete("posts/#{id}")
61
+ def mentions(entity, post_id, params = {}, options = {}, &block)
62
+ # TODO: handle options[:page] => :first || :last || page-id
63
+
64
+ params = { :entity => entity, :post => post_id }.merge(params)
65
+
66
+ new_block = proc do |request|
67
+ request.headers['Accept'] = POST_MENTIONS_CONTENT_TYPE
68
+ yield(request) if block_given?
69
+ end
70
+
71
+ client.http.send(request_method || :get, :post, params, &new_block)
32
72
  end
33
73
 
34
- def attachment
35
- PostAttachment.new(@client)
74
+ def versions(entity, post_id, params = {}, options = {}, &block)
75
+ # TODO: handle options[:page] => :first || :last || page-id
76
+
77
+ params = { :entity => entity, :post => post_id }.merge(params)
78
+
79
+ new_block = proc do |request|
80
+ request.headers['Accept'] = POST_VERSIONS_CONTENT_TYPE
81
+ yield(request) if block_given?
82
+ end
83
+
84
+ client.http.send(request_method || :get, :post, params, &new_block)
36
85
  end
37
86
 
38
- private
87
+ def children(entity, post_id, params = {}, options = {}, &block)
88
+ # TODO: handle options[:page] => :first || :last || page-id
89
+
90
+ params = { :entity => entity, :post => post_id }.merge(params)
91
+
92
+ new_block = proc do |request|
93
+ request.headers['Accept'] = POST_CHILDREN_CONTENT_TYPE
94
+ yield(request) if block_given?
95
+ end
39
96
 
40
- def multipart_post(post, options, attachments)
41
- post_body = { :category => 'post', :filename => 'post.json', :type => MEDIA_TYPE, :data => post.to_json }
42
- body = multipart_body(attachments.unshift(post_body))
43
- @client.http.post(options[:url] || 'posts', body, 'Content-Type' => "#{MULTIPART_TYPE};boundary=#{MULTIPART_BOUNDARY}")
97
+ client.http.send(request_method || :get, :post, params, &new_block)
44
98
  end
45
99
 
46
- def multipart_body(attachments)
47
- parts = attachments.inject({}) { |h,a|
48
- (h[a[:category]] ||= []) << a; h
49
- }.inject([]) { |a,(category,attachments)|
50
- if attachments.size > 1
51
- a += attachments.each_with_index.map { |attachment,i|
52
- Faraday::Parts::FilePart.new(MULTIPART_BOUNDARY, "#{category}[#{i}]", attachment_io(attachment))
53
- }
54
- else
55
- a << Faraday::Parts::FilePart.new(MULTIPART_BOUNDARY, category, attachment_io(attachments.first))
56
- end
100
+ private
101
+
102
+ def multipart_parts(data, attachments, options = {})
103
+ [data_as_attachment(data, options)] + attachments.map { |a|
104
+ a[:filename] = a.delete(:name) || a.delete('name')
105
+ a[:headers] = a[:headers] || {}
57
106
  a
58
- } << Faraday::Parts::EpiloguePart.new(MULTIPART_BOUNDARY)
59
- Faraday::CompositeReadIO.new(parts)
107
+ }
60
108
  end
61
109
 
62
- def attachment_io(attachment)
63
- Faraday::UploadIO.new(attachment[:file] || StringIO.new(attachment[:data]), attachment[:type], attachment[:filename])
110
+ def data_as_attachment(data, options = {})
111
+ content_type = POST_CONTENT_TYPE % (data[:type] || data['type'])
112
+
113
+ if options[:import]
114
+ content_type << %(; rel="https://tent.io/rels/import")
115
+ elsif options[:notification]
116
+ content_type << %(; rel="https://tent.io/rels/notification")
117
+ end
118
+
119
+ {
120
+ :category => 'post',
121
+ :filename => 'post.json',
122
+ :content_type => content_type,
123
+ :data => Yajl::Encoder.encode(data)
124
+ }
64
125
  end
65
126
  end
66
127
  end
@@ -0,0 +1,77 @@
1
+ require 'uri'
2
+
3
+ class TentClient
4
+ class TentType
5
+ ALL = 'all'.freeze
6
+
7
+ attr_accessor :base, :version, :fragment
8
+ def initialize(uri = nil)
9
+ @version = 0
10
+ parse_uri(uri) if uri
11
+ end
12
+
13
+ def all?
14
+ base == ALL
15
+ end
16
+
17
+ def has_fragment?
18
+ !!@fragment_separator
19
+ end
20
+
21
+ def fragment=(new_fragment)
22
+ return if all?
23
+
24
+ @fragment_separator = "#"
25
+ @fragment = new_fragment
26
+ @fragment = decode_fragment(@fragment) if @fragment
27
+ @fragment
28
+ end
29
+
30
+ def to_s(options = {})
31
+ return base if all?
32
+
33
+ options[:encode_fragment] = true unless options.has_key?(:encode_fragment)
34
+ if (!has_fragment? && options[:fragment] != true) || options[:fragment] == false
35
+ "#{base}/v#{version}"
36
+ else
37
+ "#{base}/v#{version}##{options[:encode_fragment] ? encode_fragment(fragment) : fragment}"
38
+ end
39
+ end
40
+
41
+ def ==(other)
42
+ unless TentType === other
43
+ if String === other
44
+ other = TentType.new(other)
45
+ else
46
+ return false
47
+ end
48
+ end
49
+
50
+ base == other.base && version == other.version && has_fragment? == other.has_fragment? && fragment == other.fragment
51
+ end
52
+
53
+ private
54
+
55
+ def parse_uri(uri)
56
+ if uri == ALL
57
+ @base = uri
58
+ elsif m = %r{\A(.+?)/v(.+?)(#(.+)?)?\Z}.match(uri.to_s)
59
+ m, @base, @version, @fragment_separator, @fragment = m.to_a
60
+ @fragment = decode_fragment(@fragment) if @fragment
61
+ @version = @version
62
+ end
63
+ end
64
+
65
+ def decode_fragment(fragment)
66
+ return unless fragment
67
+ f, *r = URI.decode(fragment).split('#')
68
+ ([f] + r.map { |_f| decode_fragment(_f) }).join('#')
69
+ end
70
+
71
+ def encode_fragment(fragment)
72
+ return unless fragment
73
+ parts = fragment.split('#')
74
+ parts.reverse.inject(nil) { |m, _p| URI.encode("#{_p}#{m ? '#' + m : ''}") }
75
+ end
76
+ end
77
+ end
@@ -1,3 +1,3 @@
1
1
  class TentClient
2
- VERSION = '0.0.1'
2
+ VERSION = "0.2.1"
3
3
  end
@@ -0,0 +1,213 @@
1
+ require 'spec_helper'
2
+ require 'tent-client/cycle_http'
3
+
4
+ describe TentClient::CycleHTTP do
5
+
6
+ let(:http_stubs) { Faraday::Adapter::Test::Stubs.new }
7
+ let(:server_urls) { %w(http://example.org/tent http://foo.example.com/tent http://baz.example.com/tent) }
8
+ let(:entity_uri) { server_urls.first }
9
+ let(:server_meta) {
10
+ {
11
+ "content" => {
12
+ "entity" => entity_uri,
13
+ "previous_entities" => [],
14
+ "servers" => server_urls.each_with_index.map { |server_url, index|
15
+ {
16
+ "version" => "0.3",
17
+ "urls" => {
18
+ "oauth_auth" => "#{server_url}/oauth/authorize",
19
+ "oauth_token" => "#{server_url}/oauth/token",
20
+ "posts_feed" => "#{server_url}/posts",
21
+ "new_post" => "#{server_url}/posts",
22
+ "post" => "#{server_url}/posts/{entity}/{post}",
23
+ "post_attachment" => "#{server_url}/posts/{entity}/{post}/attachments/{name}?version={version}",
24
+ "batch" => "#{server_url}/batch",
25
+ "server_info" => "#{server_url}/server"
26
+ },
27
+ "preference" => index
28
+ }
29
+ }
30
+ }
31
+ }
32
+ }
33
+ let(:client_options) {
34
+ {
35
+ :server_meta => server_meta
36
+ }
37
+ }
38
+ let(:client) { TentClient.new(entity_uri, client_options) }
39
+
40
+ def expect_server(env, url)
41
+ expect(env[:url].to_s).to match(url)
42
+ end
43
+
44
+ it 'proxies http verbs to Faraday' do
45
+ cycle_http = described_class.new(client) do |f|
46
+ f.adapter :test, http_stubs
47
+ end
48
+
49
+ post_entity = 'https://randomentity.example.org/xyz'
50
+ post_id = 'someid'
51
+ %w{ head get put patch post delete options }.each { |verb|
52
+ http_stubs.send(verb, "/tent/posts/#{URI.encode_www_form_component(post_entity)}/#{post_id}") { |env|
53
+ expect_server(env, server_urls.first)
54
+ [200, {}, '']
55
+ }
56
+
57
+ expect(cycle_http).to respond_to(verb)
58
+ cycle_http.send(verb, :post, :entity => post_entity, :post => post_id)
59
+ }
60
+
61
+ http_stubs.verify_stubbed_calls
62
+ end
63
+
64
+ it 'builds multipart requests' do
65
+ cycle_http = described_class.new(client) do |f|
66
+ f.adapter :net_http
67
+ end
68
+
69
+ http_stubs = []
70
+ body = "--#{TentClient::MULTIPART_BOUNDARY}\r\nContent-Disposition: form-data; name=\"photos[0]\"; filename=\"foo.png\"\r\nContent-Length: 17\r\nContent-Type: image/png\r\nContent-Transfer-Encoding: binary\r\n\r\nFake photo data 1\r\n--#{TentClient::MULTIPART_BOUNDARY}\r\nContent-Disposition: form-data; name=\"photos[1]\"; filename=\"bar.png\"\r\nContent-Length: 17\r\nContent-Type: image/png\r\nContent-Transfer-Encoding: binary\r\n\r\nFake photo data 2\r\n--#{TentClient::MULTIPART_BOUNDARY}\r\nContent-Disposition: form-data; name=\"documentation\"; filename=\"README.txt\"\r\nContent-Length: 17\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: binary\r\n\r\nSome instructions\r\n--#{TentClient::MULTIPART_BOUNDARY}--\r\n\r\n"
71
+
72
+ %w{ put patch post }.each { |verb|
73
+ http_stubs << stub_request(verb.to_sym, "#{server_urls.first}/posts").with(
74
+ :header => {
75
+ 'Content-Type' => "#{TentClient::MULTIPART_CONTENT_TYPE};boundary=#{TentClient::MULTIPART_BOUNDARY}",
76
+ 'Content-Length' => body.length
77
+ }
78
+ ).with { |request|
79
+ request.body.to_s.split(%r{-+#{TentClient::MULTIPART_BOUNDARY}-*\r\n}).sort == body.split(%r{-+#{TentClient::MULTIPART_BOUNDARY}-*\r\n}).sort
80
+ }
81
+
82
+ expect(cycle_http).to respond_to(:multipart_request)
83
+ cycle_http.multipart_request(verb, :new_post, {}, [
84
+ {
85
+ :filename => 'foo.png',
86
+ :content_type => 'image/png',
87
+ :data => 'Fake photo data 1',
88
+ :category => 'photos'
89
+ },
90
+ {
91
+ :filename => 'bar.png',
92
+ :content_type => 'image/png',
93
+ :data => 'Fake photo data 2',
94
+ :category => 'photos'
95
+ },
96
+ {
97
+ :filename => 'README.txt',
98
+ :content_type => 'text/plain',
99
+ :data => 'Some instructions',
100
+ :category => 'documentation'
101
+ }
102
+ ])
103
+ }
104
+
105
+ http_stubs.each do |stub|
106
+ expect(stub).to have_been_requested
107
+ end
108
+ end
109
+
110
+ it 'builds multipart requests with custom headers' do
111
+ cycle_http = described_class.new(client) do |f|
112
+ f.adapter :net_http
113
+ end
114
+
115
+ http_stubs = []
116
+ body = "--#{TentClient::MULTIPART_BOUNDARY}\r\nContent-Disposition: form-data; name=\"photos\"; filename=\"foo.png\"\r\nContent-Length: 17\r\nContent-Type: image/vnd.foo.bar.v0+png\r\nContent-Transfer-Encoding: binary\r\nFoo: Bar\r\n\r\nFake photo data 1\r\n--#{TentClient::MULTIPART_BOUNDARY}--\r\n\r\n"
117
+
118
+ %w{ put patch post }.each { |verb|
119
+ http_stubs << stub_request(verb.to_sym, "#{server_urls.first}/posts").with(
120
+ :body => body,
121
+ :header => {
122
+ 'Content-Type' => "#{TentClient::MULTIPART_CONTENT_TYPE};boundary=#{TentClient::MULTIPART_BOUNDARY}",
123
+ 'Content-Length' => body.length
124
+ }
125
+ )
126
+
127
+ expect(cycle_http).to respond_to(:multipart_request)
128
+ cycle_http.multipart_request(verb, :new_post, {}, [
129
+ {
130
+ :filename => 'foo.png',
131
+ :content_type => 'image/png',
132
+ :data => 'Fake photo data 1',
133
+ :category => 'photos',
134
+ :headers => {
135
+ 'Content-Type' => "image/vnd.foo.bar.v0+png",
136
+ 'Foo' => 'Bar'
137
+ }
138
+ }
139
+ ])
140
+ }
141
+
142
+ http_stubs.each do |stub|
143
+ expect(stub).to have_been_requested
144
+ end
145
+ end
146
+
147
+ it 'retries http with next server url' do
148
+ http_stubs.get('/tent/posts') { |env|
149
+ expect_server(env, server_urls.first)
150
+ [500, {}, '']
151
+ }
152
+
153
+ http_stubs.get('/tent/posts') { |env|
154
+ expect_server(env, server_urls[1])
155
+ [300, {}, '']
156
+ }
157
+
158
+ http_stubs.get('/tent/posts') { |env|
159
+ expect_server(env, server_urls.last)
160
+ [200, {}, '']
161
+ }
162
+
163
+ cycle_http = described_class.new(client) do |f|
164
+ f.adapter :test, http_stubs
165
+ end
166
+
167
+ res = cycle_http.get(:new_post)
168
+
169
+ http_stubs.verify_stubbed_calls
170
+
171
+ expect(res.env[:tent_server]).to_not be_nil
172
+ end
173
+
174
+ it 'returns response when on last server url' do
175
+ http_stubs.get('/tent/posts') { |env|
176
+ expect_server(env, server_urls.first)
177
+ raise Faraday::Error::TimeoutError.new("")
178
+ }
179
+
180
+ http_stubs.get('/tent/posts') { |env|
181
+ expect_server(env, server_urls[1])
182
+ raise Faraday::Error::ConnectionFailed.new("")
183
+ }
184
+
185
+ http_stubs.get('/tent/posts') { |env|
186
+ expect_server(env, server_urls.last)
187
+ [300, {}, '']
188
+ }
189
+
190
+ http_stubs.get('/tent/posts') { |env|
191
+ raise StandardError, 'expected stub not be called bus was'
192
+ }
193
+
194
+ cycle_http = described_class.new(client) do |f|
195
+ f.adapter :test, http_stubs
196
+ end
197
+
198
+ cycle_http.get(:new_post)
199
+ end
200
+
201
+ it 'encodes flat params' do
202
+ http_stubs.get('/tent/posts') { |env|
203
+ expect(env[:url].query).to match("color=red")
204
+ expect(env[:url].query).to match("color=blue")
205
+ }
206
+
207
+ cycle_http = described_class.new(client) do |f|
208
+ f.adapter :test, http_stubs
209
+ end
210
+
211
+ cycle_http.get(:new_post, :color => ['red', 'blue'])
212
+ end
213
+ end