elastic-app-search 0.7.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,20 @@
1
+ module Elastic
2
+ module AppSearch
3
+ module Utils
4
+ extend self
5
+
6
+ def stringify_keys(hash)
7
+ hash.each_with_object({}) do |(key, value), out|
8
+ out[key.to_s] = value
9
+ end
10
+ end
11
+
12
+ def symbolize_keys(hash)
13
+ hash.each_with_object({}) do |(key, value), out|
14
+ new_key = key.respond_to?(:to_sym) ? key.to_sym : key
15
+ out[new_key] = value
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,5 @@
1
+ module Elastic
2
+ module AppSearch
3
+ VERSION = '0.7.0'
4
+ end
5
+ end
Binary file
data/script/console ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $:.unshift File.expand_path('../../lib', __FILE__)
4
+
5
+ require 'elastic/app-search'
6
+ require 'irb'
7
+ require 'irb/completion'
8
+
9
+ IRB.start
@@ -0,0 +1,500 @@
1
+ require 'config_helper'
2
+
3
+ describe Elastic::AppSearch::Client do
4
+ let(:engine_name) { "ruby-client-test-#{Time.now.to_i}" }
5
+
6
+ include_context 'App Search Credentials'
7
+ let(:client) { Elastic::AppSearch::Client.new(client_options) }
8
+
9
+ before(:all) do
10
+ # Bootstraps a static engine for 'read-only' options that require indexing
11
+ # across the test suite
12
+ @static_engine_name = "ruby-client-test-static-#{Time.now.to_i}"
13
+ as_api_key = ConfigHelper.get_as_api_key
14
+ as_host_identifier = ConfigHelper.get_as_host_identifier
15
+ as_api_endpoint = ConfigHelper.get_as_api_endpoint
16
+ client_options = ConfigHelper.get_client_options(as_api_key, as_host_identifier, as_api_endpoint)
17
+ @static_client = Elastic::AppSearch::Client.new(client_options)
18
+ @static_client.create_engine(@static_engine_name)
19
+
20
+ @document1 = { 'id' => '1', 'title' => 'The Great Gatsby' }
21
+ @document2 = { 'id' => '2', 'title' => 'Catcher in the Rye' }
22
+ @documents = [@document1, @document2]
23
+ @static_client.index_documents(@static_engine_name, @documents)
24
+
25
+ # Wait until documents are indexed
26
+ start = Time.now
27
+ ready = false
28
+ until (ready)
29
+ sleep(3)
30
+ results = @static_client.search(@static_engine_name, '')
31
+ ready = true if results['results'].length == 2
32
+ ready = true if (Time.now - start).to_i >= 120 # Time out after 2 minutes
33
+ end
34
+ end
35
+
36
+ after(:all) do
37
+ @static_client.destroy_engine(@static_engine_name)
38
+ end
39
+
40
+ describe '#create_signed_search_key' do
41
+ let(:key) { 'private-xxxxxxxxxxxxxxxxxxxx' }
42
+ let(:api_key_name) { 'private-key' }
43
+ let(:enforced_options) do
44
+ {
45
+ 'query' => 'cat'
46
+ }
47
+ end
48
+
49
+ subject do
50
+ Elastic::AppSearch::Client.create_signed_search_key(key, api_key_name, enforced_options)
51
+ end
52
+
53
+ it 'should build a valid jwt' do
54
+ decoded_token = JWT.decode subject, key, true, { algorithm: 'HS256' }
55
+ expect(decoded_token[0]['api_key_name']).to eq(api_key_name)
56
+ expect(decoded_token[0]['query']).to eq('cat')
57
+ end
58
+ end
59
+
60
+ describe 'Requests' do
61
+ it 'should include client name and version in headers' do
62
+ stub_request(:any, "#{client_options[:host_identifier]}.api.swiftype.com/api/as/v1/engines")
63
+ client.list_engines
64
+ expect(WebMock).to have_requested(:get, "https://#{client_options[:host_identifier]}.api.swiftype.com/api/as/v1/engines")
65
+ .with(
66
+ :headers => {
67
+ 'X-Swiftype-Client' => 'elastic-app-search-ruby',
68
+ 'X-Swiftype-Client-Version' => Elastic::AppSearch::VERSION
69
+ }
70
+ )
71
+ end
72
+ end
73
+
74
+ context 'Documents' do
75
+ let(:document) { { 'url' => 'http://www.youtube.com/watch?v=v1uyQZNg2vE' } }
76
+
77
+ before do
78
+ client.create_engine(engine_name) rescue Elastic::AppSearch::BadRequest
79
+ end
80
+
81
+ after do
82
+ client.destroy_engine(engine_name) rescue Elastic::AppSearch::NonExistentRecord
83
+ end
84
+
85
+ describe '#index_document' do
86
+ subject { client.index_document(engine_name, document) }
87
+
88
+ it 'should return a processed document status hash' do
89
+ expect(subject).to match('id' => anything)
90
+ end
91
+
92
+ context 'when the document has an id' do
93
+ let(:id) { 'some_id' }
94
+ let(:document) { { 'id' => id, 'url' => 'http://www.youtube.com/watch?v=v1uyQZNg2vE' } }
95
+
96
+ it 'should return a processed document status hash with the same id' do
97
+ expect(subject).to eq('id' => id)
98
+ end
99
+ end
100
+
101
+ context 'when a document has processing errors' do
102
+ let(:document) { { 'id' => 'too long' * 100 } }
103
+
104
+ it 'should raise an error when the API returns errors in the response' do
105
+ expect do
106
+ subject
107
+ end.to raise_error(Elastic::AppSearch::InvalidDocument, /Invalid field/)
108
+ end
109
+ end
110
+
111
+ context 'when a document has a Ruby Time object' do
112
+ let(:time_rfc3339) { '2018-01-01T01:01:01+00:00' }
113
+ let(:time_object) { Time.parse(time_rfc3339) }
114
+ let(:document) { { 'created_at' => time_object } }
115
+
116
+ it 'should serialize the time object in RFC 3339' do
117
+ response = subject
118
+ expect(response).to have_key('id')
119
+ document_id = response.fetch('id')
120
+ expect do
121
+ documents = client.get_documents(engine_name, [document_id])
122
+ expect(documents.size).to eq(1)
123
+ expect(documents.first['created_at']).to eq(time_rfc3339)
124
+ end.to_not raise_error
125
+ end
126
+ end
127
+ end
128
+
129
+ describe '#index_documents' do
130
+ let(:documents) { [document, second_document] }
131
+ let(:second_document_id) { 'another_id' }
132
+ let(:second_document) { { 'id' => second_document_id, 'url' => 'https://www.youtube.com/watch?v=9T1vfsHYiKY' } }
133
+ subject { client.index_documents(engine_name, documents) }
134
+
135
+ it 'should return an array of document status hashes' do
136
+ expect(subject).to match(
137
+ [
138
+ { 'id' => anything, 'errors' => [] },
139
+ { 'id' => second_document_id, 'errors' => [] }
140
+ ]
141
+ )
142
+ end
143
+
144
+ context 'when one of the documents has processing errors' do
145
+ let(:second_document) { { 'id' => 'too long' * 100 } }
146
+
147
+ it 'should return respective errors in an array of document processing hashes' do
148
+ expect(subject).to match(
149
+ [
150
+ { 'id' => anything, 'errors' => [] },
151
+ { 'id' => anything, 'errors' => ['Invalid field type: id must be less than 800 characters'] },
152
+ ]
153
+ )
154
+ end
155
+ end
156
+ end
157
+
158
+ describe '#update_documents' do
159
+ let(:documents) { [document, second_document] }
160
+ let(:second_document_id) { 'another_id' }
161
+ let(:second_document) { { 'id' => second_document_id, 'url' => 'https://www.youtube.com/watch?v=9T1vfsHYiKY' } }
162
+ let(:updates) do
163
+ [
164
+ {
165
+ 'id' => second_document_id,
166
+ 'url' => 'https://www.example.com'
167
+ }
168
+ ]
169
+ end
170
+
171
+ subject { client.update_documents(engine_name, updates) }
172
+
173
+ before do
174
+ client.index_documents(engine_name, documents)
175
+ end
176
+
177
+ # Note that since indexing a document takes up to a minute,
178
+ # we don't expect this to succeed, so we simply verify that
179
+ # the request responded with the correct 'id', even though
180
+ # the 'errors' object likely contains errors.
181
+ it 'should update existing documents' do
182
+ expect(subject).to match(['id' => second_document_id, 'errors' => anything])
183
+ end
184
+ end
185
+
186
+ describe '#get_documents' do
187
+ let(:documents) { [first_document, second_document] }
188
+ let(:first_document_id) { 'id' }
189
+ let(:first_document) { { 'id' => first_document_id, 'url' => 'https://www.youtube.com/watch?v=v1uyQZNg2vE' } }
190
+ let(:second_document_id) { 'another_id' }
191
+ let(:second_document) { { 'id' => second_document_id, 'url' => 'https://www.youtube.com/watch?v=9T1vfsHYiKY' } }
192
+
193
+ subject { client.get_documents(engine_name, [first_document_id, second_document_id]) }
194
+
195
+ before do
196
+ client.index_documents(engine_name, documents)
197
+ end
198
+
199
+ it 'will return documents by id' do
200
+ response = subject
201
+ expect(response.size).to eq(2)
202
+ expect(response[0]['id']).to eq(first_document_id)
203
+ expect(response[1]['id']).to eq(second_document_id)
204
+ end
205
+ end
206
+
207
+ describe '#list_documents' do
208
+ let(:documents) { [first_document, second_document] }
209
+ let(:first_document_id) { 'id' }
210
+ let(:first_document) { { 'id' => first_document_id, 'url' => 'https://www.youtube.com/watch?v=v1uyQZNg2vE' } }
211
+ let(:second_document_id) { 'another_id' }
212
+ let(:second_document) { { 'id' => second_document_id, 'url' => 'https://www.youtube.com/watch?v=9T1vfsHYiKY' } }
213
+
214
+ before do
215
+ client.index_documents(engine_name, documents)
216
+ end
217
+
218
+ context 'when no options are specified' do
219
+ it 'will return all documents' do
220
+ response = client.list_documents(engine_name)
221
+ expect(response['results'].size).to eq(2)
222
+ expect(response['results'][0]['id']).to eq(first_document_id)
223
+ expect(response['results'][1]['id']).to eq(second_document_id)
224
+ end
225
+ end
226
+
227
+ context 'when options are specified' do
228
+ it 'will return all documents' do
229
+ response = client.list_documents(engine_name, :page => { :size => 1, :current => 2 })
230
+ expect(response['results'].size).to eq(1)
231
+ expect(response['results'][0]['id']).to eq(second_document_id)
232
+ end
233
+ end
234
+ end
235
+ end
236
+
237
+ context 'Search' do
238
+ describe '#search' do
239
+ subject { @static_client.search(@static_engine_name, query, options) }
240
+ let(:query) { '' }
241
+ let(:options) { { 'page' => { 'size' => 2 } } }
242
+
243
+ it 'should execute a search query' do
244
+ expect(subject).to match(
245
+ 'meta' => anything,
246
+ 'results' => [anything, anything]
247
+ )
248
+ end
249
+ end
250
+
251
+ describe '#multi_search' do
252
+ subject { @static_client.multi_search(@static_engine_name, queries) }
253
+
254
+ context 'when options are provided' do
255
+ let(:queries) do
256
+ [
257
+ { 'query' => 'gatsby', 'options' => { 'page' => { 'size' => 1 } } },
258
+ { 'query' => 'catcher', 'options' => { 'page' => { 'size' => 1 } } }
259
+ ]
260
+ end
261
+
262
+ it 'should execute a multi search query' do
263
+ response = subject
264
+ expect(response).to match(
265
+ [
266
+ {
267
+ 'meta' => anything,
268
+ 'results' => [{ 'id' => { 'raw' => '1' }, 'title' => anything, '_meta' => anything }]
269
+ },
270
+ {
271
+ 'meta' => anything,
272
+ 'results' => [{ 'id' => { 'raw' => '2' }, 'title' => anything, '_meta' => anything }]
273
+ }
274
+ ]
275
+ )
276
+ end
277
+ end
278
+
279
+ context 'when options are omitted' do
280
+ let(:queries) do
281
+ [
282
+ { 'query' => 'gatsby' },
283
+ { 'query' => 'catcher' }
284
+ ]
285
+ end
286
+
287
+ it 'should execute a multi search query' do
288
+ response = subject
289
+ expect(response).to match(
290
+ [
291
+ {
292
+ 'meta' => anything,
293
+ 'results' => [{ 'id' => { 'raw' => '1' }, 'title' => anything, '_meta' => anything }]
294
+ },
295
+ {
296
+ 'meta' => anything,
297
+ 'results' => [{ 'id' => { 'raw' => '2' }, 'title' => anything, '_meta' => anything }]
298
+ }
299
+ ]
300
+ )
301
+ end
302
+ end
303
+
304
+ context 'when a search is bad' do
305
+ let(:queries) do
306
+ [
307
+ {
308
+ 'query' => 'cat',
309
+ 'options' => { 'search_fields' => { 'taco' => {} } }
310
+ }, {
311
+ 'query' => 'dog',
312
+ 'options' => { 'search_fields' => { 'body' => {} } }
313
+ }
314
+ ]
315
+ end
316
+
317
+ it 'should throw an appropriate error' do
318
+ expect { subject }.to raise_error do |e|
319
+ expect(e).to be_a(Elastic::AppSearch::BadRequest)
320
+ expect(e.errors).to eq(['Search fields contains invalid field: taco', 'Search fields contains invalid field: body'])
321
+ end
322
+ end
323
+ end
324
+ end
325
+ end
326
+
327
+ context 'QuerySuggest' do
328
+ describe '#query_suggestion' do
329
+ let(:query) { 'cat' }
330
+ let(:options) { { :size => 3, :types => { :documents => { :fields => ['title'] } } } }
331
+
332
+ context 'when options are provided' do
333
+ subject { @static_client.query_suggestion(@static_engine_name, query, options) }
334
+
335
+ it 'should request query suggestions' do
336
+ expect(subject).to match(
337
+ 'meta' => anything,
338
+ 'results' => anything
339
+ )
340
+ end
341
+ end
342
+
343
+ context 'when options are omitted' do
344
+ subject { @static_client.query_suggestion(@static_engine_name, query) }
345
+
346
+ it 'should request query suggestions' do
347
+ expect(subject).to match(
348
+ 'meta' => anything,
349
+ 'results' => anything
350
+ )
351
+ end
352
+ end
353
+ end
354
+ end
355
+
356
+ context 'SearchSettings' do
357
+ let(:default_settings) { {
358
+ "search_fields" => {
359
+ "id" => {
360
+ "weight" => 1
361
+ }
362
+ },
363
+ "result_fields" => {"id"=>{"raw"=>{}}},
364
+ "boosts" => {}
365
+ } }
366
+
367
+ let(:updated_settings) { {
368
+ "search_fields" => {
369
+ "id" => {
370
+ "weight" => 3
371
+ }
372
+ },
373
+ "result_fields" => {"id"=>{"raw"=>{}}},
374
+ "boosts" => {}
375
+ } }
376
+
377
+ before(:each) do
378
+ client.create_engine(engine_name) rescue Elastic::AppSearch::BadRequest
379
+ end
380
+
381
+ after(:each) do
382
+ client.destroy_engine(engine_name) rescue Elastic::AppSearch::NonExistentRecord
383
+ end
384
+
385
+ describe '#show_settings' do
386
+ subject { client.show_settings(engine_name) }
387
+
388
+ it 'should return default settings' do
389
+ expect(subject).to match(default_settings)
390
+ end
391
+ end
392
+
393
+ describe '#update_settings' do
394
+ subject { client.show_settings(engine_name) }
395
+
396
+ before do
397
+ client.update_settings(engine_name, updated_settings)
398
+ end
399
+
400
+ it 'should update search settings' do
401
+ expect(subject).to match(updated_settings)
402
+ end
403
+ end
404
+
405
+ describe '#reset_settings' do
406
+ subject { client.show_settings(engine_name) }
407
+
408
+ before do
409
+ client.update_settings(engine_name, updated_settings)
410
+ client.reset_settings(engine_name)
411
+ end
412
+
413
+ it 'should reset search settings' do
414
+ expect(subject).to match(default_settings)
415
+ end
416
+ end
417
+ end
418
+
419
+ context 'Engines' do
420
+ after do
421
+ client.destroy_engine(engine_name) rescue Elastic::AppSearch::NonExistentRecord
422
+ end
423
+
424
+ context '#create_engine' do
425
+ it 'should create an engine when given a right set of parameters' do
426
+ expect { client.get_engine(engine_name) }.to raise_error(Elastic::AppSearch::NonExistentRecord)
427
+ client.create_engine(engine_name)
428
+ expect { client.get_engine(engine_name) }.to_not raise_error
429
+ end
430
+
431
+ it 'should accept an optional language parameter' do
432
+ expect { client.get_engine(engine_name) }.to raise_error(Elastic::AppSearch::NonExistentRecord)
433
+ client.create_engine(engine_name, 'da')
434
+ expect(client.get_engine(engine_name)).to match('name' => anything, 'type' => anything, 'language' => 'da')
435
+ end
436
+
437
+ it 'should return an engine object' do
438
+ engine = client.create_engine(engine_name)
439
+ expect(engine).to be_kind_of(Hash)
440
+ expect(engine['name']).to eq(engine_name)
441
+ end
442
+
443
+ it 'should return an error when the engine name has already been taken' do
444
+ client.create_engine(engine_name)
445
+ expect { client.create_engine(engine_name) }.to raise_error do |e|
446
+ expect(e).to be_a(Elastic::AppSearch::BadRequest)
447
+ expect(e.errors).to eq(['Name is already taken'])
448
+ end
449
+ end
450
+ end
451
+
452
+ context '#list_engines' do
453
+ it 'should return an array with a list of engines' do
454
+ expect(client.list_engines['results']).to be_an(Array)
455
+ end
456
+
457
+ it 'should include the engine name in listed objects' do
458
+ client.create_engine(engine_name)
459
+
460
+ engines = client.list_engines['results']
461
+ expect(engines.find { |e| e['name'] == engine_name }).to_not be_nil
462
+ end
463
+
464
+ it 'should include the engine name in listed objects with pagination' do
465
+ client.create_engine(engine_name)
466
+
467
+ engines = client.list_engines(:current => 1, :size => 20)['results']
468
+ expect(engines.find { |e| e['name'] == engine_name }).to_not be_nil
469
+ end
470
+ end
471
+
472
+ context '#destroy_engine' do
473
+ it 'should destroy the engine if it exists' do
474
+ client.create_engine(engine_name)
475
+ expect { client.get_engine(engine_name) }.to_not raise_error
476
+
477
+ client.destroy_engine(engine_name)
478
+ expect { client.get_engine(engine_name) }.to raise_error(Elastic::AppSearch::NonExistentRecord)
479
+ end
480
+
481
+ it 'should raise an error if the engine does not exist' do
482
+ expect { client.destroy_engine(engine_name) }.to raise_error(Elastic::AppSearch::NonExistentRecord)
483
+ end
484
+ end
485
+ end
486
+
487
+ context 'Configuration' do
488
+ context 'host_identifier' do
489
+ it 'sets the base url correctly' do
490
+ client = Elastic::AppSearch::Client.new(:host_identifier => 'host-asdf', :api_key => 'foo')
491
+ expect(client.api_endpoint).to eq('https://host-asdf.api.swiftype.com/api/as/v1/')
492
+ end
493
+
494
+ it 'sets the base url correctly using deprecated as_host_key' do
495
+ client = Elastic::AppSearch::Client.new(:account_host_key => 'host-asdf', :api_key => 'foo')
496
+ expect(client.api_endpoint).to eq('https://host-asdf.api.swiftype.com/api/as/v1/')
497
+ end
498
+ end
499
+ end
500
+ end