dpla-analysand 3.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +19 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +8 -0
  5. data/CHANGELOG +67 -0
  6. data/Gemfile +8 -0
  7. data/LICENSE +22 -0
  8. data/README +48 -0
  9. data/Rakefile +22 -0
  10. data/analysand.gemspec +33 -0
  11. data/bin/analysand +27 -0
  12. data/lib/analysand.rb +3 -0
  13. data/lib/analysand/bulk_response.rb +14 -0
  14. data/lib/analysand/change_watcher.rb +280 -0
  15. data/lib/analysand/config_response.rb +25 -0
  16. data/lib/analysand/connection_testing.rb +52 -0
  17. data/lib/analysand/database.rb +322 -0
  18. data/lib/analysand/errors.rb +60 -0
  19. data/lib/analysand/http.rb +90 -0
  20. data/lib/analysand/instance.rb +255 -0
  21. data/lib/analysand/reading.rb +26 -0
  22. data/lib/analysand/response.rb +35 -0
  23. data/lib/analysand/response_headers.rb +18 -0
  24. data/lib/analysand/session_response.rb +16 -0
  25. data/lib/analysand/status_code_predicates.rb +25 -0
  26. data/lib/analysand/streaming_view_response.rb +90 -0
  27. data/lib/analysand/version.rb +3 -0
  28. data/lib/analysand/view_response.rb +24 -0
  29. data/lib/analysand/view_streaming/builder.rb +142 -0
  30. data/lib/analysand/viewing.rb +95 -0
  31. data/lib/analysand/writing.rb +71 -0
  32. data/script/setup_database.rb +45 -0
  33. data/spec/analysand/a_response.rb +70 -0
  34. data/spec/analysand/change_watcher_spec.rb +102 -0
  35. data/spec/analysand/database_spec.rb +243 -0
  36. data/spec/analysand/database_writing_spec.rb +488 -0
  37. data/spec/analysand/instance_spec.rb +205 -0
  38. data/spec/analysand/response_spec.rb +26 -0
  39. data/spec/analysand/view_response_spec.rb +44 -0
  40. data/spec/analysand/view_streaming/builder_spec.rb +73 -0
  41. data/spec/analysand/view_streaming_spec.rb +122 -0
  42. data/spec/fixtures/vcr_cassettes/get_config.yml +40 -0
  43. data/spec/fixtures/vcr_cassettes/get_many_config.yml +40 -0
  44. data/spec/fixtures/vcr_cassettes/head_request_with_etag.yml +40 -0
  45. data/spec/fixtures/vcr_cassettes/reload_config.yml +114 -0
  46. data/spec/fixtures/vcr_cassettes/unauthorized_put_config.yml +43 -0
  47. data/spec/fixtures/vcr_cassettes/view.yml +40 -0
  48. data/spec/smoke/database_thread_spec.rb +59 -0
  49. data/spec/spec_helper.rb +30 -0
  50. data/spec/support/database_access.rb +40 -0
  51. data/spec/support/example_isolation.rb +86 -0
  52. data/spec/support/test_parameters.rb +39 -0
  53. metadata +283 -0
