redfish_client 0.4.0 → 0.5.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4b1f7a963b818e8c87405bd16dc08ad715d703a9bc4368a268180f3f75efd9b9
4
- data.tar.gz: 12972d4bb0c529e2a78bb6142622ef161da444ee7d409fe0677f1fdd28846311
3
+ metadata.gz: 4a61fbe0cf26d1c84428c300e40f4c85dad097b85d1290ab7ea1f9515cf63198
4
+ data.tar.gz: 4b3d92086f62fdf2553b9eb635eaf8f0dad476b7030e4ea7c935961def7e191a
5
5
  SHA512:
6
- metadata.gz: dbe8c7587ea30dc6483b42e8b17416d2a432b6f1753774b5e2f55373dc195a7b2907ab44c22dcba8db672adb6266fe24dd97b40a9c6c821c7f1587ef83f9bacf
7
- data.tar.gz: cd9cb0800c58554b877dc41c481a18e612b3d557f795ee4546fd9230e4299ac1c5c5e3eb66ca6ac279f12945679e72d86d2c699a0d7fbbfa6d5401042b69d9ec
6
+ metadata.gz: c6c9e428b744638d1e0ea9c508474ad109410c86098c662f6cd6640e299bbb647c48f2ceb113e9eabaa95968806b918d6edee879bb62c1f85e4641f9cd382c00
7
+ data.tar.gz: 79a3e67240e13830cdba2560b0b1bbf29fa027081ca3e3a0ba7896f9f66e3ddab29a53a98af431a533ee149c031faf11ce359adeeea1f82bdfcb86e1f01d9bbf
@@ -0,0 +1,21 @@
1
+ name: Ruby
2
+
3
+ on:
4
+ push:
5
+ branches: [ master ]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - name: Clone repo
13
+ uses: actions/checkout@v2
14
+ - name: Set up Ruby
15
+ uses: ruby/setup-ruby@v1
16
+ with:
17
+ ruby-version: 2.6
18
+ - name: Install dependencies
19
+ run: bundle install
20
+ - name: Run tests
21
+ run: bundle exec rake
data/.rubocop.yml CHANGED
@@ -9,6 +9,9 @@ AllCops:
9
9
  Layout/MultilineOperationIndentation:
10
10
  EnforcedStyle: indented
11
11
 
12
+ Layout/MultilineMethodCallIndentation:
13
+ EnforcedStyle: indented
14
+
12
15
  Style/MethodMissing:
13
16
  Exclude:
14
17
  - lib/redfish_client/resource.rb
@@ -22,9 +25,24 @@ Style/Documentation:
22
25
  Style/BracesAroundHashParameters:
23
26
  EnforcedStyle: context_dependent
24
27
 
28
+ Style/TrailingCommaInArguments:
29
+ EnforcedStyleForMultiline: comma
30
+
31
+ Style/TrailingCommaInArrayLiteral:
32
+ EnforcedStyleForMultiline: comma
33
+
34
+ Style/TrailingCommaInHashLiteral:
35
+ EnforcedStyleForMultiline: comma
36
+
25
37
  Metrics/AbcSize:
26
38
  Max: 20
27
39
 
28
40
  Metrics/BlockLength:
29
41
  Exclude:
30
42
  - spec/**/*.rb
43
+
44
+ Metrics/ClassLength:
45
+ Max: 500
46
+
47
+ Metrics/ParameterLists:
48
+ CountKeywordArgs: false
data/README.md CHANGED
@@ -41,6 +41,46 @@ Minimal program that uses this gem would look something like this:
41
41
  root.logout
42
42
 
43
43
 
44
+ ## Handling asynchronous operations
45
+
46
+ Redfish service can return a 202 status when we request an execution of a
47
+ long-running operation (e.g. updating firmware). We are expected to poll the
48
+ monitor for changes until the job terminates.
49
+
50
+ Responses in Redfish client have a built-in support for this, so polling the
51
+ service is rather painless:
52
+
53
+ # Start the async action
54
+ response = update_service.Actions["#UpdateService.SimpleUpdate"].post(
55
+ field: "target", payload: { ... },
56
+ )
57
+ # Wait for the termination
58
+ response = update_service.wait(response)
59
+ # Do something with response
60
+
61
+ It is also possible to manually poll the response like this:
62
+
63
+ response = update_service.Actions["#UpdateService.SimpleUpdate"].post(
64
+ field: "target", payload: { ... },
65
+ )
66
+ until response.done?
67
+ # wait a bit
68
+ response = update_service.get(response.monitor)
69
+ end
70
+
71
+ Response is also safe to (de)serialize, which means that the process that
72
+ started the async operation and the process that will wait for it can be
73
+ separate:
74
+
75
+ response = update_service.Actions["#UpdateService.SimpleUpdate"].post(
76
+ field: "target", payload: { ... },
77
+ )
78
+ send_response_somewhere(response.to_h)
79
+
80
+ # Somewhere else
81
+ response = Response.from_hash(receive_response_from_somewhere)
82
+
83
+
44
84
  ## Development
45
85
 
