redfish_client 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA256:
3
- metadata.gz: 6b0c14c35b85c191eac60fe2c468d59aee5495bd2fcc19c8fa70158eb10c934b
4
- data.tar.gz: e5b0d1bea6bc9c3912dc01724fade05f91a2bc90a9622cbbd0050aac66655e63
2
+ SHA1:
3
+ metadata.gz: 845f7a73083c73f8fe2ce79f8968332dd605aa87
4
+ data.tar.gz: 5e1e8056e6618c11428e0907050ef874242b78d3
5
5
  SHA512:
6
- metadata.gz: d8380f1294e5014e6b10cf9574381ed6a0a48d7ed6de9170020157311451902ad033527eaabe0ccc0f746dbfd1d603284f5293bfd52f318ad03685f6ca711c64
7
- data.tar.gz: 1f16c3bbe5132f62c317cc8d8afbada42f4263fc01bc895203fd9e9e3df0612bd4909d04366dcbb1626c2d2c4e9bb8dede4127483d4f17a930e0cd8331b1ea2b
6
+ metadata.gz: f2957a2b7baf789ef346b22e74e7cddb0894704eb30ae225ac4cd17cf95adf4e7f93d619859d2b3fb41c240bf2f34f1ffe09e2c3bc33110f11129d2664e19ca4
7
+ data.tar.gz: b3d582a79ce5a8bfe435d5d08d63977414c426a00b68cd1ac02555297c0000480a2dc6bdb1c6027e17585858304633f031d46e31a52f04f9f4d4ab2b346cc7b9
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/.travis.yml CHANGED
@@ -1,4 +1,3 @@
1
- sudo: false
2
1
  language: ruby
3
2
  cache: bundler
4
3
 
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,38 @@ 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
+
22
33
  # Create new connector.
23
34
  #
35
+ # By default, connector performs no caching. If caching is desired,
36
+ # Hash should be used as a cache implementation.
37
+ #
38
+ # It is also possible to pass in custom caching class. Instances of that
39
+ # class should respond to the following four methods:
40
+ #
41
+ # 1. `[](key)` - Used to access cached content and should return
42
+ # `nil` if the key has no associated value.
43
+ # 2. `[]=(key, value)` - Cache `value` under the `key`
44
+ # 3. `clear` - Clear the complete cache.
45
+ # 4. `delete(key)` - Invalidate cache entry associated with `key`.
46
+ #
24
47
  # @param url [String] base url of the Redfish service
25
48
  # @param verify [Boolean] verify SSL certificate of the service
26
- def initialize(url, verify = true)
49
+ # @param cache [Object] cache backend
50
+ def initialize(url, verify: true, cache: nil)
27
51
  @url = url
28
52
  @headers = DEFAULT_HEADERS.dup
29
53
  middlewares = Excon.defaults[:middlewares] +
@@ -31,6 +55,7 @@ module RedfishClient
31
55
  @connection = Excon.new(@url,
32
56
  ssl_verify_peer: verify,
33
57
  middlewares: middlewares)
58
+ @cache = cache || NilHash.new
34
59
  end
35
60
 
36
61
  # Add HTTP headers to the requests made by the connector.
@@ -50,44 +75,125 @@ module RedfishClient
50
75
  headers.each { |h| @headers.delete(h) }
51
76
  end
52
77
 
78
+ # Issue requests to the service.
79
+ #
80
+ # @param mathod [Symbol] HTTP method (:get, :post, :patch or :delete)
81
+ # @param path [String] path to the resource, relative to the base
82
+ # @param data [Hash] data to be sent over the socket
83
+ # @return [Response] response object
84
+ def request(method, path, data = nil)
85
+ params = prepare_request_params(method, path, data)
86
+ r = @connection.request(params)
87
+ if r.status == 401
88
+ login
89
+ r = @connection.request(params)
90
+ end
91
+ Response.new(r.status, downcase_headers(r.data[:headers]), r.data[:body])
92
+ end
93
+
53
94
  # Issue GET request to service.
54
95
  #
96
+ # This method will first try to return cached response if available. If
97
+ # cache does not contain entry for this request, data will be fetched from
98
+ # remote and then cached, but only if the response has an OK (200) status.
99
+ #
55
100
  # @param path [String] path to the resource, relative to the base url
56
- # @return [Excon::Response] response object
101
+ # @return [Response] response object
57
102
  def get(path)
58
- @connection.get(path: path, headers: @headers)
103
+ return @cache[path] if @cache[path]
104
+
105
+ request(:get, path).tap { |r| @cache[path] = r if r.status == 200 }
59
106
  end
60
107
 
61
108
  # Issue POST requests to the service.
62
109
  #
63
110
  # @param path [String] path to the resource, relative to the base
64
111
  # @param data [Hash] data to be sent over the socket, JSON encoded
65
- # @return [Excon::Response] response object
112
+ # @return [Response] response object
66
113
  def post(path, data = nil)
