dyndrop 0.0.1 → 0.0.2

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.
data/lib/dyndrop.rb ADDED
@@ -0,0 +1,2 @@
1
+ require "dyndrop/version"
2
+ require "dyndrop/client"
@@ -0,0 +1,63 @@
1
+ module Dyndrop
2
+ class AuthToken
3
+ class << self
4
+ def from_token_info(token_info)
5
+ new(
6
+ token_info[:token],
7
+ token_info[:refresh_token]
8
+ )
9
+ end
10
+
11
+ def from_hash(hash)
12
+ new(
13
+ hash[:token],
14
+ hash[:refresh_token]
15
+ )
16
+ end
17
+ end
18
+
19
+ def initialize(auth_header, refresh_token = nil)
20
+ @auth_header = auth_header
21
+ @refresh_token = refresh_token
22
+ end
23
+
24
+ attr_accessor :auth_header
25
+ attr_reader :refresh_token
26
+
27
+ def to_hash
28
+ {
29
+ :token => auth_header,
30
+ :refresh_token => @refresh_token
31
+ }
32
+ end
33
+
34
+ JSON_HASH = /\{.*?\}/.freeze
35
+
36
+ # TODO: rename to #data
37
+ def token_data
38
+ return @token_data if @token_data
39
+ return {} unless @auth_header
40
+
41
+ json_hashes = Base64.decode64(@auth_header.split(" ", 2).last)
42
+ data_json = json_hashes.sub(JSON_HASH, "")[JSON_HASH]
43
+ return {} unless data_json
44
+
45
+ @token_data = MultiJson.load data_json, :symbolize_keys => true
46
+ rescue MultiJson::DecodeError
47
+ {}
48
+ end
49
+
50
+ def auth_header=(auth_header)
51
+ @token_data = nil
52
+ @auth_header = auth_header
53
+ end
54
+
55
+ def expiration
56
+ Time.at(token_data[:exp])
57
+ end
58
+
59
+ def expires_soon?
60
+ (expiration.to_i - Time.now.to_i) < 60
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,178 @@
1
+ require "dyndrop/trace_helpers"
2
+ require "net/https"
3
+ require "net/http/post/multipart"
4
+ require "multi_json"
5
+ require "fileutils"
6
+ require "forwardable"
7
+
8
+ module Dyndrop
9
+ class BaseClient # :nodoc:
10
+ extend Forwardable
11
+
12
+ attr_reader :rest_client
13
+
14
+ def_delegators :rest_client, :target, :target=, :token,
15
+ :proxy, :proxy=, :trace, :backtrace, :backtrace=,
16
+ :log, :log=
17
+
18
+ def initialize(target = "https://api.dyndrop.com", token = nil)
19
+ @rest_client = Dyndrop::RestClient.new(target, token)
20
+ self.trace = false
21
+ self.backtrace = false
22
+ self.log = false
23
+ end
24
+
25
+ def token=(token)
26
+ if token.is_a?(String)
27
+ token = Dyndrop::AuthToken.new(token)
28
+ end
29
+
30
+ @rest_client.token = token
31
+ end
32
+
33
+ def trace=(trace)
34
+ @rest_client.trace = trace
35
+ end
36
+
37
+ # Cloud metadata
38
+ def info
39
+ get("info", :accept => :json)
40
+ end
41
+
42
+ def get(*args)
43
+ request("GET", *args)
44
+ end
45
+
46
+ def delete(*args)
47
+ request("DELETE", *args)
48
+ end
49
+
50
+ def post(*args)
51
+ request("POST", *args)
52
+ end
53
+
54
+ def put(*args)
55
+ request("PUT", *args)
56
+ end
57
+
58
+ def request(method, *args)
59
+ if needs_token_refresh?
60
+ token.auth_header = nil
61
+ refresh_token!
62
+ end
63
+
64
+ path, options = normalize_arguments(args)
65
+ request, response = request_raw(method, path, options)
66
+ handle_response(response, options, request)
67
+ end
68
+
69
+ def request_raw(method, path, options)
70
+ @rest_client.request(method, path, options)
71
+ end
72
+
73
+ def stream_url(url, &blk)
74
+ uri = URI.parse(url)
75
+
76
+ Net::HTTP.start(uri.host, uri.port) do |http|
77
+ http.read_timeout = 5
78
+
79
+ req = Net::HTTP::Get.new(uri.request_uri)
80
+ req["Authorization"] = token.auth_header if token
81
+
82
+ http.request(req) do |response|
83
+ case response
84
+ when Net::HTTPOK
85
+ response.read_body(&blk)
86
+ when Net::HTTPNotFound
87
+ raise Dyndrop::NotFound.new(response.body, 404)
88
+ when Net::HTTPForbidden
89
+ raise Dyndrop::Denied.new(response.body, 403)
90
+ else
91
+ raise Dyndrop::BadResponse.new(response.body, response.code)
92
+ end
93
+ end
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ def needs_token_refresh?
100
+ token && token.auth_header && token.refresh_token && \
101
+ token.expires_soon?
102
+ end
103
+
104
+ def status_is_successful?(code)
105
+ (code >= 200) && (code < 400)
106
+ end
107
+
108
+ def handle_response(response, options, request)
109
+ if status_is_successful?(response[:status].to_i)
110
+ handle_successful_response(response, options)
111
+ else
112
+ handle_error_response(response, request)
113
+ end
114
+ end
115
+
116
+ def handle_successful_response(response, options)
117
+ if options[:return_response]
118
+ response
119
+ elsif options[:accept] == :json
120
+ parse_json(response[:body])
121
+ else
122
+ response[:body]
123
+ end
124
+ end
125
+
126
+ def handle_error_response(response, request)
127
+ body_json = parse_json(response[:body])
128
+ body_code = body_json && body_json[:code]
129
+ code = body_code || response[:status].to_i
130
+
131
+ if body_code
132
+ error_class = Dyndrop::APIError.error_classes[body_code] || Dyndrop::APIError
133
+ raise error_class.new(body_json[:description], body_code, request, response)
134
+ end
135
+
136
+ case code
137
+ when 404
138
+ raise Dyndrop::NotFound.new(nil, code, request, response)
139
+ when 403
140
+ raise Dyndrop::Denied.new(nil, code, request, response)
141
+ else
142
+ raise Dyndrop::BadResponse.new(nil, code, request, response)
143
+ end
144
+ end
145
+
146
+ def normalize_arguments(args)
147
+ if args.last.is_a?(Hash)
148
+ options = args.pop
149
+ else
150
+ options = {}
151
+ end
152
+
153
+ [normalize_path(args), options]
154
+ end
155
+
156
+ URI_ENCODING_PATTERN = Regexp.new("[^#{URI::PATTERN::UNRESERVED}]")
157
+
158
+ def normalize_path(segments)
159
+ if segments.size == 1 && segments.first =~ /^\//
160
+ segments.first
161
+ else
162
+ segments.flatten.collect { |x|
163
+ URI.encode(x.to_s, URI_ENCODING_PATTERN)
164
+ }.join("/")
165
+ end
166
+ end
167
+
168
+ def parse_json(x)
169
+ if x.empty?
170
+ raise MultiJson::DecodeError.new("Empty JSON string", [], "")
171
+ else
172
+ MultiJson.load(x, :symbolize_keys => true)
173
+ end
174
+ rescue MultiJson::DecodeError
175
+ nil
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,18 @@
1
+ require "dyndrop/baseclient"
2
+ require "dyndrop/rest_client"
3
+ require "dyndrop/auth_token"
4
+
5
+ require "dyndrop/v1/base"
6
+ require "dyndrop/v1/client"
7
+
8
+ module Dyndrop
9
+ class Client < BaseClient
10
+ def self.new(*args)
11
+ target, _ = args
12
+
13
+ base = super(target)
14
+
15
+ Dyndrop::V1::Client.new(*args)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,152 @@
1
+ require "net/https"
2
+ require "multi_json"
3
+ require "yaml"
4
+
5
+ module Dyndrop
6
+ # Base class for Dyndrop errors (not from the server).
7
+ class Error < RuntimeError
8
+ end
9
+
10
+ class Deprecated < Error
11
+ end
12
+
13
+ class Mismatch < Error
14
+ def initialize(expected, got)
15
+ @expected = expected
16
+ @got = got
17
+ end
18
+
19
+ def to_s
20
+ "Invalid value type; expected #{@expected.inspect}, got #{@got.inspect}"
21
+ end
22
+ end
23
+
24
+ class InvalidTarget < Error
25
+ attr_reader :target
26
+
27
+ def initialize(target)
28
+ @target = target
29
+ end
30
+
31
+ def to_s
32
+ "Invalid target URI: #{@target}"
33
+ end
34
+ end
35
+
36
+ class TargetRefused < Error
37
+ # Error message.
38
+ attr_reader :message
39
+
40
+ # Message varies as this represents various network errors.
41
+ def initialize(message)
42
+ @message = message
43
+ end
44
+
45
+ # Exception message.
46
+ def to_s
47
+ "target refused connection (#@message)"
48
+ end
49
+ end
50
+
51
+ class Timeout < Timeout::Error
52
+ attr_reader :method, :uri, :parent
53
+
54
+ def initialize(method, uri, parent = nil)
55
+ @method = method
56
+ @uri = uri
57
+ @parent = parent
58
+ super(to_s)
59
+ end
60
+
61
+ def to_s
62
+ "#{method} #{uri} timed out"
63
+ end
64
+ end
65
+
66
+ # Exception representing errors returned by the API.
67
+ class APIError < RuntimeError
68
+ include TraceHelpers
69
+
70
+ class << self
71
+ def error_classes
72
+ @error_classes ||= {}
73
+ end
74
+ end
75
+
76
+ attr_reader :error_code, :description, :request, :response
77
+
78
+ # Create an APIError with a given request and response.
79
+ def initialize(description = nil, error_code = nil, request = nil, response = nil)
80
+ @response = response
81
+ @request = request
82
+ @error_code = error_code || (response ? response[:status] : nil)
83
+ @description = description || parse_description
84
+ end
85
+
86
+ # Exception message.
87
+ def to_s
88
+ "#{error_code}: #{description}"
89
+ end
90
+
91
+ def request_trace
92
+ super(request)
93
+ end
94
+
95
+ def response_trace
96
+ super(response)
97
+ end
98
+
99
+ private
100
+
101
+ def parse_description
102
+ return unless response
103
+
104
+ parse_json(response[:body])[:description]
105
+ rescue MultiJson::DecodeError
106
+ response[:body]
107
+ end
108
+
109
+ def parse_json(x)
110
+ if x.empty?
111
+ raise MultiJson::DecodeError.new("Empty JSON string", [], "")
112
+ else
113
+ MultiJson.load(x, :symbolize_keys => true)
114
+ end
115
+ end
116
+ end
117
+
118
+ class NotFound < APIError
119
+ end
120
+
121
+ class Denied < APIError
122
+ end
123
+
124
+ class BadResponse < APIError
125
+ end
126
+
127
+ class UAAError < APIError
128
+ end
129
+
130
+ def self.define_error(class_name, code)
131
+ base =
132
+ case class_name
133
+ when /NotFound$/
134
+ NotFound
135
+ else
136
+ APIError
137
+ end
138
+
139
+ klass =
140
+ if const_defined?(class_name)
141
+ const_get(class_name)
142
+ else
143
+ Class.new(base)
144
+ end
145
+
146
+ APIError.error_classes[code] = klass
147
+
148
+ unless const_defined?(class_name)
149
+ const_set(class_name, klass)
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,299 @@
1
+ require "dyndrop/trace_helpers"
2
+ require "net/https"
3
+ require "net/http/post/multipart"
4
+ require "multi_json"
5
+ require "fileutils"
6
+
7
+ module Dyndrop
8
+ class RestClient
9
+ include Dyndrop::TraceHelpers
10
+
11
+ LOG_LENGTH = 10
12
+
13
+ HTTP_METHODS = {
14
+ "GET" => Net::HTTP::Get,
15
+ "PUT" => Net::HTTP::Put,
16
+ "POST" => Net::HTTP::Post,
17
+ "DELETE" => Net::HTTP::Delete,
18
+ "HEAD" => Net::HTTP::Head,
19
+ }
20
+
21
+ DEFAULT_OPTIONS = {
22
+ :follow_redirects => true
23
+ }
24
+
25
+ attr_reader :target
26
+
27
+ attr_accessor :trace, :backtrace, :log, :request_id, :token, :target, :proxy
28
+
29
+ def initialize(target, token = nil)
30
+ @target = target
31
+ @token = token
32
+ @trace = false
33
+ @backtrace = false
34
+ @log = false
35
+ end
36
+
37
+ def request(method, path, options = {})
38
+ request_uri(method, construct_url(path), DEFAULT_OPTIONS.merge(options))
39
+ end
40
+
41
+ def generate_headers(payload, options)
42
+ headers = {}
43
+
44
+ if payload.is_a?(String)
45
+ headers["Content-Length"] = payload.size
46
+ elsif !payload
47
+ headers["Content-Length"] = 0
48
+ end
49
+
50
+ headers["X-Request-Id"] = @request_id if @request_id
51
+ headers["Authorization"] = @token.auth_header if @token
52
+ headers["Proxy-User"] = @proxy if @proxy
53
+
54
+ if accept_type = mimetype(options[:accept])
55
+ headers["Accept"] = accept_type
56
+ end
57
+
58
+ if content_type = mimetype(options[:content])
59
+ headers["Content-Type"] = content_type
60
+ end
61
+
62
+ headers.merge!(options[:headers]) if options[:headers]
63
+ headers
64
+ end
65
+
66
+ private
67
+
68
+ def request_uri(method, uri, options = {})
69
+ uri = URI.parse(uri)
70
+
71
+ unless uri.is_a?(URI::HTTP)
72
+ raise InvalidTarget.new(@target)
73
+ end
74
+
75
+ # keep original options in case there's a redirect to follow
76
+ original_options = options.dup
77
+ payload = options[:payload]
78
+
79
+ if params = options[:params]
80
+ if uri.query
81
+ uri.query += "&" + encode_params(params)
82
+ else
83
+ uri.query = encode_params(params)
84
+ end
85
+ end
86
+
87
+ unless payload.is_a?(String)
88
+ case options[:content]
89
+ when :json
90
+ payload = MultiJson.dump(payload)
91
+ when :form
92
+ payload = encode_params(payload)
93
+ end
94
+ end
95
+
96
+ method_class = get_method_class(method)
97
+ if payload.is_a?(Hash)
98
+ multipart = method_class.const_get(:Multipart)
99
+ request = multipart.new(uri.request_uri, payload)
100
+ else
101
+ request = method_class.new(uri.request_uri)
102
+ request.body = payload if payload
103
+ end
104
+
105
+ headers = generate_headers(payload, options)
106
+
107
+ request_hash = {
108
+ :url => uri.to_s,
109
+ :method => method,
110
+ :headers => headers,
111
+ :body => payload
112
+ }
113
+
114
+ print_request(request_hash) if @trace
115
+
116
+ add_headers(request, headers)
117
+
118
+ # TODO: test http proxies
119
+ http = Net::HTTP.new(uri.host, uri.port)
120
+
121
+ # TODO remove this when staging returns streaming responses
122
+ http.read_timeout = 300
123
+
124
+ if uri.is_a?(URI::HTTPS)
125
+ http.use_ssl = true
126
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
127
+ end
128
+
129
+ before = Time.now
130
+ http.start do
131
+ response = http.request(request)
132
+ time = Time.now - before
133
+
134
+ response_hash = {
135
+ :headers => sane_headers(response),
136
+ :status => response.code,
137
+ :body => response.body
138
+ }
139
+
140
+ print_response(response_hash) if @trace
141
+ print_backtrace(caller) if @trace
142
+
143
+ log_request(time, request, response)
144
+
145
+ if response.is_a?(Net::HTTPRedirection) && options[:follow_redirects]
146
+ request_uri("GET", response["location"], original_options)
147
+ else
148
+ return request_hash, response_hash
149
+ end
150
+ end
151
+ rescue ::Timeout::Error => e
152
+ raise Timeout.new(method, uri, e)
153
+ rescue SocketError, Errno::ECONNREFUSED => e
154
+ raise TargetRefused, e.message
155
+ rescue URI::InvalidURIError
156
+ raise InvalidTarget.new(@target)
157
+ end
158
+
159
+ def construct_url(path)
160
+ uri = URI.parse(path)
161
+ return path if uri.scheme
162
+
163
+ path = "/#{path}" unless path[0] == ?\/
164
+ target + path
165
+ end
166
+
167
+ def get_method_class(method_string)
168
+ HTTP_METHODS[method_string.upcase]
169
+ end
170
+
171
+ def add_headers(request, headers)
172
+ headers.each { |key, value| request[key] = value }
173
+ end
174
+
175
+ def mimetype(content)
176
+ case content
177
+ when String
178
+ content
179
+ when :json
180
+ "application/json"
181
+ when :form
182
+ "application/x-www-form-urlencoded"
183
+ when nil
184
+ nil
185
+ # return request headers (not really Accept)
186
+ else
187
+ raise Dyndrop::Error, "Unknown mimetype '#{content.inspect}'"
188
+ end
189
+ end
190
+
191
+ def encode_params(hash, escape = true)
192
+ hash.keys.map do |k|
193
+ v = hash[k]
194
+ v = MultiJson.dump(v) if v.is_a?(Hash)
195
+ v = URI.escape(v.to_s, /[^#{URI::PATTERN::UNRESERVED}]/) if escape
196
+ "#{k}=#{v}"
197
+ end.join("&")
198
+ end
199
+
200
+ def log_data(time, request, response)
201
+ { :time => time,
202
+ :request => {
203
+ :method => request.method,
204
+ :url => request.path,
205
+ :headers => sane_headers(request)
206
+ },
207
+ :response => {
208
+ :code => response.code,
209
+ :headers => sane_headers(response)
210
+ }
211
+ }
212
+ end
213
+
214
+ def log_line(io, data)
215
+ io.printf(
216
+ "[%s] %0.3fs %6s -> %d %s\n",
217
+ Time.now.strftime("%F %T"),
218
+ data[:time],
219
+ data[:request][:method].to_s.upcase,
220
+ data[:response][:code],
221
+ data[:request][:url])
222
+ end
223
+
224
+ def log_request(time, request, response)
225
+ return unless @log
226
+
227
+ data = log_data(time, request, response)
228
+
229
+ case @log
230
+ when IO
231
+ log_line(@log, data)
232
+ return
233
+ when String
234
+ if File.exists?(@log)
235
+ log = File.readlines(@log).last(LOG_LENGTH - 1)
236
+ elsif !File.exists?(File.dirname(@log))
237
+ FileUtils.mkdir_p(File.dirname(@log))
238
+ end
239
+
240
+ File.open(@log, "w") do |io|
241
+ log.each { |l| io.print l } if log
242
+ log_line(io, data)
243
+ end
244
+
245
+ return
246
+ end
247
+
248
+ if @log.respond_to?(:call)
249
+ @log.call(data)
250
+ return
251
+ end
252
+
253
+ if @log.respond_to?(:<<)
254
+ @log << data
255
+ return
256
+ end
257
+ end
258
+
259
+ def print_request(request)
260
+ $stderr.puts ">>>"
261
+ $stderr.puts request_trace(request)
262
+ end
263
+
264
+ def print_response(response)
265
+ $stderr.puts response_trace(response)
266
+ $stderr.puts "<<<"
267
+ end
268
+
269
+ def print_backtrace(locs)
270
+ return unless @backtrace
271
+
272
+ interesting_locs = locs.drop_while { |loc|
273
+ loc =~ /\/(dyndrop\/|restclient\/|net\/http)/
274
+ }
275
+
276
+ $stderr.puts "--- backtrace:"
277
+
278
+ $stderr.puts "... (boring)" unless locs == interesting_locs
279
+
280
+ trimmed_locs = interesting_locs[0..5]
281
+
282
+ trimmed_locs.each do |loc|
283
+ $stderr.puts "=== #{loc}"
284
+ end
285
+
286
+ $stderr.puts "... (trimmed)" unless trimmed_locs == interesting_locs
287
+ end
288
+
289
+ def sane_headers(obj)
290
+ hds = {}
291
+
292
+ obj.each_header do |k, v|
293
+ hds[k] = v
294
+ end
295
+
296
+ hds
297
+ end
298
+ end
299
+ end