analysand 1.0.1

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