lib/soaspec/exchange_handlers/rest_handler.rb in soaspec-0.1.6 vs lib/soaspec/exchange_handlers/rest_handler.rb in soaspec-0.1.7
- old
+ new
@@ -1,315 +1,319 @@
-require_relative 'exchange_handler'
-require_relative 'rest_accessors'
-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
-
- # 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)
- set_remove_key(options, :template_name)
- @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
-
- # 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 [q] Query for REST
- # @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
- Soaspec::SpecLogger.info("request body: #{post_data(test_values)}")
- @resource_used.send(test_values[:method].to_s, post_data(test_values), test_values[:params])
- else # :get, :delete
- @resource_used.send(test_values[:method].to_s, test_values[:params])
- end
- rescue RestClient::ExceptionWithResponse => e
- response = e.response
- end
- Soaspec::SpecLogger.info('response_headers: ' + response.headers.to_s)
- Soaspec::SpecLogger.info('response_body: ' + response.to_s)
- response
- 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
-
- # 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 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
- paths_to_check = path.split(',')
- 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, "Path '#{path}' not found in '#{response.body}'" if matching_values.empty?
- matching_values.first.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
-
- # @return [Hash] Hash representing response body
- def to_hash(response)
- case Interpreter.response_type_for(response)
- when :xml
- parser = Nori.new(strip_namespaces: strip_namespaces?, convert_tags_to: ->(tag) { tag.snakecase.to_sym })
- parser.parse(response.body.to_s)
- when :json
- JSON.parse(response.body.to_s)
- else
- raise "Unable to interpret type of #{response.body}"
- end
- end
-
- private
-
- # Work out data to send based upon payload, template_name
- # @return [String] Payload to send in REST request
- def post_data(test_values)
- if test_values[:body]
- test_values[:payload] = JSON.generate(hash_used_in_request(test_values[:body])).to_s
- elsif @request_option == :template
- request_body = File.read(File.join(Soaspec.template_folder, template_name))
- ERB.new(request_body).result(binding)
- else
- test_values[:payload]
- end
- end
-
- # @return [Hash] Hash used in REST request based on data conversion
- 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
-
- # 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 'rest_accessors'
+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
+
+ # 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)
+ set_remove_key(options, :template_name)
+ @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
+
+ # Initialize value of merged options
+ # @return [Hash] Hash of merged options
+ def init_merge_options
+ options = rest_resource_options
+ options[:headers] ||= {}
+ options[:headers].merge! parse_headers
+ if Soaspec.auto_oauth && respond_to?(:access_token)
+ options[:headers][:authorization] ||= ERB.new('Bearer <%= access_token %>').result(binding)
+ end
+ 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 [q] Query for REST
+ # @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
+ Soaspec::SpecLogger.info("request body: #{post_data(test_values)}")
+ @resource_used.send(test_values[:method].to_s, post_data(test_values), test_values[:params])
+ else # :get, :delete
+ @resource_used.send(test_values[:method].to_s, test_values[:params])
+ end
+ rescue RestClient::ExceptionWithResponse => e
+ response = e.response
+ end
+ Soaspec::SpecLogger.info('response_headers: ' + response.headers.to_s)
+ Soaspec::SpecLogger.info('response_body: ' + response.to_s)
+ response
+ 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
+
+ # 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 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
+ paths_to_check = path.split(',')
+ 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, "Path '#{path}' not found in '#{response.body}'" if matching_values.empty?
+ matching_values.first.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
+
+ # @return [Hash] Hash representing response body
+ def to_hash(response)
+ case Interpreter.response_type_for(response)
+ when :xml
+ parser = Nori.new(strip_namespaces: strip_namespaces?, convert_tags_to: ->(tag) { tag.snakecase.to_sym })
+ parser.parse(response.body.to_s)
+ when :json
+ JSON.parse(response.body.to_s)
+ else
+ raise "Unable to interpret type of #{response.body}"
+ end
+ end
+
+ private
+
+ # Work out data to send based upon payload, template_name
+ # @return [String] Payload to send in REST request
+ def post_data(test_values)
+ if test_values[:body]
+ test_values[:payload] = JSON.generate(hash_used_in_request(test_values[:body])).to_s
+ elsif @request_option == :template
+ request_body = File.read(File.join(Soaspec.template_folder, template_name))
+ ERB.new(request_body).result(binding)
+ else
+ test_values[:payload]
+ end
+ end
+
+ # @return [Hash] Hash used in REST request based on data conversion
+ 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
+
+ # 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