require_relative 'exchange_handler' require_relative '../core_ext/hash' require_relative '../not_found_errors' require_relative 'handler_accessors' require_relative '../interpreter' require 'json' require 'jsonpath' require 'nori' require 'erb' module Soaspec # Accessors specific to REST handler module RestAccessors # Defines method 'base_url_value' containing base URL used in REST requests # @param [String] url Base Url to use in REST requests. Suburl is appended to this def base_url(url) define_method('base_url_value') do url end end # Will create access_token method based on passed parameters def oauth2(client_id: nil, client_secret: nil, token_url: nil, username: nil, password: nil, security_token: nil) define_method('oauth_response') do username = ERB.new(username).result(binding) if username security_token = ERB.new(security_token).result(binding) if security_token token_url = ERB.new(token_url).result(binding) if token_url password = ERB.new(password).result(binding) if password payload = if password && username { grant_type: 'password', client_id: client_id, client_secret: client_secret, username: username, password: security_token ? (password + security_token) : password, multipart: true } else { grant_type: 'client_credentials', client_id: client_id, client_secret: client_secret } end retry_count = 0 begin Soaspec::SpecLogger.add_to 'request_params: ' + payload.to_s response = RestClient.post(token_url, payload, cache_control: 'no_cache', verify_ssl: false) rescue RestClient::Exception => e retry_count += 1 retry if retry_count < 3 raise e end Soaspec::SpecLogger.add_to("response_headers: #{response.headers}") Soaspec::SpecLogger.add_to("response_body: #{response.body}") JSON.parse(response) end define_method('access_token') do oauth_response['access_token'] end define_method('instance_url') do oauth_response['instance_url'] end end # Pass path to YAML file containing OAuth2 parameters # @param [String] path_to_filename Will have Soaspec.credentials_folder appended to it if set def oauth2_file(path_to_filename) full_path = Soaspec.credentials_folder ? File.join(Soaspec.credentials_folder, path_to_filename + '.yml') : path_to_filename + '.yml' file_hash = YAML.load_file(full_path) raise 'File at ' + full_path + ' is not a hash ' unless file_hash.is_a? Hash oauth_hash = file_hash.transform_keys_to_symbols oauth2 **oauth_hash end # @param [Hash] headers Hash of REST headers used in RestClient def headers(headers) define_method('rest_client_headers') do headers end end # Convert each key from snake_case to PascalCase def pascal_keys(set) define_method('pascal_keys?') do set end end end # Wraps around Savon client defining default values dependent on the soap request class RestHandler < ExchangeHandler extend Soaspec::RestAccessors # Savon client used to make SOAP calls attr_accessor :client # SOAP Operation to use by default attr_accessor :operation # Set through following method. Base URL in REST requests. def base_url_value nil end # Headers used in RestClient def rest_client_headers {} 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 { |k, header| [k, ERB.new(header).result(binding)] }] end # Setup object to handle communicating with a particular SOAP WSDL # @param [Hash] options Options defining SOAP request. WSDL, authentication 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 @default_hash = {} if name.is_a?(Hash) && options == {} # If name is not set options = name name = self.class.to_s end super set_remove_key(options, :default_hash) merged_options = rest_resource_options merged_options[:headers] ||= {} merged_options[:headers].merge! parse_headers merged_options.merge!(options) @resource = RestClient::Resource.new(base_url_value, merged_options) # @resource[url_extension].get end # Convert snakecase to PascalCase def convert_to_pascal_case(key) return key if /[[:upper:]]/ =~ key[0] # If first character already capital, don't do conversion key.split('_').map(&:capitalize).join end # Whether to convert each key in the request to PascalCase # It will also auto convert simple XPath, JSONPath where '//' or '..' not specified # @return Whether to convert to PascalCase def pascal_keys? false end # @return [Hash] def hash_used_in_request(override_hash) request = @default_hash.merge(override_hash) if pascal_keys? request.map { |k, v| [convert_to_pascal_case(k.to_s), v] }.to_h else request end end # Used in together with Exchange request that passes such override parameters # @param [Hash] override_parameters Params to characterize REST request # @param_value [params] Extra parameters (E.g. headers) # @param_value [suburl] URL appended to base_url of class # @param_value [method] REST method (get, post, etc) def make_request(override_parameters) test_values = override_parameters test_values[:params] ||= {} test_values[:method] ||= :post test_values[:suburl] = test_values[:suburl].to_s if test_values[:suburl] test_values[:params][:params] = test_values[:q] if test_values[:q] # Use q for query parameters. Nested :params is ugly and long @resource_used = test_values[:suburl] ? @resource[test_values[:suburl]] : @resource begin response = case test_values[:method] when :post, :patch, :put unless test_values[:payload] test_values[:payload] = JSON.generate(hash_used_in_request(test_values[:body])).to_s if test_values[:body] end @resource_used.send(test_values[:method].to_s, test_values[:payload], test_values[:params]) else @resource_used.send(test_values[:method].to_s, test_values[:params]) end rescue RestClient::ExceptionWithResponse => e response = e.response end Soaspec::SpecLogger.add_to('response_headers: ' + response.headers.to_s) Soaspec::SpecLogger.add_to('response_body: ' + response.to_s) response end # @param [Hash] _format Format of expected result. Ignored for this # @return [Object] Generic body to be displayed in error messages def response_body(response, _format: :hash) extract_hash response end def include_in_body?(response, expected) response.body.include? expected end # Whether the request found the desired value or not def found?(response) status_code_for(response) != 404 end # Convert XML or JSON response into a Hash # @param [String] response Response as a String (either in XML or JSON) # @return [Hash] def extract_hash(response) raise ArgumentError("Empty Body. Can't assert on it") if response.body.empty? case Interpreter.response_type_for response when :json converted = JSON.parse(response.body) return converted.transform_keys_to_symbols if converted.is_a? Hash return converted.map!(&:transform_keys_to_symbols) if converted.is_a? Array raise 'Incorrect Type prodcued ' + converted.class when :xml parser = Nori.new(convert_tags_to: lambda { |tag| tag.snakecase.to_sym }) parser.parse(response.body) else raise "Neither XML nor JSON detected. It is #{type}. Don't know how to parse It is #{response.body}" end 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) end # @return [Integer] HTTP Status code for response def status_code_for(response) response.code end # Override this to specify elements that must be present in the response # Will be used in 'success_scenarios' shared examples # @return [Array] Array of symbols specifying element names def mandatory_elements [] end # Override this to specify xpath results that must be present in the response # Will be used in 'success_scenarios' shared examples # @return [Hash] Hash of 'xpath' => 'expected value' pairs def mandatory_xpath_values {} end # Attributes set at the root XML element of SOAP request def root_attributes nil end # Returns the value at the provided xpath # @param [RestClient::Response] response # @param [String] xpath # @return [String] Value inside element found through Xpath def xpath_value_for(response: nil, xpath: nil, attribute: nil) raise ArgumentError unless response && xpath raise "Can't perform XPATH if response is not XML" unless Interpreter.response_type_for(response) == :xml result = if Soaspec.strip_namespaces? && !xpath.include?(':') temp_doc = Nokogiri.parse response.body temp_doc.remove_namespaces! temp_doc.xpath(xpath).first else Nokogiri.parse(response.body).xpath(xpath).first end raise NoElementAtPath, "No value at Xpath '#{xpath}'" unless result return result.inner_text if attribute.nil? result.attributes[attribute].inner_text 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 [Response] response # @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 path = "//*[@#{attribute}]" unless attribute.nil? if path[0] != '/' path = convert_to_pascal_case(path) if pascal_keys? path = '//' + path end xpath_value_for(response: response, xpath: path, attribute: attribute) when :json raise 'JSON does not support attributes' if attribute if path[0] != '$' path = convert_to_pascal_case(path) if pascal_keys? path = '$..' + path end matching_values = JsonPath.on(response.body, path) raise NoElementAtPath, "Element in #{response.body} not found with path '#{path}'" if matching_values.empty? matching_values.first when :hash response.dig(path.split('.')) # Use path as Hash dig expression separating params via '.' TODO: Unit test else response.to_s[/path/] # Perform regular expression using path if not XML nor JSON TODO: Unit test end end # Convenience methods for once off usage of a REST request class << self methods = %w[post patch put get delete] methods.each do |rest_method| # Make REST Exchange within this Handler context # @param [Hash] params Exchange parameters # @return [Exchange] Instance of Exchange class. Assertions are made by default on the response body define_method(rest_method) do |params| params ||= {} params[:name] ||= rest_method new(params[:name]) Exchange.new(params[:name], method: rest_method.to_sym, **params) end end end end end