analysand 1.0.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.
@@ -0,0 +1,46 @@
1
+ require 'celluloid/logger'
2
+
3
+ module Analysand
4
+ module ConnectionTesting
5
+ include Celluloid::Logger
6
+
7
+ ##
8
+ # Issues a HEAD request to the given URI. If it responds with a success or
9
+ # redirection code, returns true; otherwise, returns false.
10
+ def test_http_connection(uri)
11
+ begin
12
+ resp = Net::HTTP.start(uri.host, uri.port) { |h| h.head(uri.request_uri) }
13
+
14
+ case resp
15
+ when Net::HTTPSuccess then true
16
+ when Net::HTTPRedirection then true
17
+ else
18
+ error "Expected HEAD #{uri.to_s} to return 200, got #{resp.code} (#{resp.body}) instead"
19
+ false
20
+ end
21
+ rescue => e
22
+ error "#{e.class} (#{e.message}) caught while attempting connection to #{uri.to_s}"
23
+ error e.backtrace.join("\n")
24
+ false
25
+ end
26
+ end
27
+
28
+ ##
29
+ # Periodically checks a URI for success using test_http_connection, and
30
+ # raises an error if test_http_connection does not return success before
31
+ # the timeout is reached.
32
+ def wait_for_http_service(uri, timeout = 30)
33
+ state = 1.upto(timeout) do
34
+ if test_http_connection(Catalog::Settings.solr_uri)
35
+ break :started
36
+ else
37
+ sleep 1
38
+ end
39
+ end
40
+
41
+ unless state == :started
42
+ raise "#{uri.to_s} took longer than #{timeout} seconds to return a success response"
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,492 @@
1
+ require 'analysand/bulk_response'
2
+ require 'analysand/errors'
3
+ require 'analysand/response'
4
+ require 'analysand/view_response'
5
+ require 'net/http/persistent'
6
+ require 'rack/utils'
7
+
8
+ module Analysand
9
+ ##
10
+ # A wrapper around a CouchDB database in a CouchDB instance.
11
+ #
12
+ # Databases MUST be identified by an absolute URI; instantiating this class
13
+ # with a relative URI will raise an exception.
14
+ #
15
+ #
16
+ # Common tasks
17
+ # ============
18
+ #
19
+ # Creating a database
20
+ # -------------------
21
+ #
22
+ # vdb = Analysand::Database.create!('http://localhost:5984/videos/',
23
+ # credentials)
24
+ #
25
+ # If the database was successfully created, you'll get back a
26
+ # Analysand::Database instance. If database creation failed, a
27
+ # DatabaseError containing a CouchDB response will be raised.
28
+ #
29
+ # You can also instantiate a database and then create it:
30
+ #
31
+ # vdb = Analysand::Database.new('http://localhost:5984/videos')
32
+ # vdb.create(credentials) # => #<Response ...>
33
+ #
34
+ #
35
+ # Dropping a database
36
+ # -------------------
37
+ #
38
+ # Analysand::Database.drop('http://localhost:5984/videos',
39
+ # credentials)
40
+ #
41
+ # # => #<Response code=200 ...>
42
+ # # => #<Response code=401 ...>
43
+ # # => #<Response code=404 ...>
44
+ #
45
+ # You can also instantiate a database and then drop it:
46
+ #
47
+ # db = Analysand::Database.new('http://localhost:5984/videos')
48
+ # db.drop # => #<Response ...>
49
+ #
50
+ # You can also use #drop!, which will raise Analysand::CannotDropDatabase
51
+ # on a non-success response.
52
+ #
53
+ #
54
+ # Opening a database
55
+ # ------------------
56
+ #
57
+ # vdb = Analysand::Database.new('http://localhost:5984/videos/')
58
+ #
59
+ #
60
+ # Closing connections
61
+ # -------------------
62
+ #
63
+ # vdb.close
64
+ #
65
+ # Note that this only closes the connection used for the current thread. If
66
+ # the database object is being used from several threads, there will still
67
+ # be other connections active. To close all connections, you must call
68
+ # #close from all threads that are using the database object.
69
+ #
70
+ # It is safe to call #close without additional synchronization.
71
+ #
72
+ # After close returns, you can re-open a connection by calling #get, #put,
73
+ # etc.
74
+ #
75
+ #
76
+ # Creating a document
77
+ # -------------------
78
+ #
79
+ # doc = { ... }
80
+ # vdb.put(doc_id, doc, credentials) # => #<Response code=201 ...>
81
+ # # => #<Response code=403 ...>
82
+ # # => #<Response code=409 ...>
83
+ #
84
+ # Any object that responds to #to_json with a JSON representation of itself
85
+ # may be used as the document.
86
+ #
87
+ # Updating a document
88
+ # -------------------
89
+ #
90
+ # doc = { '_rev' => rev, ... }
91
+ # vdb.put(doc_id, doc, credentials) # => #<Response code=201 ...>
92
+ # # => #<Response code=401 ...>
93
+ # # => #<Response code=409 ...>
94
+ #
95
+ #
96
+ # You can also use #put!, which will raise Analysand::DocumentNotSaved if the
97
+ # response code is non-success.
98
+ #
99
+ # begin
100
+ # vdb.put!(doc_id, doc, credentials)
101
+ # rescue Analysand::DocumentNotSaved => e
102
+ # puts "Unable to save #{doc_id}, reason: #{e.response.body}"
103
+ # end
104
+ #
105
+ # #put!, if it returns, returns the response.
106
+ #
107
+ #
108
+ # Deleting a document
109
+ # -------------------
110
+ #
111
+ # vdb.delete(doc_id, rev, credentials) # => #<Response code=200 ...>
112
+ # # => #<Response code=401 ...>
113
+ # # => #<Response code=409 ...>
114
+ #
115
+ # You can also use #delete!, which will raise Analysand::DocumentNotDeleted if
116
+ # the response code is non-success.
117
+ #
118
+ #
119
+ # Retrieving a document
120
+ # ---------------------
121
+ #
122
+ # vdb.get(doc_id, credentials) # => #<Response code=200 ...>
123
+ # # => #<Response code=401 ...>
124
+ # # => #<Response code=404 ...>
125
+ #
126
+ # Note: CouchDB treats forward slashes (/) specially. For document IDs, /
127
+ # denotes a separator between document ID and the name of an attachment.
128
+ # This library makes use of that to implement attachment storage and
129
+ # retrieval (see below).
130
+ #
131
+ # If you are using forward slashes in document IDs, you MUST encode them
132
+ # (i.e. replace / with %2F).
133
+ #
134
+ # You can also use #get!, which will raise Analysand::CannotAccessDocument if
135
+ # the response code is non-success.
136
+ #
137
+ #
138
+ # Reading a view
139
+ # --------------
140
+ #
141
+ # vdb.view('video/recent', :key => ['member1'])
142
+ # vdb.view('video/by_artist', :startkey => 'a', :endkey => 'b')
143
+ #
144
+ # Keys are automatically JSON-encoded. The view method returns a
145
+ # ViewResponse, which may be accessed like this:
146
+ #
147
+ # resp = vdb.view('video/recent', :limit => 10)
148
+ # resp.total_rows # => 16
149
+ # resp.offset # => 0
150
+ # resp.rows # => [ { 'id' => ... }, ... } ]
151
+ #
152
+ # See ViewResponse for more details.
153
+ #
154
+ # You can also use view!, which will raise Analysand::CannotAccessView on a
155
+ # non-success response.
156
+ #
157
+ #
158
+ # Uploading an attachment
159
+ # -----------------------
160
+ #
161
+ # vdb.put_attachment('doc1/attachment', io, {}, credentials)
162
+ # # => #<Response>
163
+ #
164
+ # The second argument MUST be an IO-like object. The third argument MAY
165
+ # contain any of the following options:
166
+ #
167
+ # * :rev: When specified, this will be used as the rev of the document that
168
+ # will own the attachment. When not specified, no rev will be passed in
169
+ # the request. In order to add attachments to existing documents, then,
170
+ # you MUST pass this option.
171
+ # * :content_type: The MIME type of the attachment.
172
+ #
173
+ #
174
+ # Retrieving an attachment
175
+ # ------------------------
176
+ #
177
+ # vdb.get_attachment('doc1/attachment', credentials) do |resp|
178
+ # # resp is a Net::HTTPResponse
179
+ # end
180
+ #
181
+ # or, if you don't need that level of control when reading the response
182
+ # body:
183
+ #
184
+ # vdb.get_attachment('doc1/attachment', credentials)
185
+ # # => Net::HTTPResponse
186
+ #
187
+ # When a block is passed, #get_attachment does not read the response body,
188
+ # leaving that up to the programmer. When a block is _not_ passed,
189
+ # #get_attachment reads the body in full.
190
+ #
191
+ #
192
+ # Pinging a database
193
+ # ------------------
194
+ #
195
+ # Useful for connection testing:
196
+ #
197
+ # vdb.ping # => #<Response code=200 ...>
198
+ #
199
+ #
200
+ # Getting database status
201
+ # -----------------------
202
+ #
203
+ # vdb.status # => { "db_name" => "videos", ... }
204
+ #
205
+ # The returned hash is a parsed form of the JSON received from a GET on the
206
+ # database.
207
+ #
208
+ #
209
+ # Copying a document
210
+ # ------------------
211
+ #
212
+ # vdb.copy('source', 'destination', credentials)
213
+ # # => #<Response code=201 ...>
214
+ # # => #<Response code=401 ...>
215
+ # # => #<Response code=409 ...>
216
+ #
217
+ # To overwrite, you'll need to provide a rev of the destination document:
218
+ #
219
+ # vdb.copy('source', "destination?rev=#{rev}", credentials)
220
+ #
221
+ #
222
+ # Acceptable credentials
223
+ # ======================
224
+ #
225
+ # Every method that interacts with CouchDB has an optional credentials
226
+ # parameter. Two forms of credential are recognized by this class.
227
+ #
228
+ # 1. HTTP Basic authentication: When credentials is a hash of the form
229
+ #
230
+ # { :username => "...", :password => "... }
231
+ #
232
+ # then it will be transformed into an Authorization header for HTTP Basic
233
+ # authentication.
234
+ #
235
+ # 2. Token authentication: When credentials is a string, it is interpreted
236
+ # as a cookie from CouchDB's Session API. The string is used as the
237
+ # value of a Cookie header.
238
+ #
239
+ # To get a token, use a CouchDB::Instance (ahem) instance.
240
+ #
241
+ # Omitting the credentials argument, or providing a form of credentials not
242
+ # listed here, will result in no credentials being passed in the request.
243
+ #
244
+ #
245
+ # Thread safety
246
+ # =============
247
+ #
248
+ # Database objects may be shared across multiple threads. The HTTP client
249
+ # used by this object (Net::HTTP::Persistent) creates one persistent
250
+ # connection per (uri.host, uri.port, thread) tuple, so connection pooling
251
+ # is also done.
252
+ class Database
253
+ include Rack::Utils
254
+
255
+ JSON_VALUE_PARAMETERS = %w(key keys startkey endkey).map(&:to_sym)
256
+
257
+ attr_reader :http
258
+ attr_reader :uri
259
+
260
+ def self.create!(uri, credentials = nil)
261
+ new(uri).tap { |db| db.create!(credentials) }
262
+ end
263
+
264
+ def self.drop(uri, credentials = nil)
265
+ new(uri).drop(credentials)
266
+ end
267
+
268
+ def initialize(uri)
269
+ raise InvalidURIError, 'You must supply an absolute URI' unless uri.absolute?
270
+
271
+ @http = Net::HTTP::Persistent.new('catalog_database')
272
+ @uri = uri
273
+
274
+ # URI.join (used to calculate a document URI) will replace the database
275
+ # name unless we make it clear that the database is part of the path
276
+ unless uri.path.end_with?('/')
277
+ uri.path += '/'
278
+ end
279
+ end
280
+
281
+ def ping(credentials = nil)
282
+ req = Net::HTTP::Get.new(uri.to_s)
283
+ set_credentials(req, credentials)
284
+
285
+ Response.new(http.request(uri, req))
286
+ end
287
+
288
+ def status(credentials = nil)
289
+ ping(credentials).body
290
+ end
291
+
292
+ def close
293
+ http.shutdown
294
+ end
295
+
296
+ def put(doc_id, doc, credentials = nil, options = {})
297
+ query = options
298
+ headers = { 'Content-Type' => 'application/json' }
299
+
300
+ Response.new _put(doc_id, credentials, options, headers, doc.to_json)
301
+ end
302
+
303
+ def put!(doc_id, doc, credentials = nil, options = {})
304
+ put(doc_id, doc, credentials, options).tap do |resp|
305
+ raise ex(DocumentNotSaved, resp) unless resp.success?
306
+ end
307
+ end
308
+
309
+ def ensure_full_commit(credentials = nil, options = {})
310
+ headers = { 'Content-Type' => 'application/json' }
311
+
312
+ Response.new _post('_ensure_full_commit', credentials, options, headers, {}.to_json)
313
+ end
314
+
315
+ def bulk_docs(docs, credentials = nil, options = {})
316
+ headers = { 'Content-Type' => 'application/json' }
317
+ body = { 'docs' => docs }
318
+ body['all_or_nothing'] = true if options[:all_or_nothing]
319
+
320
+ BulkResponse.new _post('_bulk_docs', credentials, {}, headers, body.to_json)
321
+ end
322
+
323
+ def bulk_docs!(docs, credentials = nil, options = {})
324
+ bulk_docs(docs, credentials, options).tap do |resp|
325
+ raise ex(BulkOperationFailed, resp) unless resp.success?
326
+ end
327
+ end
328
+
329
+ def copy(source, destination, credentials = nil)
330
+ headers = { 'Destination' => destination }
331
+
332
+ Response.new _copy(source, credentials, {}, headers, nil)
333
+ end
334
+
335
+ def put_attachment(loc, io, credentials = nil, options = {})
336
+ query = {}
337
+ headers = {}
338
+
339
+ if options[:rev]
340
+ query['rev'] = options[:rev]
341
+ end
342
+
343
+ if options[:content_type]
344
+ headers['Content-Type'] = options[:content_type]
345
+ end
346
+
347
+ Response.new _put(loc, credentials, query, headers, io.read)
348
+ end
349
+
350
+ def delete(doc_id, rev, credentials = nil)
351
+ headers = { 'If-Match' => rev }
352
+
353
+ Response.new _delete(doc_id, credentials, {}, headers, nil)
354
+ end
355
+
356
+ def delete!(doc_id, rev, credentials = nil)
357
+ delete(doc_id, rev, credentials).tap do |resp|
358
+ raise ex(DocumentNotDeleted, resp) unless resp.success?
359
+ end
360
+ end
361
+
362
+ def get(doc_id, credentials = nil)
363
+ Response.new(_get(doc_id, credentials))
364
+ end
365
+
366
+ def get!(doc_id, credentials = nil)
367
+ get(doc_id, credentials).tap do |resp|
368
+ raise ex(CannotAccessDocument, resp) unless resp.success?
369
+ end
370
+ end
371
+
372
+ def head(doc_id, credentials = nil)
373
+ Response.new(_head(doc_id, credentials))
374
+ end
375
+
376
+ def get_attachment(loc, credentials = nil)
377
+ _get(loc, credentials)
378
+ end
379
+
380
+ def all_docs(parameters = {}, credentials = nil)
381
+ view('_all_docs', parameters, credentials)
382
+ end
383
+
384
+ def all_docs!(parameters = {}, credentials = nil)
385
+ view!('_all_docs', parameters, credentials)
386
+ end
387
+
388
+ def view(view_name, parameters = {}, credentials = nil)
389
+ view_path = expand_view_path(view_name)
390
+
391
+ JSON_VALUE_PARAMETERS.each do |p|
392
+ if parameters.has_key?(p)
393
+ parameters[p] = parameters[p].to_json
394
+ end
395
+ end
396
+
397
+ ViewResponse.new _get(view_path, credentials, parameters, {})
398
+ end
399
+
400
+ def expand_view_path(view_name)
401
+ if view_name.include?('/')
402
+ design_doc, view_name = view_name.split('/', 2)
403
+ "_design/#{design_doc}/_view/#{view_name}"
404
+ else
405
+ view_name
406
+ end
407
+ end
408
+
409
+ def view!(view_name, parameters = {}, credentials = nil)
410
+ view(view_name, parameters, credentials).tap do |resp|
411
+ raise ex(CannotAccessView, resp) unless resp.success?
412
+ end
413
+ end
414
+
415
+ def create(credentials = nil)
416
+ req = Net::HTTP::Put.new(uri.to_s)
417
+ set_credentials(req, credentials)
418
+
419
+ Response.new(http.request(uri, req))
420
+ end
421
+
422
+ def create!(credentials = nil)
423
+ create(credentials).tap do |resp|
424
+ raise ex(DatabaseError, resp) unless resp.success?
425
+ end
426
+ end
427
+
428
+ def drop(credentials = nil)
429
+ req = Net::HTTP::Delete.new(uri.to_s)
430
+ set_credentials(req, credentials)
431
+
432
+ Response.new(http.request(uri, req))
433
+ end
434
+
435
+ def drop!(credentials = nil)
436
+ drop(credentials).tap do |resp|
437
+ raise ex(CannotDropDatabase, resp) unless resp.success?
438
+ end
439
+ end
440
+
441
+ %w(Head Get Put Post Delete Copy).each do |m|
442
+ str = <<-END
443
+ def _#{m.downcase}(doc_id, credentials, query = {}, headers = {}, body = nil)
444
+ _req(Net::HTTP::#{m}, doc_id, credentials, query, headers, body)
445
+ end
446
+ END
447
+
448
+ class_eval str, __FILE__, __LINE__
449
+ end
450
+
451
+ ##
452
+ # @private
453
+ def _req(klass, doc_id, credentials, query, headers, body)
454
+ uri = URI(self.uri.to_s + URI.escape(doc_id))
455
+ uri.query = build_query(query) unless query.empty?
456
+
457
+ req = klass.new(uri.request_uri)
458
+
459
+ headers.each { |k, v| req.add_field(k, v) }
460
+ req.body = body if body && req.request_body_permitted?
461
+ set_credentials(req, credentials)
462
+
463
+ http.request(uri, req)
464
+ end
465
+
466
+ ##
467
+ # Sets credentials on a request object.
468
+ #
469
+ # If creds is a hash containing :username and :password keys, HTTP basic
470
+ # authorization is used. If creds is a string, the string is added as a
471
+ # cookie.
472
+ def set_credentials(req, creds)
473
+ return unless creds
474
+
475
+ if String === creds
476
+ req.add_field('Cookie', creds)
477
+ elsif creds[:username] && creds[:password]
478
+ req.basic_auth(creds[:username], creds[:password])
479
+ end
480
+ end
481
+
482
+ ##
483
+ # @private
484
+ def ex(klass, response)
485
+ klass.new("Expected response to have code 2xx, got #{response.code} instead").tap do |ex|
486
+ ex.response = response
487
+ end
488
+ end
489
+ end
490
+ end
491
+
492
+ # vim:ts=2:sw=2:et:tw=78