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 +4 -4
- data/.rubocop.yml +18 -0
- data/.travis.yml +0 -1
- data/README.md +40 -0
- data/lib/redfish_client.rb +3 -6
- data/lib/redfish_client/connector.rb +172 -12
- data/lib/redfish_client/event_listener.rb +35 -0
- data/lib/redfish_client/nil_hash.rb +44 -0
- data/lib/redfish_client/resource.rb +118 -34
- data/lib/redfish_client/response.rb +43 -0
- data/lib/redfish_client/root.rb +36 -67
- data/lib/redfish_client/version.rb +1 -1
- data/redfish_client.gemspec +3 -2
- metadata +25 -9
- data/lib/redfish_client/caching_connector.rb +0 -38
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 89fe47fe3e6d6e82a1c1fbce3a33e101ec9c0dc70b9e4725205c1e987c1f4b38
|
4
|
+
data.tar.gz: 823ea29d9a34c9a460c6a65b385714d08fa50caaeb829dfb7f1747af78389b63
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8699fd26e3c468d864132e5790ab6037d2ac6e767a38c96ee07a12c9edd29ba77aceb1937287f9bf877db0ccf23534d790bfe3aaa3ce1d87d63eb98e4f75d25d
|
7
|
+
data.tar.gz: 6538ec3de254d305b25fa076fb72d72866ec8162717c3cd1205f4c957d84b310a46b23490514e8b665acc121b6556e19b202261fc8f273d925c61ef99d5639e9
|
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
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,
|
data/lib/redfish_client.rb
CHANGED
@@ -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
|
-
|
17
|
-
|
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
|
-
|
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 [
|
100
|
+
# @return [Response] response object
|
57
101
|
def get(path)
|
58
|
-
|
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 [
|
109
|
+
# @return [Response] response object
|
66
110
|
def post(path, data = nil)
|
67
|
-
|
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 [
|
118
|
+
# @return [Response] response object
|
75
119
|
def patch(path, data = nil)
|
76
|
-
|
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 [
|
126
|
+
# @return [Response] response object
|
83
127
|
def delete(path)
|
84
|
-
|
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
|
90
|
-
params =
|
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, {#
|
21
|
-
#
|
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
|
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,
|
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
|
-
@
|
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(
|
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
|
-
|
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
|
-
#
|
130
|
+
# Pretty-print the wrapped content.
|
100
131
|
#
|
101
|
-
#
|
102
|
-
def
|
103
|
-
|
132
|
+
# @return [String] JSON-serialized raw data
|
133
|
+
def to_s
|
134
|
+
JSON.pretty_generate(raw)
|
104
135
|
end
|
105
136
|
|
106
|
-
#
|
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
|
-
#
|
109
|
-
|
110
|
-
|
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
|
-
#
|
158
|
+
# Issue a GET requests to the selected endpoint.
|
114
159
|
#
|
115
|
-
#
|
116
|
-
|
117
|
-
|
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 [
|
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
|
-
|
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 [
|
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
|
-
|
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 [
|
222
|
+
# @return [RedfishClient::Response] response
|
165
223
|
# @raise [NoODataId] resource has no OpenData id
|
166
|
-
def delete
|
167
|
-
|
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
|
-
|
174
|
-
|
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
|
-
|
177
|
-
|
178
|
-
|
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 ||
|
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,
|
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
|
data/lib/redfish_client/root.rb
CHANGED
@@ -1,48 +1,14 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "
|
4
|
-
|
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
|
-
|
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
|
-
|
68
|
-
!@content.dig("Links", "Sessions").nil?
|
40
|
+
EventListener.new(ServerSentEvents.create_client(address))
|
69
41
|
end
|
70
42
|
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
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
|
-
|
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
|
-
|
86
|
-
|
87
|
-
|
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
|
-
|
94
|
-
@content.values.map { |v| v["@odata.id"] }.compact.first
|
95
|
-
end
|
64
|
+
private
|
96
65
|
|
97
|
-
def
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
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
|
105
|
-
@
|
73
|
+
def auth_test_path
|
74
|
+
raw.values.find { |v| v["@odata.id"] }["@odata.id"]
|
106
75
|
end
|
107
76
|
end
|
108
77
|
end
|
data/redfish_client.gemspec
CHANGED
@@ -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.
|
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.
|
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:
|
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.
|
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.
|
26
|
+
version: '0.71'
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
|
-
name:
|
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
|
34
|
-
type: :
|
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
|
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
|