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,56 +1,88 @@
1
+ require 'yajl'
2
+ require 'faraday'
3
+ require 'faraday_middleware'
4
+ require 'faraday_middleware/multi_json'
1
5
  require 'nokogiri'
6
+ require 'tent-client/link_header'
7
+ require 'uri'
2
8
 
3
9
  class TentClient
4
10
  class Discovery
5
- attr_accessor :url, :profile_urls, :primary_profile_url, :profile
11
+ META_POST_REL = "https://tent.io/rels/meta-post".freeze
6
12
 
7
- def initialize(client, url)
8
- @client, @url = client, url
13
+ def self.discover(client, entity_uri, options = {})
14
+ new(client, entity_uri, :skip_serialization => false).discover(options)
9
15
  end
10
16
 
11
- def http
12
- @http ||= Faraday.new do |f|
13
- f.response :follow_redirects
14
- f.adapter *Array(@client.faraday_adapter)
15
- end
17
+ attr_reader :client, :entity_uri
18
+ attr_accessor :last_response
19
+ def initialize(client, entity_uri, options = {})
20
+ @entity_uri = entity_uri
21
+ @client = client.dup
22
+ @client.faraday_adapter = :net_http
23
+ @options = options
16
24
  end
17
25
 
18
- def perform
19
- @profile_urls = perform_head_discovery || perform_get_discovery || []
20
- @profile_urls.map! { |l| l =~ %r{\A/} ? URI.join(url, l).to_s : l }
21
- end
26
+ def discover(options = {})
27
+ discover_res, meta_post_urls = perform_head_discovery || perform_get_discovery
28
+
29
+ if meta_post_urls.empty?
30
+ return options[:return_response] ? last_response : nil
31
+ end
32
+
33
+ meta_post_urls.uniq.each do |url|
34
+ url = URI.join(discover_res.env[:url].to_s, url).to_s
35
+ begin
36
+ res = http.get(url) do |request|
37
+ request.headers['Accept'] = POST_CONTENT_TYPE % "https://tent.io/types/meta/v0#"
38
+ end
39
+ rescue Faraday::Error::TimeoutError, Faraday::Error::ConnectionFailed
40
+ res = Faraday::Response.new({})
41
+ end
22
42
 
23
- def get_profile
24
- profile_urls.each do |url|
25
- res = @client.http.get(url)
26
- if res['Content-Type'].split(';').first == MEDIA_TYPE
27
- @profile = res.body
28
- @primary_profile_url = url
29
- break
43
+ if options[:return_response]
44
+ return res
45
+ elsif res.success? && (Hash === res.body)
46
+ return res.body['post']
30
47
  end
31
48
  end
32
- [@profile, @primary_profile_url.to_s.sub(%r{/profile$}, '')]
49
+ nil
50
+ end
51
+
52
+ private
53
+
54
+ def http
55
+ @http ||= Faraday.new do |f|
56
+ f.adapter *Array(client.faraday_adapter)
57
+ f.response :follow_redirects
58
+ f.response :multi_json, :content_type => /\bjson\Z/ unless (@options[:skip_serialization] != false) && (client.options[:skip_serialization] || client.options[:skip_response_serialization])
59
+ end
33
60
  end
34
61
 
35
62
  def perform_head_discovery
36
- perform_header_discovery http.head(url)
63
+ res = http.head(entity_uri)
64
+ self.last_response = res
65
+ links = Array(perform_header_discovery(res))
66
+ [res, links] if links.any?
37
67
  end
38
68
 
39
69
  def perform_get_discovery
40
- res = http.get(url)
41
- perform_header_discovery(res) || perform_html_discovery(res)
70
+ res = http.get(entity_uri)
71
+ self.last_response = res
72
+ [res, Array(perform_link_discovery(res))]
42
73
  end
43
74
 
44
75
  def perform_header_discovery(res)
45
76
  if header = res['Link']
46
- links = LinkHeader.parse(header).links.select { |l| l[:rel] == PROFILE_REL }.map { |l| l.uri }
77
+ links = LinkHeader.parse(header).links.select { |l| l[:rel] == META_POST_REL }.map { |l| l.uri }
47
78
  links unless links.empty?
48
79
  end
80
+ rescue LinkHeader::MalformedLinkHeader
49
81
  end
50
82
 
51
- def perform_html_discovery(res)
52
- return unless res['Content-Type'] == 'text/html'
53
- Nokogiri::HTML(res.body).css(%(link[rel="#{PROFILE_REL}"])).map { |l| l['href'] }
83
+ def perform_link_discovery(res)
84
+ return unless res['Content-Type'].to_s.downcase =~ %r{\Atext/html}
85
+ Nokogiri::HTML(res.body).css(%(link[rel="#{META_POST_REL}"])).map { |l| l['href'] }
54
86
  end
