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.
- checksums.yaml +7 -0
- data/.travis.yml +1 -3
- data/Gemfile +1 -1
- data/LICENSE +27 -0
- data/README.md +8 -19
- data/lib/tent-client.rb +111 -51
- data/lib/tent-client/attachment.rb +13 -0
- data/lib/tent-client/cycle_http.rb +124 -14
- data/lib/tent-client/discovery.rb +59 -27
- data/lib/tent-client/faraday/chunked_adapter.rb +100 -0
- data/lib/tent-client/faraday/utils.rb +10 -0
- data/lib/tent-client/link_header.rb +5 -0
- data/lib/tent-client/middleware/authentication.rb +103 -0
- data/lib/tent-client/middleware/content_type_header.rb +24 -0
- data/lib/tent-client/middleware/encode_json.rb +5 -6
- data/lib/tent-client/multipart-post/parts.rb +100 -0
- data/lib/tent-client/post.rb +99 -38
- data/lib/tent-client/tent_type.rb +77 -0
- data/lib/tent-client/version.rb +1 -1
- data/spec/cycle_http_spec.rb +213 -0
- data/spec/discovery_spec.rb +114 -0
- data/spec/{unit/link_header_spec.rb → link_header_spec.rb} +10 -8
- data/spec/spec_helper.rb +7 -3
- data/spec/support/discovery_link_behaviour.rb +31 -0
- data/spec/timestamp_skew_spec.rb +126 -0
- data/tent-client.gemspec +11 -6
- metadata +75 -91
- data/.rspec +0 -1
- data/Guardfile +0 -6
- data/LICENSE.txt +0 -22
- data/lib/tent-client/app.rb +0 -33
- data/lib/tent-client/app_authorization.rb +0 -21
- data/lib/tent-client/follower.rb +0 -37
- data/lib/tent-client/following.rb +0 -31
- data/lib/tent-client/group.rb +0 -19
- data/lib/tent-client/middleware/accept_header.rb +0 -14
- data/lib/tent-client/middleware/mac_auth.rb +0 -50
- data/lib/tent-client/post_attachment.rb +0 -11
- data/lib/tent-client/profile.rb +0 -17
- data/spec/unit/cycle_http_spec.rb +0 -84
- data/spec/unit/discovery_spec.rb +0 -46
- 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
|
-
|
11
|
+
META_POST_REL = "https://tent.io/rels/meta-post".freeze
|
6
12
|
|
7
|
-
def
|
8
|
-
|
13
|
+
def self.discover(client, entity_uri, options = {})
|
14
|
+
new(client, entity_uri, :skip_serialization => false).discover(options)
|
9
15
|
end
|
10
16
|
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
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
|
19
|
-
|
20
|
-
|
21
|
-
|
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
|
-
|
24
|
-
|
25
|
-
res
|
26
|
-
|
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
|
-
|
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
|
-
|
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(
|
41
|
-
|
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] ==
|
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
|
52
|
-
return unless res['Content-Type']
|
53
|
-
Nokogiri::HTML(res.body).css(%(link[rel="#{
|
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
|
@@ -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
|
-
|
8
|
+
CONTENT_TYPE_HEADER = 'Content-Type'.freeze
|
9
9
|
|
10
10
|
dependency do
|
11
|
-
require '
|
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
|
-
::
|
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
|
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][
|
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
|