lib/soaspec/exchange_handlers/rest_handler.rb in soaspec-0.2.24 vs lib/soaspec/exchange_handlers/rest_handler.rb in soaspec-0.2.25

- old
+ new

@@ -1,259 +1,287 @@ -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 '../not_found_errors' -require_relative 'handler_accessors' -require_relative '../interpreter' -require_relative 'response_extractor' -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 - init_merge_options # Call this to verify any issues with options on creating object - 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) - @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}", "response_body: #{response}"]) - response - 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 - # @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) - 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 Interpreter.response_type_for(response) == :xml - - 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 - 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] Request of API call. Either intended request or actual request - def request(response) - return 'Request not yet sent' if response.nil? - - response.request - end - - private - - # Work out data to send based upon payload, template_name, or body - # @return [String] Payload to send in REST request - def post_data(test_values) - data = if @request_option == :hash && !test_values[:payload] - test_values[:payload] = JSON.generate(hash_used_in_request(test_values[:body])).to_s - elsif @request_option == :template - test_values = test_values[:body].dup if test_values[:body] - test_values = IndifferentHash.new(test_values) # Allow test_values to be either Symbol or String - Soaspec::TemplateReader.new.render_body(template_name, binding) - else - test_values[:payload] - end - # Soaspec::SpecLogger.info "Request Empty for '#{@request_option}'" if data.strip.empty? - data - end - - # @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 - end -end +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 '../not_found_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 + init_merge_options # Call this to verify any issues with options on creating object + end + + # @return [Boolean] Whether REST method should have a payload + def payload?(overall_params) + case overall_params[:method] + when :post, :patch, :put + true + else + false + end + end + + # @todo Use this in actually making the request + # @return [RestRequest] Parameters used in making a request + def request_parameters(override_parameters) + overall_params = interpret_parameters(override_parameters) + request = { overall: overall_params, options: init_merge_options } + request[:body] = post_data(overall_params) if payload?(overall_params) + RestRequest.new(overall_params, init_merge_options, payload?(overall_params) ? post_data(overall_params) : nil) + end + + # Interpret REST parameters given provided parameters and adding defaults, making + # transformations + # + # @param [Hash] request_parameters Parameters used in making a request + # @return [Hash] Request parameters merged with default values + def interpret_parameters(request_parameters) + request_parameters[:params] ||= {} + request_parameters[:method] ||= :post + request_parameters[:suburl] = request_parameters[:suburl].to_s if request_parameters[:suburl] + # Use q for query parameters. Nested :params is ugly, long and unclear + request_parameters[:params][:params] = request_parameters[:q] if request_parameters[:q] + request_parameters + 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) + @merged_options ||= init_merge_options # TODO: Is this var needed? Can method be passed to resource creation? + test_values = interpret_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 = 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}", "response_body: #{response}"]) + response + 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 + # @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) + 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 Interpreter.response_type_for(response) == :xml + + 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 + 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] Request of API call. Either intended request or actual request + def request(response) + return 'Request not yet sent' if response.nil? + + response.request + end + + # Work out data to send based upon payload, template_name, or body + # @return [String] Payload to send in REST request + def post_data(test_values) + data = if @request_option == :hash && !test_values[:payload] + test_values[:payload] = JSON.generate(hash_used_in_request(test_values[:body])).to_s + elsif @request_option == :template + test_values = test_values[:body].dup if test_values[:body] + test_values = IndifferentHash.new(test_values) # Allow test_values to be either Symbol or String + Soaspec::TemplateReader.new.render_body(template_name, binding) + else + test_values[:payload] + end + # Soaspec::SpecLogger.info "Request Empty for '#{@request_option}'" if data.strip.empty? + data + 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 + end +end