dolly 3.1.5 → 3.2.0
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 +4 -4
- data/README.md +82 -31
- data/lib/dolly/connection.rb +12 -27
- data/lib/dolly/curl/connection.rb +150 -0
- data/lib/dolly/curl/document_helper.rb +81 -0
- data/lib/dolly/curl/reader.rb +29 -0
- data/lib/dolly/curl/response_formatter.rb +30 -0
- data/lib/dolly/curl/stale_connection.rb +16 -0
- data/lib/dolly/curl/write_reconciler.rb +205 -0
- data/lib/dolly/curl.rb +17 -0
- data/lib/dolly/document.rb +1 -1
- data/lib/dolly/exceptions.rb +16 -0
- data/lib/dolly/version.rb +1 -1
- data/test/bulk_document_test.rb +67 -0
- data/test/connection_test.rb +145 -0
- data/test/curl/connection_test.rb +455 -0
- data/test/mango_index_test.rb +2 -2
- metadata +24 -12
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dolly/curl/document_helper'
|
|
4
|
+
require 'dolly/exceptions'
|
|
5
|
+
require 'dolly/curl/response_formatter'
|
|
6
|
+
require 'dolly/curl/stale_connection'
|
|
7
|
+
|
|
8
|
+
module Dolly
|
|
9
|
+
module Curl
|
|
10
|
+
# Reconciles ambiguous CouchDB writes after stale keep-alive failures.
|
|
11
|
+
# Depends on a transport that responds to +perform+ and +discard_handle+.
|
|
12
|
+
class WriteReconciler
|
|
13
|
+
include ResponseFormatter
|
|
14
|
+
|
|
15
|
+
def initialize(transport:, reader:, db_name:)
|
|
16
|
+
@transport = transport
|
|
17
|
+
@reader = reader
|
|
18
|
+
@documents = DocumentHelper.new(db_name: db_name)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def reconcile(method, uri, data, error, &block)
|
|
22
|
+
case method.to_sym
|
|
23
|
+
when :put then reconcile_put(uri, data, error, &block)
|
|
24
|
+
when :delete then reconcile_delete(uri, data, error, &block)
|
|
25
|
+
when :post then reconcile_post(uri, data, error, &block)
|
|
26
|
+
else raise_ambiguous(method, uri, error)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :transport, :reader, :documents
|
|
33
|
+
|
|
34
|
+
def reconcile_post(uri, data, error, &block)
|
|
35
|
+
return reconcile_bulk_docs(uri, data, error, &block) if bulk_docs?(uri)
|
|
36
|
+
|
|
37
|
+
raise_ambiguous(:post, uri, error)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def reconcile_put(uri, data, error, &block)
|
|
41
|
+
doc_id, intended = put_target(uri, data, error)
|
|
42
|
+
resolve_put(uri, data, doc_id, intended, error, &block)
|
|
43
|
+
rescue *StaleConnection::ERRORS => retry_error
|
|
44
|
+
transport.discard_handle
|
|
45
|
+
raise_ambiguous(:put, uri, retry_error)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def put_target(uri, data, error)
|
|
49
|
+
doc_id = documents.id_from_uri(uri)
|
|
50
|
+
raise_ambiguous(:put, uri, error) unless doc_id
|
|
51
|
+
|
|
52
|
+
intended = documents.normalize(data)
|
|
53
|
+
raise_ambiguous(:put, uri, error) unless intended
|
|
54
|
+
|
|
55
|
+
[doc_id, intended]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resolve_put(uri, data, doc_id, intended, error, &block)
|
|
59
|
+
stored = reader.fetch_document(doc_id)
|
|
60
|
+
return put_success(stored) if put_already_applied?(stored, intended)
|
|
61
|
+
return transport.perform(:put, uri, data, &block) if put_retryable?(stored, intended)
|
|
62
|
+
|
|
63
|
+
raise_ambiguous(:put, uri, error)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def put_already_applied?(stored, intended)
|
|
67
|
+
stored && documents.matches?(stored, intended)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def put_retryable?(stored, intended)
|
|
71
|
+
return documents.revision(intended).nil? if stored.nil?
|
|
72
|
+
|
|
73
|
+
revision = documents.revision(intended)
|
|
74
|
+
revision && documents.value(stored, :_rev) == revision
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def put_success(stored)
|
|
78
|
+
{
|
|
79
|
+
ok: true,
|
|
80
|
+
id: documents.value(stored, :_id),
|
|
81
|
+
rev: documents.value(stored, :_rev)
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def reconcile_delete(uri, data, error, &block)
|
|
86
|
+
doc_id = documents.id_from_uri(uri)
|
|
87
|
+
raise_ambiguous(:delete, uri, error) unless doc_id
|
|
88
|
+
|
|
89
|
+
expected_rev = documents.rev_from_uri(uri)
|
|
90
|
+
raise_ambiguous(:delete, uri, error) unless expected_rev
|
|
91
|
+
|
|
92
|
+
resolve_delete(uri, data, doc_id, expected_rev, error, &block)
|
|
93
|
+
rescue *StaleConnection::ERRORS => retry_error
|
|
94
|
+
transport.discard_handle
|
|
95
|
+
raise_ambiguous(:delete, uri, retry_error)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def resolve_delete(uri, data, doc_id, expected_rev, error, &block)
|
|
99
|
+
stored = reader.fetch_document(doc_id)
|
|
100
|
+
return { ok: true, id: doc_id } if stored.nil?
|
|
101
|
+
return { ok: true, id: doc_id } if documents.deleted?(stored)
|
|
102
|
+
return transport.perform(:delete, uri, data, &block) if documents.value(stored, :_rev) == expected_rev
|
|
103
|
+
|
|
104
|
+
raise_ambiguous(:delete, uri, error)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def reconcile_bulk_docs(uri, data, error, &block)
|
|
108
|
+
docs = documents.docs_from_bulk_payload(data)
|
|
109
|
+
raise_ambiguous(:post, uri, error) if docs.empty?
|
|
110
|
+
|
|
111
|
+
ids = docs.map { |doc| documents.id(doc) }.compact
|
|
112
|
+
raise_ambiguous(:post, uri, error) if ids.empty? || ids.size != docs.size
|
|
113
|
+
|
|
114
|
+
classify_and_retry_bulk(uri, docs, ids, error, &block)
|
|
115
|
+
rescue *StaleConnection::ERRORS => retry_error
|
|
116
|
+
transport.discard_handle
|
|
117
|
+
raise_ambiguous(:post, uri, retry_error)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def classify_and_retry_bulk(uri, docs, ids, error, &block)
|
|
121
|
+
rows_by_id = reader.fetch_documents_by_ids(ids)
|
|
122
|
+
results = []
|
|
123
|
+
pending = []
|
|
124
|
+
|
|
125
|
+
docs.each do |doc|
|
|
126
|
+
row = rows_by_id[documents.id(doc)]
|
|
127
|
+
stored = row && row[:doc]
|
|
128
|
+
|
|
129
|
+
if bulk_committed?(doc, stored, row)
|
|
130
|
+
results << bulk_success(doc, stored)
|
|
131
|
+
elsif bulk_unapplied?(doc, stored)
|
|
132
|
+
pending << doc
|
|
133
|
+
else
|
|
134
|
+
raise_ambiguous(:post, uri, error)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
results.concat(retry_pending_bulk(uri, pending, &block)) if pending.any?
|
|
139
|
+
results
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def retry_pending_bulk(uri, pending, &block)
|
|
143
|
+
retry_response = transport.perform(:post, uri, { docs: pending }, &block)
|
|
144
|
+
retry_results = response_format(retry_response, :post)
|
|
145
|
+
retry_results = [retry_results] unless retry_results.is_a?(Array)
|
|
146
|
+
retry_results
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def bulk_committed?(intended, stored, row)
|
|
150
|
+
return true if documents.deleted?(intended) && bulk_deleted?(stored, row)
|
|
151
|
+
return false if stored.nil?
|
|
152
|
+
|
|
153
|
+
documents.matches?(stored, intended)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def bulk_deleted?(stored, row)
|
|
157
|
+
stored.nil? || documents.deleted?(stored) || row&.dig(:value, :deleted)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def bulk_unapplied?(intended, stored)
|
|
161
|
+
return true if stored.nil? && !documents.deleted?(intended)
|
|
162
|
+
return true if delete_still_present?(intended, stored)
|
|
163
|
+
return true if update_still_at_source_rev?(intended, stored)
|
|
164
|
+
|
|
165
|
+
false
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def delete_still_present?(intended, stored)
|
|
169
|
+
documents.deleted?(intended) &&
|
|
170
|
+
stored &&
|
|
171
|
+
!documents.deleted?(stored) &&
|
|
172
|
+
documents.value(stored, :_rev) == documents.revision(intended)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def update_still_at_source_rev?(intended, stored)
|
|
176
|
+
!documents.deleted?(intended) &&
|
|
177
|
+
stored &&
|
|
178
|
+
documents.revision(intended) &&
|
|
179
|
+
documents.value(stored, :_rev) == documents.revision(intended)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def bulk_success(intended, stored)
|
|
183
|
+
id = documents.id(intended)
|
|
184
|
+
return deleted_bulk_success(id, stored) if documents.deleted?(intended)
|
|
185
|
+
|
|
186
|
+
{ ok: true, id: id, rev: documents.value(stored, :_rev) }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def deleted_bulk_success(id, stored)
|
|
190
|
+
result = { ok: true, id: id }
|
|
191
|
+
rev = stored && documents.value(stored, :_rev)
|
|
192
|
+
result[:rev] = rev if rev
|
|
193
|
+
result
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def bulk_docs?(uri)
|
|
197
|
+
uri.path.end_with?('/_bulk_docs')
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def raise_ambiguous(method, uri, error)
|
|
201
|
+
raise Dolly::AmbiguousWriteError.new(method: method, uri: uri, cause: error)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
data/lib/dolly/curl.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dolly/curl/stale_connection'
|
|
4
|
+
require 'dolly/curl/response_formatter'
|
|
5
|
+
require 'dolly/curl/document_helper'
|
|
6
|
+
require 'dolly/curl/reader'
|
|
7
|
+
require 'dolly/curl/write_reconciler'
|
|
8
|
+
require 'dolly/curl/connection'
|
|
9
|
+
|
|
10
|
+
module Dolly
|
|
11
|
+
# Namespace for Curb-based HTTP transport collaborators.
|
|
12
|
+
#
|
|
13
|
+
# Public CouchDB client API remains Dolly::Connection. Classes under
|
|
14
|
+
# Dolly::Curl are internal transport/retry helpers.
|
|
15
|
+
module Curl
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/dolly/document.rb
CHANGED
data/lib/dolly/exceptions.rb
CHANGED
|
@@ -47,6 +47,22 @@ module Dolly
|
|
|
47
47
|
end
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
class AmbiguousWriteError < RuntimeError
|
|
51
|
+
attr_reader :method, :uri, :cause
|
|
52
|
+
|
|
53
|
+
def initialize(method:, uri:, cause:)
|
|
54
|
+
@method = method
|
|
55
|
+
@uri = uri
|
|
56
|
+
@cause = cause
|
|
57
|
+
super()
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_s
|
|
61
|
+
"Ambiguous #{method.upcase} to #{uri} after #{cause.class}: #{cause.message}. " \
|
|
62
|
+
'The request may or may not have been applied.'
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
50
66
|
class PartitionedDataBaseExpectedError < RuntimeError; end
|
|
51
67
|
class IndexNotFoundError < RuntimeError; end
|
|
52
68
|
class InvalidConfigFileError < RuntimeError; end
|
data/lib/dolly/version.rb
CHANGED
data/test/bulk_document_test.rb
CHANGED
|
@@ -44,4 +44,71 @@ class BulkDocumentTest < Test::Unit::TestCase
|
|
|
44
44
|
assert_equal [], @doc.docs
|
|
45
45
|
assert_equal [], @doc.payload[:docs]
|
|
46
46
|
end
|
|
47
|
+
|
|
48
|
+
test 'save reconciles when bulk post is ambiguous but docs are committed' do
|
|
49
|
+
docs = 2.times.map { |i| Doc.new(name: "doc-#{i}").tap { |d| d.id = "doc/#{i}" } }
|
|
50
|
+
docs.each { |d| @doc << d }
|
|
51
|
+
|
|
52
|
+
connection = @doc.connection
|
|
53
|
+
curl = connection.send(:curl_connection)
|
|
54
|
+
curl.stubs(:perform).with do |method, uri, *_|
|
|
55
|
+
method.to_sym == :post && uri.path.end_with?('/_bulk_docs')
|
|
56
|
+
end.raises(Curl::Err::RecvError)
|
|
57
|
+
|
|
58
|
+
curl.reader.stubs(:fetch_documents_by_ids).returns(
|
|
59
|
+
docs.each_with_object({}) do |d, memo|
|
|
60
|
+
memo[d.id] = { id: d.id, doc: d.to_h.merge(_rev: "1-#{d.id}") }
|
|
61
|
+
end
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@doc.save
|
|
65
|
+
|
|
66
|
+
assert_equal [], @doc.errors
|
|
67
|
+
assert_equal [], @doc.docs
|
|
68
|
+
docs.each { |d| assert_equal "1-#{d.id}", d.rev }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
test 'save retries only unapplied docs after ambiguous bulk post' do
|
|
72
|
+
committed = Doc.new(name: 'committed').tap { |d| d.id = 'doc/1' }
|
|
73
|
+
pending = Doc.new(name: 'pending').tap { |d| d.id = 'doc/2' }
|
|
74
|
+
@doc << committed
|
|
75
|
+
@doc << pending
|
|
76
|
+
|
|
77
|
+
connection = @doc.connection
|
|
78
|
+
curl = connection.send(:curl_connection)
|
|
79
|
+
bulk_attempts = 0
|
|
80
|
+
retry_body = [{ ok: true, id: 'doc/2', rev: '2-doc/2' }]
|
|
81
|
+
pending_payloads = []
|
|
82
|
+
|
|
83
|
+
curl.stubs(:perform).with do |method, uri, data, *_|
|
|
84
|
+
next false unless method.to_sym == :post && uri.path.end_with?('/_bulk_docs')
|
|
85
|
+
|
|
86
|
+
bulk_attempts += 1
|
|
87
|
+
raise Curl::Err::RecvError if bulk_attempts == 1
|
|
88
|
+
|
|
89
|
+
pending_payloads << data
|
|
90
|
+
true
|
|
91
|
+
end.returns(
|
|
92
|
+
stub(
|
|
93
|
+
status: '200',
|
|
94
|
+
body_str: retry_body.to_json,
|
|
95
|
+
header_str: ''
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
curl.reader.stubs(:fetch_documents_by_ids).returns(
|
|
100
|
+
'doc/1' => { id: 'doc/1', doc: committed.to_h.merge(_rev: '1-doc/1') },
|
|
101
|
+
'doc/2' => { id: 'doc/2', error: 'not_found' }
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@doc.save
|
|
105
|
+
|
|
106
|
+
assert_equal 2, bulk_attempts
|
|
107
|
+
assert_equal 1, pending_payloads.size
|
|
108
|
+
assert_equal ['doc/2'], pending_payloads.first[:docs].map { |d| d[:_id] || d['_id'] }
|
|
109
|
+
assert_equal [], @doc.errors
|
|
110
|
+
assert_equal [], @doc.docs
|
|
111
|
+
assert_equal '1-doc/1', committed.rev
|
|
112
|
+
assert_equal '2-doc/2', pending.rev
|
|
113
|
+
end
|
|
47
114
|
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
|
|
5
|
+
class ConnectionTest < Test::Unit::TestCase
|
|
6
|
+
setup do
|
|
7
|
+
clear_thread_local_curl!
|
|
8
|
+
@connection = Dolly::Connection.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
teardown do
|
|
12
|
+
clear_thread_local_curl!
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
test 'response_format raises ResourceNotFound for 404 responses' do
|
|
16
|
+
response = build_curl_response(status: 404, body: '{"error":"not_found"}')
|
|
17
|
+
|
|
18
|
+
assert_raises(Dolly::ResourceNotFound) do
|
|
19
|
+
@connection.send(:response_format, response, :get)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
test 'response_format raises ServerError for 5xx responses' do
|
|
24
|
+
response = build_curl_response(status: 500, body: '{"error":"internal"}')
|
|
25
|
+
|
|
26
|
+
error = assert_raises(Dolly::ServerError) do
|
|
27
|
+
@connection.send(:response_format, response, :get)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
assert_equal 500, error.instance_variable_get(:@msg)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
test 'response_format raises ServerError for 4xx responses' do
|
|
34
|
+
response = build_curl_response(status: 400, body: '{"error":"bad_request"}')
|
|
35
|
+
|
|
36
|
+
error = assert_raises(Dolly::ServerError) do
|
|
37
|
+
@connection.send(:response_format, response, :get)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
assert_equal 400, error.instance_variable_get(:@msg)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
test 'response_format returns headers for head responses' do
|
|
44
|
+
response = build_curl_response(status: 200, body: '', headers: "ETag: \"abc\"\r\n")
|
|
45
|
+
|
|
46
|
+
assert_equal "ETag: \"abc\"\r\n", @connection.send(:response_format, response, :head)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
test 'response_format returns parsed json for successful responses' do
|
|
50
|
+
response = build_curl_response(status: 200, body: '{"ok":true,"count":2}')
|
|
51
|
+
|
|
52
|
+
assert_equal({ ok: true, count: 2 }, @connection.send(:response_format, response, :get))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
test 'response_format returns raw body when json parsing fails' do
|
|
56
|
+
response = build_curl_response(status: 200, body: 'plain text')
|
|
57
|
+
|
|
58
|
+
assert_equal 'plain text', @connection.send(:response_format, response, :get)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
test 'request performs get against couchdb with query params' do
|
|
62
|
+
stub_request(:get, 'http://localhost:5984/test/doc/1?rev=1-abc')
|
|
63
|
+
.to_return(status: 200, body: '{"ok":true}', headers: { 'Content-Type' => 'application/json' })
|
|
64
|
+
|
|
65
|
+
result = @connection.request(:get, 'doc/1', rev: '1-abc')
|
|
66
|
+
|
|
67
|
+
assert_equal({ ok: true }, result)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
test 'request path reuses thread local curl between calls' do
|
|
71
|
+
stub_request(:get, %r{http://localhost:5984/test/_uuids})
|
|
72
|
+
.to_return(status: 200, body: '{"uuids":["abc"]}', headers: { 'Content-Type' => 'application/json' })
|
|
73
|
+
|
|
74
|
+
@connection.tools('_uuids')
|
|
75
|
+
first_handle = @connection.send(:curl_connection).thread_local_handle
|
|
76
|
+
@connection.tools('_uuids')
|
|
77
|
+
second_handle = @connection.send(:curl_connection).thread_local_handle
|
|
78
|
+
|
|
79
|
+
assert_same first_handle, second_handle
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
test 'request performs post against couchdb' do
|
|
83
|
+
stub_request(:post, 'http://localhost:5984/test/_bulk_docs')
|
|
84
|
+
.with(body: '{"docs":[]}')
|
|
85
|
+
.to_return(status: 200, body: '{"ok":true}', headers: { 'Content-Type' => 'application/json' })
|
|
86
|
+
|
|
87
|
+
result = @connection.request(:post, '_bulk_docs', docs: [])
|
|
88
|
+
|
|
89
|
+
assert_equal({ ok: true }, result)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
test 'request performs put against couchdb' do
|
|
93
|
+
stub_request(:put, 'http://localhost:5984/test/doc%2F1')
|
|
94
|
+
.with(body: '{"foo":"bar"}')
|
|
95
|
+
.to_return(status: 201, body: '{"ok":true,"id":"doc/1","rev":"1-abc"}', headers: { 'Content-Type' => 'application/json' })
|
|
96
|
+
|
|
97
|
+
result = @connection.put('doc/1', foo: 'bar')
|
|
98
|
+
|
|
99
|
+
assert_equal({ ok: true, id: 'doc/1', rev: '1-abc' }, result)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
test 'request performs delete against couchdb' do
|
|
103
|
+
stub_request(:delete, 'http://localhost:5984/test/doc%2F1?rev=1-abc')
|
|
104
|
+
.to_return(status: 200, body: '{"ok":true}', headers: { 'Content-Type' => 'application/json' })
|
|
105
|
+
|
|
106
|
+
result = @connection.delete('doc/1', '1-abc')
|
|
107
|
+
|
|
108
|
+
assert_equal({ ok: true }, result)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
test 'request sends custom headers' do
|
|
112
|
+
stub_request(:put, 'http://localhost:5984/test/doc%2F1/attachment.txt')
|
|
113
|
+
.with(headers: { 'Content-Type' => 'text/plain' })
|
|
114
|
+
.to_return(status: 201, body: '{"ok":true}', headers: { 'Content-Type' => 'application/json' })
|
|
115
|
+
|
|
116
|
+
result = @connection.attach('doc/1', 'attachment.txt', 'payload', 'Content-Type' => 'text/plain')
|
|
117
|
+
|
|
118
|
+
assert_equal({ ok: true }, result)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
test 'curl transport helpers stay outside the public connection api' do
|
|
122
|
+
assert_raises(NoMethodError) { @connection.curl_connection }
|
|
123
|
+
assert_raises(NoMethodError) { @connection.fetch_document('doc/1') }
|
|
124
|
+
assert_raises(NoMethodError) { @connection.fetch_documents_by_ids(['doc/1']) }
|
|
125
|
+
|
|
126
|
+
curl = @connection.send(:curl_connection)
|
|
127
|
+
assert_equal 'test', curl.db_name
|
|
128
|
+
assert_instance_of Dolly::Curl::Reader, curl.reader
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def build_curl_response(status:, body:, headers: '')
|
|
134
|
+
stub(status: status.to_s, body_str: body, header_str: headers)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def clear_thread_local_curl!
|
|
138
|
+
store = Thread.current[Dolly::Curl::Connection::THREAD_STORE_KEY]
|
|
139
|
+
return unless store
|
|
140
|
+
|
|
141
|
+
store.each_value { |handle| handle.close rescue nil }
|
|
142
|
+
store.clear
|
|
143
|
+
Thread.current[Dolly::Curl::Connection::THREAD_STORE_KEY] = nil
|
|
144
|
+
end
|
|
145
|
+
end
|