55
87
  end
56
88
  end
@@ -0,0 +1,100 @@
1
+ require 'net/http'
2
+ require 'faraday/adapter/net_http'
3
+
4
+ module Faraday
5
+ class Adapter
6
+
7
+ class NetHttpStream < Adapter::NetHttp
8
+ class BodyStream
9
+ def initialize(env, &stream_body)
10
+ @env, @stream_body = env, stream_body
11
+ @read_body = false
12
+ end
13
+
14
+ def each(&block)
15
+ return if @read_body
16
+ @stream_body.call(block)
17
+ @read_body = true
18
+ end
19
+
20
+ def read
21
+ return @body if @body
22
+ @body = ""
23
+ each do |chunk|
24
+ @body << chunk
25
+ end
26
+ @body
27
+ end
28
+ end
29
+
30
+ def call(env)
31
+ env[:request_body] = env[:body]
32
+
33
+ request = new_request(env)
34
+
35
+ stream_body = proc do |callback|
36
+ @read_proc = callback
37
+ request.resume
38
+ end
39
+
40
+ response = request.resume
41
+ body = BodyStream.new(env, &stream_body)
42
+
43
+ save_response(env, response.code.to_i, body) do |response_headers|
44
+ response.each_header do |key, value|
45
+ response_headers[key] = value
46
+ end
47
+ end
48
+
49
+ @app.call(env)
50
+ rescue *Adapter::NetHttp::NET_HTTP_EXCEPTIONS
51
+ raise Error::ConnectionFailed, $!
52
+ rescue Timeout::Error => err
53
+ raise Faraday::Error::TimeoutError, err
54
+ end
55
+
56
+ private
57
+
58
+ def new_request(env)
59
+ uri = env[:url]
60
+ Fiber.new do
61
+ Net::HTTP.start(uri.host, uri.port) do |http|
62
+ configure_ssl(http, env[:ssl]) if env[:url].scheme == 'https' and env[:ssl]
63
+
64
+ request = create_request(env)
65
+
66
+ req = env[:request]
67
+ http.read_timeout = http.open_timeout = req[:timeout] if req[:timeout]
68
+ http.open_timeout = req[:open_timeout] if req[:open_timeout]
69
+
70
+ http.request(request) do |response|
71
+ Fiber.yield(response)
72
+
73
+ response.read_body do |chunk|
74
+ @read_proc.call(chunk)
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+
81
+ def create_request(env)
82
+ request = Net::HTTPGenericRequest.new \
83
+ env[:method].to_s.upcase, # request method
84
+ !!env[:body], # is there request body
85
+ :head != env[:method], # is there response body
86
+ env[:url].request_uri, # request uri path
87
+ env[:request_headers] # request headers
88
+
89
+ if env[:request_body].respond_to?(:read)
90
+ request.body_stream = env[:request_body]
91
+ else
92
+ request.body = env[:request_body]
93
+ end
94
+ request
95
+ end
96
+ end
97
+
98
+ Adapter.register_middleware(:net_http_stream => NetHttpStream)
99
+ end
100
+ end
@@ -0,0 +1,10 @@
1
+ module Faraday
2
+ module Utils
3
+ class ParamsHash
4
+ def to_query
5
+ # Build params normally (e.g. a=1&a=2 rather than a[]=1&a[]=2)
6
+ Utils.build_query(self)
7
+ end
8
+ end
9
+ end
10
+ end
@@ -5,6 +5,8 @@ class TentClient
5
5
  class LinkHeader
6
6
  attr_accessor :links
7
7
 
8
+ MalformedLinkHeader = Class.new(StandardError)
9
+
8
10
  def self.parse(header)
9
11
  new header.split(',').map { |l| Link.parse(l) }
10
12
  end
@@ -24,6 +26,9 @@ class TentClient
24
26
  s = StringScanner.new(link_text)
25
27
  s.scan(/[^<]+/)
26
28
  link = s.scan(/<[^\s]+>/)
29
+
30
+ raise MalformedLinkHeader.new("Link: #{s.inspect}") unless link
31
+
27
32
  link = link[1..-2]
28
33
 
29
34
  s.scan(/[^a-z]+/)