46
86
  After checking out the repo, run `bin/setup` to install dependencies. Then,
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "redfish_client/caching_connector"
4
3
  require "redfish_client/connector"
4
+ require "redfish_client/nil_hash"
5
5
  require "redfish_client/root"
6
6
  require "redfish_client/version"
7
7
 
@@ -13,11 +13,8 @@ module RedfishClient
13
13
  # @param verify [Boolean] verify certificates for https connections
14
14
  # @param use_cache [Boolean] cache API responses
15
15
  def self.new(url, prefix: "/redfish/v1", verify: true, use_cache: true)
16
- con = if use_cache
17
- CachingConnector.new(url, verify)
18
- else
19
- Connector.new(url, verify)
20
- end
16
+ cache = (use_cache ? Hash : NilHash).new
17
+ con = Connector.new(url, verify: verify, cache: cache)
21
18
  Root.new(con, oid: prefix)
22
19
  end
23
20
  end
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "base64"
3
4
  require "excon"
4
5
  require "json"
5
6
 
7
+ require "redfish_client/nil_hash"
8
+ require "redfish_client/response"
9
+
6
10
  module RedfishClient
7
11
  # Connector serves as a low-level wrapper around HTTP calls that are used
8
12
  # to retrieve data from the service API. It abstracts away implementation
@@ -12,18 +16,39 @@ module RedfishClient
12
16
  # Library users should treat this class as an implementation detail and
13
17
  # use higer-level {RedfishClient::Resource} instead.
14
18
  class Connector
19
+ # AuthError is raised if the credentials are invalid.
20
+ class AuthError < StandardError; end
21
+
15
22
  # Default headers, as required by Redfish spec
16
23
  # https://redfish.dmtf.org/schemas/DSP0266_1.4.0.html#request-headers
17
24
  DEFAULT_HEADERS = {
18
25
  "Accept" => "application/json",
19
- "OData-Version" => "4.0"
26
+ "OData-Version" => "4.0",
20
27
  }.freeze
21
28
 
29
+ # Basic and token authentication header names
30
+ BASIC_AUTH_HEADER = "Authorization"
31
+ TOKEN_AUTH_HEADER = "X-Auth-Token"
32
+ LOCATION_HEADER = "Location"
33
+
22
34
  # Create new connector.
23
35
  #
36
+ # By default, connector performs no caching. If caching is desired,
37
+ # Hash should be used as a cache implementation.
38
+ #
39
+ # It is also possible to pass in custom caching class. Instances of that
40
+ # class should respond to the following four methods:
41
+ #
42
+ # 1. `[](key)` - Used to access cached content and should return
43
+ # `nil` if the key has no associated value.
44
+ # 2. `[]=(key, value)` - Cache `value` under the `key`
45
+ # 3. `clear` - Clear the complete cache.
46
+ # 4. `delete(key)` - Invalidate cache entry associated with `key`.
47
+ #
24
48
  # @param url [String] base url of the Redfish service
25
49
  # @param verify [Boolean] verify SSL certificate of the service
26
- def initialize(url, verify = true)
50
+ # @param cache [Object] cache backend
51
+ def initialize(url, verify: true, cache: nil)
27
52
  @url = url
28
53
  @headers = DEFAULT_HEADERS.dup
29
54
  middlewares = Excon.defaults[:middlewares] +
@@ -31,6 +56,7 @@ module RedfishClient
31
56
  @connection = Excon.new(@url,
32
57
  ssl_verify_peer: verify,
33
58
  middlewares: middlewares)
59
+ @cache = cache || NilHash.new
34
60
  end
35
61
 
36
62
  # Add HTTP headers to the requests made by the connector.
@@ -50,44 +76,131 @@ module RedfishClient
50
76
  headers.each { |h| @headers.delete(h) }
51
77
  end
52
78
 
79
+ # Issue requests to the service.
80
+ #
81
+ # @param mathod [Symbol] HTTP method (:get, :post, :patch or :delete)
82
+ # @param path [String] path to the resource, relative to the base
83
+ # @param data [Hash] data to be sent over the socket
84
+ # @return [Response] response object
85
+ def request(method, path, data = nil)
86
+ return @cache[path] if method == :get && @cache[path]
87
+
88
+ do_request(method, path, data).tap do |r|
89
+ @cache[path] = r if method == :get && r.status == 200
90
+ end
91
+ end
92
+
53
93
  # Issue GET request to service.
54
94
  #
95
+ # This method will first try to return cached response if available. If
96
+ # cache does not contain entry for this request, data will be fetched from
97
+ # remote and then cached, but only if the response has an OK (200) status.
98
+ #
55
99
  # @param path [String] path to the resource, relative to the base url
56
- # @return [Excon::Response] response object
100
+ # @return [Response] response object
57
101
  def get(path)
58
- @connection.get(path: path, headers: @headers)
102
+ request(:get, path)
59
103
  end
60
104
 
61
105
  # Issue POST requests to the service.
62
106
  #
63
107
  # @param path [String] path to the resource, relative to the base
64
108
  # @param data [Hash] data to be sent over the socket, JSON encoded
65
- # @return [Excon::Response] response object
109
+ # @return [Response] response object
66
110
  def post(path, data = nil)
