yoga_pants 0.1.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.
@@ -0,0 +1,144 @@
1
+ module YogaPants
2
+ class Client
3
+ # This class will handle:
4
+ # * connecting to ES nodes
5
+ # * failing over to nodes in a list
6
+ # * ES-specific error handling
7
+
8
+ class RequestError < RuntimeError
9
+ attr_reader :http_error
10
+
11
+ def initialize(message, http_error = nil)
12
+ @http_error = nil
13
+ super(message)
14
+ end
15
+ end
16
+
17
+ attr_accessor :hosts, :options, :active_host
18
+
19
+ def initialize(hosts, options = {})
20
+ @hosts = [hosts].flatten.freeze # Accept 1 or more hosts
21
+ @options = options
22
+ @max_retries = options[:max_retries] || 10
23
+ @retries = 0
24
+ reset_hosts
25
+ end
26
+
27
+ def reset_hosts
28
+ @active_hosts = hosts.dup
29
+ @active_host = @active_hosts.shift
30
+ end
31
+
32
+ def get(path, args = {})
33
+ with_error_handling do
34
+ connection.get(path, args)
35
+ end
36
+ end
37
+
38
+ def post(path, args = {})
39
+ with_error_handling do
40
+ connection.post(path, args)
41
+ end
42
+ end
43
+
44
+ def put(path, args = {})
45
+ with_error_handling do
46
+ connection.put(path, args)
47
+ end
48
+ end
49
+
50
+ def delete(path, args = {})
51
+ with_error_handling do
52
+ connection.delete(path, args)
53
+ end
54
+ end
55
+
56
+ BULK_OPERATIONS_WITH_DATA = [:index, :create].freeze
57
+ def bulk(path, operations, args = {})
58
+ path = path.sub(%r{/(?:_bulk)?$}, '/_bulk')
59
+
60
+ with_error_handling do
61
+ payload = StringIO.new
62
+
63
+ operations.each do |action, metadata, data|
64
+ payload << JSON.dump({action => metadata})
65
+ payload << "\n"
66
+ if BULK_OPERATIONS_WITH_DATA.include?(action.to_sym)
67
+ payload << JSON.dump(data)
68
+ payload << "\n"
69
+ end
70
+ end
71
+
72
+ payload.rewind
73
+ connection.post(path, :query_string => args, :body => payload.read)
74
+ end
75
+ end
76
+
77
+ def multi_search(path, operations, args={})
78
+ path = path.sub(%r{/?(?:_msearch)?$}, '/_msearch')
79
+
80
+ with_error_handling do
81
+ payload = StringIO.new
82
+
83
+ operations.each do |header, body|
84
+ payload << JSON.dump(header) << "\n"
85
+ payload << JSON.dump(body) << "\n"
86
+ end
87
+
88
+ payload.rewind
89
+ connection.get(path, :query_string => args, :body => payload.read)
90
+ end
91
+ end
92
+
93
+ def exists?(path, args = {})
94
+ with_error_handling do
95
+ begin
96
+ if path.count("/") >= 3 # More than
97
+ connection.get(path, args)
98
+ else
99
+ connection.head(path).status_code == 200
100
+ end
101
+ rescue Connection::HTTPError => e
102
+ if e.status_code == 404
103
+ false
104
+ else
105
+ raise e
106
+ end
107
+ end
108
+ end
109
+ end
110
+
111
+ def reset
112
+ connection.reset
113
+ end
114
+
115
+ private
116
+
117
+ def connection
118
+ @connection ||= Connection.new(active_host, options[:connection])
119
+ end
120
+
121
+ def pick_next_host
122
+ @active_host = @active_hosts.shift
123
+ @connection = nil
124
+ reset_hosts if active_host.nil?
125
+ end
126
+
127
+ def with_error_handling(&block)
128
+ block.call.tap do
129
+ @retries = 0
130
+ end
131
+ rescue Connection::HTTPError => e
132
+
133
+ if @retries <= @max_retries
134
+ @retries += 1
135
+ pick_next_host
136
+ retry
137
+ elsif e.body.is_a?(Hash) && error = e.body['error']
138
+ raise RequestError.new("ElasticSearch Error: #{error}", e).tap { |ex| ex.set_backtrace(e.backtrace) }
139
+ else
140
+ raise RequestError.new(e.message, e).tap { |ex| ex.set_backtrace(e.backtrace) }
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,150 @@
1
+ module YogaPants
2
+ class Connection
3
+ # This class is/will be an abstraction layer for the underlying
4
+ # HTTP client.
5
+ # TODO: Use https://github.com/rubiii/httpi so we don't have to deal
6
+ # with interfacing with multiple HTTP libraries
7
+
8
+ attr_accessor :host, :options
9
+
10
+ class HTTPError < RuntimeError
11
+ attr_reader :response, :status_code
12
+ def initialize(message, response = nil)
13
+ @response = response
14
+ if response
15
+ @status_code = response.status_code
16
+ super(message + "\nBody: #{response.body}")
17
+ else
18
+ super(message)
19
+ end
20
+ end
21
+
22
+ def body
23
+ return nil if response.nil?
24
+ @body ||= begin
25
+ JSON.load(response.body)
26
+ rescue MultiJson::DecodeError
27
+ response.body
28
+ end
29
+ end
30
+ end
31
+
32
+ def initialize(host, options = {})
33
+ @host = host.chomp('/')
34
+ @options = options || {}
35
+ @http = HTTPClient.new
36
+
37
+ default_timeout = @options[:timeout] || 5
38
+ http.connect_timeout = @options[:connect_timeout] || default_timeout
39
+ http.send_timeout = @options[:send_timeout] || default_timeout
40
+ http.receive_timeout = @options[:receive_timeout] || default_timeout
41
+ end
42
+
43
+ # Body can be a string or hash
44
+
45
+ def get(path, args = {})
46
+ with_error_handling do
47
+ parse_arguments_and_handle_response(args) do |query_string, body|
48
+ http.get(url_for(path), :query => query_string, :body => body)
49
+ end
50
+ end
51
+ end
52
+
53
+ def post(path, args = {})
54
+ with_error_handling do
55
+ parse_arguments_and_handle_response(args) do |query_string, body|
56
+ response = http.post(url_for(path), :query => query_string, :body => body)
57
+ end
58
+ end
59
+ end
60
+
61
+ def put(path, args = {})
62
+ with_error_handling do
63
+ parse_arguments_and_handle_response(args) do |query_string, body|
64
+ response = http.put(url_for(path), :query => query_string, :body => body)
65
+ end
66
+ end
67
+ end
68
+
69
+ def delete(path, args = {})
70
+ with_error_handling do
71
+ parse_arguments_and_handle_response(args) do |query_string, body|
72
+ response = http.delete(url_for(path), :query => query_string, :body => body)
73
+ end
74
+ end
75
+ end
76
+
77
+ def head(path, args = {})
78
+ with_error_handling do
79
+ query_string, _ = parse_arguments(args)
80
+ http.head(url_for(path), :query => query_string)
81
+ end
82
+ end
83
+
84
+ def reset
85
+ http.reset_all
86
+ end
87
+
88
+ private
89
+
90
+ def parse_arguments_and_handle_response(args, &block)
91
+ query_string, body = parse_arguments(args)
92
+ parse_and_handle_response(
93
+ block.call(query_string, body)
94
+ )
95
+ end
96
+
97
+ def parse_and_handle_response(response)
98
+ case response.status_code
99
+ when 200..299
100
+ JSON.load(response.body)
101
+ else
102
+ raise HTTPError.new("Error performing HTTP request: #{response.status_code} #{response.reason}", response)
103
+ end
104
+ end
105
+
106
+ def parse_arguments(args)
107
+ [args[:query_string], jsonify_body(args[:body])]
108
+ end
109
+
110
+ def with_error_handling(&block)
111
+ block.call
112
+ rescue HTTPError => e
113
+ raise e
114
+ rescue Errno::ECONNREFUSED
115
+ raise HTTPError.new("Connection refused to #{host}")
116
+ rescue HTTPClient::ConnectTimeoutError
117
+ raise HTTPError.new("Connection timed out to #{host}")
118
+ rescue HTTPClient::SendTimeoutError
119
+ raise HTTPError.new("Request send timed out to #{host}")
120
+ rescue HTTPClient::ReceiveTimeoutError
121
+ raise HTTPError.new("Receive timed out from #{host}")
122
+ rescue => e
123
+ raise HTTPError.new("Unhandled exception within YogaPants::Connection: #{e} - #{e.message}").tap { |ex| ex.set_backtrace(e.backtrace) }
124
+ end
125
+
126
+ def jsonify_body(string_or_hash)
127
+ return nil if string_or_hash.nil?
128
+ case string_or_hash
129
+ when Hash
130
+ JSON.dump(string_or_hash)
131
+ when String
132
+ string_or_hash
133
+ else
134
+ raise ArgumentError.new("Unrecognised body class #{string_or_hash.class}")
135
+ end
136
+ end
137
+
138
+ def http
139
+ @http
140
+ end
141
+
142
+ def url_for(path)
143
+ if path[0..0] == "/"
144
+ "#{host}#{path}"
145
+ else
146
+ "#{host}/#{path}"
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,25 @@
1
+ require 'multi_json'
2
+
3
+ # FIXME: This whole thing is cancer. tire uses multi_json 1.0.3 which
4
+ # uses decode/encode.
5
+
6
+ module YogaPants
7
+ class JSON
8
+ def self.load(*args)
9
+ if MultiJson.respond_to?(:decode)
10
+ MultiJson.decode(*args)
11
+ else
12
+ MultiJson.load(*args)
13
+ end
14
+ end
15
+
16
+ def self.dump(*args)
17
+ if MultiJson.respond_to?(:encode)
18
+ MultiJson.encode(*args)
19
+ else
20
+ MultiJson.dump(*args)
21
+ end
22
+ end
23
+
24
+ end
25
+ end
@@ -0,0 +1,3 @@
1
+ module YogaPants
2
+ VERSION = "0.1.0"
3
+ end
data/lib/yoga_pants.rb ADDED
@@ -0,0 +1,10 @@
1
+ require "httpclient"
2
+ require "multi_json"
3
+ require "yoga_pants/version"
4
+ require "yoga_pants/json"
5
+ require "yoga_pants/connection"
6
+ require "yoga_pants/client"
7
+
8
+ module YogaPants
9
+ # Your code goes here...
10
+ end
@@ -0,0 +1,205 @@
1
+ require "spec_helper"
2
+
3
+ module YogaPants
4
+ describe "basic integration tests" do
5
+ subject do
6
+ Client.new("http://localhost:9200/")
7
+ end
8
+
9
+ before do
10
+ VCR.use_cassette('before_block') do
11
+ if subject.exists?("/yoga_pants_test")
12
+ subject.delete("/yoga_pants_test")
13
+ end
14
+
15
+ if subject.exists?("/yoga_pants_test_1")
16
+ subject.delete("/yoga_pants_test_1")
17
+ end
18
+
19
+ subject.post("/yoga_pants_test")
20
+ subject.put("/yoga_pants_test/doc/_mapping", :body => {
21
+ :doc => {
22
+ :properties => {
23
+ :foo => {:type => 'string'},
24
+ :bar => {:type => 'integer'}
25
+ }
26
+ }
27
+ })
28
+ subject.post("/yoga_pants_test/_refresh")
29
+ end
30
+ end
31
+
32
+ it "indexes a valid document" do
33
+ VCR.use_cassette('indexing') do
34
+ subject.post("/yoga_pants_test/doc/1", :body => {
35
+ :foo => 'bar'
36
+ })
37
+ subject.get("/yoga_pants_test/doc/1").should include({
38
+ '_index' => 'yoga_pants_test',
39
+ '_type' => 'doc',
40
+ '_id' => '1',
41
+ 'exists' => true,
42
+ '_source' => {
43
+ 'foo' => 'bar'
44
+ }
45
+ })
46
+ end
47
+ end
48
+
49
+ describe "bulk operations" do
50
+ it 'does bulk operations just fine' do
51
+ VCR.use_cassette('bulk') do
52
+ subject.bulk("/", [
53
+ [:index, {:_index => 'yoga_pants_test', :_type => 'doc', :_id => 2}, {:foo => 'hello bulk'}],
54
+ [:index, {:_index => 'yoga_pants_test_1', :_type => 'doc2', :_id => 2}, {:foo => 'hello bulk 2'}]
55
+ ], :refresh => true)
56
+
57
+ subject.get("/yoga_pants_test/doc/2").should include({
58
+ '_index' => 'yoga_pants_test',
59
+ '_type' => 'doc',
60
+ '_id' => '2',
61
+ 'exists' => true,
62
+ '_source' => {
63
+ 'foo' => 'hello bulk'
64
+ }
65
+ })
66
+
67
+ subject.get("/yoga_pants_test_1/doc2/2").should include({
68
+ '_index' => 'yoga_pants_test_1',
69
+ '_type' => 'doc2',
70
+ '_id' => '2',
71
+ 'exists' => true,
72
+ '_source' => {
73
+ 'foo' => 'hello bulk 2'
74
+ }
75
+ })
76
+ end
77
+ end
78
+
79
+ it 'does not break on per-document index errors' do
80
+ VCR.use_cassette('bulk_document_index_error') do
81
+ ret = subject.bulk("/", [
82
+ [:index, {:_index => 'yoga_pants_test', :_type => 'doc', :_id => 1}, {:bar => 1}],
83
+ [:index, {:_index => 'yoga_pants_test', :_type => 'doc', :_id => 2}, {:bar => 'invalid'}],
84
+ ])
85
+ errors = ret['items'].select { |operation| operation['index']['error'] }
86
+ errors.length.should == 1
87
+ errors.first["index"]["_id"].should == '2'
88
+ subject.exists?("/yoga_pants_test/doc/1").should be_true
89
+ subject.exists?("/yoga_pants_test/doc/2").should be_false
90
+ end
91
+ end
92
+
93
+ it 'throws an exception when ES barfs' do
94
+ VCR.use_cassette('bulk_error') do
95
+ expect do
96
+ subject.bulk("/", [
97
+ [:index, {:_index => 'yoga_pants_test', :_type => 'doc', :_id => 1}, {:bar => 1}],
98
+ [:index, {:_index => '', :_type => 'doc', :_id => 2}, {:bar => 'invalid'}],
99
+ ])
100
+ end.to raise_error(Client::RequestError, "ElasticSearch Error: ElasticSearchException[String index out of range: 0]; nested: StringIndexOutOfBoundsException[String index out of range: 0]; ")
101
+
102
+ subject.exists?("/yoga_pants_test/doc/1").should be_false
103
+ subject.exists?("/yoga_pants_test/doc/2").should be_false
104
+ end
105
+ end
106
+ end
107
+
108
+ describe "multi search" do
109
+ it 'does bulk operations just fine' do
110
+ VCR.use_cassette('multi_search') do
111
+ result = subject.multi_search("/", [
112
+ [{'index' => 'yoga_pants_test', 'type' => 'doc'}, {'query' => {'match_all' => {}}}],
113
+ [{'index' => 'yoga_pants_test_1', 'type' => 'doc2'}, {'query' => {'match_all' => {}}}],
114
+ ])
115
+
116
+ result.should have_key('responses')
117
+ result['responses'].size.should == 2
118
+
119
+ end
120
+ end
121
+ end
122
+
123
+ it "raises an exception on missing documents" do
124
+ VCR.use_cassette('missing') do
125
+ expect { subject.get("/yoga_pants_test/doc/not_exist") }.to raise_error(Client::RequestError, "Error performing HTTP request: 404 Not Found\nBody: #{'{"_index":"yoga_pants_test","_type":"doc","_id":"not_exist","exists":false}'}")
126
+ end
127
+ end
128
+
129
+ it "raises an exception on an invalid request" do
130
+ VCR.use_cassette('invalid_request') do
131
+ expect { subject.get("/this_does_not_exist") }.to raise_error(Client::RequestError, "Error performing HTTP request: 400 Bad Request\nBody: No handler found for uri [/this_does_not_exist] and method [GET]")
132
+ end
133
+ end
134
+
135
+ it "raises an exception when ES returns a hash with an error object" do
136
+ VCR.use_cassette('error') do
137
+ subject.post("/yoga_pants_test/doc/1", :body => {
138
+ :foo => 'bar'
139
+ })
140
+ expect { subject.get("/yoga_pants_test/doc/1/_mlt?min_term_freq=invalid") }.to raise_error(Client::RequestError, "ElasticSearch Error: Failed to parse int parameter [min_term_freq] with value [invalid]")
141
+ end
142
+ end
143
+
144
+ end
145
+
146
+ describe "network-related failures" do
147
+ let(:host) { "http://localhost:9200" }
148
+ subject do
149
+ Client.new(host, :connection => {:connect_timeout => 0.01})
150
+ end
151
+
152
+ context "connection refused" do
153
+ let(:host) { "http://localhost:1" } # Unlikely to be anything running on this port
154
+
155
+ it "raises an RequestError" do
156
+ VCR.use_cassette('connection_refused') do
157
+ expect { subject.exists?("/foo") }.to raise_error(Client::RequestError, "Connection refused to http://localhost:1")
158
+ end
159
+ end
160
+ end
161
+
162
+ context "connection timed out" do
163
+ let(:host) { "http://192.168.34.58" } # Ewww but yeah.
164
+
165
+ it "raises an RequestError" do
166
+ VCR.use_cassette('timed_out') do
167
+ expect { subject.exists?("/foo") }.to raise_error(Client::RequestError, "Connection timed out to #{host}")
168
+ end
169
+ end
170
+ end
171
+ end
172
+
173
+ describe "failing over to other nodes" do
174
+ subject do
175
+ Client.new(hosts)
176
+ end
177
+
178
+ describe "connection refused on first node" do
179
+ let(:hosts) { ["http://localhost:1/", "http://localhost:9200/"] }
180
+ it "automatically fails over" do
181
+ VCR.use_cassette('failover_refused') do
182
+ subject.exists?("/foo").should == false
183
+ end
184
+ end
185
+ end
186
+
187
+ describe "connection timed out on first node" do
188
+ let(:hosts) { ["http://10.13.37.3:1/", "http://localhost:9200/"] }
189
+ it "automatically fails over" do
190
+ VCR.use_cassette('failover_timeout') do
191
+ subject.exists?("/foo").should == false
192
+ end
193
+ end
194
+ end
195
+
196
+ describe "no working hosts" do
197
+ let(:hosts) { ["http://localhost:1/", "http://localhost:2/"] }
198
+ it "throws an exception" do
199
+ VCR.use_cassette('failover_impossible') do
200
+ expect { subject.exists?("/foo") }.to raise_error(Client::RequestError, /Connection refused to/)
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,9 @@
1
+ require 'vcr'
2
+ require 'yoga_pants'
3
+
4
+ VCR.configure do |c|
5
+ c.cassette_library_dir = 'fixtures/vcr_cassettes'
6
+ c.hook_into :webmock
7
+ c.allow_http_connections_when_no_cassette = true if ENV['ALLOW_NO_CASSETTE']
8
+ c.ignore_localhost = true if ENV['INTEGRATION']
9
+ end
@@ -0,0 +1,26 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/yoga_pants/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = ["Jack Chen (chendo)"]
6
+ gem.description = %q{A super lightweight interface to ElasticSearch's HTTP REST API}
7
+ gem.summary = <<-TEXT.strip
8
+ A super lightweight interface to ElasticSearch's HTTP REST API.
9
+ TEXT
10
+ gem.homepage = "https://github.com/chendo/yoga_pants"
11
+
12
+ gem.files = `git ls-files`.split($\)
13
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
14
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
15
+ gem.name = "yoga_pants"
16
+ gem.require_paths = ["lib"]
17
+ gem.version = YogaPants::VERSION
18
+
19
+ gem.add_runtime_dependency 'httpclient', '2.2.5'
20
+ gem.add_runtime_dependency 'multi_json'
21
+
22
+ gem.add_development_dependency 'rspec'
23
+ gem.add_development_dependency 'vcr'
24
+ gem.add_development_dependency 'webmock'
25
+ gem.add_development_dependency 'rake'
26
+ end