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