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.
- 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
|