analysand 1.0.1

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