analysand 1.1.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGELOG CHANGED
@@ -1,6 +1,14 @@
1
1
  Issue numbers refer to issues on Analysand's Github tracker:
2
2
  https://github.com/yipdw/analysand/issues
3
3
 
4
+ 2.0.0 (2012-11-29)
5
+ ------------------
6
+ * Instance#establish_session and Instance#renew_session now return a (session,
7
+ Analysand::Response pair)
8
+ * Share HTTP code between Database and Instance
9
+ * Session handling on Instance rewritten: #post_session, #get_session
10
+ * New response methods: #cookies, #session_cookie
11
+
4
12
  1.1.0 (2012-11-03)
5
13
  ------------------
6
14
 
@@ -9,7 +17,6 @@ https://github.com/yipdw/analysand/issues
9
17
  * Some code organization cleanups
10
18
  * require "analysand" now loads the Database and Instance classes
11
19
 
12
-
13
20
  1.0.1 (2012-10-01)
14
21
  ------------------
15
22
 
data/Rakefile CHANGED
@@ -5,4 +5,18 @@ require 'rspec/core/rake_task'
5
5
 
6
6
  RSpec::Core::RakeTask.new
7
7
 
8
+ namespace :git do
9
+ desc 'Strip trailing whitespace from tracked source files'
10
+ task :strip_spaces do
11
+ `git ls-files`.split("\n").each do |file|
12
+ puts file
13
+
14
+ if `file '#{file}'` =~ /text/
15
+ sh "git stripspace < '#{file}' > '#{file}.out'"
16
+ mv "#{file}.out", file
17
+ end
18
+ end
19
+ end
20
+ end
21
+
8
22
  task :default => :spec
@@ -81,7 +81,7 @@ module Analysand
81
81
  def initialize(database)
82
82
  @db = database
83
83
  @waiting = {}
84
- @http_parser = Http::Parser.new(self)
84
+ @http_parser = ::Http::Parser.new(self)
85
85
  @json_parser = Yajl::Parser.new
86
86
  @json_parser.on_parse_complete = lambda { |doc| process(doc) }
87
87
 
@@ -0,0 +1,24 @@
1
+ require 'analysand/response'
2
+
3
+ module Analysand
4
+ # Public: Wraps responses from /_config.
5
+ #
6
+ # Not all responses from sub-resources of _config return valid JSON objects,
7
+ # but JSON.parse expects to see a full JSON object. This object implements a
8
+ # bit of a hacky workaround if the body does not start with a '{' and end
9
+ # with a '}', then it is not run through the JSON parser.
10
+ class ConfigResponse < Response
11
+ alias_method :value, :body
12
+
13
+ def initialize(response)
14
+ body = response.body.chomp
15
+
16
+ if body.start_with?('{') && body.end_with?('}')
17
+ super
18
+ else
19
+ @response = response
20
+ @body = body
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,11 +1,9 @@
1
1
  require 'analysand/errors'
2
+ require 'analysand/http'
2
3
  require 'analysand/reading'
3
4
  require 'analysand/response'
4
5
  require 'analysand/viewing'
5
6
  require 'analysand/writing'
6
- require 'net/http/persistent'
7
- require 'rack/utils'
8
- require 'uri'
9
7
 
10
8
  module Analysand
11
9
  ##
@@ -249,7 +247,13 @@ module Analysand
249
247
  # as a cookie from CouchDB's Session API. The string is used as the
250
248
  # value of a Cookie header.
251
249
  #
252
- # To get a token, use a CouchDB::Instance (ahem) instance.
250
+ # There are two ways to retrieve a token:
251
+ #
252
+ # 1. Establishing a session. You can use
253
+ # Analysand::Instance#establish_sesssion for this.
254
+ # 2. CouchDB may also issue updated session cookies as part of a response.
255
+ # You can access such cookies using #session_cookie on the response
256
+ # object.
253
257
  #
254
258
  # Omitting the credentials argument, or providing a form of credentials not
255
259
  # listed here, will result in no credentials being passed in the request.
@@ -263,13 +267,15 @@ module Analysand
263
267
  # connection per (uri.host, uri.port, thread) tuple, so connection pooling
264
268
  # is also done.