@@ -0,0 +1,90 @@
1
+ require 'forwardable'
2
+ require 'net/http/persistent'
3
+ require 'rack/utils'
4
+ require 'uri'
5
+
6
+ module Analysand
7
+ # Private: HTTP client methods for Database and Instance.
8
+ #
9
+ # Users of this module MUST set @http and @uri in their initializer. @http
10
+ # SHOULD be a Net::HTTP::Persistent instance, and @uri SHOULD be a URI
11
+ # instance.
12
+ module Http
13
+ extend Forwardable
14
+
15
+ include Rack::Utils
16
+
17
+ attr_reader :http
18
+ attr_reader :uri
19
+
20
+ SSL_METHODS = %w(
21
+ certificate ca_file cert_store private_key
22
+ reuse_ssl_sessions ssl_version verify_callback verify_mode
23
+ ).map { |m| [m, "#{m}="] }.flatten
24
+
25
+ def_delegators :http, *SSL_METHODS
26
+
27
+ def init_http_client(uri)
28
+ unless uri.respond_to?(:path) && uri.respond_to?(:absolute?)
29
+ uri = URI(uri)
30
+ end
31
+
32
+ raise InvalidURIError, 'You must supply an absolute URI' unless uri.absolute?
33
+
34
+ @http = Net::HTTP::Persistent.new('analysand')
35
+ @uri = uri
36
+
37
+ # Document IDs and other database bits are appended to the URI path,
38
+ # so we need to make sure that it ends in a /.
39
+ unless uri.path.end_with?('/')
40
+ uri.path += '/'
41
+ end
42
+ end
43
+
44
+ def close
45
+ http.shutdown
46
+ end
47
+
48
+ %w(Head Get Put Post Delete Copy).each do |m|
49
+ str = <<-END
50
+ def _#{m.downcase}(doc_id, credentials, query = {}, headers = {}, body = nil, block = nil)
51
+ _req(Net::HTTP::#{m}, doc_id, credentials, query, headers, body, block)
52
+ end
53
+ END
54
+
55
+ module_eval str, __FILE__, __LINE__
56
+ end
57
+
58
+ ##
59
+ # @private
60
+ def _req(klass, doc_id, credentials, query, headers, body, block)
61
+ uri = self.uri.dup
62
+ uri.path += URI.escape(doc_id)
63
+ uri.query = build_query(query) unless query.empty?
64
+
65
+ req = klass.new(uri.request_uri)
66
+
67
+ headers.each { |k, v| req.add_field(k, v) }
68
+ req.body = body if body && req.request_body_permitted?
69
+ set_credentials(req, credentials)
70
+
71
+ http.request(uri, req, &block)
72
+ end
73
+
74
+ ##
75
+ # Sets credentials on a request object.
76
+ #
77
+ # If creds is a hash containing :username and :password keys, HTTP basic
78
+ # authorization is used. If creds is a string, the string is added as a
79
+ # cookie.
80
+ def set_credentials(req, creds)
81
+ return unless creds
82
+
83
+ if String === creds
84
+ req.add_field('Cookie', creds)
85
+ elsif creds[:username] && creds[:password]
86
+ req.basic_auth(creds[:username], creds[:password])
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,255 @@
1
+ require 'analysand/config_response'
2
+ require 'analysand/errors'
3
+ require 'analysand/http'
4
+ require 'analysand/response'
5
+ require 'analysand/session_response'
6
+ require 'base64'
7
+ require 'net/http/persistent'
8
+ require 'rack/utils'
9
+ require 'uri'
10
+
11
+ module Analysand
12
+ ##
13
+ # Wraps a CouchDB instance.
14
+ #
15
+ # This class is meant to be used for interacting with parts of CouchDB that
16
+ # aren't associated with any particular database: session management, for
17
+ # example. If you're looking to do database operations,
18
+ # Analysand::Database is where you want to be.
19
+ #
20
+ # Instances MUST be identified by an absolute URI; instantiating this class
21
+ # with a relative URI will raise an exception.
22
+ #
23
+ # Common tasks
24
+ # ============
25
+ #
26
+ # Opening an instance
27
+ # -------------------
28
+ #
29
+ # instance = Analysand::Instance(URI('http://localhost:5984'))
30
+ #
31
+ #
32
+ # Pinging an instance
33
+ # -------------------
34
+ #
35
+ # instance.ping # => #<Response code=200 ...>
36
+ #
37
+ #
38
+ # Establishing a session
39
+ # ----------------------
40
+ #
41
+ # resp, = instance.post_session('username', 'password')
42
+ # cookie = resp.session_cookie
43
+ #
44
+ # For harmony, the same credentials hash accepted by database methods is
45
+ # also supported:
46
+ #
47
+ # resp = instance.post_session(:username => 'username',
48
+ # :password => 'password')
49
+ #
50
+ #
51
+ # resp.success? will be true if the session cookie is not empty, false
52
+ # otherwise.
53
+ #
54
+ #
55
+ # Testing a session cookie for validity
56
+ # -------------------------------------
57
+ #
58
+ # resp = instance.get_session(cookie)
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
+ # Adding and removing admins
82
+ # --------------------------
83
+ #
84
+ # instance.put_admin('admin', 'password', credentials)
85
+ # # => #<ConfigResponse code=200 ...>
86
+ # instance.delete_admin('admin', credentials)
87
+ # # => #<ConfigResponse code=200 ...>
88
+ #
89
+ # Obviously, you'll need admin credentials to manage the admin list.
90
+ #
91
+ # There also exist bang-method variants:
92
+ #
93
+ # instance.put_admin!('admin', 'password', bad_creds)
94
+ # # => raises ConfigurationNotSaved on failure
95
+ # instance.delete_admin!('admin', bad_creds)
96
+ # # => raises ConfigurationNotDeleted on failure
97
+ #
98
+ #
99
+ # Getting and setting instance configuration
100
+ # ------------------------------------------
101
+ #
102
+ # v = instance.get_config('couchdb_httpd_auth/allow_persistent_cookies',
103
+ # credentials)
104
+ # v.value # => false
105
+ #
106
+ # instance.put_config('couchdb_httpd_auth/allow_persistent_cookies',
107
+ # '"true"', credentials)
108
+ # # => #<Response code=200 ...>
109
+ #
110
+ # v = instance.get_config('couchdb_httpd_auth/allow_persistent_cookies',
111
+ # credentials)
112
+ # v.value #=> '"true"'
113
+ #
114
+ # instance.delete_config('couchdb_httpd_auth/allow_persistent_cookies',
115
+ # credentials)
116
+ #
117
+ # You can get configuration at any level:
118
+ #
119
+ # v = instance.get_config('', credentials)
120
+ # v.body['stats']['rate'] # => "1000", or whatever you have it set to
121
+ #
122
+ # #get_config and #put_config both return Response-like objects. You can
123
+ # check for failure or success that way:
124
+ #
125
+ # v = instance.get_config('couchdb_httpd_auth/allow_persistent_cookies')
126
+ # v.code # => '403'
127
+ #
128
+ # instance.put_config('couchdb_httpd_auth/allow_persistent_cookies', '"false"')
129
+ # # => #<Response code=403 ...>
130
+ #
131
+ # If you want to set configuration and just want to let errors bubble
132
+ # up the stack, you can use the bang-variants:
133
+ #
134
+ # instance.put_config!('stats/rate', '"1000"')
135
+ # # => on non-2xx response, raises ConfigurationNotSaved
136
+ #
137
+ # instance.delete_config!('stats/rate')
138
+ # # => on non-2xx response, raises ConfigurationNotDeleted
139
+ #
140
+ #
141
+ # Other instance-level services
142
+ # -----------------------------
143
+ #
144
+ # CouchDB can be extended with additional service handlers; authentication
145
+ # handlers are a popular example.
146
+ #
147
+ # Instance exposes #get, #put, and #post methods to access arbitrary
148
+ # endpoints.
149
+ #
150
+ # Examples:
151
+ #
152
+ # instance.get('_log', {}, admin_credentials)
153
+ # instance.post('_browserid', { 'assertion' => assertion },
154
+ # { 'Content-Type' => 'application/json' })
155
+ # instance.put('_config/httpd/bind_address', '192.168.0.1', {},
156
+ # admin_credentials)
157
+ #
158
+ class Instance
159
+ include Errors
160
+ include Http
161
+ include Rack::Utils
162
+
163
+ def initialize(uri)
164
+ init_http_client(uri)
165
+ end
166
+
167
+ def get(path, headers = {}, credentials = nil)
168
+ _get(path, credentials, {}, headers)
169
+ end
170
+
171
+ def post(path, body = nil, headers = {}, credentials = nil)
172
+ _post(path, credentials, {}, headers, body)
173
+ end
174
+
175
+ def put(path, body = nil, headers = {}, credentials = nil)
176
+ _put(path, credentials, {}, headers, body)
177
+ end
178
+
179
+ def delete(path, headers = {}, credentials = nil)
180
+ _delete(path, credentials, {}, headers)
181
+ end
182
+
183
+ def put_admin(username, password, credentials = nil)
184
+ put_config("admins/#{username}", %Q{"#{password}"}, credentials)
185
+ end
186
+
187
+ def put_admin!(username, password, credentials = nil)
188
+ raise_put_error { put_admin(username, password, credentials) }
189
+ end
190
+
191
+ def delete_admin(username, credentials = nil)
192
+ delete_config("admins/#{username}", credentials)
193
+ end
194
+
195
+ def delete_admin!(username, credentials = nil)
196
+ raise_delete_error { delete_admin(username, credentials) }
197
+ end
198
+
199
+ def post_session(*args)
200
+ username, password = if args.length == 2
201
+ args
202
+ else
203
+ h = args.first
204
+ [h[:username], h[:password]]
205
+ end
206
+
207
+ headers = { 'Content-Type' => 'application/x-www-form-urlencoded' }
208
+ body = build_query('name' => username, 'password' => password)
209
+
210
+ Response.new post('_session', body, headers)
211
+ end
212
+
213
+ def get_session(cookie)
214
+ headers = { 'Cookie' => cookie }
215
+
216
+ SessionResponse.new get('_session', headers)
217
+ end
218
+
219
+ def get_config(key, credentials = nil)
220
+ ConfigResponse.new get("_config/#{key}", {}, credentials)
221
+ end
222
+
223
+ def put_config(key, value, credentials = nil)
224
+ ConfigResponse.new put("_config/#{key}", value, {}, credentials)
225
+ end
226
+
227
+ def put_config!(key, value, credentials = nil)
228
+ raise_put_error { put_config(key, value, credentials) }
229
+ end
230
+
231
+ def delete_config(key, credentials = nil)
232
+ ConfigResponse.new delete("_config/#{key}", {}, credentials)
233
+ end
234
+
235
+ def delete_config!(key, credentials = nil)
236
+ raise_delete_error { delete_config(key, credentials) }
237
+ end
238
+
239
+ private
240
+
241
+ def raise_put_error
242
+ yield.tap do |resp|
243
+ raise ex(ConfigurationNotSaved, resp) unless resp.success?
244
+ end
245
+ end
246
+
247
+ def raise_delete_error
248
+ yield.tap do |resp|
249
+ raise ex(ConfigurationNotDeleted, resp) unless resp.success?
250
+ end
251
+ end
252
+ end
253
+ end
254
+
255
+ # vim:ts=2:sw=2:et:tw=78
@@ -0,0 +1,26 @@
1
+ require 'analysand/errors'
2
+ require 'analysand/response'
3
+
4
+ module Analysand
5
+ module Reading
6
+ def get(doc_id, credentials = nil)
7
+ Response.new(_get(doc_id, credentials))
8
+ end
9
+
10
+ def get!(doc_id, credentials = nil)
11
+ get(doc_id, credentials).tap do |resp|
12
+ raise ex(CannotAccessDocument, resp) unless resp.success?
13
+ end
14
+ end
15
+
16
+ def head(doc_id, credentials = nil)
17
+ Response.new(_head(doc_id, credentials))
18
+ end
19
+
20
+ def get_attachment(loc, credentials = nil)
21
+ _get(loc, credentials)
22
+ end
23
+ end
24
+ end
25
+
26
+ # vim:ts=2:sw=2:et:tw=78
@@ -0,0 +1,35 @@
1
+ require 'analysand/response_headers'
2
+ require 'analysand/status_code_predicates'
3
+ require 'forwardable'
4
+ require 'json/ext'
5
+
6
+ module Analysand
7
+ ##
8
+ # The response object is a wrapper around Net::HTTPResponse that provides a
9
+ # few amenities:
10
+ #
11
+ # 1. A #success? method, which checks if 200 <= response code <= 299.
12
+ # 2. A #conflict method, which checks if response code == 409.
13
+ # 3. Automatic JSON deserialization of all response bodies.
14
+ # 4. Delegates the [] property accessor to the body.
15
+ class Response
16
+ extend Forwardable
17
+ include ResponseHeaders
18
+ include StatusCodePredicates
19
+
20
+ attr_reader :response
21
+ attr_reader :body
22
+
23
+ def_delegators :body, :[]
24
+
25
+ def initialize(response)
26
+ @response = response
27
+
28
+ if !@response.body.nil? && !@response.body.empty?
29
+ @body = JSON.parse(@response.body)
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ # vim:ts=2:sw=2:et:tw=78
@@ -0,0 +1,18 @@
1
+ module Analysand
2
+ module ResponseHeaders
3
+ def etag
4
+ response.get_fields('ETag').first.gsub('"', '')
5
+ end
6
+
7
+ def cookies
8
+ response.get_fields('Set-Cookie')
9
+ end
10
+
11
+ def session_cookie
12
+ return unless (cs = cookies)
13
+
14
+ cs.detect { |c| c =~ /^(AuthSession=[^;]+)/i }
15
+ $1
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,16 @@
1
+ require 'analysand/response'
2
+
3
+ module Analysand
4
+ # Public: Wraps the response from GET /_session.
5
+ #
6
+ # GET /_session can be a bit surprising. A 200 OK response from _session
7
+ # indicates that the session cookie was well-formed; it doesn't indicate that
8
+ # the session is _valid_.
9
+ #
10
+ # Hence, this class adds a #valid? predicate.
11
+ class SessionResponse < Response
12
+ def valid?
13
+ (uc = body['userCtx']) && uc['name']
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,25 @@
1
+ module Analysand
2
+ module StatusCodePredicates
3
+ def code
4
+ response.code
5
+ end
6
+
7
+ def success?
8
+ c = code.to_i
9
+
10
+ c >= 200 && c <= 299
11
+ end
12
+
13
+ def unauthorized?
14
+ code.to_i == 401
15
+ end
16
+
17
+ def not_found?
18
+ code.to_i == 404
19
+ end
20
+
21
+ def conflict?
22
+ code.to_i == 409
23
+ end
24
+ end
25
+ end