67
- @connection.post(prepare_request_params(path, data))
111
+ request(:post, path, data)
68
112
  end
69
113
 
70
114
  # Issue PATCH requests to the service.
71
115
  #
72
116
  # @param path [String] path to the resource, relative to the base
73
117
  # @param data [Hash] data to be sent over the socket
74
- # @return [Excon::Response] response object
118
+ # @return [Response] response object
75
119
  def patch(path, data = nil)
76
- @connection.patch(prepare_request_params(path, data))
120
+ request(:patch, path, data)
77
121
  end
78
122
 
79
123
  # Issue DELETE requests to the service.
80
124
  #
81
125
  # @param path [String] path to the resource, relative to the base
82
- # @return [Excon::Response] response object
126
+ # @return [Response] response object
83
127
  def delete(path)
84
- @connection.delete(path: path, headers: @headers)
128
+ request(:delete, path)
129
+ end
130
+
131
+ # Clear the cached responses.
132
+ #
133
+ # If path is passed as a parameter, only one cache entry gets invalidated,
134
+ # else complete cache gets invalidated.
135
+ #
136
+ # Next GET request will repopulate the cache.
137
+ #
138
+ # @param path [String] path to invalidate
139
+ def reset(path = nil)
140
+ path.nil? ? @cache.clear : @cache.delete(path)
141
+ end
142
+
143
+ # Set authentication-related variables.
144
+ #
145
+ # Last parameter controls the kind of login connector will perform. If
146
+ # session_path is `nil`, basic authentication will be used, otherwise
147
+ # connector will use session-based authentication.
148
+ #
149
+ # Note that actual login is done lazily. If you need to check for
150
+ # credential validity, call #{login} method.
151
+ #
152
+ # @param username [String] API username
153
+ # @param password [String] API password
154
+ # @param auth_test_path [String] API path to test credential's validity
155
+ # @param session_path [String, nil] API session path
156
+ def set_auth_info(username, password, auth_test_path, session_path = nil)
157
+ @username = username
158
+ @password = password
159
+ @auth_test_path = auth_test_path
160
+ @session_path = session_path
161
+ end
162
+
163
+ # Authenticate against the service.
164
+ #
165
+ # Calling this method will try to authenticate against API using
166
+ # credentials provided by #{set_auth_info} call.
167
+ # If authentication fails, # {AuthError} will be raised.
168
+ #
169
+ # @raise [AuthError] if credentials are invalid
170
+ def login
171
+ @session_path ? session_login : basic_login
172
+ end
173
+
174
+ # Sign out of the service.
175
+ def logout
176
+ # We bypass request here because we do not want any retries on 401
177
+ # when doing logout.
178
+ if @session_oid
179
+ params = prepare_request_params(:delete, @session_oid)
180
+ @connection.request(params)
181
+ @session_oid = nil
182
+ end
183
+ remove_headers([BASIC_AUTH_HEADER, TOKEN_AUTH_HEADER])
85
184
  end
86
185
 
87
186
  private
88
187
 
89
- def prepare_request_params(path, data)
90
- params = { path: path }
188
+ def do_request(method, path, data)
189
+ params = prepare_request_params(method, path, data)
190
+ r = @connection.request(params)
191
+ if r.status == 401
192
+ login
193
+ r = @connection.request(params)
194
+ end
195
+ Response.new(r.status, downcase_headers(r.data[:headers]), r.data[:body])
196
+ end
197
+
198
+ def downcase_headers(headers)
199
+ headers.each_with_object({}) { |(k, v), obj| obj[k.downcase] = v }
200
+ end
201
+
202
+ def prepare_request_params(method, path, data = nil)
203
+ params = { method: method, path: path }
91
204
  if data
92
205
  params[:body] = data.to_json
93
206
  params[:headers] = @headers.merge("Content-Type" => "application/json")
@@ -96,5 +209,52 @@ module RedfishClient
96
209
  end
97
210
  params
98
211
  end
212
+
213
+ def session_login
214
+ # We bypass request here because we do not want any retries on 401
215
+ # when doing login.
216
+ params = prepare_request_params(:post, @session_path,
217
+ "UserName" => @username,
218
+ "Password" => @password)
219
+ r = @connection.request(params)
220
+ raise_invalid_auth_error unless r.status == 201
221
+
222
+ body = JSON.parse(r.data[:body])
223
+ headers = r.data[:headers]
224
+
225
+ add_headers(TOKEN_AUTH_HEADER => headers[TOKEN_AUTH_HEADER])
226
+ save_session_oid!(body, headers)
227
+ end
228
+
229
+ def save_session_oid!(body, headers)
230
+ @session_oid = body["@odata.id"] if body.key?("@odata.id")
231
+ return if @session_oid
232
+
233
+ return unless headers.key?(LOCATION_HEADER)
234
+
235
+ location = URI.parse(headers[LOCATION_HEADER])
236
+ @session_oid = [location.path, location.query].compact.join("?")
237
+ end
238
+
239
+ def basic_login
240
+ payload = Base64.encode64("#{@username}:#{@password}").strip
241
+ add_headers(BASIC_AUTH_HEADER => "Basic #{payload}")
242
+ return if auth_valid?
243
+
244
+ remove_headers([BASIC_AUTH_HEADER])
245
+ raise_invalid_auth_error
246
+ end
247
+
248
+ def raise_invalid_auth_error
249
+ raise AuthError, "Invalid credentials"
250
+ end
251
+
252
+ def auth_valid?
253
+ # We bypass request here because we do not want any retries on 401
254
+ # when checking authentication headers.
255
+ reset(@auth_test_path) # Do not want to see cached response
256
+ params = prepare_request_params(:get, @auth_test_path)
257
+ @connection.request(params).status == 200
258
+ end
99
259
  end
