lib/soaspec/exchange_handlers/rest_handler.rb in soaspec-0.0.81 vs lib/soaspec/exchange_handlers/rest_handler.rb in soaspec-0.0.82

- old
+ new

@@ -1,368 +1,368 @@ - -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 = api_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 - Soaspec::SpecLogger.add_to("oauth_error: #{e.message}") - Soaspec::SpecLogger.add_to("oauth_error: #{e.response}") - 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 - - # User used in making API calls - attr_accessor :api_username - - # 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, :api_username) - set_remove_key(options, :default_hash) - @init_options = options - 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 - - # Initialize value of merged options - def init_merge_options - options = rest_resource_options - options[:headers] ||= {} - options[:headers].merge! parse_headers - options.merge(@init_options) - 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) - @merged_options ||= init_merge_options - 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 - - # 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 = 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 [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 Interpreter.response_type_for(response) == :xml - xpath = "//*[@#{attribute}]" unless attribute.nil? - if xpath[0] != '/' - xpath = convert_to_pascal_case(xpath) if pascal_keys? - xpath = '//' + xpath - end - if Soaspec.strip_namespaces? && !xpath.include?(':') - temp_doc = Nokogiri.parse response.body - temp_doc.remove_namespaces! - temp_doc.xpath(xpath) - else - Nokogiri.parse(response.body).xpath(xpath) - 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 - if path[0] != '$' - path = convert_to_pascal_case(path) if pascal_keys? - path = '$..' + path - end - JsonPath.on(response.body, path) - 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 - result = xpath_elements_for(response: response, xpath: path, attribute: attribute).first - raise NoElementAtPath, "No value at Xpath '#{path}'" unless result - return result.inner_text if attribute.nil? - return result.attributes[attribute].inner_text - when :json - matching_values = json_path_values_for(response, path, attribute: attribute) - 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 - - # @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 - json_path_values_for(response, path, attribute: attribute) - else - raise "Unable to interpret type of #{response.body}" - 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 + +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 = api_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 + Soaspec::SpecLogger.add_to("oauth_error: #{e.message}") + Soaspec::SpecLogger.add_to("oauth_error: #{e.response}") + 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 + + # User used in making API calls + attr_accessor :api_username + + # 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, :api_username) + set_remove_key(options, :default_hash) + @init_options = options + 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 + + # Initialize value of merged options + def init_merge_options + options = rest_resource_options + options[:headers] ||= {} + options[:headers].merge! parse_headers + options.merge(@init_options) + 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) + @merged_options ||= init_merge_options + 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 + + # 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 = 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 [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 Interpreter.response_type_for(response) == :xml + xpath = "//*[@#{attribute}]" unless attribute.nil? + if xpath[0] != '/' + xpath = convert_to_pascal_case(xpath) if pascal_keys? + xpath = '//' + xpath + end + temp_doc = Nokogiri.parse(response.body).dup + if Soaspec.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 + if path[0] != '$' + path = convert_to_pascal_case(path) if pascal_keys? + path = '$..' + path + end + JsonPath.on(response.body, path) + 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 + result = xpath_elements_for(response: response, xpath: path, attribute: attribute).first + raise NoElementAtPath, "No value at Xpath '#{path}'" unless result + return result.inner_text if attribute.nil? + return result.attributes[attribute].inner_text + when :json + matching_values = json_path_values_for(response, path, attribute: attribute) + 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 + + # @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 + json_path_values_for(response, path, attribute: attribute) + else + raise "Unable to interpret type of #{response.body}" + 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 \ No newline at end of file