265
269
  class Database
266
- include Rack::Utils
270
+ include Errors
271
+ include Http
267
272
  include Reading
268
273
  include Viewing
269
274
  include Writing
270
275
 
271
- attr_reader :http
272
- attr_reader :uri
276
+ def initialize(uri)
277
+ init_http_client(uri)
278
+ end
273
279
 
274
280
  def self.create!(uri, credentials = nil)
275
281
  new(uri).tap { |db| db.create!(credentials) }
@@ -279,19 +285,6 @@ module Analysand
279
285
  new(uri).drop(credentials)
280
286
  end
281
287
 
282
- def initialize(uri)
283
- raise InvalidURIError, 'You must supply an absolute URI' unless uri.absolute?
284
-
285
- @http = Net::HTTP::Persistent.new('analysand_database')
286
- @uri = uri
287
-
288
- # Document IDs and other database bits are appended to the URI path,
289
- # so we need to make sure that it ends in a /.
290
- unless uri.path.end_with?('/')
291
- uri.path += '/'
292
- end
293
- end
294
-
295
288
  def ping(credentials = nil)
296
289
  Response.new _get('', credentials)
297
290
  end
@@ -300,10 +293,6 @@ module Analysand
300
293
  ping(credentials).body
301
294
  end
302
295
 
303
- def close
304
- http.shutdown
305
- end
306
-
307
296
  def create(credentials = nil)
308
297
  Response.new _put('', credentials)
309
298
  end
@@ -324,59 +313,9 @@ module Analysand
324
313
  end
325
314
  end
326
315
 
