lib/redfish_client/connector.rb in redfish_client-0.4.1 vs lib/redfish_client/connector.rb in redfish_client-0.5.0
- old
+ new
@@ -1,38 +1,63 @@
# frozen_string_literal: true
+require "base64"
require "excon"
require "json"
+require "redfish_client/nil_hash"
+require "redfish_client/response"
+
module RedfishClient
# Connector serves as a low-level wrapper around HTTP calls that are used
# to retrieve data from the service API. It abstracts away implementation
# details such as sending the proper headers in request, which do not
# change between resource fetches.
#
# Library users should treat this class as an implementation detail and
# use higer-level {RedfishClient::Resource} instead.
class Connector
+ # AuthError is raised if the credentials are invalid.
+ class AuthError < StandardError; end
+
# Default headers, as required by Redfish spec
# https://redfish.dmtf.org/schemas/DSP0266_1.4.0.html#request-headers
DEFAULT_HEADERS = {
"Accept" => "application/json",
- "OData-Version" => "4.0"
+ "OData-Version" => "4.0",
}.freeze
+ # Basic and token authentication header names
+ BASIC_AUTH_HEADER = "Authorization"
+ TOKEN_AUTH_HEADER = "X-Auth-Token"
+
# Create new connector.
#
+ # By default, connector performs no caching. If caching is desired,
+ # Hash should be used as a cache implementation.
+ #
+ # It is also possible to pass in custom caching class. Instances of that
+ # class should respond to the following four methods:
+ #
+ # 1. `[](key)` - Used to access cached content and should return
+ # `nil` if the key has no associated value.
+ # 2. `[]=(key, value)` - Cache `value` under the `key`
+ # 3. `clear` - Clear the complete cache.
+ # 4. `delete(key)` - Invalidate cache entry associated with `key`.
+ #
# @param url [String] base url of the Redfish service
# @param verify [Boolean] verify SSL certificate of the service
- def initialize(url, verify = true)
+ # @param cache [Object] cache backend
+ def initialize(url, verify: true, cache: nil)
@url = url
@headers = DEFAULT_HEADERS.dup
middlewares = Excon.defaults[:middlewares] +
[Excon::Middleware::RedirectFollower]
@connection = Excon.new(@url,
ssl_verify_peer: verify,
middlewares: middlewares)
+ @cache = cache || NilHash.new
end
# Add HTTP headers to the requests made by the connector.
#
# @param headers [Hash<String, String>] headers to be added
@@ -48,53 +73,169 @@
# @param headers [List<String>] headers to remove
def remove_headers(headers)
headers.each { |h| @headers.delete(h) }
end
+ # Issue requests to the service.
+ #
+ # @param mathod [Symbol] HTTP method (:get, :post, :patch or :delete)
+ # @param path [String] path to the resource, relative to the base
+ # @param data [Hash] data to be sent over the socket
+ # @return [Response] response object
+ def request(method, path, data = nil)
+ params = prepare_request_params(method, path, data)
+ r = @connection.request(params)
+ if r.status == 401
+ login
+ r = @connection.request(params)
+ end
+ Response.new(r.status, downcase_headers(r.data[:headers]), r.data[:body])
+ end
+
# Issue GET request to service.
#
+ # This method will first try to return cached response if available. If
+ # cache does not contain entry for this request, data will be fetched from
+ # remote and then cached, but only if the response has an OK (200) status.
+ #
# @param path [String] path to the resource, relative to the base url
- # @return [Excon::Response] response object
+ # @return [Response] response object
def get(path)
- @connection.get(path: path, headers: @headers)
+ return @cache[path] if @cache[path]
+
+ request(:get, path).tap { |r| @cache[path] = r if r.status == 200 }
end
# Issue POST requests to the service.
#
# @param path [String] path to the resource, relative to the base
# @param data [Hash] data to be sent over the socket, JSON encoded
- # @return [Excon::Response] response object
+ # @return [Response] response object
def post(path, data = nil)
- @connection.post(prepare_request_params(path, data))
+ request(:post, path, data)
end
# Issue PATCH requests to the service.
#
# @param path [String] path to the resource, relative to the base
# @param data [Hash] data to be sent over the socket
- # @return [Excon::Response] response object
+ # @return [Response] response object
def patch(path, data = nil)
- @connection.patch(prepare_request_params(path, data))
+ request(:patch, path, data)
end
# Issue DELETE requests to the service.
#
# @param path [String] path to the resource, relative to the base
- # @return [Excon::Response] response object
+ # @return [Response] response object
def delete(path)
- @connection.delete(path: path, headers: @headers)
+ request(:delete, path)
end
+ # Clear the cached responses.
+ #
+ # If path is passed as a parameter, only one cache entry gets invalidated,
+ # else complete cache gets invalidated.
+ #
+ # Next GET request will repopulate the cache.
+ #
+ # @param path [String] path to invalidate
+ def reset(path = nil)
+ path.nil? ? @cache.clear : @cache.delete(path)
+ end
+
+ # Set authentication-related variables.
+ #
+ # Last parameter controls the kind of login connector will perform. If
+ # session_path is `nil`, basic authentication will be used, otherwise
+ # connector will use session-based authentication.
+ #
+ # Note that actual login is done lazily. If you need to check for
+ # credential validity, call #{login} method.
+ #
+ # @param username [String] API username
+ # @param password [String] API password
+ # @param auth_test_path [String] API path to test credential's validity
+ # @param session_path [String, nil] API session path
+ def set_auth_info(username, password, auth_test_path, session_path = nil)
+ @username = username
+ @password = password
+ @auth_test_path = auth_test_path
+ @session_path = session_path
+ end
+
+ # Authenticate against the service.
+ #
+ # Calling this method will try to authenticate against API using
+ # credentials provided by #{set_auth_info} call.
+ # If authentication fails, # {AuthError} will be raised.
+ #
+ # @raise [AuthError] if credentials are invalid
+ def login
+ @session_path ? session_login : basic_login
+ end
+
+ # Sign out of the service.
+ def logout
+ # We bypass request here because we do not want any retries on 401
+ # when doing logout.
+ if @session_oid
+ params = prepare_request_params(:delete, @session_oid)
+ @connection.request(params)
+ @session_oid = nil
+ end
+ remove_headers([BASIC_AUTH_HEADER, TOKEN_AUTH_HEADER])
+ end
+
private
- def prepare_request_params(path, data)
- params = { path: path }
+ def downcase_headers(headers)
+ headers.each_with_object({}) { |(k, v), obj| obj[k.downcase] = v }
+ end
+
+ def prepare_request_params(method, path, data = nil)
+ params = { method: method, path: path }
if data
params[:body] = data.to_json
params[:headers] = @headers.merge("Content-Type" => "application/json")
else
params[:headers] = @headers
end
params
+ end
+
+ def session_login
+ # We bypass request here because we do not want any retries on 401
+ # when doing login.
+ params = prepare_request_params(:post, @session_path,
+ "UserName" => @username,
+ "Password" => @password)
+ r = @connection.request(params)
+ raise_invalid_auth_error unless r.status == 201
+
+ token = r.data[:headers][TOKEN_AUTH_HEADER]
+ add_headers(TOKEN_AUTH_HEADER => token)
+ @session_oid = JSON.parse(r.data[:body])["@odata.id"]
+ end
+
+ def basic_login
+ payload = Base64.encode64("#{@username}:#{@password}").strip
+ add_headers(BASIC_AUTH_HEADER => "Basic #{payload}")
+ return if auth_valid?
+
+ remove_headers([BASIC_AUTH_HEADER])
+ raise_invalid_auth_error
+ end
+
+ def raise_invalid_auth_error
+ raise AuthError, "Invalid credentials"
+ end
+
+ def auth_valid?
+ # We bypass request here because we do not want any retries on 401
+ # when checking authentication headers.
+ reset(@auth_test_path) # Do not want to see cached response
+ params = prepare_request_params(:get, @auth_test_path)
+ @connection.request(params).status == 200
end
end
end