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