diff --git a/lib/airtable/httparty_patched_hash_conversions.rb b/lib/airtable/httparty_patched_hash_conversions.rb new file mode 100644 index 0000000..9d779df --- /dev/null +++ b/lib/airtable/httparty_patched_hash_conversions.rb @@ -0,0 +1,49 @@ +module HTTParty + module PatchedHashConversions + # @return This hash as a query string + # + # @example + # { name: "Bob", + # address: { + # street: '111 Ruby Ave.', + # city: 'Ruby Central', + # phones: ['111-111-1111', '222-222-2222'] + # } + # }.to_params + # #=> "name=Bob&address[city]=Ruby Central&address[phones][0]=111-111-1111&address[phones][1]=222-222-2222&address[street]=111 Ruby Ave." + def self.to_params(hash) + hash.to_hash.map { |k, v| normalize_param(k, v) }.join.chop + end + + # @param key The key for the param. + # @param value The value for the param. + # + # @return This key value pair as a param + # + # @example normalize_param(:name, "Bob Jones") #=> "name=Bob%20Jones&" + def self.normalize_param(key, value) + param = '' + stack = [] + + if value.respond_to?(:to_ary) + param << value.to_ary.each_with_index.map { |element, index| normalize_param("#{key}[#{index}]", element) }.join + elsif value.respond_to?(:to_hash) + stack << [key, value.to_hash] + else + param << "#{key}=#{ERB::Util.url_encode(value.to_s)}&" + end + + stack.each do |parent, hash| + hash.each do |k, v| + if v.respond_to?(:to_hash) + stack << ["#{parent}[#{k}]", v.to_hash] + else + param << normalize_param("#{parent}[#{k}]", v) + end + end + end + + param + end + end +end diff --git a/lib/airtable/resource.rb b/lib/airtable/resource.rb index 26ae69a..20e0e6f 100644 --- a/lib/airtable/resource.rb +++ b/lib/airtable/resource.rb @@ -1,12 +1,18 @@ +require 'airtable/httparty_patched_hash_conversions' + module Airtable # Base class for authorized resources sending network requests class Resource include HTTParty - base_uri 'https://api.airtable.com/v0/' + base_uri (ENV['AIRTABLE_ENDPOINT_URL'] || 'https://api.airtable.com/') + 'v0/' # debug_output $stdout attr_reader :api_key, :app_token, :worksheet_name + query_string_normalizer(proc do |query| + HTTParty::PatchedHashConversions.to_params(query) + end) + def initialize(api_key, app_token, worksheet_name) @api_key = api_key @app_token = app_token diff --git a/lib/airtable/table.rb b/lib/airtable/table.rb index 2205db8..0ea9712 100644 --- a/lib/airtable/table.rb +++ b/lib/airtable/table.rb @@ -20,10 +20,25 @@ def all(options={}) # Fetch records from the sheet given the list options # Options: limit = 100, offset = "as345g", sort = ["Name", "asc"] + # sort could be an array # records(:sort => ["Name", :desc], :limit => 50, :offset => "as345g") def records(options={}) - options["sortField"], options["sortDirection"] = options.delete(:sort) if options[:sort] - results = self.class.get(worksheet_url, query: options).parsed_response + update_sort_options!(options) + raw_response = self.class.get(worksheet_url, query: options) + case raw_response.code + when 200 + # ok + when 422 + results = raw_response.parsed_response + check_and_raise_error(results) + raise Error.new('Invalid request') + when 503 + raise Error.new('Service not available') + when 500...600 + puts "Server error #{response.code}" + raise Error.new('Server error please contact support@airtable.com') + end + results = raw_response.parsed_response check_and_raise_error(results) RecordSet.new(results) end @@ -34,7 +49,7 @@ def records(options={}) # # select(limit: 10, sort: ["Name", "asc"], formula: "Order < 2") def select(options={}) - options['sortField'], options['sortDirection'] = options.delete(:sort) if options[:sort] + update_sort_options!(options) options['maxRecords'] = options.delete(:limit) if options[:limit] if options[:formula] @@ -47,6 +62,32 @@ def select(options={}) RecordSet.new(results) end + def update_sort_options!(options) + sortOption = options.delete(:sort) || options.delete('sort') + if sortOption && sortOption.is_a?(Array) + if sortOption.length > 0 + if sortOption[0].is_a? String + singleSortField, singleSortDirection = sortOption + options["sort"] = [{field: singleSortField, direction: singleSortDirection}] + elsif sortOption[0].is_a?(Hash) + options["sort"] = sortOption.to_a + elsif sortOption.is_a?(Array) && sortOption[0].is_a?(Array) + options["sort"] = sortOption.map {|(sortField, sortDirection)| {field: sortField, direction: sortDirection} } + else + raise ArgumentError.new("Unknown sort options format.") + end + end + elsif sortOption + options["sort"] = sortOption + end + + if options["sort"] + # standardize the sort spec + options["sort"] = options["sort"].map {|sortSpec| {field: sortSpec[:field].to_s, direction: sortSpec[:direction].to_s.downcase} } + raise ArgumentError.new("Unknown sort direction") unless options["sort"].all? {|sortObj| ['asc', 'desc'].include? sortObj[:direction]} + end + end + def raise_bad_formula_error raise ArgumentError.new("The value for filter should be a String.") end diff --git a/test/airtable_test.rb b/test/airtable_test.rb index be443e7..8c4f704 100644 --- a/test/airtable_test.rb +++ b/test/airtable_test.rb @@ -32,6 +32,13 @@ assert_equal @select_records.records, [] end + it "should sort by multiple pairs" do + stub_airtable_response!("https://api.airtable.com/v0/#{@app_key}/#{@sheet_name}?sort[0][field]=foo&sort[0][direction]=asc&sort[1][field]=bar&sort[1][direction]=desc", { "records" => [], "offset" => "abcde" }) + @table = Airtable::Client.new(@client_key).table(@app_key, @sheet_name) + @select_records = @table.select(sort: [['foo', 'asc'], ['bar', 'desc']]) + assert_equal @select_records.records, [] + end + it "should raise an ArgumentError if a formula is not a string" do stub_airtable_response!("https://api.airtable.com/v0/#{@app_key}/#{@sheet_name}", { "records" => [], "offset" => "abcde" }) @table = Airtable::Client.new(@client_key).table(@app_key, @sheet_name) diff --git a/test/test_helper.rb b/test/test_helper.rb index c0e9338..c5f0371 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -4,11 +4,10 @@ require 'minitest/autorun' def stub_airtable_response!(url, response, method=:get, status=200) - stub_request(method, url) .to_return( body: response.to_json, status: status, - headers: { 'Content-Type' => "application/json"} + headers: { 'Content-Type' => 'application/json' } ) end