67
- @connection.post(prepare_request_params(path, data))
114
+ request(:post, path, data)
68
115
  end
69
116
 
70
117
  # Issue PATCH requests to the service.
71
118
  #
72
119
  # @param path [String] path to the resource, relative to the base
73
120
  # @param data [Hash] data to be sent over the socket
74
- # @return [Excon::Response] response object
121
+ # @return [Response] response object
75
122
  def patch(path, data = nil)
76
- @connection.patch(prepare_request_params(path, data))
123
+ request(:patch, path, data)
77
124
  end
78
125
 
79
126
  # Issue DELETE requests to the service.
80
127
  #
81
128
  # @param path [String] path to the resource, relative to the base
82
- # @return [Excon::Response] response object
129
+ # @return [Response] response object
83
130
  def delete(path)
84
- @connection.delete(path: path, headers: @headers)
131
+ request(:delete, path)
132
+ end
133
+
134
+ # Clear the cached responses.
135
+ #
136
+ # If path is passed as a parameter, only one cache entry gets invalidated,
137
+ # else complete cache gets invalidated.
138
+ #
139
+ # Next GET request will repopulate the cache.
140
+ #
141
+ # @param path [String] path to invalidate
142
+ def reset(path = nil)
143
+ path.nil? ? @cache.clear : @cache.delete(path)
144
+ end
145
+
146
+ # Set authentication-related variables.
147
+ #
148
+ # Last parameter controls the kind of login connector will perform. If
149
+ # session_path is `nil`, basic authentication will be used, otherwise
150
+ # connector will use session-based authentication.
151
+ #
152
+ # Note that actual login is done lazily. If you need to check for
153
+ # credential validity, call #{login} method.
154
+ #
155
+ # @param username [String] API username
156
+ # @param password [String] API password
157
+ # @param auth_test_path [String] API path to test credential's validity
158
+ # @param session_path [String, nil] API session path
159
+ def set_auth_info(username, password, auth_test_path, session_path = nil)
160
+ @username = username
161
+ @password = password
162
+ @auth_test_path = auth_test_path
163
+ @session_path = session_path
164
+ end
165
+
166
+ # Authenticate against the service.
167
+ #
168
+ # Calling this method will try to authenticate against API using
169
+ # credentials provided by #{set_auth_info} call.
170
+ # If authentication fails, # {AuthError} will be raised.
171
+ #
172
+ # @raise [AuthError] if credentials are invalid
173
+ def login
174
+ @session_path ? session_login : basic_login
175
+ end
176
+
177
+ # Sign out of the service.
178
+ def logout
179
+ # We bypass request here because we do not want any retries on 401
180
+ # when doing logout.
181
+ if @session_oid
182
+ params = prepare_request_params(:delete, @session_oid)
183
+ @connection.request(params)
184
+ @session_oid = nil
185
+ end
186
+ remove_headers([BASIC_AUTH_HEADER, TOKEN_AUTH_HEADER])
85
187
  end
86
188
 
87
189
  private
88
190
 
89
- def prepare_request_params(path, data)
90
- params = { path: path }
191
+ def downcase_headers(headers)
192
+ headers.each_with_object({}) { |(k, v), obj| obj[k.downcase] = v }
193
+ end
194
+
195
+ def prepare_request_params(method, path, data = nil)
196
+ params = { method: method, path: path }
91
197
  if data
92
198
  params[:body] = data.to_json
93
199
  params[:headers] = @headers.merge("Content-Type" => "application/json")
@@ -96,5 +202,40 @@ module RedfishClient
96
202
  end
97
203
  params
98
204
  end
205
+
206
+ def session_login
207
+ # We bypass request here because we do not want any retries on 401
208
+ # when doing login.
209
+ params = prepare_request_params(:post, @session_path,
210
+ "UserName" => @username,
211
+ "Password" => @password)
212
+ r = @connection.request(params)
213
+ raise_invalid_auth_error unless r.status == 201
214
+
215
+ token = r.data[:headers][TOKEN_AUTH_HEADER]
216
+ add_headers(TOKEN_AUTH_HEADER => token)
217
+ @session_oid = JSON.parse(r.data[:body])["@odata.id"]
218
+ end
219
+
220
+ def basic_login
221
+ payload = Base64.encode64("#{@username}:#{@password}").strip
222
+ add_headers(BASIC_AUTH_HEADER => "Basic #{payload}")
223
+ return if auth_valid?
224
+
225
+ remove_headers([BASIC_AUTH_HEADER])
226
+ raise_invalid_auth_error
227
+ end
228
+
229
+ def raise_invalid_auth_error
230
+ raise AuthError, "Invalid credentials"
231
+ end
232
+
233
+ def auth_valid?
234
+ # We bypass request here because we do not want any retries on 401
235
+ # when checking authentication headers.
236
+ reset(@auth_test_path) # Do not want to see cached response
237
+ params = prepare_request_params(:get, @auth_test_path)
238
+ @connection.request(params).status == 200
239
+ end
99
240
  end
