dyndrop 0.0.1 → 0.0.2

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