# frozen_string_literal: true require_relative 'exchange_handler' require_relative 'rest_parameters' require_relative 'rest_parameters_defaults' require_relative 'rest_exchanger_factory' require_relative '../core_ext/hash' require_relative '../errors' require_relative 'handler_accessors' require_relative '../interpreter' require_relative 'response_extractor' require_relative 'request/rest_request' require 'json' require 'jsonpath' require 'nori' require 'erb' module Soaspec # Wraps around Savon client defining default values dependent on the soap request class RestHandler < ExchangeHandler include ResponseExtractor extend Soaspec::RestParameters include Soaspec::RestParametersDefaults extend Soaspec::RestExchangeFactory # User used in making API calls attr_accessor :api_username # Setup object to handle communicating with a particular SOAP WSDL # @param [Hash] options Options defining REST request. base_url, default_hash def initialize(name = self.class.to_s, options = {}) raise "Base URL not set! Please set in class with 'base_url' method" unless base_url_value if name.is_a?(Hash) && options == {} # If name is not set, use first parameter as the options hash options = name name = self.class.to_s end super set_remove_keys(options, %i[api_username default_hash template_name]) @init_options = options @merged_options ||= init_merge_options # Caches initial merge options end # @return [Soaspec::RestRequest] Parameters used in making a request def request_parameters(override_parameters) RestRequest.new(override_parameters, @merged_options, self) end # Override this with 'after_response' within class definition to perform an action # after response is retrieved # @param [RestClient::Response] _response Response to interpret to perform after block def after_response(_response, _self); end # Used in together with Exchange request that passes such override parameters # @param [Hash] override_parameters Params to characterize REST request # @option override_parameters [Hash] :params Extra parameters (E.g. headers) # @option override_parameters [String] suburl URL appended to base_url of class # @option override_parameters [Hash] :q Query for REST # @option override_parameters [Symbol] :method REST method (:get, :post, :patch, etc) # Following are for the body of the request # @option override_parameters [Hash] :body Hash to be converted to JSON in request body # @option override_parameters [String] :payload String to be passed directly in request body # @option override_parameters [String] :template_name Path to file to be read via ERB and passed in request body # @return [RestClient::Response] Response from making request def make_request(override_parameters) req_params = request_parameters override_parameters # In order for ERB to be calculated at correct time, the first time request is made, the resource should be created @resource ||= RestClient::Resource.new(ERB.new(base_url_value).result(binding), @merged_options) @resource_used = req_params.suburl ? @resource[req_params.suburl] : @resource self.exception = nil # Remove any previously stored exception begin response = case req_params.method when :post, :patch, :put Soaspec::SpecLogger.info("request body: #{req_params.post_data}") @resource_used.send(req_params.method, req_params.post_data, req_params.other_params) else # :get, :delete @resource_used.send(req_params.method, req_params.other_params ) end rescue RestClient::Exception => e self.exception = e raise e unless e.respond_to? :response response = e.response end Soaspec::SpecLogger.info("response: \n headers: #{response&.headers}\n body: #{response}\n") after_response(response, self) response end # @param [Hash] override_hash Values to override default hash with # @return [Hash] Hash used in REST request based on data conversion def hash_used_in_request(override_hash) request = override_hash ? @default_hash.merge(override_hash) : @default_hash if pascal_keys? request.map { |k, v| [convert_to_pascal_case(k.to_s), v] }.to_h else request end end # Add values to here when extending this class to have default REST options. # See rest client resource at https://github.com/rest-client/rest-client for details # It's easier to set headers via 'headers' accessor rather than here # @return [Hash] Options adding to & overriding defaults def rest_resource_options {} end # Perform ERB on each header value # @return [Hash] Hash from 'rest_client_headers' passed through ERB def parse_headers Hash[rest_client_headers.map do |header_name, header_value| raise ArgumentError, "Header '#{header_name}' is null. Headers are #{rest_client_headers}" if header_value.nil? [header_name, ERB.new(header_value).result(binding)] end] end # Initialize value of merged options # Also called to verify any issues with options on creating object # @return [Hash] Hash of merged options def init_merge_options options = rest_resource_options options.merge! basic_auth_params if respond_to? :basic_auth_params options[:headers] ||= {} options[:headers].merge! parse_headers options[:headers][:authorization] ||= ERB.new('Bearer <%= access_token %>').result(binding) if Soaspec.auto_oauth && respond_to?(:access_token) options.merge(@init_options) end # @param [Hash] format Format of expected result. # @return [Object] Generic body to be displayed in error messages def response_body(response, format: :hash) extract_hash response end # @return [Boolean] Whether response body includes String def include_in_body?(response, expected) response.body.include? expected end # @@return [Boolean] Whether the request found the desired value or not def found?(response) status_code_for(response) != 404 end # @return [Boolean] Whether response contains expected value def include_value?(response, expected) extract_hash(response).include_value? expected end # @return [Boolean] Whether response body contains expected key def include_key?(response, expected) value_from_path(response, expected) true rescue NoElementAtPath false end # @return [Integer] HTTP Status code for response def status_code_for(response) response.code end # Returns the value at the provided xpath # @param [RestClient::Response] response # @param [String] xpath Path to find elements from # @param [String] attribute Attribute to find path for # @return [Enumerable] Value inside element found through Xpath def xpath_elements_for(response: nil, xpath: nil, attribute: nil) raise ArgumentError unless response && xpath raise "Can't perform XPATH if response is not XML" unless %i[xml html].include? Interpreter.response_type_for(response) xpath = prefix_xpath(xpath, attribute) temp_doc = Nokogiri.parse(response.body).dup if strip_namespaces? && !xpath.include?(':') temp_doc.remove_namespaces! temp_doc.xpath(xpath) else temp_doc.xpath(xpath, temp_doc.collect_namespaces) end end # @return [Enumerable] List of values matching JSON path def json_path_values_for(response, path, attribute: nil) raise 'JSON does not support attributes' if attribute JsonPath.on(response.body, path) end # Calculate all JSON path values based on rules. ',', pascal_case # @param [RestClient::Response] response Response from API # @param [Object] path Xpath, JSONPath or other path identifying how to find element # @param [String] attribute Generic attribute to find. Will override path # @param [Boolean] not_empty Whether to fail if result is empty # @return [Array] Paths to check as first and matching values (List of values matching JSON Path) as second def calculated_json_path_matches(path, response, attribute, not_empty: false) path = add_pascal_path(path) paths_to_check = path.split(',') paths_to_check = paths_to_check.map { |path_to_check| prefix_json_path(path_to_check) } matching_values = paths_to_check.collect do |path_to_check| json_path_values_for(response, path_to_check, attribute: attribute) end.reject(&:empty?) raise NoElementAtPath, "No value at JSONPath '#{paths_to_check}' in '#{response.body}'" if matching_values.empty? && not_empty matching_values.first end # Based on a exchange, return the value at the provided xpath # If the path does not begin with a '/', a '//' is added to it # @param [RestClient::Response] response Response from API # @param [Object] path Xpath, JSONPath or other path identifying how to find element # @param [String] attribute Generic attribute to find. Will override path # @return [String] Value at Xpath def value_from_path(response, path, attribute: nil) path = path.to_s case Interpreter.response_type_for(response) when :xml, :html result = xpath_elements_for(response: response, xpath: path, attribute: attribute).first raise NoElementAtPath, "No value at Xpath '#{prefix_xpath(path, attribute)}' in '#{response.body}'" unless result return result.inner_text if attribute.nil? return result.attributes[attribute].inner_text when :json matching_values = calculated_json_path_matches(path, response, attribute, not_empty: true) matching_values.first else # Assume this is a String raise NoElementAtPath, 'Response is empty' if response.to_s.empty? response.to_s[/#{path}/] # Perform regular expression using path if not XML nor JSON end end # @return [Enumerable] List of values returned from path def values_from_path(response, path, attribute: nil) path = path.to_s case Interpreter.response_type_for(response) when :xml xpath_elements_for(response: response, xpath: path, attribute: attribute).map(&:inner_text) when :json result = calculated_json_path_matches(path, response, attribute) result || [] # json_path_values_for(response, path, attribute: attribute) else raise "Unable to interpret type of #{response.body}" end end # @return [RestClient::Request, String] Request of API call. Either intended request or actual request def request(response) if response.nil? return 'Request not yet sent. Call :request_parameters' \ 'for what will be sent' end response.request end end end