elastic-transport 8.0.0.pre1

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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.github/check_license_headers.rb +33 -0
  3. data/.github/license-header.txt +16 -0
  4. data/.github/workflows/license.yml +13 -0
  5. data/.github/workflows/tests.yml +45 -0
  6. data/.gitignore +19 -0
  7. data/CHANGELOG.md +224 -0
  8. data/Gemfile +38 -0
  9. data/LICENSE +202 -0
  10. data/README.md +552 -0
  11. data/Rakefile +87 -0
  12. data/elastic-transport.gemspec +74 -0
  13. data/lib/elastic/transport/client.rb +276 -0
  14. data/lib/elastic/transport/meta_header.rb +135 -0
  15. data/lib/elastic/transport/redacted.rb +73 -0
  16. data/lib/elastic/transport/transport/base.rb +450 -0
  17. data/lib/elastic/transport/transport/connections/collection.rb +126 -0
  18. data/lib/elastic/transport/transport/connections/connection.rb +160 -0
  19. data/lib/elastic/transport/transport/connections/selector.rb +91 -0
  20. data/lib/elastic/transport/transport/errors.rb +91 -0
  21. data/lib/elastic/transport/transport/http/curb.rb +120 -0
  22. data/lib/elastic/transport/transport/http/faraday.rb +95 -0
  23. data/lib/elastic/transport/transport/http/manticore.rb +179 -0
  24. data/lib/elastic/transport/transport/loggable.rb +83 -0
  25. data/lib/elastic/transport/transport/response.rb +36 -0
  26. data/lib/elastic/transport/transport/serializer/multi_json.rb +52 -0
  27. data/lib/elastic/transport/transport/sniffer.rb +101 -0
  28. data/lib/elastic/transport/version.rb +22 -0
  29. data/lib/elastic/transport.rb +37 -0
  30. data/lib/elastic-transport.rb +18 -0
  31. data/spec/elasticsearch/connections/collection_spec.rb +266 -0
  32. data/spec/elasticsearch/connections/selector_spec.rb +166 -0
  33. data/spec/elasticsearch/transport/base_spec.rb +264 -0
  34. data/spec/elasticsearch/transport/client_spec.rb +1651 -0
  35. data/spec/elasticsearch/transport/meta_header_spec.rb +274 -0
  36. data/spec/elasticsearch/transport/sniffer_spec.rb +275 -0
  37. data/spec/spec_helper.rb +90 -0
  38. data/test/integration/transport_test.rb +98 -0
  39. data/test/profile/client_benchmark_test.rb +132 -0
  40. data/test/test_helper.rb +83 -0
  41. data/test/unit/connection_test.rb +135 -0
  42. data/test/unit/response_test.rb +30 -0
  43. data/test/unit/serializer_test.rb +33 -0
  44. data/test/unit/transport_base_test.rb +664 -0
  45. data/test/unit/transport_curb_test.rb +135 -0
  46. data/test/unit/transport_faraday_test.rb +228 -0
  47. data/test/unit/transport_manticore_test.rb +251 -0
  48. metadata +412 -0