100
241
  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,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RedfishClient
4
+ # Response struct.
5
+ #
6
+ # This struct is returned from the methods that interact with the remote API.
7
+ class Response
8
+ attr_reader :status
9
+ attr_reader :headers
10
+ attr_reader :body
11
+
12
+ def initialize(status, headers, body)
13
+ @status = status
14
+ @headers = headers
15
+ @body = body
16
+ end
17
+
18
+ def done?
19
+ status != 202
20
+ end
21
+
22
+ def monitor
23
+ done? ? nil : headers["location"]
24
+ end
25
+
26
+ def to_h
27
+ { "status" => status, "headers" => headers, "body" => body }
28
+ end
29
+
30
+ def self.from_hash(data)
31
+ new(*data.values_at("status", "headers", "body"))
32
+ end
33
+ end
34
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "base64"
4
- require "json"
5
3
  require "server_sent_events"
6
4
 
7
5
  require "redfish_client/event_listener"
@@ -11,41 +9,6 @@ module RedfishClient
11
9
  # Root resource represents toplevel entry point into Redfish service data.
12
10
  # Its main purpose is to provide authentication support for the API.
13
11
  class Root < Resource
14
- # AuthError is raised if the user session cannot be created.
15
- class AuthError < StandardError; end
16
-
17
- # Basic and token authentication headers.
18
- BASIC_AUTH_HEADER = "Authorization"
19
- TOKEN_AUTH_HEADER = "X-Auth-Token"
20
-
21
- # Authenticate against the service.
22
- #
23
- # Calling this method will try to create new session on the service using
24
- # provided credentials. If the session creation fails, basic
25
- # authentication will be attempted. If basic authentication fails,
26
- # {AuthError} will be raised.
27
- #
28
- # @param username [String] username
29
- # @param password [String] password
30
- # @raise [AuthError] if user session could not be created
31
- def login(username, password)
32
- # Since session auth is more secure, we try it first and use basic auth
33
- # only if session auth is not available.
34
- if session_login_available?
35
- session_login(username, password)
36
- else
37
- basic_login(username, password)
38
- end
39
- end
40
-
41
- # Sign out of the service.
42
- #
43
- # If the session could not be deleted, {AuthError} will be raised.
44
- def logout
45
- session_logout
46
- basic_logout
47
- end
48
-
49
12
  # Find Redfish service object by OData ID field.
50
13
  #
51
14
  # @param oid [String] Odata id of the resource
@@ -77,47 +40,38 @@ module RedfishClient
77
40
  EventListener.new(ServerSentEvents.create_client(address))
78
41
  end
79
42
 
80
- private
81
-
82
- def session_login_available?
83
- !@content.dig("Links", "Sessions").nil?
84
- end
85
-
86
- def session_login(username, password)
87
- r = @connector.post(
88
- @content["Links"]["Sessions"]["@odata.id"],
89
- "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
90
55
  )
91
- raise AuthError, "Invalid credentials" unless r.status == 201
92
-
93
- session_logout
94
-
95
- payload = r.data[:headers][TOKEN_AUTH_HEADER]
96
- @connector.add_headers(TOKEN_AUTH_HEADER => payload)
97
- @session = Resource.new(@connector, content: JSON.parse(r.data[:body]))
56
+ @connector.login
98
57
  end
99
58
 
100
- def session_logout
101
- return unless @session
102
- r = @session.delete
103
- raise AuthError unless r.status == 204
104
- @session = nil
105
- @connector.remove_headers([TOKEN_AUTH_HEADER])
59
+ # Sign out of the service.
60
+ def logout
61
+ @connector.logout
106
62
  end
107
63
 
108
- def auth_test_path
109
- @content.values.map { |v| v["@odata.id"] }.compact.first
110
- end
64
+ private
111
65
 
112
- def basic_login(username, password)
113
- payload = Base64.encode64("#{username}:#{password}").strip
114
- @connector.add_headers(BASIC_AUTH_HEADER => "Basic #{payload}")
115
- r = @connector.get(auth_test_path)
116
- 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")
117
71
  end
118
72
 
119
- def basic_logout
120
- @connector.remove_headers([BASIC_AUTH_HEADER])
73
+ def auth_test_path
74
+ raw.values.find { |v| v["@odata.id"] }["@odata.id"]
121
75
  end
122
76
  end
123
77
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RedfishClient
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -32,10 +32,10 @@ Gem::Specification.new do |spec|
32
32
  spec.add_runtime_dependency "excon", "~> 0.60"
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.1
4
+ version: 0.5.0
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-10-18 00:00:00.000000000 Z
11
+ date: 2019-06-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: excon
@@ -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
@@ -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
@@ -184,7 +185,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
184
185
  version: '0'
185
186
  requirements: []
186
187
  rubyforge_project:
187
- rubygems_version: 2.7.7
188
+ rubygems_version: 2.6.14
188
189
  signing_key:
189
190
  specification_version: 4
190
191
  summary: Simple Redfish client library
@@ -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