From f74138171cbe939ed4e63512e6a8985cb6f45136 Mon Sep 17 00:00:00 2001 From: Alexander Simonov Date: Tue, 12 Dec 2017 13:42:40 +0200 Subject: [PATCH 01/11] Refactoring started. Docker support added. Rspec support added. --- .gitignore | 3 + .rspec | 1 + Dockerfile | 5 + Rakefile | 20 +++- airtable.gemspec | 10 +- docker-compose.yml | 15 +++ lib/airtable.rb | 47 ++++++++-- lib/airtable/base.rb | 12 +++ lib/airtable/client.rb | 15 ++- lib/airtable/error.rb | 11 +++ lib/airtable/request.rb | 65 +++++++++++++ lib/airtable/response.rb | 26 ++++++ lib/airtable/table.rb | 154 ++++++++++--------------------- lib/airtable/table_old.rb | 123 ++++++++++++++++++++++++ lib/airtable/version.rb | 2 +- spec/lib/airtable/client_spec.rb | 40 ++++++++ spec/lib/airtable_spec.rb | 45 +++++++++ spec/spec_helper.rb | 84 +++++++++++++++++ 18 files changed, 546 insertions(+), 132 deletions(-) create mode 100644 .rspec create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 lib/airtable/base.rb create mode 100644 lib/airtable/request.rb create mode 100644 lib/airtable/response.rb create mode 100644 lib/airtable/table_old.rb create mode 100644 spec/lib/airtable/client_spec.rb create mode 100644 spec/lib/airtable_spec.rb create mode 100644 spec/spec_helper.rb diff --git a/.gitignore b/.gitignore index 90d2f3a..4b2e429 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ tmp *.a mkmf.log .DS_Store +.idea +.env.docker +*.log diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..c99d2e7 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..595b444 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM ruby:2.4.2 + +ENV LIB_HOME /home/lib + +WORKDIR $LIB_HOME diff --git a/Rakefile b/Rakefile index d30a219..0bc3d2c 100644 --- a/Rakefile +++ b/Rakefile @@ -1,7 +1,17 @@ -require "bundler/gem_tasks" -require 'rake/testtask' +require 'bundler' +Bundler.setup +Bundler::GemHelper.install_tasks -Rake::TestTask.new do |t| - t.libs << 'test' - t.pattern = "test/*_test.rb" +require 'rspec/core/rake_task' + +desc 'Run all tests' +RSpec::Core::RakeTask.new(:spec) do |t| + t.ruby_opts = %w[-w] +end + +desc 'Run RuboCop on the lib directory' +task :rubocop do + sh 'bundle exec rubocop lib' end + +task :default => [:spec, :rubocop] diff --git a/airtable.gemspec b/airtable.gemspec index ae7e853..cf035dd 100644 --- a/airtable.gemspec +++ b/airtable.gemspec @@ -18,11 +18,11 @@ Gem::Specification.new do |spec| spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ["lib"] - spec.add_dependency "httparty", "~> 0.14.0" - spec.add_dependency "activesupport", ">= 3.0" - spec.add_development_dependency "bundler", "~> 1.6" spec.add_development_dependency "rake" - spec.add_development_dependency "minitest", "~> 5.6.0" - spec.add_development_dependency "webmock", "~> 2.1.0" + spec.add_development_dependency "rspec", "~> 3.7" + spec.add_development_dependency "vcr", "~> 4.0" + spec.add_development_dependency "webmock", "~> 3.1" + spec.add_development_dependency "rubocop", "~> 0.51" + spec.add_development_dependency "simplecov", "~> 0.15" end diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..59d9b9e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +version: '2.1' +volumes: + app-gems: + driver: local +services: + lib: + build: + context: . + dockerfile: Dockerfile + env_file: + - .env.docker + volumes: + - app-gems:/usr/local/bundle + - .:/home/lib + diff --git a/lib/airtable.rb b/lib/airtable.rb index 52b7858..a51ef3c 100644 --- a/lib/airtable.rb +++ b/lib/airtable.rb @@ -1,11 +1,40 @@ -require 'httparty' -require 'delegate' -require 'active_support/core_ext/hash' - -require 'airtable/version' -require 'airtable/resource' -require 'airtable/record' -require 'airtable/record_set' -require 'airtable/table' +require 'logger' + +module Airtable + class << self + DEFAULT_URL = 'https://api.airtable.com/v0'.freeze + attr_writer :log_path + + def debug? + !ENV['DEBUG'].nil? + end + + def log_path + @log_path ||= 'airtable.log' + end + + def logger + @logger ||= ::Logger.new(@log_path) + end + + def server_url + @server_url ||= ENV.fetch('AIRTABLE_ENDPOINT_URL') { DEFAULT_URL } + end + + def reset! + @log_path = nil + @logger = nil + @server_url = nil + end + end +end + +require 'airtable/base' require 'airtable/client' +require 'airtable/table' require 'airtable/error' + + +#require 'airtable/resource' +#require 'airtable/record' +#require 'airtable/record_set' diff --git a/lib/airtable/base.rb b/lib/airtable/base.rb new file mode 100644 index 0000000..4d9e13a --- /dev/null +++ b/lib/airtable/base.rb @@ -0,0 +1,12 @@ +module Airtable + class Base + def initialize(id, client) + @id = id + @client = client + end + + def table(name) + ::Airtable::Table.new(name, @id, @client) + end + end +end diff --git a/lib/airtable/client.rb b/lib/airtable/client.rb index 159366a..f2f6904 100644 --- a/lib/airtable/client.rb +++ b/lib/airtable/client.rb @@ -8,13 +8,18 @@ module Airtable class Client - def initialize(api_key) - @api_key = api_key + def initialize(api_key = nil) + @api_key = api_key || ENV['AIRTABLE_KEY'] + raise ::Airtable::MissingApiKeyError.new if @api_key.nil? || @api_key.empty? end - # table("appXXV84QuCy2BPgLk", "Sheet Name") - def table(app_token, worksheet_name) - Table.new(@api_key, app_token, worksheet_name) + def base(id) + ::Airtable::Base.new(id, self) end + + # table("appXXV84QuCy2BPgLk", "Sheet Name") + # def table(app_token, worksheet_name) + # Table.new(@api_key, app_token, worksheet_name) + # end end # Client end # Airtable \ No newline at end of file diff --git a/lib/airtable/error.rb b/lib/airtable/error.rb index d81872e..23ae186 100644 --- a/lib/airtable/error.rb +++ b/lib/airtable/error.rb @@ -10,6 +10,17 @@ def initialize(error_hash) @type = error_hash['type'] super(@message) end + end + class SortOptionsError < ::ArgumentError + def initialize + super('Unknown sort options format.') + end + end + + class MissingApiKeyError < ::ArgumentError + def initialize + super('Missing API key') + end end end diff --git a/lib/airtable/request.rb b/lib/airtable/request.rb new file mode 100644 index 0000000..871c243 --- /dev/null +++ b/lib/airtable/request.rb @@ -0,0 +1,65 @@ +require 'net/http' +require 'net/https' + +module Airtable + class Request + attr_accessor :url, :body, :headers, :http + + def initialize(url, body, token) + @url = URI(url) + @body = body + @headers = { + 'Authorization' => "Bearer #{token}", + 'Content-Type' => 'application/json', + } + end + + def request(type = :get) + @http = setup_http + request = setup_request(type) + setup_headers(request) + ::Airtable::Response.new(http.request(request)) + end + + def setup_http + http = ::Net::HTTP.new(url.host, url.port) + http.use_ssl = true + http + end + + def setup_get_request + request = ::Net::HTTP::Get.new(url.path) + request.set_form_data(body) + ::Net::HTTP::Get.new(url.path + '?' + request.body) + end + + def setup_post_request + request = ::Net::HTTP::Post.new(url.path) + request.body = body + request + end + + def setup_put_request + request = ::Net::HTTP::Put.new(url.path) + request.body = body.to_json + request + end + + def setup_request(type) + case type + when :get + setup_get_request + when :post + setup_post_request + when :put + setup_put_request + end + end + + def setup_headers(request) + headers.each do |name, value| + request[name] = value + end + end + end +end \ No newline at end of file diff --git a/lib/airtable/response.rb b/lib/airtable/response.rb new file mode 100644 index 0000000..f193012 --- /dev/null +++ b/lib/airtable/response.rb @@ -0,0 +1,26 @@ +module Airtable + class Response + attr_accessor :raw, :result + + def initialize(raw_response) + @raw = raw_response + begin + @result = ::JSON.parse(raw.body) + ::Airtable.logger.info "Response: #{@result}" + @success = @raw.code.to_i == 200 + rescue + @success = false + ::Airtable.logger.info "ERROR Response: #{raw.body}" + @result = { 'detail' => raw.body } if @result.blank? + end + end + + def success? + @success + end + + def rate_limited? + @raw.code.to_i == 429 + end + end +end \ No newline at end of file diff --git a/lib/airtable/table.rb b/lib/airtable/table.rb index 82dc903..545ec53 100644 --- a/lib/airtable/table.rb +++ b/lib/airtable/table.rb @@ -1,123 +1,63 @@ module Airtable + class Table + PAGE_SIZE = 100.freeze + DEFAULT_DIRECTION = 'asc'.freeze - class Table < Resource - # Maximum results per request - LIMIT_MAX = 100 - - # Fetch all records iterating through offsets until retrieving the entire collection - # all(:sort => ["Name", :desc]) - def all(options={}) - offset = nil - results = [] - begin - options.merge!(:limit => LIMIT_MAX, :offset => offset) - response = records(options) - results += response.records - offset = response.offset - end until offset.nil? || offset.empty? || results.empty? - results - end - - # Fetch records from the sheet given the list options - # Options: limit = 100, offset = "as345g", sort = ["Name", "asc"] - # 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 - check_and_raise_error(results) - RecordSet.new(results) - end - - # Query for records using a string formula - # Options: limit = 100, offset = "as345g", sort = ["Name", "asc"], - # fields = [Name, Email], formula = "Count > 5", view = "Main View" - # - # select(limit: 10, sort: ["Name", "asc"], formula: "Order < 2") - def select(options={}) - options['sortField'], options['sortDirection'] = options.delete(:sort) if options[:sort] - options['maxRecords'] = options.delete(:limit) if options[:limit] - - if options[:formula] - raise_bad_formula_error unless options[:formula].is_a? String - options['filterByFormula'] = options.delete(:formula) - end - - results = self.class.get(worksheet_url, query: options).parsed_response - check_and_raise_error(results) - RecordSet.new(results) - end - - def raise_bad_formula_error - raise ArgumentError.new("The value for filter should be a String.") - end - - # Returns record based given row id - def find(id) - result = self.class.get(worksheet_url + "/" + id).parsed_response - check_and_raise_error(result) - Record.new(result_attributes(result)) if result.present? && result["id"] - end - - # Creates a record by posting to airtable - def create(record) - result = self.class.post(worksheet_url, - :body => { "fields" => record.fields }.to_json, - :headers => { "Content-type" => "application/json" }).parsed_response - - check_and_raise_error(result) - - record.override_attributes!(result_attributes(result)) - record + def initialize(name, base_id, client) + @name = name + @base_id = base_id + @client = client end - # Replaces record in airtable based on id - def update(record) - result = self.class.put(worksheet_url + "/" + record.id, - :body => { "fields" => record.fields_for_update }.to_json, - :headers => { "Content-type" => "application/json" }).parsed_response - - check_and_raise_error(result) - - record.override_attributes!(result_attributes(result)) - record - + def records(options = {}) + params = {} + params[:fields] = option_value_for(options, :fields) + params[:maxRecords] = option_value_for(options, :max_records) + params[:pageSize] = option_value_for(options, :limit) || PAGE_SIZE + update_sort_options(params, options) end - def update_record_fields(record_id, fields_for_update) - result = self.class.patch(worksheet_url + "/" + record_id, - :body => { "fields" => fields_for_update }.to_json, - :headers => { "Content-type" => "application/json" }).parsed_response - - check_and_raise_error(result) - - Record.new(result_attributes(result)) + def option_value_for(hash, key) + hash.delete(key) || hash.delete(key.to_s) end - # Deletes record in table based on id - def destroy(id) - self.class.delete(worksheet_url + "/" + id).parsed_response + def update_sort_options(params, options) + sort_option = option_value_for(options, :sort) + case sort_option + when ::Array + raise ::Airtable::SortOptionsError if sort_option.size == 0 + when ::Hash + add_hash_sort_option(params, sort_option) + when ::String + add_string_sort_option(params, sort_option) + end end - protected - - def check_and_raise_error(response) - response['error'] ? raise(Error.new(response['error'])) : false + def add_string_sort_options(params, string) + raise ::Airtable::SortOptionsError if string.nil? || string.empty? + params[:sort] ||= [] + params[:sort] << { field: string, direction: DEFAULT_DIRECTION } end - def result_attributes(res) - res["fields"].merge("id" => res["id"]) if res.present? && res["id"] + def add_hash_sort_option(params, hash) + raise ::Airtable::SortOptionsError if hash.keys.map(&:to_sym).sort == [:direction, :field] + params[:sort] ||= [] + params[:sort] << hash end - def worksheet_url - "/#{app_token}/#{url_encode(worksheet_name)}" - end + def add_sort_options(params, sort_option) + case sort_option + when ::Array + if sort_option.size == 2 + params[:sort] ||= [] + params[:sort] << { field: sort_option[0], direction: sort_option[1] } + else + raise ::Airtable::SortOptionsError.new + end + when ::Hash + when ::String - # From http://apidock.com/ruby/ERB/Util/url_encode - def url_encode(s) - s.to_s.dup.force_encoding("ASCII-8BIT").gsub(/[^a-zA-Z0-9_\-.]/) { - sprintf("%%%02X", $&.unpack("C")[0]) - } + end end - end # Table - -end # Airtable + end +end diff --git a/lib/airtable/table_old.rb b/lib/airtable/table_old.rb new file mode 100644 index 0000000..82dc903 --- /dev/null +++ b/lib/airtable/table_old.rb @@ -0,0 +1,123 @@ +module Airtable + + class Table < Resource + # Maximum results per request + LIMIT_MAX = 100 + + # Fetch all records iterating through offsets until retrieving the entire collection + # all(:sort => ["Name", :desc]) + def all(options={}) + offset = nil + results = [] + begin + options.merge!(:limit => LIMIT_MAX, :offset => offset) + response = records(options) + results += response.records + offset = response.offset + end until offset.nil? || offset.empty? || results.empty? + results + end + + # Fetch records from the sheet given the list options + # Options: limit = 100, offset = "as345g", sort = ["Name", "asc"] + # 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 + check_and_raise_error(results) + RecordSet.new(results) + end + + # Query for records using a string formula + # Options: limit = 100, offset = "as345g", sort = ["Name", "asc"], + # fields = [Name, Email], formula = "Count > 5", view = "Main View" + # + # select(limit: 10, sort: ["Name", "asc"], formula: "Order < 2") + def select(options={}) + options['sortField'], options['sortDirection'] = options.delete(:sort) if options[:sort] + options['maxRecords'] = options.delete(:limit) if options[:limit] + + if options[:formula] + raise_bad_formula_error unless options[:formula].is_a? String + options['filterByFormula'] = options.delete(:formula) + end + + results = self.class.get(worksheet_url, query: options).parsed_response + check_and_raise_error(results) + RecordSet.new(results) + end + + def raise_bad_formula_error + raise ArgumentError.new("The value for filter should be a String.") + end + + # Returns record based given row id + def find(id) + result = self.class.get(worksheet_url + "/" + id).parsed_response + check_and_raise_error(result) + Record.new(result_attributes(result)) if result.present? && result["id"] + end + + # Creates a record by posting to airtable + def create(record) + result = self.class.post(worksheet_url, + :body => { "fields" => record.fields }.to_json, + :headers => { "Content-type" => "application/json" }).parsed_response + + check_and_raise_error(result) + + record.override_attributes!(result_attributes(result)) + record + end + + # Replaces record in airtable based on id + def update(record) + result = self.class.put(worksheet_url + "/" + record.id, + :body => { "fields" => record.fields_for_update }.to_json, + :headers => { "Content-type" => "application/json" }).parsed_response + + check_and_raise_error(result) + + record.override_attributes!(result_attributes(result)) + record + + end + + def update_record_fields(record_id, fields_for_update) + result = self.class.patch(worksheet_url + "/" + record_id, + :body => { "fields" => fields_for_update }.to_json, + :headers => { "Content-type" => "application/json" }).parsed_response + + check_and_raise_error(result) + + Record.new(result_attributes(result)) + end + + # Deletes record in table based on id + def destroy(id) + self.class.delete(worksheet_url + "/" + id).parsed_response + end + + protected + + def check_and_raise_error(response) + response['error'] ? raise(Error.new(response['error'])) : false + end + + def result_attributes(res) + res["fields"].merge("id" => res["id"]) if res.present? && res["id"] + end + + def worksheet_url + "/#{app_token}/#{url_encode(worksheet_name)}" + end + + # From http://apidock.com/ruby/ERB/Util/url_encode + def url_encode(s) + s.to_s.dup.force_encoding("ASCII-8BIT").gsub(/[^a-zA-Z0-9_\-.]/) { + sprintf("%%%02X", $&.unpack("C")[0]) + } + end + end # Table + +end # Airtable diff --git a/lib/airtable/version.rb b/lib/airtable/version.rb index 019ad9a..a1e4c34 100644 --- a/lib/airtable/version.rb +++ b/lib/airtable/version.rb @@ -1,3 +1,3 @@ module Airtable - VERSION = "0.1.1" + VERSION = '0.2.0'.freeze end diff --git a/spec/lib/airtable/client_spec.rb b/spec/lib/airtable/client_spec.rb new file mode 100644 index 0000000..c15c3b6 --- /dev/null +++ b/spec/lib/airtable/client_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +RSpec.describe ::Airtable::Client do + context '#new' do + context '(api_key)' do + it 'should return proper ::Airtable::Client' do + c = described_class.new('TEST_API') + expect(c.instance_variable_get(:@api_key)).to eq('TEST_API') + expect(c).to respond_to(:base) + end + end + + context '()' do + it 'should return proper ::Airtable::Client' do + allow(::ENV).to receive(:[]).with('AIRTABLE_KEY').and_return('TEST_API2') + c = described_class.new + expect(c.instance_variable_get(:@api_key)).to eq('TEST_API2') + expect(c).to respond_to(:base) + end + + it 'should raise error if no key present' do + allow(::ENV).to receive(:[]).with('AIRTABLE_KEY').and_return(nil) + expect { described_class.new }.to raise_error(::Airtable::MissingApiKeyError) + end + end + end + + context '#base' do + let(:client) {described_class.new} + it 'should return ::Airtable::Base' do + expect(client.base('TEST')).to be_a(::Airtable::Base) + end + + it 'should have a proper data' do + b = client.base('TEST') + expect(b.instance_variable_get(:@id)).to eq('TEST') + expect(b.instance_variable_get(:@client)).to eq(client) + end + end +end \ No newline at end of file diff --git a/spec/lib/airtable_spec.rb b/spec/lib/airtable_spec.rb new file mode 100644 index 0000000..be3c090 --- /dev/null +++ b/spec/lib/airtable_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +RSpec.describe ::Airtable do + context '#server_url' do + before :each do + described_class.reset! + end + it 'should return default' do + expect(described_class.server_url).to eq('https://api.airtable.com/v0') + end + + it 'should custom from ENV' do + allow(ENV).to receive(:fetch).with('AIRTABLE_ENDPOINT_URL').and_return('CUSTOM') + expect(described_class.server_url).to eq('CUSTOM') + end + end + + context '#log_path' do + it 'should return default path' do + expect(described_class.log_path).to eq('airtable.log') + end + + it 'should return a custom' do + described_class.log_path = 'another.log' + expect(described_class.log_path).to eq('another.log') + end + end + + context '#debug?' do + it 'should return false' do + expect(described_class.debug?).to be_falsey + end + + it 'should return true' do + allow(ENV).to receive(:[]).with('DEBUG').and_return('1') + expect(described_class.debug?).to be_truthy + end + end + + context '#logger' do + it 'should return logger object' do + expect(described_class.logger).to be_a(::Logger) + end + end +end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..dbf432a --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,84 @@ +$LOAD_PATH.unshift(File.expand_path('../lib', __FILE__)) +require 'bundler/setup' +require 'simplecov' + +SimpleCov.start do + add_filter %r{^/spec/} +end + +require 'rspec' +require 'vcr' +require 'airtable' + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + + VCR.configure do |vcr_config| + vcr_config.cassette_library_dir = 'spec/vcr_cassettes' + vcr_config.hook_into :webmock + vcr_config.configure_rspec_metadata! + vcr_config.ignore_localhost = true + end + +# The settings below are suggested to provide a good initial experience +# with RSpec, but feel free to customize to your heart's content. +=begin + # This allows you to limit a spec run to individual examples or groups + # you care about by tagging them with `:focus` metadata. When nothing + # is tagged with `:focus`, all examples get run. RSpec also provides + # aliases for `it`, `describe`, and `context` that include `:focus` + # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + config.filter_run_when_matching :focus + + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + config.example_status_persistence_file_path = "spec/examples.txt" + + # Limits the available syntax to the non-monkey patched syntax that is + # recommended. For more details, see: + # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ + # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ + # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode + config.disable_monkey_patching! + + # This setting enables warnings. It's recommended, but in some cases may + # be too noisy due to issues in dependencies. + config.warnings = true + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = "doc" + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + config.profile_examples = 10 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed +=end +end From f889f87edcbbfd8d4017466b7775f608fd95d54d Mon Sep 17 00:00:00 2001 From: Alexander Simonov Date: Tue, 12 Dec 2017 16:25:41 +0200 Subject: [PATCH 02/11] Rubocop changes --- Gemfile | 2 + Rakefile | 2 +- airtable.gemspec | 32 ++--- lib/airtable.rb | 13 +- lib/airtable/base.rb | 12 -- lib/airtable/client.rb | 26 ++-- lib/airtable/entity.rb | 8 ++ lib/airtable/entity/base.rb | 15 +++ lib/airtable/entity/record.rb | 22 ++++ lib/airtable/entity/table.rb | 84 ++++++++++++ lib/airtable/error.rb | 1 - lib/airtable/record.rb | 76 ----------- lib/airtable/record_set.rb | 22 ---- lib/airtable/request.rb | 4 +- lib/airtable/resource.rb | 17 --- lib/airtable/response.rb | 2 +- lib/airtable/table.rb | 63 --------- lib/airtable/table_old.rb | 123 ------------------ spec/lib/airtable/client_spec.rb | 8 +- spec/lib/airtable/entity/base_spec.rb | 11 ++ spec/lib/airtable/entity/table_spec.rb | 16 +++ spec/lib/airtable_spec.rb | 2 +- spec/spec_helper.rb | 104 ++++++++------- ...return_array_of_Airtable_Entity_Record.yml | 81 ++++++++++++ test/airtable_test.rb | 73 ----------- test/record_test.rb | 21 --- test/test_helper.rb | 14 -- 27 files changed, 336 insertions(+), 518 deletions(-) delete mode 100644 lib/airtable/base.rb create mode 100644 lib/airtable/entity.rb create mode 100644 lib/airtable/entity/base.rb create mode 100644 lib/airtable/entity/record.rb create mode 100644 lib/airtable/entity/table.rb delete mode 100644 lib/airtable/record.rb delete mode 100644 lib/airtable/record_set.rb delete mode 100644 lib/airtable/resource.rb delete mode 100644 lib/airtable/table.rb delete mode 100644 lib/airtable/table_old.rb create mode 100644 spec/lib/airtable/entity/base_spec.rb create mode 100644 spec/lib/airtable/entity/table_spec.rb create mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_records/_/should_return_array_of_Airtable_Entity_Record.yml delete mode 100644 test/airtable_test.rb delete mode 100644 test/record_test.rb delete mode 100644 test/test_helper.rb diff --git a/Gemfile b/Gemfile index b8bbbb2..e690800 100644 --- a/Gemfile +++ b/Gemfile @@ -2,3 +2,5 @@ source 'https://rubygems.org' # Specify your gem's dependencies in airtable.gemspec gemspec + +gem 'pry' diff --git a/Rakefile b/Rakefile index 0bc3d2c..be153a9 100644 --- a/Rakefile +++ b/Rakefile @@ -14,4 +14,4 @@ task :rubocop do sh 'bundle exec rubocop lib' end -task :default => [:spec, :rubocop] +task default: %i[spec rubocop] diff --git a/airtable.gemspec b/airtable.gemspec index cf035dd..e3135e7 100644 --- a/airtable.gemspec +++ b/airtable.gemspec @@ -1,28 +1,28 @@ -# coding: utf-8 + lib = File.expand_path('../lib', __FILE__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'airtable/version' Gem::Specification.new do |spec| - spec.name = "airtable" + spec.name = 'airtable' spec.version = Airtable::VERSION - spec.authors = ["Nathan Esquenazi", "Alexander Sorokin"] - spec.email = ["nesquena@gmail.com", "syrnick@gmail.com"] - spec.summary = %q{Easily connect to airtable data using ruby} - spec.description = %q{Easily connect to airtable data using ruby with access to all of the airtable features.} - spec.homepage = "https://github.com/nesquena/airtable-ruby" - spec.license = "MIT" + spec.authors = ['Nathan Esquenazi', 'Alexander Sorokin'] + spec.email = ['nesquena@gmail.com', 'syrnick@gmail.com'] + spec.summary = 'Easily connect to airtable data using ruby' + spec.description = 'Easily connect to airtable data using ruby with access to all of the airtable features.' + spec.homepage = 'https://github.com/nesquena/airtable-ruby' + spec.license = 'MIT' spec.files = `git ls-files -z`.split("\x0") spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) - spec.require_paths = ["lib"] + spec.require_paths = ['lib'] - spec.add_development_dependency "bundler", "~> 1.6" - spec.add_development_dependency "rake" - spec.add_development_dependency "rspec", "~> 3.7" - spec.add_development_dependency "vcr", "~> 4.0" - spec.add_development_dependency "webmock", "~> 3.1" - spec.add_development_dependency "rubocop", "~> 0.51" - spec.add_development_dependency "simplecov", "~> 0.15" + spec.add_development_dependency 'bundler', '~> 1.6' + spec.add_development_dependency 'rake' + spec.add_development_dependency 'rspec', '~> 3.7' + spec.add_development_dependency 'rubocop', '~> 0.51' + spec.add_development_dependency 'simplecov', '~> 0.15' + spec.add_development_dependency 'vcr', '~> 4.0' + spec.add_development_dependency 'webmock', '~> 3.1' end diff --git a/lib/airtable.rb b/lib/airtable.rb index a51ef3c..6179d75 100644 --- a/lib/airtable.rb +++ b/lib/airtable.rb @@ -1,5 +1,6 @@ require 'logger' +# Airtable wrapper library module Airtable class << self DEFAULT_URL = 'https://api.airtable.com/v0'.freeze @@ -29,12 +30,12 @@ def reset! end end -require 'airtable/base' +require 'airtable/response' +require 'airtable/request' +require 'airtable/entity' require 'airtable/client' -require 'airtable/table' require 'airtable/error' - -#require 'airtable/resource' -#require 'airtable/record' -#require 'airtable/record_set' +# require 'airtable/resource' +# require 'airtable/record' +# require 'airtable/record_set' diff --git a/lib/airtable/base.rb b/lib/airtable/base.rb deleted file mode 100644 index 4d9e13a..0000000 --- a/lib/airtable/base.rb +++ /dev/null @@ -1,12 +0,0 @@ -module Airtable - class Base - def initialize(id, client) - @id = id - @client = client - end - - def table(name) - ::Airtable::Table.new(name, @id, @client) - end - end -end diff --git a/lib/airtable/client.rb b/lib/airtable/client.rb index f2f6904..035d5b4 100644 --- a/lib/airtable/client.rb +++ b/lib/airtable/client.rb @@ -1,25 +1,27 @@ -# Allows access to data on airtable -# -# Fetch all records from table: -# -# client = Airtable::Client.new("keyPtVG4L4sVudsCx5W") -# client.table("appXXV84QuCy2BPgLk", "Sheet Name").all -# - module Airtable + # Main client to Airtable class Client + # @return [String] Airtable API Key + attr_reader :api_key + + # Initialize new Airtable client + # @param api_key [String] API Key for access Airtable + # @return [::Airtable::Client] Airtable client object def initialize(api_key = nil) @api_key = api_key || ENV['AIRTABLE_KEY'] - raise ::Airtable::MissingApiKeyError.new if @api_key.nil? || @api_key.empty? + raise Airtable::MissingApiKeyError if @api_key.nil? || @api_key.empty? end + # Get the Base Airtable Entity by id + # @param id [String] Id of Base on Airtable + # @return [::Airtable::Entity::Base] Airtable Base entity object def base(id) - ::Airtable::Base.new(id, self) + ::Airtable::Entity::Base.new(id, self) end # table("appXXV84QuCy2BPgLk", "Sheet Name") # def table(app_token, worksheet_name) # Table.new(@api_key, app_token, worksheet_name) # end - end # Client -end # Airtable \ No newline at end of file + end +end diff --git a/lib/airtable/entity.rb b/lib/airtable/entity.rb new file mode 100644 index 0000000..613283a --- /dev/null +++ b/lib/airtable/entity.rb @@ -0,0 +1,8 @@ +module Airtable + module Entity + end +end + +require 'airtable/entity/base' +require 'airtable/entity/table' +require 'airtable/entity/record' diff --git a/lib/airtable/entity/base.rb b/lib/airtable/entity/base.rb new file mode 100644 index 0000000..59f0b80 --- /dev/null +++ b/lib/airtable/entity/base.rb @@ -0,0 +1,15 @@ +module Airtable + module Entity + # Airtable Base entity + class Base + def initialize(id, client) + @id = id + @client = client + end + + def table(name) + ::Airtable::Entity::Table.new(name, @id, @client) + end + end + end +end diff --git a/lib/airtable/entity/record.rb b/lib/airtable/entity/record.rb new file mode 100644 index 0000000..dc271e9 --- /dev/null +++ b/lib/airtable/entity/record.rb @@ -0,0 +1,22 @@ +# Main object for store Airtable Record entity +module Airtable + module Entity + class Record + attr_reader :id, :created_at, :fields + + def initialize(id, options = {}) + @id = id + parse_options(options) + end + + def parse_options(options = {}) + if (fields = options.delete(:fields)) && !fields.empty? + @fields = fields + end + if (created_at = options.delete(:created_at)) + @created_at = created_at + end + end + end + end +end diff --git a/lib/airtable/entity/table.rb b/lib/airtable/entity/table.rb new file mode 100644 index 0000000..ab3ccda --- /dev/null +++ b/lib/airtable/entity/table.rb @@ -0,0 +1,84 @@ +module Airtable + module Entity + # Airtable Table entity + class Table + PAGE_SIZE = 100 + DEFAULT_DIRECTION = 'asc'.freeze + + def initialize(name, base_id, client) + @name = name + @base_id = base_id + @client = client + end + + def records(options = {}) + params = {} + update_default_params(params, options) + update_sort_options(params, options) + fetch_records(params) + end + + def raise_correct_error_for(resp) + ; + end + + def option_value_for(hash, key) + hash.delete(key) || hash.delete(key.to_s) + end + + def fetch_records(params) + url = [::Airtable.server_url, @base_id, @name].join('/') + resp = ::Airtable::Request.new(url, params, @client.api_key).request(:get) + if resp.success? + resp.result['records'].map do |item| + ::Airtable::Entity::Record.new(item['id'], fields: item['fields'], created_at: item['createdTime']) + end + else + raise_correct_error_for(resp) + end + end + + def update_default_params(params, options) + params[:fields] = option_value_for(options, :fields) + params[:maxRecords] = option_value_for(options, :max_records) + params[:pageSize] = option_value_for(options, :limit) || PAGE_SIZE + end + + def update_sort_options(params, options) + sort_option = option_value_for(options, :sort) + case sort_option + when ::Array + raise ::Airtable::SortOptionsError if sort_option.empty? + when ::Hash + add_hash_sort_option(params, sort_option) + when ::String + add_string_sort_option(params, sort_option) + end + end + + def add_string_sort_options(params, string) + raise ::Airtable::SortOptionsError if string.nil? || string.empty? + params[:sort] ||= [] + params[:sort] << { field: string, direction: DEFAULT_DIRECTION } + end + + def add_hash_sort_option(params, hash) + raise ::Airtable::SortOptionsError if hash.keys.map(&:to_sym).sort == %i[direction field] + params[:sort] ||= [] + params[:sort] << hash + end + + def add_sort_options(params, sort_option) + case sort_option + when ::Array + raise Airtable::SortOptionsError if sort_option.size != 2 + params[:sort] ||= [] + params[:sort] << { field: sort_option[0], direction: sort_option[1] } + when ::Hash + when ::String + + end + end + end + end +end diff --git a/lib/airtable/error.rb b/lib/airtable/error.rb index 23ae186..2e4c806 100644 --- a/lib/airtable/error.rb +++ b/lib/airtable/error.rb @@ -1,7 +1,6 @@ module Airtable class Error < StandardError - attr_reader :message, :type # {"error"=>{"type"=>"UNKNOWN_COLUMN_NAME", "message"=>"Could not find fields foo"}} diff --git a/lib/airtable/record.rb b/lib/airtable/record.rb deleted file mode 100644 index 03ba2c6..0000000 --- a/lib/airtable/record.rb +++ /dev/null @@ -1,76 +0,0 @@ -module Airtable - - class Record - def initialize(attrs={}) - override_attributes!(attrs) - end - - def id; @attrs["id"]; end - def id=(val); @attrs["id"] = val; end - - # Return given attribute based on name or blank otherwise - def [](name) - @attrs.has_key?(to_key(name)) ? @attrs[to_key(name)] : "" - end - - # Set the given attribute to value - def []=(name, value) - @column_keys << name - @attrs[to_key(name)] = value - define_accessor(name) unless respond_to?(name) - end - - def inspect - "##{v.inspect}" }.join(", ")}>" - end - - # Hash of attributes with underscored column names - def attributes; @attrs; end - - # Removes old and add new attributes for the record - def override_attributes!(attrs={}) - @column_keys = attrs.keys - @attrs = HashWithIndifferentAccess.new(Hash[attrs.map { |k, v| [ to_key(k), v ] }]) - @attrs.map { |k, v| define_accessor(k) } - end - - # Hash with keys based on airtable original column names - def fields - HashWithIndifferentAccess.new(Hash[@column_keys.map { |k| [ k, @attrs[to_key(k)] ] }]) - end - - # Airtable will complain if we pass an 'id' as part of the request body. - def fields_for_update; fields.except(:id); end - - def method_missing(name, *args, &blk) - # Accessor for attributes - if args.empty? && blk.nil? && @attrs.has_key?(name) - @attrs[name] - else - super - end - end - - def respond_to?(name, include_private = false) - @attrs.has_key?(name) || super - end - - protected - - def to_key(string) - string.is_a?(Symbol) ? string : underscore(string).to_sym - end - - def underscore(string) - string.gsub(/::/, '/'). - gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2'). - gsub(/([a-z\d])([A-Z])/,'\1_\2'). - gsub(/\s/, '_').tr("-", "_").downcase - end - - def define_accessor(name) - self.class.send(:define_method, name) { @attrs[name] } - end - - end # Record -end # Airtable diff --git a/lib/airtable/record_set.rb b/lib/airtable/record_set.rb deleted file mode 100644 index 15f7085..0000000 --- a/lib/airtable/record_set.rb +++ /dev/null @@ -1,22 +0,0 @@ -module Airtable - - # Contains records and the offset after a record query - class RecordSet < SimpleDelegator - - attr_reader :records, :offset - - # results = { "records" => [{ ... }], "offset" => "abc5643" } - # response from records api - def initialize(results) - # Parse records - @records = results && results["records"] ? - results["records"].map { |r| Record.new(r["fields"].merge("id" => r["id"])) } : [] - # Store offset - @offset = results["offset"] if results - # Assign delegation object - __setobj__(@records) - end - - end # Record - -end # Airtable \ No newline at end of file diff --git a/lib/airtable/request.rb b/lib/airtable/request.rb index 871c243..f59c171 100644 --- a/lib/airtable/request.rb +++ b/lib/airtable/request.rb @@ -10,7 +10,7 @@ def initialize(url, body, token) @body = body @headers = { 'Authorization' => "Bearer #{token}", - 'Content-Type' => 'application/json', + 'Content-Type' => 'application/json' } end @@ -62,4 +62,4 @@ def setup_headers(request) end end end -end \ No newline at end of file +end diff --git a/lib/airtable/resource.rb b/lib/airtable/resource.rb deleted file mode 100644 index 26ae69a..0000000 --- a/lib/airtable/resource.rb +++ /dev/null @@ -1,17 +0,0 @@ -module Airtable - # Base class for authorized resources sending network requests - class Resource - include HTTParty - base_uri 'https://api.airtable.com/v0/' - # debug_output $stdout - - attr_reader :api_key, :app_token, :worksheet_name - - def initialize(api_key, app_token, worksheet_name) - @api_key = api_key - @app_token = app_token - @worksheet_name = worksheet_name - self.class.headers({'Authorization' => "Bearer #{@api_key}"}) - end - end # AuthorizedResource -end # Airtable \ No newline at end of file diff --git a/lib/airtable/response.rb b/lib/airtable/response.rb index f193012..80fbe46 100644 --- a/lib/airtable/response.rb +++ b/lib/airtable/response.rb @@ -23,4 +23,4 @@ def rate_limited? @raw.code.to_i == 429 end end -end \ No newline at end of file +end diff --git a/lib/airtable/table.rb b/lib/airtable/table.rb deleted file mode 100644 index 545ec53..0000000 --- a/lib/airtable/table.rb +++ /dev/null @@ -1,63 +0,0 @@ -module Airtable - class Table - PAGE_SIZE = 100.freeze - DEFAULT_DIRECTION = 'asc'.freeze - - def initialize(name, base_id, client) - @name = name - @base_id = base_id - @client = client - end - - def records(options = {}) - params = {} - params[:fields] = option_value_for(options, :fields) - params[:maxRecords] = option_value_for(options, :max_records) - params[:pageSize] = option_value_for(options, :limit) || PAGE_SIZE - update_sort_options(params, options) - end - - def option_value_for(hash, key) - hash.delete(key) || hash.delete(key.to_s) - end - - def update_sort_options(params, options) - sort_option = option_value_for(options, :sort) - case sort_option - when ::Array - raise ::Airtable::SortOptionsError if sort_option.size == 0 - when ::Hash - add_hash_sort_option(params, sort_option) - when ::String - add_string_sort_option(params, sort_option) - end - end - - def add_string_sort_options(params, string) - raise ::Airtable::SortOptionsError if string.nil? || string.empty? - params[:sort] ||= [] - params[:sort] << { field: string, direction: DEFAULT_DIRECTION } - end - - def add_hash_sort_option(params, hash) - raise ::Airtable::SortOptionsError if hash.keys.map(&:to_sym).sort == [:direction, :field] - params[:sort] ||= [] - params[:sort] << hash - end - - def add_sort_options(params, sort_option) - case sort_option - when ::Array - if sort_option.size == 2 - params[:sort] ||= [] - params[:sort] << { field: sort_option[0], direction: sort_option[1] } - else - raise ::Airtable::SortOptionsError.new - end - when ::Hash - when ::String - - end - end - end -end diff --git a/lib/airtable/table_old.rb b/lib/airtable/table_old.rb deleted file mode 100644 index 82dc903..0000000 --- a/lib/airtable/table_old.rb +++ /dev/null @@ -1,123 +0,0 @@ -module Airtable - - class Table < Resource - # Maximum results per request - LIMIT_MAX = 100 - - # Fetch all records iterating through offsets until retrieving the entire collection - # all(:sort => ["Name", :desc]) - def all(options={}) - offset = nil - results = [] - begin - options.merge!(:limit => LIMIT_MAX, :offset => offset) - response = records(options) - results += response.records - offset = response.offset - end until offset.nil? || offset.empty? || results.empty? - results - end - - # Fetch records from the sheet given the list options - # Options: limit = 100, offset = "as345g", sort = ["Name", "asc"] - # 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 - check_and_raise_error(results) - RecordSet.new(results) - end - - # Query for records using a string formula - # Options: limit = 100, offset = "as345g", sort = ["Name", "asc"], - # fields = [Name, Email], formula = "Count > 5", view = "Main View" - # - # select(limit: 10, sort: ["Name", "asc"], formula: "Order < 2") - def select(options={}) - options['sortField'], options['sortDirection'] = options.delete(:sort) if options[:sort] - options['maxRecords'] = options.delete(:limit) if options[:limit] - - if options[:formula] - raise_bad_formula_error unless options[:formula].is_a? String - options['filterByFormula'] = options.delete(:formula) - end - - results = self.class.get(worksheet_url, query: options).parsed_response - check_and_raise_error(results) - RecordSet.new(results) - end - - def raise_bad_formula_error - raise ArgumentError.new("The value for filter should be a String.") - end - - # Returns record based given row id - def find(id) - result = self.class.get(worksheet_url + "/" + id).parsed_response - check_and_raise_error(result) - Record.new(result_attributes(result)) if result.present? && result["id"] - end - - # Creates a record by posting to airtable - def create(record) - result = self.class.post(worksheet_url, - :body => { "fields" => record.fields }.to_json, - :headers => { "Content-type" => "application/json" }).parsed_response - - check_and_raise_error(result) - - record.override_attributes!(result_attributes(result)) - record - end - - # Replaces record in airtable based on id - def update(record) - result = self.class.put(worksheet_url + "/" + record.id, - :body => { "fields" => record.fields_for_update }.to_json, - :headers => { "Content-type" => "application/json" }).parsed_response - - check_and_raise_error(result) - - record.override_attributes!(result_attributes(result)) - record - - end - - def update_record_fields(record_id, fields_for_update) - result = self.class.patch(worksheet_url + "/" + record_id, - :body => { "fields" => fields_for_update }.to_json, - :headers => { "Content-type" => "application/json" }).parsed_response - - check_and_raise_error(result) - - Record.new(result_attributes(result)) - end - - # Deletes record in table based on id - def destroy(id) - self.class.delete(worksheet_url + "/" + id).parsed_response - end - - protected - - def check_and_raise_error(response) - response['error'] ? raise(Error.new(response['error'])) : false - end - - def result_attributes(res) - res["fields"].merge("id" => res["id"]) if res.present? && res["id"] - end - - def worksheet_url - "/#{app_token}/#{url_encode(worksheet_name)}" - end - - # From http://apidock.com/ruby/ERB/Util/url_encode - def url_encode(s) - s.to_s.dup.force_encoding("ASCII-8BIT").gsub(/[^a-zA-Z0-9_\-.]/) { - sprintf("%%%02X", $&.unpack("C")[0]) - } - end - end # Table - -end # Airtable diff --git a/spec/lib/airtable/client_spec.rb b/spec/lib/airtable/client_spec.rb index c15c3b6..ed76c7b 100644 --- a/spec/lib/airtable/client_spec.rb +++ b/spec/lib/airtable/client_spec.rb @@ -26,9 +26,9 @@ end context '#base' do - let(:client) {described_class.new} - it 'should return ::Airtable::Base' do - expect(client.base('TEST')).to be_a(::Airtable::Base) + let(:client) { described_class.new } + it 'should return ::Airtable::Entity::Base' do + expect(client.base('TEST')).to be_a(::Airtable::Entity::Base) end it 'should have a proper data' do @@ -37,4 +37,4 @@ expect(b.instance_variable_get(:@client)).to eq(client) end end -end \ No newline at end of file +end diff --git a/spec/lib/airtable/entity/base_spec.rb b/spec/lib/airtable/entity/base_spec.rb new file mode 100644 index 0000000..0ae03ed --- /dev/null +++ b/spec/lib/airtable/entity/base_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +RSpec.describe ::Airtable::Entity::Base do + let(:client) { ::Airtable::Client.new } + let(:base_entity) { described_class.new('appnlJrQ2fxlfRsov', client) } + context '#table' do + it 'should return ::Airtable::Entity::Table' do + expect(base_entity.table('Applicants')).to be_a(::Airtable::Entity::Table) + end + end +end diff --git a/spec/lib/airtable/entity/table_spec.rb b/spec/lib/airtable/entity/table_spec.rb new file mode 100644 index 0000000..5710e6c --- /dev/null +++ b/spec/lib/airtable/entity/table_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' +require 'pry' + +RSpec.describe ::Airtable::Entity::Table, vcr: true do + let(:client) { ::Airtable::Client.new } + let(:base_id) { 'appnlJrQ2fxlfRsov' } + let(:table_entity) { described_class.new('Applicants', base_id, client) } + context '#records' do + context '()' do + it 'should return array of ::Airtable::Entity::Record' do + res = table_entity.records + expect(res).to be_a(::Array) + end + end + end +end diff --git a/spec/lib/airtable_spec.rb b/spec/lib/airtable_spec.rb index be3c090..5d7cf83 100644 --- a/spec/lib/airtable_spec.rb +++ b/spec/lib/airtable_spec.rb @@ -42,4 +42,4 @@ expect(described_class.logger).to be_a(::Logger) end end -end \ No newline at end of file +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index dbf432a..c250c08 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -28,57 +28,55 @@ vcr_config.ignore_localhost = true end -# The settings below are suggested to provide a good initial experience -# with RSpec, but feel free to customize to your heart's content. -=begin - # This allows you to limit a spec run to individual examples or groups - # you care about by tagging them with `:focus` metadata. When nothing - # is tagged with `:focus`, all examples get run. RSpec also provides - # aliases for `it`, `describe`, and `context` that include `:focus` - # metadata: `fit`, `fdescribe` and `fcontext`, respectively. - config.filter_run_when_matching :focus - - # Allows RSpec to persist some state between runs in order to support - # the `--only-failures` and `--next-failure` CLI options. We recommend - # you configure your source control system to ignore this file. - config.example_status_persistence_file_path = "spec/examples.txt" - - # Limits the available syntax to the non-monkey patched syntax that is - # recommended. For more details, see: - # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ - # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ - # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode - config.disable_monkey_patching! - - # This setting enables warnings. It's recommended, but in some cases may - # be too noisy due to issues in dependencies. - config.warnings = true - - # Many RSpec users commonly either run the entire suite or an individual - # file, and it's useful to allow more verbose output when running an - # individual spec file. - if config.files_to_run.one? - # Use the documentation formatter for detailed output, - # unless a formatter has already been configured - # (e.g. via a command-line flag). - config.default_formatter = "doc" - end - - # Print the 10 slowest examples and example groups at the - # end of the spec run, to help surface which specs are running - # particularly slow. - config.profile_examples = 10 - - # Run specs in random order to surface order dependencies. If you find an - # order dependency and want to debug it, you can fix the order by providing - # the seed, which is printed after each run. - # --seed 1234 - config.order = :random - - # Seed global randomization in this process using the `--seed` CLI option. - # Setting this allows you to use `--seed` to deterministically reproduce - # test failures related to randomization by passing the same `--seed` value - # as the one that triggered the failure. - Kernel.srand config.seed -=end + # The settings below are suggested to provide a good initial experience + # with RSpec, but feel free to customize to your heart's content. + # # This allows you to limit a spec run to individual examples or groups + # # you care about by tagging them with `:focus` metadata. When nothing + # # is tagged with `:focus`, all examples get run. RSpec also provides + # # aliases for `it`, `describe`, and `context` that include `:focus` + # # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + # config.filter_run_when_matching :focus + # + # # Allows RSpec to persist some state between runs in order to support + # # the `--only-failures` and `--next-failure` CLI options. We recommend + # # you configure your source control system to ignore this file. + # config.example_status_persistence_file_path = "spec/examples.txt" + # + # # Limits the available syntax to the non-monkey patched syntax that is + # # recommended. For more details, see: + # # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ + # # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ + # # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode + # config.disable_monkey_patching! + # + # # This setting enables warnings. It's recommended, but in some cases may + # # be too noisy due to issues in dependencies. + # config.warnings = true + # + # # Many RSpec users commonly either run the entire suite or an individual + # # file, and it's useful to allow more verbose output when running an + # # individual spec file. + # if config.files_to_run.one? + # # Use the documentation formatter for detailed output, + # # unless a formatter has already been configured + # # (e.g. via a command-line flag). + # config.default_formatter = "doc" + # end + # + # # Print the 10 slowest examples and example groups at the + # # end of the spec run, to help surface which specs are running + # # particularly slow. + # config.profile_examples = 10 + # + # # Run specs in random order to surface order dependencies. If you find an + # # order dependency and want to debug it, you can fix the order by providing + # # the seed, which is printed after each run. + # # --seed 1234 + # config.order = :random + # + # # Seed global randomization in this process using the `--seed` CLI option. + # # Setting this allows you to use `--seed` to deterministically reproduce + # # test failures related to randomization by passing the same `--seed` value + # # as the one that triggered the failure. + # Kernel.srand config.seed end diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_/should_return_array_of_Airtable_Entity_Record.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_records/_/should_return_array_of_Airtable_Entity_Record.yml new file mode 100644 index 0000000..e06b26b --- /dev/null +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_records/_/should_return_array_of_Airtable_Entity_Record.yml @@ -0,0 +1,81 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?fields&maxRecords&pageSize=100 + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Tue, 12 Dec 2017 13:50:31 GMT + Etag: + - W/"1396-s0iIZ/It90SJrQVS5uVNRPfb7Cw" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '1770' + Connection: + - keep-alive + body: + encoding: ASCII-8BIT + string: '{"records":[{"id":"recQes7d2DCuEcGe0","fields":{"Email Address":"c.potato@example.com","Phone + Screen Score":"2 - worth consideration","Onsite Interview Date":"2013-02-14","Stage":"Decision + Needed","Onsite Interview Notes":"Seems like a really hard worker, and has + a great attitude. Very observant: He''s got eyes everywhere. But I am concerned + that he won''t be able to think outside the bag.","Phone":"(208) 555-0505","Phone + Screen Date":"2013-02-07","Name":"Chippy the Potato","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attgK5ha1ajpVWOSY","url":"https://dl.airtable.com/xwv2ejXtTBqbTqjcnbQi_Chippypotato.jpg","filename":"Chippypotato.jpg","size":59402,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/g7BoJwauSyOZIdtoQALf_Chippypotato.jpg","width":27,"height":36},"large":{"url":"https://dl.airtable.com/IRqeRk4sTkud3Ga12uYI_Chippypotato.jpg","width":256,"height":341}}},{"id":"attU5T1mSPQ8r3nJ6","url":"https://dl.airtable.com/e6kCK3Z0SUS6hhCUoKcD_chippypotatoresume.docx","filename":"chippypotatoresume.docx","size":127681,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-256x331.png","width":256,"height":331}}}],"Onsite + Interview Score":"2 - worth consideration","Applying for":["recYPK7fwrFojHO9H"],"Phone + Screen Interviewer":["recmtSmO51zqqWAqs"],"Phone Screen Notes":"Questionable, + but tentatively move to on-site interview"},"createdTime":"2015-11-11T23:05:58.000Z"},{"id":"recSIn39bSTqt4Swc","fields":{"Email + Address":"ohuxley@example.com","Phone Screen Score":"3 - good candidate","Onsite + Interview Date":"2013-02-19","Stage":"Interviewing","Onsite Interview Notes":"Owldous + was impressive overall, and he has a lot of experience in the corporate world. + But if he were to join with a sports team, he''d still have a lot to learn + about the industry, e.g., I asked him for his opinion on Leon the Lion''s + recent resignation and he responded, \"Whooo?\"","Phone":"(646) 555-4389","Phone + Screen Date":"2013-02-11","Name":"Owldous Huxley","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attxKBp3Hb4FJH2I8","url":"https://dl.airtable.com/1ghGlZqKTViBcofjpPMC_owl.jpg","filename":"owl.jpg","size":242886,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/dFcZepTFCeG0YUXQSOrA_owl.jpg","width":54,"height":36},"large":{"url":"https://dl.airtable.com/QSkAheQ2SFuD3txaTgge_owl.jpg","width":256,"height":171}}},{"id":"attZJp9i2TJ4Z5xeM","url":"https://dl.airtable.com/WwYtwDVURFCGHCoipxhM_Resume%20(O.%20Huxley).docx","filename":"Resume + (O. Huxley).docx","size":16443,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-512x663.png","width":512,"height":663}}}],"Onsite + Interview Score":"3 - good candidate","Applying for":["recgrjFdvUdTGnDsF"],"Phone + Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Good analytical + skills, very articulate. Proceed to in-person interview."},"createdTime":"2015-11-11T23:19:11.000Z"},{"id":"recxczR4BaLmvsdU2","fields":{"Email + Address":"hrmqueenlizzy@example.com","Phone Screen Score":"3 - good candidate","Onsite + Interview Date":"2013-02-17","Stage":"Decision Needed","Onsite Interview Notes":"Liz + was highly qualified, but seemed shifty. When faced with a question she was + uncomfortable with, she slithered out of it. Could be someone that is very + smart, but difficult to deal with. Not sure that she''s a team player, which + of course is a problem if you''re going to be representing a team.","Phone":"(865) + 123-4567","Phone Screen Date":"2013-02-12","Name":"Queen Elizardbeth II","Onsite + Interviewer":["recmtSmO51zqqWAqs"],"Attachments":[{"id":"attQ6vAjxpfVlQedd","url":"https://dl.airtable.com/EXBc35SWSKKHxhbTUbOe_elizardbeth.jpg","filename":"elizardbeth.jpg","size":465335,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/jl9v4DQWSx8IoqzgWRTg_elizardbeth.jpg","width":24,"height":36},"large":{"url":"https://dl.airtable.com/YHtaEY7mTie2ZpS5hfJz_elizardbeth.jpg","width":256,"height":388}}},{"id":"attZa28XJJt0GL3DB","url":"https://dl.airtable.com/XdCerQy8MqG5RacAKwdn_Q%20Elizardbeth%20Resume.docx","filename":"Q + Elizardbeth Resume.docx","size":6519,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-256x331.png","width":256,"height":331}}}],"Onsite + Interview Score":"2 - worth consideration","Applying for":["recgrjFdvUdTGnDsF"],"Phone + Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Move to in-person."},"createdTime":"2015-11-11T23:11:29.000Z"}]}' + http_version: + recorded_at: Tue, 12 Dec 2017 13:50:34 GMT +recorded_with: VCR 4.0.0 diff --git a/test/airtable_test.rb b/test/airtable_test.rb deleted file mode 100644 index be443e7..0000000 --- a/test/airtable_test.rb +++ /dev/null @@ -1,73 +0,0 @@ -require 'test_helper' - -describe Airtable do - before do - @client_key = "12345" - @app_key = "appXXV84Qu" - @sheet_name = "Test" - end - - describe "with Airtable" do - it "should allow client to be created" do - @client = Airtable::Client.new(@client_key) - assert_kind_of Airtable::Client, @client - @table = @client.table(@app_key, @sheet_name) - assert_kind_of Airtable::Table, @table - end - - it "should fetch record set" 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) - @records = @table.records - assert_equal "abcde", @records.offset - end - - it "should select records based on a formula" do - query_str = "OR(RECORD_ID() = 'recXYZ1', RECORD_ID() = 'recXYZ2', RECORD_ID() = 'recXYZ3', RECORD_ID() = 'recXYZ4')" - escaped_query = HTTParty::Request::NON_RAILS_QUERY_STRING_NORMALIZER.call(filterByFormula: query_str) - request_url = "https://api.airtable.com/v0/#{@app_key}/#{@sheet_name}?#{escaped_query}" - stub_airtable_response!(request_url, { "records" => []}) - @table = Airtable::Client.new(@client_key).table(@app_key, @sheet_name) - @select_records = @table.select(formula: query_str) - 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) - proc { @table.select(formula: {foo: 'bar'}) }.must_raise ArgumentError - end - - it "should allow creating records" do - stub_airtable_response!("https://api.airtable.com/v0/#{@app_key}/#{@sheet_name}", - { "fields" => { "name" => "Sarah Jaine", "email" => "sarah@jaine.com", "foo" => "bar" }, "id" => "12345" }, :post) - table = Airtable::Client.new(@client_key).table(@app_key, @sheet_name) - record = Airtable::Record.new(:name => "Sarah Jaine", :email => "sarah@jaine.com") - table.create(record) - assert_equal "12345", record["id"] - assert_equal "bar", record["foo"] - end - - it "should allow updating records" do - record_id = "12345" - stub_airtable_response!("https://api.airtable.com/v0/#{@app_key}/#{@sheet_name}/#{record_id}", - { "fields" => { "name" => "Sarah Jaine", "email" => "sarah@jaine.com", "foo" => "bar" }, "id" => record_id }, :put) - table = Airtable::Client.new(@client_key).table(@app_key, @sheet_name) - record = Airtable::Record.new(:name => "Sarah Jaine", :email => "sarah@jaine.com", :id => record_id) - table.update(record) - assert_equal "12345", record["id"] - assert_equal "bar", record["foo"] - end - - it "should raise an error when the API returns an error" do - stub_airtable_response!("https://api.airtable.com/v0/#{@app_key}/#{@sheet_name}", - {"error"=>{"type"=>"UNKNOWN_COLUMN_NAME", "message"=>"Could not find fields foo"}}, :post, 422) - table = Airtable::Client.new(@client_key).table(@app_key, @sheet_name) - record = Airtable::Record.new(:foo => "bar") - assert_raises Airtable::Error do - table.create(record) - end - end - - end # describe Airtable -end # Airtable diff --git a/test/record_test.rb b/test/record_test.rb deleted file mode 100644 index 1ee836a..0000000 --- a/test/record_test.rb +++ /dev/null @@ -1,21 +0,0 @@ -require 'test_helper' - -describe Airtable do - describe Airtable::Record do - it "should not return id in fields_for_update" do - record = Airtable::Record.new(:name => "Sarah Jaine", :email => "sarah@jaine.com", :id => 12345) - record.fields_for_update.wont_include(:id) - end - - it "returns new columns in fields_for_update" do - record = Airtable::Record.new(:name => "Sarah Jaine", :email => "sarah@jaine.com", :id => 12345) - record[:website] = "http://sarahjaine.com" - record.fields_for_update.must_include(:website) - end - - it "returns fields_for_update in original capitalization" do - record = Airtable::Record.new("Name" => "Sarah Jaine") - record.fields_for_update.must_include("Name") - end - end # describe Record -end # Airtable diff --git a/test/test_helper.rb b/test/test_helper.rb deleted file mode 100644 index c0e9338..0000000 --- a/test/test_helper.rb +++ /dev/null @@ -1,14 +0,0 @@ -require 'airtable' -require 'webmock/minitest' -require 'minitest/pride' -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"} - ) -end From dcc801a0cd58cbb0a0024a8fc06df8e8c37f2450 Mon Sep 17 00:00:00 2001 From: Alexander Simonov Date: Wed, 13 Dec 2017 19:38:00 +0200 Subject: [PATCH 03/11] Sorting by different variants --- lib/airtable/entity/record.rb | 16 ++- lib/airtable/entity/table.rb | 30 ++-- lib/airtable/request.rb | 42 +++++- spec/lib/airtable/entity/table_spec.rb | 91 ++++++++++++- ...return_array_of_Airtable_Entity_Record.yml | 6 +- ...d_return_proper_Airtable_Entity_Record.yml | 81 +++++++++++ .../should_return_all_records_records.yml | 128 ++++++++++++++++++ .../should_return_only_2_records.yml | 70 ++++++++++ .../should_sort_records_by_Name.yml | 81 +++++++++++ .../should_sort_records_by_Name.yml | 72 ++++++++++ .../should_sort_records_by_Name.yml | 70 ++++++++++ .../should_sort_records_by_Name.yml | 72 ++++++++++ 12 files changed, 741 insertions(+), 18 deletions(-) create mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_records/_/should_return_proper_Airtable_Entity_Record.yml create mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_records/_limit_2_/should_return_all_records_records.yml create mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_records/_max_records_2_/should_return_only_2_records.yml create mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_/should_sort_records_by_Name.yml create mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_desc_max_records_2_/should_sort_records_by_Name.yml create mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_max_records_2_/should_sort_records_by_Name.yml create mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_field_Name_direction_desc_max_records_2_/should_sort_records_by_Name.yml diff --git a/lib/airtable/entity/record.rb b/lib/airtable/entity/record.rb index dc271e9..b4d988e 100644 --- a/lib/airtable/entity/record.rb +++ b/lib/airtable/entity/record.rb @@ -2,21 +2,35 @@ module Airtable module Entity class Record + extend Forwardable attr_reader :id, :created_at, :fields + def_delegators :@fields, :[], :[]= + def initialize(id, options = {}) @id = id parse_options(options) end + def [](key) + @fields[key.to_s] + end + + def []=(key, value) + @fields[key.to_s] = value + end + + private + def parse_options(options = {}) if (fields = options.delete(:fields)) && !fields.empty? @fields = fields end if (created_at = options.delete(:created_at)) - @created_at = created_at + @created_at = ::Time.parse(created_at) end end + end end end diff --git a/lib/airtable/entity/table.rb b/lib/airtable/entity/table.rb index ab3ccda..19f547a 100644 --- a/lib/airtable/entity/table.rb +++ b/lib/airtable/entity/table.rb @@ -15,7 +15,9 @@ def records(options = {}) params = {} update_default_params(params, options) update_sort_options(params, options) - fetch_records(params) + res = [] + fetch_records(params.compact, res) + res end def raise_correct_error_for(resp) @@ -26,12 +28,15 @@ def option_value_for(hash, key) hash.delete(key) || hash.delete(key.to_s) end - def fetch_records(params) + def fetch_records(params, res) url = [::Airtable.server_url, @base_id, @name].join('/') resp = ::Airtable::Request.new(url, params, @client.api_key).request(:get) if resp.success? - resp.result['records'].map do |item| - ::Airtable::Entity::Record.new(item['id'], fields: item['fields'], created_at: item['createdTime']) + resp.result['records'].each do |item| + res << ::Airtable::Entity::Record.new(item['id'], fields: item['fields'], created_at: item['createdTime']) + end + if resp.result['offset'] + fetch_records(params.merge(offset: resp.result['offset']), res) end else raise_correct_error_for(resp) @@ -41,6 +46,7 @@ def fetch_records(params) def update_default_params(params, options) params[:fields] = option_value_for(options, :fields) params[:maxRecords] = option_value_for(options, :max_records) + params[:offset] = option_value_for(options, :offset) params[:pageSize] = option_value_for(options, :limit) || PAGE_SIZE end @@ -49,6 +55,13 @@ def update_sort_options(params, options) case sort_option when ::Array raise ::Airtable::SortOptionsError if sort_option.empty? + if sort_option.size == 2 + add_sort_options(params, sort_option) + else + sort_option.each do |item| + add_sort_options(params, item) + end + end when ::Hash add_hash_sort_option(params, sort_option) when ::String @@ -56,14 +69,14 @@ def update_sort_options(params, options) end end - def add_string_sort_options(params, string) + def add_string_sort_option(params, string) raise ::Airtable::SortOptionsError if string.nil? || string.empty? params[:sort] ||= [] params[:sort] << { field: string, direction: DEFAULT_DIRECTION } end def add_hash_sort_option(params, hash) - raise ::Airtable::SortOptionsError if hash.keys.map(&:to_sym).sort == %i[direction field] + raise ::Airtable::SortOptionsError if hash.keys.map(&:to_sym).sort != %i[direction field] params[:sort] ||= [] params[:sort] << hash end @@ -75,8 +88,9 @@ def add_sort_options(params, sort_option) params[:sort] ||= [] params[:sort] << { field: sort_option[0], direction: sort_option[1] } when ::Hash - when ::String - + add_hash_sort_option(params, sort_option) + else + raise Airtable::SortOptionsError end end end diff --git a/lib/airtable/request.rb b/lib/airtable/request.rb index f59c171..6422a2d 100644 --- a/lib/airtable/request.rb +++ b/lib/airtable/request.rb @@ -2,6 +2,7 @@ require 'net/https' module Airtable + # Main Object that made all requests to server class Request attr_accessor :url, :body, :headers, :http @@ -21,6 +22,8 @@ def request(type = :get) ::Airtable::Response.new(http.request(request)) end + private + def setup_http http = ::Net::HTTP.new(url.host, url.port) http.use_ssl = true @@ -28,9 +31,42 @@ def setup_http end def setup_get_request - request = ::Net::HTTP::Get.new(url.path) - request.set_form_data(body) - ::Net::HTTP::Get.new(url.path + '?' + request.body) + ::Net::HTTP::Get.new(url.path + '?' + to_query_hash(body)) + end + + def to_query_default(key, value) + "#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}" + end + + def to_query_hash(hash, namespace = nil) + hash.collect do |key, value| + case value + when ::Hash + to_query_hash(value, namespace ? "#{namespace}[#{key}]" : key) + when ::Array + to_query_array(value, namespace ? "#{namespace}[#{key}]" : key) + else + to_query_default(namespace ? "#{namespace}[#{key}]" : key, value) + end + end.compact.sort! * '&' + end + + def to_query_array(array, namespace) + prefix = "#{namespace}[]" + if array.empty? + to_query_default(prefix, nil) + else + array.collect do |value| + case value + when ::Hash + to_query_hash(value, prefix) + when ::Array + to_query_array(value, prefix) + else + to_query_default(prefix, value) + end + end.join('&') + end end def setup_post_request diff --git a/spec/lib/airtable/entity/table_spec.rb b/spec/lib/airtable/entity/table_spec.rb index 5710e6c..f2b60db 100644 --- a/spec/lib/airtable/entity/table_spec.rb +++ b/spec/lib/airtable/entity/table_spec.rb @@ -2,15 +2,100 @@ require 'pry' RSpec.describe ::Airtable::Entity::Table, vcr: true do - let(:client) { ::Airtable::Client.new } - let(:base_id) { 'appnlJrQ2fxlfRsov' } - let(:table_entity) { described_class.new('Applicants', base_id, client) } + let(:client) {::Airtable::Client.new} + let(:base_id) {'appnlJrQ2fxlfRsov'} + let(:table_entity) {described_class.new('Applicants', base_id, client)} context '#records' do context '()' do it 'should return array of ::Airtable::Entity::Record' do res = table_entity.records expect(res).to be_a(::Array) + expect(res.map(&:class).uniq).to eq([::Airtable::Entity::Record]) + end + it 'should return proper ::Airtable::Entity::Record' do + record = table_entity.records[0] + expect(record.id).to eq('recQes7d2DCuEcGe0') + expect(record.created_at).to be_a(::Time) + expect(record.created_at).to eq(::Time.parse('2015-11-11T23:05:58.000Z')) + expect(record.fields).to be_a(::Hash) + expect(record).to respond_to(:[]) + expect(record).to respond_to(:[]=) + expect(record['Name']).to eq('Chippy the Potato') + expect(record[:Name]).to eq('Chippy the Potato') + end + end + + context '({max_records:2})' do + it 'should return only 2 records' do + expect(table_entity.records(max_records: 2).size).to eq(2) + end + end + + context '({limit: 2})' do + it 'should return all records records' do + expect(table_entity.records(limit: 2).size).to eq(3) + end + end + + context '({sort: "Name", max_records: 2})' do + it 'should sort records by Name' do + params = { + pageSize: described_class::PAGE_SIZE, + sort: [{ field: 'Name', direction: described_class::DEFAULT_DIRECTION }], + maxRecords: 2 + } + expect(table_entity).to receive(:fetch_records).with(params, []).and_call_original + expect {table_entity.records(sort: 'Name', max_records: 2)}.to_not raise_error end end + + context '({sort: ["Name", "desc"], max_records: 2})' do + it 'should sort records by Name' do + params = { + pageSize: described_class::PAGE_SIZE, + sort: [{ field: 'Name', direction: 'desc' }], + maxRecords: 2 + } + expect(table_entity).to receive(:fetch_records).with(params, []).and_call_original + expect {table_entity.records(sort: ['Name', 'desc'], max_records: 2)}.to_not raise_error + end + end + + context '({sort: [["Name", "desc"]], max_records: 2})' do + it 'should sort records by Name' do + params = { + pageSize: described_class::PAGE_SIZE, + sort: [{ field: 'Name', direction: 'desc' }], + maxRecords: 2 + } + expect(table_entity).to receive(:fetch_records).with(params, []).and_call_original + expect {table_entity.records(sort: [['Name', 'desc']], max_records: 2)}.to_not raise_error + end + end + + context '({sort: [{field: "Name", direction: "desc"}], max_records: 2})' do + it 'should sort records by Name' do + params = { + pageSize: described_class::PAGE_SIZE, + sort: [{ field: 'Name', direction: 'desc' }], + maxRecords: 2 + } + expect(table_entity).to receive(:fetch_records).with(params, []).and_call_original + expect {table_entity.records(sort: [{ field: 'Name', direction: 'desc' }], max_records: 2)}.to_not raise_error + end + end + + context '({sort: {field: "Name", direction: "desc"}, max_records: 2})' do + it 'should sort records by Name' do + params = { + pageSize: described_class::PAGE_SIZE, + sort: [{ field: 'Name', direction: 'desc' }], + maxRecords: 2 + } + expect(table_entity).to receive(:fetch_records).with(params, []).and_call_original + expect {table_entity.records(sort: { field: 'Name', direction: 'desc' }, max_records: 2)}.to_not raise_error + end + end + end end diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_/should_return_array_of_Airtable_Entity_Record.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_records/_/should_return_array_of_Airtable_Entity_Record.yml index e06b26b..5e3495e 100644 --- a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_/should_return_array_of_Airtable_Entity_Record.yml +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_records/_/should_return_array_of_Airtable_Entity_Record.yml @@ -2,7 +2,7 @@ http_interactions: - request: method: get - uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?fields&maxRecords&pageSize=100 + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?pageSize=100 body: encoding: US-ASCII string: '' @@ -32,7 +32,7 @@ http_interactions: Content-Type: - application/json; charset=utf-8 Date: - - Tue, 12 Dec 2017 13:50:31 GMT + - Wed, 13 Dec 2017 17:05:32 GMT Etag: - W/"1396-s0iIZ/It90SJrQVS5uVNRPfb7Cw" Server: @@ -77,5 +77,5 @@ http_interactions: Interview Score":"2 - worth consideration","Applying for":["recgrjFdvUdTGnDsF"],"Phone Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Move to in-person."},"createdTime":"2015-11-11T23:11:29.000Z"}]}' http_version: - recorded_at: Tue, 12 Dec 2017 13:50:34 GMT + recorded_at: Wed, 13 Dec 2017 17:05:44 GMT recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_/should_return_proper_Airtable_Entity_Record.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_records/_/should_return_proper_Airtable_Entity_Record.yml new file mode 100644 index 0000000..31fae60 --- /dev/null +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_records/_/should_return_proper_Airtable_Entity_Record.yml @@ -0,0 +1,81 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?pageSize=100 + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Wed, 13 Dec 2017 17:05:34 GMT + Etag: + - W/"1396-s0iIZ/It90SJrQVS5uVNRPfb7Cw" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '1770' + Connection: + - keep-alive + body: + encoding: ASCII-8BIT + string: '{"records":[{"id":"recQes7d2DCuEcGe0","fields":{"Email Address":"c.potato@example.com","Phone + Screen Score":"2 - worth consideration","Onsite Interview Date":"2013-02-14","Stage":"Decision + Needed","Onsite Interview Notes":"Seems like a really hard worker, and has + a great attitude. Very observant: He''s got eyes everywhere. But I am concerned + that he won''t be able to think outside the bag.","Phone":"(208) 555-0505","Phone + Screen Date":"2013-02-07","Name":"Chippy the Potato","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attgK5ha1ajpVWOSY","url":"https://dl.airtable.com/xwv2ejXtTBqbTqjcnbQi_Chippypotato.jpg","filename":"Chippypotato.jpg","size":59402,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/g7BoJwauSyOZIdtoQALf_Chippypotato.jpg","width":27,"height":36},"large":{"url":"https://dl.airtable.com/IRqeRk4sTkud3Ga12uYI_Chippypotato.jpg","width":256,"height":341}}},{"id":"attU5T1mSPQ8r3nJ6","url":"https://dl.airtable.com/e6kCK3Z0SUS6hhCUoKcD_chippypotatoresume.docx","filename":"chippypotatoresume.docx","size":127681,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-256x331.png","width":256,"height":331}}}],"Onsite + Interview Score":"2 - worth consideration","Applying for":["recYPK7fwrFojHO9H"],"Phone + Screen Interviewer":["recmtSmO51zqqWAqs"],"Phone Screen Notes":"Questionable, + but tentatively move to on-site interview"},"createdTime":"2015-11-11T23:05:58.000Z"},{"id":"recSIn39bSTqt4Swc","fields":{"Email + Address":"ohuxley@example.com","Phone Screen Score":"3 - good candidate","Onsite + Interview Date":"2013-02-19","Stage":"Interviewing","Onsite Interview Notes":"Owldous + was impressive overall, and he has a lot of experience in the corporate world. + But if he were to join with a sports team, he''d still have a lot to learn + about the industry, e.g., I asked him for his opinion on Leon the Lion''s + recent resignation and he responded, \"Whooo?\"","Phone":"(646) 555-4389","Phone + Screen Date":"2013-02-11","Name":"Owldous Huxley","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attxKBp3Hb4FJH2I8","url":"https://dl.airtable.com/1ghGlZqKTViBcofjpPMC_owl.jpg","filename":"owl.jpg","size":242886,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/dFcZepTFCeG0YUXQSOrA_owl.jpg","width":54,"height":36},"large":{"url":"https://dl.airtable.com/QSkAheQ2SFuD3txaTgge_owl.jpg","width":256,"height":171}}},{"id":"attZJp9i2TJ4Z5xeM","url":"https://dl.airtable.com/WwYtwDVURFCGHCoipxhM_Resume%20(O.%20Huxley).docx","filename":"Resume + (O. Huxley).docx","size":16443,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-512x663.png","width":512,"height":663}}}],"Onsite + Interview Score":"3 - good candidate","Applying for":["recgrjFdvUdTGnDsF"],"Phone + Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Good analytical + skills, very articulate. Proceed to in-person interview."},"createdTime":"2015-11-11T23:19:11.000Z"},{"id":"recxczR4BaLmvsdU2","fields":{"Email + Address":"hrmqueenlizzy@example.com","Phone Screen Score":"3 - good candidate","Onsite + Interview Date":"2013-02-17","Stage":"Decision Needed","Onsite Interview Notes":"Liz + was highly qualified, but seemed shifty. When faced with a question she was + uncomfortable with, she slithered out of it. Could be someone that is very + smart, but difficult to deal with. Not sure that she''s a team player, which + of course is a problem if you''re going to be representing a team.","Phone":"(865) + 123-4567","Phone Screen Date":"2013-02-12","Name":"Queen Elizardbeth II","Onsite + Interviewer":["recmtSmO51zqqWAqs"],"Attachments":[{"id":"attQ6vAjxpfVlQedd","url":"https://dl.airtable.com/EXBc35SWSKKHxhbTUbOe_elizardbeth.jpg","filename":"elizardbeth.jpg","size":465335,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/jl9v4DQWSx8IoqzgWRTg_elizardbeth.jpg","width":24,"height":36},"large":{"url":"https://dl.airtable.com/YHtaEY7mTie2ZpS5hfJz_elizardbeth.jpg","width":256,"height":388}}},{"id":"attZa28XJJt0GL3DB","url":"https://dl.airtable.com/XdCerQy8MqG5RacAKwdn_Q%20Elizardbeth%20Resume.docx","filename":"Q + Elizardbeth Resume.docx","size":6519,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-256x331.png","width":256,"height":331}}}],"Onsite + Interview Score":"2 - worth consideration","Applying for":["recgrjFdvUdTGnDsF"],"Phone + Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Move to in-person."},"createdTime":"2015-11-11T23:11:29.000Z"}]}' + http_version: + recorded_at: Wed, 13 Dec 2017 17:05:45 GMT +recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_limit_2_/should_return_all_records_records.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_records/_limit_2_/should_return_all_records_records.yml new file mode 100644 index 0000000..2f59f17 --- /dev/null +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_records/_limit_2_/should_return_all_records_records.yml @@ -0,0 +1,128 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?pageSize=2 + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Wed, 13 Dec 2017 17:05:36 GMT + Etag: + - W/"d1c-Tk2c9qMrGDaL3N5k+FE7QYB2E8s" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '1366' + Connection: + - keep-alive + body: + encoding: ASCII-8BIT + string: '{"records":[{"id":"recQes7d2DCuEcGe0","fields":{"Email Address":"c.potato@example.com","Phone + Screen Score":"2 - worth consideration","Onsite Interview Date":"2013-02-14","Stage":"Decision + Needed","Onsite Interview Notes":"Seems like a really hard worker, and has + a great attitude. Very observant: He''s got eyes everywhere. But I am concerned + that he won''t be able to think outside the bag.","Phone":"(208) 555-0505","Phone + Screen Date":"2013-02-07","Name":"Chippy the Potato","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attgK5ha1ajpVWOSY","url":"https://dl.airtable.com/xwv2ejXtTBqbTqjcnbQi_Chippypotato.jpg","filename":"Chippypotato.jpg","size":59402,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/g7BoJwauSyOZIdtoQALf_Chippypotato.jpg","width":27,"height":36},"large":{"url":"https://dl.airtable.com/IRqeRk4sTkud3Ga12uYI_Chippypotato.jpg","width":256,"height":341}}},{"id":"attU5T1mSPQ8r3nJ6","url":"https://dl.airtable.com/e6kCK3Z0SUS6hhCUoKcD_chippypotatoresume.docx","filename":"chippypotatoresume.docx","size":127681,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-256x331.png","width":256,"height":331}}}],"Onsite + Interview Score":"2 - worth consideration","Applying for":["recYPK7fwrFojHO9H"],"Phone + Screen Interviewer":["recmtSmO51zqqWAqs"],"Phone Screen Notes":"Questionable, + but tentatively move to on-site interview"},"createdTime":"2015-11-11T23:05:58.000Z"},{"id":"recSIn39bSTqt4Swc","fields":{"Email + Address":"ohuxley@example.com","Phone Screen Score":"3 - good candidate","Onsite + Interview Date":"2013-02-19","Stage":"Interviewing","Onsite Interview Notes":"Owldous + was impressive overall, and he has a lot of experience in the corporate world. + But if he were to join with a sports team, he''d still have a lot to learn + about the industry, e.g., I asked him for his opinion on Leon the Lion''s + recent resignation and he responded, \"Whooo?\"","Phone":"(646) 555-4389","Phone + Screen Date":"2013-02-11","Name":"Owldous Huxley","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attxKBp3Hb4FJH2I8","url":"https://dl.airtable.com/1ghGlZqKTViBcofjpPMC_owl.jpg","filename":"owl.jpg","size":242886,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/dFcZepTFCeG0YUXQSOrA_owl.jpg","width":54,"height":36},"large":{"url":"https://dl.airtable.com/QSkAheQ2SFuD3txaTgge_owl.jpg","width":256,"height":171}}},{"id":"attZJp9i2TJ4Z5xeM","url":"https://dl.airtable.com/WwYtwDVURFCGHCoipxhM_Resume%20(O.%20Huxley).docx","filename":"Resume + (O. Huxley).docx","size":16443,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-512x663.png","width":512,"height":663}}}],"Onsite + Interview Score":"3 - good candidate","Applying for":["recgrjFdvUdTGnDsF"],"Phone + Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Good analytical + skills, very articulate. Proceed to in-person interview."},"createdTime":"2015-11-11T23:19:11.000Z"}],"offset":"itrbhxUibpjx0UlZm/recSIn39bSTqt4Swc"}' + http_version: + recorded_at: Wed, 13 Dec 2017 17:05:47 GMT +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?offset=itrbhxUibpjx0UlZm/recSIn39bSTqt4Swc&pageSize=2 + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Wed, 13 Dec 2017 17:05:37 GMT + Etag: + - W/"6b6-xICWluzkgs/5ZR7gBgQHpK8U1Z8" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '889' + Connection: + - keep-alive + body: + encoding: ASCII-8BIT + string: '{"records":[{"id":"recxczR4BaLmvsdU2","fields":{"Email Address":"hrmqueenlizzy@example.com","Phone + Screen Score":"3 - good candidate","Onsite Interview Date":"2013-02-17","Stage":"Decision + Needed","Onsite Interview Notes":"Liz was highly qualified, but seemed shifty. + When faced with a question she was uncomfortable with, she slithered out of + it. Could be someone that is very smart, but difficult to deal with. Not sure + that she''s a team player, which of course is a problem if you''re going to + be representing a team.","Phone":"(865) 123-4567","Phone Screen Date":"2013-02-12","Name":"Queen + Elizardbeth II","Onsite Interviewer":["recmtSmO51zqqWAqs"],"Attachments":[{"id":"attQ6vAjxpfVlQedd","url":"https://dl.airtable.com/EXBc35SWSKKHxhbTUbOe_elizardbeth.jpg","filename":"elizardbeth.jpg","size":465335,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/jl9v4DQWSx8IoqzgWRTg_elizardbeth.jpg","width":24,"height":36},"large":{"url":"https://dl.airtable.com/YHtaEY7mTie2ZpS5hfJz_elizardbeth.jpg","width":256,"height":388}}},{"id":"attZa28XJJt0GL3DB","url":"https://dl.airtable.com/XdCerQy8MqG5RacAKwdn_Q%20Elizardbeth%20Resume.docx","filename":"Q + Elizardbeth Resume.docx","size":6519,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-256x331.png","width":256,"height":331}}}],"Onsite + Interview Score":"2 - worth consideration","Applying for":["recgrjFdvUdTGnDsF"],"Phone + Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Move to in-person."},"createdTime":"2015-11-11T23:11:29.000Z"}]}' + http_version: + recorded_at: Wed, 13 Dec 2017 17:05:48 GMT +recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_max_records_2_/should_return_only_2_records.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_records/_max_records_2_/should_return_only_2_records.yml new file mode 100644 index 0000000..2c177b1 --- /dev/null +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_records/_max_records_2_/should_return_only_2_records.yml @@ -0,0 +1,70 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?maxRecords=2&pageSize=100 + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Wed, 13 Dec 2017 17:05:35 GMT + Etag: + - W/"ced-6o2UVrJhZP7zN/aRnJse9JOltJM" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '1343' + Connection: + - keep-alive + body: + encoding: ASCII-8BIT + string: '{"records":[{"id":"recQes7d2DCuEcGe0","fields":{"Email Address":"c.potato@example.com","Phone + Screen Score":"2 - worth consideration","Onsite Interview Date":"2013-02-14","Stage":"Decision + Needed","Onsite Interview Notes":"Seems like a really hard worker, and has + a great attitude. Very observant: He''s got eyes everywhere. But I am concerned + that he won''t be able to think outside the bag.","Phone":"(208) 555-0505","Phone + Screen Date":"2013-02-07","Name":"Chippy the Potato","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attgK5ha1ajpVWOSY","url":"https://dl.airtable.com/xwv2ejXtTBqbTqjcnbQi_Chippypotato.jpg","filename":"Chippypotato.jpg","size":59402,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/g7BoJwauSyOZIdtoQALf_Chippypotato.jpg","width":27,"height":36},"large":{"url":"https://dl.airtable.com/IRqeRk4sTkud3Ga12uYI_Chippypotato.jpg","width":256,"height":341}}},{"id":"attU5T1mSPQ8r3nJ6","url":"https://dl.airtable.com/e6kCK3Z0SUS6hhCUoKcD_chippypotatoresume.docx","filename":"chippypotatoresume.docx","size":127681,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-256x331.png","width":256,"height":331}}}],"Onsite + Interview Score":"2 - worth consideration","Applying for":["recYPK7fwrFojHO9H"],"Phone + Screen Interviewer":["recmtSmO51zqqWAqs"],"Phone Screen Notes":"Questionable, + but tentatively move to on-site interview"},"createdTime":"2015-11-11T23:05:58.000Z"},{"id":"recSIn39bSTqt4Swc","fields":{"Email + Address":"ohuxley@example.com","Phone Screen Score":"3 - good candidate","Onsite + Interview Date":"2013-02-19","Stage":"Interviewing","Onsite Interview Notes":"Owldous + was impressive overall, and he has a lot of experience in the corporate world. + But if he were to join with a sports team, he''d still have a lot to learn + about the industry, e.g., I asked him for his opinion on Leon the Lion''s + recent resignation and he responded, \"Whooo?\"","Phone":"(646) 555-4389","Phone + Screen Date":"2013-02-11","Name":"Owldous Huxley","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attxKBp3Hb4FJH2I8","url":"https://dl.airtable.com/1ghGlZqKTViBcofjpPMC_owl.jpg","filename":"owl.jpg","size":242886,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/dFcZepTFCeG0YUXQSOrA_owl.jpg","width":54,"height":36},"large":{"url":"https://dl.airtable.com/QSkAheQ2SFuD3txaTgge_owl.jpg","width":256,"height":171}}},{"id":"attZJp9i2TJ4Z5xeM","url":"https://dl.airtable.com/WwYtwDVURFCGHCoipxhM_Resume%20(O.%20Huxley).docx","filename":"Resume + (O. Huxley).docx","size":16443,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-512x663.png","width":512,"height":663}}}],"Onsite + Interview Score":"3 - good candidate","Applying for":["recgrjFdvUdTGnDsF"],"Phone + Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Good analytical + skills, very articulate. Proceed to in-person interview."},"createdTime":"2015-11-11T23:19:11.000Z"}]}' + http_version: + recorded_at: Wed, 13 Dec 2017 17:05:46 GMT +recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_/should_sort_records_by_Name.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_/should_sort_records_by_Name.yml new file mode 100644 index 0000000..aabb83d --- /dev/null +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_/should_sort_records_by_Name.yml @@ -0,0 +1,81 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?pageSize=100&sort%5B%5D%5Bdirection%5D=asc&sort%5B%5D%5Bfield%5D=Name + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Wed, 13 Dec 2017 16:26:48 GMT + Etag: + - W/"1396-s0iIZ/It90SJrQVS5uVNRPfb7Cw" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '1770' + Connection: + - keep-alive + body: + encoding: ASCII-8BIT + string: '{"records":[{"id":"recQes7d2DCuEcGe0","fields":{"Email Address":"c.potato@example.com","Phone + Screen Score":"2 - worth consideration","Onsite Interview Date":"2013-02-14","Stage":"Decision + Needed","Onsite Interview Notes":"Seems like a really hard worker, and has + a great attitude. Very observant: He''s got eyes everywhere. But I am concerned + that he won''t be able to think outside the bag.","Phone":"(208) 555-0505","Phone + Screen Date":"2013-02-07","Name":"Chippy the Potato","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attgK5ha1ajpVWOSY","url":"https://dl.airtable.com/xwv2ejXtTBqbTqjcnbQi_Chippypotato.jpg","filename":"Chippypotato.jpg","size":59402,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/g7BoJwauSyOZIdtoQALf_Chippypotato.jpg","width":27,"height":36},"large":{"url":"https://dl.airtable.com/IRqeRk4sTkud3Ga12uYI_Chippypotato.jpg","width":256,"height":341}}},{"id":"attU5T1mSPQ8r3nJ6","url":"https://dl.airtable.com/e6kCK3Z0SUS6hhCUoKcD_chippypotatoresume.docx","filename":"chippypotatoresume.docx","size":127681,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-256x331.png","width":256,"height":331}}}],"Onsite + Interview Score":"2 - worth consideration","Applying for":["recYPK7fwrFojHO9H"],"Phone + Screen Interviewer":["recmtSmO51zqqWAqs"],"Phone Screen Notes":"Questionable, + but tentatively move to on-site interview"},"createdTime":"2015-11-11T23:05:58.000Z"},{"id":"recSIn39bSTqt4Swc","fields":{"Email + Address":"ohuxley@example.com","Phone Screen Score":"3 - good candidate","Onsite + Interview Date":"2013-02-19","Stage":"Interviewing","Onsite Interview Notes":"Owldous + was impressive overall, and he has a lot of experience in the corporate world. + But if he were to join with a sports team, he''d still have a lot to learn + about the industry, e.g., I asked him for his opinion on Leon the Lion''s + recent resignation and he responded, \"Whooo?\"","Phone":"(646) 555-4389","Phone + Screen Date":"2013-02-11","Name":"Owldous Huxley","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attxKBp3Hb4FJH2I8","url":"https://dl.airtable.com/1ghGlZqKTViBcofjpPMC_owl.jpg","filename":"owl.jpg","size":242886,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/dFcZepTFCeG0YUXQSOrA_owl.jpg","width":54,"height":36},"large":{"url":"https://dl.airtable.com/QSkAheQ2SFuD3txaTgge_owl.jpg","width":256,"height":171}}},{"id":"attZJp9i2TJ4Z5xeM","url":"https://dl.airtable.com/WwYtwDVURFCGHCoipxhM_Resume%20(O.%20Huxley).docx","filename":"Resume + (O. Huxley).docx","size":16443,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-512x663.png","width":512,"height":663}}}],"Onsite + Interview Score":"3 - good candidate","Applying for":["recgrjFdvUdTGnDsF"],"Phone + Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Good analytical + skills, very articulate. Proceed to in-person interview."},"createdTime":"2015-11-11T23:19:11.000Z"},{"id":"recxczR4BaLmvsdU2","fields":{"Email + Address":"hrmqueenlizzy@example.com","Phone Screen Score":"3 - good candidate","Onsite + Interview Date":"2013-02-17","Stage":"Decision Needed","Onsite Interview Notes":"Liz + was highly qualified, but seemed shifty. When faced with a question she was + uncomfortable with, she slithered out of it. Could be someone that is very + smart, but difficult to deal with. Not sure that she''s a team player, which + of course is a problem if you''re going to be representing a team.","Phone":"(865) + 123-4567","Phone Screen Date":"2013-02-12","Name":"Queen Elizardbeth II","Onsite + Interviewer":["recmtSmO51zqqWAqs"],"Attachments":[{"id":"attQ6vAjxpfVlQedd","url":"https://dl.airtable.com/EXBc35SWSKKHxhbTUbOe_elizardbeth.jpg","filename":"elizardbeth.jpg","size":465335,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/jl9v4DQWSx8IoqzgWRTg_elizardbeth.jpg","width":24,"height":36},"large":{"url":"https://dl.airtable.com/YHtaEY7mTie2ZpS5hfJz_elizardbeth.jpg","width":256,"height":388}}},{"id":"attZa28XJJt0GL3DB","url":"https://dl.airtable.com/XdCerQy8MqG5RacAKwdn_Q%20Elizardbeth%20Resume.docx","filename":"Q + Elizardbeth Resume.docx","size":6519,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-256x331.png","width":256,"height":331}}}],"Onsite + Interview Score":"2 - worth consideration","Applying for":["recgrjFdvUdTGnDsF"],"Phone + Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Move to in-person."},"createdTime":"2015-11-11T23:11:29.000Z"}]}' + http_version: + recorded_at: Wed, 13 Dec 2017 16:26:57 GMT +recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_desc_max_records_2_/should_sort_records_by_Name.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_desc_max_records_2_/should_sort_records_by_Name.yml new file mode 100644 index 0000000..0da2234 --- /dev/null +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_desc_max_records_2_/should_sort_records_by_Name.yml @@ -0,0 +1,72 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?maxRecords=2&pageSize=100&sort%5B%5D%5Bdirection%5D=desc&sort%5B%5D%5Bfield%5D=Name + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Wed, 13 Dec 2017 17:12:37 GMT + Etag: + - W/"d4f-L2lRYBpsRwSZC+5vIYM5rC9fB+4" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '1386' + Connection: + - keep-alive + body: + encoding: ASCII-8BIT + string: '{"records":[{"id":"recxczR4BaLmvsdU2","fields":{"Email Address":"hrmqueenlizzy@example.com","Phone + Screen Score":"3 - good candidate","Onsite Interview Date":"2013-02-17","Stage":"Decision + Needed","Onsite Interview Notes":"Liz was highly qualified, but seemed shifty. + When faced with a question she was uncomfortable with, she slithered out of + it. Could be someone that is very smart, but difficult to deal with. Not sure + that she''s a team player, which of course is a problem if you''re going to + be representing a team.","Phone":"(865) 123-4567","Phone Screen Date":"2013-02-12","Name":"Queen + Elizardbeth II","Onsite Interviewer":["recmtSmO51zqqWAqs"],"Attachments":[{"id":"attQ6vAjxpfVlQedd","url":"https://dl.airtable.com/EXBc35SWSKKHxhbTUbOe_elizardbeth.jpg","filename":"elizardbeth.jpg","size":465335,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/jl9v4DQWSx8IoqzgWRTg_elizardbeth.jpg","width":24,"height":36},"large":{"url":"https://dl.airtable.com/YHtaEY7mTie2ZpS5hfJz_elizardbeth.jpg","width":256,"height":388}}},{"id":"attZa28XJJt0GL3DB","url":"https://dl.airtable.com/XdCerQy8MqG5RacAKwdn_Q%20Elizardbeth%20Resume.docx","filename":"Q + Elizardbeth Resume.docx","size":6519,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-256x331.png","width":256,"height":331}}}],"Onsite + Interview Score":"2 - worth consideration","Applying for":["recgrjFdvUdTGnDsF"],"Phone + Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Move to in-person."},"createdTime":"2015-11-11T23:11:29.000Z"},{"id":"recSIn39bSTqt4Swc","fields":{"Email + Address":"ohuxley@example.com","Phone Screen Score":"3 - good candidate","Onsite + Interview Date":"2013-02-19","Stage":"Interviewing","Onsite Interview Notes":"Owldous + was impressive overall, and he has a lot of experience in the corporate world. + But if he were to join with a sports team, he''d still have a lot to learn + about the industry, e.g., I asked him for his opinion on Leon the Lion''s + recent resignation and he responded, \"Whooo?\"","Phone":"(646) 555-4389","Phone + Screen Date":"2013-02-11","Name":"Owldous Huxley","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attxKBp3Hb4FJH2I8","url":"https://dl.airtable.com/1ghGlZqKTViBcofjpPMC_owl.jpg","filename":"owl.jpg","size":242886,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/dFcZepTFCeG0YUXQSOrA_owl.jpg","width":54,"height":36},"large":{"url":"https://dl.airtable.com/QSkAheQ2SFuD3txaTgge_owl.jpg","width":256,"height":171}}},{"id":"attZJp9i2TJ4Z5xeM","url":"https://dl.airtable.com/WwYtwDVURFCGHCoipxhM_Resume%20(O.%20Huxley).docx","filename":"Resume + (O. Huxley).docx","size":16443,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-512x663.png","width":512,"height":663}}}],"Onsite + Interview Score":"3 - good candidate","Applying for":["recgrjFdvUdTGnDsF"],"Phone + Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Good analytical + skills, very articulate. Proceed to in-person interview."},"createdTime":"2015-11-11T23:19:11.000Z"}]}' + http_version: + recorded_at: Wed, 13 Dec 2017 17:12:48 GMT +recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_max_records_2_/should_sort_records_by_Name.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_max_records_2_/should_sort_records_by_Name.yml new file mode 100644 index 0000000..0b793bf --- /dev/null +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_max_records_2_/should_sort_records_by_Name.yml @@ -0,0 +1,70 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?maxRecords=2&pageSize=100&sort%5B%5D%5Bdirection%5D=asc&sort%5B%5D%5Bfield%5D=Name + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Wed, 13 Dec 2017 17:05:02 GMT + Etag: + - W/"ced-6o2UVrJhZP7zN/aRnJse9JOltJM" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '1343' + Connection: + - keep-alive + body: + encoding: ASCII-8BIT + string: '{"records":[{"id":"recQes7d2DCuEcGe0","fields":{"Email Address":"c.potato@example.com","Phone + Screen Score":"2 - worth consideration","Onsite Interview Date":"2013-02-14","Stage":"Decision + Needed","Onsite Interview Notes":"Seems like a really hard worker, and has + a great attitude. Very observant: He''s got eyes everywhere. But I am concerned + that he won''t be able to think outside the bag.","Phone":"(208) 555-0505","Phone + Screen Date":"2013-02-07","Name":"Chippy the Potato","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attgK5ha1ajpVWOSY","url":"https://dl.airtable.com/xwv2ejXtTBqbTqjcnbQi_Chippypotato.jpg","filename":"Chippypotato.jpg","size":59402,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/g7BoJwauSyOZIdtoQALf_Chippypotato.jpg","width":27,"height":36},"large":{"url":"https://dl.airtable.com/IRqeRk4sTkud3Ga12uYI_Chippypotato.jpg","width":256,"height":341}}},{"id":"attU5T1mSPQ8r3nJ6","url":"https://dl.airtable.com/e6kCK3Z0SUS6hhCUoKcD_chippypotatoresume.docx","filename":"chippypotatoresume.docx","size":127681,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-256x331.png","width":256,"height":331}}}],"Onsite + Interview Score":"2 - worth consideration","Applying for":["recYPK7fwrFojHO9H"],"Phone + Screen Interviewer":["recmtSmO51zqqWAqs"],"Phone Screen Notes":"Questionable, + but tentatively move to on-site interview"},"createdTime":"2015-11-11T23:05:58.000Z"},{"id":"recSIn39bSTqt4Swc","fields":{"Email + Address":"ohuxley@example.com","Phone Screen Score":"3 - good candidate","Onsite + Interview Date":"2013-02-19","Stage":"Interviewing","Onsite Interview Notes":"Owldous + was impressive overall, and he has a lot of experience in the corporate world. + But if he were to join with a sports team, he''d still have a lot to learn + about the industry, e.g., I asked him for his opinion on Leon the Lion''s + recent resignation and he responded, \"Whooo?\"","Phone":"(646) 555-4389","Phone + Screen Date":"2013-02-11","Name":"Owldous Huxley","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attxKBp3Hb4FJH2I8","url":"https://dl.airtable.com/1ghGlZqKTViBcofjpPMC_owl.jpg","filename":"owl.jpg","size":242886,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/dFcZepTFCeG0YUXQSOrA_owl.jpg","width":54,"height":36},"large":{"url":"https://dl.airtable.com/QSkAheQ2SFuD3txaTgge_owl.jpg","width":256,"height":171}}},{"id":"attZJp9i2TJ4Z5xeM","url":"https://dl.airtable.com/WwYtwDVURFCGHCoipxhM_Resume%20(O.%20Huxley).docx","filename":"Resume + (O. Huxley).docx","size":16443,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-512x663.png","width":512,"height":663}}}],"Onsite + Interview Score":"3 - good candidate","Applying for":["recgrjFdvUdTGnDsF"],"Phone + Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Good analytical + skills, very articulate. Proceed to in-person interview."},"createdTime":"2015-11-11T23:19:11.000Z"}]}' + http_version: + recorded_at: Wed, 13 Dec 2017 17:05:14 GMT +recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_field_Name_direction_desc_max_records_2_/should_sort_records_by_Name.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_field_Name_direction_desc_max_records_2_/should_sort_records_by_Name.yml new file mode 100644 index 0000000..afb2219 --- /dev/null +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_field_Name_direction_desc_max_records_2_/should_sort_records_by_Name.yml @@ -0,0 +1,72 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?maxRecords=2&pageSize=100&sort%5B%5D%5Bdirection%5D=desc&sort%5B%5D%5Bfield%5D=Name + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Wed, 13 Dec 2017 17:23:43 GMT + Etag: + - W/"d4f-L2lRYBpsRwSZC+5vIYM5rC9fB+4" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '1386' + Connection: + - keep-alive + body: + encoding: ASCII-8BIT + string: '{"records":[{"id":"recxczR4BaLmvsdU2","fields":{"Email Address":"hrmqueenlizzy@example.com","Phone + Screen Score":"3 - good candidate","Onsite Interview Date":"2013-02-17","Stage":"Decision + Needed","Onsite Interview Notes":"Liz was highly qualified, but seemed shifty. + When faced with a question she was uncomfortable with, she slithered out of + it. Could be someone that is very smart, but difficult to deal with. Not sure + that she''s a team player, which of course is a problem if you''re going to + be representing a team.","Phone":"(865) 123-4567","Phone Screen Date":"2013-02-12","Name":"Queen + Elizardbeth II","Onsite Interviewer":["recmtSmO51zqqWAqs"],"Attachments":[{"id":"attQ6vAjxpfVlQedd","url":"https://dl.airtable.com/EXBc35SWSKKHxhbTUbOe_elizardbeth.jpg","filename":"elizardbeth.jpg","size":465335,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/jl9v4DQWSx8IoqzgWRTg_elizardbeth.jpg","width":24,"height":36},"large":{"url":"https://dl.airtable.com/YHtaEY7mTie2ZpS5hfJz_elizardbeth.jpg","width":256,"height":388}}},{"id":"attZa28XJJt0GL3DB","url":"https://dl.airtable.com/XdCerQy8MqG5RacAKwdn_Q%20Elizardbeth%20Resume.docx","filename":"Q + Elizardbeth Resume.docx","size":6519,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-256x331.png","width":256,"height":331}}}],"Onsite + Interview Score":"2 - worth consideration","Applying for":["recgrjFdvUdTGnDsF"],"Phone + Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Move to in-person."},"createdTime":"2015-11-11T23:11:29.000Z"},{"id":"recSIn39bSTqt4Swc","fields":{"Email + Address":"ohuxley@example.com","Phone Screen Score":"3 - good candidate","Onsite + Interview Date":"2013-02-19","Stage":"Interviewing","Onsite Interview Notes":"Owldous + was impressive overall, and he has a lot of experience in the corporate world. + But if he were to join with a sports team, he''d still have a lot to learn + about the industry, e.g., I asked him for his opinion on Leon the Lion''s + recent resignation and he responded, \"Whooo?\"","Phone":"(646) 555-4389","Phone + Screen Date":"2013-02-11","Name":"Owldous Huxley","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attxKBp3Hb4FJH2I8","url":"https://dl.airtable.com/1ghGlZqKTViBcofjpPMC_owl.jpg","filename":"owl.jpg","size":242886,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/dFcZepTFCeG0YUXQSOrA_owl.jpg","width":54,"height":36},"large":{"url":"https://dl.airtable.com/QSkAheQ2SFuD3txaTgge_owl.jpg","width":256,"height":171}}},{"id":"attZJp9i2TJ4Z5xeM","url":"https://dl.airtable.com/WwYtwDVURFCGHCoipxhM_Resume%20(O.%20Huxley).docx","filename":"Resume + (O. Huxley).docx","size":16443,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-512x663.png","width":512,"height":663}}}],"Onsite + Interview Score":"3 - good candidate","Applying for":["recgrjFdvUdTGnDsF"],"Phone + Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Good analytical + skills, very articulate. Proceed to in-person interview."},"createdTime":"2015-11-11T23:19:11.000Z"}]}' + http_version: + recorded_at: Wed, 13 Dec 2017 17:23:55 GMT +recorded_with: VCR 4.0.0 From 11f0d0b39b16b5de8331b67dc0d2c379360ed047 Mon Sep 17 00:00:00 2001 From: Alexander Simonov Date: Thu, 14 Dec 2017 19:04:15 +0200 Subject: [PATCH 04/11] Make calls more like in Airtable.js --- lib/airtable/client.rb | 2 +- lib/airtable/entity/base.rb | 18 ++++- lib/airtable/entity/record.rb | 49 +++++++++++ lib/airtable/entity/table.rb | 42 +++++----- spec/lib/airtable/entity/table_spec.rb | 60 ++++++++++---- .../_find/should_return_a_one_record.yml | 59 ++++++++++++++ .../should_sort_records_by_Name.yml | 81 ------------------- ...return_array_of_Airtable_Entity_Record.yml | 4 +- ...d_return_proper_Airtable_Entity_Record.yml | 4 +- .../should_return_all_records_records.yml | 16 ++-- .../should_return_only_2_records.yml | 4 +- .../should_sort_records_by_Name.yml | 4 +- .../should_sort_records_by_Name.yml | 4 +- .../should_sort_records_by_Name.yml | 4 +- 14 files changed, 208 insertions(+), 143 deletions(-) create mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_find/should_return_a_one_record.yml delete mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_/should_sort_records_by_Name.yml rename spec/vcr_cassettes/Airtable_Entity_Table/{_records => _select}/_/should_return_array_of_Airtable_Entity_Record.yml (98%) rename spec/vcr_cassettes/Airtable_Entity_Table/{_records => _select}/_/should_return_proper_Airtable_Entity_Record.yml (98%) rename spec/vcr_cassettes/Airtable_Entity_Table/{_records => _select}/_limit_2_/should_return_all_records_records.yml (95%) rename spec/vcr_cassettes/Airtable_Entity_Table/{_records => _select}/_max_records_2_/should_return_only_2_records.yml (98%) rename spec/vcr_cassettes/Airtable_Entity_Table/{_records => _select}/_sort_Name_desc_max_records_2_/should_sort_records_by_Name.yml (98%) rename spec/vcr_cassettes/Airtable_Entity_Table/{_records => _select}/_sort_Name_max_records_2_/should_sort_records_by_Name.yml (98%) rename spec/vcr_cassettes/Airtable_Entity_Table/{_records => _select}/_sort_field_Name_direction_desc_max_records_2_/should_sort_records_by_Name.yml (98%) diff --git a/lib/airtable/client.rb b/lib/airtable/client.rb index 035d5b4..0b5c9e2 100644 --- a/lib/airtable/client.rb +++ b/lib/airtable/client.rb @@ -16,7 +16,7 @@ def initialize(api_key = nil) # @param id [String] Id of Base on Airtable # @return [::Airtable::Entity::Base] Airtable Base entity object def base(id) - ::Airtable::Entity::Base.new(id, self) + ::Airtable::Entity::Base.new(self, id) end # table("appXXV84QuCy2BPgLk", "Sheet Name") diff --git a/lib/airtable/entity/base.rb b/lib/airtable/entity/base.rb index 59f0b80..31f5f33 100644 --- a/lib/airtable/entity/base.rb +++ b/lib/airtable/entity/base.rb @@ -2,13 +2,27 @@ module Airtable module Entity # Airtable Base entity class Base - def initialize(id, client) + def initialize(client, id) @id = id @client = client end def table(name) - ::Airtable::Entity::Table.new(name, @id, @client) + ::Airtable::Entity::Table.new(self, name) + end + + def __make_request__(method, path, data) + url = [::Airtable.server_url, @id, path].join('/') + resp = ::Airtable::Request.new(url, data, @client.api_key).request(method) + if resp.success? + resp.result + else + raise_correct_error_for(resp) + end + end + + def raise_correct_error_for(resp) + ; end end end diff --git a/lib/airtable/entity/record.rb b/lib/airtable/entity/record.rb index b4d988e..e831364 100644 --- a/lib/airtable/entity/record.rb +++ b/lib/airtable/entity/record.rb @@ -12,6 +12,34 @@ def initialize(id, options = {}) parse_options(options) end + def new_record? + @id.nil? || @id.empty? + end + + def save(url, api_key) + if new_record? + __create__(url, api_key) + else + __update__ + end + end + + def __create__(url, api_key) + resp = ::Airtable::Request.new(url, {}, api_key).request(:get) + if resp.success? + + end + end + + def __update__ + end + + def __fetch__(base, path) + res = base.__make_request__(:get, path, {}) + parse_options(fields: res['fields'], created_at: res['createdTime']) + self + end + def [](key) @fields[key.to_s] end @@ -20,6 +48,27 @@ def []=(key, value) @fields[key.to_s] = value end + class << self + def all(base, name, params) + res = [] + __fetch__(base, name, params, res) + res + end + + private + + def __fetch__(base, name, params, res) + result = base.__make_request__(:get, name, params) + result['records'].each do |item| + res << new(item['id'], fields: item['fields'], created_at: item['createdTime']) + end + if result['offset'] + __fetch__(base, name, params.merge(offset: result['offset']), res) + end + end + + end + private def parse_options(options = {}) diff --git a/lib/airtable/entity/table.rb b/lib/airtable/entity/table.rb index 19f547a..cf5f6b9 100644 --- a/lib/airtable/entity/table.rb +++ b/lib/airtable/entity/table.rb @@ -5,42 +5,38 @@ class Table PAGE_SIZE = 100 DEFAULT_DIRECTION = 'asc'.freeze - def initialize(name, base_id, client) - @name = name - @base_id = base_id - @client = client + def initialize(base, name) + @name = name + @base = base end - def records(options = {}) + def select(options = {}) params = {} update_default_params(params, options) update_sort_options(params, options) - res = [] - fetch_records(params.compact, res) - res + fetch_records(params.compact) end - def raise_correct_error_for(resp) - ; + def find(id) + ::Airtable::Entity::Record.new(id).__fetch__(@base, [@name, id].join('/')) end + def create(fields) + url = [::Airtable.server_url, @base_id, @name].join('/') + ::Airtable::Entity::Record.new(nil, fields: fields).save(url, @client.api_key) + end + + def update(id, fields) + end + + private + def option_value_for(hash, key) hash.delete(key) || hash.delete(key.to_s) end - def fetch_records(params, res) - url = [::Airtable.server_url, @base_id, @name].join('/') - resp = ::Airtable::Request.new(url, params, @client.api_key).request(:get) - if resp.success? - resp.result['records'].each do |item| - res << ::Airtable::Entity::Record.new(item['id'], fields: item['fields'], created_at: item['createdTime']) - end - if resp.result['offset'] - fetch_records(params.merge(offset: resp.result['offset']), res) - end - else - raise_correct_error_for(resp) - end + def fetch_records(params) + ::Airtable::Entity::Record.all(@base, @name, params) end def update_default_params(params, options) diff --git a/spec/lib/airtable/entity/table_spec.rb b/spec/lib/airtable/entity/table_spec.rb index f2b60db..1d0075a 100644 --- a/spec/lib/airtable/entity/table_spec.rb +++ b/spec/lib/airtable/entity/table_spec.rb @@ -4,16 +4,17 @@ RSpec.describe ::Airtable::Entity::Table, vcr: true do let(:client) {::Airtable::Client.new} let(:base_id) {'appnlJrQ2fxlfRsov'} - let(:table_entity) {described_class.new('Applicants', base_id, client)} - context '#records' do + let(:base) {::Airtable::Entity::Base.new(client, base_id) } + let(:table_entity) {described_class.new(base, 'Applicants')} + context '#select' do context '()' do it 'should return array of ::Airtable::Entity::Record' do - res = table_entity.records + res = table_entity.select expect(res).to be_a(::Array) expect(res.map(&:class).uniq).to eq([::Airtable::Entity::Record]) end it 'should return proper ::Airtable::Entity::Record' do - record = table_entity.records[0] + record = table_entity.select[0] expect(record.id).to eq('recQes7d2DCuEcGe0') expect(record.created_at).to be_a(::Time) expect(record.created_at).to eq(::Time.parse('2015-11-11T23:05:58.000Z')) @@ -27,13 +28,13 @@ context '({max_records:2})' do it 'should return only 2 records' do - expect(table_entity.records(max_records: 2).size).to eq(2) + expect(table_entity.select(max_records: 2).size).to eq(2) end end context '({limit: 2})' do it 'should return all records records' do - expect(table_entity.records(limit: 2).size).to eq(3) + expect(table_entity.select(limit: 2).size).to eq(3) end end @@ -44,8 +45,8 @@ sort: [{ field: 'Name', direction: described_class::DEFAULT_DIRECTION }], maxRecords: 2 } - expect(table_entity).to receive(:fetch_records).with(params, []).and_call_original - expect {table_entity.records(sort: 'Name', max_records: 2)}.to_not raise_error + expect(table_entity).to receive(:fetch_records).with(params).and_call_original + expect {table_entity.select(sort: 'Name', max_records: 2)}.to_not raise_error end end @@ -56,8 +57,8 @@ sort: [{ field: 'Name', direction: 'desc' }], maxRecords: 2 } - expect(table_entity).to receive(:fetch_records).with(params, []).and_call_original - expect {table_entity.records(sort: ['Name', 'desc'], max_records: 2)}.to_not raise_error + expect(table_entity).to receive(:fetch_records).with(params).and_call_original + expect {table_entity.select(sort: ['Name', 'desc'], max_records: 2)}.to_not raise_error end end @@ -68,8 +69,8 @@ sort: [{ field: 'Name', direction: 'desc' }], maxRecords: 2 } - expect(table_entity).to receive(:fetch_records).with(params, []).and_call_original - expect {table_entity.records(sort: [['Name', 'desc']], max_records: 2)}.to_not raise_error + expect(table_entity).to receive(:fetch_records).with(params).and_call_original + expect {table_entity.select(sort: [['Name', 'desc']], max_records: 2)}.to_not raise_error end end @@ -80,8 +81,8 @@ sort: [{ field: 'Name', direction: 'desc' }], maxRecords: 2 } - expect(table_entity).to receive(:fetch_records).with(params, []).and_call_original - expect {table_entity.records(sort: [{ field: 'Name', direction: 'desc' }], max_records: 2)}.to_not raise_error + expect(table_entity).to receive(:fetch_records).with(params).and_call_original + expect {table_entity.select(sort: [{ field: 'Name', direction: 'desc' }], max_records: 2)}.to_not raise_error end end @@ -92,10 +93,37 @@ sort: [{ field: 'Name', direction: 'desc' }], maxRecords: 2 } - expect(table_entity).to receive(:fetch_records).with(params, []).and_call_original - expect {table_entity.records(sort: { field: 'Name', direction: 'desc' }, max_records: 2)}.to_not raise_error + expect(table_entity).to receive(:fetch_records).with(params).and_call_original + expect {table_entity.select(sort: { field: 'Name', direction: 'desc' }, max_records: 2)}.to_not raise_error end end + context '({sort: ["Name", "desc", "other"]})' do + it 'should raise ::Airtable::SortOptionsError' do + expect { table_entity.select(sort: ["Name", "desc", "other"]) }.to raise_error(::Airtable::SortOptionsError) + end + end + + context '({sort: {feild: "Name", direction: "desc"}})' do + it 'should raise ::Airtable::SortOptionsError' do + expect { table_entity.select(sort: {feild: "Name", direction: "desc"}) }.to raise_error(::Airtable::SortOptionsError) + end + end + + end + context '#find' do + it 'should return a one record' do + record = table_entity.find('recQes7d2DCuEcGe0') + expect(record).to be_a(::Airtable::Entity::Record) + expect(record.id).to eq('recQes7d2DCuEcGe0') + expect(record.created_at).to be_a(::Time) + expect(record.created_at).to eq(::Time.parse('2015-11-11T23:05:58.000Z')) + expect(record.fields).to be_a(::Hash) + expect(record).to respond_to(:[]) + expect(record).to respond_to(:[]=) + expect(record['Name']).to eq('Chippy the Potato') + expect(record[:Name]).to eq('Chippy the Potato') + end end + end diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_find/should_return_a_one_record.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_find/should_return_a_one_record.yml new file mode 100644 index 0000000..411cafa --- /dev/null +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_find/should_return_a_one_record.yml @@ -0,0 +1,59 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants/recQes7d2DCuEcGe0 + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 14 Dec 2017 17:03:48 GMT + Etag: + - W/"646-c8+q4A2imuACKi9LmHulkyNZAhU" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '808' + Connection: + - keep-alive + body: + encoding: ASCII-8BIT + string: '{"id":"recQes7d2DCuEcGe0","fields":{"Email Address":"c.potato@example.com","Phone + Screen Score":"2 - worth consideration","Onsite Interview Date":"2013-02-14","Stage":"Decision + Needed","Onsite Interview Notes":"Seems like a really hard worker, and has + a great attitude. Very observant: He''s got eyes everywhere. But I am concerned + that he won''t be able to think outside the bag.","Phone":"(208) 555-0505","Phone + Screen Date":"2013-02-07","Name":"Chippy the Potato","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attgK5ha1ajpVWOSY","url":"https://dl.airtable.com/xwv2ejXtTBqbTqjcnbQi_Chippypotato.jpg","filename":"Chippypotato.jpg","size":59402,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/g7BoJwauSyOZIdtoQALf_Chippypotato.jpg","width":27,"height":36},"large":{"url":"https://dl.airtable.com/IRqeRk4sTkud3Ga12uYI_Chippypotato.jpg","width":256,"height":341}}},{"id":"attU5T1mSPQ8r3nJ6","url":"https://dl.airtable.com/e6kCK3Z0SUS6hhCUoKcD_chippypotatoresume.docx","filename":"chippypotatoresume.docx","size":127681,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-256x331.png","width":256,"height":331}}}],"Onsite + Interview Score":"2 - worth consideration","Applying for":["recYPK7fwrFojHO9H"],"Phone + Screen Interviewer":["recmtSmO51zqqWAqs"],"Phone Screen Notes":"Questionable, + but tentatively move to on-site interview"},"createdTime":"2015-11-11T23:05:58.000Z"}' + http_version: + recorded_at: Thu, 14 Dec 2017 17:03:50 GMT +recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_/should_sort_records_by_Name.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_/should_sort_records_by_Name.yml deleted file mode 100644 index aabb83d..0000000 --- a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_/should_sort_records_by_Name.yml +++ /dev/null @@ -1,81 +0,0 @@ ---- -http_interactions: -- request: - method: get - uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?pageSize=100&sort%5B%5D%5Bdirection%5D=asc&sort%5B%5D%5Bfield%5D=Name - body: - encoding: US-ASCII - string: '' - headers: - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - User-Agent: - - Ruby - Authorization: - - Bearer key3nfyQojyvXquoR - Content-Type: - - application/json - response: - status: - code: 200 - message: OK - headers: - Access-Control-Allow-Headers: - - content-type, authorization, content-length, x-requested-with, x-api-version, - x-airtable-application-id - Access-Control-Allow-Methods: - - GET,PUT,POST,PATCH,DELETE,OPTIONS - Access-Control-Allow-Origin: - - "*" - Content-Type: - - application/json; charset=utf-8 - Date: - - Wed, 13 Dec 2017 16:26:48 GMT - Etag: - - W/"1396-s0iIZ/It90SJrQVS5uVNRPfb7Cw" - Server: - - Tengine - Vary: - - Accept-Encoding - Content-Length: - - '1770' - Connection: - - keep-alive - body: - encoding: ASCII-8BIT - string: '{"records":[{"id":"recQes7d2DCuEcGe0","fields":{"Email Address":"c.potato@example.com","Phone - Screen Score":"2 - worth consideration","Onsite Interview Date":"2013-02-14","Stage":"Decision - Needed","Onsite Interview Notes":"Seems like a really hard worker, and has - a great attitude. Very observant: He''s got eyes everywhere. But I am concerned - that he won''t be able to think outside the bag.","Phone":"(208) 555-0505","Phone - Screen Date":"2013-02-07","Name":"Chippy the Potato","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attgK5ha1ajpVWOSY","url":"https://dl.airtable.com/xwv2ejXtTBqbTqjcnbQi_Chippypotato.jpg","filename":"Chippypotato.jpg","size":59402,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/g7BoJwauSyOZIdtoQALf_Chippypotato.jpg","width":27,"height":36},"large":{"url":"https://dl.airtable.com/IRqeRk4sTkud3Ga12uYI_Chippypotato.jpg","width":256,"height":341}}},{"id":"attU5T1mSPQ8r3nJ6","url":"https://dl.airtable.com/e6kCK3Z0SUS6hhCUoKcD_chippypotatoresume.docx","filename":"chippypotatoresume.docx","size":127681,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-256x331.png","width":256,"height":331}}}],"Onsite - Interview Score":"2 - worth consideration","Applying for":["recYPK7fwrFojHO9H"],"Phone - Screen Interviewer":["recmtSmO51zqqWAqs"],"Phone Screen Notes":"Questionable, - but tentatively move to on-site interview"},"createdTime":"2015-11-11T23:05:58.000Z"},{"id":"recSIn39bSTqt4Swc","fields":{"Email - Address":"ohuxley@example.com","Phone Screen Score":"3 - good candidate","Onsite - Interview Date":"2013-02-19","Stage":"Interviewing","Onsite Interview Notes":"Owldous - was impressive overall, and he has a lot of experience in the corporate world. - But if he were to join with a sports team, he''d still have a lot to learn - about the industry, e.g., I asked him for his opinion on Leon the Lion''s - recent resignation and he responded, \"Whooo?\"","Phone":"(646) 555-4389","Phone - Screen Date":"2013-02-11","Name":"Owldous Huxley","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attxKBp3Hb4FJH2I8","url":"https://dl.airtable.com/1ghGlZqKTViBcofjpPMC_owl.jpg","filename":"owl.jpg","size":242886,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/dFcZepTFCeG0YUXQSOrA_owl.jpg","width":54,"height":36},"large":{"url":"https://dl.airtable.com/QSkAheQ2SFuD3txaTgge_owl.jpg","width":256,"height":171}}},{"id":"attZJp9i2TJ4Z5xeM","url":"https://dl.airtable.com/WwYtwDVURFCGHCoipxhM_Resume%20(O.%20Huxley).docx","filename":"Resume - (O. Huxley).docx","size":16443,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-512x663.png","width":512,"height":663}}}],"Onsite - Interview Score":"3 - good candidate","Applying for":["recgrjFdvUdTGnDsF"],"Phone - Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Good analytical - skills, very articulate. Proceed to in-person interview."},"createdTime":"2015-11-11T23:19:11.000Z"},{"id":"recxczR4BaLmvsdU2","fields":{"Email - Address":"hrmqueenlizzy@example.com","Phone Screen Score":"3 - good candidate","Onsite - Interview Date":"2013-02-17","Stage":"Decision Needed","Onsite Interview Notes":"Liz - was highly qualified, but seemed shifty. When faced with a question she was - uncomfortable with, she slithered out of it. Could be someone that is very - smart, but difficult to deal with. Not sure that she''s a team player, which - of course is a problem if you''re going to be representing a team.","Phone":"(865) - 123-4567","Phone Screen Date":"2013-02-12","Name":"Queen Elizardbeth II","Onsite - Interviewer":["recmtSmO51zqqWAqs"],"Attachments":[{"id":"attQ6vAjxpfVlQedd","url":"https://dl.airtable.com/EXBc35SWSKKHxhbTUbOe_elizardbeth.jpg","filename":"elizardbeth.jpg","size":465335,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/jl9v4DQWSx8IoqzgWRTg_elizardbeth.jpg","width":24,"height":36},"large":{"url":"https://dl.airtable.com/YHtaEY7mTie2ZpS5hfJz_elizardbeth.jpg","width":256,"height":388}}},{"id":"attZa28XJJt0GL3DB","url":"https://dl.airtable.com/XdCerQy8MqG5RacAKwdn_Q%20Elizardbeth%20Resume.docx","filename":"Q - Elizardbeth Resume.docx","size":6519,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-256x331.png","width":256,"height":331}}}],"Onsite - Interview Score":"2 - worth consideration","Applying for":["recgrjFdvUdTGnDsF"],"Phone - Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Move to in-person."},"createdTime":"2015-11-11T23:11:29.000Z"}]}' - http_version: - recorded_at: Wed, 13 Dec 2017 16:26:57 GMT -recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_/should_return_array_of_Airtable_Entity_Record.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_/should_return_array_of_Airtable_Entity_Record.yml similarity index 98% rename from spec/vcr_cassettes/Airtable_Entity_Table/_records/_/should_return_array_of_Airtable_Entity_Record.yml rename to spec/vcr_cassettes/Airtable_Entity_Table/_select/_/should_return_array_of_Airtable_Entity_Record.yml index 5e3495e..0f162fd 100644 --- a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_/should_return_array_of_Airtable_Entity_Record.yml +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_/should_return_array_of_Airtable_Entity_Record.yml @@ -32,7 +32,7 @@ http_interactions: Content-Type: - application/json; charset=utf-8 Date: - - Wed, 13 Dec 2017 17:05:32 GMT + - Thu, 14 Dec 2017 17:03:37 GMT Etag: - W/"1396-s0iIZ/It90SJrQVS5uVNRPfb7Cw" Server: @@ -77,5 +77,5 @@ http_interactions: Interview Score":"2 - worth consideration","Applying for":["recgrjFdvUdTGnDsF"],"Phone Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Move to in-person."},"createdTime":"2015-11-11T23:11:29.000Z"}]}' http_version: - recorded_at: Wed, 13 Dec 2017 17:05:44 GMT + recorded_at: Thu, 14 Dec 2017 17:03:39 GMT recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_/should_return_proper_Airtable_Entity_Record.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_/should_return_proper_Airtable_Entity_Record.yml similarity index 98% rename from spec/vcr_cassettes/Airtable_Entity_Table/_records/_/should_return_proper_Airtable_Entity_Record.yml rename to spec/vcr_cassettes/Airtable_Entity_Table/_select/_/should_return_proper_Airtable_Entity_Record.yml index 31fae60..3602f18 100644 --- a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_/should_return_proper_Airtable_Entity_Record.yml +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_/should_return_proper_Airtable_Entity_Record.yml @@ -32,7 +32,7 @@ http_interactions: Content-Type: - application/json; charset=utf-8 Date: - - Wed, 13 Dec 2017 17:05:34 GMT + - Thu, 14 Dec 2017 17:03:39 GMT Etag: - W/"1396-s0iIZ/It90SJrQVS5uVNRPfb7Cw" Server: @@ -77,5 +77,5 @@ http_interactions: Interview Score":"2 - worth consideration","Applying for":["recgrjFdvUdTGnDsF"],"Phone Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Move to in-person."},"createdTime":"2015-11-11T23:11:29.000Z"}]}' http_version: - recorded_at: Wed, 13 Dec 2017 17:05:45 GMT + recorded_at: Thu, 14 Dec 2017 17:03:40 GMT recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_limit_2_/should_return_all_records_records.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_limit_2_/should_return_all_records_records.yml similarity index 95% rename from spec/vcr_cassettes/Airtable_Entity_Table/_records/_limit_2_/should_return_all_records_records.yml rename to spec/vcr_cassettes/Airtable_Entity_Table/_select/_limit_2_/should_return_all_records_records.yml index 2f59f17..f401386 100644 --- a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_limit_2_/should_return_all_records_records.yml +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_limit_2_/should_return_all_records_records.yml @@ -32,15 +32,15 @@ http_interactions: Content-Type: - application/json; charset=utf-8 Date: - - Wed, 13 Dec 2017 17:05:36 GMT + - Thu, 14 Dec 2017 17:03:41 GMT Etag: - - W/"d1c-Tk2c9qMrGDaL3N5k+FE7QYB2E8s" + - W/"d1c-G1Cnc71KD1wbzFDlYtOZhQUdERE" Server: - Tengine Vary: - Accept-Encoding Content-Length: - - '1366' + - '1367' Connection: - keep-alive body: @@ -64,12 +64,12 @@ http_interactions: (O. Huxley).docx","size":16443,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-512x663.png","width":512,"height":663}}}],"Onsite Interview Score":"3 - good candidate","Applying for":["recgrjFdvUdTGnDsF"],"Phone Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Good analytical - skills, very articulate. Proceed to in-person interview."},"createdTime":"2015-11-11T23:19:11.000Z"}],"offset":"itrbhxUibpjx0UlZm/recSIn39bSTqt4Swc"}' + skills, very articulate. Proceed to in-person interview."},"createdTime":"2015-11-11T23:19:11.000Z"}],"offset":"itrgpd7Fl6zYVgyIC/recSIn39bSTqt4Swc"}' http_version: - recorded_at: Wed, 13 Dec 2017 17:05:47 GMT + recorded_at: Thu, 14 Dec 2017 17:03:43 GMT - request: method: get - uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?offset=itrbhxUibpjx0UlZm/recSIn39bSTqt4Swc&pageSize=2 + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?offset=itrgpd7Fl6zYVgyIC/recSIn39bSTqt4Swc&pageSize=2 body: encoding: US-ASCII string: '' @@ -99,7 +99,7 @@ http_interactions: Content-Type: - application/json; charset=utf-8 Date: - - Wed, 13 Dec 2017 17:05:37 GMT + - Thu, 14 Dec 2017 17:03:42 GMT Etag: - W/"6b6-xICWluzkgs/5ZR7gBgQHpK8U1Z8" Server: @@ -124,5 +124,5 @@ http_interactions: Interview Score":"2 - worth consideration","Applying for":["recgrjFdvUdTGnDsF"],"Phone Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Move to in-person."},"createdTime":"2015-11-11T23:11:29.000Z"}]}' http_version: - recorded_at: Wed, 13 Dec 2017 17:05:48 GMT + recorded_at: Thu, 14 Dec 2017 17:03:44 GMT recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_max_records_2_/should_return_only_2_records.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_max_records_2_/should_return_only_2_records.yml similarity index 98% rename from spec/vcr_cassettes/Airtable_Entity_Table/_records/_max_records_2_/should_return_only_2_records.yml rename to spec/vcr_cassettes/Airtable_Entity_Table/_select/_max_records_2_/should_return_only_2_records.yml index 2c177b1..874711f 100644 --- a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_max_records_2_/should_return_only_2_records.yml +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_max_records_2_/should_return_only_2_records.yml @@ -32,7 +32,7 @@ http_interactions: Content-Type: - application/json; charset=utf-8 Date: - - Wed, 13 Dec 2017 17:05:35 GMT + - Thu, 14 Dec 2017 17:03:40 GMT Etag: - W/"ced-6o2UVrJhZP7zN/aRnJse9JOltJM" Server: @@ -66,5 +66,5 @@ http_interactions: Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Good analytical skills, very articulate. Proceed to in-person interview."},"createdTime":"2015-11-11T23:19:11.000Z"}]}' http_version: - recorded_at: Wed, 13 Dec 2017 17:05:46 GMT + recorded_at: Thu, 14 Dec 2017 17:03:42 GMT recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_desc_max_records_2_/should_sort_records_by_Name.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_sort_Name_desc_max_records_2_/should_sort_records_by_Name.yml similarity index 98% rename from spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_desc_max_records_2_/should_sort_records_by_Name.yml rename to spec/vcr_cassettes/Airtable_Entity_Table/_select/_sort_Name_desc_max_records_2_/should_sort_records_by_Name.yml index 0da2234..b6c2d32 100644 --- a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_desc_max_records_2_/should_sort_records_by_Name.yml +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_sort_Name_desc_max_records_2_/should_sort_records_by_Name.yml @@ -32,7 +32,7 @@ http_interactions: Content-Type: - application/json; charset=utf-8 Date: - - Wed, 13 Dec 2017 17:12:37 GMT + - Thu, 14 Dec 2017 17:03:46 GMT Etag: - W/"d4f-L2lRYBpsRwSZC+5vIYM5rC9fB+4" Server: @@ -68,5 +68,5 @@ http_interactions: Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Good analytical skills, very articulate. Proceed to in-person interview."},"createdTime":"2015-11-11T23:19:11.000Z"}]}' http_version: - recorded_at: Wed, 13 Dec 2017 17:12:48 GMT + recorded_at: Thu, 14 Dec 2017 17:03:48 GMT recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_max_records_2_/should_sort_records_by_Name.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_sort_Name_max_records_2_/should_sort_records_by_Name.yml similarity index 98% rename from spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_max_records_2_/should_sort_records_by_Name.yml rename to spec/vcr_cassettes/Airtable_Entity_Table/_select/_sort_Name_max_records_2_/should_sort_records_by_Name.yml index 0b793bf..41ef2b5 100644 --- a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_Name_max_records_2_/should_sort_records_by_Name.yml +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_sort_Name_max_records_2_/should_sort_records_by_Name.yml @@ -32,7 +32,7 @@ http_interactions: Content-Type: - application/json; charset=utf-8 Date: - - Wed, 13 Dec 2017 17:05:02 GMT + - Thu, 14 Dec 2017 17:03:44 GMT Etag: - W/"ced-6o2UVrJhZP7zN/aRnJse9JOltJM" Server: @@ -66,5 +66,5 @@ http_interactions: Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Good analytical skills, very articulate. Proceed to in-person interview."},"createdTime":"2015-11-11T23:19:11.000Z"}]}' http_version: - recorded_at: Wed, 13 Dec 2017 17:05:14 GMT + recorded_at: Thu, 14 Dec 2017 17:03:46 GMT recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_field_Name_direction_desc_max_records_2_/should_sort_records_by_Name.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_sort_field_Name_direction_desc_max_records_2_/should_sort_records_by_Name.yml similarity index 98% rename from spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_field_Name_direction_desc_max_records_2_/should_sort_records_by_Name.yml rename to spec/vcr_cassettes/Airtable_Entity_Table/_select/_sort_field_Name_direction_desc_max_records_2_/should_sort_records_by_Name.yml index afb2219..296294a 100644 --- a/spec/vcr_cassettes/Airtable_Entity_Table/_records/_sort_field_Name_direction_desc_max_records_2_/should_sort_records_by_Name.yml +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_sort_field_Name_direction_desc_max_records_2_/should_sort_records_by_Name.yml @@ -32,7 +32,7 @@ http_interactions: Content-Type: - application/json; charset=utf-8 Date: - - Wed, 13 Dec 2017 17:23:43 GMT + - Thu, 14 Dec 2017 17:03:47 GMT Etag: - W/"d4f-L2lRYBpsRwSZC+5vIYM5rC9fB+4" Server: @@ -68,5 +68,5 @@ http_interactions: Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Good analytical skills, very articulate. Proceed to in-person interview."},"createdTime":"2015-11-11T23:19:11.000Z"}]}' http_version: - recorded_at: Wed, 13 Dec 2017 17:23:55 GMT + recorded_at: Thu, 14 Dec 2017 17:03:49 GMT recorded_with: VCR 4.0.0 From f33cf3f2c1d44692859b48e0562b9010eb3a50cc Mon Sep 17 00:00:00 2001 From: Alexander Simonov Date: Thu, 14 Dec 2017 19:33:02 +0200 Subject: [PATCH 05/11] Create, Update actions --- lib/airtable/entity/record.rb | 20 ++-- lib/airtable/entity/table.rb | 4 +- lib/airtable/request.rb | 3 +- spec/lib/airtable/entity/table_spec.rb | 30 ++++++ .../_create/should_create_new_record.yml | 99 +++++++++++++++++++ .../_update/should_update_record.yml | 99 +++++++++++++++++++ 6 files changed, 243 insertions(+), 12 deletions(-) create mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_create/should_create_new_record.yml create mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_update/should_update_record.yml diff --git a/lib/airtable/entity/record.rb b/lib/airtable/entity/record.rb index e831364..566485c 100644 --- a/lib/airtable/entity/record.rb +++ b/lib/airtable/entity/record.rb @@ -16,22 +16,24 @@ def new_record? @id.nil? || @id.empty? end - def save(url, api_key) + def save(base, name) if new_record? - __create__(url, api_key) + __create__(base, name) else - __update__ + __update__(base, name) end + self end - def __create__(url, api_key) - resp = ::Airtable::Request.new(url, {}, api_key).request(:get) - if resp.success? - - end + def __create__(base, name) + res = base.__make_request__(:post, name, { fields: fields }) + @id = res['id'] + parse_options(fields: res['fields'], created_at: res['createdTime']) end - def __update__ + def __update__(base, name) + res = base.__make_request__(:put, [name, @id].join('/'), { fields: fields }) + parse_options(fields: res['fields']) end def __fetch__(base, path) diff --git a/lib/airtable/entity/table.rb b/lib/airtable/entity/table.rb index cf5f6b9..26a8940 100644 --- a/lib/airtable/entity/table.rb +++ b/lib/airtable/entity/table.rb @@ -22,11 +22,11 @@ def find(id) end def create(fields) - url = [::Airtable.server_url, @base_id, @name].join('/') - ::Airtable::Entity::Record.new(nil, fields: fields).save(url, @client.api_key) + ::Airtable::Entity::Record.new(nil, fields: fields).save(@base, @name) end def update(id, fields) + ::Airtable::Entity::Record.new(id, fields: fields).save(@base, @name) end private diff --git a/lib/airtable/request.rb b/lib/airtable/request.rb index 6422a2d..a62e6b0 100644 --- a/lib/airtable/request.rb +++ b/lib/airtable/request.rb @@ -1,5 +1,6 @@ require 'net/http' require 'net/https' +require 'json' module Airtable # Main Object that made all requests to server @@ -71,7 +72,7 @@ def to_query_array(array, namespace) def setup_post_request request = ::Net::HTTP::Post.new(url.path) - request.body = body + request.body = body.to_json request end diff --git a/spec/lib/airtable/entity/table_spec.rb b/spec/lib/airtable/entity/table_spec.rb index 1d0075a..841b50c 100644 --- a/spec/lib/airtable/entity/table_spec.rb +++ b/spec/lib/airtable/entity/table_spec.rb @@ -126,4 +126,34 @@ end end + context '#create' do + it 'should create new record' do + + fields = { + 'Name' => 'Super Test Name' + } + record = table_entity.create(fields) + expect(record).to be_a(::Airtable::Entity::Record) + expect(record.id).to_not be_nil + expect(record.created_at).to be_a(::Time) + expect(record.fields).to be_a(::Hash) + expect(record['Name']).to eq(fields['Name']) + rec = table_entity.find(record.id) + expect(rec['Name']).to eq(record['Name']) + end + end + + context '#update' do + it 'should update record' do + id = 'recIsbIqSnj72dp0O' + fields = { + 'Name' => 'Super Dupper Name' + } + record = table_entity.update(id, fields) + expect(record['Name']).to eq(fields['Name']) + rec = table_entity.find(id) + expect(rec['Name']).to eq(record['Name']) + end + end + end diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_create/should_create_new_record.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_create/should_create_new_record.yml new file mode 100644 index 0000000..c49c469 --- /dev/null +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_create/should_create_new_record.yml @@ -0,0 +1,99 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants + body: + encoding: UTF-8 + string: '{"fields":{"Name":"Super Test Name"}}' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 14 Dec 2017 17:24:31 GMT + Etag: + - W/"67-ERjG9vhqlhEvpl+Uv7GZxQAiIGk" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '103' + Connection: + - keep-alive + body: + encoding: UTF-8 + string: '{"id":"recIsbIqSnj72dp0O","fields":{"Name":"Super Test Name"},"createdTime":"2017-12-14T17:24:30.931Z"}' + http_version: + recorded_at: Thu, 14 Dec 2017 17:24:34 GMT +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants/recIsbIqSnj72dp0O + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 14 Dec 2017 17:24:32 GMT + Etag: + - W/"67-ERjG9vhqlhEvpl+Uv7GZxQAiIGk" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '103' + Connection: + - keep-alive + body: + encoding: UTF-8 + string: '{"id":"recIsbIqSnj72dp0O","fields":{"Name":"Super Test Name"},"createdTime":"2017-12-14T17:24:30.931Z"}' + http_version: + recorded_at: Thu, 14 Dec 2017 17:24:35 GMT +recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_update/should_update_record.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_update/should_update_record.yml new file mode 100644 index 0000000..9c07df7 --- /dev/null +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_update/should_update_record.yml @@ -0,0 +1,99 @@ +--- +http_interactions: +- request: + method: put + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants/recIsbIqSnj72dp0O + body: + encoding: UTF-8 + string: '{"fields":{"Name":"Super Dupper Name"}}' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 14 Dec 2017 17:31:42 GMT + Etag: + - W/"69-S72JAuw/B5v79C0cOeqgy+rkf/g" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '105' + Connection: + - keep-alive + body: + encoding: UTF-8 + string: '{"id":"recIsbIqSnj72dp0O","fields":{"Name":"Super Dupper Name"},"createdTime":"2017-12-14T17:24:30.931Z"}' + http_version: + recorded_at: Thu, 14 Dec 2017 17:31:45 GMT +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants/recIsbIqSnj72dp0O + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 14 Dec 2017 17:31:43 GMT + Etag: + - W/"69-S72JAuw/B5v79C0cOeqgy+rkf/g" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '105' + Connection: + - keep-alive + body: + encoding: UTF-8 + string: '{"id":"recIsbIqSnj72dp0O","fields":{"Name":"Super Dupper Name"},"createdTime":"2017-12-14T17:24:30.931Z"}' + http_version: + recorded_at: Thu, 14 Dec 2017 17:31:46 GMT +recorded_with: VCR 4.0.0 From 4929bb3686d914edd8728e99db7106025093dc22 Mon Sep 17 00:00:00 2001 From: Alexander Simonov Date: Thu, 14 Dec 2017 19:50:08 +0200 Subject: [PATCH 06/11] Replace and Destroy methods --- lib/airtable/entity/record.rb | 24 +-- lib/airtable/entity/table.rb | 12 +- lib/airtable/request.rb | 72 ++++---- spec/lib/airtable/entity/table_spec.rb | 22 +++ .../_destroy/should_remove_record.yml | 51 ++++++ .../_replace/should_replace_record.yml | 158 ++++++++++++++++++ .../_update/should_update_record.yml | 6 +- 7 files changed, 301 insertions(+), 44 deletions(-) create mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_destroy/should_remove_record.yml create mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_replace/should_replace_record.yml diff --git a/lib/airtable/entity/record.rb b/lib/airtable/entity/record.rb index 566485c..ecb9abb 100644 --- a/lib/airtable/entity/record.rb +++ b/lib/airtable/entity/record.rb @@ -16,24 +16,17 @@ def new_record? @id.nil? || @id.empty? end - def save(base, name) - if new_record? - __create__(base, name) - else - __update__(base, name) - end - self - end - def __create__(base, name) res = base.__make_request__(:post, name, { fields: fields }) @id = res['id'] parse_options(fields: res['fields'], created_at: res['createdTime']) + self end def __update__(base, name) - res = base.__make_request__(:put, [name, @id].join('/'), { fields: fields }) + res = base.__make_request__(:patch, [name, @id].join('/'), { fields: fields }) parse_options(fields: res['fields']) + self end def __fetch__(base, path) @@ -42,6 +35,17 @@ def __fetch__(base, path) self end + def __replace__(base, name) + res = base.__make_request__(:put, [name, @id].join('/'), { fields: fields }) + parse_options(fields: res['fields']) + self + end + + def __destroy__(base, name) + res = base.__make_request__(:delete, [name, @id].join('/'), {}) + res['deleted'] + end + def [](key) @fields[key.to_s] end diff --git a/lib/airtable/entity/table.rb b/lib/airtable/entity/table.rb index 26a8940..08c9bcd 100644 --- a/lib/airtable/entity/table.rb +++ b/lib/airtable/entity/table.rb @@ -22,11 +22,19 @@ def find(id) end def create(fields) - ::Airtable::Entity::Record.new(nil, fields: fields).save(@base, @name) + ::Airtable::Entity::Record.new(nil, fields: fields).__create__(@base, @name) end def update(id, fields) - ::Airtable::Entity::Record.new(id, fields: fields).save(@base, @name) + ::Airtable::Entity::Record.new(id, fields: fields).__update__(@base, @name) + end + + def replace(id, fields) + ::Airtable::Entity::Record.new(id, fields: fields).__replace__(@base, @name) + end + + def destroy(id) + ::Airtable::Entity::Record.new(id).__destroy__(@base, @name) end private diff --git a/lib/airtable/request.rb b/lib/airtable/request.rb index a62e6b0..5a23647 100644 --- a/lib/airtable/request.rb +++ b/lib/airtable/request.rb @@ -35,6 +35,49 @@ def setup_get_request ::Net::HTTP::Get.new(url.path + '?' + to_query_hash(body)) end + def setup_post_request + request = ::Net::HTTP::Post.new(url.path) + request.body = body.to_json + request + end + + def setup_put_request + request = ::Net::HTTP::Put.new(url.path) + request.body = body.to_json + request + end + + def setup_patch_request + request = ::Net::HTTP::Patch.new(url.path) + request.body = body.to_json + request + end + + def setup_delete_request + ::Net::HTTP::Delete.new(url.path) + end + + def setup_request(type) + case type + when :get + setup_get_request + when :post + setup_post_request + when :put + setup_put_request + when :patch + setup_patch_request + when :delete + setup_delete_request + end + end + + def setup_headers(request) + headers.each do |name, value| + request[name] = value + end + end + def to_query_default(key, value) "#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}" end @@ -69,34 +112,5 @@ def to_query_array(array, namespace) end.join('&') end end - - def setup_post_request - request = ::Net::HTTP::Post.new(url.path) - request.body = body.to_json - request - end - - def setup_put_request - request = ::Net::HTTP::Put.new(url.path) - request.body = body.to_json - request - end - - def setup_request(type) - case type - when :get - setup_get_request - when :post - setup_post_request - when :put - setup_put_request - end - end - - def setup_headers(request) - headers.each do |name, value| - request[name] = value - end - end end end diff --git a/spec/lib/airtable/entity/table_spec.rb b/spec/lib/airtable/entity/table_spec.rb index 841b50c..e52ac39 100644 --- a/spec/lib/airtable/entity/table_spec.rb +++ b/spec/lib/airtable/entity/table_spec.rb @@ -156,4 +156,26 @@ end end + context '#replace' do + it 'should replace record' do + id = 'recSIn39bSTqt4Swc' + fields = { + 'Name' => 'Super Dupper Pupper Name' + } + expect(table_entity.find(id)['Phone']).to eq('(646) 555-4389') + record = table_entity.replace(id, fields) + expect(record['Name']).to eq(fields['Name']) + rec = table_entity.find(id) + expect(rec['Name']).to eq(record['Name']) + expect(rec['Phone']).to be_nil + end + end + + context '#destroy' do + it 'should remove record' do + id = 'recOjo2sDyUYHNSEH' + expect(table_entity.destroy(id)).to be_truthy + end + end + end diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_destroy/should_remove_record.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_destroy/should_remove_record.yml new file mode 100644 index 0000000..e10d519 --- /dev/null +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_destroy/should_remove_record.yml @@ -0,0 +1,51 @@ +--- +http_interactions: +- request: + method: delete + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants/recOjo2sDyUYHNSEH + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 14 Dec 2017 17:49:29 GMT + Etag: + - W/"29-bYS+sJQwWG0RieiK6J1qa9k85ko" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '41' + Connection: + - keep-alive + body: + encoding: UTF-8 + string: '{"deleted":true,"id":"recOjo2sDyUYHNSEH"}' + http_version: + recorded_at: Thu, 14 Dec 2017 17:49:33 GMT +recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_replace/should_replace_record.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_replace/should_replace_record.yml new file mode 100644 index 0000000..80aeb2f --- /dev/null +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_replace/should_replace_record.yml @@ -0,0 +1,158 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants/recSIn39bSTqt4Swc + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 14 Dec 2017 17:46:56 GMT + Etag: + - W/"698-3AuHK2TpKh22V1BQ3ybhvB18XCw" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '888' + Connection: + - keep-alive + body: + encoding: ASCII-8BIT + string: '{"id":"recSIn39bSTqt4Swc","fields":{"Email Address":"ohuxley@example.com","Phone + Screen Score":"3 - good candidate","Onsite Interview Date":"2013-02-19","Stage":"Interviewing","Onsite + Interview Notes":"Owldous was impressive overall, and he has a lot of experience + in the corporate world. But if he were to join with a sports team, he''d still + have a lot to learn about the industry, e.g., I asked him for his opinion + on Leon the Lion''s recent resignation and he responded, \"Whooo?\"","Phone":"(646) + 555-4389","Phone Screen Date":"2013-02-11","Name":"Owldous Huxley","Onsite + Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attxKBp3Hb4FJH2I8","url":"https://dl.airtable.com/1ghGlZqKTViBcofjpPMC_owl.jpg","filename":"owl.jpg","size":242886,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/dFcZepTFCeG0YUXQSOrA_owl.jpg","width":54,"height":36},"large":{"url":"https://dl.airtable.com/QSkAheQ2SFuD3txaTgge_owl.jpg","width":256,"height":171}}},{"id":"attZJp9i2TJ4Z5xeM","url":"https://dl.airtable.com/WwYtwDVURFCGHCoipxhM_Resume%20(O.%20Huxley).docx","filename":"Resume + (O. Huxley).docx","size":16443,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-512x663.png","width":512,"height":663}}}],"Onsite + Interview Score":"3 - good candidate","Applying for":["recgrjFdvUdTGnDsF"],"Phone + Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Good analytical + skills, very articulate. Proceed to in-person interview."},"createdTime":"2015-11-11T23:19:11.000Z"}' + http_version: + recorded_at: Thu, 14 Dec 2017 17:47:00 GMT +- request: + method: put + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants/recSIn39bSTqt4Swc + body: + encoding: UTF-8 + string: '{"fields":{"Name":"Super Dupper Pupper Name"}}' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 14 Dec 2017 17:46:58 GMT + Etag: + - W/"70-fJeV0f8Rv4gryp/FzVC28KLqPm0" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '112' + Connection: + - keep-alive + body: + encoding: UTF-8 + string: '{"id":"recSIn39bSTqt4Swc","fields":{"Name":"Super Dupper Pupper Name"},"createdTime":"2015-11-11T23:19:11.000Z"}' + http_version: + recorded_at: Thu, 14 Dec 2017 17:47:02 GMT +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants/recSIn39bSTqt4Swc + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 14 Dec 2017 17:47:00 GMT + Etag: + - W/"70-fJeV0f8Rv4gryp/FzVC28KLqPm0" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '112' + Connection: + - keep-alive + body: + encoding: UTF-8 + string: '{"id":"recSIn39bSTqt4Swc","fields":{"Name":"Super Dupper Pupper Name"},"createdTime":"2015-11-11T23:19:11.000Z"}' + http_version: + recorded_at: Thu, 14 Dec 2017 17:47:03 GMT +recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_update/should_update_record.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_update/should_update_record.yml index 9c07df7..14d0621 100644 --- a/spec/vcr_cassettes/Airtable_Entity_Table/_update/should_update_record.yml +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_update/should_update_record.yml @@ -1,7 +1,7 @@ --- http_interactions: - request: - method: put + method: patch uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants/recIsbIqSnj72dp0O body: encoding: UTF-8 @@ -46,7 +46,7 @@ http_interactions: body: encoding: UTF-8 string: '{"id":"recIsbIqSnj72dp0O","fields":{"Name":"Super Dupper Name"},"createdTime":"2017-12-14T17:24:30.931Z"}' - http_version: + http_version: recorded_at: Thu, 14 Dec 2017 17:31:45 GMT - request: method: get @@ -94,6 +94,6 @@ http_interactions: body: encoding: UTF-8 string: '{"id":"recIsbIqSnj72dp0O","fields":{"Name":"Super Dupper Name"},"createdTime":"2017-12-14T17:24:30.931Z"}' - http_version: + http_version: recorded_at: Thu, 14 Dec 2017 17:31:46 GMT recorded_with: VCR 4.0.0 From ed3bc163d2e529f0ca7b9535153303888536aa7c Mon Sep 17 00:00:00 2001 From: Alexander Simonov Date: Fri, 15 Dec 2017 16:07:19 +0200 Subject: [PATCH 07/11] Binary started --- airtable.gemspec | 27 +++--- exe/airtable | 5 ++ lib/airtable/cli.rb | 83 +++++++++++++++++++ lib/airtable/entity/record.rb | 1 + lib/airtable/entity/table.rb | 7 ++ lib/airtable/error.rb | 30 ++++--- spec/lib/airtable/entity/table_spec.rb | 36 ++++++-- .../should_collect_only_specified_fields.yml | 53 ++++++++++++ ...ould_raise_Airtable_FieldsOptionsError.yml | 52 ++++++++++++ 9 files changed, 264 insertions(+), 30 deletions(-) create mode 100755 exe/airtable create mode 100644 lib/airtable/cli.rb create mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_select/_fields_Name_/should_collect_only_specified_fields.yml create mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_select/_fields_Test_/should_raise_Airtable_FieldsOptionsError.yml diff --git a/airtable.gemspec b/airtable.gemspec index e3135e7..e1502ee 100644 --- a/airtable.gemspec +++ b/airtable.gemspec @@ -1,22 +1,23 @@ - lib = File.expand_path('../lib', __FILE__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'airtable/version' Gem::Specification.new do |spec| - spec.name = 'airtable' - spec.version = Airtable::VERSION - spec.authors = ['Nathan Esquenazi', 'Alexander Sorokin'] - spec.email = ['nesquena@gmail.com', 'syrnick@gmail.com'] - spec.summary = 'Easily connect to airtable data using ruby' - spec.description = 'Easily connect to airtable data using ruby with access to all of the airtable features.' - spec.homepage = 'https://github.com/nesquena/airtable-ruby' - spec.license = 'MIT' + spec.name = 'airtable' + spec.version = Airtable::VERSION + spec.authors = ['Nathan Esquenazi', 'Alexander Sorokin'] + spec.email = ['nesquena@gmail.com', 'syrnick@gmail.com'] + spec.summary = 'Easily connect to airtable data using ruby' + spec.description = 'Easily connect to airtable data using ruby with access to all of the airtable features.' + spec.homepage = 'https://github.com/nesquena/airtable-ruby' + spec.license = 'MIT' - spec.files = `git ls-files -z`.split("\x0") - spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } - spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) - spec.require_paths = ['lib'] + spec.files = `git ls-files -z`.split("\x0") + spec.bindir = 'exe' + spec.executables = `git ls-files -- exe/*`.split("\n").map {|f| File.basename(f)} + spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) + spec.require_paths = ['lib'] + spec.required_ruby_version = '>= 2.0.0' spec.add_development_dependency 'bundler', '~> 1.6' spec.add_development_dependency 'rake' diff --git a/exe/airtable b/exe/airtable new file mode 100755 index 0000000..a11ae35 --- /dev/null +++ b/exe/airtable @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +$stderr.sync = true +require 'airtable' +require 'airtable/cli' +::Airtable::CLI.start \ No newline at end of file diff --git a/lib/airtable/cli.rb b/lib/airtable/cli.rb new file mode 100644 index 0000000..94fd40d --- /dev/null +++ b/lib/airtable/cli.rb @@ -0,0 +1,83 @@ +require 'optparse' +require 'airtable' + +# * Build a command-line tool to allow simple command-line interactions: +# Interactions: +# * retrieve record JSON by record ID and table ID +# * retrieve all values for a single field ID or name from a table (allow adding viewId and filterByFormula) +# * update single record field + +module Airtable + class CLI + class << self + SUPPORTED_OPERATIONS = [ + "record", + "values", + "update_record_field" + ] + + def start + run(ARGV, $stderr, $stdout) + end + + def run(args, err=$stderr, out=$stdout) + trap_interrupt + options = {} + OptionParser.new do |parser| + parser.banner = "Usage: airtable [options]" + parser.separator "" + parser.separator "Common options:" + parser.on("-kKEY", "--api_key=KEY", "Airtable API key") do |key| + options[:api_key] = key + end + parser.on("-oOPERATION", "--operation OPERATION", "Operation what need to make") do |operation| + options[:operation] = operation + end + parser.on("-tNAME", "--table NAME", "Table Name") do |table| + options[:table_name] = table + end + parser.on("-bBASE_ID", "--base BASE_ID", "Base ID") do |base_id| + options[:base_id] = base_id + end + parser.on("-rRECORD_ID", "--record RECORD_ID", "Record ID") do |record_id| + options[:record_id] = record_id + end + parser.on_tail("-h", "--help", "Show this message") do + puts parser + exit + end + parser.on_tail("--version", "Show version") do + puts ::Airtable::Version + exit + end + parser.separator "" + parser.separator "Supported Operations" + SUPPORTED_OPERATIONS.each do |operation| + parser.separator "\t* #{operation}" + end + parser.separator "" + parser.parse!(args) + if options.empty? + puts parser + else + case options.delete(:operation) + when "record" + print_record(options) + else + puts parser + end + end + end + end + + def print_record(options) + record = ::Airtable::Client.new(options[:api_key]).base(options[:base_id]).table(options[:table_name]).find(options[:record_id]) + puts ({:id => record.id, :fields => record.fields, createdTime: record.created_at}).to_json + end + + def trap_interrupt + trap('INT') {exit!(1)} + end + end + end +end \ No newline at end of file diff --git a/lib/airtable/entity/record.rb b/lib/airtable/entity/record.rb index ecb9abb..61d45ee 100644 --- a/lib/airtable/entity/record.rb +++ b/lib/airtable/entity/record.rb @@ -1,3 +1,4 @@ +require 'time' # Main object for store Airtable Record entity module Airtable module Entity diff --git a/lib/airtable/entity/table.rb b/lib/airtable/entity/table.rb index 08c9bcd..bea2ab6 100644 --- a/lib/airtable/entity/table.rb +++ b/lib/airtable/entity/table.rb @@ -14,6 +14,7 @@ def select(options = {}) params = {} update_default_params(params, options) update_sort_options(params, options) + validate_params(params) fetch_records(params.compact) end @@ -54,6 +55,12 @@ def update_default_params(params, options) params[:pageSize] = option_value_for(options, :limit) || PAGE_SIZE end + def validate_params(params) + raise ::Airtable::FieldsOptionsError if params[:fields] && !params[:fields].is_a?(::Array) + raise ::Airtable::LimitOptionsError if params[:pageSize] && !(params[:pageSize].to_i > 0) + raise ::Airtable::MaxRecordsOptionsError if params[:maxRecords] && !(params[:maxRecords].to_i > 0) + end + def update_sort_options(params, options) sort_option = option_value_for(options, :sort) case sort_option diff --git a/lib/airtable/error.rb b/lib/airtable/error.rb index 2e4c806..5b55c65 100644 --- a/lib/airtable/error.rb +++ b/lib/airtable/error.rb @@ -1,25 +1,31 @@ - module Airtable - class Error < StandardError - attr_reader :message, :type - # {"error"=>{"type"=>"UNKNOWN_COLUMN_NAME", "message"=>"Could not find fields foo"}} + class SortOptionsError < ::ArgumentError + def initialize + super('Unknown sort option format.') + end + end - def initialize(error_hash) - @message = error_hash['message'] - @type = error_hash['type'] - super(@message) + class MissingApiKeyError < ::ArgumentError + def initialize + super('Missing API key') end end - class SortOptionsError < ::ArgumentError + class FieldsOptionsError < ::ArgumentError def initialize - super('Unknown sort options format.') + super('Invalid fields option format.') end end - class MissingApiKeyError < ::ArgumentError + class LimitOptionsError < ::ArgumentError def initialize - super('Missing API key') + super('Invalid limit option format.') + end + end + + class MaxRecordsOptionsError < ::ArgumentError + def initialize + super('Invalid max_records option format.') end end end diff --git a/spec/lib/airtable/entity/table_spec.rb b/spec/lib/airtable/entity/table_spec.rb index e52ac39..342572d 100644 --- a/spec/lib/airtable/entity/table_spec.rb +++ b/spec/lib/airtable/entity/table_spec.rb @@ -4,7 +4,7 @@ RSpec.describe ::Airtable::Entity::Table, vcr: true do let(:client) {::Airtable::Client.new} let(:base_id) {'appnlJrQ2fxlfRsov'} - let(:base) {::Airtable::Entity::Base.new(client, base_id) } + let(:base) {::Airtable::Entity::Base.new(client, base_id)} let(:table_entity) {described_class.new(base, 'Applicants')} context '#select' do context '()' do @@ -100,13 +100,39 @@ context '({sort: ["Name", "desc", "other"]})' do it 'should raise ::Airtable::SortOptionsError' do - expect { table_entity.select(sort: ["Name", "desc", "other"]) }.to raise_error(::Airtable::SortOptionsError) + expect {table_entity.select(sort: ["Name", "desc", "other"])}.to raise_error(::Airtable::SortOptionsError) end end context '({sort: {feild: "Name", direction: "desc"}})' do it 'should raise ::Airtable::SortOptionsError' do - expect { table_entity.select(sort: {feild: "Name", direction: "desc"}) }.to raise_error(::Airtable::SortOptionsError) + expect {table_entity.select(sort: { feild: "Name", direction: "desc" })}.to raise_error(::Airtable::SortOptionsError) + end + end + + context '(fields: ["Name"])' do + it 'should collect only specified fields' do + res = table_entity.select(fields: ['Name'], max_records: 2) + expect(res[0].fields.keys).to eq(['Name']) + expect(res[1].fields.keys).to eq(['Name']) + end + end + + context '(fields: "Test")' do + it 'should raise ::Airtable::FieldsOptionsError' do + expect {table_entity.select(fields: 'Test')}.to raise_error(::Airtable::FieldsOptionsError) + end + end + + context '(limit: "Test")' do + it 'should raise ::Airtable::LimitOptionsError' do + expect {table_entity.select(limit: 'Test')}.to raise_error(::Airtable::LimitOptionsError) + end + end + + context '(max_records: "Test")' do + it 'should raise ::Airtable::MaxRecordsOptionsError' do + expect {table_entity.select(max_records: 'Test')}.to raise_error(::Airtable::MaxRecordsOptionsError) end end @@ -145,7 +171,7 @@ context '#update' do it 'should update record' do - id = 'recIsbIqSnj72dp0O' + id = 'recIsbIqSnj72dp0O' fields = { 'Name' => 'Super Dupper Name' } @@ -158,7 +184,7 @@ context '#replace' do it 'should replace record' do - id = 'recSIn39bSTqt4Swc' + id = 'recSIn39bSTqt4Swc' fields = { 'Name' => 'Super Dupper Pupper Name' } diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_select/_fields_Name_/should_collect_only_specified_fields.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_fields_Name_/should_collect_only_specified_fields.yml new file mode 100644 index 0000000..8a8b358 --- /dev/null +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_fields_Name_/should_collect_only_specified_fields.yml @@ -0,0 +1,53 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?fields%5B%5D=Name&maxRecords=2&pageSize=100 + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Fri, 15 Dec 2017 11:45:30 GMT + Etag: + - W/"e1-+BYhrDObLAiu8+VGH4H5Cd+wFFc" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '225' + Connection: + - keep-alive + body: + encoding: UTF-8 + string: '{"records":[{"id":"recIsbIqSnj72dp0O","fields":{"Name":"Super Dupper + Name"},"createdTime":"2017-12-14T17:24:31.000Z"},{"id":"recQes7d2DCuEcGe0","fields":{"Name":"Chippy + the Potato"},"createdTime":"2015-11-11T23:05:58.000Z"}]}' + http_version: + recorded_at: Fri, 15 Dec 2017 11:45:40 GMT +recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_select/_fields_Test_/should_raise_Airtable_FieldsOptionsError.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_fields_Test_/should_raise_Airtable_FieldsOptionsError.yml new file mode 100644 index 0000000..4f1147b --- /dev/null +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_fields_Test_/should_raise_Airtable_FieldsOptionsError.yml @@ -0,0 +1,52 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?fields=Test&pageSize=100 + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 422 + message: Unprocessable Entity + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Fri, 15 Dec 2017 11:50:19 GMT + Etag: + - W/"7f-ic7Ppjtti07fW0oQKWhpH6lrz8A" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '127' + Connection: + - keep-alive + body: + encoding: UTF-8 + string: '{"error":{"type":"INVALID_REQUEST_UNKNOWN","message":"Invalid request: + parameter validation failed. Check your request data."}}' + http_version: + recorded_at: Fri, 15 Dec 2017 11:50:30 GMT +recorded_with: VCR 4.0.0 From 984574c3cd425a0e690430c36b13920b421d0915 Mon Sep 17 00:00:00 2001 From: Alexander Simonov Date: Tue, 19 Dec 2017 17:38:34 +0200 Subject: [PATCH 08/11] CLI finished. Code refactoring started --- .rubocop.yml | 4 + README.md | 78 ++++++++--- exe/airtable | 2 +- lib/airtable/cli.rb | 172 +++++++++++++++---------- lib/airtable/entity.rb | 1 + lib/airtable/entity/base.rb | 7 +- lib/airtable/entity/record.rb | 28 ++-- lib/airtable/entity/table.rb | 52 +++++--- lib/airtable/error.rb | 20 +-- lib/airtable/request.rb | 40 +++--- lib/airtable/response.rb | 13 +- spec/lib/airtable/entity/table_spec.rb | 10 +- 12 files changed, 264 insertions(+), 163 deletions(-) create mode 100644 .rubocop.yml diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..7236fda --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,4 @@ +AllCops: + Exclude: + - 'spec/**/*' + TargetRubyVersion: 2.1 \ No newline at end of file diff --git a/README.md b/README.md index 8fb0a0c..82669c6 100644 --- a/README.md +++ b/README.md @@ -33,28 +33,38 @@ First, be sure to register for an [airtable](https://airtable.com) account, crea ```ruby # Pass in api key to client @client = Airtable::Client.new("keyPCx5W") +# Or if you have AIRTABLE_KEY varibale you can use +@client = Airtable::Client.new ``` - Your API key carries the same privileges as your user account, so be sure to keep it secret! +### Accessing a Base + +Now we can access any base in our Airsheet account by referencing the [API docs](https://airtable.com/api): + +```ruby +# Pass in the base id +@base = @client.table("appPo84QuCy2BPgLk") +``` + ### Accessing a Table Now we can access any table in our Airsheet account by referencing the [API docs](https://airtable.com/api): ```ruby -# Pass in the app key and table name -@table = @client.table("appPo84QuCy2BPgLk", "Table Name") +# Pass in the table name +@table = @base.table("Table Name") ``` -### Querying Records +### Batch Querying All Records Once you have access to a table from above, we can query a set of records in the table with: ```ruby -@records = @table.records +@records = @table.select ``` -We can specify a `sort` order, `limit`, and `offset` as part of our query: +We can specify a `sort` order, `limit`, `max_records` and `offset` as part of our query: ```ruby @records = @table.records(:sort => ["Name", :asc], :limit => 50) @@ -72,16 +82,6 @@ This will return the records based on the query as well as an `offset` for the n @bill[:email] # => "billery@gmail.com" ``` -Note that you can only request a maximimum of 100 records in a single query. To retrieve more records, use the "batch" feature below. - -### Batch Querying All Records - -We can also query all records in the table through a series of batch requests with: - -```ruby -@records = @table.all(:sort => ["Name", :asc]) -``` - This executes a variable number of network requests (100 records per batch) to retrieve all records in a sheet. ### Finding a Record @@ -121,6 +121,52 @@ Records can be destroyed using the `destroy` method on a table: @table.destroy(record) ``` +## Command Line Tool + +This gem is include a very simple command line tool which can show basic functionality of service. + +``` +$ airtable +Usage: airtable [options] + +Common options: + -k, --api_key=KEY Airtable API key + -t, --table NAME Table Name + -b, --base BASE_ID Base ID + -r, --record RECORD_ID Record ID + -f, --field FIELD_NAME Field name to update or read + -v, --value VALUE Field value for update + +Supported Operations: + Get Record (if only RECORD_ID provided) + Get Field (if RECORD_ID and FIELD_ID are provided) + Update Field (if RECORD_ID, FIELD_ID and VALUE are provided) + + -h, --help Show this message + --version Show version +``` + +### Get record's JSON + +``` +$ airtable -b base_id -t Table -r record_id +{"id":"record_id","fields":{...},"createdTime":"2015-11-11 23:05:58 UTC"} +``` + +### Get record's field value + +``` +$ airtable -b base_id -t Table -r record_id -f field_name +FIELD_VALUE +``` + +### Update record's field value + +``` +$ airtable -b base_id -t Table -r record_id -f field_name -v NEW_VALUE +OK +``` + ## Contributing 1. Fork it ( https://github.com/nesquena/airtable-ruby/fork ) diff --git a/exe/airtable b/exe/airtable index a11ae35..0867383 100755 --- a/exe/airtable +++ b/exe/airtable @@ -2,4 +2,4 @@ $stderr.sync = true require 'airtable' require 'airtable/cli' -::Airtable::CLI.start \ No newline at end of file +::Airtable::CLI.new(ARGV).start \ No newline at end of file diff --git a/lib/airtable/cli.rb b/lib/airtable/cli.rb index 94fd40d..50e4c9b 100644 --- a/lib/airtable/cli.rb +++ b/lib/airtable/cli.rb @@ -1,83 +1,119 @@ require 'optparse' require 'airtable' -# * Build a command-line tool to allow simple command-line interactions: -# Interactions: -# * retrieve record JSON by record ID and table ID -# * retrieve all values for a single field ID or name from a table (allow adding viewId and filterByFormula) -# * update single record field - module Airtable + # Command line Class class CLI - class << self - SUPPORTED_OPERATIONS = [ - "record", - "values", - "update_record_field" - ] + def initialize(args) + trap_interrupt + @args = args + @options = {} + @parser = OptionParser.new + end - def start - run(ARGV, $stderr, $stdout) + def start + add_banner + add_options + add_tail_options + add_supported_operations + @parser.parse!(@args) + if @options.empty? + puts @parser + else + unless valid_options? + puts @parser + return + end + run_operation end + end - def run(args, err=$stderr, out=$stdout) - trap_interrupt - options = {} - OptionParser.new do |parser| - parser.banner = "Usage: airtable [options]" - parser.separator "" - parser.separator "Common options:" - parser.on("-kKEY", "--api_key=KEY", "Airtable API key") do |key| - options[:api_key] = key - end - parser.on("-oOPERATION", "--operation OPERATION", "Operation what need to make") do |operation| - options[:operation] = operation - end - parser.on("-tNAME", "--table NAME", "Table Name") do |table| - options[:table_name] = table - end - parser.on("-bBASE_ID", "--base BASE_ID", "Base ID") do |base_id| - options[:base_id] = base_id - end - parser.on("-rRECORD_ID", "--record RECORD_ID", "Record ID") do |record_id| - options[:record_id] = record_id - end - parser.on_tail("-h", "--help", "Show this message") do - puts parser - exit - end - parser.on_tail("--version", "Show version") do - puts ::Airtable::Version - exit - end - parser.separator "" - parser.separator "Supported Operations" - SUPPORTED_OPERATIONS.each do |operation| - parser.separator "\t* #{operation}" - end - parser.separator "" - parser.parse!(args) - if options.empty? - puts parser - else - case options.delete(:operation) - when "record" - print_record(options) - else - puts parser - end - end - end + def add_banner + @parser.banner = 'Usage: airtable [options]' + @parser.separator '' + end + + def add_options + @parser.separator 'Common options:' + @parser.on('-kKEY', '--api_key=KEY', 'Airtable API key') do |key| + @options[:api_key] = key + end + @parser.on('-tNAME', '--table NAME', 'Table Name') do |table| + @options[:table_name] = table + end + @parser.on('-bBASE_ID', '--base BASE_ID', 'Base ID') do |base_id| + @options[:base_id] = base_id + end + @parser.on('-rRECORD_ID', '--record RECORD_ID', 'Record ID') do |record_id| + @options[:record_id] = record_id + end + @parser.on('-fFIELD_NAME', '--field FIELD_NAME', 'Field name to update or read') do |field_name| + @options[:field_name] = field_name + end + @parser.on('-vVALUE', '--value VALUE', 'Field value for update') do |field_value| + @options[:field_value] = field_value + end + end + + def add_tail_options + @parser.on_tail('-h', '--help', 'Show this message') do + puts @parser + exit + end + @parser.on_tail('--version', 'Show version') do + puts ::Airtable::VERSION + exit end + end + + def add_supported_operations + @parser.separator '' + @parser.separator 'Supported Operations:' + @parser.separator "\tGet Record (if only RECORD_ID provided)" + @parser.separator "\tGet Field (if RECORD_ID and FIELD_ID are provided)" + @parser.separator "\tUpdate Field (if RECORD_ID, FIELD_ID and VALUE are provided)" + @parser.separator '' + end + + def valid_options? + @options[:table_name] && !@options[:table_name].empty? && + @options[:base_id] && !@options[:base_id].empty? && + @options[:record_id] && !@options[:record_id].empty? + end - def print_record(options) - record = ::Airtable::Client.new(options[:api_key]).base(options[:base_id]).table(options[:table_name]).find(options[:record_id]) - puts ({:id => record.id, :fields => record.fields, createdTime: record.created_at}).to_json + def run_operation + if @options[:field_value] && !@options[:field_value].empty? && @options[:field_name] && !@options[:field_name].empty? + update_field + elsif @options[:field_name] && !@options[:field_name].empty? + print_field + else + print_record end + end + + def print_record + record = ::Airtable::Client.new(@options[:api_key]).base(@options[:base_id]).table(@options[:table_name]).find(@options[:record_id]) + puts ({ id: record.id, fields: record.fields, createdTime: record.created_at }).to_json + end - def trap_interrupt - trap('INT') {exit!(1)} + def print_field + record = ::Airtable::Client.new(@options[:api_key]).base(@options[:base_id]).table(@options[:table_name]).find(@options[:record_id]) + puts record.fields[@options[:field_name]] + end + + def update_field + ::Airtable::Client.new(@options[:api_key]).base(@options[:base_id]).table(@options[:table_name]) + .update(@options[:record_id], @options[:field_name] => @options[:field_value]) + record = ::Airtable::Client.new(@options[:api_key]).base(@options[:base_id]).table(@options[:table_name]).find(@options[:record_id]) + if record.fields[@options[:field_name]] == @options[:field_value] + puts 'OK' + else + puts 'ERROR' end end + + def trap_interrupt + trap('INT') { exit!(1) } + end end -end \ No newline at end of file +end diff --git a/lib/airtable/entity.rb b/lib/airtable/entity.rb index 613283a..92991af 100644 --- a/lib/airtable/entity.rb +++ b/lib/airtable/entity.rb @@ -1,4 +1,5 @@ module Airtable + # Entity submodule module Entity end end diff --git a/lib/airtable/entity/base.rb b/lib/airtable/entity/base.rb index 31f5f33..22cdbdb 100644 --- a/lib/airtable/entity/base.rb +++ b/lib/airtable/entity/base.rb @@ -13,7 +13,8 @@ def table(name) def __make_request__(method, path, data) url = [::Airtable.server_url, @id, path].join('/') - resp = ::Airtable::Request.new(url, data, @client.api_key).request(method) + resp = ::Airtable::Request.new(url, data, @client.api_key) + .request(method) if resp.success? resp.result else @@ -21,9 +22,7 @@ def __make_request__(method, path, data) end end - def raise_correct_error_for(resp) - ; - end + def raise_correct_error_for(resp); end end end end diff --git a/lib/airtable/entity/record.rb b/lib/airtable/entity/record.rb index 61d45ee..eed7f60 100644 --- a/lib/airtable/entity/record.rb +++ b/lib/airtable/entity/record.rb @@ -2,6 +2,7 @@ # Main object for store Airtable Record entity module Airtable module Entity + # Airtable Record entity class Record extend Forwardable attr_reader :id, :created_at, :fields @@ -18,14 +19,15 @@ def new_record? end def __create__(base, name) - res = base.__make_request__(:post, name, { fields: fields }) + res = base.__make_request__(:post, name, fields: fields) @id = res['id'] parse_options(fields: res['fields'], created_at: res['createdTime']) self end def __update__(base, name) - res = base.__make_request__(:patch, [name, @id].join('/'), { fields: fields }) + args = [:patch, [name, @id].join('/'), fields: fields] + res = base.__make_request__(*args) parse_options(fields: res['fields']) self end @@ -37,7 +39,7 @@ def __fetch__(base, path) end def __replace__(base, name) - res = base.__make_request__(:put, [name, @id].join('/'), { fields: fields }) + res = base.__make_request__(:put, [name, @id].join('/'), fields: fields) parse_options(fields: res['fields']) self end @@ -66,14 +68,15 @@ def all(base, name, params) def __fetch__(base, name, params, res) result = base.__make_request__(:get, name, params) - result['records'].each do |item| - res << new(item['id'], fields: item['fields'], created_at: item['createdTime']) - end - if result['offset'] - __fetch__(base, name, params.merge(offset: result['offset']), res) + result['records'].each do |r| + args = [ + r['id'], fields: r['fields'], created_at: r['createdTime'] + ] + res << new(*args) end + return unless result['offset'] + __fetch__(base, name, params.merge(offset: result['offset']), res) end - end private @@ -82,11 +85,10 @@ def parse_options(options = {}) if (fields = options.delete(:fields)) && !fields.empty? @fields = fields end - if (created_at = options.delete(:created_at)) - @created_at = ::Time.parse(created_at) - end + created_at = options.delete(:created_at) + return unless created_at + @created_at = ::Time.parse(created_at) end - end end end diff --git a/lib/airtable/entity/table.rb b/lib/airtable/entity/table.rb index bea2ab6..55636b6 100644 --- a/lib/airtable/entity/table.rb +++ b/lib/airtable/entity/table.rb @@ -8,6 +8,7 @@ class Table def initialize(base, name) @name = name @base = base + @args = [@base, @name] end def select(options = {}) @@ -19,23 +20,24 @@ def select(options = {}) end def find(id) - ::Airtable::Entity::Record.new(id).__fetch__(@base, [@name, id].join('/')) + args = [@base, [@name, id].join('/')] + ::Airtable::Entity::Record.new(id).__fetch__(*args) end def create(fields) - ::Airtable::Entity::Record.new(nil, fields: fields).__create__(@base, @name) + ::Airtable::Entity::Record.new(nil, fields: fields).__create__(*@args) end def update(id, fields) - ::Airtable::Entity::Record.new(id, fields: fields).__update__(@base, @name) + ::Airtable::Entity::Record.new(id, fields: fields).__update__(*@args) end def replace(id, fields) - ::Airtable::Entity::Record.new(id, fields: fields).__replace__(@base, @name) + ::Airtable::Entity::Record.new(id, fields: fields).__replace__(*@args) end def destroy(id) - ::Airtable::Entity::Record.new(id).__destroy__(@base, @name) + ::Airtable::Entity::Record.new(id).__destroy__(*@args) end private @@ -56,23 +58,20 @@ def update_default_params(params, options) end def validate_params(params) - raise ::Airtable::FieldsOptionsError if params[:fields] && !params[:fields].is_a?(::Array) - raise ::Airtable::LimitOptionsError if params[:pageSize] && !(params[:pageSize].to_i > 0) - raise ::Airtable::MaxRecordsOptionsError if params[:maxRecords] && !(params[:maxRecords].to_i > 0) + if params[:fields] && !params[:fields].is_a?(::Array) + raise ::Airtable::FieldsOptionError + end + raise ::Airtable::LimitOptionError if params[:pageSize].to_i <= 0 + if params[:maxRecords] && params[:maxRecords].to_i <= 0 + raise ::Airtable::MaxRecordsOptionError + end end def update_sort_options(params, options) sort_option = option_value_for(options, :sort) case sort_option when ::Array - raise ::Airtable::SortOptionsError if sort_option.empty? - if sort_option.size == 2 - add_sort_options(params, sort_option) - else - sort_option.each do |item| - add_sort_options(params, item) - end - end + add_array_sort_options(params, sort_option) when ::Hash add_hash_sort_option(params, sort_option) when ::String @@ -80,14 +79,27 @@ def update_sort_options(params, options) end end + def add_array_sort_options(params, array) + raise ::Airtable::SortOptionError if array.empty? + if array.size == 2 + add_sort_options(params, array) + else + array.each do |item| + add_sort_options(params, item) + end + end + end + def add_string_sort_option(params, string) - raise ::Airtable::SortOptionsError if string.nil? || string.empty? + raise ::Airtable::SortOptionError if string.nil? || string.empty? params[:sort] ||= [] params[:sort] << { field: string, direction: DEFAULT_DIRECTION } end def add_hash_sort_option(params, hash) - raise ::Airtable::SortOptionsError if hash.keys.map(&:to_sym).sort != %i[direction field] + if hash.keys.map(&:to_sym).sort != %i[direction field] + raise ::Airtable::SortOptionError + end params[:sort] ||= [] params[:sort] << hash end @@ -95,13 +107,13 @@ def add_hash_sort_option(params, hash) def add_sort_options(params, sort_option) case sort_option when ::Array - raise Airtable::SortOptionsError if sort_option.size != 2 + raise Airtable::SortOptionError if sort_option.size != 2 params[:sort] ||= [] params[:sort] << { field: sort_option[0], direction: sort_option[1] } when ::Hash add_hash_sort_option(params, sort_option) else - raise Airtable::SortOptionsError + raise Airtable::SortOptionError end end end diff --git a/lib/airtable/error.rb b/lib/airtable/error.rb index 5b55c65..de7e71e 100644 --- a/lib/airtable/error.rb +++ b/lib/airtable/error.rb @@ -1,29 +1,33 @@ module Airtable - class SortOptionsError < ::ArgumentError + # missing api key error + class MissingApiKeyError < ::ArgumentError def initialize - super('Unknown sort option format.') + super('Missing API key') end end - class MissingApiKeyError < ::ArgumentError + # sort option key error + class SortOptionError < ::ArgumentError def initialize - super('Missing API key') + super('Unknown sort option format.') end end - class FieldsOptionsError < ::ArgumentError + # fields option key error + class FieldsOptionError < ::ArgumentError def initialize super('Invalid fields option format.') end end - - class LimitOptionsError < ::ArgumentError + # limit option key error + class LimitOptionError < ::ArgumentError def initialize super('Invalid limit option format.') end end - class MaxRecordsOptionsError < ::ArgumentError + # max records option key error + class MaxRecordsOptionError < ::ArgumentError def initialize super('Invalid max_records option format.') end diff --git a/lib/airtable/request.rb b/lib/airtable/request.rb index 5a23647..11e37d4 100644 --- a/lib/airtable/request.rb +++ b/lib/airtable/request.rb @@ -5,6 +5,7 @@ module Airtable # Main Object that made all requests to server class Request + METHODS = %i[get post put patch delete].freeze attr_accessor :url, :body, :headers, :http def initialize(url, body, token) @@ -12,6 +13,7 @@ def initialize(url, body, token) @body = body @headers = { 'Authorization' => "Bearer #{token}", + 'User-Agent' => "Airtable gem v#{::Airtable::VERSION}", 'Content-Type' => 'application/json' } end @@ -58,18 +60,8 @@ def setup_delete_request end def setup_request(type) - case type - when :get - setup_get_request - when :post - setup_post_request - when :put - setup_put_request - when :patch - setup_patch_request - when :delete - setup_delete_request - end + raise ::Airtable::BrokenMethod unless METHODS.include?(type) + __send__("setup_#{type}_request") end def setup_headers(request) @@ -100,17 +92,21 @@ def to_query_array(array, namespace) if array.empty? to_query_default(prefix, nil) else - array.collect do |value| - case value - when ::Hash - to_query_hash(value, prefix) - when ::Array - to_query_array(value, prefix) - else - to_query_default(prefix, value) - end - end.join('&') + to_query_collect(array, prefix) end end + + def to_query_collect(array, prefix) + array.collect do |value| + case value + when ::Hash + to_query_hash(value, prefix) + when ::Array + to_query_array(value, prefix) + else + to_query_default(prefix, value) + end + end.join('&') + end end end diff --git a/lib/airtable/response.rb b/lib/airtable/response.rb index 80fbe46..4eba454 100644 --- a/lib/airtable/response.rb +++ b/lib/airtable/response.rb @@ -1,17 +1,18 @@ module Airtable + # Response processor class class Response attr_accessor :raw, :result - def initialize(raw_response) - @raw = raw_response + def initialize(raw_resp) + @raw = raw_resp + body = raw.body + ::Airtable.logger.info "Response: #{body}" if ::Airtable.debug? begin - @result = ::JSON.parse(raw.body) - ::Airtable.logger.info "Response: #{@result}" + @result = ::JSON.parse(body) @success = @raw.code.to_i == 200 rescue @success = false - ::Airtable.logger.info "ERROR Response: #{raw.body}" - @result = { 'detail' => raw.body } if @result.blank? + @result = { 'raw' => body } if @result.blank? end end diff --git a/spec/lib/airtable/entity/table_spec.rb b/spec/lib/airtable/entity/table_spec.rb index 342572d..3482523 100644 --- a/spec/lib/airtable/entity/table_spec.rb +++ b/spec/lib/airtable/entity/table_spec.rb @@ -100,13 +100,13 @@ context '({sort: ["Name", "desc", "other"]})' do it 'should raise ::Airtable::SortOptionsError' do - expect {table_entity.select(sort: ["Name", "desc", "other"])}.to raise_error(::Airtable::SortOptionsError) + expect {table_entity.select(sort: ["Name", "desc", "other"])}.to raise_error(::Airtable::SortOptionError) end end context '({sort: {feild: "Name", direction: "desc"}})' do it 'should raise ::Airtable::SortOptionsError' do - expect {table_entity.select(sort: { feild: "Name", direction: "desc" })}.to raise_error(::Airtable::SortOptionsError) + expect {table_entity.select(sort: { feild: "Name", direction: "desc" })}.to raise_error(::Airtable::SortOptionError) end end @@ -120,19 +120,19 @@ context '(fields: "Test")' do it 'should raise ::Airtable::FieldsOptionsError' do - expect {table_entity.select(fields: 'Test')}.to raise_error(::Airtable::FieldsOptionsError) + expect {table_entity.select(fields: 'Test')}.to raise_error(::Airtable::FieldsOptionError) end end context '(limit: "Test")' do it 'should raise ::Airtable::LimitOptionsError' do - expect {table_entity.select(limit: 'Test')}.to raise_error(::Airtable::LimitOptionsError) + expect {table_entity.select(limit: 'Test')}.to raise_error(::Airtable::LimitOptionError) end end context '(max_records: "Test")' do it 'should raise ::Airtable::MaxRecordsOptionsError' do - expect {table_entity.select(max_records: 'Test')}.to raise_error(::Airtable::MaxRecordsOptionsError) + expect {table_entity.select(max_records: 'Test')}.to raise_error(::Airtable::MaxRecordsOptionError) end end From 612eee89cc0c79c09865e0b3d8ff2487029c1920 Mon Sep 17 00:00:00 2001 From: Alexander Simonov Date: Thu, 21 Dec 2017 11:08:41 +0200 Subject: [PATCH 09/11] Status error catching --- lib/airtable/cli.rb | 2 ++ lib/airtable/entity/base.rb | 27 +++++++++++++- lib/airtable/entity/table.rb | 2 ++ lib/airtable/error.rb | 70 ++++++++++++++++++++++++++++++++++++ lib/airtable/response.rb | 13 ++----- 5 files changed, 102 insertions(+), 12 deletions(-) diff --git a/lib/airtable/cli.rb b/lib/airtable/cli.rb index 50e4c9b..1dd2be2 100644 --- a/lib/airtable/cli.rb +++ b/lib/airtable/cli.rb @@ -1,6 +1,7 @@ require 'optparse' require 'airtable' +# rubocop:disable all module Airtable # Command line Class class CLI @@ -117,3 +118,4 @@ def trap_interrupt end end end +# rubocop:enable all diff --git a/lib/airtable/entity/base.rb b/lib/airtable/entity/base.rb index 22cdbdb..dc3b10a 100644 --- a/lib/airtable/entity/base.rb +++ b/lib/airtable/entity/base.rb @@ -22,7 +22,32 @@ def __make_request__(method, path, data) end end - def raise_correct_error_for(resp); end + # rubocop:disable all + def raise_correct_error_for(resp) + case resp.raw.code.to_i + when 401 + raise Airtable::AuthRequiredError.new(resp.raw) + when 403 + raise ::Airtable::NotAuthorizedError.new(resp.raw) + when 404 + raise ::Airtable::NotFoundError.new(resp.raw) + when 413 + raise ::Airtable::RequestBodyTooLargeError.new(resp.raw) + when 422 + raise ::Airtable::UnprocessableEntityError.new(resp.raw) + when 429 + raise ::Airtable::TooManyRequestsError.new(resp.raw) + when 500 + raise ::Airtable::ServerError.new(resp.raw) + when 503 + raise ::Airtable::ServiceUnavailableError.new(resp.raw) + else + if resp.raw.code.to_i > 400 + raise ::Airtable::RequestError.new(resp.raw, 'An unexpected error occurred') + end + end + # rubocop:enable all + end end end end diff --git a/lib/airtable/entity/table.rb b/lib/airtable/entity/table.rb index 55636b6..c0ddffe 100644 --- a/lib/airtable/entity/table.rb +++ b/lib/airtable/entity/table.rb @@ -62,9 +62,11 @@ def validate_params(params) raise ::Airtable::FieldsOptionError end raise ::Airtable::LimitOptionError if params[:pageSize].to_i <= 0 + # rubocop:disable all if params[:maxRecords] && params[:maxRecords].to_i <= 0 raise ::Airtable::MaxRecordsOptionError end + # rubocop:enable all end def update_sort_options(params, options) diff --git a/lib/airtable/error.rb b/lib/airtable/error.rb index de7e71e..369db12 100644 --- a/lib/airtable/error.rb +++ b/lib/airtable/error.rb @@ -32,4 +32,74 @@ def initialize super('Invalid max_records option format.') end end + + # Request error + class RequestError < StandardError + attr_reader :body + + def initialize(body, message) + @body = body + super(message) + end + end + + # auth error + class AuthRequiredError < ::Airtable::RequestError + def initialize(body) + super(body, 'You should provide valid api key to perform this operation') + end + end + + # not authorized + class NotAuthorizedError < ::Airtable::RequestError + def initialize(body) + super(body, 'You are not authorized to perform this operation') + end + end + + # not found error + class NotFoundError < ::Airtable::RequestError + def initialize(body) + super(body, 'Could not find what you are looking for') + end + end + + # request body too large + class RequestBodyTooLargeError < ::Airtable::RequestError + def initialize(body) + super(body, 'Request body is too large') + end + end + + # unprocessed error + class UnprocessableEntityError < ::Airtable::RequestError + def initialize(body) + super(body, 'The operation cannot be processed') + end + end + + # too many requests + class TooManyRequestsError < ::Airtable::RequestError + def initialize(body) + # rubocop:disable Metrics/LineLength + super(body, 'You have made too many requests in a short period of time. Please retry your request later') + # rubocop:enable Metrics/LineLength + end + end + + # server error + class ServerError < ::Airtable::RequestError + def initialize(body) + super(body, 'Try again. If the problem persists, contact support.') + end + end + + # service unavailable + class ServiceUnavailableError < ::Airtable::RequestError + def initialize(body) + # rubocop:disable Metrics/LineLength + super(body, 'The service is temporarily unavailable. Please retry shortly.') + # rubocop:enable Metrics/LineLength + end + end end diff --git a/lib/airtable/response.rb b/lib/airtable/response.rb index 4eba454..08fa45e 100644 --- a/lib/airtable/response.rb +++ b/lib/airtable/response.rb @@ -7,21 +7,12 @@ def initialize(raw_resp) @raw = raw_resp body = raw.body ::Airtable.logger.info "Response: #{body}" if ::Airtable.debug? - begin - @result = ::JSON.parse(body) - @success = @raw.code.to_i == 200 - rescue - @success = false - @result = { 'raw' => body } if @result.blank? - end + @result = ::JSON.parse(body) + @success = @raw.code.to_i == 200 end def success? @success end - - def rate_limited? - @raw.code.to_i == 429 - end end end From 4c7cb6224c75ba0aa92015c15265df468e8f547b Mon Sep 17 00:00:00 2001 From: Alexander Simonov Date: Fri, 5 Jan 2018 12:29:41 +0200 Subject: [PATCH 10/11] Fixes and new functionality updates --- .rubocop.yml | 9 ++- README.md | 34 ++++++--- airtable.gemspec | 4 +- lib/airtable/cli.rb | 59 ++++++++++++--- lib/airtable/entity/base.rb | 2 +- lib/airtable/entity/record.rb | 28 ++++---- lib/airtable/entity/table.rb | 43 +++++++---- lib/airtable/error.rb | 22 ++++-- spec/lib/airtable/entity/table_spec.rb | 23 ++++++ ...should_raise_Airtable_ViewOptionsError.yml | 72 +++++++++++++++++++ ...e_Airtable_FilterByFormulaOptionsError.yml | 52 ++++++++++++++ ...should_raise_Airtable_ViewOptionsError.yml | 72 +++++++++++++++++++ ...should_raise_Airtable_ViewOptionsError.yml | 52 ++++++++++++++ 13 files changed, 411 insertions(+), 61 deletions(-) create mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_select/_filter_by_formula_/should_raise_Airtable_ViewOptionsError.yml create mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_select/_filter_by_formula_view_/should_raise_Airtable_FilterByFormulaOptionsError.yml create mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_select/_view_/should_raise_Airtable_ViewOptionsError.yml create mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_select/_view_view_/should_raise_Airtable_ViewOptionsError.yml diff --git a/.rubocop.yml b/.rubocop.yml index 7236fda..b9ccbdd 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,4 +1,11 @@ AllCops: Exclude: - 'spec/**/*' - TargetRubyVersion: 2.1 \ No newline at end of file + TargetRubyVersion: 2.1 +Metrics/LineLength: + Max: 100 +Metrics/ClassLength: + Exclude: + - "lib/airtable/entity/table.rb" +Metrics/AbcSize: + Max: 16 diff --git a/README.md b/README.md index 82669c6..d0f7b91 100644 --- a/README.md +++ b/README.md @@ -33,14 +33,20 @@ First, be sure to register for an [airtable](https://airtable.com) account, crea ```ruby # Pass in api key to client @client = Airtable::Client.new("keyPCx5W") -# Or if you have AIRTABLE_KEY varibale you can use +``` + +Also you can have `AIRTABLE_KEY` environment variable which is your API key. + +```ruby +# if you have AIRTABLE_KEY variable @client = Airtable::Client.new ``` + Your API key carries the same privileges as your user account, so be sure to keep it secret! ### Accessing a Base -Now we can access any base in our Airsheet account by referencing the [API docs](https://airtable.com/api): +Now we can access any base in our Airtable account by referencing the [API docs](https://airtable.com/api): ```ruby # Pass in the base id @@ -49,7 +55,7 @@ Now we can access any base in our Airsheet account by referencing the [API docs] ### Accessing a Table -Now we can access any table in our Airsheet account by referencing the [API docs](https://airtable.com/api): +Now we can access any table in our Airtable account by referencing the [API docs](https://airtable.com/api): ```ruby # Pass in the table name @@ -123,11 +129,11 @@ Records can be destroyed using the `destroy` method on a table: ## Command Line Tool -This gem is include a very simple command line tool which can show basic functionality of service. +This gem includes a simple command line tool which shows the basic functionality of the service. ``` $ airtable -Usage: airtable [options] +Usage: airtable operation options Common options: -k, --api_key=KEY Airtable API key @@ -138,9 +144,15 @@ Common options: -v, --value VALUE Field value for update Supported Operations: - Get Record (if only RECORD_ID provided) - Get Field (if RECORD_ID and FIELD_ID are provided) - Update Field (if RECORD_ID, FIELD_ID and VALUE are provided) + get - Get Record/Field + update - Update Field + +Examples: + airtable get -B Base -t Table + airtable get -B Base -t Table -r RECORD_ID + airtable get -B Base -t Table -f FIELD_NAME + airtable get -B Base -t Table -f FIELD_NAME -r RECORD_ID + airtable update -b Base -t table -r RECORD_ID -f FIELD_NAME -v newValue -h, --help Show this message --version Show version @@ -149,21 +161,21 @@ Supported Operations: ### Get record's JSON ``` -$ airtable -b base_id -t Table -r record_id +$ airtable get -b base_id -t Table -r record_id {"id":"record_id","fields":{...},"createdTime":"2015-11-11 23:05:58 UTC"} ``` ### Get record's field value ``` -$ airtable -b base_id -t Table -r record_id -f field_name +$ airtable get -b base_id -t Table -r record_id -f field_name FIELD_VALUE ``` ### Update record's field value ``` -$ airtable -b base_id -t Table -r record_id -f field_name -v NEW_VALUE +$ airtable update -b base_id -t Table -r record_id -f field_name -v NEW_VALUE OK ``` diff --git a/airtable.gemspec b/airtable.gemspec index e1502ee..f7185da 100644 --- a/airtable.gemspec +++ b/airtable.gemspec @@ -5,8 +5,8 @@ require 'airtable/version' Gem::Specification.new do |spec| spec.name = 'airtable' spec.version = Airtable::VERSION - spec.authors = ['Nathan Esquenazi', 'Alexander Sorokin'] - spec.email = ['nesquena@gmail.com', 'syrnick@gmail.com'] + spec.authors = ['Nathan Esquenazi', 'Alexander Sorokin', 'Oleksandr Simonov'] + spec.email = ['nesquena@gmail.com', 'syrnick@gmail.com', 'oleksandr@simonov.me'] spec.summary = 'Easily connect to airtable data using ruby' spec.description = 'Easily connect to airtable data using ruby with access to all of the airtable features.' spec.homepage = 'https://github.com/nesquena/airtable-ruby' diff --git a/lib/airtable/cli.rb b/lib/airtable/cli.rb index 1dd2be2..05bd322 100644 --- a/lib/airtable/cli.rb +++ b/lib/airtable/cli.rb @@ -7,6 +7,7 @@ module Airtable class CLI def initialize(args) trap_interrupt + @operation = args.shift @args = args @options = {} @parser = OptionParser.new @@ -30,7 +31,7 @@ def start end def add_banner - @parser.banner = 'Usage: airtable [options]' + @parser.banner = 'Usage: airtable operation options' @parser.separator '' end @@ -70,27 +71,57 @@ def add_tail_options def add_supported_operations @parser.separator '' @parser.separator 'Supported Operations:' - @parser.separator "\tGet Record (if only RECORD_ID provided)" - @parser.separator "\tGet Field (if RECORD_ID and FIELD_ID are provided)" - @parser.separator "\tUpdate Field (if RECORD_ID, FIELD_ID and VALUE are provided)" + @parser.separator "\tget - Get Record/Field" + @parser.separator "\tupdate - Update Field" + @parser.separator '' + @parser.separator 'Examples:' + @parser.separator "\tairtable get -B Base -t Table" + @parser.separator "\tairtable get -B Base -t Table -r RECORD_ID" + @parser.separator "\tairtable get -B Base -t Table -f FIELD_NAME" + @parser.separator "\tairtable get -B Base -t Table -f FIELD_NAME -r RECORD_ID" + @parser.separator "\tairtable update -b Base -t table -r RECORD_ID -f FIELD_NAME -v newValue" @parser.separator '' end def valid_options? @options[:table_name] && !@options[:table_name].empty? && - @options[:base_id] && !@options[:base_id].empty? && - @options[:record_id] && !@options[:record_id].empty? + @options[:base_id] && !@options[:base_id].empty? + end + + def record_id? + @options[:record_id] && !@options[:record_id].empty? + end + + def field_name? + @options[:field_name] && !@options[:field_name].empty? end def run_operation - if @options[:field_value] && !@options[:field_value].empty? && @options[:field_name] && !@options[:field_name].empty? + case @operation + when "get" + case + when record_id? && field_name? + print_field + when record_id? && !field_name? + print_record + when !record_id? && field_name? + print_fields + else + print_records + end + when "update" + [:field_value, :field_name, :record_id].each do |key| + return if !@options[key] || @options[key].empty? + end update_field - elsif @options[:field_name] && !@options[:field_name].empty? - print_field - else - print_record end end + + def print_records + puts (::Airtable::Client.new(@options[:api_key]).base(@options[:base_id]).table(@options[:table_name]).select.map do |record| + { id: record.id, fields: record.fields, createdTime: record.created_at } + end).to_json + end def print_record record = ::Airtable::Client.new(@options[:api_key]).base(@options[:base_id]).table(@options[:table_name]).find(@options[:record_id]) @@ -102,6 +133,12 @@ def print_field puts record.fields[@options[:field_name]] end + def print_fields + ::Airtable::Client.new(@options[:api_key]).base(@options[:base_id]).table(@options[:table_name]).select.each do |record| + puts record.fields[@options[:field_name]] + end + end + def update_field ::Airtable::Client.new(@options[:api_key]).base(@options[:base_id]).table(@options[:table_name]) .update(@options[:record_id], @options[:field_name] => @options[:field_value]) diff --git a/lib/airtable/entity/base.rb b/lib/airtable/entity/base.rb index dc3b10a..eb5f959 100644 --- a/lib/airtable/entity/base.rb +++ b/lib/airtable/entity/base.rb @@ -12,7 +12,7 @@ def table(name) end def __make_request__(method, path, data) - url = [::Airtable.server_url, @id, path].join('/') + url = [::Airtable.server_url, CGI.escape(@id), path].join('/') resp = ::Airtable::Request.new(url, data, @client.api_key) .request(method) if resp.success? diff --git a/lib/airtable/entity/record.rb b/lib/airtable/entity/record.rb index eed7f60..69c3711 100644 --- a/lib/airtable/entity/record.rb +++ b/lib/airtable/entity/record.rb @@ -7,8 +7,6 @@ class Record extend Forwardable attr_reader :id, :created_at, :fields - def_delegators :@fields, :[], :[]= - def initialize(id, options = {}) @id = id parse_options(options) @@ -18,15 +16,15 @@ def new_record? @id.nil? || @id.empty? end - def __create__(base, name) - res = base.__make_request__(:post, name, fields: fields) + def __create__(base, table_name) + res = base.__make_request__(:post, table_name, fields: fields) @id = res['id'] parse_options(fields: res['fields'], created_at: res['createdTime']) self end - def __update__(base, name) - args = [:patch, [name, @id].join('/'), fields: fields] + def __update__(base, table_name) + args = [:patch, [table_name, @id].join('/'), fields: fields] res = base.__make_request__(*args) parse_options(fields: res['fields']) self @@ -38,14 +36,14 @@ def __fetch__(base, path) self end - def __replace__(base, name) - res = base.__make_request__(:put, [name, @id].join('/'), fields: fields) + def __replace__(base, table_name) + res = base.__make_request__(:put, [table_name, @id].join('/'), fields: fields) parse_options(fields: res['fields']) self end - def __destroy__(base, name) - res = base.__make_request__(:delete, [name, @id].join('/'), {}) + def __destroy__(base, table_name) + res = base.__make_request__(:delete, [table_name, @id].join('/'), {}) res['deleted'] end @@ -58,16 +56,16 @@ def []=(key, value) end class << self - def all(base, name, params) + def all(base, table_name, params) res = [] - __fetch__(base, name, params, res) + __fetch__(base, table_name, params, res) res end private - def __fetch__(base, name, params, res) - result = base.__make_request__(:get, name, params) + def __fetch__(base, table_name, params, res) + result = base.__make_request__(:get, table_name, params) result['records'].each do |r| args = [ r['id'], fields: r['fields'], created_at: r['createdTime'] @@ -75,7 +73,7 @@ def __fetch__(base, name, params, res) res << new(*args) end return unless result['offset'] - __fetch__(base, name, params.merge(offset: result['offset']), res) + __fetch__(base, table_name, params.merge(offset: result['offset']), res) end end diff --git a/lib/airtable/entity/table.rb b/lib/airtable/entity/table.rb index c0ddffe..c923679 100644 --- a/lib/airtable/entity/table.rb +++ b/lib/airtable/entity/table.rb @@ -1,3 +1,4 @@ +require 'cgi' module Airtable module Entity # Airtable Table entity @@ -6,9 +7,9 @@ class Table DEFAULT_DIRECTION = 'asc'.freeze def initialize(base, name) - @name = name + @name = CGI.escape(name) @base = base - @args = [@base, @name] + @table_path = [@base, @name] end def select(options = {}) @@ -20,24 +21,24 @@ def select(options = {}) end def find(id) - args = [@base, [@name, id].join('/')] - ::Airtable::Entity::Record.new(id).__fetch__(*args) + table_path = [@base, [@name, id].join('/')] + ::Airtable::Entity::Record.new(id).__fetch__(*table_path) end def create(fields) - ::Airtable::Entity::Record.new(nil, fields: fields).__create__(*@args) + ::Airtable::Entity::Record.new(nil, fields: fields).__create__(*@table_path) end def update(id, fields) - ::Airtable::Entity::Record.new(id, fields: fields).__update__(*@args) + ::Airtable::Entity::Record.new(id, fields: fields).__update__(*@table_path) end def replace(id, fields) - ::Airtable::Entity::Record.new(id, fields: fields).__replace__(*@args) + ::Airtable::Entity::Record.new(id, fields: fields).__replace__(*@table_path) end def destroy(id) - ::Airtable::Entity::Record.new(id).__destroy__(*@args) + ::Airtable::Entity::Record.new(id).__destroy__(*@table_path) end private @@ -51,16 +52,18 @@ def fetch_records(params) end def update_default_params(params, options) - params[:fields] = option_value_for(options, :fields) - params[:maxRecords] = option_value_for(options, :max_records) - params[:offset] = option_value_for(options, :offset) - params[:pageSize] = option_value_for(options, :limit) || PAGE_SIZE + params[:fields] = option_value_for(options, :fields) + params[:maxRecords] = option_value_for(options, :max_records) + params[:offset] = option_value_for(options, :offset) + params[:view] = option_value_for(options, :view) + params[:filterByFormula] = option_value_for(options, :filter_by_formula) + params[:pageSize] = option_value_for(options, :limit) || PAGE_SIZE end def validate_params(params) - if params[:fields] && !params[:fields].is_a?(::Array) - raise ::Airtable::FieldsOptionError - end + validate_fields(params[:fields]) + param_not_empty?(params[:view], ::Airtable::ViewOptionError) + param_not_empty?(params[:filterByFormula], ::Airtable::FilterByFormulaOptionError) raise ::Airtable::LimitOptionError if params[:pageSize].to_i <= 0 # rubocop:disable all if params[:maxRecords] && params[:maxRecords].to_i <= 0 @@ -69,6 +72,16 @@ def validate_params(params) # rubocop:enable all end + def validate_fields(value) + return if !value || value.is_a?(::Array) + raise ::Airtable::FieldsOptionError + end + + def param_not_empty?(value, klass) + return if !value || (!value.empty? && value.is_a?(::String)) + raise klass + end + def update_sort_options(params, options) sort_option = option_value_for(options, :sort) case sort_option diff --git a/lib/airtable/error.rb b/lib/airtable/error.rb index 369db12..50fbfd2 100644 --- a/lib/airtable/error.rb +++ b/lib/airtable/error.rb @@ -33,6 +33,20 @@ def initialize end end + # view is empty + class ViewOptionError < ::ArgumentError + def initialize + super('Invalid view option format.') + end + end + + # filter by formula is empty + class FilterByFormulaOptionError < ::ArgumentError + def initialize + super('Invalid filter_by_formula option format.') + end + end + # Request error class RequestError < StandardError attr_reader :body @@ -81,9 +95,9 @@ def initialize(body) # too many requests class TooManyRequestsError < ::Airtable::RequestError def initialize(body) - # rubocop:disable Metrics/LineLength - super(body, 'You have made too many requests in a short period of time. Please retry your request later') - # rubocop:enable Metrics/LineLength + super(body, + 'You have made too many requests in a short period of time. Please retry your request later' + ) end end @@ -97,9 +111,7 @@ def initialize(body) # service unavailable class ServiceUnavailableError < ::Airtable::RequestError def initialize(body) - # rubocop:disable Metrics/LineLength super(body, 'The service is temporarily unavailable. Please retry shortly.') - # rubocop:enable Metrics/LineLength end end end diff --git a/spec/lib/airtable/entity/table_spec.rb b/spec/lib/airtable/entity/table_spec.rb index 3482523..f380099 100644 --- a/spec/lib/airtable/entity/table_spec.rb +++ b/spec/lib/airtable/entity/table_spec.rb @@ -136,6 +136,29 @@ end end + context '({view: ["view"]})' do + it 'should raise ::Airtable::ViewOptionsError' do + expect {table_entity.select(view: ['view'])}.to raise_error(::Airtable::ViewOptionError) + end + end + + context '({view: ""})' do + it 'should raise ::Airtable::ViewOptionsError' do + expect {table_entity.select(view: '')}.to raise_error(::Airtable::ViewOptionError) + end + end + + context '({filter_by_formula: ""})' do + it 'should raise ::Airtable::ViewOptionsError' do + expect {table_entity.select(filter_by_formula: '')}.to raise_error(::Airtable::FilterByFormulaOptionError) + end + end + + context '({filter_by_formula: ["view"]})' do + it 'should raise ::Airtable::FilterByFormulaOptionsError' do + expect {table_entity.select(filter_by_formula: ['view'])}.to raise_error(::Airtable::FilterByFormulaOptionError) + end + end end context '#find' do it 'should return a one record' do diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_select/_filter_by_formula_/should_raise_Airtable_ViewOptionsError.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_filter_by_formula_/should_raise_Airtable_ViewOptionsError.yml new file mode 100644 index 0000000..219d357 --- /dev/null +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_filter_by_formula_/should_raise_Airtable_ViewOptionsError.yml @@ -0,0 +1,72 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?filterByFormula=&pageSize=100 + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Airtable gem v0.2.0 + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Fri, 05 Jan 2018 10:14:01 GMT + Etag: + - W/"eca-Rf28b2fTsBkZuGX4RHwEOrLFa2w" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '1475' + Connection: + - keep-alive + body: + encoding: ASCII-8BIT + string: '{"records":[{"id":"recIsbIqSnj72dp0O","fields":{"Name":"Supper Potato"},"createdTime":"2017-12-14T17:24:31.000Z"},{"id":"recQes7d2DCuEcGe0","fields":{"Email + Address":"test@example.com","Phone Screen Score":"2 - worth consideration","Onsite + Interview Date":"2013-02-14","Stage":"Decision Needed","Onsite Interview Notes":"Seems + like a really hard worker, and has a great attitude. Very observant: He''s + got eyes everywhere. But I am concerned that he won''t be able to think outside + the bag.","Phone":"(208) 555-0505","Phone Screen Date":"2013-02-07","Name":"Chippy + the Potato","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attgK5ha1ajpVWOSY","url":"https://dl.airtable.com/xwv2ejXtTBqbTqjcnbQi_Chippypotato.jpg","filename":"Chippypotato.jpg","size":59402,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/g7BoJwauSyOZIdtoQALf_Chippypotato.jpg","width":27,"height":36},"large":{"url":"https://dl.airtable.com/IRqeRk4sTkud3Ga12uYI_Chippypotato.jpg","width":256,"height":341}}},{"id":"attU5T1mSPQ8r3nJ6","url":"https://dl.airtable.com/e6kCK3Z0SUS6hhCUoKcD_chippypotatoresume.docx","filename":"chippypotatoresume.docx","size":127681,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-256x331.png","width":256,"height":331}}}],"Onsite + Interview Score":"2 - worth consideration","Applying for":["recYPK7fwrFojHO9H"],"Phone + Screen Interviewer":["recmtSmO51zqqWAqs"],"Phone Screen Notes":"Questionable, + but tentatively move to on-site interview"},"createdTime":"2015-11-11T23:05:58.000Z"},{"id":"recSIn39bSTqt4Swc","fields":{"Name":"Super + Dupper Pupper Name"},"createdTime":"2015-11-11T23:19:11.000Z"},{"id":"reccgV23NfAFo0IxD","fields":{"Name":"353f02de-6928-4c36-a7ed-6807d369e0c3"},"createdTime":"2017-12-14T17:23:05.000Z"},{"id":"recmfqZPhlsMcuJS2","fields":{"Name":"8e7f34d5-7e04-410d-aa70-8a01e60a38de"},"createdTime":"2017-12-14T17:22:26.000Z"},{"id":"recxczR4BaLmvsdU2","fields":{"Email + Address":"hrmqueenlizzy@example.com","Phone Screen Score":"3 - good candidate","Onsite + Interview Date":"2013-02-17","Stage":"Decision Needed","Onsite Interview Notes":"Liz + was highly qualified, but seemed shifty. When faced with a question she was + uncomfortable with, she slithered out of it. Could be someone that is very + smart, but difficult to deal with. Not sure that she''s a team player, which + of course is a problem if you''re going to be representing a team.","Phone":"(865) + 123-4567","Phone Screen Date":"2013-02-12","Name":"Queen Elizardbeth II","Onsite + Interviewer":["recmtSmO51zqqWAqs"],"Attachments":[{"id":"attQ6vAjxpfVlQedd","url":"https://dl.airtable.com/EXBc35SWSKKHxhbTUbOe_elizardbeth.jpg","filename":"elizardbeth.jpg","size":465335,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/jl9v4DQWSx8IoqzgWRTg_elizardbeth.jpg","width":24,"height":36},"large":{"url":"https://dl.airtable.com/YHtaEY7mTie2ZpS5hfJz_elizardbeth.jpg","width":256,"height":388}}},{"id":"attZa28XJJt0GL3DB","url":"https://dl.airtable.com/XdCerQy8MqG5RacAKwdn_Q%20Elizardbeth%20Resume.docx","filename":"Q + Elizardbeth Resume.docx","size":6519,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-256x331.png","width":256,"height":331}}}],"Onsite + Interview Score":"2 - worth consideration","Applying for":["recgrjFdvUdTGnDsF"],"Phone + Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Move to in-person."},"createdTime":"2015-11-11T23:11:29.000Z"}]}' + http_version: + recorded_at: Fri, 05 Jan 2018 10:14:02 GMT +recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_select/_filter_by_formula_view_/should_raise_Airtable_FilterByFormulaOptionsError.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_filter_by_formula_view_/should_raise_Airtable_FilterByFormulaOptionsError.yml new file mode 100644 index 0000000..63a78c6 --- /dev/null +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_filter_by_formula_view_/should_raise_Airtable_FilterByFormulaOptionsError.yml @@ -0,0 +1,52 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?pageSize=100&view%5B%5D=view + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Airtable gem v0.2.0 + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 422 + message: Unprocessable Entity + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Fri, 05 Jan 2018 10:13:18 GMT + Etag: + - W/"7f-ic7Ppjtti07fW0oQKWhpH6lrz8A" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '127' + Connection: + - keep-alive + body: + encoding: UTF-8 + string: '{"error":{"type":"INVALID_REQUEST_UNKNOWN","message":"Invalid request: + parameter validation failed. Check your request data."}}' + http_version: + recorded_at: Fri, 05 Jan 2018 10:13:19 GMT +recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_select/_view_/should_raise_Airtable_ViewOptionsError.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_view_/should_raise_Airtable_ViewOptionsError.yml new file mode 100644 index 0000000..0b47f59 --- /dev/null +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_view_/should_raise_Airtable_ViewOptionsError.yml @@ -0,0 +1,72 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?pageSize=100&view= + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Airtable gem v0.2.0 + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Fri, 05 Jan 2018 10:14:00 GMT + Etag: + - W/"eca-Rf28b2fTsBkZuGX4RHwEOrLFa2w" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '1475' + Connection: + - keep-alive + body: + encoding: ASCII-8BIT + string: '{"records":[{"id":"recIsbIqSnj72dp0O","fields":{"Name":"Supper Potato"},"createdTime":"2017-12-14T17:24:31.000Z"},{"id":"recQes7d2DCuEcGe0","fields":{"Email + Address":"test@example.com","Phone Screen Score":"2 - worth consideration","Onsite + Interview Date":"2013-02-14","Stage":"Decision Needed","Onsite Interview Notes":"Seems + like a really hard worker, and has a great attitude. Very observant: He''s + got eyes everywhere. But I am concerned that he won''t be able to think outside + the bag.","Phone":"(208) 555-0505","Phone Screen Date":"2013-02-07","Name":"Chippy + the Potato","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attgK5ha1ajpVWOSY","url":"https://dl.airtable.com/xwv2ejXtTBqbTqjcnbQi_Chippypotato.jpg","filename":"Chippypotato.jpg","size":59402,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/g7BoJwauSyOZIdtoQALf_Chippypotato.jpg","width":27,"height":36},"large":{"url":"https://dl.airtable.com/IRqeRk4sTkud3Ga12uYI_Chippypotato.jpg","width":256,"height":341}}},{"id":"attU5T1mSPQ8r3nJ6","url":"https://dl.airtable.com/e6kCK3Z0SUS6hhCUoKcD_chippypotatoresume.docx","filename":"chippypotatoresume.docx","size":127681,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-256x331.png","width":256,"height":331}}}],"Onsite + Interview Score":"2 - worth consideration","Applying for":["recYPK7fwrFojHO9H"],"Phone + Screen Interviewer":["recmtSmO51zqqWAqs"],"Phone Screen Notes":"Questionable, + but tentatively move to on-site interview"},"createdTime":"2015-11-11T23:05:58.000Z"},{"id":"recSIn39bSTqt4Swc","fields":{"Name":"Super + Dupper Pupper Name"},"createdTime":"2015-11-11T23:19:11.000Z"},{"id":"reccgV23NfAFo0IxD","fields":{"Name":"353f02de-6928-4c36-a7ed-6807d369e0c3"},"createdTime":"2017-12-14T17:23:05.000Z"},{"id":"recmfqZPhlsMcuJS2","fields":{"Name":"8e7f34d5-7e04-410d-aa70-8a01e60a38de"},"createdTime":"2017-12-14T17:22:26.000Z"},{"id":"recxczR4BaLmvsdU2","fields":{"Email + Address":"hrmqueenlizzy@example.com","Phone Screen Score":"3 - good candidate","Onsite + Interview Date":"2013-02-17","Stage":"Decision Needed","Onsite Interview Notes":"Liz + was highly qualified, but seemed shifty. When faced with a question she was + uncomfortable with, she slithered out of it. Could be someone that is very + smart, but difficult to deal with. Not sure that she''s a team player, which + of course is a problem if you''re going to be representing a team.","Phone":"(865) + 123-4567","Phone Screen Date":"2013-02-12","Name":"Queen Elizardbeth II","Onsite + Interviewer":["recmtSmO51zqqWAqs"],"Attachments":[{"id":"attQ6vAjxpfVlQedd","url":"https://dl.airtable.com/EXBc35SWSKKHxhbTUbOe_elizardbeth.jpg","filename":"elizardbeth.jpg","size":465335,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/jl9v4DQWSx8IoqzgWRTg_elizardbeth.jpg","width":24,"height":36},"large":{"url":"https://dl.airtable.com/YHtaEY7mTie2ZpS5hfJz_elizardbeth.jpg","width":256,"height":388}}},{"id":"attZa28XJJt0GL3DB","url":"https://dl.airtable.com/XdCerQy8MqG5RacAKwdn_Q%20Elizardbeth%20Resume.docx","filename":"Q + Elizardbeth Resume.docx","size":6519,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-256x331.png","width":256,"height":331}}}],"Onsite + Interview Score":"2 - worth consideration","Applying for":["recgrjFdvUdTGnDsF"],"Phone + Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Move to in-person."},"createdTime":"2015-11-11T23:11:29.000Z"}]}' + http_version: + recorded_at: Fri, 05 Jan 2018 10:14:02 GMT +recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_select/_view_view_/should_raise_Airtable_ViewOptionsError.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_view_view_/should_raise_Airtable_ViewOptionsError.yml new file mode 100644 index 0000000..1426252 --- /dev/null +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_view_view_/should_raise_Airtable_ViewOptionsError.yml @@ -0,0 +1,52 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?pageSize=100&view%5B%5D=view + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Airtable gem v0.2.0 + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 422 + message: Unprocessable Entity + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Fri, 05 Jan 2018 10:13:17 GMT + Etag: + - W/"7f-ic7Ppjtti07fW0oQKWhpH6lrz8A" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '127' + Connection: + - keep-alive + body: + encoding: UTF-8 + string: '{"error":{"type":"INVALID_REQUEST_UNKNOWN","message":"Invalid request: + parameter validation failed. Check your request data."}}' + http_version: + recorded_at: Fri, 05 Jan 2018 10:13:19 GMT +recorded_with: VCR 4.0.0 From 8102ec6b2e700dd1bb92385e6faf34b7c33593d4 Mon Sep 17 00:00:00 2001 From: Alexander Simonov Date: Sun, 20 May 2018 14:45:15 +0300 Subject: [PATCH 11/11] Latest fixes --- README.md | 4 +- lib/airtable/entity/base.rb | 2 +- lib/airtable/entity/table.rb | 17 +- spec/lib/airtable/entity/table_spec.rb | 8 +- .../should_return_all_records_records.yml | 128 ------------- .../should_return_all_records_records.yml | 168 ++++++++++++++++++ 6 files changed, 187 insertions(+), 140 deletions(-) delete mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_select/_limit_2_/should_return_all_records_records.yml create mode 100644 spec/vcr_cassettes/Airtable_Entity_Table/_select/_page_size_2_/should_return_all_records_records.yml diff --git a/README.md b/README.md index d0f7b91..89b8be4 100644 --- a/README.md +++ b/README.md @@ -70,10 +70,10 @@ Once you have access to a table from above, we can query a set of records in the @records = @table.select ``` -We can specify a `sort` order, `limit`, `max_records` and `offset` as part of our query: +We can specify a `sort` order, `per_page`, `max_records` and `offset` as part of our query: ```ruby -@records = @table.records(:sort => ["Name", :asc], :limit => 50) +@records = @table.records(:sort => ["Name", :asc], :page_size => 50) @records # => [#"Bill Lowry", :email=>"billery@gmail.com">, ...] @records.offset #=> "itrEN2TCbrcSN2BMs" ``` diff --git a/lib/airtable/entity/base.rb b/lib/airtable/entity/base.rb index eb5f959..dc3b10a 100644 --- a/lib/airtable/entity/base.rb +++ b/lib/airtable/entity/base.rb @@ -12,7 +12,7 @@ def table(name) end def __make_request__(method, path, data) - url = [::Airtable.server_url, CGI.escape(@id), path].join('/') + url = [::Airtable.server_url, @id, path].join('/') resp = ::Airtable::Request.new(url, data, @client.api_key) .request(method) if resp.success? diff --git a/lib/airtable/entity/table.rb b/lib/airtable/entity/table.rb index c923679..820279e 100644 --- a/lib/airtable/entity/table.rb +++ b/lib/airtable/entity/table.rb @@ -6,10 +6,17 @@ class Table PAGE_SIZE = 100 DEFAULT_DIRECTION = 'asc'.freeze + attr_reader :name, :base + def initialize(base, name) - @name = CGI.escape(name) + @name = name @base = base - @table_path = [@base, @name] + @url_name = url_encode(@name) + @table_path = [@base, @url_name] + end + + def url_encode(str) + URI.escape(str, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]")) end def select(options = {}) @@ -21,7 +28,7 @@ def select(options = {}) end def find(id) - table_path = [@base, [@name, id].join('/')] + table_path = [@base, [@url_name, id].join('/')] ::Airtable::Entity::Record.new(id).__fetch__(*table_path) end @@ -48,7 +55,7 @@ def option_value_for(hash, key) end def fetch_records(params) - ::Airtable::Entity::Record.all(@base, @name, params) + ::Airtable::Entity::Record.all(@base, @url_name, params) end def update_default_params(params, options) @@ -57,7 +64,7 @@ def update_default_params(params, options) params[:offset] = option_value_for(options, :offset) params[:view] = option_value_for(options, :view) params[:filterByFormula] = option_value_for(options, :filter_by_formula) - params[:pageSize] = option_value_for(options, :limit) || PAGE_SIZE + params[:pageSize] = option_value_for(options, :page_size) || PAGE_SIZE end def validate_params(params) diff --git a/spec/lib/airtable/entity/table_spec.rb b/spec/lib/airtable/entity/table_spec.rb index f380099..c573e82 100644 --- a/spec/lib/airtable/entity/table_spec.rb +++ b/spec/lib/airtable/entity/table_spec.rb @@ -32,9 +32,9 @@ end end - context '({limit: 2})' do + context '({page_size: 2})' do it 'should return all records records' do - expect(table_entity.select(limit: 2).size).to eq(3) + expect(table_entity.select(page_size: 2).size).to eq(2) end end @@ -124,9 +124,9 @@ end end - context '(limit: "Test")' do + context '(per_page: "Test")' do it 'should raise ::Airtable::LimitOptionsError' do - expect {table_entity.select(limit: 'Test')}.to raise_error(::Airtable::LimitOptionError) + expect {table_entity.select(page_size: 'Test')}.to raise_error(::Airtable::LimitOptionError) end end diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_select/_limit_2_/should_return_all_records_records.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_limit_2_/should_return_all_records_records.yml deleted file mode 100644 index f401386..0000000 --- a/spec/vcr_cassettes/Airtable_Entity_Table/_select/_limit_2_/should_return_all_records_records.yml +++ /dev/null @@ -1,128 +0,0 @@ ---- -http_interactions: -- request: - method: get - uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?pageSize=2 - body: - encoding: US-ASCII - string: '' - headers: - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - User-Agent: - - Ruby - Authorization: - - Bearer key3nfyQojyvXquoR - Content-Type: - - application/json - response: - status: - code: 200 - message: OK - headers: - Access-Control-Allow-Headers: - - content-type, authorization, content-length, x-requested-with, x-api-version, - x-airtable-application-id - Access-Control-Allow-Methods: - - GET,PUT,POST,PATCH,DELETE,OPTIONS - Access-Control-Allow-Origin: - - "*" - Content-Type: - - application/json; charset=utf-8 - Date: - - Thu, 14 Dec 2017 17:03:41 GMT - Etag: - - W/"d1c-G1Cnc71KD1wbzFDlYtOZhQUdERE" - Server: - - Tengine - Vary: - - Accept-Encoding - Content-Length: - - '1367' - Connection: - - keep-alive - body: - encoding: ASCII-8BIT - string: '{"records":[{"id":"recQes7d2DCuEcGe0","fields":{"Email Address":"c.potato@example.com","Phone - Screen Score":"2 - worth consideration","Onsite Interview Date":"2013-02-14","Stage":"Decision - Needed","Onsite Interview Notes":"Seems like a really hard worker, and has - a great attitude. Very observant: He''s got eyes everywhere. But I am concerned - that he won''t be able to think outside the bag.","Phone":"(208) 555-0505","Phone - Screen Date":"2013-02-07","Name":"Chippy the Potato","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attgK5ha1ajpVWOSY","url":"https://dl.airtable.com/xwv2ejXtTBqbTqjcnbQi_Chippypotato.jpg","filename":"Chippypotato.jpg","size":59402,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/g7BoJwauSyOZIdtoQALf_Chippypotato.jpg","width":27,"height":36},"large":{"url":"https://dl.airtable.com/IRqeRk4sTkud3Ga12uYI_Chippypotato.jpg","width":256,"height":341}}},{"id":"attU5T1mSPQ8r3nJ6","url":"https://dl.airtable.com/e6kCK3Z0SUS6hhCUoKcD_chippypotatoresume.docx","filename":"chippypotatoresume.docx","size":127681,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-256x331.png","width":256,"height":331}}}],"Onsite - Interview Score":"2 - worth consideration","Applying for":["recYPK7fwrFojHO9H"],"Phone - Screen Interviewer":["recmtSmO51zqqWAqs"],"Phone Screen Notes":"Questionable, - but tentatively move to on-site interview"},"createdTime":"2015-11-11T23:05:58.000Z"},{"id":"recSIn39bSTqt4Swc","fields":{"Email - Address":"ohuxley@example.com","Phone Screen Score":"3 - good candidate","Onsite - Interview Date":"2013-02-19","Stage":"Interviewing","Onsite Interview Notes":"Owldous - was impressive overall, and he has a lot of experience in the corporate world. - But if he were to join with a sports team, he''d still have a lot to learn - about the industry, e.g., I asked him for his opinion on Leon the Lion''s - recent resignation and he responded, \"Whooo?\"","Phone":"(646) 555-4389","Phone - Screen Date":"2013-02-11","Name":"Owldous Huxley","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attxKBp3Hb4FJH2I8","url":"https://dl.airtable.com/1ghGlZqKTViBcofjpPMC_owl.jpg","filename":"owl.jpg","size":242886,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/dFcZepTFCeG0YUXQSOrA_owl.jpg","width":54,"height":36},"large":{"url":"https://dl.airtable.com/QSkAheQ2SFuD3txaTgge_owl.jpg","width":256,"height":171}}},{"id":"attZJp9i2TJ4Z5xeM","url":"https://dl.airtable.com/WwYtwDVURFCGHCoipxhM_Resume%20(O.%20Huxley).docx","filename":"Resume - (O. Huxley).docx","size":16443,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZJp9i2TJ4Z5xeM-512x663.png","width":512,"height":663}}}],"Onsite - Interview Score":"3 - good candidate","Applying for":["recgrjFdvUdTGnDsF"],"Phone - Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Good analytical - skills, very articulate. Proceed to in-person interview."},"createdTime":"2015-11-11T23:19:11.000Z"}],"offset":"itrgpd7Fl6zYVgyIC/recSIn39bSTqt4Swc"}' - http_version: - recorded_at: Thu, 14 Dec 2017 17:03:43 GMT -- request: - method: get - uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?offset=itrgpd7Fl6zYVgyIC/recSIn39bSTqt4Swc&pageSize=2 - body: - encoding: US-ASCII - string: '' - headers: - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - User-Agent: - - Ruby - Authorization: - - Bearer key3nfyQojyvXquoR - Content-Type: - - application/json - response: - status: - code: 200 - message: OK - headers: - Access-Control-Allow-Headers: - - content-type, authorization, content-length, x-requested-with, x-api-version, - x-airtable-application-id - Access-Control-Allow-Methods: - - GET,PUT,POST,PATCH,DELETE,OPTIONS - Access-Control-Allow-Origin: - - "*" - Content-Type: - - application/json; charset=utf-8 - Date: - - Thu, 14 Dec 2017 17:03:42 GMT - Etag: - - W/"6b6-xICWluzkgs/5ZR7gBgQHpK8U1Z8" - Server: - - Tengine - Vary: - - Accept-Encoding - Content-Length: - - '889' - Connection: - - keep-alive - body: - encoding: ASCII-8BIT - string: '{"records":[{"id":"recxczR4BaLmvsdU2","fields":{"Email Address":"hrmqueenlizzy@example.com","Phone - Screen Score":"3 - good candidate","Onsite Interview Date":"2013-02-17","Stage":"Decision - Needed","Onsite Interview Notes":"Liz was highly qualified, but seemed shifty. - When faced with a question she was uncomfortable with, she slithered out of - it. Could be someone that is very smart, but difficult to deal with. Not sure - that she''s a team player, which of course is a problem if you''re going to - be representing a team.","Phone":"(865) 123-4567","Phone Screen Date":"2013-02-12","Name":"Queen - Elizardbeth II","Onsite Interviewer":["recmtSmO51zqqWAqs"],"Attachments":[{"id":"attQ6vAjxpfVlQedd","url":"https://dl.airtable.com/EXBc35SWSKKHxhbTUbOe_elizardbeth.jpg","filename":"elizardbeth.jpg","size":465335,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/jl9v4DQWSx8IoqzgWRTg_elizardbeth.jpg","width":24,"height":36},"large":{"url":"https://dl.airtable.com/YHtaEY7mTie2ZpS5hfJz_elizardbeth.jpg","width":256,"height":388}}},{"id":"attZa28XJJt0GL3DB","url":"https://dl.airtable.com/XdCerQy8MqG5RacAKwdn_Q%20Elizardbeth%20Resume.docx","filename":"Q - Elizardbeth Resume.docx","size":6519,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-256x331.png","width":256,"height":331}}}],"Onsite - Interview Score":"2 - worth consideration","Applying for":["recgrjFdvUdTGnDsF"],"Phone - Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Move to in-person."},"createdTime":"2015-11-11T23:11:29.000Z"}]}' - http_version: - recorded_at: Thu, 14 Dec 2017 17:03:44 GMT -recorded_with: VCR 4.0.0 diff --git a/spec/vcr_cassettes/Airtable_Entity_Table/_select/_page_size_2_/should_return_all_records_records.yml b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_page_size_2_/should_return_all_records_records.yml new file mode 100644 index 0000000..0c0a261 --- /dev/null +++ b/spec/vcr_cassettes/Airtable_Entity_Table/_select/_page_size_2_/should_return_all_records_records.yml @@ -0,0 +1,168 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?pageSize=2 + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Airtable gem v0.2.0 + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id, user-agent, x-airtable-user-agent + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Sun, 20 May 2018 11:44:04 GMT + Etag: + - W/"6e5-sI32pUdODr7DKyvogbpzwf+tRaI" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '878' + Connection: + - keep-alive + body: + encoding: ASCII-8BIT + string: '{"records":[{"id":"recIsbIqSnj72dp0O","fields":{"Name":"Supper Potato"},"createdTime":"2017-12-14T17:24:31.000Z"},{"id":"recQes7d2DCuEcGe0","fields":{"Email + Address":"test@example.com","Phone Screen Score":"2 - worth consideration","Onsite + Interview Date":"2013-02-14","Stage":"Decision Needed","Onsite Interview Notes":"Seems + like a really hard worker, and has a great attitude. Very observant: He''s + got eyes everywhere. But I am concerned that he won''t be able to think outside + the bag.","Phone":"(208) 555-0505","Phone Screen Date":"2013-02-07","Name":"Chippy + the Potato","Onsite Interviewer":["recdnQmTV3Dxn3Apz"],"Attachments":[{"id":"attgK5ha1ajpVWOSY","url":"https://dl.airtable.com/xwv2ejXtTBqbTqjcnbQi_Chippypotato.jpg","filename":"Chippypotato.jpg","size":59402,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/g7BoJwauSyOZIdtoQALf_Chippypotato.jpg","width":27,"height":36},"large":{"url":"https://dl.airtable.com/IRqeRk4sTkud3Ga12uYI_Chippypotato.jpg","width":256,"height":341}}},{"id":"attU5T1mSPQ8r3nJ6","url":"https://dl.airtable.com/e6kCK3Z0SUS6hhCUoKcD_chippypotatoresume.docx","filename":"chippypotatoresume.docx","size":127681,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attU5T1mSPQ8r3nJ6-256x331.png","width":256,"height":331}}}],"Onsite + Interview Score":"2 - worth consideration","Applying for":["recYPK7fwrFojHO9H"],"Phone + Screen Interviewer":["recmtSmO51zqqWAqs"],"Phone Screen Notes":"Questionable, + but tentatively move to on-site interview"},"createdTime":"2015-11-11T23:05:58.000Z"}],"offset":"itrBoHdqyN9t1mJnn/recQes7d2DCuEcGe0"}' + http_version: + recorded_at: Sun, 20 May 2018 11:44:04 GMT +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?offset=itrBoHdqyN9t1mJnn/recQes7d2DCuEcGe0&pageSize=2 + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Airtable gem v0.2.0 + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id, user-agent, x-airtable-user-agent + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Sun, 20 May 2018 11:44:04 GMT + Etag: + - W/"12a-PieMTkMBioEOMqsOCn9l7HI3OqY" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '298' + Connection: + - keep-alive + body: + encoding: UTF-8 + string: '{"records":[{"id":"recSIn39bSTqt4Swc","fields":{"Name":"Super Dupper + Pupper Name"},"createdTime":"2015-11-11T23:19:11.000Z"},{"id":"reccgV23NfAFo0IxD","fields":{"Name":"353f02de-6928-4c36-a7ed-6807d369e0c3"},"createdTime":"2017-12-14T17:23:05.000Z"}],"offset":"itrBoHdqyN9t1mJnn/reccgV23NfAFo0IxD"}' + http_version: + recorded_at: Sun, 20 May 2018 11:44:04 GMT +- request: + method: get + uri: https://api.airtable.com/v0/appnlJrQ2fxlfRsov/Applicants?offset=itrBoHdqyN9t1mJnn/reccgV23NfAFo0IxD&pageSize=2 + body: + encoding: US-ASCII + string: '' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Airtable gem v0.2.0 + Authorization: + - Bearer key3nfyQojyvXquoR + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Headers: + - content-type, authorization, content-length, x-requested-with, x-api-version, + x-airtable-application-id, user-agent, x-airtable-user-agent + Access-Control-Allow-Methods: + - GET,PUT,POST,PATCH,DELETE,OPTIONS + Access-Control-Allow-Origin: + - "*" + Content-Type: + - application/json; charset=utf-8 + Date: + - Sun, 20 May 2018 11:44:05 GMT + Etag: + - W/"733-C2V1JrjxZiOHMIZerL0vjd/qaDY" + Server: + - Tengine + Vary: + - Accept-Encoding + Content-Length: + - '952' + Connection: + - keep-alive + body: + encoding: ASCII-8BIT + string: '{"records":[{"id":"recmfqZPhlsMcuJS2","fields":{"Name":"8e7f34d5-7e04-410d-aa70-8a01e60a38de"},"createdTime":"2017-12-14T17:22:26.000Z"},{"id":"recxczR4BaLmvsdU2","fields":{"Email + Address":"hrmqueenlizzy@example.com","Phone Screen Score":"3 - good candidate","Onsite + Interview Date":"2013-02-17","Stage":"Decision Needed","Onsite Interview Notes":"Liz + was highly qualified, but seemed shifty. When faced with a question she was + uncomfortable with, she slithered out of it. Could be someone that is very + smart, but difficult to deal with. Not sure that she''s a team player, which + of course is a problem if you''re going to be representing a team.","Phone":"(865) + 123-4567","Phone Screen Date":"2013-02-12","Name":"Queen Elizardbeth II","Onsite + Interviewer":["recmtSmO51zqqWAqs"],"Attachments":[{"id":"attQ6vAjxpfVlQedd","url":"https://dl.airtable.com/EXBc35SWSKKHxhbTUbOe_elizardbeth.jpg","filename":"elizardbeth.jpg","size":465335,"type":"image/jpeg","thumbnails":{"small":{"url":"https://dl.airtable.com/jl9v4DQWSx8IoqzgWRTg_elizardbeth.jpg","width":24,"height":36},"large":{"url":"https://dl.airtable.com/YHtaEY7mTie2ZpS5hfJz_elizardbeth.jpg","width":256,"height":388}}},{"id":"attZa28XJJt0GL3DB","url":"https://dl.airtable.com/XdCerQy8MqG5RacAKwdn_Q%20Elizardbeth%20Resume.docx","filename":"Q + Elizardbeth Resume.docx","size":6519,"type":"application/vnd.openxmlformats-officedocument.wordprocessingml.document","thumbnails":{"small":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-28x36.png","width":28,"height":36},"large":{"url":"https://dl.airtable.com/attZa28XJJt0GL3DB-256x331.png","width":256,"height":331}}}],"Onsite + Interview Score":"2 - worth consideration","Applying for":["recgrjFdvUdTGnDsF"],"Phone + Screen Interviewer":["recnxTtR21srnQBoG"],"Phone Screen Notes":"Move to in-person."},"createdTime":"2015-11-11T23:11:29.000Z"}]}' + http_version: + recorded_at: Sun, 20 May 2018 11:44:05 GMT +recorded_with: VCR 4.0.0