redfish_client 0.3.0 → 0.5.2

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: 45295e03b5e8ec0dda4fd0d7401f95516c92d48eba415337d25d3d2ef210a869
4
- data.tar.gz: bfe94afbb669fdd6bb4b407b8ddacfaf17695b49f4bdd2d83645290f97f7b5b6
3
+ metadata.gz: 89fe47fe3e6d6e82a1c1fbce3a33e101ec9c0dc70b9e4725205c1e987c1f4b38
4
+ data.tar.gz: 823ea29d9a34c9a460c6a65b385714d08fa50caaeb829dfb7f1747af78389b63
5
5
  SHA512:
6
- metadata.gz: c99e489c2328e9a1fb3ada71d3c62f0a3ce9d75e93bf78206e56490a4153d60a20601bc5d7fee351485ede848a6c640310f7df3b29c3c73c138ac4b466f62b3d
7
- data.tar.gz: 20b278901afbfaa467ec1bd72056f6fbf7420d6ab62f9352193023a46a9e44b7712a37ebb495db1016d6b77a8ca1a7eb729c06ab56c7e8061150e894b64a22f2
6
+ metadata.gz: 8699fd26e3c468d864132e5790ab6037d2ac6e767a38c96ee07a12c9edd29ba77aceb1937287f9bf877db0ccf23534d790bfe3aaa3ce1d87d63eb98e4f75d25d
7
+ data.tar.gz: 6538ec3de254d305b25fa076fb72d72866ec8162717c3cd1205f4c957d84b310a46b23490514e8b665acc121b6556e19b202261fc8f273d925c61ef99d5639e9
@@ -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
@@ -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,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,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module RedfishClient
7
+ # EventListener class can be used to stream events from Redfish service. It
8
+ # is a thin wrapper around SSE listener that does the dirty work of
9
+ # splitting each event into its EventRecords and reporting them as separate
10
+ # events.
11
+ class EventListener
12
+ # Create new EventListener instance.
13
+ #
14
+ # @param sse_client [ServerSentEvents::Client] SSE client
15
+ def initialize(sse_client)
16
+ @sse_client = sse_client
17
+ end
18
+
19
+ # Stream events from redfish service.
20
+ #
21
+ # Events that this method yields are actually EventRecords, extracted from
22
+ # the actual Redfish Event.
23
+ def listen
24
+ @sse_client.listen do |event|
25
+ split_event_into_records(event).each { |r| yield(r) }
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def split_event_into_records(event)
32
+ JSON.parse(event.data).fetch("Events", [])
33
+ end
34
+ end
35
+ 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,48 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "base64"
4
- require "json"
3
+ require "server_sent_events"
4
+
5
+ require "redfish_client/event_listener"
5
6
  require "redfish_client/resource"
6
7
 
7
8
  module RedfishClient
8
9
  # Root resource represents toplevel entry point into Redfish service data.
9
10
  # Its main purpose is to provide authentication support for the API.
10
11
  class Root < Resource
11
- # AuthError is raised if the user session cannot be created.
12
- class AuthError < StandardError; end
13
-
14
- # Basic and token authentication headers.
15
- BASIC_AUTH_HEADER = "Authorization"
16
- TOKEN_AUTH_HEADER = "X-Auth-Token"
17
-
18
- # Authenticate against the service.
19
- #
20
- # Calling this method will try to create new session on the service using
21
- # provided credentials. If the session creation fails, basic
22
- # authentication will be attempted. If basic authentication fails,
23
- # {AuthError} will be raised.
24
- #
25
- # @param username [String] username
26
- # @param password [String] password
27
- # @raise [AuthError] if user session could not be created
28
- def login(username, password)
29
- # Since session auth is more secure, we try it first and use basic auth
30
- # only if session auth is not available.
31
- if session_login_available?
32
- session_login(username, password)
33
- else
34
- basic_login(username, password)
35
- end
36
- end
37
-
38
- # Sign out of the service.
39
- #
40
- # If the session could not be deleted, {AuthError} will be raised.
41
- def logout
42
- session_logout
43
- basic_logout
44
- end
45
-
46
12
  # Find Redfish service object by OData ID field.
47
13
  #
48
14
  # @param oid [String] Odata id of the resource
