redfish_client 0.4.0 → 0.5.3

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: 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