dpla-analysand 3.0.2

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.
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