cloudstack_client 1.6.0 → 2.0.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.
data/README.md CHANGED
@@ -1,24 +1,34 @@
1
1
  # cloudstack_client
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/cloudstack_client.png)](http://badge.fury.io/rb/cloudstack_client)
3
+ [![Gem Version](https://img.shields.io/gem/v/cloudstack_client.svg)](https://rubygems.org/gems/cloudstack_client)
4
+ [![CI](https://github.com/niwo/cloudstack_client/actions/workflows/ci.yml/badge.svg)](https://github.com/niwo/cloudstack_client/actions/workflows/ci.yml)
4
5
 
5
6
  A CloudStack API client written in Ruby.
6
7
 
8
+ ## Version 2.0.0
9
+
10
+ Version 2.0.0 requires Ruby 3.0 or newer. It also fixes request dispatch when
11
+ no custom host is configured and improves support for CloudStack commands with
12
+ acronyms, such as `create_ssh_key_pair`.
13
+
7
14
  ## Installation
8
15
 
9
16
  Install the cloudstack_client gem:
10
17
 
11
18
  ```bash
12
- $ gem install cloudstack_client
19
+ gem install cloudstack_client
13
20
  ```
14
21
 
15
22
  ## Features
16
23
 
17
24
  - Access to the whole CloudStack-API from Ruby
18
- - Interactive console for playing with the CloudStack API: ```cloudstack_client console```
25
+ - Interactive console for playing with the CloudStack API:
26
+ `cloudstack_client console`
19
27
  - Dynamically builds API methods based on the listApis function of CloudStack
20
- - Command names are converted to match Ruby naming conventions (i.e. ListVirtualMachines becomes list_virtual_machines)
21
- - Accepts Ruby Hash arguments passed to commands as options (i.e. list_all: true becomes listall=true)
28
+ - Command names are converted to match Ruby naming conventions (i.e.
29
+ ListVirtualMachines becomes list_virtual_machines)
30
+ - Accepts Ruby Hash arguments passed to commands as options (i.e. `list_all: true`
31
+ becomes `listall=true`)
22
32
  - Assure all required arguments are passed
23
33
  - Removes unsupported arguments and arguments with nil values from commands
24
34
 
@@ -69,11 +79,15 @@ cs = CloudstackClient::Client.new(
69
79
 
70
80
  ### Pagination and Response Options
71
81
 
72
- When working with paginated responses, you can include the total count in the API response:
82
+ When working with paginated responses, you can include the total count in the
83
+ API response:
73
84
 
74
85
  ```ruby
75
86
  # Get paginated results with count information
76
- vms = cs.list_virtual_machines({ page: 1, pagesize: 10 }, { include_count: true })
87
+ vms = cs.list_virtual_machines(
88
+ { page: 1, pagesize: 10 },
89
+ { include_count: true }
90
+ )
77
91
  total_count = vms[:count]
78
92
  items = vms[:virtualmachine]
79
93
 
@@ -84,9 +98,10 @@ vms = cs.list_virtual_machines(page: 1, pagesize: 10)
84
98
 
85
99
  ### Using the configuration module
86
100
 
87
- The configuration module of CloudstackClient makes it easy to load CloudStack API settings from configuration files.
101
+ The configuration module of CloudstackClient makes it easy to load CloudStack
102
+ API settings from configuration files.
88
103
 
89
- #### Example
104
+ #### Configuration Example
90
105
 
91
106
  ```ruby
92
107
  require "cloudstack_client"
@@ -94,7 +109,11 @@ require "cloudstack_client/configuration"
94
109
 
95
110
  # looks for ~/.cloudstack.yml per default
96
111
  config = CloudstackClient::Configuration.load
97
- cs = CloudstackClient::Client.new(config[:url], config[:api_key], config[:secret_key])
112
+ cs = CloudstackClient::Client.new(
113
+ config[:url],
114
+ config[:api_key],
115
+ config[:secret_key]
116
+ )
98
117
  ```
99
118
 
100
119
  #### Configuration files
@@ -120,62 +139,117 @@ test:
120
139
 
121
140
  ### Configuration options
122
141
 
123
- You can pass `options` as 4th argument in `CloudstackClient::Client.new`. All its keys are optional.
142
+ You can pass `options` as 4th argument in `CloudstackClient::Client.new`.
143
+ All its keys are optional.
124
144
 
125
145
  ```ruby
126
146
  options = {
127
- symbolize_keys: true, # pass symbolize_names: true in JSON#parse for Cloudstack responses, default: false
128
- host: 'localhost', # custom host header to be used in Net::Http. May be useful when Cloudstack is set up locally via docker (i.e. Cloudstack-simulator), default: parsed from config[:url] via Net::Http
129
- read_timeout: 10, # timeout in seconds of a connection to the Cloudstack, default: 60
130
- request_retries: 3 # number of attempts for HTTP requests before raising a ConnectionError, default: 1 (no retries). Uses incremental back-off between attempts.
147
+ # Pass symbolize_names: true in JSON#parse for CloudStack responses.
148
+ # Default: false.
149
+ symbolize_keys: true,
150
+ # Custom host header for Net::HTTP. Useful with CloudStack-simulator.
151
+ # Default: parsed from config[:url] via Net::HTTP.
152
+ host: 'localhost',
153
+ # Timeout in seconds for a connection to CloudStack. Default: 60.
154
+ read_timeout: 10,
155
+ # HTTP request attempts before raising ConnectionError. Default: 1.
156
+ # Uses incremental back-off between attempts.
157
+ request_retries: 3,
158
+ # Verify HTTPS certificates. Default: true.
159
+ verify_ssl: true,
160
+ # Optional path to a custom CA certificate bundle.
161
+ ca_file: "/path/to/ca-bundle.pem",
162
+ # Raise ParameterError for unsupported command parameters. Default: false.
163
+ strict_params: false
131
164
  }
132
- cs = CloudstackClient::Client.new(config[:url], config[:api_key], config[:secret_key], options)
165
+ cs = CloudstackClient::Client.new(
166
+ config[:url],
167
+ config[:api_key],
168
+ config[:secret_key],
169
+ options
170
+ )
133
171
  ```
134
172
 
135
- For a single call you can override defaults on the **second** hash (client options), without changing the client instance:
173
+ HTTPS certificate verification is enabled by default. Set `verify_ssl: false`
174
+ only when connecting to a trusted endpoint with a certificate that cannot be
175
+ validated, such as a local development server.
176
+
177
+ By default, unsupported command parameters are ignored. Set
178
+ `strict_params: true` to raise `ParameterError` instead.
179
+
180
+ For a single call you can override defaults on the **second** hash (client
181
+ options), without changing the client instance:
136
182
 
137
183
  ```ruby
138
- cs.deploy_virtual_machine({ zoneid: "...", serviceofferingid: "...", templateid: "..." },
139
- async_timeout: 600, async_poll_interval: 5)
184
+ cs.deploy_virtual_machine(
185
+ { zoneid: "...", serviceofferingid: "...", templateid: "..." },
186
+ async_timeout: 600,
187
+ async_poll_interval: 5
188
+ )
140
189
  ```
141
190
 
142
191
  ### Interactive Console
143
192
 
144
193
  cloudstack_client comes with an interactive console.
145
194
 
146
- #### Example
195
+ #### Console Example
147
196
 
148
197
  ```bash
149
- $ cloudstack_client console -e prod
198
+ cloudstack_client console -e prod
150
199
  prod >> list_virtual_machines
151
200
  ```
152
201
 
153
202
  ## Development
154
203
 
204
+ ### Running the tests
205
+
206
+ ```bash
207
+ bundle install
208
+ bundle exec rake # tests and RuboCop
209
+ bundle exec rake test # tests only
210
+ bundle exec rake rubocop # RuboCop only
211
+ bundle exec rake benchmark
212
+ ```
213
+
214
+ Tests use [Minitest](https://github.com/minitest/minitest) in spec style and
215
+ [WebMock](https://github.com/bblimke/webmock). Outbound network access is
216
+ disabled in the test suite, so any request that is not stubbed fails the test
217
+ rather than reaching a real endpoint. Helpers for stubbing CloudStack responses
218
+ live in `test/support/api_stubs.rb`.
219
+
220
+ `Gemfile.lock` is deliberately not checked in, so each supported Ruby version
221
+ resolves its own compatible dependency set.
222
+
155
223
  ### Generate or update API definitions
156
224
 
157
225
  New API definitions can be generated using the `list_apis` command.
158
226
 
159
- #### Example
227
+ #### API Definition Example
160
228
 
161
229
  ```bash
162
230
  # running against a CloudStack 4.15 API endpoint:
163
- $ cloudstack_client list_apis > data/4.15.json
164
- $ gzip data/4.15.json
231
+ cloudstack_client list_apis > data/4.15.json
232
+ gzip data/4.15.json
165
233
  ```
166
234
 
167
235
  ### GitHub Actions
168
236
 
169
237
  This repository includes GitHub Actions workflows for:
170
238
 
171
- - Running tests and gem build on every push and pull request (`CI`)
239
+ - Running tests and gem build against every supported Ruby version, plus a
240
+ RuboCop lint job, on every push and pull request (`CI`)
172
241
  - Publishing the gem to RubyGems when a GitHub Release is published (`Release`)
173
242
 
174
- To enable publishing, add this repository secret:
243
+ Dependency and GitHub Actions updates are proposed weekly by Dependabot.
175
244
 
176
- - `RUBYGEMS_AUTH_TOKEN`: your RubyGems API key with push permissions
245
+ To enable publishing, configure a RubyGems trusted publisher for this
246
+ repository, workflow, and `rubygems` environment. The release workflow grants
247
+ the publish job GitHub's OIDC `id-token` permission, so no RubyGems API key
248
+ secret is required.
177
249
 
178
- The release workflow checks that `CloudstackClient::VERSION` is greater than the latest version on RubyGems before building, then uses the `rubygems` environment to publish.
250
+ The release workflow checks that `CloudstackClient::VERSION` is greater than
251
+ the latest version on RubyGems before building, then uses the `rubygems`
252
+ environment to publish.
179
253
 
180
254
  ## References
181
255
 
@@ -191,4 +265,6 @@ The release workflow checks that `CloudstackClient::VERSION` is greater than the
191
265
 
192
266
  ## License
193
267
 
194
- Released under the MIT License. See the [LICENSE](https://raw.github.com/niwo/cloudstack_client/master/LICENSE.txt) file for further details.
268
+ Released under the MIT License. See the
269
+ [LICENSE](https://raw.github.com/niwo/cloudstack_client/master/LICENSE.txt)
270
+ file for further details.
data/Rakefile CHANGED
@@ -9,5 +9,20 @@ Rake::TestTask.new do |t|
9
9
  t.warning = false
10
10
  end
11
11
 
12
- desc "Run Tests"
13
- task default: :test
12
+ begin
13
+ require 'rubocop/rake_task'
14
+ RuboCop::RakeTask.new
15
+ rescue LoadError
16
+ desc "rubocop is not available"
17
+ task :rubocop do
18
+ abort "RuboCop is not available. Run `bundle install` first."
19
+ end
20
+ end
21
+
22
+ desc "Run the API benchmark"
23
+ task :benchmark do
24
+ ruby "-Ilib test/benchmark.rb"
25
+ end
26
+
27
+ desc "Run tests and RuboCop"
28
+ task default: %i[test rubocop]
@@ -8,20 +8,39 @@ Gem::Specification.new do |gem|
8
8
  gem.version = CloudstackClient::VERSION
9
9
  gem.authors = ["Nik Wolfgramm"]
10
10
  gem.email = ["nik.wolfgramm@gmail.com"]
11
- gem.description = %q{CloudStack API client written in Ruby}
11
+ gem.description = %q{A Ruby client for the Apache CloudStack API that builds its
12
+ methods dynamically from the CloudStack API definition,
13
+ including an interactive console.}.gsub(/\s+/, ' ')
12
14
  gem.summary = %q{CloudStack API client written in Ruby}
13
15
  gem.homepage = "https://github.com/niwo/cloudstack_client"
14
16
  gem.license = 'MIT'
15
17
 
16
- gem.required_ruby_version = '>= 1.9.3'
18
+ gem.required_ruby_version = '>= 3.0'
17
19
  gem.files = `git ls-files`.split($/)
18
20
  gem.executables = %w(cloudstack_client)
19
- gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
20
21
  gem.require_paths = ["lib"]
21
22
  gem.rdoc_options = %w[--line-numbers --inline-source]
22
23
 
24
+ gem.metadata = {
25
+ "homepage_uri" => gem.homepage,
26
+ "bug_tracker_uri" => "#{gem.homepage}/issues",
27
+ "changelog_uri" => "#{gem.homepage}/releases",
28
+ "rubygems_mfa_required" => "true"
29
+ }
30
+
31
+ # base64 stopped being a default gem in Ruby 3.4, so `require "base64"`
32
+ # in connection.rb fails under Bundler unless it is declared here.
33
+ gem.add_dependency('base64', '>= 0.1')
34
+
23
35
  gem.add_development_dependency('rake', '~> 13.0')
24
36
  gem.add_development_dependency('thor', '~> 1.1')
25
37
  gem.add_development_dependency('ripl', '~> 0.7')
26
- gem.add_development_dependency('minitest', '~> 5.14')
38
+ # Allow minitest 6 on Ruby >= 3.2 while staying installable on Ruby 3.0/3.1.
39
+ gem.add_development_dependency('minitest', '>= 5.14', '< 7')
40
+ gem.add_development_dependency('minitest-reporters', '~> 1.8')
41
+ gem.add_development_dependency('webmock', '~> 3.26')
42
+ gem.add_development_dependency('benchmark', '>= 0.3')
43
+ gem.add_development_dependency('rubocop', '~> 1.90')
44
+ gem.add_development_dependency('rubocop-minitest', '~> 0.40')
45
+ gem.add_development_dependency('rubocop-rake', '~> 0.7')
27
46
  end
@@ -1,5 +1,6 @@
1
1
  require "zlib"
2
2
  require "json"
3
+ require "cloudstack_client/error"
3
4
  require "cloudstack_client/utils"
4
5
 
5
6
  module CloudstackClient
@@ -25,14 +26,29 @@ module CloudstackClient
25
26
  end
26
27
 
27
28
  def command_supported?(command)
28
- @commands.has_key? underscore_to_camel_case(command)
29
+ !find_command(command).nil?
29
30
  end
30
31
 
31
32
  def command_supports_param?(command, key)
32
- command = underscore_to_camel_case(command)
33
- @commands[command]["params"].detect do |params|
34
- params["name"] == key.to_s
35
- end ? true : false
33
+ command = find_command(command)
34
+ return false if command.nil?
35
+
36
+ command["params"].any? { |param| param["name"] == key.to_s }
37
+ end
38
+
39
+ # Resolves a command given either its CloudStack name ("createSSHKeyPair")
40
+ # or its underscored Ruby name ("create_ssh_key_pair").
41
+ #
42
+ # underscore_to_camel_case cannot be relied on here: converting to
43
+ # underscores loses acronym casing, so "create_ssh_key_pair" converts back
44
+ # to "createSshKeyPair", which is not a CloudStack command. Underscored
45
+ # names are resolved through an index built in the lossy direction instead.
46
+ #
47
+ # The index is built on first use: most lookups arrive as CloudStack names
48
+ # and hit @commands directly, so instantiating an Api should not pay for it.
49
+ def find_command(command)
50
+ command = command.to_s
51
+ @commands[command] || underscored_commands[command]
36
52
  end
37
53
 
38
54
  def required_params(command)
@@ -68,11 +84,7 @@ module CloudstackClient
68
84
  end
69
85
 
70
86
  def set_api_path(options)
71
- @api_path = if options[:api_path]
72
- File.expand_path(options[:api_path])
73
- else
74
- API_PATH
75
- end
87
+ @api_path = options[:api_path] ? File.expand_path(options[:api_path]) : API_PATH
76
88
  end
77
89
 
78
90
  def set_api_version(options)
@@ -89,13 +101,26 @@ module CloudstackClient
89
101
  @api_version
90
102
  end
91
103
 
104
+ def underscored_commands
105
+ @underscored_commands ||= @commands.each_with_object({}) do |(name, command), index|
106
+ index[camel_case_to_underscore(name)] = command
107
+ end
108
+ end
109
+
92
110
  def load_commands
93
111
  @commands = {}
94
- Zlib::GzipReader.open(@api_file) do |gz|
112
+ @underscored_commands = nil
113
+ parsed = Zlib::GzipReader.open(@api_file) do |gz|
95
114
  JSON.parse(gz.read)
96
- end.each {|cmd| @commands[cmd["name"]] = cmd }
97
- rescue => e
98
- raise "Error: Unable to read file '#{@api_file}': #{e.message}"
115
+ end
116
+ unless parsed.is_a?(Array) && parsed.all?(Hash)
117
+ raise ApiDefinitionError,
118
+ "Unable to read API definition '#{@api_file}': unexpected format (expected an array of command hashes)"
119
+ end
120
+ parsed.each { |cmd| @commands[cmd["name"]] = cmd }
121
+ rescue Zlib::Error, JSON::ParserError, SystemCallError, EOFError => e
122
+ raise ApiDefinitionError,
123
+ "Unable to read API definition '#{@api_file}': #{e.message}"
99
124
  end
100
125
 
101
126
  end
@@ -98,7 +98,7 @@ module CloudstackClient
98
98
 
99
99
  ARGV.clear
100
100
  Ripl.config[:prompt] = "#{@config[:environment]} >> "
101
- Ripl.start binding: cs_client.instance_eval{ binding }
101
+ Ripl.start binding: cs_client.instance_eval { binding }
102
102
  end
103
103
 
104
104
  no_commands do
@@ -25,8 +25,11 @@ module CloudstackClient
25
25
 
26
26
  args.each do |k, v|
27
27
  k = k.to_s.gsub("_", "")
28
- if v && @api.command_supports_param?(command["name"], k)
28
+ if !v.nil? && @api.command_supports_param?(command["name"], k)
29
29
  params[k] = v
30
+ elsif !v.nil? && options.fetch(:strict_params, @options[:strict_params])
31
+ raise ParameterError,
32
+ "#{command['name']} does not support parameter #{k}"
30
33
  end
31
34
  end
32
35
 
@@ -10,7 +10,7 @@ module CloudstackClient
10
10
  end
11
11
 
12
12
  begin
13
- config = YAML::load(IO.read file)
13
+ config = YAML.safe_load(IO.read(file), permitted_classes: [Symbol])
14
14
  rescue => e
15
15
  message = "Can't load configuration from file '#{file}'."
16
16
  if configuration[:debug]
@@ -20,13 +20,25 @@ module CloudstackClient
20
20
  raise ConfigurationError, message
21
21
  end
22
22
 
23
+ unless config.is_a?(Hash)
24
+ raise ConfigurationError, "Configuration file '#{file}' must contain a hash."
25
+ end
26
+
23
27
  if env = configuration[:env] || config[:default]
24
28
  unless config = config[env]
25
29
  raise ConfigurationError, "Can't find environment #{env}."
26
30
  end
27
31
  end
28
32
 
29
- unless config.key?(:url) && config.key?(:api_key) && config.key?(:secret_key)
33
+ unless config.is_a?(Hash)
34
+ raise ConfigurationError, "Environment #{env} must contain a hash."
35
+ end
36
+
37
+ required_keys = %i[url api_key secret_key]
38
+ missing_keys = required_keys.reject do |key|
39
+ config.key?(key) && !config[key].to_s.strip.empty?
40
+ end
41
+ unless missing_keys.empty?
30
42
  message = "The environment #{env || '\'-\''} does not contain all required keys."
31
43
  raise ConfigurationError, message
32
44
  end
@@ -4,12 +4,16 @@ require "uri"
4
4
  require "cgi"
5
5
  require "net/http"
6
6
  require "json"
7
+ require "cloudstack_client/request_handling"
7
8
 
8
9
  module CloudstackClient
9
10
  class Connection
10
11
  include Utils
12
+ include RequestHandling
11
13
 
12
- attr_accessor :api_url, :api_key, :secret_key, :verbose, :debug, :symbolize_keys, :host, :read_timeout
14
+ attr_reader :api_key, :secret_key
15
+ attr_accessor :api_url, :verbose, :debug, :symbolize_keys, :host, :read_timeout
16
+ attr_accessor :verify_ssl, :ca_file
13
17
  attr_accessor :async_poll_interval, :async_timeout, :request_retries
14
18
 
15
19
  DEF_POLL_INTERVAL = 2.0
@@ -25,6 +29,8 @@ module CloudstackClient
25
29
  @debug = options[:debug] ? true : false
26
30
  @symbolize_keys = options[:symbolize_keys] ? true : false
27
31
  @host = options[:host]
32
+ @verify_ssl = options.fetch(:verify_ssl, true)
33
+ @ca_file = options[:ca_file]
28
34
  @read_timeout = options[:read_timeout] || DEF_REQ_TIMEOUT
29
35
  @async_poll_interval = options[:async_poll_interval] || DEF_POLL_INTERVAL
30
36
  @async_timeout = options[:async_timeout] || DEF_ASYNC_TIMEOUT
@@ -37,60 +43,6 @@ module CloudstackClient
37
43
  # Sends a synchronous request to the CloudStack API and returns the response as a Hash.
38
44
  #
39
45
 
40
- def send_request(params, opts = {})
41
- params['response'] = 'json'
42
- params['apiKey'] = @api_key
43
- print_debug_output JSON.pretty_generate(params) if @debug
44
-
45
- data = params_to_data(params)
46
- uri = URI.parse "#{@api_url}?#{data}&signature=#{create_signature(data)}"
47
-
48
- http = Net::HTTP.new(uri.host, uri.port)
49
- if uri.scheme == 'https'
50
- http.use_ssl = true
51
- http.verify_mode = OpenSSL::SSL::VERIFY_NONE
52
- end
53
- http.read_timeout = @read_timeout
54
-
55
- retries = 0
56
- begin
57
- req = Net::HTTP::Get.new(uri.request_uri)
58
- req['Host'] = host if host.present?
59
- response = http.request(req)
60
- rescue => e
61
- retries += 1
62
- if retries < @request_retries
63
- sleep(retries) # incremental back-off
64
- print "." if @verbose
65
- retry
66
- end
67
- raise ConnectionError, "API URL \'#{@api_url}\' is not reachable (after #{retries} attempt#{'s' if retries > 1}): #{e.message}"
68
- end
69
-
70
- begin
71
- body = JSON.parse(response.body, symbolize_names: @symbolize_keys).values.first
72
- rescue JSON::ParserError
73
- raise ParseError,
74
- "Response from server is not readable. Check if the API endpoint (#{@api_url}) is valid and accessible."
75
- end
76
-
77
- if response.is_a?(Net::HTTPOK)
78
- return body unless body.respond_to?(:keys)
79
- if body.size == 2 && body.key?(k('count'))
80
- return opts[:include_count] ? body : body.reject { |key, _| key == k('count') }.values.first
81
- elsif body.size == 1 && body.values.first.respond_to?(:keys)
82
- item = body.values.first
83
- return (item.is_a?(Array) || item.is_a?(Hash)) ? item : []
84
- else
85
- body.reject! { |key, _| key == k('count') } if body.key?(k('count')) && !opts[:include_count]
86
- body.size == 0 ? [] : body
87
- end
88
- else
89
- message = body[k('errortext')] rescue body
90
- raise ApiError, "Status #{response.code}: #{message}."
91
- end
92
- end
93
-
94
46
  ##
95
47
  # Sends an asynchronous request and waits for the response.
96
48
  #
@@ -116,7 +68,11 @@ module CloudstackClient
116
68
  when 1
117
69
  return data[k('jobresult')]
118
70
  when 2
119
- raise JobError, "Request failed (#{data[k('jobresultcode')]}): #{data[k('jobresult')][k('errortext')]}."
71
+ result = data[k('jobresult')]
72
+ error_text = result.is_a?(Hash) ? result[k('errortext')] : nil
73
+ error_text ||= "Unknown error"
74
+ raise JobError,
75
+ "Request failed (#{data[k('jobresultcode')]}): #{error_text}."
120
76
  end
121
77
 
122
78
  STDOUT.flush if @verbose
@@ -4,6 +4,7 @@ module CloudstackClient
4
4
  class ConnectionError < Error; end
5
5
  class ConfigurationError < Error; end
6
6
  class ParseError < Error; end
7
+ class ApiDefinitionError < Error; end
7
8
  class ApiError < Error; end
8
9
  class JobError < Error; end
9
10
  class TimeoutError < Error; end
@@ -0,0 +1,91 @@
1
+ module CloudstackClient
2
+ module RequestHandling
3
+ def send_request(params, opts = {})
4
+ params['response'] = 'json'
5
+ params['apiKey'] = @api_key
6
+ print_debug_output JSON.pretty_generate(params) if @debug
7
+
8
+ data = params_to_data(params)
9
+ uri = URI.parse "#{@api_url}?#{data}&signature=#{create_signature(data)}"
10
+ http = build_http(uri)
11
+ response = request_with_retries(http, uri)
12
+
13
+ handle_response(response, opts)
14
+ end
15
+
16
+ private
17
+
18
+ def build_http(uri)
19
+ http = Net::HTTP.new(uri.host, uri.port)
20
+ return configure_https(http) if uri.scheme == 'https'
21
+
22
+ http.read_timeout = @read_timeout
23
+ http
24
+ end
25
+
26
+ def configure_https(http)
27
+ http.use_ssl = true
28
+ http.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
29
+ http.ca_file = @ca_file if @ca_file
30
+ http.read_timeout = @read_timeout
31
+ http
32
+ end
33
+
34
+ def request_with_retries(http, uri)
35
+ retries = 0
36
+ begin
37
+ request = Net::HTTP::Get.new(uri.request_uri)
38
+ request['Host'] = host unless host.to_s.strip.empty?
39
+ http.request(request)
40
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH,
41
+ Errno::ENETUNREACH, Errno::ETIMEDOUT, Net::OpenTimeout,
42
+ Net::ReadTimeout, SocketError, EOFError => e
43
+ retries += 1
44
+ if retries < @request_retries
45
+ sleep(retries)
46
+ print "." if @verbose
47
+ retry
48
+ end
49
+ raise ConnectionError,
50
+ "API URL '#{@api_url}' is not reachable " \
51
+ "(after #{retries} attempt#{'s' if retries > 1}): #{e.message}"
52
+ end
53
+ end
54
+
55
+ def handle_response(response, opts)
56
+ body = parse_response_body(response)
57
+ return normalize_success(body, opts) if response.is_a?(Net::HTTPOK)
58
+
59
+ message = body.is_a?(Hash) ? body[k('errortext')] : body
60
+ raise ApiError, "Status #{response.code}: #{message}."
61
+ end
62
+
63
+ def parse_response_body(response)
64
+ JSON.parse(response.body, symbolize_names: @symbolize_keys).values.first
65
+ rescue JSON::ParserError
66
+ raise ParseError,
67
+ "Response from server is not readable. Check if the API endpoint " \
68
+ "(#{@api_url}) is valid and accessible."
69
+ end
70
+
71
+ def normalize_success(body, opts)
72
+ return body unless body.respond_to?(:keys)
73
+ return normalize_counted_response(body, opts) if body.size == 2 && body.key?(k('count'))
74
+ return normalize_nested_response(body) if body.size == 1 && body.values.first.respond_to?(:keys)
75
+
76
+ body.reject! { |key, _| key == k('count') } if body.key?(k('count')) && !opts[:include_count]
77
+ body.empty? ? [] : body
78
+ end
79
+
80
+ def normalize_counted_response(body, opts)
81
+ return body if opts[:include_count]
82
+
83
+ body.reject { |key, _| key == k('count') }.values.first
84
+ end
85
+
86
+ def normalize_nested_response(body)
87
+ item = body.values.first
88
+ item.is_a?(Array) || item.is_a?(Hash) ? item : []
89
+ end
90
+ end
91
+ end
@@ -1,3 +1,3 @@
1
1
  module CloudstackClient
2
- VERSION = "1.6.0"
2
+ VERSION = "2.0.0"
3
3
  end