analysand 1.1.0 → 2.0.0

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