noiseless 0.1.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3568cdfb6fe26188f2ccb0287265f3adfd128b8a056f06636496c8644a87cad3
4
- data.tar.gz: f0b29fa15d18a083950553337b9295e480c3966204dc614df5b9b4999b073742
3
+ metadata.gz: 7fb5d9afa49be27d359d92a2fdf24999d488f8a7d865547452ea10b7aae67850
4
+ data.tar.gz: da2fb4b847d7c1b53bb17b7b0a588d2a1660006cbe9413af0b657282087ad681
5
5
  SHA512:
6
- metadata.gz: cda0f5f8ebdfa6d4f35f6caa9d8c49d179d0fd0d1bdbe928ac9605d3da67e91635b0a15827fcc74c5b2196b4381050a11051a97c1f6a8b75890a20f88d32be60
7
- data.tar.gz: f379b20348f2c293255aa56815e458a0668ad77aa0fe01026ed21239a751a815264bbccd87bee54dbad6b55496940f543c186f008621eaa5e767e455a6f9d156
6
+ metadata.gz: 32b345fcc0ac56e25765f628c1ab8dd7dd03740e9a1a12bbcf5e13e9ce5078ef9ad57581f2b0a0f445f4d4b543559e01ceb50dd1a0dae86e7db5d35440b8e3c8
7
+ data.tar.gz: da9518b7381e61a16d7efef22e2d8d585b895b2e644820099403781c86ee9c7b541cf2ee00bad6cac7c40830764fd30aa841dd685dd9c63cb4ed227a1ed7ce8a
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "async"
4
+ require "json"
4
5
  require_relative "introspection"
5
6
 
6
7
  module Noiseless
@@ -208,6 +209,31 @@ module Noiseless
208
209
  }
209
210
  end
210
211
 
212
+ # Parses a backend HTTP response, raising when the backend reported an error.
213
+ # Success responses return the parsed JSON payload; error responses raise
214
+ # `error_class` with the backend's error type/reason so failures are never
215
+ # silently converted into empty results.
216
+ def parse_json_response!(response, error_class: Noiseless::RequestError, context: nil)
217
+ body = response.read
218
+ return JSON.parse(body) if response.success?
219
+
220
+ payload = begin
221
+ JSON.parse(body)
222
+ rescue JSON::ParserError, TypeError
223
+ nil
224
+ end
225
+ error = payload.is_a?(Hash) ? payload["error"] : nil
226
+ reason = if error.is_a?(Hash)
227
+ [ error["type"], error["reason"] ].compact.join(": ")
228
+ elsif error
229
+ error.to_s
230
+ else
231
+ "HTTP #{response.status}"
232
+ end
233
+ message = context ? "#{context}: #{reason}" : reason
234
+ raise error_class.new(message, status: response.status, error_type: error.is_a?(Hash) ? error["type"] : nil)
235
+ end
236
+
211
237
  # Override in subclasses
212
238
  def execute_search(_query_hash, **_opts)
