elasticsearch 7.13.3 → 8.14.0

Sign up to get free protection for your applications and to get access to all the features.
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 "elasticsearch/version"
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
- module Transport
25
- class Client
26
- include Elasticsearch::API
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 elasticsearch-transport meta-header
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