100
260
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RedfishClient
4
+ # NilHash imitates the built-in Hash class without storing anything
5
+ # permanently.
6
+ #
7
+ # Main use of this class is as a non-caching connector backend.
8
+ class NilHash
9
+ # Access hash member.
10
+ #
11
+ # Since this implementation does not store any data, return value is
12
+ # always nil.
13
+ #
14
+ # @param _key not used
15
+ # @return [nil]
16
+ def [](_key)
17
+ nil
18
+ end
19
+
20
+ # Set hash member.
21
+ #
22
+ # This is just a pass-through method, since it always simply returns the
23
+ # value without actually storing it.
24
+ #
25
+ # @param _key not used
26
+ # @param value [Object] any value
27
+ # @return [Object] value
28
+ def []=(_key, value)
29
+ value
30
+ end
31
+
32
+ # Clear the contents of the cache.
33
+ #
34
+ # Since hash is not storing anything, this is a no-op.
35
+ def clear; end
36
+
37
+ # Delete entry from hash.
38
+ #
39
+ # Since hash is not storing anything, this is a no-op.
40
+ #
41
+ # @param _key not used
42
+ def delete(_key) end
43
+ end
44
+ end
@@ -17,8 +17,8 @@ module RedfishClient
17
17
  #
18
18
  # In order to reduce the amount of requests being sent to the service,
19
19
  # resource can also utilise caching connector. If we would like to get
20
- # fresh values from the service, {#reset} call will flush the cache,
21
- # causing next access to retrieve fresh data.
20
+ # fresh values from the service, {#refresh} call will flush the cache and
21
+ # retrieve fresh data from the remote.
22
22
  class Resource
23
23
  # NoODataId error is raised when operation would need OpenData id of the
24
24
  # resource to accomplish the task a hand.
@@ -28,9 +28,20 @@ module RedfishClient
28
28
  # resource.
29
29
  class NoResource < StandardError; end
30
30
 
31
+ # Timeout error is raised if the async request is not handled in due time.
32
+ class Timeout < StandardError; end
33
+
31
34
  # Headers, returned from the service when resource has been constructed.
35
+ #
36
+ # @return [Hash] resource headers
32
37
  attr_reader :headers
33
38
 
39
+ # Raw data that has been used to construct resource by either fetching it
40
+ # from the remote API or by being passed-in as a parameter to constructor.
41
+ #
42
+ # @return [Hash] resource raw data
43
+ attr_reader :raw
44
+
34
45
  # Create new resource.
35
46
  #
36
47
  # Resource can be created either by passing in OpenData identifier or
@@ -41,16 +52,36 @@ module RedfishClient
41
52
  # @param connector [RedfishClient::Connector] connector that will be used
42
53
  # to fetch the resources
43
54
  # @param oid [String] OpenData id of the resource
44
- # @param content [Hash] content to populate resource with
55
+ # @param raw [Hash] raw content to populate resource with
45
56
  # @raise [NoResource] resource cannot be retrieved from the service
46
- def initialize(connector, oid: nil, content: nil)
57
+ def initialize(connector, oid: nil, raw: nil)
47
58
  @connector = connector
48
-
49
59
  if oid
50
60
  initialize_from_service(oid)
51
61
  else
52
- @content = content
62
+ @raw = raw
63
+ end
64
+ end
65
+
66
+ # Wait for the potentially async operation to terminate
67
+ #
68
+ # Note that this can be safely called on response from non-async
69
+ # operations where the function will return immediately and without making
70
+ # any additional requests to the service.
71
+ #
72
+ # @param response [RedfishClient::Response] response
73
+ # @param retries [Integer] number of retries
74
+ # @param delay [Integer] number of seconds between retries
75
+ # @return [RedfishClient::Response] final response
76
+ # @raise [Timeout] if the operation did not terminate in time
77
+ def wait(response, retries: 10, delay: 1)
78
+ retries.times do |_i|
79
+ return response if response.done?
80
+
81
+ sleep(delay)
82
+ response = get(path: response.monitor)
53
83
  end
84
+ raise Timeout, "Async operation did not terminate in allotted time"
54
85
  end
55
86
 
56
87
  # Access resource content.
@@ -61,7 +92,7 @@ module RedfishClient
61
92
  # @param attr [String] key for accessing data
62
93
  # @return associated value or `nil` if attr is missing
63
94
  def [](attr)
