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.
- checksums.yaml +7 -0
- data/.gitignore +19 -0
- data/.rspec +2 -0
- data/.travis.yml +8 -0
- data/CHANGELOG +67 -0
- data/Gemfile +8 -0
- data/LICENSE +22 -0
- data/README +48 -0
- data/Rakefile +22 -0
- data/analysand.gemspec +33 -0
- data/bin/analysand +27 -0
- data/lib/analysand.rb +3 -0
- data/lib/analysand/bulk_response.rb +14 -0
- data/lib/analysand/change_watcher.rb +280 -0
- data/lib/analysand/config_response.rb +25 -0
- data/lib/analysand/connection_testing.rb +52 -0
- data/lib/analysand/database.rb +322 -0
- data/lib/analysand/errors.rb +60 -0
- data/lib/analysand/http.rb +90 -0
- data/lib/analysand/instance.rb +255 -0
- data/lib/analysand/reading.rb +26 -0
- data/lib/analysand/response.rb +35 -0
- data/lib/analysand/response_headers.rb +18 -0
- data/lib/analysand/session_response.rb +16 -0
- data/lib/analysand/status_code_predicates.rb +25 -0
- data/lib/analysand/streaming_view_response.rb +90 -0
- data/lib/analysand/version.rb +3 -0
- data/lib/analysand/view_response.rb +24 -0
- data/lib/analysand/view_streaming/builder.rb +142 -0
- data/lib/analysand/viewing.rb +95 -0
- data/lib/analysand/writing.rb +71 -0
- data/script/setup_database.rb +45 -0
- data/spec/analysand/a_response.rb +70 -0
- data/spec/analysand/change_watcher_spec.rb +102 -0
- data/spec/analysand/database_spec.rb +243 -0
- data/spec/analysand/database_writing_spec.rb +488 -0
- data/spec/analysand/instance_spec.rb +205 -0
- data/spec/analysand/response_spec.rb +26 -0
- data/spec/analysand/view_response_spec.rb +44 -0
- data/spec/analysand/view_streaming/builder_spec.rb +73 -0
- data/spec/analysand/view_streaming_spec.rb +122 -0
- data/spec/fixtures/vcr_cassettes/get_config.yml +40 -0
- data/spec/fixtures/vcr_cassettes/get_many_config.yml +40 -0
- data/spec/fixtures/vcr_cassettes/head_request_with_etag.yml +40 -0
- data/spec/fixtures/vcr_cassettes/reload_config.yml +114 -0
- data/spec/fixtures/vcr_cassettes/unauthorized_put_config.yml +43 -0
- data/spec/fixtures/vcr_cassettes/view.yml +40 -0
- data/spec/smoke/database_thread_spec.rb +59 -0
- data/spec/spec_helper.rb +30 -0
- data/spec/support/database_access.rb +40 -0
- data/spec/support/example_isolation.rb +86 -0
- data/spec/support/test_parameters.rb +39 -0
- 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
|