elasticsearch 7.13.3 → 8.14.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile +8 -6
- data/README.md +71 -33
- data/Rakefile +15 -20
- data/bin/elastic_ruby_console +2 -18
- data/elasticsearch.gemspec +24 -35
- data/lib/elasticsearch/helpers/bulk_helper.rb +129 -0
- data/lib/elasticsearch/helpers/esql_helper.rb +72 -0
- data/lib/elasticsearch/helpers/scroll_helper.rb +95 -0
- data/lib/elasticsearch/version.rb +1 -1
- data/lib/elasticsearch.rb +167 -7
- data/spec/integration/characters_escaping_spec.rb +94 -0
- data/spec/integration/client_integration_spec.rb +64 -0
- data/spec/integration/helpers/bulk_helper_spec.rb +206 -0
- data/spec/integration/helpers/esql_helper_spec.rb +118 -0
- data/spec/integration/helpers/helpers_spec_helper.rb +29 -0
- data/spec/integration/helpers/scroll_helper_spec.rb +81 -0
- data/spec/integration/opentelemetry_spec.rb +55 -0
- data/spec/spec_helper.rb +43 -0
- data/spec/unit/api_key_spec.rb +138 -0
- data/spec/unit/cloud_credentials_spec.rb +167 -0
- data/spec/unit/custom_transport_implementation_spec.rb +43 -0
- data/spec/unit/elasticsearch_product_validation_spec.rb +148 -0
- data/spec/unit/headers_spec.rb +55 -0
- data/spec/unit/opaque_id_spec.rb +48 -0
- data/spec/unit/user_agent_spec.rb +69 -0
- data/{test/unit/wrapper_gem_test.rb → spec/unit/wrapper_gem_spec.rb} +11 -21
- metadata +64 -111
- data/test/integration/client_integration_test.rb +0 -80
- data/test/test_helper.rb +0 -115
- /data/{LICENSE → LICENSE.txt} +0 -0
data/lib/elasticsearch.rb
CHANGED
@@ -15,18 +15,178 @@
|
|
15
15
|
# specific language governing permissions and limitations
|
16
16
|
# under the License.
|
17
17
|
|
18
|
-
require
|
19
|
-
|
20
|
-
require 'elasticsearch/transport'
|
18
|
+
require 'elasticsearch/version'
|
19
|
+
require 'elastic/transport'
|
21
20
|
require 'elasticsearch/api'
|
22
21
|
|
23
22
|
module Elasticsearch
|
24
|
-
|
25
|
-
|
26
|
-
|
23
|
+
NOT_ELASTICSEARCH_WARNING = 'The client noticed that the server is not Elasticsearch and we do not support this unknown product.'.freeze
|
24
|
+
SECURITY_PRIVILEGES_VALIDATION_WARNING = 'The client is unable to verify that the server is Elasticsearch due to security privileges on the server side. Some functionality may not be compatible if the server is running an unsupported product.'.freeze
|
25
|
+
VALIDATION_WARNING = 'The client is unable to verify that the server is Elasticsearch. Some functionality may not be compatible if the server is running an unsupported product.'.freeze
|
26
|
+
|
27
|
+
# This is the stateful Elasticsearch::Client, using an instance of elastic-transport.
|
28
|
+
class Client
|
29
|
+
include Elasticsearch::API
|
30
|
+
# The default port to use if connecting using a Cloud ID.
|
31
|
+
# Updated from 9243 to 443 in client version 7.10.1
|
32
|
+
#
|
33
|
+
# @since 7.2.0
|
34
|
+
DEFAULT_CLOUD_PORT = 443
|
35
|
+
|
36
|
+
# Create a client connected to an Elasticsearch cluster.
|
37
|
+
#
|
38
|
+
# @param [Hash] arguments - initializer arguments
|
39
|
+
# @option arguments [String] :cloud_id - The Cloud ID to connect to Elastic Cloud
|
40
|
+
# @option arguments [String, Hash] :api_key Use API Key Authentication, either the base64 encoding of `id` and `api_key`
|
41
|
+
# joined by a colon as a String, or a hash with the `id` and `api_key` values.
|
42
|
+
# @option arguments [String] :opaque_id_prefix set a prefix for X-Opaque-Id when initializing the client.
|
43
|
+
# This will be prepended to the id you set before each request
|
44
|
+
# if you're using X-Opaque-Id
|
45
|
+
# @option arguments [Hash] :headers Custom HTTP Request Headers
|
46
|
+
#
|
47
|
+
def initialize(arguments = {}, &block)
|
48
|
+
@verified = false
|
49
|
+
@warned = false
|
50
|
+
@opaque_id_prefix = arguments[:opaque_id_prefix] || nil
|
51
|
+
api_key(arguments) if arguments[:api_key]
|
52
|
+
setup_cloud(arguments) if arguments[:cloud_id]
|
53
|
+
set_user_agent!(arguments) unless sent_user_agent?(arguments)
|
54
|
+
@transport = Elastic::Transport::Client.new(arguments, &block)
|
55
|
+
end
|
56
|
+
|
57
|
+
def method_missing(name, *args, &block)
|
58
|
+
if methods.include?(name)
|
59
|
+
super
|
60
|
+
elsif name == :perform_request
|
61
|
+
# The signature for perform_request is:
|
62
|
+
# method, path, params, body, headers, opts
|
63
|
+
if (opaque_id = args[2]&.delete(:opaque_id))
|
64
|
+
headers = args[4] || {}
|
65
|
+
opaque_id = @opaque_id_prefix ? "#{@opaque_id_prefix}#{opaque_id}" : opaque_id
|
66
|
+
args[4] = headers.merge('X-Opaque-Id' => opaque_id)
|
67
|
+
end
|
68
|
+
unless @verified
|
69
|
+
verify_elasticsearch(*args, &block)
|
70
|
+
else
|
71
|
+
@transport.perform_request(*args, &block)
|
72
|
+
end
|
73
|
+
else
|
74
|
+
@transport.send(name, *args, &block)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def respond_to_missing?(method_name, *args)
|
79
|
+
@transport.respond_to?(method_name) || super
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def verify_elasticsearch(*args, &block)
|
85
|
+
begin
|
86
|
+
response = @transport.perform_request(*args, &block)
|
87
|
+
rescue Elastic::Transport::Transport::Errors::Unauthorized,
|
88
|
+
Elastic::Transport::Transport::Errors::Forbidden,
|
89
|
+
Elastic::Transport::Transport::Errors::RequestEntityTooLarge => e
|
90
|
+
warn(SECURITY_PRIVILEGES_VALIDATION_WARNING)
|
91
|
+
@verified = true
|
92
|
+
raise e
|
93
|
+
rescue Elastic::Transport::Transport::Error => e
|
94
|
+
unless @warned
|
95
|
+
warn(VALIDATION_WARNING)
|
96
|
+
@warned = true
|
97
|
+
end
|
98
|
+
raise e
|
99
|
+
rescue StandardError => e
|
100
|
+
warn(VALIDATION_WARNING)
|
101
|
+
raise e
|
102
|
+
end
|
103
|
+
raise Elasticsearch::UnsupportedProductError unless response.headers['x-elastic-product'] == 'Elasticsearch'
|
104
|
+
@verified = true
|
105
|
+
response
|
106
|
+
end
|
107
|
+
|
108
|
+
def setup_cloud_host(cloud_id, user, password, port)
|
109
|
+
name = cloud_id.split(':')[0]
|
110
|
+
base64_decoded = cloud_id.gsub("#{name}:", '').unpack1('m')
|
111
|
+
cloud_url, elasticsearch_instance = base64_decoded.split('$')
|
112
|
+
|
113
|
+
if cloud_url.include?(':')
|
114
|
+
url, port = cloud_url.split(':')
|
115
|
+
host = "#{elasticsearch_instance}.#{url}"
|
116
|
+
else
|
117
|
+
host = "#{elasticsearch_instance}.#{cloud_url}"
|
118
|
+
port ||= DEFAULT_CLOUD_PORT
|
119
|
+
end
|
120
|
+
[{ scheme: 'https', user: user, password: password, host: host, port: port.to_i }]
|
121
|
+
end
|
122
|
+
|
123
|
+
def api_key(arguments)
|
124
|
+
api_key = if arguments[:api_key].is_a? Hash
|
125
|
+
encode(arguments[:api_key])
|
126
|
+
else
|
127
|
+
arguments[:api_key]
|
128
|
+
end
|
129
|
+
arguments.delete(:user)
|
130
|
+
arguments.delete(:password)
|
131
|
+
authorization = { 'Authorization' => "ApiKey #{api_key}" }
|
132
|
+
if (headers = arguments.dig(:transport_options, :headers))
|
133
|
+
headers.merge!(authorization)
|
134
|
+
else
|
135
|
+
arguments[:transport_options] ||= {}
|
136
|
+
arguments[:transport_options].merge!({ headers: authorization })
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def setup_cloud(arguments)
|
141
|
+
arguments[:hosts] = setup_cloud_host(
|
142
|
+
arguments[:cloud_id],
|
143
|
+
arguments[:user],
|
144
|
+
arguments[:password],
|
145
|
+
arguments[:port]
|
146
|
+
)
|
147
|
+
end
|
148
|
+
|
149
|
+
# Encode credentials for the Authorization Header
|
150
|
+
# Credentials is the base64 encoding of id and api_key joined by a colon
|
151
|
+
# @see https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-api-key.html
|
152
|
+
def encode(api_key)
|
153
|
+
credentials = [api_key[:id], api_key[:api_key]].join(':')
|
154
|
+
[credentials].pack('m0')
|
155
|
+
end
|
156
|
+
|
157
|
+
def elasticsearch_validation_request
|
158
|
+
@transport.perform_request('GET', '/')
|
159
|
+
end
|
160
|
+
|
161
|
+
def sent_user_agent?(arguments)
|
162
|
+
return unless (headers = arguments&.[](:transport_options)&.[](:headers))
|
163
|
+
!!headers.keys.detect { |h| h =~ /user-?_?agent/ }
|
164
|
+
end
|
165
|
+
|
166
|
+
def set_user_agent!(arguments)
|
167
|
+
user_agent = [
|
168
|
+
"elasticsearch-ruby/#{Elasticsearch::VERSION}",
|
169
|
+
"elastic-transport-ruby/#{Elastic::Transport::VERSION}",
|
170
|
+
"RUBY_VERSION: #{RUBY_VERSION}"
|
171
|
+
]
|
172
|
+
if RbConfig::CONFIG && RbConfig::CONFIG['host_os']
|
173
|
+
user_agent << "#{RbConfig::CONFIG['host_os'].split('_').first[/[a-z]+/i].downcase} #{RbConfig::CONFIG['target_cpu']}"
|
174
|
+
end
|
175
|
+
arguments[:transport_options] ||= {}
|
176
|
+
arguments[:transport_options][:headers] ||= {}
|
177
|
+
arguments[:transport_options][:headers].merge!({ user_agent: user_agent.join('; ')})
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
# Error class for when we detect an unsupported version of Elasticsearch
|
182
|
+
class UnsupportedProductError < StandardError
|
183
|
+
def initialize(message = NOT_ELASTICSEARCH_WARNING)
|
184
|
+
super(message)
|
27
185
|
end
|
28
186
|
end
|
29
187
|
end
|
188
|
+
|
189
|
+
# Helper for the meta-header value for Cloud
|
30
190
|
module Elastic
|
31
191
|
# If the version is X.X.X.pre/alpha/beta, use X.X.Xp for the meta-header:
|
32
192
|
def self.client_meta_version
|
@@ -37,6 +197,6 @@ module Elastic
|
|
37
197
|
Elasticsearch::VERSION
|
38
198
|
end
|
39
199
|
|
40
|
-
# Constant for
|
200
|
+
# Constant for elastic-transport meta-header
|
41
201
|
ELASTICSEARCH_SERVICE_VERSION = [:es, client_meta_version].freeze
|
42
202
|
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
# Licensed to Elasticsearch B.V. under one or more contributor
|
2
|
+
# license agreements. See the NOTICE file distributed with
|
3
|
+
# this work for additional information regarding copyright
|
4
|
+
# ownership. Elasticsearch B.V. licenses this file to you under
|
5
|
+
# the Apache License, Version 2.0 (the "License"); you may
|
6
|
+
# not use this file except in compliance with the License.
|
7
|
+
# You may obtain a copy of the License at
|
8
|
+
#
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
10
|
+
#
|
11
|
+
# Unless required by applicable law or agreed to in writing,
|
12
|
+
# software distributed under the License is distributed on an
|
13
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
14
|
+
# KIND, either express or implied. See the License for the
|
15
|
+
# specific language governing permissions and limitations
|
16
|
+
# under the License.
|
17
|
+
require 'spec_helper'
|
18
|
+
|
19
|
+
ELASTICSEARCH_URL = ENV['TEST_ES_SERVER'] || "http://localhost:#{(ENV['PORT'] || 9200)}"
|
20
|
+
raise URI::InvalidURIError unless ELASTICSEARCH_URL =~ /\A#{URI::DEFAULT_PARSER.make_regexp}\z/
|
21
|
+
|
22
|
+
context 'Elasticsearch client' do
|
23
|
+
let(:client) do
|
24
|
+
Elasticsearch::Client.new(host: ELASTICSEARCH_URL, user: 'elastic', password: 'changeme')
|
25
|
+
end
|
26
|
+
let(:index) { 'tvs' }
|
27
|
+
|
28
|
+
after do
|
29
|
+
client.indices.delete(index: index)
|
30
|
+
end
|
31
|
+
|
32
|
+
context 'escaping spaces in ids' do
|
33
|
+
it 'escapes spaces for id when using index' do
|
34
|
+
response = client.index(index: index, id: 'a test 1', body: { name: 'A test 1' }, refresh: true)
|
35
|
+
expect(response.body['_id']).to eq 'a test 1'
|
36
|
+
|
37
|
+
response = client.search(index: index)
|
38
|
+
expect(response.body['hits']['hits'].first['_id']).to eq 'a test 1'
|
39
|
+
|
40
|
+
# Raises exception, _id is unrecognized
|
41
|
+
expect do
|
42
|
+
client.index(index: index, _id: 'a test 2', body: { name: 'A test 2' })
|
43
|
+
end.to raise_exception Elastic::Transport::Transport::Errors::BadRequest
|
44
|
+
|
45
|
+
# Raises exception, id is a query parameter
|
46
|
+
expect do
|
47
|
+
client.index(index: index, body: { name: 'A test 3', _id: 'a test 3' })
|
48
|
+
end.to raise_exception Elastic::Transport::Transport::Errors::BadRequest
|
49
|
+
end
|
50
|
+
|
51
|
+
it 'escapes spaces for id when using create' do
|
52
|
+
# Works with create
|
53
|
+
response = client.create(index: index, id: 'a test 4', body: { name: 'A test 4' })
|
54
|
+
expect(response.body['_id']).to eq 'a test 4'
|
55
|
+
end
|
56
|
+
|
57
|
+
it 'escapes spaces for id when using bulk' do
|
58
|
+
body = [
|
59
|
+
{ create: { _index: index, _id: 'a test 5', data: { name: 'A test 5' } } }
|
60
|
+
]
|
61
|
+
expect(client.bulk(body: body, refresh: true).status).to eq 200
|
62
|
+
|
63
|
+
response = client.search(index: index)
|
64
|
+
expect(
|
65
|
+
response.body['hits']['hits'].select { |a| a['_id'] == 'a test 5' }.size
|
66
|
+
).to eq 1
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
context 'it doesnae escape plus signs in id' do
|
71
|
+
it 'escapes spaces for id when using index' do
|
72
|
+
response = client.index(index: index, id: 'a+test+1', body: { name: 'A test 1' })
|
73
|
+
expect(response.body['_id']).to eq 'a+test+1'
|
74
|
+
end
|
75
|
+
|
76
|
+
it 'escapes spaces for id when using create' do
|
77
|
+
# Works with create
|
78
|
+
response = client.create(index: index, id: 'a+test+2', body: { name: 'A test 2' })
|
79
|
+
expect(response.body['_id']).to eq 'a+test+2'
|
80
|
+
end
|
81
|
+
|
82
|
+
it 'escapes spaces for id when using bulk' do
|
83
|
+
body = [
|
84
|
+
{ create: { _index: index, _id: 'a+test+3', data: { name: 'A test 3' } } }
|
85
|
+
]
|
86
|
+
expect(client.bulk(body: body, refresh: true).status).to eq 200
|
87
|
+
|
88
|
+
response = client.search(index: index)
|
89
|
+
expect(
|
90
|
+
response.body['hits']['hits'].select { |a| a['_id'] == 'a+test+3' }.size
|
91
|
+
).to eq 1
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# Licensed to Elasticsearch B.V. under one or more contributor
|
2
|
+
# license agreements. See the NOTICE file distributed with
|
3
|
+
# this work for additional information regarding copyright
|
4
|
+
# ownership. Elasticsearch B.V. licenses this file to you under
|
5
|
+
# the Apache License, Version 2.0 (the "License"); you may
|
6
|
+
# not use this file except in compliance with the License.
|
7
|
+
# You may obtain a copy of the License at
|
8
|
+
#
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
10
|
+
#
|
11
|
+
# Unless required by applicable law or agreed to in writing,
|
12
|
+
# software distributed under the License is distributed on an
|
13
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
14
|
+
# KIND, either express or implied. See the License for the
|
15
|
+
# specific language governing permissions and limitations
|
16
|
+
# under the License.
|
17
|
+
|
18
|
+
require 'spec_helper'
|
19
|
+
require 'logger'
|
20
|
+
|
21
|
+
ELASTICSEARCH_URL = ENV['TEST_ES_SERVER'] || "http://localhost:#{(ENV['PORT'] || 9200)}"
|
22
|
+
raise URI::InvalidURIError unless ELASTICSEARCH_URL =~ /\A#{URI::DEFAULT_PARSER.make_regexp}\z/
|
23
|
+
|
24
|
+
context 'Elasticsearch client' do
|
25
|
+
let(:logger) { Logger.new($stderr) }
|
26
|
+
|
27
|
+
let(:client) do
|
28
|
+
Elasticsearch::Client.new(
|
29
|
+
host: ELASTICSEARCH_URL,
|
30
|
+
logger: logger,
|
31
|
+
user: 'elastic',
|
32
|
+
password: 'changeme'
|
33
|
+
)
|
34
|
+
end
|
35
|
+
|
36
|
+
context 'Integrates with elasticsearch API' do
|
37
|
+
it 'should perform the API methods' do
|
38
|
+
expect do
|
39
|
+
# Index a document
|
40
|
+
client.index(index: 'test-index', id: '1', body: { title: 'Test' })
|
41
|
+
|
42
|
+
# Refresh the index
|
43
|
+
client.indices.refresh(index: 'test-index')
|
44
|
+
|
45
|
+
# Search
|
46
|
+
response = client.search(index: 'test-index', body: { query: { match: { title: 'test' } } })
|
47
|
+
|
48
|
+
expect(response['hits']['total']['value']).to eq 1
|
49
|
+
expect(response['hits']['hits'][0]['_source']['title']).to eq 'Test'
|
50
|
+
|
51
|
+
# Delete the index
|
52
|
+
client.indices.delete(index: 'test-index')
|
53
|
+
end.not_to raise_error
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
context 'Reports the right meta header' do
|
58
|
+
it 'Reports es service name and gem version' do
|
59
|
+
headers = client.transport.connections.first.connection.headers
|
60
|
+
version = Class.new.extend(Elastic::Transport::MetaHeader).send(:client_meta_version, Elasticsearch::VERSION)
|
61
|
+
expect(headers['x-elastic-client-meta']).to match /^es=#{version}/
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,206 @@
|
|
1
|
+
# Licensed to Elasticsearch B.V. under one or more contributor
|
2
|
+
# license agreements. See the NOTICE file distributed with
|
3
|
+
# this work for additional information regarding copyright
|
4
|
+
# ownership. Elasticsearch B.V. licenses this file to you under
|
5
|
+
# the Apache License, Version 2.0 (the "License"); you may
|
6
|
+
# not use this file except in compliance with the License.
|
7
|
+
# You may obtain a copy of the License at
|
8
|
+
#
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
10
|
+
#
|
11
|
+
# Unless required by applicable law or agreed to in writing,
|
12
|
+
# software distributed under the License is distributed on an
|
13
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
14
|
+
# KIND, either express or implied. See the License for the
|
15
|
+
# specific language governing permissions and limitations
|
16
|
+
# under the License.
|
17
|
+
require_relative 'helpers_spec_helper'
|
18
|
+
require 'elasticsearch/helpers/bulk_helper'
|
19
|
+
require 'tempfile'
|
20
|
+
|
21
|
+
context 'Elasticsearch client helpers' do
|
22
|
+
context 'Bulk helper' do
|
23
|
+
let(:index) { 'bulk_animals' }
|
24
|
+
let(:index_slice) { 'bulk_animals_slice' }
|
25
|
+
let(:params) { { refresh: 'wait_for' } }
|
26
|
+
let(:bulk_helper) { Elasticsearch::Helpers::BulkHelper.new(client, index, params) }
|
27
|
+
let(:docs) do
|
28
|
+
[
|
29
|
+
{ scientific_name: 'Lama guanicoe', name:'Guanaco' },
|
30
|
+
{ scientific_name: 'Tayassu pecari', name:'White-lipped peccary' },
|
31
|
+
{ scientific_name: 'Snycerus caffer', name:'Buffalo, african' },
|
32
|
+
{ scientific_name: 'Coluber constrictor', name:'Snake, racer' },
|
33
|
+
{ scientific_name: 'Thalasseus maximus', name:'Royal tern' },
|
34
|
+
{ scientific_name: 'Centrocercus urophasianus', name:'Hen, sage' },
|
35
|
+
{ scientific_name: 'Sitta canadensis', name:'Nuthatch, red-breasted' },
|
36
|
+
{ scientific_name: 'Aegypius tracheliotus', name:'Vulture, lappet-faced' },
|
37
|
+
{ scientific_name: 'Bucephala clangula', name:'Common goldeneye' },
|
38
|
+
{ scientific_name: 'Felis pardalis', name:'Ocelot' }
|
39
|
+
]
|
40
|
+
end
|
41
|
+
|
42
|
+
after do
|
43
|
+
client.indices.delete(index: index, ignore: 404)
|
44
|
+
client.indices.delete(index: index_slice, ignore: 404)
|
45
|
+
end
|
46
|
+
|
47
|
+
it 'Ingests documents' do
|
48
|
+
response = bulk_helper.ingest(docs)
|
49
|
+
expect(response).to be_an_instance_of Elasticsearch::API::Response
|
50
|
+
expect(response.status).to eq(200)
|
51
|
+
expect(response['items'].map { |a| a['index']['status'] }.uniq.first).to eq 201
|
52
|
+
end
|
53
|
+
|
54
|
+
it 'Updates documents' do
|
55
|
+
docs = [
|
56
|
+
{ scientific_name: 'Otocyon megalotos', name: 'Bat-eared fox' },
|
57
|
+
{ scientific_name: 'Herpestes javanicus', name: 'Small Indian mongoose' }
|
58
|
+
]
|
59
|
+
bulk_helper.ingest(docs)
|
60
|
+
# Get the ingested documents, add id and modify them to update them:
|
61
|
+
animals = client.search(index: index)['hits']['hits']
|
62
|
+
# Add id to each doc
|
63
|
+
docs = animals.map { |animal| animal['_source'].merge({'id' => animal['_id'] }) }
|
64
|
+
docs.map { |doc| doc['scientific_name'].upcase! }
|
65
|
+
response = bulk_helper.update(docs)
|
66
|
+
expect(response.status).to eq(200)
|
67
|
+
expect(response['items'].map { |i| i['update']['result'] }.uniq.first).to eq('updated')
|
68
|
+
end
|
69
|
+
|
70
|
+
it 'Deletes documents' do
|
71
|
+
response = bulk_helper.ingest(docs)
|
72
|
+
ids = response.body['items'].map { |a| a['index']['_id'] }
|
73
|
+
response = bulk_helper.delete(ids)
|
74
|
+
expect(response.status).to eq 200
|
75
|
+
expect(response['items'].map { |item| item['delete']['result'] }.uniq.first).to eq('deleted')
|
76
|
+
expect(client.count(index: index)['count']).to eq(0)
|
77
|
+
end
|
78
|
+
|
79
|
+
it 'Ingests documents and yields response and docs' do
|
80
|
+
slice = 2
|
81
|
+
bulk_helper = Elasticsearch::Helpers::BulkHelper.new(client, index_slice, params)
|
82
|
+
response = bulk_helper.ingest(docs, {slice: slice}) do |response, docs|
|
83
|
+
expect(response).to be_an_instance_of Elasticsearch::API::Response
|
84
|
+
expect(docs.count).to eq slice
|
85
|
+
end
|
86
|
+
response = client.search(index: index_slice, size: 200)
|
87
|
+
expect(response['hits']['hits'].map { |a| a['_source'].transform_keys(&:to_sym) }).to eq docs
|
88
|
+
end
|
89
|
+
|
90
|
+
context 'JSON File helper' do
|
91
|
+
let(:file) { Tempfile.new('test-data.json') }
|
92
|
+
let(:json) do
|
93
|
+
json = <<~JSON
|
94
|
+
[
|
95
|
+
{
|
96
|
+
"character_name": "Anallese Lonie",
|
97
|
+
"species": "mouse",
|
98
|
+
"catchphrase": "Seamless regional definition",
|
99
|
+
"favorite_food": "pizza"
|
100
|
+
},
|
101
|
+
{
|
102
|
+
"character_name": "Janey Davidovsky",
|
103
|
+
"species": "cat",
|
104
|
+
"catchphrase": "Down-sized responsive pricing structure",
|
105
|
+
"favorite_food": "pizza"
|
106
|
+
},
|
107
|
+
{
|
108
|
+
"character_name": "Morse Mountford",
|
109
|
+
"species": "cat",
|
110
|
+
"catchphrase": "Ameliorated modular data-warehouse",
|
111
|
+
"favorite_food": "carrots"
|
112
|
+
},
|
113
|
+
{
|
114
|
+
"character_name": "Saundra Kauble",
|
115
|
+
"species": "dog",
|
116
|
+
"catchphrase": "Synchronised 24/7 support",
|
117
|
+
"favorite_food": "carrots"
|
118
|
+
},
|
119
|
+
{
|
120
|
+
"character_name": "Kain Viggars",
|
121
|
+
"species": "cat",
|
122
|
+
"catchphrase": "Open-architected asymmetric circuit",
|
123
|
+
"favorite_food": "carrots"
|
124
|
+
}
|
125
|
+
]
|
126
|
+
JSON
|
127
|
+
end
|
128
|
+
|
129
|
+
before do
|
130
|
+
file.write(json)
|
131
|
+
file.rewind
|
132
|
+
end
|
133
|
+
|
134
|
+
after do
|
135
|
+
file.close
|
136
|
+
file.unlink
|
137
|
+
end
|
138
|
+
|
139
|
+
it 'Ingests a JSON file' do
|
140
|
+
response = bulk_helper.ingest_json(file)
|
141
|
+
|
142
|
+
expect(response).to be_an_instance_of Elasticsearch::API::Response
|
143
|
+
expect(response.status).to eq(200)
|
144
|
+
end
|
145
|
+
|
146
|
+
context 'with data not in root of JSON file' do
|
147
|
+
let(:json) do
|
148
|
+
json = <<~JSON
|
149
|
+
{
|
150
|
+
"field": "value",
|
151
|
+
"status": 200,
|
152
|
+
"data": {
|
153
|
+
"items": [
|
154
|
+
{
|
155
|
+
"character_name": "Anallese Lonie",
|
156
|
+
"species": "mouse",
|
157
|
+
"catchphrase": "Seamless regional definition",
|
158
|
+
"favorite_food": "pizza"
|
159
|
+
},
|
160
|
+
{
|
161
|
+
"character_name": "Janey Davidovsky",
|
162
|
+
"species": "cat",
|
163
|
+
"catchphrase": "Down-sized responsive pricing structure",
|
164
|
+
"favorite_food": "pizza"
|
165
|
+
},
|
166
|
+
{
|
167
|
+
"character_name": "Morse Mountford",
|
168
|
+
"species": "cat",
|
169
|
+
"catchphrase": "Ameliorated modular data-warehouse",
|
170
|
+
"favorite_food": "carrots"
|
171
|
+
},
|
172
|
+
{
|
173
|
+
"character_name": "Saundra Kauble",
|
174
|
+
"species": "dog",
|
175
|
+
"catchphrase": "Synchronised 24/7 support",
|
176
|
+
"favorite_food": "carrots"
|
177
|
+
},
|
178
|
+
{
|
179
|
+
"character_name": "Kain Viggars",
|
180
|
+
"species": "cat",
|
181
|
+
"catchphrase": "Open-architected asymmetric circuit",
|
182
|
+
"favorite_food": "carrots"
|
183
|
+
}
|
184
|
+
]
|
185
|
+
}
|
186
|
+
}
|
187
|
+
JSON
|
188
|
+
end
|
189
|
+
|
190
|
+
it 'Ingests a JSON file passing keys as Array' do
|
191
|
+
response = bulk_helper.ingest_json(file, { keys: ['data', 'items'] })
|
192
|
+
expect(response).to be_an_instance_of Elasticsearch::API::Response
|
193
|
+
expect(response.status).to eq(200)
|
194
|
+
expect(response['items'].map { |a| a['index']['status'] }.uniq.first).to eq 201
|
195
|
+
end
|
196
|
+
|
197
|
+
it 'Ingests a JSON file passing keys as String' do
|
198
|
+
response = bulk_helper.ingest_json(file, { keys: 'data,items' })
|
199
|
+
expect(response).to be_an_instance_of Elasticsearch::API::Response
|
200
|
+
expect(response.status).to eq(200)
|
201
|
+
expect(response['items'].map { |a| a['index']['status'] }.uniq.first).to eq 201
|
202
|
+
end
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
# Licensed to Elasticsearch B.V. under one or more contributor
|
2
|
+
# license agreements. See the NOTICE file distributed with
|
3
|
+
# this work for additional information regarding copyright
|
4
|
+
# ownership. Elasticsearch B.V. licenses this file to you under
|
5
|
+
# the Apache License, Version 2.0 (the "License"); you may
|
6
|
+
# not use this file except in compliance with the License.
|
7
|
+
# You may obtain a copy of the License at
|
8
|
+
#
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
10
|
+
#
|
11
|
+
# Unless required by applicable law or agreed to in writing,
|
12
|
+
# software distributed under the License is distributed on an
|
13
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
14
|
+
# KIND, either express or implied. See the License for the
|
15
|
+
# specific language governing permissions and limitations
|
16
|
+
# under the License.
|
17
|
+
require_relative 'helpers_spec_helper'
|
18
|
+
require 'elasticsearch/helpers/esql_helper'
|
19
|
+
require 'ipaddr'
|
20
|
+
|
21
|
+
context 'Elasticsearch client helpers' do
|
22
|
+
let(:index) { 'esql_helper_test' }
|
23
|
+
let(:body) { { size: 12, query: { match_all: {} } } }
|
24
|
+
let(:esql_helper) { Elasticsearch::Helpers::ESQLHelper }
|
25
|
+
let(:query) do
|
26
|
+
<<~ESQL
|
27
|
+
FROM #{index}
|
28
|
+
| EVAL duration_ms = ROUND(event.duration / 1000000.0, 1)
|
29
|
+
ESQL
|
30
|
+
end
|
31
|
+
|
32
|
+
before do
|
33
|
+
client.indices.create(
|
34
|
+
index: index,
|
35
|
+
body: {
|
36
|
+
mappings: {
|
37
|
+
properties: { 'client.ip' => { type: 'ip' }, message: { type: 'keyword' } }
|
38
|
+
}
|
39
|
+
}
|
40
|
+
)
|
41
|
+
client.bulk(
|
42
|
+
index: index,
|
43
|
+
body: [
|
44
|
+
{'index': {}},
|
45
|
+
{'@timestamp' => '2023-10-23T12:15:03.360Z', 'client.ip' => '172.21.2.162', message: 'Connected to 10.1.0.3', 'event.duration' => 3450233},
|
46
|
+
{'index': {}},
|
47
|
+
{'@timestamp' => '2023-10-23T12:27:28.948Z', 'client.ip' => '172.21.2.113', message: 'Connected to 10.1.0.2', 'event.duration' => 2764889},
|
48
|
+
{'index': {}},
|
49
|
+
{'@timestamp' => '2023-10-23T13:33:34.937Z', 'client.ip' => '172.21.0.5', message: 'Disconnected', 'event.duration' => 1232382},
|
50
|
+
{'index': {}},
|
51
|
+
{'@timestamp' => '2023-10-23T13:51:54.732Z', 'client.ip' => '172.21.3.15', message: 'Connection error', 'event.duration' => 725448},
|
52
|
+
{'index': {}},
|
53
|
+
{'@timestamp' => '2023-10-23T13:52:55.015Z', 'client.ip' => '172.21.3.15', message: 'Connection error', 'event.duration' => 8268153},
|
54
|
+
{'index': {}},
|
55
|
+
{'@timestamp' => '2023-10-23T13:53:55.832Z', 'client.ip' => '172.21.3.15', message: 'Connection error', 'event.duration' => 5033755},
|
56
|
+
{'index': {}},
|
57
|
+
{'@timestamp' => '2023-10-23T13:55:01.543Z', 'client.ip' => '172.21.3.15', message: 'Connected to 10.1.0.1', 'event.duration' => 1756467}
|
58
|
+
],
|
59
|
+
refresh: true
|
60
|
+
)
|
61
|
+
end
|
62
|
+
|
63
|
+
after do
|
64
|
+
client.indices.delete(index: index)
|
65
|
+
end
|
66
|
+
|
67
|
+
it 'returns an ESQL response as a relational key/value object' do
|
68
|
+
response = esql_helper.query(client, query)
|
69
|
+
expect(response.count).to eq 7
|
70
|
+
expect(response.first.keys).to eq ['duration_ms', 'message', 'event.duration', 'client.ip', '@timestamp']
|
71
|
+
response.each do |r|
|
72
|
+
expect(r['@timestamp']).to be_a String
|
73
|
+
expect(r['client.ip']).to be_a String
|
74
|
+
expect(r['message']).to be_a String
|
75
|
+
expect(r['event.duration']).to be_a Integer
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
it 'parses iterated objects when procs are passed in' do
|
80
|
+
parser = {
|
81
|
+
'@timestamp' => Proc.new { |t| DateTime.parse(t) },
|
82
|
+
'client.ip' => Proc.new { |i| IPAddr.new(i) },
|
83
|
+
'event.duration' => Proc.new { |d| d.to_s }
|
84
|
+
}
|
85
|
+
response = esql_helper.query(client, query, parser: parser)
|
86
|
+
response.each do |r|
|
87
|
+
expect(r['@timestamp']).to be_a DateTime
|
88
|
+
expect(r['client.ip']).to be_a IPAddr
|
89
|
+
expect(r['message']).to be_a String
|
90
|
+
expect(r['event.duration']).to be_a String
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
it 'parser does not error when value is nil, leaves nil' do
|
95
|
+
client.index(
|
96
|
+
index: index,
|
97
|
+
body: {
|
98
|
+
'@timestamp' => nil,
|
99
|
+
'client.ip' => nil,
|
100
|
+
message: 'Connected to 10.1.0.1',
|
101
|
+
'event.duration' => 1756465
|
102
|
+
},
|
103
|
+
refresh: true
|
104
|
+
)
|
105
|
+
parser = {
|
106
|
+
'@timestamp' => Proc.new { |t| DateTime.parse(t) },
|
107
|
+
'client.ip' => Proc.new { |i| IPAddr.new(i) },
|
108
|
+
'event.duration' => Proc.new { |d| d.to_s }
|
109
|
+
}
|
110
|
+
response = esql_helper.query(client, query, parser: parser)
|
111
|
+
response.each do |r|
|
112
|
+
expect [DateTime, NilClass].include?(r['@timestamp'].class)
|
113
|
+
expect [IPAddr, NilClass].include?(r['client.ip'].class)
|
114
|
+
expect(r['message']).to be_a String
|
115
|
+
expect(r['event.duration']).to be_a String
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|