64
- build_resource(@content[attr])
95
+ build_resource(raw[attr])
65
96
  end
66
97
 
67
98
  # Safely access nested resource content.
@@ -82,7 +113,7 @@ module RedfishClient
82
113
  # @param name [String, Symbol] key name to test
83
114
  # @return [Boolean] inclusion test result
84
115
  def key?(name)
85
- @content.key?(name.to_s)
116
+ raw.key?(name.to_s)
86
117
  end
87
118
 
88
119
  # Convenience access for resource data.
@@ -96,25 +127,52 @@ module RedfishClient
96
127
  key?(symbol.to_s) || super
97
128
  end
98
129
 
99
- # Clear the cached sub-resources.
130
+ # Pretty-print the wrapped content.
100
131
  #
101
- # This method is a no-op if connector in use does not support caching.
102
- def reset
103
- @connector.reset if @connector.respond_to?(:reset)
132
+ # @return [String] JSON-serialized raw data
133
+ def to_s
134
+ JSON.pretty_generate(raw)
104
135
  end
105
136
 
106
- # Access raw JSON data that resource wraps.
137
+ # Issue a requests to the selected endpoint.
138
+ #
139
+ # By default, request will be sent to the path, stored in `@odata.id`
140
+ # field. Source field can be changed by specifying the `field` parameter
141
+ # when calling this function. Specifying the `path` argument will bypass
142
+ # the field lookup altogether and issue a request directly to the selected
143
+ # path.
107
144
  #
108
- # @return [Hash] wrapped data
109
- def raw
110
- @content
145
+ # If the resource has no lookup field, {NoODataId} error will be raised,
146
+ # since posting to non-networked resources makes no sense and probably
147
+ # indicates bug in library consumer.
148
+ #
149
+ # @param method [Symbol] HTTP method (:get, :post, :patch or :delete)
150
+ # @param field [String, Symbol] path lookup field
151
+ # @param path [String] path to post to
152
+ # @return [RedfishClient::Response] response
153
+ # @raise [NoODataId] resource has no OpenData id
154
+ def request(method, field, path, payload = nil)
155
+ @connector.request(method, get_path(field, path), payload)
111
156
  end
112
157
 
113
- # Pretty-print the wrapped content.
158
+ # Issue a GET requests to the selected endpoint.
114
159
  #
115
- # @return [String] JSON-serialized raw data
116
- def to_s
117
- JSON.pretty_generate(@content)
160
+ # By default, GET request will be sent to the path, stored in `@odata.id`
161
+ # field. Source field can be changed by specifying the `field` parameter
162
+ # when calling this function. Specifying the `path` argument will bypass
163
+ # the field lookup altogether and issue a GET request directly to the
164
+ # selected path.
165
+ #
166
+ # If the resource has no lookup field, {NoODataId} error will be raised,
167
+ # since posting to non-networked resources makes no sense and probably
168
+ # indicates bug in library consumer.
169
+ #
170
+ # @param field [String, Symbol] path lookup field
171
+ # @param path [String] path to post to
172
+ # @return [RedfishClient::Response] response
173
+ # @raise [NoODataId] resource has no OpenData id
174
+ def get(field: "@odata.id", path: nil)
175
+ request(:get, field, path)
118
176
  end
119
177
 
120
178
  # Issue a POST requests to the selected endpoint.
@@ -135,10 +193,10 @@ module RedfishClient
135
193
  # @param field [String, Symbol] path lookup field
136
194
  # @param path [String] path to post to
137
195
  # @param payload [Hash<String, >] data to send
138
- # @return [Excon::Response] response
196
+ # @return [RedfishClient::Response] response
139
197
  # @raise [NoODataId] resource has no OpenData id
140
198
  def post(field: "@odata.id", path: nil, payload: nil)
141
- @connector.post(get_path(field, path), payload)
199
+ request(:post, field, path, payload)
142
200
  end
143
201
 
144
202
  # Issue a PATCH requests to the selected endpoint.
@@ -149,10 +207,10 @@ module RedfishClient
149
207
  # @param field [String, Symbol] path lookup field
150
208
  # @param path [String] path to patch
151
209
  # @param payload [Hash<String, >] data to send
152
- # @return [Excon::Response] response
210
+ # @return [RedfishClient::Response] response
153
211
  # @raise [NoODataId] resource has no OpenData id
154
212
  def patch(field: "@odata.id", path: nil, payload: nil)
155
- @connector.patch(get_path(field, path), payload)
213
+ request(:patch, field, path, payload)
156
214
  end
157
215
 
158
216
  # Issue a DELETE requests to the endpoint of the resource.
@@ -161,26 +219,52 @@ module RedfishClient
161
219
  # raised, since deleting non-networked resources makes no sense and
162
220
  # probably indicates bug in library consumer.
163
221
  #
164
- # @return [Excon::Response] response
222
+ # @return [RedfishClient::Response] response
165
223
  # @raise [NoODataId] resource has no OpenData id