327
- %w(Head Get Put Post Delete Copy).each do |m|
328
- str = <<-END
329
- def _#{m.downcase}(doc_id, credentials, query = {}, headers = {}, body = nil, block = nil)
330
- _req(Net::HTTP::#{m}, doc_id, credentials, query, headers, body, block)
331
- end
332
- END
333
-
334
- class_eval str, __FILE__, __LINE__
335
- end
336
-
337
- ##
338
- # @private
339
- def _req(klass, doc_id, credentials, query, headers, body, block)
340
- uri = self.uri.dup
341
- uri.path += URI.escape(doc_id)
342
- uri.query = build_query(query) unless query.empty?
343
-
344
- req = klass.new(uri.request_uri)
345
-
346
- headers.each { |k, v| req.add_field(k, v) }
347
- req.body = body if body && req.request_body_permitted?
348
- set_credentials(req, credentials)
349
-
350
- http.request(uri, req, &block)
351
- end
352
-
353
- ##
354
- # Sets credentials on a request object.
355
- #
356
- # If creds is a hash containing :username and :password keys, HTTP basic
357
- # authorization is used. If creds is a string, the string is added as a
358
- # cookie.
359
- def set_credentials(req, creds)
360
- return unless creds
361
-
362
- if String === creds
363
- req.add_field('Cookie', creds)
364
- elsif creds[:username] && creds[:password]
365
- req.basic_auth(creds[:username], creds[:password])
366
- end
367
- end
368
-
369
316
  def json_headers
370
317
  { 'Content-Type' => 'application/json' }
371
318
  end
372
-
373
- ##
374
- # @private
375
- def ex(klass, response)
376
- klass.new("Expected response to have code 2xx, got #{response.code} instead").tap do |ex|
377
- ex.response = response
378
- end
379
- end
380
319
  end
381
320
  end
382
321
 
@@ -1,4 +1,17 @@
1
1
  module Analysand
2
+ # Private: Methods to generate exceptions.
3
+ module Errors
4
+ # Instantiates an exception and fills in a response.
5
+ #
6
+ # klass - the exception class
7
+ # response - the response object that caused the error
8
+ def ex(klass, response)
9
+ klass.new("Expected response to have code 2xx, got #{response.code} instead").tap do |ex|
10
+ ex.response = response
11
+ end
12
+ end
13
+ end
14
+
2
15
  class InvalidURIError < StandardError
3
16
  end
4
17
 
@@ -6,6 +19,9 @@ module Analysand
6
19
  attr_accessor :response
7
20
  end
8
21
 
22
+ class ConfigurationNotSaved < DatabaseError
23
+ end
24
+
9
25
  class DocumentNotSaved < DatabaseError
10
26
  end
11
27
 
@@ -0,0 +1,80 @@
1
+ require 'net/http/persistent'
2
+ require 'rack/utils'
3
+ require 'uri'
4
+
5
+ module Analysand
6
+ # Private: HTTP client methods for Database and Instance.
7
+ #
8
+ # Users of this module MUST set @http and @uri in their initializer. @http
9
+ # SHOULD be a Net::HTTP::Persistent instance, and @uri SHOULD be a URI
10
+ # instance.
11
+ module Http
12
+ include Rack::Utils
13
+
14
+ attr_reader :http
15
+ attr_reader :uri
16
+
17
+ def init_http_client(uri)
18
+ unless uri.respond_to?(:path) && uri.respond_to?(:absolute?)
19
+ uri = URI(uri)
20
+ end
21
+
22
+ raise InvalidURIError, 'You must supply an absolute URI' unless uri.absolute?
23
+
24
+ @http = Net::HTTP::Persistent.new('analysand')
25
+ @uri = uri
26
+
27
+ # Document IDs and other database bits are appended to the URI path,
28
+ # so we need to make sure that it ends in a /.
29
+ unless uri.path.end_with?('/')
30
+ uri.path += '/'
31
+ end
32
+ end
33
+
34
+ def close
35
+ http.shutdown
36
+ end
37
+
38
+ %w(Head Get Put Post Delete Copy).each do |m|
39
+ str = <<-END
40
+ def _#{m.downcase}(doc_id, credentials, query = {}, headers = {}, body = nil, block = nil)
41
+ _req(Net::HTTP::#{m}, doc_id, credentials, query, headers, body, block)
42
+ end
43
+ END
44
+
45
+ module_eval str, __FILE__, __LINE__
46
+ end
47
+
48
+ ##
49
+ # @private
50
+ def _req(klass, doc_id, credentials, query, headers, body, block)
51
+ uri = self.uri.dup
52
+ uri.path += URI.escape(doc_id)
53
+ uri.query = build_query(query) unless query.empty?
54
+
55
+ req = klass.new(uri.request_uri)
56
+
57
+ headers.each { |k, v| req.add_field(k, v) }
58
+ req.body = body if body && req.request_body_permitted?
59
+ set_credentials(req, credentials)
60
+
61
+ http.request(uri, req, &block)
62
+ end
63
+
64
+ ##
65
+ # Sets credentials on a request object.
66
+ #
67
+ # If creds is a hash containing :username and :password keys, HTTP basic
68
+ # authorization is used. If creds is a string, the string is added as a
69
+ # cookie.
70
+ def set_credentials(req, creds)
71
+ return unless creds
72
+
73
+ if String === creds
74
+ req.add_field('Cookie', creds)
75
+ elsif creds[:username] && creds[:password]
76
+ req.basic_auth(creds[:username], creds[:password])
77
+ end
78
+ end
79
+ end
80
+ end
@@ -1,7 +1,11 @@
1
+ require 'analysand/config_response'
1
2
  require 'analysand/errors'
3
+ require 'analysand/http'
4
+ require 'analysand/response'
5
+ require 'analysand/session_response'
2
6
  require 'base64'
3
- require 'json/ext'
4
7
  require 'net/http/persistent'
8
+ require 'rack/utils'
5
9
  require 'uri'
6
10
 
7
11
  module Analysand
@@ -34,119 +38,160 @@ module Analysand
34
38
  # Establishing a session
35
39
  # ----------------------
36
40
  #
37
- # session, resp = instance.establish_session('username', 'password')
38
- # # for correct credentials:
39
- # # => [ {
40
- # # :issued_at => (a UNIX timestamp),
41
- # # :roles => [...roles...],
42
- # # :token => 'AuthSession ...',
43
- # # :username => (the supplied username)
44
- # # },
45
- # # the response
46
- # # ]
47
- # #
48
- # # for incorrect credentials:
49
- # # => [nil, the response]
41
+ # resp, = instance.post_session('username', 'password')
42
+ # cookie = resp.session_cookie
50
43
  #
51
- # The value in :token should be supplied as a cookie on subsequent requests,
52
- # and can be passed as a credential when using Analysand::Database
53
- # methods, e.g.
44
+ # For harmony, the same credentials hash accepted by database methods is
45
+ # also supported:
54
46
  #
55
- # db = Analysand::Database.new(...)
56
- # session, resp = instance.establish_session(username, password)
47
+ # resp = instance.post_session(:username => 'username',
48
+ # :password => 'password')
57
49
  #
58
- # db.put(doc, session[:token])
59
50
  #
51
+ # resp.success? will be true if the session cookie is not empty, false
52
+ # otherwise.
60
53
  #
61
- # Renewing a session
62
- # ------------------
63
54
  #
64
- # auth, _ = instance.establish_session('username', 'password')
65
- # # ...time passes...
66
- # session, resp = instance.renew_session(auth)
55
+ # Testing a session cookie for validity
56
+ # -------------------------------------
67
57
  #
68
- # Note: CouchDB doesn't always renew a session when asked; see the
69
- # documentation for #renew_session for more details.
58
+ # resp = instance.get_session(cookie)
70
59
  #
60
+ # In CouchDB 1.2.0, the response body is a JSON object that looks like
61
+ #
62
+ # {
63
+ # "info": {
64
+ # "authentication_db": "_users",
65
+ # "authentication_handlers": [
66
+ # "oauth",
67
+ # "cookie",
68
+ # "default"
69
+ # ]
70
+ # },
71
+ # "ok": true,
72
+ # "userCtx": {
73
+ # "name": "username",
74
+ # "roles": ["member"]
75
+ # }
76
+ # }
77
+ #
78
+ # resp.valid? will be true if userCtx['name'] is non-null, false otherwise.
79
+ #
80
+ #
81
+ # Getting and setting instance configuration
82
+ # ------------------------------------------
83
+ #
84
+ # v = instance.get_config('couchdb_httpd_auth/allow_persistent_cookies',
85
+ # credentials)
86
+ # v.value # => false
87
+ #
88
+ # instance.set_config('couchdb_httpd_auth/allow_persistent_cookies',
89
+ # true, credentials)
90
+ # # => #<Response code=200 ...>
91
+ #
92
+ # v = instance.get_config('couchdb_httpd_auth/allow_persistent_cookies',
93
+ # credentials)
94
+ # v.value #=> true
95
+ #
96
+ # You can get configuration at any level:
97
+ #
98
+ # v = instance.get_config('', credentials)
99
+ # v.body['stats']['rate'] # => "1000", or whatever you have it set to
100
+ #
101
+ # #get_config and #set_config both return Response-like objects. You can
102
+ # check for failure or success that way:
103
+ #
104
+ # v = instance.get_config('couchdb_httpd_auth/allow_persistent_cookies')
105
+ # v.code # => '403'
106
+ #
107
+ # instance.set_config('couchdb_httpd_auth/allow_persistent_cookies', false)
108
+ # # => #<Response code=403 ...>
109
+ #
110
+ # If you want to set configuration and just want to let errors bubble
111
+ # up the stack, you can use the bang-variants:
112
+ #
113
+ # instance.set_config!('stats/rate', 1000)
114
+ # # => on non-2xx response, raises ConfigurationNotSaved
71
115
  class Instance
72
- attr_reader :http
73
- attr_reader :uri
116
+ include Errors
117
+ include Http
118
+ include Rack::Utils
74
119
 
75
120
  def initialize(uri)
76
- raise InvalidURIError, 'You must supply an absolute URI' unless uri.absolute?
121
+ init_http_client(uri)
122
+ end
77
123
 
78
- @http = Net::HTTP::Persistent.new('catalog_database')
79
- @uri = uri
124
+ def post_session(*args)
125
+ username, password = if args.length == 2
126
+ args
127
+ else
128
+ h = args.first
129
+ [h[:username], h[:password]]
130
+ end
131
+
132
+ headers = { 'Content-Type' => 'application/x-www-form-urlencoded' }
133
+ body = build_query('name' => username, 'password' => password)
134
+
135
+ Response.new _post('_session', nil, {}, headers, body)
80
136
  end
81
137
 
82
- def establish_session(username, password)
83
- path = uri + "/_session"
138
+ def get_session(cookie)
139
+ headers = { 'Cookie' => cookie }
84
140
 
85
- req = Net::HTTP::Post.new(path.to_s)
86
- req.add_field('Content-Type', 'application/x-www-form-urlencoded')
87
- req.body =
88
- "name=#{URI.encode(username)}&password=#{URI.encode(password)}"
141
+ SessionResponse.new _get('_session', nil, {}, headers, nil)
142
+ end
89
143
 
90
- resp = http.request path, req
144
+ def get_config(key, credentials = nil)
145
+ ConfigResponse.new _get("_config/#{key}", credentials)
146
+ end
91
147
 
92
- if Net::HTTPSuccess === resp
93
- [session(resp), resp]
94
- else
95
- [nil, resp]
96
- end
148
+ def set_config(key, value, credentials = nil)
149
+ # This is a bizarre transformation that deserves some explanation.
150
+ #
151
+ # CouchDB configuration is made available as strings containing JSON
152
+ # data. GET /_config/stats, for example, will return something like
153
+ # this:
154
+ #
155
+ # {"rate":"1000","samples":"[0, 60, 300, 900]"}
156
+ #
157
+ # However, I'd really like to write
158
+ #
159
+ # instance.set_config('stats/samples', [0, 60, 300, 900])
160
+ #
161
+ # and I'd also like to be able to use values from get_config directly,
162
+ # just for symmetry:
163
+ #
164
+ # v = instance1.get_config('stats/samples')
165
+ # instance2.set_config('stats/samples', v)
166
+ #
167
+ # To accomplish this, we convert non-string values to JSON twice.
168
+ # Strings are passed through.
169
+ body = (String === value) ? value : value.to_json.to_json
170
+
171
+ ConfigResponse.new _put("_config/#{key}", credentials, {}, {}, body)
97
172
  end
98
173
 
99
- ##
100
- # Attempts to renew a session.
101
- #
102
- # If the session was renewed, returns a session information hash identical
103
- # in form to the hash returned by #establish_session. If the session was
104
- # not renewed, returns the passed-in hash.
105
- #
106
- #
107
- # Renewal behavior
108
- # ================
109
- #
110
- # CouchDB will only send a new session cookie if the current time is
111
- # close enough to the session timeout. For CouchDB, that means that the
112
- # current time must be within a 10% timeout window (i.e. time left before
113
- # timeout < timeout * 0.9).
114
- def renew_session(old_session)
115
- path = uri + "/_session"
116
-
117
- req = Net::HTTP::Get.new(path.to_s)
118
- req.add_field('Cookie', old_session[:token])
119
-
120
- resp = http.request path, req
121
-
122
- if Net::HTTPSuccess === resp
123
- if !resp.get_fields('Set-Cookie')
124
- [old_session, resp]
125
- else
126
- [session(resp), resp]
127
- end
128
- else
129
- [nil, resp]
174
+ def set_config!(key, value, credentials = nil)
175
+ set_config(key, value, credentials).tap do |resp|
176
+ raise ex(ConfigurationNotSaved, resp) unless resp.success?
130
177
  end
131
178
  end
132
179
 
133
180
  private
134
181
 
135
- def session(resp)
136
- token = resp.get_fields('Set-Cookie').
137
- detect { |c| c =~ /^AuthSession=([^;]+)/i }
182
+ def session(cookie, resp)
183
+ token = cookie.split('=', 2).last
184
+ fields = Base64.decode64(token).split(':')
138
185
 
139
- fields = Base64.decode64($1).split(':')
140
186
  username = fields[0]
141
187
  time = fields[1].to_i(16)
142
188
 
143
- body = JSON.parse(resp.body)
144
- roles = body.has_key?('userCtx') ?
145
- body['userCtx']['roles'] : body['roles']
189
+ roles = resp.body.has_key?('userCtx') ?
190
+ resp.body['userCtx']['roles'] : resp.body['roles']
146
191
 
147
192
  { :issued_at => time,
148
193
  :roles => roles,
149
- :token => token,
194
+ :token => cookie,
150
195
  :username => username
151
196
  }
152
197
  end