yoga_pants 0.1.0

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