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,26 @@
1
+ module Analysand
2
+ class InvalidURIError < StandardError
3
+ end
4
+
5
+ class DatabaseError < StandardError
6
+ attr_accessor :response
7
+ end
8
+
9
+ class DocumentNotSaved < DatabaseError
10
+ end
11
+
12
+ class DocumentNotDeleted < DatabaseError
13
+ end
14
+
15
+ class CannotAccessDocument < DatabaseError
16
+ end
17
+
18
+ class CannotAccessView < DatabaseError
19
+ end
20
+
21
+ class CannotDropDatabase < DatabaseError
22
+ end
23
+
24
+ class BulkOperationFailed < DatabaseError
25
+ end
26
+ end
@@ -0,0 +1,156 @@
1
+ require 'analysand/errors'
2
+ require 'base64'
3
+ require 'json/ext'
4
+ require 'net/http/persistent'
5
+ require 'uri'
6
+
7
+ module Analysand
8
+ ##
9
+ # Wraps a CouchDB instance.
10
+ #
11
+ # This class is meant to be used for interacting with parts of CouchDB that
12
+ # aren't associated with any particular database: session management, for
13
+ # example. If you're looking to do database operations,
14
+ # Analysand::Database is where you want to be.
15
+ #
16
+ # Instances MUST be identified by an absolute URI; instantiating this class
17
+ # with a relative URI will raise an exception.
18
+ #
19
+ # Common tasks
20
+ # ============
21
+ #
22
+ # Opening an instance
23
+ # -------------------
24
+ #
25
+ # instance = Analysand::Instance(URI('http://localhost:5984'))
26
+ #
27
+ #
28
+ # Pinging an instance
29
+ # -------------------
30
+ #
31
+ # instance.ping # => #<Response code=200 ...>
32
+ #
33
+ #
34
+ # Establishing a session
35
+ # ----------------------
36
+ #
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]
50
+ #
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.
54
+ #
55
+ # db = Analysand::Database.new(...)
56
+ # session, resp = instance.establish_session(username, password)
57
+ #
58
+ # db.put(doc, session[:token])
59
+ #
60
+ #
61
+ # Renewing a session
62
+ # ------------------
63
+ #
64
+ # auth, _ = instance.establish_session('username', 'password')
65
+ # # ...time passes...
66
+ # session, resp = instance.renew_session(auth)
67
+ #
68
+ # Note: CouchDB doesn't always renew a session when asked; see the
69
+ # documentation for #renew_session for more details.
70
+ #
71
+ class Instance
72
+ attr_reader :http
73
+ attr_reader :uri
74
+
75
+ def initialize(uri)
76
+ raise InvalidURIError, 'You must supply an absolute URI' unless uri.absolute?
77
+
78
+ @http = Net::HTTP::Persistent.new('catalog_database')
79
+ @uri = uri
80
+ end
81
+
82
+ def establish_session(username, password)
83
+ path = uri + "/_session"
84
+
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)}"
89
+
90
+ resp = http.request path, req
91
+
92
+ if Net::HTTPSuccess === resp
93
+ [session(resp), resp]
94
+ else
95
+ [nil, resp]
96
+ end
97
+ end
98
+
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]
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ def session(resp)
136
+ token = resp.get_fields('Set-Cookie').
137
+ detect { |c| c =~ /^AuthSession=([^;]+)/i }
138
+
139
+ fields = Base64.decode64($1).split(':')
140
+ username = fields[0]
141
+ time = fields[1].to_i(16)
142
+
143
+ body = JSON.parse(resp.body)
144
+ roles = body.has_key?('userCtx') ?
145
+ body['userCtx']['roles'] : body['roles']
146
+
147
+ { :issued_at => time,
148
+ :roles => roles,
149
+ :token => token,
150
+ :username => username
151
+ }
152
+ end
153
+ end
154
+ end
155
+
156
+ # vim:ts=2:sw=2:et:tw=78
@@ -0,0 +1,45 @@
1
+ require 'forwardable'
2
+ require 'json/ext'
3
+
4
+ module Analysand
5
+ ##
6
+ # The response object is a wrapper around Net::HTTPResponse that provides a
7
+ # few amenities:
8
+ #
9
+ # 1. A #success? method. It returns true if the response code is between
10
+ # (200..299) and false otherwise.
11
+ # 2. Automatic JSON deserialization of all response bodies.
12
+ # 3. Delegates the [] property accessor to the body.
13
+ class Response
14
+ extend Forwardable
15
+
16
+ attr_reader :response
17
+ attr_reader :body
18
+
19
+ def_delegators :body, :[]
20
+
21
+ def initialize(response)
22
+ @response = response
23
+
24
+ if !@response.body.nil? && !@response.body.empty?
25
+ @body = JSON.parse(@response.body)
26
+ end
27
+ end
28
+
29
+ def etag
30
+ response.get_fields('ETag').first.gsub('"', '')
31
+ end
32
+
33
+ def success?
34
+ c = code.to_i
35
+
36
+ c >= 200 && c <= 299
37
+ end
38
+
39
+ def code
40
+ response.code
41
+ end
42
+ end
43
+ end
44
+
45
+ # vim:ts=2:sw=2:et:tw=78
@@ -0,0 +1,3 @@
1
+ module Analysand
2
+ VERSION = "1.0.1"
3
+ end
@@ -0,0 +1,24 @@
1
+ require 'analysand/response'
2
+
3
+ module Analysand
4
+ ##
5
+ # A subclass of Response with additional view-specific accessors: total_rows,
6
+ # offset, and rows.
7
+ class ViewResponse < Response
8
+ def total_rows
9
+ body['total_rows']
10
+ end
11
+
12
+ def offset
13
+ body['offset']
14
+ end
15
+
16
+ def rows
17
+ body['rows']
18
+ end
19
+
20
+ def docs
21
+ rows.map { |r| r['doc'] }.compact
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require File.expand_path('../../spec/support/test_parameters', __FILE__)
4
+ require 'digest'
5
+ require 'json'
6
+
7
+ include TestParameters
8
+
9
+ admin_command = ['curl',
10
+ '-X PUT',
11
+ %Q{--data-binary '"#{admin_password}"'},
12
+ "#{instance_uri}/_config/admins/#{admin_username}"
13
+ ].join(' ')
14
+
15
+ salt = "ff518dabd59b04b527de7c55179059a46ac54976"
16
+
17
+ member_doc = {
18
+ "name" => member1_username,
19
+ "salt" => salt,
20
+ "password_sha" => Digest::SHA1.hexdigest("#{member1_password}#{salt}"),
21
+ "type" => "user",
22
+ "roles" => []
23
+ }
24
+
25
+ member_command = ['curl',
26
+ '-X PUT',
27
+ "--data-binary '#{member_doc.to_json}'",
28
+ "-u #{admin_username}:#{admin_password}",
29
+ "#{instance_uri}/_users/org.couchdb.user:#{member1_username}"
30
+ ].join(' ')
31
+
32
+ db_command = ['curl',
33
+ '-X PUT',
34
+ "-u #{admin_username}:#{admin_password}",
35
+ database_uri.to_s
36
+ ].join(' ')
37
+
38
+ [ admin_command,
39
+ member_command,
40
+ db_command
41
+ ].each do |cmd|
42
+ puts cmd
43
+ system cmd
44
+ puts
45
+ end
@@ -0,0 +1,40 @@
1
+ require 'base64'
2
+
3
+ shared_examples_for 'a session grantor' do
4
+ let(:result) { instance.establish_session(credentials[:username], credentials[:password]) }
5
+ let(:role_locator) do
6
+ lambda { |resp| resp['roles'] }
7
+ end
8
+
9
+ before do
10
+ @session, @resp = result
11
+ end
12
+
13
+ it 'returns a session and the original response' do
14
+ @session.should_not be_nil
15
+ @resp.should_not be_nil
16
+ end
17
+
18
+ describe 'the session object' do
19
+ it 'has the username' do
20
+ @session[:username].should == credentials[:username]
21
+ end
22
+
23
+ it 'has the token issuance time' do
24
+ @session[:token] =~ /AuthSession=([^;]+)/
25
+
26
+ time_as_hex = Base64.decode64($1).split(':')[1]
27
+ issuance_time = time_as_hex.to_i(16)
28
+
29
+ @session[:issued_at].should == issuance_time
30
+ end
31
+
32
+ it 'has the user roles' do
33
+ @session[:roles].should == role_locator[JSON.parse(@resp.body)]
34
+ end
35
+
36
+ it 'has a session token' do
37
+ @session[:token].should =~ /AuthSession.+/
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,84 @@
1
+ require 'spec_helper'
2
+
3
+ require 'analysand/change_watcher'
4
+ require 'analysand/database'
5
+ require 'celluloid'
6
+
7
+ module Analysand
8
+ describe ChangeWatcher do
9
+ class TestWatcher < Analysand::ChangeWatcher
10
+ attr_accessor :changes
11
+
12
+ def initialize(database, credentials)
13
+ super(database)
14
+
15
+ self.changes = []
16
+
17
+ @credentials = credentials
18
+ end
19
+
20
+ def customize_request(req)
21
+ req.basic_auth(@credentials[:username], @credentials[:password])
22
+ end
23
+
24
+ def process(change)
25
+ changes << change
26
+ change_processed(change)
27
+ end
28
+ end
29
+
30
+ let(:db) { Database.new(database_uri) }
31
+
32
+ before do
33
+ create_databases!
34
+ end
35
+
36
+ after do
37
+ Celluloid.shutdown
38
+
39
+ drop_databases!
40
+ end
41
+
42
+ describe '#changes_feed_uri' do
43
+ let!(:watcher) { TestWatcher.new(db, admin_credentials) }
44
+
45
+ describe 'when invoked multiple times' do
46
+ it 'returns what it returned the first time' do
47
+ uri1 = watcher.changes_feed_uri
48
+ uri2 = watcher.changes_feed_uri
49
+
50
+ uri1.should == uri2
51
+ end
52
+ end
53
+ end
54
+
55
+ describe '#waiter_for' do
56
+ let!(:watcher) { TestWatcher.new(db, admin_credentials) }
57
+
58
+ describe 'if the given document has not been processed' do
59
+ it 'blocks until the document has been processed' do
60
+ waiter = watcher.waiter_for('bar')
61
+
62
+ Thread.new do
63
+ db.put('foo', { 'foo' => 'bar' }, admin_credentials)
64
+ db.put('bar', { 'foo' => 'bar' }, admin_credentials)
65
+ end
66
+
67
+ waiter.wait
68
+
69
+ watcher.changes.detect { |r| r['id'] == 'foo' }.should_not be_nil
70
+ end
71
+ end
72
+ end
73
+
74
+ it 'receives changes' do
75
+ watcher = TestWatcher.new(db, admin_credentials)
76
+
77
+ waiter = watcher.waiter_for('foo')
78
+ db.put('foo', { 'foo' => 'bar' }, admin_credentials)
79
+ waiter.wait
80
+
81
+ watcher.changes.select { |r| r['id'] == 'foo' }.length.should == 1
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,228 @@
1
+ require 'spec_helper'
2
+
3
+ require 'analysand/database'
4
+ require 'analysand/errors'
5
+ require 'thread'
6
+ require 'uri'
7
+
8
+ module Analysand
9
+ describe Database do
10
+ let(:db) { Database.new(database_uri) }
11
+
12
+ describe '#initialize' do
13
+ it 'requires an absolute URI' do
14
+ lambda { Database.new(URI("/abc")) }.should raise_error(InvalidURIError)
15
+ end
16
+ end
17
+
18
+ describe '.create!' do
19
+ before do
20
+ drop_databases!
21
+ end
22
+
23
+ it 'creates the database at the given URI' do
24
+ db = Database.create!(database_uri, admin_credentials)
25
+
26
+ resp = db.ping
27
+ resp.body['db_name'].should == database_name
28
+ end
29
+
30
+ it 'raises an exception if the database cannot be created' do
31
+ Database.create!(database_uri, admin_credentials)
32
+
33
+ lambda { Database.create!(database_uri, admin_credentials) }.should raise_error(DatabaseError)
34
+ end
35
+ end
36
+
37
+ describe '.drop' do
38
+ before do
39
+ create_databases!
40
+ end
41
+
42
+ it 'drops the database at the given URI' do
43
+ resp = Database.drop(database_uri, admin_credentials)
44
+
45
+ resp.should be_success
46
+ Database.new(database_uri).ping.code.should == '404'
47
+ end
48
+ end
49
+
50
+ describe '#close' do
51
+ it "shuts down the current thread's connection" do
52
+ pending 'a good way to test this'
53
+ end
54
+ end
55
+
56
+ describe '#head' do
57
+ before do
58
+ clean_databases!
59
+
60
+ db.put!('foo', { 'foo' => 'bar' })
61
+ end
62
+
63
+ it 'retrieves the rev of a document' do
64
+ resp = db.head('foo')
65
+
66
+ resp.etag.should_not be_empty
67
+ end
68
+ end
69
+
70
+ describe '#get!' do
71
+ before do
72
+ clean_databases!
73
+
74
+ db.put!('foo', { 'foo' => 'bar' })
75
+ end
76
+
77
+ describe 'if the response code is 200' do
78
+ it 'returns the document' do
79
+ db.get!('foo').body['foo'].should == 'bar'
80
+ end
81
+ end
82
+
83
+ describe 'if the response code is 404' do
84
+ it 'raises Analysand::CannotAccessDocument' do
85
+ lambda { db.get!('bar') }.should raise_error(Analysand::CannotAccessDocument)
86
+ end
87
+
88
+ it 'includes the response in the exception' do
89
+ code = nil
90
+
91
+ begin
92
+ db.get!('bar')
93
+ rescue Analysand::CannotAccessDocument => e
94
+ code = e.response.code
95
+ end
96
+
97
+ code.should == '404'
98
+ end
99
+ end
100
+ end
101
+
102
+ describe '#get_attachment' do
103
+ let(:io) { StringIO.new('an attachment') }
104
+
105
+ before do
106
+ clean_databases!
107
+
108
+ db.put_attachment('doc_id/a', io, {}, admin_credentials)
109
+ end
110
+
111
+ xit 'permits streaming' do
112
+ resp = db.get_attachment('doc_id/a')
113
+
114
+ lambda { resp.read_body { } }.should_not raise_error
115
+ end
116
+ end
117
+
118
+ describe '#view' do
119
+ before do
120
+ clean_databases!
121
+
122
+ doc = {
123
+ 'views' => {
124
+ 'a_view' => {
125
+ 'map' => %q{function (doc) { emit(doc['_id'], 1); }}
126
+ },
127
+ 'composite_key' => {
128
+ 'map' => %q{function (doc) { emit([1, doc['_id']], 1); }}
129
+ }
130
+ }
131
+ }
132
+
133
+ db.put('_design/doc', doc, admin_credentials)
134
+ db.put('abc123', {}, admin_credentials)
135
+ db.put('abc456', {}, admin_credentials)
136
+ end
137
+
138
+ it 'retrieves _all_docs' do
139
+ resp = db.view('_all_docs')
140
+
141
+ resp.code.should == '200'
142
+ end
143
+
144
+ it 'retrieves a view' do
145
+ resp = db.view('doc/a_view')
146
+
147
+ resp.code.should == '200'
148
+ resp.total_rows.should == resp.body['total_rows']
149
+ resp.offset.should == resp.body['offset']
150
+ resp.rows.should == resp.body['rows']
151
+ end
152
+
153
+ it 'passes through view parameters' do
154
+ resp = db.view('doc/a_view', :skip => 1)
155
+
156
+ resp.offset.should == 1
157
+ resp.rows.length.should == 1
158
+ end
159
+
160
+ it 'JSON-encodes the key parameter' do
161
+ resp = db.view('doc/composite_key', :key => [1, 'abc123'])
162
+
163
+ resp.code.should == '200'
164
+ resp.rows.length.should == 1
165
+ end
166
+
167
+ it 'JSON-encodes the keys parameter' do
168
+ resp = db.view('doc/composite_key', :keys => [[1, 'abc123'], [1, 'abc456']])
169
+
170
+ resp.code.should == '200'
171
+ resp.rows.length.should == 2
172
+ end
173
+
174
+ it 'JSON-encodes the startkey parameter' do
175
+ resp = db.view('doc/composite_key', :startkey => [1, 'abc123'])
176
+
177
+ resp.code.should == '200'
178
+ resp.rows.length.should == 2
179
+ end
180
+
181
+ it 'JSON-encodes the endkey parameter' do
182
+ resp = db.view('doc/composite_key', :endkey => [1, 'abc456'], :skip => 1)
183
+
184
+ resp.code.should == '200'
185
+ resp.rows.length.should == 1
186
+ end
187
+
188
+ it 'passes credentials' do
189
+ security = {
190
+ 'members' => {
191
+ 'names' => [member1_username],
192
+ 'roles' => []
193
+ }
194
+ }
195
+
196
+ db.put!('_security', security, admin_credentials)
197
+
198
+ resp = db.view('doc/a_view', { :skip => 1 }, member1_credentials)
199
+
200
+ resp.code.should == '200'
201
+ end
202
+ end
203
+
204
+ describe '#view!' do
205
+ before do
206
+ clean_databases!
207
+ end
208
+
209
+ describe 'if the response code is 404' do
210
+ it 'raises Analysand::CannotAccessView' do
211
+ lambda { db.view!('unknown/view') }.should raise_error(Analysand::CannotAccessView)
212
+ end
213
+
214
+ it 'includes the response in the exception' do
215
+ code = nil
216
+
217
+ begin
218
+ db.view!('unknown/view')
219
+ rescue Analysand::CannotAccessView => e
220
+ code = e.response.code
221
+ end
222
+
223
+ code.should == '404'
224
+ end
225
+ end
226
+ end
227
+ end
228
+ end