213
239
  {
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ module Adapters
5
+ # Cluster health API - needed for Rails healthcheck
6
+ class ClusterAPI
7
+ def initialize(adapter)
8
+ @adapter = adapter
9
+ end
10
+
11
+ def health(**)
12
+ Sync do
13
+ @adapter.send(:execute_cluster_health, **)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -7,22 +7,8 @@ module Noiseless
7
7
  class Elasticsearch < Adapter
8
8
  include ExecutionModules::ElasticsearchExecution
9
9
 
10
- def initialize(hosts: [], **connection_params)
11
- # Ensure we always have at least one host
12
- hosts_array = Array(hosts)
13
- default_port = ENV["ELASTICSEARCH_PORT"] || 9200
14
- @hosts = hosts_array.empty? ? ["http://localhost:#{default_port}"] : hosts_array
15
- @connection_params = connection_params
16
-
17
- # Initialize HTTP clients for each host
18
- @clients = {}
19
- @hosts.each do |host|
20
- endpoint = Async::HTTP::Endpoint.parse(host)
21
- @clients[host] = Async::HTTP::Client.new(endpoint)
22
- end
23
-
24
- super(hosts: @hosts, **connection_params)
25
- end
10
+ ClusterAPI = Adapters::ClusterAPI
11
+ IndicesAPI = Adapters::IndicesAPI
26
12
 
27
13
  # Cluster health API - needed for Rails healthcheck
28
14
  def cluster
@@ -34,36 +20,10 @@ module Noiseless
34
20
  @indices ||= IndicesAPI.new(self)
35
21
  end
36
22
 
37
- class ClusterAPI
38
- def initialize(adapter)
39
- @adapter = adapter
40
- end
41
-
42
- def health(**)
43
- Sync do
44
- @adapter.send(:execute_cluster_health, **)
45
- end
46
- end
47
- end
48
-
49
- class IndicesAPI
50
- def initialize(adapter)
51
- @adapter = adapter
52
- end
53
-
54
- def get(index:)
55
- @adapter.execute_index_exists?(index) ? { index => {} } : raise("Index not found")
56
- end
57
-
58
- def stats(index:)
59
- # Return basic stats structure
60
- { "indices" => { index => {} } }
61
- end
23
+ private
62
24
 
63
- def refresh(index:)
64
- # Refresh the index to make documents immediately searchable
65
- @adapter.send(:execute_refresh_index, index)
66
- end
25
+ def default_port
26
+ ENV["ELASTICSEARCH_PORT"] || 9200
67
27
  end
68
28
  end
69
29
  end
@@ -1,14 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "es_compatible_execution"
4
5
 
5
6
  module Noiseless
6
7
  module Adapters
7
8
  module ExecutionModules
8
9
  module ElasticsearchExecution
9
- def close
10
- @clients&.each_value(&:close)
11
- end
10
+ include EsCompatibleExecution
12
11
 
13
12
  private
14
13
 
@@ -17,16 +16,7 @@ module Noiseless
17
16
  body = JSON.generate(query_hash)
18
17
 
19
18
  response = post_request(path, body)
20
- JSON.parse(response.read)
21
- ensure
22
- response&.close
23
- end
24
-
25
- def execute_bulk(actions, **_opts)
26
- body = "#{actions.map { |action| JSON.generate(action) }.join("\n")}\n"
27
-
28
- response = post_request("/_bulk", body, content_type: "application/x-ndjson")
29
- JSON.parse(response.read)
19
+ parse_json_response!(response, error_class: Noiseless::SearchError, context: "search")
30
20
  ensure
31
21
  response&.close
32
22
  end
@@ -37,42 +27,7 @@ module Noiseless
37
27
  body[:settings] = settings if settings
38
28
 
39
29
  response = put_request("/#{index_name}", body.any? ? JSON.generate(body) : nil)
40
- JSON.parse(response.read)
41
- ensure
42
- response&.close
43
- end
44
-
45
- def execute_delete_index(index_name, **_opts)
46
- response = delete_request("/#{index_name}")
47
- JSON.parse(response.read)
48
- ensure
49
- response&.close
50
- end
51
-
52
- def execute_refresh_index(index_name)
53
- response = post_request("/#{index_name}/_refresh", nil)
54
- JSON.parse(response.read)
55
- rescue StandardError => e
56
- {
57
- "_shards" => {
58
- "total" => 0,
59
- "successful" => 0,
60
- "failed" => 0
61
- },
62
- "error" => {
63
- "type" => e.class.name,
64
- "reason" => e.message
65
- }
66
- }
67
- ensure
68
- response&.close
69
- end
70
-
71
- def execute_index_exists?(index_name)
72
- response = head_request("/#{index_name}")
73
- response.success?
74
- rescue StandardError
75
- false
30
+ parse_json_response!(response, context: "create index #{index_name}")
76
31
  ensure
77
32
  response&.close
78
33
  end
@@ -82,32 +37,7 @@ module Noiseless
82
37
  body = JSON.generate(document)
83
38
 
84
39
  response = id ? put_request(path, body) : post_request(path, body)
85
- JSON.parse(response.read)
86
- ensure
87
- response&.close
88
- end
89
-
90
- def execute_update_document(index, id, changes, **_opts)
91
- body = JSON.generate(doc: changes)
92
-
93
- response = post_request("/#{index}/_update/#{id}", body)
94
- JSON.parse(response.read)
95
- ensure
96
- response&.close
97
- end
98
-
99
- def execute_delete_document(index, id, **_opts)
100
- response = delete_request("/#{index}/_doc/#{id}")
101
- JSON.parse(response.read)
102
- ensure
103
- response&.close
104
- end
105
-
106
- def execute_document_exists?(index, id)
107
- response = head_request("/#{index}/_doc/#{id}")
108
- response.success?
109
- rescue StandardError
110
- false
40
+ parse_json_response!(response, context: "index document #{index}/#{id}")
111
41
  ensure
112
42
  response&.close
113
43
  end
@@ -132,56 +62,6 @@ module Noiseless
132
62
  ensure
133
63
  response&.close
134
64
  end
135
-
136
- # HTTP helpers using Async::HTTP with connection pooling
137
- def get_request(path)
138
- with_client do |client|
139
- client.get(path, default_headers)
140
- end
141
- end
142
-
143
- def post_request(path, body, content_type: "application/json")
144
- headers = body ? default_headers + [["content-type", content_type]] : default_headers
145
-
146
- with_client do |client|
147
- client.post(path, headers, body)
148
- end
149
- end
150
-
151
- def put_request(path, body, content_type: "application/json")
152
- headers = body ? default_headers + [["content-type", content_type]] : default_headers
153
-
154
- with_client do |client|
155
- client.put(path, headers, body)
156
- end
157
- end
158
-
159
- def delete_request(path)
160
- with_client do |client|
161
- client.delete(path, default_headers)
162
- end
163
- end
164
-
165
- def head_request(path)
166
- with_client do |client|
167
- client.head(path, default_headers)
168
- end
169
- end
170
-
171
- def with_client
172
- # Select a random host for load balancing
173
- host = @hosts.sample
174
- client = @clients[host]
175
-
176
- yield(client)
177
- end
178
-
179
- def default_headers
180
- [
181
- ["accept", "application/json"],
182
- ["user-agent", "Noiseless/#{Noiseless::VERSION} (Ruby/#{RUBY_VERSION})"]
183
- ]
184
- end
185
65
  end
186
66
  end
187
67
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "http_transport"
5
+
6
+ module Noiseless
7
+ module Adapters
8
+ module ExecutionModules
9
+ # Document and index operations shared by the wire-compatible
10
+ # Elasticsearch and OpenSearch HTTP APIs.
11
+ module EsCompatibleExecution
12
+ include HttpTransport
13
+
14
+ private
15
+
16
+ def execute_bulk(actions, **_opts)
17
+ body = actions.map do |action|
18
+ if action[:index]
19
+ action_line = { index: { _index: action[:index][:_index], _id: action[:index][:_id] } }
20
+ data_line = action[:index][:data]
21
+ "#{JSON.generate(action_line)}\n#{JSON.generate(data_line)}\n"
22
+ else
23
+ "#{JSON.generate(action)}\n"
24
+ end
25
+ end.join
26
+
27
+ response = post_request("/_bulk", body, content_type: "application/x-ndjson")
28
+ parse_json_response!(response, context: "bulk")
29
+ ensure
30
+ response&.close
31
+ end
32
+
33
+ def execute_delete_index(index_name, **_opts)
34
+ response = delete_request("/#{index_name}")
35
+ parse_json_response!(response, context: "delete index #{index_name}")
36
+ ensure
37
+ response&.close
38
+ end
39
+
40
+ def execute_refresh_index(index_name)
41
+ response = post_request("/#{index_name}/_refresh", nil)
42
+ parse_json_response!(response, context: "refresh index #{index_name}")
43
+ ensure
44
+ response&.close
45
+ end
46
+
47
+ def execute_index_exists?(index_name)
48
+ response = head_request("/#{index_name}")
49
+ response.success?
50
+ rescue StandardError
51
+ false
52
+ ensure
53
+ response&.close
54
+ end
55
+
56
+ def execute_update_document(index, id, changes, **_opts)
57
+ body = JSON.generate(doc: changes)
58
+
59
+ response = post_request("/#{index}/_update/#{id}", body)
60
+ parse_json_response!(response, context: "update document #{index}/#{id}")
61
+ ensure
62
+ response&.close
63
+ end
64
+
65
+ def execute_delete_document(index, id, **_opts)
66
+ response = delete_request("/#{index}/_doc/#{id}")
67
+ parse_json_response!(response, context: "delete document #{index}/#{id}")
68
+ ensure
69
+ response&.close
70
+ end
71
+
72
+ def execute_document_exists?(index, id)
73
+ response = head_request("/#{index}/_doc/#{id}")
74
+ response.success?
75
+ rescue StandardError
76
+ false
77
+ ensure
78
+ response&.close
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ module Adapters
5
+ module ExecutionModules
6
+ # Shared Async::HTTP connection handling for HTTP-based adapters.
7
+ # Host classes must provide a private +default_port+ method.
8
+ module HttpTransport
9
+ def initialize(hosts: [], **connection_params)
10
+ # Ensure we always have at least one host
11
+ hosts_array = Array(hosts)
12
+ @hosts = hosts_array.empty? ? ["http://localhost:#{default_port}"] : hosts_array
13
+ @connection_params = connection_params
14
+
15
+ # Initialize HTTP clients for each host
16
+ @clients = {}
17
+ @hosts.each do |host|
18
+ endpoint = Async::HTTP::Endpoint.parse(host)
19
+ @clients[host] = Async::HTTP::Client.new(endpoint)
20
+ end
21
+
22
+ super(hosts: @hosts, **connection_params)
23
+ end
24
+
25
+ def close
26
+ @clients&.each_value(&:close)
27
+ end
28
+
29
+ private
30
+
31
+ # HTTP helpers using Async::HTTP with connection pooling
32
+ def get_request(path)
33
+ with_client do |client|
34
+ client.get(path, default_headers)
35
+ end
36
+ end
37
+
38
+ def post_request(path, body, content_type: "application/json")
39
+ headers = body ? default_headers + [["content-type", content_type]] : default_headers
40
+
41
+ with_client do |client|
42
+ client.post(path, headers, body)
43
+ end
44
+ end
45
+
46
+ def put_request(path, body, content_type: "application/json")
47
+ headers = body ? default_headers + [["content-type", content_type]] : default_headers
48
+
49
+ with_client do |client|
50
+ client.put(path, headers, body)
51
+ end
52
+ end
53
+
54
+ def delete_request(path)
55
+ with_client do |client|
56
+ client.delete(path, default_headers)
57
+ end
58
+ end
59
+
60
+ def head_request(path)
61
+ with_client do |client|
62
+ client.head(path, default_headers)
63
+ end
64
+ end
65
+
66
+ def with_client
67
+ # Select a random host for load balancing
68
+ host = @hosts.sample
69
+ client = @clients[host]
70
+
71
+ yield(client)
72
+ end
73
+
74
+ def default_headers
75
+ [
76
+ ["accept", "application/json"],
77
+ ["user-agent", "Noiseless/#{Noiseless::VERSION} (Ruby/#{RUBY_VERSION})"]
78
+ ]
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -1,14 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "es_compatible_execution"
4
5
 
5
6
  module Noiseless
6
7
  module Adapters
7
8
  module ExecutionModules
8
9
  module OpensearchExecution
9
- def close
10
- @clients&.each_value(&:close)
11
- end
10
+ include EsCompatibleExecution
12
11
 
13
12
  private
14
13
 
@@ -18,43 +17,7 @@ module Noiseless
18
17
  body = JSON.generate(query_hash)
19
18
 
20
19
  response = post_request(path, body)
21
- JSON.parse(response.read)
22
- rescue StandardError => e
23
- # Return empty response on error to maintain compatibility
24
- {
25
- took: 0,
26
- timed_out: false,
27
- _shards: { total: 0, successful: 0, skipped: 0, failed: 0 },
28
- hits: {
29
- total: { value: 0, relation: "eq" },
30
- max_score: nil,
31
- hits: []
32
- },
33
- error: {
34
- type: e.class.name,
35
- reason: e.message
36
- }
37
- }
38
- ensure
39
- response&.close
40
- end
41
-
42
- def execute_bulk(actions, **_opts)
43
- # Build bulk request body
44
- bulk_body = actions.map do |action|
45
- if action[:index]
46
- action_line = { index: { _index: action[:index][:_index], _id: action[:index][:_id] } }
47
- data_line = action[:index][:data]
48
- "#{JSON.generate(action_line)}\n#{JSON.generate(data_line)}\n"
49
- else
50
- "#{JSON.generate(action)}\n"
51
- end
52
- end.join
53
-
54
- response = post_request("/_bulk", bulk_body, content_type: "application/x-ndjson")
55
- JSON.parse(response.read)
56
- rescue StandardError => e
57
- { items: [], errors: true, error: { type: e.class.name, reason: e.message } }
20
+ parse_json_response!(response, error_class: Noiseless::SearchError, context: "search #{index_path}")
58
21
  ensure
59
22
  response&.close
60
23
  end
@@ -65,46 +28,7 @@ module Noiseless
65
28
  body[:settings] = settings if settings
66
29
 
67
30
  response = put_request("/#{index_name}", body.any? ? JSON.generate(body) : nil)
68
- JSON.parse(response.read)
69
- rescue StandardError => e
70
- { acknowledged: false, error: { type: e.class.name, reason: e.message } }
71
- ensure
72
- response&.close
73
- end
74
-
75
- def execute_delete_index(index_name, **_opts)
76
- response = delete_request("/#{index_name}")
77
- JSON.parse(response.read)
78
- rescue StandardError => e
79
- { acknowledged: false, error: { type: e.class.name, reason: e.message } }
80
- ensure
81
- response&.close
82
- end
83
-
84
- def execute_refresh_index(index_name)
85
- response = post_request("/#{index_name}/_refresh", nil)
86
- JSON.parse(response.read)
87
- rescue StandardError => e
88
- {
89
- "_shards" => {
90
- "total" => 0,
91
- "successful" => 0,
92
- "failed" => 0
93
- },
94
- "error" => {
95
- "type" => e.class.name,
96
- "reason" => e.message
97
- }
98
- }
99
- ensure
100
- response&.close
101
- end
102
-
103
- def execute_index_exists?(index_name)
104
- response = head_request("/#{index_name}")
105
- response.success?
106
- rescue StandardError
107
- false
31
+ parse_json_response!(response, context: "create index #{index_name}")
108
32
  ensure
109
33
  response&.close
110
34
  end
@@ -114,38 +38,7 @@ module Noiseless
114
38
  body = JSON.generate(document)
115
39
 
116
40
  response = put_request(path, body)
117
- JSON.parse(response.read)
118
- rescue StandardError => e
119
- { _index: index, _id: id, result: "error", error: { type: e.class.name, reason: e.message } }
120
- ensure
121
- response&.close
122
- end
123
-
124
- def execute_update_document(index, id, changes, **_opts)
125
- body = JSON.generate(doc: changes)
126
-
127
- response = post_request("/#{index}/_update/#{id}", body)
128
- JSON.parse(response.read)
129
- rescue StandardError => e
130
- { _index: index, _id: id, result: "error", error: { type: e.class.name, reason: e.message } }
131
- ensure
132
- response&.close
133
- end
134
-
135
- def execute_delete_document(index, id, **_opts)
136
- response = delete_request("/#{index}/_doc/#{id}")
137
- JSON.parse(response.read)
138
- rescue StandardError => e
139
- { _index: index, _id: id, result: "error", error: { type: e.class.name, reason: e.message } }
140
- ensure
141
- response&.close
142
- end
143
-
144
- def execute_document_exists?(index, id)
145
- response = head_request("/#{index}/_doc/#{id}")
146
- response.success?
147
- rescue StandardError
148
- false
41
+ parse_json_response!(response, context: "index document #{index}/#{id}")
149
42
  ensure
150
43
  response&.close
151
44
  end
@@ -178,13 +71,7 @@ module Noiseless
178
71
  body = JSON.generate(enhanced_query)
179
72
 
180
73
  response = post_request("/_search", body)
181
- JSON.parse(response.read)
182
- rescue StandardError => e
183
- {
184
- pit_id: pit_id,
185
- error: { type: e.class.name, reason: e.message },
186
- hits: { total: { value: 0 }, hits: [] }
187
- }
74
+ parse_json_response!(response, error_class: Noiseless::SearchError, context: "point-in-time search")
188
75
  ensure
189
76
  response&.close
190
77
  end
@@ -198,12 +85,7 @@ module Noiseless
198
85
  body = JSON.generate(template_query)
199
86
 
200
87
  response = post_request("/_search/template", body)
201
- JSON.parse(response.read)
202
- rescue StandardError => e
203
- {
204
- error: { type: e.class.name, reason: e.message },
205
- hits: { total: { value: 0 }, hits: [] }
206
- }
88
+ parse_json_response!(response, error_class: Noiseless::SearchError, context: "search template #{template_id}")
207
89
  ensure
208
90
  response&.close
209
91
  end
@@ -321,56 +203,6 @@ module Noiseless
321
203
  ensure
322
204
  response&.close
323
205
  end
324
-
325
- # HTTP helpers using Async::HTTP with connection pooling
326
- def get_request(path)
327
- with_client do |client|
328
- client.get(path, default_headers)
329
- end
330
- end
331
-
332
- def post_request(path, body, content_type: "application/json")
333
- headers = body ? default_headers + [["content-type", content_type]] : default_headers
334
-
335
- with_client do |client|
336
- client.post(path, headers, body)
337
- end
338
- end
339
-
340
- def put_request(path, body, content_type: "application/json")
341
- headers = body ? default_headers + [["content-type", content_type]] : default_headers
342
-
343
- with_client do |client|
344
- client.put(path, headers, body)
345
- end
346
- end
347
-
348
- def delete_request(path)
349
- with_client do |client|
350
- client.delete(path, default_headers)
351
- end
352
- end
353
-
354
- def head_request(path)
355
- with_client do |client|
356
- client.head(path, default_headers)
357
- end
358
- end
359
-
360
- def with_client
361
- # Select a random host for load balancing
362
- host = @hosts.sample
363
- client = @clients[host]
364
-
365
- yield(client)
366
- end
367
-
368
- def default_headers
369
- [
370
- ["accept", "application/json"],
371
- ["user-agent", "Noiseless/#{Noiseless::VERSION} (Ruby/#{RUBY_VERSION})"]
372
- ]
373
- end
374
206
  end
375
207
  end
376
208
  end
@@ -1,14 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "http_transport"
4
5
 
5
6
  module Noiseless
6
7
  module Adapters
7
8
  module ExecutionModules
8
9
  module TypesenseExecution
9
- def close
10
- @clients&.each_value(&:close)
11
- end
10
+ include HttpTransport
12
11
 
13
12
  private
14
13
 
@@ -391,54 +390,8 @@ module Noiseless
391
390
  response&.close
392
391
  end
393
392
 
394
- # HTTP helpers using Async::HTTP with connection pooling
395
- def get_request(path)
396
- with_client do |client|
397
- client.get(path, default_headers)
398
- end
399
- end
400
-
401
- def post_request(path, body, content_type: "application/json")
402
- headers = body ? default_headers + [["content-type", content_type]] : default_headers
403
-
404
- with_client do |client|
405
- client.post(path, headers, body)
406
- end
407
- end
408
-
409
- def put_request(path, body, content_type: "application/json")
410
- headers = body ? default_headers + [["content-type", content_type]] : default_headers
411
-
412
- with_client do |client|
413
- client.put(path, headers, body)
414
- end
415
- end
416
-
417
- def delete_request(path)
418
- with_client do |client|
419
- client.delete(path, default_headers)
420
- end
421
- end
422
-
423
- def head_request(path)
424
- with_client do |client|
425
- client.head(path, default_headers)
426
- end
427
- end
428
-
429
- def with_client
430
- # Select a random host for load balancing
431
- host = @hosts.sample
432
- client = @clients[host]
433
-
434
- yield(client)
435
- end
436
-
437
393
  def default_headers
438
- headers = [
439
- ["accept", "application/json"],
440
- ["user-agent", "Noiseless/#{Noiseless::VERSION} (Ruby/#{RUBY_VERSION})"]
441
- ]
394
+ headers = super
442
395
 
443
396
  # Add Typesense API key if configured
444
397
  if @connection_params && @connection_params[:api_key]
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ module Adapters
5
+ # Indices API - needed for index management operations
6
+ class IndicesAPI
7
+ def initialize(adapter)
8
+ @adapter = adapter
9
+ end
10
+
11
+ def get(index:)
12
+ @adapter.execute_index_exists?(index) ? { index => {} } : raise("Index not found")
13
+ end
14
+
15
+ def stats(index:)
16
+ # Return basic stats structure
17
+ { "indices" => { index => {} } }
18
+ end
19
+
20
+ def refresh(index:)
21
+ # Refresh the index to make documents immediately searchable
22
+ @adapter.send(:execute_refresh_index, index)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -7,22 +7,8 @@ module Noiseless
7
7
  class OpenSearch < Adapter
8
8
  include ExecutionModules::OpensearchExecution
9
9
 
10
- def initialize(hosts: [], **connection_params)
11
- # Ensure we always have at least one host
12
- hosts_array = Array(hosts)
13
- default_port = ENV["OPENSEARCH_PORT"] || 9200
14
- @hosts = hosts_array.empty? ? ["http://localhost:#{default_port}"] : hosts_array
15
- @connection_params = connection_params
16
-
17
- # Initialize HTTP clients for each host
18
- @clients = {}
19
- @hosts.each do |host|
20
- endpoint = Async::HTTP::Endpoint.parse(host)
21
- @clients[host] = Async::HTTP::Client.new(endpoint)
22
- end
23
-
24
- super(hosts: @hosts, **connection_params)
25
- end
10
+ ClusterAPI = Adapters::ClusterAPI
11
+ IndicesAPI = Adapters::IndicesAPI
26
12
 
27
13
  # OpenSearch-specific features
28
14
  def point_in_time_search(ast_node, pit_id:, **)
@@ -65,38 +51,6 @@ module Noiseless
65
51
  end
66
52
  end
67
53
 
68
- class ClusterAPI
69
- def initialize(adapter)
70
- @adapter = adapter
71
- end
72
-
73
- def health(**)
74
- Sync do
75
- @adapter.send(:execute_cluster_health, **)
76
- end
77
- end
78
- end
79
-
80
- class IndicesAPI
81
- def initialize(adapter)
82
- @adapter = adapter
83
- end
84
-
85
- def get(index:)
86
- @adapter.execute_index_exists?(index) ? { index => {} } : raise("Index not found")
87
- end
88
-
89
- def stats(index:)
90
- # Return basic stats structure
91
- { "indices" => { index => {} } }
92
- end
93
-
94
- def refresh(index:)
95
- # Refresh the index to make documents immediately searchable
96
- @adapter.send(:execute_refresh_index, index)
97
- end
98
- end
99
-
100
54
  # Search Pipelines API for OpenSearch 3.x
101
55
  # Pipelines can include request and response processors for neural search, reranking, etc.
102
56
  class PipelinesAPI
@@ -203,6 +157,12 @@ module Noiseless
203
157
  end
204
158
  end
205
159
  end
160
+
161
+ private
162
+
163
+ def default_port
164
+ ENV["OPENSEARCH_PORT"] || 9200
165
+ end
206
166
  end
207
167
  end
208
168
  end
@@ -7,22 +7,7 @@ module Noiseless
7
7
  class Typesense < Adapter
8
8
  include ExecutionModules::TypesenseExecution
9
9
 
10
- def initialize(hosts: [], **connection_params)
11
- # Ensure we always have at least one host
12
- hosts_array = Array(hosts)
13
- default_port = ENV["TYPESENSE_PORT"] || 8108
14
- @hosts = hosts_array.empty? ? ["http://localhost:#{default_port}"] : hosts_array
15
- @connection_params = connection_params
16
-
17
- # Initialize HTTP clients for each host
18
- @clients = {}
19
- @hosts.each do |host|
20
- endpoint = Async::HTTP::Endpoint.parse(host)
21
- @clients[host] = Async::HTTP::Client.new(endpoint)
22
- end
23
-
24
- super(hosts: @hosts, **connection_params)
25
- end
10
+ ClusterAPI = Adapters::ClusterAPI
26
11
 
27
12
  # Cluster health API - needed for Rails healthcheck
28
13
  def cluster
@@ -34,37 +19,18 @@ module Noiseless
34
19
  @indices ||= IndicesAPI.new(self)
35
20
  end
36
21
 
37
- class ClusterAPI
38
- def initialize(adapter)
39
- @adapter = adapter
40
- end
41
-
42
- def health(**)
43
- Sync do
44
- @adapter.send(:execute_cluster_health, **)
45
- end
46
- end
47
- end
48
-
49
- class IndicesAPI
50
- def initialize(adapter)
51
- @adapter = adapter
52
- end
53
-
54
- def get(index:)
55
- @adapter.execute_index_exists?(index) ? { index => {} } : raise("Index not found")
56
- end
57
-
58
- def stats(index:)
59
- # Return basic stats structure
60
- { "indices" => { index => {} } }
61
- end
62
-
22
+ class IndicesAPI < Adapters::IndicesAPI
63
23
  def refresh(index: nil) # rubocop:disable Lint/UnusedMethodArgument
64
24
  # Typesense doesn't require explicit refresh - documents are immediately available
65
25
  { "_shards" => { "total" => 1, "successful" => 1, "failed" => 0 } }
66
26
  end
67
27
  end
28
+
29
+ private
30
+
31
+ def default_port
32
+ ENV["TYPESENSE_PORT"] || 8108
33
+ end
68
34
  end
69
35
  end
70
36
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ module AST
5
+ # Base for leaf nodes that pair a single field with a value.
6
+ class FieldValueNode < Node
7
+ attr_reader :field, :value
8
+
9
+ def initialize(field, value)
10
+ super()
11
+ @field = field
12
+ @value = value
13
+ end
14
+ end
15
+ end
16
+ end
@@ -2,14 +2,7 @@
2
2
 
3
3
  module Noiseless
4
4
  module AST
5
- class Filter < Node
6
- attr_reader :field, :value
7
-
8
- def initialize(field, value)
9
- super()
10
- @field = field
11
- @value = value
12
- end
5
+ class Filter < FieldValueNode
13
6
  end
14
7
  end
15
8
  end
@@ -2,14 +2,7 @@
2
2
 
3
3
  module Noiseless
4
4
  module AST
5
- class Match < Node
6
- attr_reader :field, :value
7
-
8
- def initialize(field, value)
9
- super()
10
- @field = field
11
- @value = value
12
- end
5
+ class Match < FieldValueNode
13
6
  end
14
7
  end
15
8
  end
@@ -2,14 +2,7 @@
2
2
 
3
3
  module Noiseless
4
4
  module AST
5
- class Prefix < Node
6
- attr_reader :field, :value
7
-
8
- def initialize(field, value)
9
- super()
10
- @field = field
11
- @value = value
12
- end
5
+ class Prefix < FieldValueNode
13
6
  end
14
7
  end
15
8
  end
@@ -2,14 +2,7 @@
2
2
 
3
3
  module Noiseless
4
4
  module AST
5
- class Wildcard < Node
6
- attr_reader :field, :value
7
-
8
- def initialize(field, value)
9
- super()
10
- @field = field
11
- @value = value
12
- end
5
+ class Wildcard < FieldValueNode
13
6
  end
14
7
  end
15
8
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Noiseless
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/noiseless.rb CHANGED
@@ -17,6 +17,21 @@ require_relative "noiseless/version"
17
17
  module Noiseless
18
18
  class Error < StandardError; end
19
19
 
20
+ # HTTP-level failure from the search backend (non-2xx response).
21
+ class RequestError < Error
22
+ attr_reader :status, :error_type
23
+
24
+ def initialize(message, status: nil, error_type: nil)
25
+ super(message)
26
+ @status = status
27
+ @error_type = error_type
28
+ end
29
+ end
30
+
31
+ # Raised when a search query is rejected by the backend
32
+ # (malformed query, missing index, shard failures).
33
+ class SearchError < RequestError; end
34
+
20
35
  class Configuration
21
36
  attr_accessor :connections_config, :default_connection, :default_adapter, :config_path
22
37
 
@@ -85,7 +100,8 @@ module Noiseless
85
100
 
86
101
  # Setup Zeitwerk autoloader
87
102
  loader = Zeitwerk::Loader.for_gem
88
- loader.inflector.inflect("ast" => "AST", "dsl" => "DSL", "open_search" => "OpenSearch")
103
+ loader.inflector.inflect("ast" => "AST", "dsl" => "DSL", "open_search" => "OpenSearch",
104
+ "cluster_api" => "ClusterAPI", "indices_api" => "IndicesAPI")
89
105
  loader.ignore("#{__dir__}/application_search.rb")
90
106
  loader.ignore("#{__dir__}/noiseless/test_helper.rb")
91
107
  loader.ignore("#{__dir__}/noiseless/test_case.rb")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: noiseless
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -206,12 +206,16 @@ files:
206
206
  - lib/noiseless.rb
207
207
  - lib/noiseless/adapter.rb
208
208
  - lib/noiseless/adapters.rb
209
+ - lib/noiseless/adapters/cluster_api.rb
209
210
  - lib/noiseless/adapters/elasticsearch.rb
210
211
  - lib/noiseless/adapters/execution_modules/elasticsearch_execution.rb
212
+ - lib/noiseless/adapters/execution_modules/es_compatible_execution.rb
213
+ - lib/noiseless/adapters/execution_modules/http_transport.rb
211
214
  - lib/noiseless/adapters/execution_modules/opensearch_execution.rb
212
215
  - lib/noiseless/adapters/execution_modules/pgvector_support.rb
213
216
  - lib/noiseless/adapters/execution_modules/postgresql_execution.rb
214
217
  - lib/noiseless/adapters/execution_modules/typesense_execution.rb
218
+ - lib/noiseless/adapters/indices_api.rb
215
219
  - lib/noiseless/adapters/open_search.rb
216
220
  - lib/noiseless/adapters/postgresql.rb
217
221
  - lib/noiseless/adapters/typesense.rb
@@ -222,6 +226,7 @@ files:
222
226
  - lib/noiseless/ast/collapse.rb
223
227
  - lib/noiseless/ast/combined_fields.rb
224
228
  - lib/noiseless/ast/conversation.rb
229
+ - lib/noiseless/ast/field_value_node.rb
225
230
  - lib/noiseless/ast/filter.rb
226
231
  - lib/noiseless/ast/hybrid.rb
227
232
  - lib/noiseless/ast/image_query.rb
@@ -289,7 +294,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
289
294
  - !ruby/object:Gem::Version
290
295
  version: '0'
291
296
  requirements: []
292
- rubygems_version: 3.6.9
297
+ rubygems_version: 4.0.10
293
298
  specification_version: 4
294
299
  summary: Async-first Rails search abstraction with multi-backend support
295
300
  test_files: []