@@ -0,0 +1,103 @@
1
+ require 'faraday_middleware'
2
+ require 'hawk'
3
+
4
+ class TentClient
5
+ module Middleware
6
+ class Authentication < Faraday::Middleware
7
+ AUTHORIZATION_HEADER = 'Authorization'.freeze
8
+ CONTENT_TYPE_HEADER = 'Content-Type'.freeze
9
+
10
+ MAX_RETRIES = 1.freeze
11
+
12
+ def initialize(app, credentials, options = {})
13
+ super(app)
14
+
15
+ @credentials = {
16
+ :id => credentials[:id],
17
+ :key => credentials[:hawk_key],
18
+ :algorithm => credentials[:hawk_algorithm]
19
+ } if credentials
20
+
21
+ @ts_skew = options[:ts_skew] || 0
22
+ @update_ts_skew = options[:update_ts_skew] || lambda {}
23
+ @ts_skew_retry_enabled = options[:ts_skew_retry_enabled]
24
+ @retry_count = 0
25
+ end
26
+
27
+ def call(env)
28
+ set_auth_header(env)
29
+ res = @app.call(env)
30
+
31
+ tsm_header = res.env[:response_headers]['WWW-Authenticate']
32
+
33
+ if tsm_header && (tsm_header =~ /tsm/) && (skew = timestamp_skew_from_header(tsm_header)) && @retry_count < MAX_RETRIES
34
+ @update_ts_skew.call(skew)
35
+ @ts_skew = skew
36
+
37
+ @retry_count += 1
38
+
39
+ return call(env) if @ts_skew_retry_enabled
40
+ end
41
+
42
+ res
43
+ end
44
+
45
+ private
46
+
47
+ def set_auth_header(env)
48
+ env[:request_headers][AUTHORIZATION_HEADER] = build_authorization_header(env) if @credentials
49
+ end
50
+
51
+ def timestamp
52
+ Time.now.to_i + @ts_skew
53
+ end
54
+
55
+ def timestamp_skew_from_header(header)
56
+ Hawk::Client.calculate_time_offset(header, :credentials => @credentials)
57
+ end
58
+
59
+ def build_authorization_header(env)
60
+ Hawk::Client.build_authorization_header(
61
+ :credentials => @credentials,
62
+ :ts => timestamp,
63
+ :payload => request_body(env),
64
+ :content_type => request_content_type(env),
65
+ :method => request_method(env),
66
+ :port => request_port(env),
67
+ :host => request_host(env),
68
+ :request_uri => request_path(env)
69
+ )
70
+ end
71
+
72
+ def request_body(env)
73
+ if env[:body].respond_to?(:read)
74
+ body = env[:body].read
75
+ env[:body].rewind if env[:body].respond_to?(:rewind)
76
+ body
77
+ else
78
+ env[:body]
79
+ end
80
+ end
81
+
82
+ def request_content_type(env)
83
+ env[:request_headers]['Content-Type'].to_s.split(';').first
84
+ end
85
+
86
+ def request_method(env)
87
+ env[:method].to_s.upcase
88
+ end
89
+
90
+ def request_port(env)
91
+ env[:url].port || (env[:url].scheme == 'https' ? 443 : 80)
92
+ end
93
+
94
+ def request_host(env)
95
+ env[:url].host
96
+ end
97
+
98
+ def request_path(env)
99
+ env[:url].to_s.sub(%r{\A#{env[:url].scheme}://#{env[:url].host}(:#{env[:url].port})?}, '') # maintain query and fragment
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,24 @@
1
+ require 'faraday_middleware'
2
+
3
+ class TentClient
4
+ module Middleware
5
+ class ContentTypeHeader < Faraday::Middleware
6
+ CONTENT_TYPE_HEADER = 'Content-Type'.freeze
7
+ MEDIA_TYPE = %(application/vnd.tent.post.v0+json; type="%s").freeze
8
+
9
+ def call(env)
10
+ if env[:body] && Hash === env[:body] && !env[:request_headers][CONTENT_TYPE_HEADER]
11
+ env[:request_headers][CONTENT_TYPE_HEADER] = MEDIA_TYPE % (env[:body]['type'] || env[:body][:type])
12
+
13
+ if env[:request]['tent.import']
14
+ env[:request_headers][CONTENT_TYPE_HEADER] << %(; rel="https://tent.io/rels/import")
15
+ elsif env[:request]['tent.notification']
16
+ env[:request_headers][CONTENT_TYPE_HEADER] << %(; rel="https://tent.io/rels/notification")
17
+ end
18
+ end
19
+
20
+ @app.call env
21
+ end
22
+ end
23
+ end
24
+ end
@@ -5,10 +5,10 @@ class TentClient
5
5
  # FaradayMiddleware::EncodeJson with our media type
6
6
  # https://github.com/pengwynn/faraday_middleware/blob/master/lib/faraday_middleware/request/encode_json.rb
7
7
  class EncodeJson < Faraday::Middleware
8
- CONTENT_TYPE = 'Content-Type'.freeze
8
+ CONTENT_TYPE_HEADER = 'Content-Type'.freeze
9
9
 
10
10
  dependency do
11
- require 'oj' unless defined?(::Oj)
11
+ require 'yajl' unless defined?(Yajl)
12
12
  end
13
13
 
14
14
  def call(env)
@@ -19,19 +19,18 @@ class TentClient
19
19
  end
20
20
 
21
21
  def encode(data)
22
- ::Oj.dump data
22
+ Yajl::Encoder.encode(data)
23
23
  end
24
24
 
25
25
  def match_content_type(env)
26
26
  if process_request?(env)
27
- env[:request_headers][CONTENT_TYPE] ||= MEDIA_TYPE
28
27
  yield env[:body] unless env[:body].respond_to?(:to_str)
29
28
  end
30
29
  end
31
30
 
32
31
  def process_request?(env)
33
32
  type = request_type(env)
34
- has_body?(env) and (type.empty? or type == MEDIA_TYPE)
33
+ has_body?(env) and (type.empty? or type =~ /\bjson\Z/)
35
34
  end
36
35
 
37
36
  def has_body?(env)
@@ -39,7 +38,7 @@ class TentClient
39
38
  end
40
39
 
41
40
  def request_type(env)
42
- type = env[:request_headers][CONTENT_TYPE].to_s
41
+ type = env[:request_headers][CONTENT_TYPE_HEADER].to_s
43
42
  type = type.split(';', 2).first if type.index(';')
44
43
  type
45
44
  end
@@ -0,0 +1,100 @@
1
+ # Modified version of https://github.com/nicksieger/multipart-post/blob/7d8b74888bf13f702a9590212533f869f0f43f64/lib/parts.rb
2
+
3
+ require 'parts'
4
+
5
+ module Parts
6
+ module Part #:nodoc:
7
+ def self.new(boundary, name, value, options = {})
8
+ if value.respond_to?(:content_type)
9
+ FilePart.new(boundary, name, value, options)
10
+ else
11
+ ParamPart.new(boundary, name, value, options)
12
+ end
13
+ end
14
+
15
+ def length
16
+ @part.length
17
+ end
18
+
19
+ def to_io
20
+ @io
21
+ end
22
+ end
23
+
24
+ class ParamPart
25
+ include Part
26
+ def initialize(boundary, name, value, options = {})
27
+ @part = build_part(boundary, name, value, options)
28
+ @io = StringIO.new(@part)
29
+ end
30
+
31
+ def length
32
+ @part.bytesize
33
+ end
34
+
35
+ def build_part(boundary, name, value, options = {})
36
+ part = []
37
+ part << "--#{boundary}"
38
+ part << %(Content-Disposition: form-data; name="#{name.to_s}")
39
+
40
+ (options[:headers] || {}).each do |name, value|
41
+ part << "#{name}: #{value}"
42
+ end
43
+
44
+ part.join("\r\n") << "\r\n\r\n"
45
+ part << "#{value}\r\n"
46
+ end
47
+ end
48
+
49
+ # Represents a part to be filled from file IO.
50
+ class FilePart
51
+ include Part
52
+ attr_reader :length
53
+ def initialize(boundary, name, io, options = {})
54
+ file_length = io.respond_to?(:length) ? io.length : File.size(io.local_path)
55
+
56
+ options ||= {}
57
+ options.merge!(
58
+ :io_opts => io.respond_to?(:opts) ? io.opts : {}
59
+ )
60
+
61
+ @head = build_head(boundary, name, io.original_filename, io.content_type, file_length, options)
62
+ @foot = "\r\n"
63
+ @length = @head.length + file_length + @foot.length
64
+ @io = CompositeReadIO.new(StringIO.new(@head), io, StringIO.new(@foot))
65
+ end
66
+
67
+ def build_head(boundary, name, filename, type, content_len, options = {})
68
+ io_opts = options[:io_opts]
69
+ trans_encoding = io_opts["Content-Transfer-Encoding"] || "binary"
70
+ content_disposition = io_opts["Content-Disposition"] || "form-data"
71
+
72
+ options[:headers] ||= {}
73
+
74
+ part = []
75
+ part << "--#{boundary}"
76
+ part << %(Content-Disposition: #{content_disposition}; name="#{name.to_s}"; filename="#{filename}")
77
+ part << "Content-Length: #{content_len}"
78
+ if content_id = io_opts["Content-ID"]
79
+ part << "Content-ID: #{content_id}"
80
+ end
81
+ part << "Content-Type: #{options[:headers].delete('Content-Type') || type}"
82
+ part << "Content-Transfer-Encoding: #{trans_encoding}"
83
+
84
+ options[:headers].each do |name, value|
85
+ part << "#{name}: #{value}"
86
+ end
87
+
88
+ part.join("\r\n") + "\r\n\r\n"
89
+ end
90
+ end
91
+
92
+ # Represents the epilogue or closing boundary.
93
+ class EpiloguePart
94
+ include Part
95
+ def initialize(boundary)
96
+ @part = "--#{boundary}--\r\n\r\n"
97
+ @io = StringIO.new(@part)
98
+ end
99
+ end
100
+ end