require 'httparty' require 'pp' require 'json' require 'active_support/core_ext' require 'money' require 'money_extensions/money_field' require 'quick_travel/config' require 'quick_travel/adapter_exception' require 'quick_travel/init_from_hash' module QuickTravel class Adapter include HTTParty include QuickTravel::InitFromHash include MoneyField class_attribute :api_base, :lookup # In case of object_key_name, we need to extract each record by a key # # Example: # [ # { :passenger_type => {"position"=>1, "name"=>"Adult", "maximum_age"=>30, "id"=>1} }, # { :passenger_type => {"position"=>2, "name"=>"Child", "maximum_age"=>15, "id"=>2} } # ] class_attribute :object_key # Key of sub-objects (i.e. convert under a key) def initialize(hash = {}) return nil if hash.blank? super(Parser.new(hash).parsed_attributes) end def self.find(id, opts = {}) if lookup all.detect { |o| o.id == id.to_i } else find_all!("#{api_base}/#{id}.json", opts).first end end def self.all(opts = {}) if lookup QuickTravel::Cache.cache("#{name}.all") { find_all!("#{api_base}.json", opts) } else find_all!("#{api_base}.json", opts) end end def self.create(options = {}) post_and_validate("#{api_base}.json", options) end def self.update(id, options = {}) put_and_validate("#{api_base}/#{id}.json", options) end def self.qt_date_format_conversion(str) qt_date_format_conversion!(str) rescue ArgumentError end def self.qt_date_format_conversion!(str) Date.strptime(str, '%d/%m/%Y').strftime('%d-%m-%Y') end def to_hash instance_values end protected # Find first def self.generic_first(request_path, opts = {}) find_all!(request_path, opts).first end def self.find_all!(request_path, opts = {}) response = get_and_validate(request_path, opts, return_response_object: true) deserializer = Deserializer.new(response.parsed_response) objects = Array.wrap(deserializer.extract_under_root(self)) if response.headers['pagination'].present? pagination_headers = ::JSON.parse(response.headers['pagination']) WillPaginate::Collection.create(pagination_headers['current_page'], pagination_headers['per_page'], pagination_headers['total_entries']) do |pager| pager.replace(objects) end else objects end end class Deserializer def initialize(data) @data = data end # Extract the 'root' -- the data must be a direct collection of objects # # opts[:object_key_name] => inside each hash extract attributes under a subkey (per object) def extract_under_root(klass, opts = {}) self.class.extract(@data, klass, opts) end # Extract under a specified key in the data def extract(key, klass, opts = {}) collection_data = @data[key] fail "No collection key [#{key}] found in data from API" if collection_data.blank? self.class.extract(collection_data, klass, opts) end def self.extract(objects, klass, opts = {}) objects = Array.wrap(objects) objects.map! { |item| item[opts[:object_key_name]] } if opts[:object_key_name] objects.map! { |item| klass.new(item) } # if opts[:page] && opts[:per_page] # objects = WillPaginate::Collection.create(opts[:page], opts[:per_page], opts).replace(objects) objects end end # The above generic_first find_all and the above find # all suck # # We should make it standard behaviour that objects # instantiate on get... # # Also, we should combine the two methods (get and validate) automatically... # # -> I found one - we can set custom parser() # but that won't have access to response code only the body # # Better idea: # Just override get, put, post and delete? # If you need the originals, alias as raw.get() ? # Seems to solve both issues... def get_and_validate(path, query = {}, opts = {}) self.class.get_and_validate(path, query, opts) end def post_and_validate(path, query = {}, opts = {}) self.class.post_and_validate(path, query, opts) end def put_and_validate(path, query = {}, opts = {}) self.class.put_and_validate(path, query, opts) end def delete_and_validate(path, query = {}, opts = {}) self.class.delete_and_validate(path, query, opts) end def self.get_and_validate(path, query = {}, opts = {}) call_and_validate(:get, path, query, opts) end def self.post_and_validate(path, query = {}, opts = {}) call_and_validate(:post, path, query, opts) end def self.put_and_validate(path, query = {}, opts = {}) call_and_validate(:put, path, query, opts) end def self.delete_and_validate(path, query = {}, opts = {}) call_and_validate(:delete, path, query, opts) end def self.call_and_validate(http_method, path, query = {}, opts = {}) http_params = opts.clone return_response_object = http_params.delete(:return_response_object) klass = self # Set default token http_params[:query] ||= query http_params[:headers] ||= {} http_params[:headers]['Content-length'] = '0' if http_params[:body].blank? expect = http_params.delete(:expect) # Use :body instead of :query for put/post. # # Causes webrick to give back error - -maybe other servers too. http_params[:body] ||= {} if [:put, :post].include?(http_method.to_sym) http_params[:body].merge!(http_params.delete(:query)) end http_params[:body][:access_key] = QuickTravel.config.access_key http_params[:follow_redirects] = false begin response = klass.send(http_method, path, http_params) rescue Errno::ECONNREFUSED raise ConnectionError.new('Connection refused') rescue SocketError raise ConnectionError.new('Socket error') rescue Timeout::Error raise ConnectionError.new('Timeout error') end if expect && expect == :json && !response.is_a?(Hash) fail AdapterException, <<-FAIL Request expected to be json but failed. Debug information below: http_method: #{http_method.inspect} path: #{path.inspect} http_params: #{http_params.inspect} response object: #{response.inspect} parsed_response: #{response.parsed_response.inspect} FAIL end klass.validate!(response) if return_response_object response else response.parsed_response end end # Do standard validations on response # # Firstly, check if a valid HTTP code was returned # Secondly, check for presence of "error" key in returned hash def self.validate!(response) case response.code when 300..399 # redirects fail ConnectionError.new('We were redirected. QT YML configuration appears to be incorrect. Verify your URL and API.') when 400..599 # client and server errors fail AdapterException.new(response) end if response_contains_error?(response) fail AdapterException, response end end def self.response_contains_error?(response) parsed_response = response.parsed_response parsed_response.is_a?(Hash) && parsed_response.key?('error') end end class Parser def initialize(attributes) @attributes = attributes end def attributes @attributes ||= {} end def parsed_attributes @parsed_attributes ||= parse_attributes end private def parse_attributes attributes.map.with_object({}) do |(attribute, value), hash| hash[attribute] = parse(attribute, value) end end def parse(attribute, value) return nil if value.nil? return convert(value, :to_date) if attribute.to_s.ends_with?('_date') # to_datetime as it converts to app time zone, to_time converts to system time zone return convert(value, :to_datetime) if attribute.to_s.ends_with?('_time') return convert(value, :to_datetime) if attribute.to_s.ends_with?('_at') value end def convert(value, conversion_method) convertable_value = value.is_a?(Hash) ? value['_value'] : value convertable_value.send(conversion_method) end end end