@@ -0,0 +1,73 @@
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
+ module Elastic
19
+ module Transport
20
+ # Class for wrapping a hash that could have sensitive data.
21
+ # When printed, the sensitive values will be redacted.
22
+ #
23
+ # @since 6.1.1
24
+ class Redacted < ::Hash
25
+ def initialize(elements = nil)
26
+ super()
27
+ (elements || {}).each_pair{ |key, value| self[key] = value }
28
+ end
29
+
30
+ # The keys whose values will be redacted.
31
+ #
32
+ # @since 6.1.1
33
+ SENSITIVE_KEYS = [ :password,
34
+ :pwd ].freeze
35
+
36
+ # The replacement string used in place of the value for sensitive keys.
37
+ #
38
+ # @since 6.1.1
39
+ STRING_REPLACEMENT = '<REDACTED>'.freeze
40
+
41
+ # Get a string representation of the hash.
42
+ #
43
+ # @return [ String ] The string representation of the hash.
44
+ #
45
+ # @since 6.1.1
46
+ def inspect
47
+ redacted_string(:inspect)
48
+ end
49
+
50
+ # Get a string representation of the hash.
51
+ #
52
+ # @return [ String ] The string representation of the hash.
53
+ #
54
+ # @since 6.1.1
55
+ def to_s
56
+ redacted_string(:to_s)
57
+ end
58
+
59
+ private
60
+
61
+ def redacted_string(method)
62
+ '{' + reduce([]) do |list, (k, v)|
63
+ list << "#{k.send(method)}=>#{redact(k, v, method)}"
64
+ end.join(', ') + '}'
65
+ end
66
+
67
+ def redact(k, v, method)
68
+ return STRING_REPLACEMENT if SENSITIVE_KEYS.include?(k.to_sym)
69
+ v.send(method)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,450 @@
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
+ module Elastic
19
+ module Transport
20
+ module Transport
21
+ # @abstract Module with common functionality for transport implementations.
22
+ #
23
+ module Base
24
+ include Loggable
25
+
26
+ DEFAULT_PORT = 9200
27
+ DEFAULT_PROTOCOL = 'http'
28
+ DEFAULT_RELOAD_AFTER = 10_000 # Requests
29
+ DEFAULT_RESURRECT_AFTER = 60 # Seconds
30
+ DEFAULT_MAX_RETRIES = 3 # Requests
31
+ DEFAULT_SERIALIZER_CLASS = Serializer::MultiJson
32
+ SANITIZED_PASSWORD = '*' * (rand(14)+1)
33
+
34
+ attr_reader :hosts, :options, :connections, :counter, :last_request_at, :protocol
35
+ attr_accessor :serializer, :sniffer, :logger, :tracer,
36
+ :reload_connections, :reload_after,
37
+ :resurrect_after
38
+
39
+ # Creates a new transport object
40
+ #
41
+ # @param arguments [Hash] Settings and options for the transport
42
+ # @param block [Proc] Lambda or Proc which can be evaluated in the context of the "session" object
43
+ #
44
+ # @option arguments [Array] :hosts An Array of normalized hosts information
45
+ # @option arguments [Array] :options A Hash with options (usually passed by {Client})
46
+ #
47
+ # @see Client#initialize
48
+ #
49
+ def initialize(arguments = {}, &block)
50
+ @state_mutex = Mutex.new
51
+
52
+ @hosts = arguments[:hosts] || []
53
+ @options = arguments[:options] || {}
54
+ @options[:http] ||= {}
55
+ @options[:retry_on_status] ||= []
56
+
57
+ @block = block
58
+ @compression = !!@options[:compression]
59
+ @connections = __build_connections
60
+
61
+ @serializer = options[:serializer] || ( options[:serializer_class] ? options[:serializer_class].new(self) : DEFAULT_SERIALIZER_CLASS.new(self) )
62
+ @protocol = options[:protocol] || DEFAULT_PROTOCOL
63
+
64
+ @logger = options[:logger]
65
+ @tracer = options[:tracer]
66
+
67
+ @sniffer = options[:sniffer_class] ? options[:sniffer_class].new(self) : Sniffer.new(self)
68
+ @counter = 0
69
+ @counter_mtx = Mutex.new
70
+ @last_request_at = Time.now
71
+ @reload_connections = options[:reload_connections]
72
+ @reload_after = options[:reload_connections].is_a?(Integer) ? options[:reload_connections] : DEFAULT_RELOAD_AFTER
73
+ @resurrect_after = options[:resurrect_after] || DEFAULT_RESURRECT_AFTER
74
+ @retry_on_status = Array(options[:retry_on_status]).map { |d| d.to_i }
75
+ end
76
+
77
+ # Returns a connection from the connection pool by delegating to {Connections::Collection#get_connection}.
78
+ #
79
+ # Resurrects dead connection if the `resurrect_after` timeout has passed.
80
+ # Increments the counter and performs connection reloading if the `reload_connections` option is set.
81
+ #
82
+ # @return [Connections::Connection]
83
+ # @see Connections::Collection#get_connection
84
+ #
85
+ def get_connection(options={})
86
+ resurrect_dead_connections! if Time.now > @last_request_at + @resurrect_after
87
+
88
+ @counter_mtx.synchronize { @counter += 1 }
89
+ reload_connections! if reload_connections && counter % reload_after == 0
90
+ connections.get_connection(options)
91
+ end
92
+
93
+ # Reloads and replaces the connection collection based on cluster information
94
+ #
95
+ # @see Sniffer#hosts
96
+ #
97
+ def reload_connections!
98
+ hosts = sniffer.hosts
99
+ __rebuild_connections :hosts => hosts, :options => options
100
+ self
101
+ rescue SnifferTimeoutError
102
+ log_error "[SnifferTimeoutError] Timeout when reloading connections."
103
+ self
104
+ end
105
+
106
+ # Tries to "resurrect" all eligible dead connections
107
+ #
108
+ # @see Connections::Connection#resurrect!
109
+ #
110
+ def resurrect_dead_connections!
111
+ connections.dead.each { |c| c.resurrect! }
112
+ end
113
+
114
+ # Rebuilds the connections collection in the transport.
115
+ #
116
+ # The methods *adds* new connections from the passed hosts to the collection,
117
+ # and *removes* all connections not contained in the passed hosts.
118
+ #
119
+ # @return [Connections::Collection]
120
+ # @api private
121
+ #
122
+ def __rebuild_connections(arguments={})
123
+ @state_mutex.synchronize do
124
+ @hosts = arguments[:hosts] || []
125
+ @options = arguments[:options] || {}
126
+
127
+ __close_connections
128
+
129
+ new_connections = __build_connections
130
+ stale_connections = @connections.all.select { |c| ! new_connections.include?(c) }
131
+ new_connections = new_connections.reject { |c| @connections.all.include?(c) }
132
+
133
+ @connections.remove(stale_connections)
134
+ @connections.add(new_connections)
135
+ @connections
136
+ end
137
+ end
138
+
139
+ # Builds and returns a collection of connections
140
+ #
141
+ # The adapters have to implement the {Base#__build_connection} method.
142
+ #
143
+ # @return [Connections::Collection]
144
+ # @api private
145
+ #
146
+ def __build_connections
147
+ Connections::Collection.new(
148
+ connections: __connections_from_host,
149
+ selector_class: options[:selector_class],
150
+ selector: options[:selector]
151
+ )
152
+ end
153
+
154
+ # Helper function: Maps over hosts, sets protocol, port and user/password and builds connections
155
+ #
156
+ # @return [Array<Connection>
157
+ # @api private
158
+ #
159
+ def __connections_from_host
160
+ hosts.map do |host|
161
+ host[:protocol] = host[:scheme] || options[:scheme] || options[:http][:scheme] || DEFAULT_PROTOCOL
162
+ host[:port] ||= options[:port] || options[:http][:port] || DEFAULT_PORT
163
+ if (options[:user] || options[:http][:user]) && !host[:user]
164
+ host[:user] ||= options[:user] || options[:http][:user]
165
+ host[:password] ||= options[:password] || options[:http][:password]
166
+ end
167
+
168
+ __build_connection(host, (options[:transport_options] || {}), @block)
169
+ end
170
+ end
171
+
172
+ # @abstract Build and return a connection.
173
+ # A transport implementation *must* implement this method.
174
+ # See {HTTP::Faraday#__build_connection} for an example.
175
+ #
176
+ # @return [Connections::Connection]
177
+ # @api private
178
+ #
179
+ def __build_connection(host, options={}, block=nil)
180
+ raise NoMethodError, "Implement this method in your class"
181
+ end
182
+
183
+ # Closes the connections collection
184
+ #
185
+ # @api private
186
+ #
187
+ def __close_connections
188
+ # A hook point for specific adapters when they need to close connections
189
+ end
190
+
191
+ # Log request and response information
192
+ #
193
+ # @api private
194
+ #
195
+ def __log_response(method, path, params, body, url, response, json, took, duration)
196
+ if logger
197
+ sanitized_url = url.to_s.gsub(/\/\/(.+):(.+)@/, '//' + '\1:' + SANITIZED_PASSWORD + '@')
198
+ log_info "#{method.to_s.upcase} #{sanitized_url} " +
199
+ "[status:#{response.status}, request:#{sprintf('%.3fs', duration)}, query:#{took}]"
200
+ log_debug "> #{__convert_to_json(body)}" if body
201
+ log_debug "< #{response.body}"
202
+ end
203
+ end
204
+
205
+ # Trace the request in the `curl` format
206
+ #
207
+ # @api private
208
+ #
209
+ def __trace(method, path, params, headers, body, url, response, json, took, duration)
210
+ trace_url = "http://localhost:9200/#{path}?pretty" +
211
+ ( params.empty? ? '' : "&#{::Faraday::Utils::ParamsHash[params].to_query}" )
212
+ trace_body = body ? " -d '#{__convert_to_json(body, :pretty => true)}'" : ''
213
+ trace_command = "curl -X #{method.to_s.upcase}"
214
+ trace_command += " -H '#{headers.collect { |k,v| "#{k}: #{v}" }.join(", ")}'" if headers && !headers.empty?
215
+ trace_command += " '#{trace_url}'#{trace_body}\n"
216
+ tracer.info trace_command
217
+ tracer.debug "# #{Time.now.iso8601} [#{response.status}] (#{format('%.3f', duration)}s)\n#"
218
+ tracer.debug json ? serializer.dump(json, :pretty => true).gsub(/^/, '# ').sub(/\}$/, "\n# }")+"\n" : "# #{response.body}\n"
219
+ end
220
+
221
+ # Raise error specific for the HTTP response status or a generic server error
222
+ #
223
+ # @api private
224
+ #
225
+ def __raise_transport_error(response)
226
+ error = ERRORS[response.status] || ServerError
227
+ raise error.new "[#{response.status}] #{response.body}"
228
+ end
229
+
230
+ # Converts any non-String object to JSON
231
+ #
232
+ # @api private
233
+ #
234
+ def __convert_to_json(o=nil, options={})
235
+ o = o.is_a?(String) ? o : serializer.dump(o, options)
236
+ end
237
+
238
+ # Returns a full URL based on information from host
239
+ #
240
+ # @param host [Hash] Host configuration passed in from {Client}
241
+ #
242
+ # @api private
243
+ def __full_url(host)
244
+ url = "#{host[:protocol]}://"
245
+ url += "#{CGI.escape(host[:user])}:#{CGI.escape(host[:password])}@" if host[:user]
246
+ url += host[:host]
247
+ url += ":#{host[:port]}" if host[:port]
248
+ url += host[:path] if host[:path]
249
+ url
250
+ end
251
+
252
+ # Performs a request to Elasticsearch, while handling logging, tracing, marking dead connections,
253
+ # retrying the request and reloading the connections.
254
+ #
255
+ # @abstract The transport implementation has to implement this method either in full,
256
+ # or by invoking this method with a block. See {HTTP::Faraday#perform_request} for an example.
257
+ #
258
+ # @param method [String] Request method
259
+ # @param path [String] The API endpoint
260
+ # @param params [Hash] Request parameters (will be serialized by {Connections::Connection#full_url})
261
+ # @param body [Hash] Request body (will be serialized by the {#serializer})
262
+ # @param headers [Hash] Request headers (will be serialized by the {#serializer})
263
+ # @param block [Proc] Code block to evaluate, passed from the implementation
264
+ #
265
+ # @return [Response]
266
+ # @raise [NoMethodError] If no block is passed
267
+ # @raise [ServerError] If request failed on server
268
+ # @raise [Error] If no connection is available
269
+ #
270
+ def perform_request(method, path, params = {}, body = nil, headers = nil, opts = {}, &block)
271
+ raise NoMethodError, 'Implement this method in your transport class' unless block_given?
272
+
273
+ start = Time.now
274
+ tries = 0
275
+ reload_on_failure = opts.fetch(:reload_on_failure, @options[:reload_on_failure])
276
+
277
+ max_retries = if opts.key?(:retry_on_failure)
278
+ opts[:retry_on_failure] === true ? DEFAULT_MAX_RETRIES : opts[:retry_on_failure]
279
+ elsif options.key?(:retry_on_failure)
280
+ options[:retry_on_failure] === true ? DEFAULT_MAX_RETRIES : options[:retry_on_failure]
281
+ end
282
+
283
+ params = params.clone
284
+
285
+ ignore = Array(params.delete(:ignore)).compact.map { |s| s.to_i }
286
+
287
+ begin
288
+ tries += 1
289
+ connection = get_connection or raise Error.new('Cannot get new connection from pool.')
290
+
291
+ if connection.connection.respond_to?(:params) && connection.connection.params.respond_to?(:to_hash)
292
+ params = connection.connection.params.merge(params.to_hash)
293
+ end
294
+
295
+ url = connection.full_url(path, params)
296
+
297
+ response = block.call(connection, url)
298
+
299
+ connection.healthy! if connection.failures > 0
300
+
301
+ # Raise an exception so we can catch it for `retry_on_status`
302
+ __raise_transport_error(response) if response.status.to_i >= 300 && @retry_on_status.include?(response.status.to_i)
303
+
304
+ rescue Elastic::Transport::Transport::ServerError => e
305
+ if response && @retry_on_status.include?(response.status)
306
+ log_warn "[#{e.class}] Attempt #{tries} to get response from #{url}"
307
+ if tries <= (max_retries || DEFAULT_MAX_RETRIES)
308
+ retry
309
+ else
310
+ log_fatal "[#{e.class}] Cannot get response from #{url} after #{tries} tries"
311
+ raise e
312
+ end
313
+ else
314
+ raise e
315
+ end
316
+
317
+ rescue *host_unreachable_exceptions => e
318
+ log_error "[#{e.class}] #{e.message} #{connection.host.inspect}"
319
+
320
+ connection.dead!
321
+
322
+ if reload_on_failure and tries < connections.all.size
323
+ log_warn "[#{e.class}] Reloading connections (attempt #{tries} of #{connections.all.size})"
324
+ reload_connections! and retry
325
+ end
326
+
327
+ if max_retries
328
+ log_warn "[#{e.class}] Attempt #{tries} connecting to #{connection.host.inspect}"
329
+ if tries <= max_retries
330
+ retry
331
+ else
332
+ log_fatal "[#{e.class}] Cannot connect to #{connection.host.inspect} after #{tries} tries"
333
+ raise e
334
+ end
335
+ else
336
+ raise e
337
+ end
338
+
339
+ rescue Exception => e
340
+ log_fatal "[#{e.class}] #{e.message} (#{connection.host.inspect if connection})"
341
+ raise e
342
+
343
+ end #/begin
344
+
345
+ duration = Time.now - start
346
+
347
+ if response.status.to_i >= 300
348
+ __log_response method, path, params, body, url, response, nil, 'N/A', duration
349
+ __trace method, path, params, connection.connection.headers, body, url, response, nil, 'N/A', duration if tracer
350
+
351
+ # Log the failure only when `ignore` doesn't match the response status
352
+ unless ignore.include?(response.status.to_i)
353
+ log_fatal "[#{response.status}] #{response.body}"
354
+ end
355
+
356
+ __raise_transport_error response unless ignore.include?(response.status.to_i)
357
+ end
358
+
359
+ json = serializer.load(response.body) if response.body && !response.body.empty? && response.headers && response.headers["content-type"] =~ /json/
360
+ took = (json['took'] ? sprintf('%.3fs', json['took']/1000.0) : 'n/a') rescue 'n/a'
361
+
362
+ unless ignore.include?(response.status.to_i)
363
+ __log_response method, path, params, body, url, response, json, took, duration
364
+ end
365
+
366
+ __trace method, path, params, connection.connection.headers, body, url, response, nil, 'N/A', duration if tracer
367
+
368
+ warnings(response.headers['warning']) if response.headers&.[]('warning')
369
+
370
+ Response.new response.status, json || response.body, response.headers
371
+ ensure
372
+ @last_request_at = Time.now
373
+ end
374
+
375
+ # @abstract Returns an Array of connection errors specific to the transport implementation.
376
+ # See {HTTP::Faraday#host_unreachable_exceptions} for an example.
377
+ #
378
+ # @return [Array]
379
+ #
380
+ def host_unreachable_exceptions
381
+ [Errno::ECONNREFUSED]
382
+ end
383
+
384
+ private
385
+
386
+ USER_AGENT_STR = 'User-Agent'.freeze
387
+ USER_AGENT_REGEX = /user\-?\_?agent/
388
+ CONTENT_TYPE_STR = 'Content-Type'.freeze
389
+ CONTENT_TYPE_REGEX = /content\-?\_?type/
390
+ DEFAULT_CONTENT_TYPE = 'application/json'.freeze
391
+ GZIP = 'gzip'.freeze
392
+ ACCEPT_ENCODING = 'Accept-Encoding'.freeze
393
+ GZIP_FIRST_TWO_BYTES = '1f8b'.freeze
394
+ HEX_STRING_DIRECTIVE = 'H*'.freeze
395
+ RUBY_ENCODING = '1.9'.respond_to?(:force_encoding)
396
+
397
+ def decompress_response(body)
398
+ return body unless use_compression?
399
+ return body unless gzipped?(body)
400
+
401
+ io = StringIO.new(body)
402
+ gzip_reader = if RUBY_ENCODING
403
+ Zlib::GzipReader.new(io, :encoding => 'ASCII-8BIT')
404
+ else
405
+ Zlib::GzipReader.new(io)
406
+ end
407
+ gzip_reader.read
408
+ end
409
+
410
+ def gzipped?(body)
411
+ body[0..1].unpack(HEX_STRING_DIRECTIVE)[0] == GZIP_FIRST_TWO_BYTES
412
+ end
413
+
414
+ def use_compression?
415
+ @compression
416
+ end
417
+
418
+ def apply_headers(client, options)
419
+ headers = options[:headers] || {}
420
+ headers[CONTENT_TYPE_STR] = find_value(headers, CONTENT_TYPE_REGEX) || DEFAULT_CONTENT_TYPE
421
+ headers[USER_AGENT_STR] = find_value(headers, USER_AGENT_REGEX) || user_agent_header(client)
422
+ client.headers[ACCEPT_ENCODING] = GZIP if use_compression?
423
+ client.headers.merge!(headers)
424
+ end
425
+
426
+ def find_value(hash, regex)
427
+ key_value = hash.find { |k,v| k.to_s.downcase =~ regex }
428
+ if key_value
429
+ hash.delete(key_value[0])
430
+ key_value[1]
431
+ end
432
+ end
433
+
434
+ def user_agent_header(client)
435
+ @user_agent ||= begin
436
+ meta = ["RUBY_VERSION: #{RUBY_VERSION}"]
437
+ if RbConfig::CONFIG && RbConfig::CONFIG['host_os']
438
+ meta << "#{RbConfig::CONFIG['host_os'].split('_').first[/[a-z]+/i].downcase} #{RbConfig::CONFIG['target_cpu']}"
439
+ end
440
+ "elastic-transport-ruby/#{VERSION} (#{meta.join('; ')})"
441
+ end
442
+ end
443
+
444
+ def warnings(warning)
445
+ warn("warning: #{warning}")
446
+ end
447
+ end
448
+ end
449
+ end
450
+ end