166
- def delete
167
- @connector.delete(get_path("@odata.id", nil))
224
+ def delete(field: "@odata.id", path: nil, payload: nil)
225
+ request(:delete, field, path, payload)
226
+ end
227
+
228
+ # Refresh resource content from the API
229
+ #
230
+ # Caling this method will ensure that the resource data is in sync with
231
+ # the Redfis API, invalidating any caches as necessary.
232
+ def refresh
233
+ return unless self["@odata.id"]
234
+
235
+ # TODO(@tadeboro): raise more sensible exception if resource cannot be
236
+ # refreshed.
237
+ @connector.reset(self["@odata.id"])
238
+ initialize_from_service(self["@odata.id"])
168
239
  end
169
240
 
170
241
  private
171
242
 
172
243
  def initialize_from_service(oid)
173
- resp = @connector.get(oid)
174
- raise NoResource unless resp.status == 200
244
+ url, fragment = oid.split("#", 2)
245
+ resp = wait(get(path: url))
246
+ raise NoResource unless [200, 201].include?(resp.status)
247
+
248
+ @raw = get_fragment(JSON.parse(resp.body), fragment)
249
+ @raw["@odata.id"] = oid
250
+ @headers = resp.headers
251
+ end
252
+
253
+ def get_fragment(data, fragment)
254
+ # data, /my/0/part -> data["my"][0]["part"]
255
+ parse_fragment_string(fragment).reduce(data) do |acc, c|
256
+ acc[acc.is_a?(Array) ? c.to_i : c]
257
+ end
258
+ end
175
259
 
176
- @content = JSON.parse(resp.data[:body])
177
- @content["@odata.id"] = oid
178
- @headers = resp.data[:headers]
260
+ def parse_fragment_string(fragment)
261
+ # /my/0/part -> ["my", "0", "part"]
262
+ fragment ? fragment.split("/").reject { |i| i == "" } : []
179
263
  end
180
264
 
181
265
  def get_path(field, path)
182
266
  raise NoODataId if path.nil? && !key?(field)
183
- path || @content[field]
267
+ path || raw[field]
184
268
  end
185
269
 
186
270
  def build_resource(data)
@@ -197,7 +281,7 @@ module RedfishClient
197
281
  if data.key?("@odata.id")
198
282
  Resource.new(@connector, oid: data["@odata.id"])
199
283
  else
200
- Resource.new(@connector, content: data)
284
+ Resource.new(@connector, raw: data)
201
285
  end
202
286
  rescue NoResource
203
287
  nil
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module RedfishClient
6
+ # Response struct.
7
+ #
8
+ # This struct is returned from the methods that interact with the remote API.
9
+ class Response
10
+ attr_reader :status
11
+ attr_reader :headers
12
+ attr_reader :body
13
+
14
+ def initialize(status, headers, body)
15
+ @status = status
16
+ @headers = headers
17
+ @body = body
18
+ end
19
+
20
+ def done?
21
+ status != 202
22
+ end
23
+
24
+ def monitor
25
+ return nil if done?
26
+
27
+ uri = URI.parse(headers["location"])
28
+ [uri.path, uri.query].compact.join("?")
29
+ end
30
+
31
+ def to_h
32
+ { "status" => status, "headers" => headers, "body" => body }
33
+ end
34
+
35
+ def to_s
36
+ "Response[status=#{status}, headers=#{headers}, body='#{body}']"
37
+ end
38
+
39
+ def self.from_hash(data)
40
+ new(*data.values_at("status", "headers", "body"))
41
+ end
42
+ end
43
+ end
@@ -1,50 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "base64"
4
- require "json"
5
3
  require "server_sent_events"
6
4
 
5
+ require "redfish_client/event_listener"
7
6
  require "redfish_client/resource"
8
7
 
9
8
  module RedfishClient
10
9
  # Root resource represents toplevel entry point into Redfish service data.
11
10
  # Its main purpose is to provide authentication support for the API.
12
11
  class Root < Resource
13
- # AuthError is raised if the user session cannot be created.
14
- class AuthError < StandardError; end
15
-
16
- # Basic and token authentication headers.
17
- BASIC_AUTH_HEADER = "Authorization"
18
- TOKEN_AUTH_HEADER = "X-Auth-Token"
19
-
20
- # Authenticate against the service.
21
- #
22
- # Calling this method will try to create new session on the service using
23
- # provided credentials. If the session creation fails, basic
24
- # authentication will be attempted. If basic authentication fails,
25
- # {AuthError} will be raised.
26
- #
27
- # @param username [String] username
28
- # @param password [String] password
29
- # @raise [AuthError] if user session could not be created
30
- def login(username, password)
31
- # Since session auth is more secure, we try it first and use basic auth
32
- # only if session auth is not available.
33
- if session_login_available?
34
- session_login(username, password)
35
- else
36
- basic_login(username, password)
37
- end
38
- end
39
-
40
- # Sign out of the service.
41
- #
42
- # If the session could not be deleted, {AuthError} will be raised.
43
- def logout
44
- session_logout
45
- basic_logout
46
- end
47
-
48
12
  # Find Redfish service object by OData ID field.
49
13
  #
50
14
  # @param oid [String] Odata id of the resource
@@ -76,47 +40,38 @@ module RedfishClient
76
40
  EventListener.new(ServerSentEvents.create_client(address))
