redfish_client 0.6.0 → 0.6.1

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.
@@ -1,262 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "base64"
4
- require "excon"
5
- require "json"
6
-
7
- require "redfish_client/nil_hash"
8
- require "redfish_client/response"
9
-
10
- module RedfishClient
11
- # Connector serves as a low-level wrapper around HTTP calls that are used
12
- # to retrieve data from the service API. It abstracts away implementation
13
- # details such as sending the proper headers in request, which do not
14
- # change between resource fetches.
15
- #
16
- # Library users should treat this class as an implementation detail and
17
- # use higer-level {RedfishClient::Resource} instead.
18
- class Connector
19
- # AuthError is raised if the credentials are invalid.
20
- class AuthError < StandardError; end
21
-
22
- # Default headers, as required by Redfish spec
23
- # https://redfish.dmtf.org/schemas/DSP0266_1.4.0.html#request-headers
24
- DEFAULT_HEADERS = {
25
- "Accept" => "application/json",
26
- "OData-Version" => "4.0",
27
- }.freeze
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
-
34
- # Create new connector.
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
- #
48
- # @param url [String] base url of the Redfish service
49
- # @param verify [Boolean] verify SSL certificate of the service
50
- # @param use_session [Boolean] Use a session for authentication
51
- # @param cache [Object] cache backend
52
- def initialize(url, verify: true, cache: nil, use_session: true)
53
- @url = url
54
- @headers = DEFAULT_HEADERS.dup
55
- middlewares = Excon.defaults[:middlewares] +
56
- [Excon::Middleware::RedirectFollower]
57
- @connection = Excon.new(@url,
58
- ssl_verify_peer: verify,
59
- middlewares: middlewares)
60
- @cache = cache || NilHash.new
61
- @use_session = use_session
62
- end
63
-
64
- # Add HTTP headers to the requests made by the connector.
65
- #
66
- # @param headers [Hash<String, String>] headers to be added
67
- def add_headers(headers)
68
- @headers.merge!(headers)
69
- end
70
-
71
- # Remove HTTP headers from requests made by the connector.
72
- #
73
- # Headers that are not currently set are silently ignored and no error is
74
- # raised.
75
- #
76
- # @param headers [List<String>] headers to remove
77
- def remove_headers(headers)
78
- headers.each { |h| @headers.delete(h) }
79
- end
80
-
81
- # Issue requests to the service.
82
- #
83
- # @param mathod [Symbol] HTTP method (:get, :post, :patch or :delete)
84
- # @param path [String] path to the resource, relative to the base
85
- # @param data [Hash] data to be sent over the socket
86
- # @return [Response] response object
87
- def request(method, path, data = nil)
88
- return @cache[path] if method == :get && @cache[path]
89
-
90
- do_request(method, path, data).tap do |r|
91
- @cache[path] = r if method == :get && r.status == 200
92
- end
93
- end
94
-
95
- # Issue GET request to service.
96
- #
97
- # This method will first try to return cached response if available. If
98
- # cache does not contain entry for this request, data will be fetched from
99
- # remote and then cached, but only if the response has an OK (200) status.
100
- #
101
- # @param path [String] path to the resource, relative to the base url
102
- # @return [Response] response object
103
- def get(path)
104
- request(:get, path)
105
- end
106
-
107
- # Issue POST requests to the service.
108
- #
109
- # @param path [String] path to the resource, relative to the base
110
- # @param data [Hash] data to be sent over the socket, JSON encoded
111
- # @return [Response] response object
112
- def post(path, data = nil)
113
- request(:post, path, data)
114
- end
115
-
116
- # Issue PATCH requests to the service.
117
- #
118
- # @param path [String] path to the resource, relative to the base
119
- # @param data [Hash] data to be sent over the socket
120
- # @return [Response] response object
121
- def patch(path, data = nil)
122
- request(:patch, path, data)
123
- end
124
-
125
- # Issue DELETE requests to the service.
126
- #
127
- # @param path [String] path to the resource, relative to the base
128
- # @return [Response] response object
129
- def delete(path)
130
- request(:delete, path)
131
- end
132
-
133
- # Clear the cached responses.
134
- #
135
- # If path is passed as a parameter, only one cache entry gets invalidated,
136
- # else complete cache gets invalidated.
137
- #
138
- # Next GET request will repopulate the cache.
139
- #
140
- # @param path [String] path to invalidate
141
- def reset(path = nil)
142
- path.nil? ? @cache.clear : @cache.delete(path)
143
- end
144
-
145
- # Set authentication-related variables.
146
- #
147
- # Last parameter controls the kind of login connector will perform. If
148
- # session_path is `nil`, basic authentication will be used, otherwise
149
- # connector will use session-based authentication.
150
- #
151
- # Note that actual login is done lazily. If you need to check for
152
- # credential validity, call #{login} method.
153
- #
154
- # @param username [String] API username
155
- # @param password [String] API password
156
- # @param auth_test_path [String] API path to test credential's validity
157
- # @param session_path [String, nil] API session path
158
- def set_auth_info(username, password, auth_test_path, session_path = nil)
159
- @username = username
160
- @password = password
161
- @auth_test_path = auth_test_path
162
- @session_path = @use_session ? session_path : nil
163
- end
164
-
165
- # Authenticate against the service.
166
- #
167
- # Calling this method will try to authenticate against API using
168
- # credentials provided by #{set_auth_info} call.
169
- # If authentication fails, # {AuthError} will be raised.
170
- #
171
- # @raise [AuthError] if credentials are invalid
172
- def login
173
- @session_path ? session_login : basic_login
174
- end
175
-
176
- # Sign out of the service.
177
- def logout
178
- # We bypass request here because we do not want any retries on 401
179
- # when doing logout.
180
- if @session_oid
181
- params = prepare_request_params(:delete, @session_oid)
182
- @connection.request(params)
183
- @session_oid = nil
184
- end
185
- remove_headers([BASIC_AUTH_HEADER, TOKEN_AUTH_HEADER])
186
- end
187
-
188
- private
189
-
190
- def do_request(method, path, data)
191
- params = prepare_request_params(method, path, data)
192
- r = @connection.request(params)
193
- if r.status == 401
194
- login
195
- r = @connection.request(params)
196
- end
197
- Response.new(r.status, downcase_headers(r.data[:headers]), r.data[:body])
198
- end
199
-
200
- def downcase_headers(headers)
201
- headers.each_with_object({}) { |(k, v), obj| obj[k.downcase] = v }
202
- end
203
-
204
- def prepare_request_params(method, path, data = nil)
205
- params = { method: method, path: path }
206
- if data
207
- params[:body] = data.to_json
208
- params[:headers] = @headers.merge("Content-Type" => "application/json")
209
- else
210
- params[:headers] = @headers
211
- end
212
- params
213
- end
214
-
215
- def session_login
216
- # We bypass request here because we do not want any retries on 401
217
- # when doing login.
218
- params = prepare_request_params(:post, @session_path,
219
- "UserName" => @username,
220
- "Password" => @password)
221
- r = @connection.request(params)
222
- raise_invalid_auth_error unless r.status == 201
223
-
224
- body = JSON.parse(r.data[:body])
225
- headers = r.data[:headers]
226
-
227
- add_headers(TOKEN_AUTH_HEADER => headers[TOKEN_AUTH_HEADER])
228
- save_session_oid!(body, headers)
229
- end
230
-
231
- def save_session_oid!(body, headers)
232
- @session_oid = body["@odata.id"] if body.key?("@odata.id")
233
- return if @session_oid
234
-
235
- return unless headers.key?(LOCATION_HEADER)
236
-
237
- location = URI.parse(headers[LOCATION_HEADER])
238
- @session_oid = [location.path, location.query].compact.join("?")
239
- end
240
-
241
- def basic_login
242
- payload = Base64.encode64("#{@username}:#{@password}").strip
243
- add_headers(BASIC_AUTH_HEADER => "Basic #{payload}")
244
- return if auth_valid?
245
-
246
- remove_headers([BASIC_AUTH_HEADER])
247
- raise_invalid_auth_error
248
- end
249
-
250
- def raise_invalid_auth_error
251
- raise AuthError, "Invalid credentials"
252
- end
253
-
254
- def auth_valid?
255
- # We bypass request here because we do not want any retries on 401
256
- # when checking authentication headers.
257
- reset(@auth_test_path) # Do not want to see cached response
258
- params = prepare_request_params(:get, @auth_test_path)
259
- @connection.request(params).status == 200
260
- end
261
- end
262
- end
@@ -1,35 +0,0 @@
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
@@ -1,44 +0,0 @@
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
@@ -1,290 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- module RedfishClient
6
- # Resource is basic building block of Redfish client and serves as a
7
- # container for the data that is retrieved from the Redfish service.
8
- #
9
- # When we interact with the Redfish service, resource will wrap the data
10
- # retrieved from the service API and offer us dot-notation accessors for
11
- # values stored.
12
- #
13
- # Resource will also load any sub-resource on demand when we access it.
14
- # For example, if we have a root Redfish resource stored in `root`,
15
- # accessing `root.SessionService` will automatically fetch the appropriate
16
- # resource from the API.
17
- #
18
- # In order to reduce the amount of requests being sent to the service,
19
- # resource can also utilise caching connector. If we would like to get
20
- # fresh values from the service, {#refresh} call will flush the cache and
21
- # retrieve fresh data from the remote.
22
- class Resource
23
- # NoODataId error is raised when operation would need OpenData id of the
24
- # resource to accomplish the task a hand.
25
- class NoODataId < StandardError; end
26
-
27
- # NoResource error is raised if the service cannot find requested
28
- # resource.
29
- class NoResource < StandardError; end
30
-
31
- # Timeout error is raised if the async request is not handled in due time.
32
- class Timeout < StandardError; end
33
-
34
- # Headers, returned from the service when resource has been constructed.
35
- #
36
- # @return [Hash] resource headers
37
- attr_reader :headers
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
-
45
- # Create new resource.
46
- #
47
- # Resource can be created either by passing in OpenData identifier or
48
- # supplying the content (hash). In the first case, connector will be used
49
- # to fetch the resource data. In the second case, resource only wraps the
50
- # passed-in hash and does no fetching.
51
- #
52
- # @param connector [RedfishClient::Connector] connector that will be used
53
- # to fetch the resources
54
- # @param oid [String] OpenData id of the resource
55
- # @param raw [Hash] raw content to populate resource with
56
- # @raise [NoResource] resource cannot be retrieved from the service
57
- def initialize(connector, oid: nil, raw: nil)
58
- @connector = connector
59
- if oid
60
- initialize_from_service(oid)
61
- else
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)
83
- end
84
- raise Timeout, "Async operation did not terminate in allotted time"
85
- end
86
-
87
- # Access resource content.
88
- #
89
- # This function offers a way of accessing resource data in the same way
90
- # that hash exposes its content.
91
- #
92
- # @param attr [String] key for accessing data
93
- # @return associated value or `nil` if attr is missing
94
- def [](attr)
95
- build_resource(raw[attr])
96
- end
97
-
98
- # Safely access nested resource content.
99
- #
100
- # This function is an equivalent of safe navigation operator that can be
101
- # used with arbitrary keys.
102
- #
103
- # Calling `res.dig("a", "b", "c")` is equivalent to `res.a&.b&.c` and
104
- # `res["a"] && res["a"]["b"] && res["a"]["b"]["c"]`.
105
- # @params keys [Array<Symbol, String>] sequence of keys to access
106
- # @return associated value or `nil` if any key is missing
107
- def dig(*keys)
108
- keys.reduce(self) { |a, k| a.nil? ? nil : a[k] }
109
- end
110
-
111
- # Test if resource contains required key.
112
- #
113
- # @param name [String, Symbol] key name to test
114
- # @return [Boolean] inclusion test result
115
- def key?(name)
116
- raw.key?(name.to_s)
117
- end
118
-
119
- # Convenience access for resource data.
120
- #
121
- # Calling `resource.Value` is exactly the same as `resource["Value"]`.
122
- def method_missing(symbol, *_args, &_block)
123
- self[symbol.to_s]
124
- end
125
-
126
- def respond_to_missing?(symbol, include_private = false)
127
- key?(symbol.to_s) || super
128
- end
129
-
130
- # Pretty-print the wrapped content.
131
- #
132
- # @return [String] JSON-serialized raw data
133
- def to_s
134
- JSON.pretty_generate(raw)
135
- end
136
-
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.
144
- #
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)
156
- end
157
-
158
- # Issue a GET requests to the selected endpoint.
159
- #
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)
176
- end
177
-
178
- # Issue a POST requests to the selected endpoint.
179
- #
180
- # By default, POST request will be sent to the path, stored in `@odata.id`
181
- # field. Source field can be changed by specifying the `field` parameter
182
- # when calling this function. Specifying the `path` argument will bypass
183
- # the field lookup altogether and POST directly to the requested path.
184
- #
185
- # In order to avoid having to manually serialize data to JSON, this
186
- # function call takes Hash as a payload and encodes it before sending it
187
- # to the endpoint.
188
- #
189
- # If the resource has no lookup field, {NoODataId} error will be raised,
190
- # since posting to non-networked resources makes no sense and probably
191
- # indicates bug in library consumer.
192
- #
193
- # @param field [String, Symbol] path lookup field
194
- # @param path [String] path to post to
195
- # @param payload [Hash<String, >] data to send
196
- # @return [RedfishClient::Response] response
197
- # @raise [NoODataId] resource has no OpenData id
198
- def post(field: "@odata.id", path: nil, payload: nil)
199
- request(:post, field, path, payload)
200
- end
201
-
202
- # Issue a PATCH requests to the selected endpoint.
203
- #
204
- # Works exactly the same as the {post} method, but issued a PATCH request
205
- # to the server.
206
- #
207
- # @param field [String, Symbol] path lookup field
208
- # @param path [String] path to patch
209
- # @param payload [Hash<String, >] data to send
210
- # @return [RedfishClient::Response] response
211
- # @raise [NoODataId] resource has no OpenData id
212
- def patch(field: "@odata.id", path: nil, payload: nil)
213
- request(:patch, field, path, payload)
214
- end
215
-
216
- # Issue a DELETE requests to the endpoint of the resource.
217
- #
218
- # If the resource has no `@odata.id` field, {NoODataId} error will be
219
- # raised, since deleting non-networked resources makes no sense and
220
- # probably indicates bug in library consumer.
221
- #
222
- # @return [RedfishClient::Response] response
223
- # @raise [NoODataId] resource has no OpenData id
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"])
239
- end
240
-
241
- private
242
-
243
- def initialize_from_service(oid)
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
259
-
260
- def parse_fragment_string(fragment)
261
- # /my/0/part -> ["my", "0", "part"]
262
- fragment ? fragment.split("/").reject { |i| i == "" } : []
263
- end
264
-
265
- def get_path(field, path)
266
- raise NoODataId if path.nil? && !key?(field)
267
- path || raw[field]
268
- end
269
-
270
- def build_resource(data)
271
- return nil if data.nil?
272
-
273
- case data
274
- when Hash then build_hash_resource(data)
275
- when Array then data.collect { |d| build_resource(d) }
276
- else data
277
- end
278
- end
279
-
280
- def build_hash_resource(data)
281
- if data.key?("@odata.id")
282
- Resource.new(@connector, oid: data["@odata.id"])
283
- else
284
- Resource.new(@connector, raw: data)
285
- end
286
- rescue NoResource
287
- nil
288
- end
289
- end
290
- end
@@ -1,47 +0,0 @@
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
- # Returns wether the request is completed or ongoing. Be aware than completed
21
- # doesn't mean completed successfully, and that you still need to check status
22
- # for success or failure.
23
- # @return [true] if the request was completed
24
- def done?
25
- status != 202
26
- end
27
-
28
- def monitor
29
- return nil if done?
30
-
31
- uri = URI.parse(headers["location"])
32
- [uri.path, uri.query].compact.join("?")
33
- end
34
-
35
- def to_h
36
- { "status" => status, "headers" => headers, "body" => body }
37
- end
38
-
39
- def to_s
40
- "Response[status=#{status}, headers=#{headers}, body='#{body}']"
41
- end
42
-
43
- def self.from_hash(data)
44
- new(*data.values_at("status", "headers", "body"))
45
- end
46
- end
47
- end