@@ -62,47 +28,50 @@ module RedfishClient
62
28
  Resource.new(@connector, oid: oid)
63
29
  end
64
30
 
65
- private
31
+ # Return event listener.
32
+ #
33
+ # If the service does not support SSE, this function will return nil.
34
+ #
35
+ # @return [EventListener, nil] event listener
36
+ def event_listener
37
+ address = dig("EventService", "ServerSentEventUri")
38
+ return nil if address.nil?
66
39
 
67
- def session_login_available?
68
- !@content.dig("Links", "Sessions").nil?
40
+ EventListener.new(ServerSentEvents.create_client(address))
69
41
  end
70
42
 
71
- def session_login(username, password)
72
- r = @connector.post(
73
- @content["Links"]["Sessions"]["@odata.id"],
74
- "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
75
55
  )
76
- raise AuthError, "Invalid credentials" unless r.status == 201
77
-
78
- session_logout
79
-
80
- payload = r.data[:headers][TOKEN_AUTH_HEADER]
81
- @connector.add_headers(TOKEN_AUTH_HEADER => payload)
82
- @session = Resource.new(@connector, content: JSON.parse(r.data[:body]))
56
+ @connector.login
83
57
  end
84
58
 
85
- def session_logout
86
- return unless @session
87
- r = @session.delete
88
- raise AuthError unless r.status == 204
89
- @session = nil
90
- @connector.remove_headers([TOKEN_AUTH_HEADER])
59
+ # Sign out of the service.
60
+ def logout
61
+ @connector.logout
91
62
  end
92
63
 
93
- def auth_test_path
94
- @content.values.map { |v| v["@odata.id"] }.compact.first
95
- end
64
+ private
96
65
 
97
- def basic_login(username, password)
98
- payload = Base64.encode64("#{username}:#{password}").strip
99
- @connector.add_headers(BASIC_AUTH_HEADER => "Basic #{payload}")
100
- r = @connector.get(auth_test_path)
101
- 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")
102
71
  end
103
72
 
104
- def basic_logout
105
- @connector.remove_headers([BASIC_AUTH_HEADER])
73
+ def auth_test_path
74
+ raw.values.find { |v| v["@odata.id"] }["@odata.id"]
106
75
  end
107
76
  end
108
77
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RedfishClient
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.2"
5
5
  end
@@ -29,12 +29,13 @@ Gem::Specification.new do |spec|
29
29
 
30
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
+ spec.add_runtime_dependency "server_sent_events", "~> 0.1"
33
34
 
34
- spec.add_development_dependency "bundler", "~> 1.16"
35
35
  spec.add_development_dependency "rake", ">= 11.0"
36
36
  spec.add_development_dependency "rspec", ">= 3.7"
37
37
  spec.add_development_dependency "simplecov"
38
+ spec.add_development_dependency "webmock", "~> 3.4"
38
39
  spec.add_development_dependency "yard"
39
40
  spec.add_development_dependency "rubocop", "~> 0.54.0"
40
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.3.0
4
+ version: 0.5.2
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-12 00:00:00.000000000 Z
11
+ date: 2020-05-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: excon
@@ -16,28 +16,28 @@ 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
- name: bundler
28
+ name: server_sent_events
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '1.16'
34
- type: :development
33
+ version: '0.1'
34
+ type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '1.16'
40
+ version: '0.1'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: rake
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -80,6 +80,20 @@ dependencies:
80
80
  - - ">="
81
81
  - !ruby/object:Gem::Version
82
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'
83
97
  - !ruby/object:Gem::Dependency
84
98
  name: yard
85
99
  requirement: !ruby/object:Gem::Requirement
@@ -142,9 +156,11 @@ files:
142
156
  - bin/console
143
157
  - bin/setup
144
158
  - lib/redfish_client.rb
145
- - lib/redfish_client/caching_connector.rb
146
159
  - lib/redfish_client/connector.rb
160
+ - lib/redfish_client/event_listener.rb
161
+ - lib/redfish_client/nil_hash.rb
147
162
  - lib/redfish_client/resource.rb
163
+ - lib/redfish_client/response.rb
148
164
  - lib/redfish_client/root.rb
149
165
  - lib/redfish_client/version.rb
150
166
  - redfish_client.gemspec
@@ -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