77
41
  end
78
42
 
79
- private
80
-
81
- def session_login_available?
82
- !@content.dig("Links", "Sessions").nil?
83
- end
84
-
85
- def session_login(username, password)
86
- r = @connector.post(
87
- @content["Links"]["Sessions"]["@odata.id"],
88
- "UserName" => username, "Password" => password
43
+ # Authenticate against the service.
44
+ #
45
+ # Calling this method will select the appropriate method of authentication
46
+ # and try to login using provided credentials.
47
+ #
48
+ # @param username [String] username
49
+ # @param password [String] password
50
+ # @raise [RedfishClient::AuthenticatedConnector::AuthError] if user
51
+ # session could not be authenticated
52
+ def login(username, password)
53
+ @connector.set_auth_info(
54
+ username, password, auth_test_path, session_path
89
55
  )
90
- raise AuthError, "Invalid credentials" unless r.status == 201
91
-
92
- session_logout
93
-
94
- payload = r.data[:headers][TOKEN_AUTH_HEADER]
95
- @connector.add_headers(TOKEN_AUTH_HEADER => payload)
96
- @session = Resource.new(@connector, content: JSON.parse(r.data[:body]))
56
+ @connector.login
97
57
  end
98
58
 
99
- def session_logout
100
- return unless @session
101
- r = @session.delete
102
- raise AuthError unless r.status == 204
103
- @session = nil
104
- @connector.remove_headers([TOKEN_AUTH_HEADER])
59
+ # Sign out of the service.
60
+ def logout
61
+ @connector.logout
105
62
  end
106
63
 
107
- def auth_test_path
108
- @content.values.map { |v| v["@odata.id"] }.compact.first
109
- end
64
+ private
110
65
 
111
- def basic_login(username, password)
112
- payload = Base64.encode64("#{username}:#{password}").strip
113
- @connector.add_headers(BASIC_AUTH_HEADER => "Basic #{payload}")
114
- r = @connector.get(auth_test_path)
115
- raise AuthError, "Invalid credentials" unless r.status == 200
66
+ def session_path
67
+ # We access raw values here on purpose, since calling dig on resource
68
+ # instance would try to download the sessions collection, which would
69
+ # fail since we are not yet logged in.
70
+ raw.dig("Links", "Sessions", "@odata.id")
116
71
  end
117
72
 
118
- def basic_logout
119
- @connector.remove_headers([BASIC_AUTH_HEADER])
73
+ def auth_test_path
74
+ raw.values.find { |v| v["@odata.id"] }["@odata.id"]
120
75
  end
121
76
  end
122
77
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RedfishClient
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.3"
5
5
  end
@@ -27,15 +27,15 @@ Gem::Specification.new do |spec|
27
27
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
28
28
  spec.require_paths = ["lib"]
29
29
 
30
- spec.required_ruby_version = "~> 2.1"
30
+ spec.required_ruby_version = ">= 2.1"
31
31
 
32
- spec.add_runtime_dependency "excon", "~> 0.60"
32
+ spec.add_runtime_dependency "excon", "~> 0.71"
33
33
  spec.add_runtime_dependency "server_sent_events", "~> 0.1"
34
34
 
35
- spec.add_development_dependency "bundler", "~> 1.16"
36
35
  spec.add_development_dependency "rake", ">= 11.0"
37
36
  spec.add_development_dependency "rspec", ">= 3.7"
38
37
  spec.add_development_dependency "simplecov"
38
+ spec.add_development_dependency "webmock", "~> 3.4"
39
39
  spec.add_development_dependency "yard"
40
40
  spec.add_development_dependency "rubocop", "~> 0.54.0"
41
41
  spec.add_development_dependency "pry"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redfish_client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tadej Borovšak
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2018-09-25 00:00:00.000000000 Z
11
+ date: 2021-03-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: excon
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '0.60'
19
+ version: '0.71'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '0.60'
26
+ version: '0.71'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: server_sent_events
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -38,20 +38,6 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0.1'
41
- - !ruby/object:Gem::Dependency
42
- name: bundler
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - "~>"
46
- - !ruby/object:Gem::Version
47
- version: '1.16'
48
- type: :development
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - "~>"
53
- - !ruby/object:Gem::Version
54
- version: '1.16'
55
41
  - !ruby/object:Gem::Dependency
56
42
  name: rake
57
43
  requirement: !ruby/object:Gem::Requirement
@@ -94,6 +80,20 @@ dependencies:
94
80
  - - ">="
95
81
  - !ruby/object:Gem::Version
96
82
  version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: webmock
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '3.4'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '3.4'
97
97
  - !ruby/object:Gem::Dependency
98
98
  name: yard
99
99
  requirement: !ruby/object:Gem::Requirement
@@ -144,11 +144,11 @@ extensions: []
144
144
  extra_rdoc_files: []
145
145
  files:
146
146
  - ".codeclimate.yml"
147
+ - ".github/workflows/ci.yml"
147
148
  - ".gitignore"
148
149
  - ".rspec"
149
150
  - ".rubocop.yml"
150
151
  - ".simplecov"
151
- - ".travis.yml"
152
152
  - ".yardopts"
153
153
  - Gemfile
154
154
  - README.md
@@ -156,10 +156,11 @@ files:
156
156
  - bin/console
157
157
  - bin/setup
158
158
  - lib/redfish_client.rb
159
- - lib/redfish_client/caching_connector.rb
160
159
  - lib/redfish_client/connector.rb
161
160
  - lib/redfish_client/event_listener.rb
161
+ - lib/redfish_client/nil_hash.rb
162
162
  - lib/redfish_client/resource.rb
163
+ - lib/redfish_client/response.rb
163
164
  - lib/redfish_client/root.rb
164
165
  - lib/redfish_client/version.rb
165
166
  - redfish_client.gemspec
@@ -174,7 +175,7 @@ require_paths:
174
175
  - lib
175
176
  required_ruby_version: !ruby/object:Gem::Requirement
176
177
  requirements:
177
- - - "~>"
178
+ - - ">="
178
179
  - !ruby/object:Gem::Version
179
180
  version: '2.1'
180
181
  required_rubygems_version: !ruby/object:Gem::Requirement
@@ -183,8 +184,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
183
184
  - !ruby/object:Gem::Version
184
185
  version: '0'
185
186
  requirements: []
186
- rubyforge_project:
187
- rubygems_version: 2.7.7
187
+ rubygems_version: 3.2.3
188
188
  signing_key:
189
189
  specification_version: 4
190
190
  summary: Simple Redfish client library
data/.travis.yml DELETED
@@ -1,32 +0,0 @@
1
- sudo: false
2
- language: ruby
3
- cache: bundler
4
-
5
- rvm:
6
- - 2.4.3
7
-
8
- env:
9
- global:
10
- - CC_TEST_REPORTER_ID=64aae57d1a096aebb16347cbc64d418352f3ec38ebbe4d002bc84c2ceda837b3
11
-
12
- before_script:
13
- - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
14
- - chmod +x ./cc-test-reporter
15
- - ./cc-test-reporter before-build
16
-
17
- after_script:
18
- - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT
19
-
20
- branches:
21
- only:
22
- - master
23
- - /^\d+\.\d+(\.\d+)?(-\S*)?$/
24
-
25
- deploy:
26
- provider: rubygems
27
- api_key:
28
- secure: NdOd7EA9XQZNbnkzLFNXeJcOoaTi93uuwzjADS+fxFJ2HYGJs/T+gi98f742Oj4j187lBQjwICkdduGQjVn3BA/8YZD7sOkdywDSELupTjJmugf/hks6MiKJ0fex5GmHwspjZ7P+9iLPXGcaYWbGamWlSDL+Z/79s4bi8b5sBVwJ01YnIJvFn32/I5vPFwbwUzvH0n3HAjYHZ/kpZ122Zp/DhSpGam2azWF0Tynu7RNMsQrGamAH0mC1DzAscSD6VTyMcSMkD6XUr3GX7w7yvVO0qo7e6EcjrsJKMRC8/s2C/SWQ4pRDAldhQpd4C7/QSaHE0mmQcEYi2qsBmrbFb7cAfRmS5BKn8LSv7YPVH0VFl3fwYAycwqA068TZ68t99YPw7uVcJpDOWBODCQWA1wgV1tXHop7CJOcQW4MoXzyaRbbpF+6lyxXEgTz8Me8zEucMsHWzS9dI1E5x/bWl3TgEM9n+/G6BAcBj/mZv/dxC6H7kWp0ubKw0bRsdET/YmG53vxISaFbnLxNhsHzeE2mFeF+JL7a1amlC4aTiSBnxDgRzDdnoo42Hu8SUg/mp0xLjyoTME1w1zaB+GvmWCdrpkLQL2kNDgQUvBfOp+Zqfb37tKgc15zb7v2sru5acixkSG9UVElleDW8K4R1QcqXwLxCYccCi/ExVR1zSvSM=
29
- gem: redfish_client
30
- on:
31
- tags: true
32
- repo: xlab-si/redfish-client-ruby
@@ -1,38 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "redfish_client/connector"
4
-
5
- module RedfishClient
6
- # Variant of {RedfishClient::Connector} that caches GET responses.
7
- class CachingConnector < Connector
8
- # Create new caching connector.
9
- #
10
- # @param url [String] base url of the Redfish service
11
- # @param verify [Boolean] verify SSL certificate of the service
12
- def initialize(url, verify = true)
13
- super
14
- @cache = {}
15
- end
16
-
17
- # Issue GET request to service.
18
- #
19
- # Request is only issued if there is no cache entry for the existing path.
20
- #
21
- # @param path [String] path to the resource, relative to the base url
22
- # @return [Excon::Response] response object
23
- def get(path)
24
- @cache[path] ||= super
25
- end
26
-
27
- # Clear the cached responses.
28
- #
29
- # Next GET request will repopulate the cache.
30
- def reset(path: nil)
31
- if path.nil?
32
- @cache = {}
33
- else
34
- @cache.delete(path)
35
- end
36
- end
37
- end
38
- end