airmodel 0.0.1pre

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 52609d17520965802a5ce6ad3bfbd4ed9e16d1bc
4
+ data.tar.gz: a2aa97650c28a577de2a568827ef8e52ab502004
5
+ SHA512:
6
+ metadata.gz: 1cb44df1db02298370724c4245548cb0dc52f49700efa0a8946c255b3aeaf9603912a1f4b7d451a8273a03927b31382714453e54b7a41981f5d60f447b005ef1
7
+ data.tar.gz: 7b8a4704f3679579fc8f0324f6a6f66a4764b47770843ae8a20d56898699eb535b8d608146ed0834fc48893d00ce1612619af13bf7102349b9d3ffc2324ebba0
data/.gitignore ADDED
@@ -0,0 +1,22 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ *.bundle
19
+ *.so
20
+ *.o
21
+ *.a
22
+ mkmf.log
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
data/README.md ADDED
@@ -0,0 +1,81 @@
1
+ Airmodel
2
+ ===========
3
+
4
+ Interact with your Airtable data using ActiveRecord-style models.
5
+
6
+ Installation
7
+ ----------------
8
+
9
+ Add this line to your Gemfile:
10
+
11
+ gem install 'airmodel', git: 'https://github.com/chrisfrank/airmodel.git'
12
+
13
+ Configuration
14
+ ----------------
15
+ 1. Supply your Airtable API key, either by setting ENV['AIRTABLE_API_KEY']
16
+ before your app starts...
17
+
18
+ ENV['AIRTABLE_API_KEY'] = YOUR_API_KEY
19
+
20
+ ... or by putting this line somewhere in your app's init block:
21
+
22
+ Airmodel.client(YOUR_API_KEY_HERE)
23
+
24
+ 2. Tell Airmodel where your bases are, either by creating a YAML file at
25
+ *config/bases.yml*, or with this line somewhere in your init block:
26
+
27
+ Airmodel.bases(path_to_your_yaml_file)
28
+
29
+ Your YAML file should look something like this:
30
+
31
+ :songs:
32
+ :table_name: Songs
33
+ :bases: appXYZ123ABC
34
+ :albums:
35
+ :table_name: Albums
36
+ :bases: appZZTOPETC
37
+
38
+ Airmodel supports sharding your data across multiple Airtable bases, as long as
39
+ they all have the same structure. If you've split your customers into east- and
40
+ west-coast bases, for example, your YAML file should look like this:
41
+
42
+ :songs:
43
+ :table_name: Customers
44
+ :bases:
45
+ "east_coast": appXYZ123ABC
46
+ "west_coast": appWXYOMGWTF
47
+
48
+ Usage
49
+ ----------------
50
+
51
+ Create a class for each key in your YAML file. You should name it after the
52
+ singularized version of your YAML key:
53
+
54
+ class Song < Airmodel::Model
55
+ end
56
+
57
+ class Album < Airmodel::Model
58
+ end
59
+
60
+ Now you can write code like
61
+
62
+ Song.all
63
+
64
+ Song.first
65
+
66
+ Song.new("Name": "Best Song Ever").save
67
+
68
+ Song.where("Artist Name": "The Beatles", "Composer": "Harrison")
69
+
70
+ See lib/airmodel/model.rb for all available methods.
71
+
72
+
73
+ Contributions
74
+ ----------------
75
+
76
+ Add a test to spec/models_spec.rb, make sure it passes, then send a pull
77
+ request. Thanks!
78
+
79
+
80
+
81
+
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require 'rspec/core/rake_task'
2
+ RSpec::Core::RakeTask.new(:spec) do |t|
3
+ t.pattern = Dir.glob('spec/*_spec.rb')
4
+ t.rspec_opts = '--format documentation'
5
+ end
6
+ task :default => :spec
data/airmodel.gemspec ADDED
@@ -0,0 +1,27 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'airmodel/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'airmodel'
7
+ spec.version = Airmodel::VERSION
8
+ spec.authors = ['chrisfrankdotfm']
9
+ spec.email = ['chris.frank@thefutureproject.org']
10
+ spec.description = 'Airtable data in ActiveRecord-style syntax'
11
+ spec.summary = 'Interact with your Airtable data using ActiveRecord-style models'
12
+ spec.homepage = 'https://github.com/chrisfrank/airmodel'
13
+ spec.license = 'MIT'
14
+
15
+ spec.files = `git ls-files`.split($/)
16
+ spec.test_files = spec.files.grep('^(test|spec|features)/')
17
+ spec.require_paths = ['lib']
18
+
19
+ spec.add_development_dependency 'bundler', '~> 1'
20
+ spec.add_development_dependency 'rake', '~> 10'
21
+ spec.add_development_dependency 'rspec', '~> 3'
22
+ spec.add_development_dependency 'pry', '~> 0.10'
23
+ spec.add_development_dependency 'fakeweb', '~> 1.3'
24
+
25
+ spec.add_dependency 'airtable', '~> 0.0.8'
26
+ spec.add_dependency 'activesupport', '~> 5.0'
27
+ end
data/config/bases.yml ADDED
@@ -0,0 +1,8 @@
1
+ :test_models:
2
+ :table_name: "example_table"
3
+ :bases: appXYZ
4
+ :sharded_test_models:
5
+ :table_name: "example_table"
6
+ :bases:
7
+ "nyc": appLGA
8
+ "sf": appSFO
data/lib/airmodel.rb ADDED
@@ -0,0 +1,44 @@
1
+ require 'active_support/all'
2
+ require 'airtable'
3
+
4
+ require "airmodel/version"
5
+ require "airmodel/utils"
6
+ require "airmodel/model"
7
+
8
+ # builds ActiveRecord-style models on top of Airtable
9
+ module Airmodel
10
+
11
+ def self.root
12
+ File.expand_path '../..', __FILE__
13
+ end
14
+
15
+ def self.client(api_key=ENV["AIRTABLE_API_KEY"])
16
+ @@api_client ||= Airtable::Client.new(api_key)
17
+ @@api_client
18
+ end
19
+
20
+ def self.bases(path_to_config_file="#{Airmodel.root}/config/bases.yml")
21
+ @@bases ||= YAML.load_file(path_to_config_file)
22
+ @@bases
23
+ end
24
+
25
+ end
26
+
27
+ # monkeypatch airtable-ruby to add v 0.0.9's PATCH method,
28
+ # at least until Airtable adds 0.0.9 to rubygems
29
+ module Airtable
30
+ class Table
31
+
32
+ def update_record_fields(record_id, fields_for_update)
33
+ result = self.class.patch(worksheet_url + "/" + record_id,
34
+ :body => { "fields" => fields_for_update }.to_json,
35
+ :headers => { "Content-type" => "application/json" }).parsed_response
36
+ if result.present? && result["id"].present?
37
+ Record.new(result_attributes(result))
38
+ else # failed
39
+ false
40
+ end
41
+ end
42
+
43
+ end
44
+ end
@@ -0,0 +1,153 @@
1
+ module Airmodel
2
+ class Model < Airtable::Record
3
+ extend Utils
4
+
5
+ # returns all records in the database, making as many calls as necessary
6
+ # to work around Airtable's 100-record per page design
7
+ def self.all(args={sort: default_sort})
8
+ puts "RUNNING EXPENSIVE API QUERY TO AIRTABLE (#{self.name})"
9
+ self.classify tables(args).map{|tbl| tbl.all(args)}.flatten
10
+ end
11
+
12
+ # returns up to 100 records from Airtable
13
+ def self.records(args={sort: default_sort})
14
+ puts "RUNNING EXPENSIVE API QUERY TO AIRTABLE (#{self.name})"
15
+ self.classify tables(args).map{|tbl| tbl.records(args) }.flatten
16
+ end
17
+
18
+ # default to whatever order airtable returns
19
+ # this method gets overridden on Airtabled classes
20
+ def self.default_sort
21
+ nil
22
+ end
23
+
24
+ # find records that match the filters
25
+ def self.where(filters)
26
+ shard = filters.delete(:shard)
27
+ order = filters.delete(:sort)
28
+ formula = "AND(" + filters.map{|k,v| "{#{k}}='#{v}'" }.join(',') + ")"
29
+ records(
30
+ shard: shard,
31
+ sort: order,
32
+ filterByFormula: formula,
33
+ )
34
+ end
35
+
36
+ # find a record by specified attributes, return it
37
+ def self.find_by(filters)
38
+ shard = filters.delete(:shard)
39
+ if filters[:id]
40
+ results = self.classify tables(shard: shard).map{|tbl| tbl.find(filters[:id]) }
41
+ else
42
+ formula = "AND(" + filters.map{|k,v| "{#{k}}='#{v}'" }.join(',') + ")"
43
+ results = records(
44
+ shard: shard,
45
+ filterByFormula: formula,
46
+ limit: 1
47
+ )
48
+ end
49
+ results.count == 0 ? nil : results.first
50
+ end
51
+
52
+ # return the first record
53
+ def self.first
54
+ results = records(
55
+ limit: 1
56
+ )
57
+ results.count == 0 ? nil : results.first
58
+ end
59
+
60
+ # create a new record and save it to Airtable
61
+ def self.create(*records)
62
+ results = records.map{|r|
63
+ record = self.new(r)
64
+ tables.map{|tbl| tbl.create(record) }.first
65
+ }
66
+ results.length == 1 ? results.first : results
67
+ end
68
+
69
+ # send a PATCH request to update a few fields on a record in one API call
70
+ def self.patch(id, fields, shard=nil)
71
+ r = tables(shard: shard).map{|tbl|
72
+ tbl.update_record_fields(id, airtable_formatted(fields))
73
+ }.first
74
+ self.new(r.fields)
75
+ end
76
+
77
+ # INSTANCE METHODS
78
+
79
+ def formatted_fields
80
+ self.class.airtable_formatted(self.fields)
81
+ end
82
+
83
+ def save(shard=self.shard_identifier)
84
+ if self.valid?
85
+ if new_record?
86
+ results = self.class.tables(shard: shard).map{|tbl|
87
+ tbl.create self
88
+ }
89
+ # return the first version of this record that saved successfully
90
+ results.find{|x| !!x }
91
+ else
92
+ results = self.class.tables(shard: shard).map{|tbl|
93
+ tbl.update_record_fields(id, self.changed_fields)
94
+ }
95
+ results.find{|x| !!x }
96
+ end
97
+ else
98
+ false
99
+ end
100
+ end
101
+
102
+ def changed_fields
103
+ current = fields
104
+ old = self.class.find_by(id: id, shard: shard_identifier).fields
105
+ self.class.hash_diff(current, old)
106
+ end
107
+
108
+ def destroy
109
+ self.class.tables(shard: shard_identifier).map{|tbl| tbl.destroy(id) }.first
110
+ end
111
+
112
+ def update(fields)
113
+ res = self.class.tables(shard: shard_identifier).map{|tbl| tbl.update_record_fields(id, fields) }.first
114
+ res.fields.each{|field, value| self[field] = value }
115
+ true
116
+ end
117
+
118
+ def new_record?
119
+ id.nil?
120
+ end
121
+
122
+ def cache_key
123
+ "#{self.class.table_name}_#{self.id}"
124
+ end
125
+
126
+ # override if you want to write server-side model validations
127
+ def valid?
128
+ true
129
+ end
130
+
131
+ # override if you want to return validation errors
132
+ def errors
133
+ {}
134
+ end
135
+
136
+ # getter method that should return the YAML key that defines
137
+ # which shard the record lives in. Override if you're sharding
138
+ # your data, otherwise just let it return nil
139
+ def shard_identifier
140
+ nil
141
+ end
142
+
143
+ end
144
+
145
+ # raise this error when a table
146
+ # is not defined in config/airtable_data.yml
147
+ class NoSuchBase < StandardError
148
+ end
149
+
150
+ class NoConnection < StandardError
151
+ end
152
+
153
+ end
@@ -0,0 +1,100 @@
1
+ module Airmodel
2
+ module Utils
3
+
4
+ def table_name
5
+ self.name.tableize.to_sym
6
+ end
7
+ #
8
+ # return an array of Airtable::Table objects,
9
+ # each backed by a base defined in DB YAML file
10
+ def tables(args={})
11
+ db = Airmodel.bases[table_name] || raise(NoSuchBase.new("Could not find base '#{table_name}' in config file"))
12
+ bases = normalized_base_config(db[:bases])
13
+ # return just one Airtable::Table if a particular shard was requested
14
+ if args[:shard]
15
+ [Airmodel.client.table(bases[args.delete(:shard)], db[:table_name])]
16
+ # otherwise return each one
17
+ else
18
+ bases.map{|key, val| Airmodel.client.table val, db[:table_name] }
19
+ end
20
+ end
21
+
22
+ def at(base_id, table_name)
23
+ Airmodel.client.table(base_id, table_name)
24
+ end
25
+
26
+ ## converts array of generic airtable records to the instances
27
+ # of the appropriate class
28
+ def classify(list=[])
29
+ list.map{|r| self.new(r.fields) }
30
+ end
31
+
32
+ # convert blank strings to nil, [""] to [], and "true" to a boolean
33
+ def airtable_formatted(hash)
34
+ h = hash.dup
35
+ h.each{|k,v|
36
+ if v == [""]
37
+ h[k] = []
38
+ elsif v == ""
39
+ h[k] = nil
40
+ elsif v == "true"
41
+ h[k] = true
42
+ elsif v == "false"
43
+ h[k] = false
44
+ end
45
+ }
46
+ end
47
+
48
+ # standardizes bases from config file, whether you've defined
49
+ # your bases as a single string, a hash, an array,
50
+ # or an array of hashes, returns hash of form { "base_label" => "base_id" }
51
+ def normalized_base_config(config)
52
+ if config.is_a? String
53
+ { "#{config}" => config }
54
+ elsif config.is_a? Array
55
+ parsed = config.map{|x|
56
+ if x.respond_to? :keys
57
+ [x.keys.first, x.values.first]
58
+ else
59
+ [x,x]
60
+ end
61
+ }
62
+ Hash[parsed]
63
+ else
64
+ config
65
+ end
66
+ end
67
+
68
+
69
+ # Returns a hash that removes any matches with the other hash
70
+ #
71
+ # {a: {b:"c"}} - {:a=>{:b=>"c"}} # => {}
72
+ # {a: [{c:"d"},{b:"c"}]} - {:a => [{c:"d"}, {b:"d"}]} # => {:a=>[{:b=>"c"}]}
73
+ #
74
+ def hash_diff!(first_hash, second_hash)
75
+ second_hash.each_pair do |k,v|
76
+ tv = first_hash[k]
77
+ if tv.is_a?(Hash) && v.is_a?(Hash) && v.present? && tv.present?
78
+ tv.diff!(v)
79
+ elsif v.is_a?(Array) && tv.is_a?(Array) && v.present? && tv.present?
80
+ v.each_with_index do |x, i|
81
+ tv[i].diff!(x)
82
+ end
83
+ first_hash[k] = tv - [{}]
84
+ else
85
+ first_hash.delete(k) if first_hash.has_key?(k) && tv == v
86
+ end
87
+ first_hash.delete(k) if first_hash.has_key?(k) && first_hash[k].blank?
88
+ end
89
+ first_hash
90
+ end
91
+
92
+ def hash_diff(first_hash, second_hash)
93
+ hash_diff!(first_hash.dup, second_hash)
94
+ end
95
+
96
+ def -(first_hash, second_hash)
97
+ hash_diff(first_hash, second_hash)
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,3 @@
1
+ module Airmodel
2
+ VERSION = '0.0.1pre'
3
+ end
@@ -0,0 +1,18 @@
1
+ require 'spec_helper'
2
+ describe Airmodel do
3
+
4
+ describe "bases" do
5
+ it 'should load Airtable base configuration from a YAML file' do
6
+ db = Airmodel.bases
7
+ expect(db).to eq YAML.load_file("#{Airmodel.root}/config/bases.yml")
8
+ end
9
+ end
10
+
11
+ describe "client" do
12
+ it 'should return a connected Airtable API client' do
13
+ client = Airmodel.client
14
+ expect(client.class).to eq Airtable::Client
15
+ end
16
+ end
17
+
18
+ end
@@ -0,0 +1,277 @@
1
+ require 'spec_helper'
2
+
3
+ class TestModel < Airmodel::Model
4
+ end
5
+
6
+ describe TestModel do
7
+
8
+ before(:each) do
9
+ config = Airmodel.bases[:test_models]
10
+ #stub INDEX requests
11
+ stub_airtable_response!(
12
+ Regexp.new("https://api.airtable.com/v0/#{config[:bases]}/#{config[:table_name]}"),
13
+ { "records" => [{"id": "recXYZ", fields: {"color":"red"} }, {"id":"recABC", fields: {"color": "blue"} }] }
14
+ )
15
+ #stub CREATE requests
16
+ stub_airtable_response!("https://api.airtable.com/v0/appXYZ/example_table",
17
+ { "fields" => { "color" => "red", "foo" => "bar" }, "id" => "12345" },
18
+ :post
19
+ )
20
+ end
21
+
22
+ after(:each) do
23
+ FakeWeb.clean_registry
24
+ end
25
+
26
+ describe "Class Methods" do
27
+ describe "tables" do
28
+ it "should return Airtable::Table objects for each base in the config file" do
29
+ tables = TestModel.tables
30
+ tables.each do |t|
31
+ expect(t.class).to eq Airtable::Table
32
+ expect(t.app_token).not_to eq nil
33
+ end
34
+ end
35
+ end
36
+
37
+ describe "at" do
38
+ it "should connect to an arbitrary base and table" do
39
+ table = TestModel.at("hello", "world")
40
+ expect(table.class).to eq Airtable::Table
41
+ end
42
+ end
43
+
44
+ describe "records" do
45
+ it "should return a list of airtable records" do
46
+ records = TestModel.records
47
+ expect(records.first.id).to eq "recXYZ"
48
+ end
49
+ end
50
+
51
+ describe "classify" do
52
+ it "should return TestModels from Airtable::Records" do
53
+ array = [Airtable::Record.new]
54
+ results = TestModel.classify(array)
55
+ expect(results.first.class).to eq TestModel
56
+ end
57
+ end
58
+
59
+ describe "all" do
60
+ it "should return a list of airtable records" do
61
+ records = TestModel.all
62
+ expect(records.first.id).to eq "recXYZ"
63
+ end
64
+ end
65
+
66
+ describe "where" do
67
+ it "should return a list of airtable records that match filters" do
68
+ records = TestModel.where(color: "red")
69
+ expect(records.first.class).to eq TestModel
70
+ expect(records.first.color).to eq "red"
71
+ end
72
+ end
73
+
74
+ describe "find_by" do
75
+ it "should return one record that matches the supplied filters", skip_before: true do
76
+ stub_airtable_response!(
77
+ Regexp.new("https://api.airtable.com/v0/appXYZ/example_table"),
78
+ { "records" => [{"id":"recABC", fields: {"color": "blue"} }] }
79
+ )
80
+ record = TestModel.find_by(color: "blue")
81
+ expect(record.color).to eq "blue"
82
+ end
83
+ it "should call airtable-ruby's 'find' method when the filter is an id" do
84
+ stub_airtable_response! "https://api.airtable.com/v0/appXYZ/example_table/recABC", { "id":"recABC", fields: {"name": "example record"} }
85
+ record = TestModel.find_by(id: "recABC")
86
+ expect(record.name).to eq "example record"
87
+ end
88
+ end
89
+
90
+ describe "first" do
91
+ it "should return the first record" do
92
+ record = TestModel.first
93
+ expect(record.class).to eq TestModel
94
+ end
95
+ end
96
+
97
+ describe "create" do
98
+ it "should create a new record" do
99
+ record = TestModel.create(color: "red")
100
+ expect(record.id).to eq "12345"
101
+ end
102
+ end
103
+
104
+ describe "patch" do
105
+ it "should update a record" do
106
+ stub_airtable_response!(Regexp.new("/v0/appXYZ/example_table/12345"),
107
+ { "fields" => { "color" => "blue", "foo" => "bar" }, "id" => "12345" },
108
+ :patch
109
+ )
110
+ record = TestModel.create(color: "red")
111
+ record = TestModel.patch("12345", { color: "blue" })
112
+ expect(record.color).to eq "blue"
113
+ end
114
+ end
115
+
116
+ end
117
+
118
+ describe "Instance Methods" do
119
+
120
+ describe "save" do
121
+ record = TestModel.new(color: "red")
122
+ it "should create a new record" do
123
+ record.save
124
+ expect(record.id).to eq "12345"
125
+ end
126
+ it "should update an existing record" do
127
+ stub_airtable_response!("https://api.airtable.com/v0/appXYZ/example_table/12345",
128
+ { "fields" => { "color" => "red", "foo" => "bar" }, "id" => "12345" },
129
+ :get
130
+ )
131
+ stub_airtable_response!("https://api.airtable.com/v0/appXYZ/example_table/12345",
132
+ { "fields" => { "color" => "blue" }, "id" => "12345" },
133
+ :patch
134
+ )
135
+ record[:color] = "blue"
136
+ record.save
137
+ expect(record.color).to eq "blue"
138
+ end
139
+ end
140
+
141
+ describe "destroy" do
142
+ it "should delete a record" do
143
+ stub_airtable_response!("https://api.airtable.com/v0/appXYZ/example_table/12345",
144
+ { "deleted": true, "id" => "12345" },
145
+ :delete
146
+ )
147
+ response = TestModel.new(id: "12345").destroy
148
+ expect(response["deleted"]).to eq true
149
+ end
150
+ end
151
+
152
+ describe "update" do
153
+ it "should update the supplied attrs on an existing record" do
154
+ stub_airtable_response!("https://api.airtable.com/v0/appXYZ/example_table/12345",
155
+ { "fields" => { "color" => "green"}, "id" => "12345" },
156
+ :patch
157
+ )
158
+ record = TestModel.create(color: "red", id:"12345")
159
+ record.update(color: "green")
160
+ expect(record.color).to eq "green"
161
+ end
162
+ end
163
+
164
+ describe "cache_key" do
165
+ it "should return a unique key that can be used to id this record in memcached" do
166
+ record = TestModel.new(id: "recZXY")
167
+ expect(record.cache_key).to eq "test_models_recZXY"
168
+ end
169
+ end
170
+
171
+ describe "changed_fields" do
172
+ it "should return a hash of attrs changed since last save" do
173
+ stub_airtable_response!("https://api.airtable.com/v0/appXYZ/example_table/12345",
174
+ { fields: { 'color': 'red' }, "id" => "12345" },
175
+ :get
176
+ )
177
+ record = TestModel.create(color: 'red')
178
+ record[:color] = 'green'
179
+ expect(record.changed_fields).to have_key 'color'
180
+ end
181
+ end
182
+
183
+ describe "new_record?" do
184
+ it "should return true if the record hasn't been saved to airtable yet" do
185
+ record = TestModel.new(color: 'red')
186
+ expect(record.new_record?).to eq true
187
+ end
188
+ end
189
+
190
+ describe "formatted_fields" do
191
+ attrs = {
192
+ "empty_array": [''],
193
+ "empty_array_bis": [""],
194
+ "blank_string": "",
195
+ "truthy_string": "true",
196
+ "falsy_string": "false"
197
+ }
198
+ record = TestModel.new(attrs)
199
+ formatted_attrs = record.formatted_fields
200
+ it "should convert empty arrays [''] to []" do
201
+ expect(formatted_attrs["empty_array"]).to eq []
202
+ expect(formatted_attrs["empty_array_bis"]).to eq []
203
+ end
204
+ it "should convert blank strings to nil" do
205
+ expect(formatted_attrs["blank_string"]).to eq nil
206
+ end
207
+ it "should convert 'true' to a boolean" do
208
+ expect(formatted_attrs["truthy_string"]).to eq true
209
+ end
210
+ it "should convert 'false' to a boolean" do
211
+ expect(formatted_attrs["falsy_string"]).to eq false
212
+ end
213
+ end
214
+
215
+ end
216
+ end
217
+
218
+ class ShardedTestModel < Airmodel::Model
219
+ end
220
+
221
+ describe ShardedTestModel do
222
+ let(:config) { Airmodel.bases[:test_models_sharded] }
223
+
224
+ describe "class_methods" do
225
+
226
+ describe "tables" do
227
+ it "should return Airtable::Table objects for each base in the config file" do
228
+ tables = ShardedTestModel.tables
229
+ tables.each do |t|
230
+ expect(t.class).to eq Airtable::Table
231
+ expect(t.app_token.class).to eq String
232
+ end
233
+ end
234
+ it "should return just the one table matching args[:shard]" do
235
+ tables = ShardedTestModel.tables(shard: "east_coast")
236
+ end
237
+ end
238
+
239
+ describe "normalized_base_config" do
240
+ it "should return a hash from a string" do
241
+ sample_config = "appXYZ"
242
+ target = { "appXYZ" => "appXYZ" }
243
+ expect(TestModel.normalized_base_config(sample_config)).to eq target
244
+ end
245
+ it "should return a hash from an array of strings" do
246
+ sample_config = %w(appXYZ appABC)
247
+ target = { "appXYZ" => "appXYZ", "appABC" => "appABC" }
248
+ expect(TestModel.normalized_base_config(sample_config)).to eq target
249
+ end
250
+ it "should return a hash from an array of hashes" do
251
+ sample_config = [{"nyc": "appXYZ"}, {"sf": "appABC"}]
252
+ target = { "nyc": "appXYZ", "sf": "appABC" }
253
+ expect(TestModel.normalized_base_config(sample_config)).to eq target
254
+ end
255
+ it "should return itself from a hash" do
256
+ sample_config = {"nyc": "appXYZ", "sf": "appABC" }
257
+ target = { "nyc": "appXYZ", "sf": "appABC" }
258
+ expect(TestModel.normalized_base_config(sample_config)).to eq target
259
+ end
260
+ end
261
+
262
+ end
263
+ end
264
+
265
+ class BaselessTestModel < Airmodel::Model
266
+ end
267
+
268
+ describe BaselessTestModel do
269
+ describe "it should raise a NoSuchBaseError when no base is defined" do
270
+ begin
271
+ records = BaselessTestModel.records
272
+ false
273
+ rescue Airmodel::NoSuchBase
274
+ true
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,37 @@
1
+ require 'airmodel'
2
+ require 'fakeweb'
3
+ require 'pry'
4
+
5
+ def stub_airtable_response!(url, response, method=:get)
6
+ FakeWeb.register_uri(
7
+ method,
8
+ url,
9
+ body: response.to_json,
10
+ content_type: "application/json"
11
+ )
12
+ end
13
+
14
+ RSpec.configure do |config|
15
+ config.color = true
16
+ end
17
+
18
+ FakeWeb.allow_net_connect = false
19
+
20
+ # enable Debug mode in Airtable
21
+ module Airtable
22
+ # Base class for authorized resources sending network requests
23
+ class Resource
24
+ include HTTParty
25
+ base_uri 'https://api.airtable.com/v0/'
26
+ debug_output $stdout
27
+
28
+ attr_reader :api_key, :app_token, :worksheet_name
29
+
30
+ def initialize(api_key, app_token, worksheet_name)
31
+ @api_key = api_key
32
+ @app_token = app_token
33
+ @worksheet_name = worksheet_name
34
+ self.class.headers({'Authorization' => "Bearer #{@api_key}"})
35
+ end
36
+ end # AuthorizedResource
37
+ end # Airtable
metadata ADDED
@@ -0,0 +1,155 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: airmodel
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1pre
5
+ platform: ruby
6
+ authors:
7
+ - chrisfrankdotfm
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-10-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3'
55
+ - !ruby/object:Gem::Dependency
56
+ name: pry
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.10'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.10'
69
+ - !ruby/object:Gem::Dependency
70
+ name: fakeweb
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.3'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.3'
83
+ - !ruby/object:Gem::Dependency
84
+ name: airtable
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: 0.0.8
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: 0.0.8
97
+ - !ruby/object:Gem::Dependency
98
+ name: activesupport
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '5.0'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '5.0'
111
+ description: Airtable data in ActiveRecord-style syntax
112
+ email:
113
+ - chris.frank@thefutureproject.org
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - ".gitignore"
119
+ - Gemfile
120
+ - README.md
121
+ - Rakefile
122
+ - airmodel.gemspec
123
+ - config/bases.yml
124
+ - lib/airmodel.rb
125
+ - lib/airmodel/model.rb
126
+ - lib/airmodel/utils.rb
127
+ - lib/airmodel/version.rb
128
+ - spec/airmodel_spec.rb
129
+ - spec/models_spec.rb
130
+ - spec/spec_helper.rb
131
+ homepage: https://github.com/chrisfrank/airmodel
132
+ licenses:
133
+ - MIT
134
+ metadata: {}
135
+ post_install_message:
136
+ rdoc_options: []
137
+ require_paths:
138
+ - lib
139
+ required_ruby_version: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - ">="
142
+ - !ruby/object:Gem::Version
143
+ version: '0'
144
+ required_rubygems_version: !ruby/object:Gem::Requirement
145
+ requirements:
146
+ - - ">"
147
+ - !ruby/object:Gem::Version
148
+ version: 1.3.1
149
+ requirements: []
150
+ rubyforge_project:
151
+ rubygems_version: 2.6.4
152
+ signing_key:
153
+ specification_version: 4
154
+ summary: Interact with your Airtable data using ActiveRecord-style models
155
+ test_files: []