tent-client 0.0.1 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
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