airrecord 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 904b965e6a422a31eefc32699470d534ba089cb9
4
+ data.tar.gz: 49764c8c357d0033cbf26e7763cff0a3550482c2
5
+ SHA512:
6
+ metadata.gz: d1fa9bacd29b577fcfd4ed3c23b2771d5d89ac08b7419b66a88a962761c4dee44e90322a36b7b335a1f003b4f32de22df4511d616aac45587664081b87c9bfa6
7
+ data.tar.gz: 24b7b7af6920262ce662e18f6a94ed57c274a04c0cac0159e1417fcae051f8ccbd18da7c0eaadbd02cbc036869ce54633c46c0e4bd4926297baab6a29cae45dd
data/.gitignore ADDED
@@ -0,0 +1,12 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ vendor/
11
+ test/fixtures
12
+ .byebug_history
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.2.3
5
+ before_install: gem install bundler -v 1.12.5
data/:w ADDED
@@ -0,0 +1,341 @@
1
+ require 'securerandom'
2
+ require 'test_helper'
3
+
4
+ class AirtableTest < Minitest::Test
5
+ def setup
6
+ @table = Airtable.table("key6Zk9xjvoRQG1FO", "appxIxY6d7lz4yHZG", "table1")
7
+
8
+ @stubs = Faraday::Adapter::Test::Stubs.new
9
+ @table.client.connection = Faraday.new { |builder|
10
+ builder.adapter :test, @stubs
11
+ }
12
+
13
+ stub_request([{"Name": "omg", "Notes": "hello world", " Something else\n" => "hi"}, {"Name": "more", "Notes": "walrus"}])
14
+ end
15
+
16
+ def test_retrieve_records
17
+ assert_instance_of Array, @table.records
18
+ end
19
+
20
+ def test_filter_records
21
+ stub_request([{"Name": "yes"}, {"Name": "no"}])
22
+
23
+ records = @table.records(filter: "Name")
24
+ assert_equal "yes", records[0][:name]
25
+ end
26
+
27
+ def test_sort_records
28
+ stub_request([{"Name": "a"}, {"Name": "b"}])
29
+
30
+ records = @table.records(sort: { Name: 'asc' })
31
+ assert_equal "a", records[0][:name]
32
+ assert_equal "b", records[1][:name]
33
+ end
34
+
35
+ def test_view_records
36
+ stub_request([{"Name": "a"}, {"Name": "a"}])
37
+
38
+ records = @table.records(view: 'A')
39
+ assert_equal "a", records[0][:name]
40
+ assert_equal "a", records[1][:name]
41
+ end
42
+
43
+ def test_follow_pagination_by_default
44
+ stub_request([{"Name": "1"}, {"Name": "2"}], offset: 'dasfuhiu')
45
+ stub_request([{"Name": "3"}, {"Name": "4"}], offset: 'odjafio', clear: false)
46
+ stub_request([{"Name": "5"}, {"Name": "6"}], clear: false)
47
+
48
+ records = @table.records
49
+ assert_equal 6, records.size
50
+ end
51
+
52
+ def test_dont_follow_pagination_if_disabled
53
+ stub_request([{"Name": "1"}, {"Name": "2"}], offset: 'dasfuhiu')
54
+ stub_request([{"Name": "3"}, {"Name": "4"}], offset: 'odjafio', clear: false)
55
+ stub_request([{"Name": "5"}, {"Name": "6"}], clear: false)
56
+
57
+ records = @table.records(paginate: false)
58
+ assert_equal 2, records.size
59
+ end
60
+
61
+ def test_index_by_normalized_name
62
+ assert_equal "omg", first_record[:name]
63
+ end
64
+
65
+ def test_index_by_column_name
66
+ assert_equal "omg", first_record["Name"]
67
+ end
68
+
69
+ def test_cleans_bad_keys
70
+ assert_equal "hi", first_record[:something_else]
71
+ end
72
+
73
+ def test_id
74
+ assert_instance_of String, first_record.id
75
+ end
76
+
77
+ def test_created_at
78
+ assert_instance_of Time, first_record.created_at
79
+ end
80
+
81
+ def test_error_response
82
+ stub_error_request(type: "TABLE_NOT_FOUND", message: "Could not find table", table: "unknown")
83
+
84
+ assert_raises Airtable::Error do
85
+ Airtable.table("key6Zk9xjvoRQG1FO", "appxIxY6d7lz4yHZG", "unknown").records
86
+ end
87
+ end
88
+
89
+ def test_change_value
90
+ record = first_record
91
+ record[:name] = "testest"
92
+ assert_equal "testest", record[:name]
93
+ end
94
+
95
+ def test_change_value_on_column_name
96
+ record = first_record
97
+ record["Name"] = "testest"
98
+ assert_equal "testest", record[:name]
99
+ end
100
+
101
+ def test_change_value_and_update
102
+ record = first_record
103
+
104
+ record[:name] = "new_name"
105
+ stub_patch_request(record, ["Name"])
106
+
107
+ assert record.save
108
+ end
109
+
110
+ def test_change_value_then_save_again_should_noop
111
+ record = first_record
112
+
113
+ record[:name] = "new_name"
114
+ stub_patch_request(record, ["Name"])
115
+
116
+ assert record.save
117
+ assert record.save
118
+ end
119
+
120
+ def test_updates_fields_to_newest_values_after_update
121
+ record = first_record
122
+
123
+ record[:name] = "new_name"
124
+ stub_patch_request(record, ["Name"], return_body: record.fields.merge("Notes" => "new animal"))
125
+
126
+ assert record.save
127
+ assert_equal "new_name", record[:name]
128
+ assert_equal "new animal", record[:notes]
129
+ end
130
+
131
+ def test_update_failure
132
+ record = first_record
133
+
134
+ record[:name] = "new_name"
135
+ stub_patch_request(record, ["Name"], return_body: { error: { type: "oh noes", message: 'yes' } }, status: 401)
136
+
137
+ assert_raises Airtable::Error do
138
+ record.save
139
+ end
140
+ end
141
+
142
+ def test_update_failure_then_succeed
143
+ record = first_record
144
+
145
+ record[:name] = "new_name"
146
+ stub_patch_request(record, ["Name"], return_body: { error: { type: "oh noes", message: 'yes' } }, status: 401)
147
+
148
+ assert_raises Airtable::Error do
149
+ record.save
150
+ end
151
+
152
+ stub_patch_request(record, ["Name"])
153
+ assert record.save
154
+ end
155
+
156
+ def test_update_raises_if_new_record
157
+ record = @table.build_record(Name: "omg")
158
+
159
+ assert_raises Airtable::Error do
160
+ record.save
161
+ end
162
+ end
163
+
164
+ def test_existing_record_is_not_new
165
+ refute first_record.new_record?
166
+ end
167
+
168
+ def test_build_new_record
169
+ record = @table.build_record(Name: "omg")
170
+
171
+ refute record.id
172
+ refute record.created_at
173
+ assert record.new_record?
174
+ end
175
+
176
+ def test_create_new_record
177
+ record = @table.build_record(Name: "omg")
178
+
179
+ stub_post_request(record)
180
+
181
+ assert record.create
182
+ end
183
+
184
+ def test_create_existing_record_fails
185
+ record = @table.build_record(Name: "omg")
186
+
187
+ stub_post_request(record)
188
+
189
+ assert record.create
190
+
191
+ assert_raises Airtable::Error do
192
+ record.create
193
+ end
194
+ end
195
+
196
+ def test_create_handles_error
197
+ record = @table.build_record(Name: "omg")
198
+
199
+ stub_post_request(record, status: 401, return_body: { error: { type: "omg", message: "wow" }})
200
+
201
+ assert_raises Airtable::Error do
202
+ record.create
203
+ end
204
+ end
205
+
206
+ def test_find
207
+ record = @table.build_record(Name: "walrus")
208
+
209
+ stub_find_request(record, id: "iodfajsofja")
210
+
211
+ record = @table.find("iodfajsofja")
212
+ assert_equal "walrus", record[:name]
213
+ end
214
+
215
+ def test_find_handles_error
216
+ stub_find_request(nil, return_body: { error: { type: "not found", message: "not found" } }, id: "noep", status: 404)
217
+
218
+ assert_raises Airtable::Error do
219
+ @table.find("noep")
220
+ end
221
+ end
222
+
223
+ def test_destroy_new_record_fails
224
+ record = @table.build_record(Name: "walrus")
225
+
226
+ assert_raises Airtable::Error do
227
+ record.destroy
228
+ end
229
+ end
230
+
231
+ def test_destroy_record
232
+ record = first_record
233
+ stub_delete_request(record.id)
234
+ assert record.destroy
235
+ end
236
+
237
+ def test_fail_destroy_record
238
+ record = first_record
239
+ stub_delete_request(record.id, status: 404, response_body: { error: { type: "not found", message: "whatever" } }.to_json)
240
+
241
+ assert_raises Airtable::Error do
242
+ record.destroy
243
+ end
244
+ end
245
+
246
+ def test_error_handles_errors_without_body
247
+ record = first_record
248
+
249
+ stub_delete_request(record.id, status: 500)
250
+
251
+ assert_raises Airtable::Error do
252
+ record.destroy
253
+ end
254
+ end
255
+
256
+ private
257
+
258
+ def first_record
259
+ @table.records.first
260
+ end
261
+
262
+ def stub_delete_request(id, table: @table, status: 202, response_body: "")
263
+ @stubs.delete("/v0/#{@table.base_key}/#{@table.table_name}/#{id}") do |env|
264
+ [status, {}, response_body]
265
+ end
266
+ end
267
+
268
+ def stub_post_request(record, table: @table, status: 200, headers: {}, return_body: nil)
269
+ return_body ||= {
270
+ id: SecureRandom.hex(16),
271
+ fields: record.fields,
272
+ createdTime: Time.now,
273
+ }
274
+ return_body = return_body.to_json
275
+
276
+ request_body = { fields: record.fields }.to_json
277
+
278
+ @stubs.post("/v0/#{@table.base_key}/#{@table.table_name}", request_body) do |env|
279
+ [status, headers, return_body]
280
+ end
281
+ end
282
+
283
+ def stub_patch_request(record, updated_keys, table: @table1, status: 200, headers: {}, return_body: nil)
284
+ return_body ||= record.fields
285
+ return_body = return_body.to_json
286
+
287
+ request_body = {
288
+ fields: Hash[updated_keys.map { |key|
289
+ [key, record.fields[key]]
290
+ }]
291
+ }.to_json
292
+
293
+ @stubs.patch("/v0/#{@table.base_key}/#{@table.table_name}/#{record.id}", request_body) do |env|
294
+ [status, headers, return_body]
295
+ end
296
+ end
297
+
298
+ # TODO: Problem, can't stub on params.
299
+ def stub_request(records, table: @table, status: 200, headers: {}, offset: nil, clear: true)
300
+ @stubs.instance_variable_set(:@stack, {}) if clear
301
+
302
+ body = {
303
+ records: records.map { |record|
304
+ {
305
+ id: SecureRandom.hex(16),
306
+ fields: record,
307
+ createdTime: Time.now,
308
+ }
309
+ },
310
+ offset: offset,
311
+ }.to_json
312
+
313
+ @stubs.get("/v0/#{@table.base_key}/#{@table.table_name}") do |env|
314
+ [status, headers, body]
315
+ end
316
+ end
317
+
318
+ def stub_find_request(record = nil, table: @table, status: 200, headers: {}, return_body: nil, id: nil)
319
+ return_body ||= record.fields
320
+ return_body = return_body.to_json
321
+
322
+ id ||= record.id
323
+
324
+ @stubs.get("/v0/#{@table.base_key}/#{@table.table_name}/#{id}") do |env|
325
+ [status, headers, return_body]
326
+ end
327
+ end
328
+
329
+ def stub_error_request(type:, message:, status: 401, headers: {}, table: @table)
330
+ body = {
331
+ error: {
332
+ type: type,
333
+ message: message,
334
+ }
335
+ }.to_json
336
+
337
+ @stubs.get("/v0/#{@table.base_key}/#{@table.table_name}") do |env|
338
+ [status, headers, body]
339
+ end
340
+ end
341
+ end
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in airtable.gemspec
4
+ gemspec
data/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # Airrecord
2
+
3
+ Airrecord is an alternative to
4
+ [`airtable-ruby`](https://github.com/airtable/airtable-ruby). Airrecord takes an
5
+ approach to approaching Airtable more like a database from Ruby's point of view,
6
+ inviting inspiration from ActiveRecord's API.
7
+
8
+ ```ruby
9
+ gem 'airrecord'
10
+ ```
11
+
12
+ ## Examples
13
+
14
+ There's a simple API that allows more ad-hoc querying of Airtable:
15
+
16
+ ```ruby
17
+ teas = Airrecord.table("key1", "app1", "Teas")
18
+
19
+ teas.records.each do |record|
20
+ puts "#{record.id}: #{record[:name]}"
21
+ end
22
+
23
+ p teas.find(teas.records.first.id)
24
+ ```
25
+
26
+ Then there's the API which allows you to define tables as classes and
27
+ relationships between them. This maps with ActiveRecord models. This makes
28
+ working with relationships much easier, and allows you to define domain-specific
29
+ logic on the models.
30
+
31
+ ```ruby
32
+ class Brew < Airrecord::Table
33
+ self.api_key = "key1"
34
+ self.base_key = "app1"
35
+ self.table_name = "Hot Brews"
36
+
37
+ belongs_to :tea, class: 'Tea', column: 'Tea'
38
+ end
39
+
40
+ class Tea < Airrecord::Table
41
+ self.api_key = "key1"
42
+ self.base_key = "app1"
43
+ self.table_name = "Teas"
44
+
45
+ has_many :hot_brews, class: 'Brew', column: "Hot Brews"
46
+
47
+ def location
48
+ [self[:village], self[:country], self[:region]].compact.join(", ")
49
+ end
50
+ end
51
+
52
+ tea = Tea.all[2]
53
+ brew = tea[:hot_brews].first
54
+ brew[:tea].id == tea.id
55
+
56
+ tea = Tea.new(Name: "omg tea", Type: ["Oolong"])
57
+ tea.create
58
+
59
+ tea[:name] = "super omg tea"
60
+ tea.save
61
+
62
+ tea = Tea.find(tea.id)
63
+ puts tea[:name]
64
+ # => "super omg tea"
65
+
66
+ brew = Brew.new(Tea: [tea], Rating: "3 - Fine")
67
+ brew.create
68
+
69
+ tea = Tea.find(tea.id)
70
+ puts tea[:hot_brews].first[:rating]
71
+ # => "3 - Fine"
72
+ ```
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList['test/**/*_test.rb']
8
+ end
9
+
10
+ task :default => :test
data/airrecord.gemspec ADDED
@@ -0,0 +1,28 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'airrecord/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "airrecord"
8
+ spec.version = Airrecord::VERSION
9
+ spec.authors = ["Simon Eskildsen"]
10
+ spec.email = ["sirup@sirupsen.com"]
11
+
12
+ spec.summary = %q{Airtable client}
13
+ spec.description = %q{Airtable client to make Airtable interactions a breeze}
14
+ spec.homepage = "https://github.com/sirupsen/airtable"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
17
+ spec.bindir = "exe"
18
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency 'faraday'
22
+ spec.add_dependency "net-http-persistent"
23
+
24
+ spec.add_development_dependency "bundler", "~> 1.12"
25
+ spec.add_development_dependency "rake", "~> 10.0"
26
+ spec.add_development_dependency "minitest", "~> 5.0"
27
+ spec.add_development_dependency "byebug"
28
+ end
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "airtable"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/lib/airrecord.rb ADDED
@@ -0,0 +1,294 @@
1
+ require "airrecord/version"
2
+ require "json"
3
+ require "faraday"
4
+
5
+ module Airrecord
6
+ Error = Class.new(StandardError)
7
+
8
+ # TODO: This would be much simplified if we had a schema instead. Hopefully
9
+ # one day Airtable will add this, but to simplify and crush the majority of
10
+ # the bugs that hide in here (which would be related to the dynamic schema) we
11
+ # may just query the first page and infer a schema from there that can be
12
+ # overridden on the specific classes.
13
+ #
14
+ # Right now I bet there's a bunch of bugs around similar named column keys (in
15
+ # terms of capitalization), it's inconsistent and non-obvious that `create`
16
+ # doesn't use the same column keys as everything else.
17
+ class Table
18
+ class << self
19
+ attr_accessor :base_key, :table_name, :api_key, :associations
20
+
21
+ def client
22
+ @@client ||= Client.new(api_key)
23
+ end
24
+
25
+ def has_many(name, options)
26
+ @associations ||= []
27
+ @associations << {
28
+ field: name.to_sym,
29
+ }.merge(options)
30
+ end
31
+
32
+ def belongs_to(name, options)
33
+ has_many(name, options.merge(single: true))
34
+ end
35
+
36
+ def find(id)
37
+ response = client.connection.get("/v0/#{base_key}/#{client.escape(table_name)}/#{id}")
38
+ parsed_response = client.parse(response.body)
39
+
40
+ if response.success?
41
+ self.new(parsed_response["fields"], id: id)
42
+ else
43
+ client.handle_error(response.status, parsed_response)
44
+ end
45
+ end
46
+
47
+ def schema
48
+ # handle associations as specific field type
49
+ # TODO: what if there's an overlap in alias and keys??
50
+ schema = {}
51
+
52
+ records(paginate: false).each do |record|
53
+ record.fields.keys.each do |key|
54
+ unless schema.find { |column| column[:key] == key }
55
+ schema[key] = {
56
+ key: key,
57
+ type: :field,
58
+ alias: underscore(key),
59
+ }
60
+ end
61
+ end
62
+ end
63
+
64
+ if @associations
65
+ @associations.each do |assoc|
66
+ schema[assoc[:field]][:type] = :association
67
+ end
68
+ end
69
+
70
+ schema
71
+ end
72
+
73
+ def records(filter: nil, sort: nil, view: nil, offset: nil, paginate: true, fields: nil)
74
+ options = {}
75
+ options[:filterByFormula] = filter if filter
76
+
77
+ if sort
78
+ options[:sort] = sort.map { |field, direction|
79
+ { field: field.to_s, direction: direction }
80
+ }
81
+ end
82
+
83
+ options[:view] = view if view
84
+ options[:offset] = offset if offset
85
+ options[:fields] = fields if fields
86
+
87
+ path = "/v0/#{base_key}/#{client.escape(table_name)}"
88
+ response = client.connection.get(path, options)
89
+ parsed_response = client.parse(response.body)
90
+
91
+ if response.success?
92
+ records = parsed_response["records"]
93
+ records = records.map { |record|
94
+ self.new(record["fields"], id: record["id"], created_at: record["createdTime"])
95
+ }
96
+
97
+ if paginate && parsed_response["offset"]
98
+ records.concat(records(**options.merge(offset: parsed_response["offset"])))
99
+ end
100
+
101
+ records
102
+ else
103
+ client.handle_error(response.status, parsed_response)
104
+ end
105
+ end
106
+ alias_method :all, :records
107
+ end
108
+
109
+ attr_reader :fields, :column_mappings, :id, :created_at, :updated_fields
110
+
111
+ def initialize(fields, id: nil, created_at: nil)
112
+ @id = id
113
+ self.created_at = created_at
114
+ self.fields = fields
115
+ end
116
+
117
+ def new_record?
118
+ !id
119
+ end
120
+
121
+ def [](key)
122
+ value = nil
123
+
124
+ if fields[key]
125
+ value = fields[key]
126
+ elsif column_mappings[key]
127
+ value = fields[column_mappings[key]]
128
+ end
129
+
130
+ if association = self.association(key)
131
+ klass = Kernel.const_get(association[:class])
132
+ associations = value.map { |id_or_obj|
133
+ id_or_obj = id_or_obj.respond_to?(:id) ? id_or_obj.id : id_or_obj
134
+ klass.find(id_or_obj)
135
+ }
136
+ return associations.first if association[:single]
137
+ associations
138
+ else
139
+ value
140
+ end
141
+ end
142
+
143
+ def []=(key, value)
144
+ if fields[key]
145
+ @updated_keys << key
146
+ fields[key] = value
147
+ elsif column_mappings[key]
148
+ @updated_keys << column_mappings[key]
149
+ fields[column_mappings[key]] = value
150
+ else
151
+ @updated_keys << key
152
+ fields[key] = value
153
+ end
154
+ end
155
+
156
+ def create
157
+ raise Error, "Record already exists" unless new_record?
158
+
159
+ body = { fields: serializable_fields }.to_json
160
+ response = client.connection.post("/v0/#{self.class.base_key}/#{client.escape(self.class.table_name)}", body, { 'Content-Type': 'application/json' })
161
+ parsed_response = client.parse(response.body)
162
+
163
+ if response.success?
164
+ @id = parsed_response["id"]
165
+ self.created_at = parsed_response["createdTime"]
166
+ self.fields = parsed_response["fields"]
167
+ else
168
+ client.handle_error(response.status, parsed_response)
169
+ end
170
+ end
171
+
172
+ def save
173
+ raise Error, "Unable to save a new record" if new_record?
174
+
175
+ return true if @updated_keys.empty?
176
+
177
+ # To avoid trying to update computed fields we *always* use PATCH
178
+ body = {
179
+ fields: Hash[@updated_keys.map { |key|
180
+ [key, fields[key]]
181
+ }]
182
+ }.to_json
183
+
184
+ response = client.connection.patch("/v0/#{self.class.base_key}/#{client.escape(self.class.table_name)}/#{self.id}", body, { 'Content-Type': 'application/json' })
185
+ parsed_response = client.parse(response.body)
186
+
187
+ if response.success?
188
+ self.fields = parsed_response
189
+ else
190
+ client.handle_error(response.status, parsed_response)
191
+ end
192
+ end
193
+
194
+ def destroy
195
+ raise Error, "Unable to destroy new record" if new_record?
196
+
197
+ response = client.connection.delete("/v0/#{self.class.base_key}/#{client.escape(self.class.table_name)}/#{self.id}")
198
+ parsed_response = client.parse(response.body)
199
+
200
+ if response.success?
201
+ true
202
+ else
203
+ client.handle_error(response.status, parsed_response)
204
+ end
205
+ end
206
+
207
+ def serializable_fields(fields = self.fields)
208
+ Hash[fields.map { |(key, value)|
209
+ if association(key)
210
+ [key, value.map(&:id)]
211
+ else
212
+ [key, value]
213
+ end
214
+ }]
215
+ end
216
+
217
+ protected
218
+
219
+ def association(key)
220
+ if self.class.associations
221
+ self.class.associations.find { |association|
222
+ association[:column].to_s == column_mappings[key].to_s || association[:column].to_s == key.to_s
223
+ }
224
+ end
225
+ end
226
+
227
+ def fields=(fields)
228
+ @updated_keys = []
229
+ @column_mappings = Hash[fields.keys.map { |key| [underscore(key), key] }]
230
+ @fields = fields
231
+ end
232
+
233
+ def self.underscore(key)
234
+ key.to_s.strip.gsub(/\W+/, "_").downcase.to_sym
235
+ end
236
+
237
+ def underscore(key)
238
+ self.class.underscore(key)
239
+ end
240
+
241
+ def created_at=(created_at)
242
+ return unless created_at
243
+ @created_at = Time.parse(created_at)
244
+ end
245
+
246
+ def client
247
+ self.class.client
248
+ end
249
+ end
250
+
251
+ def self.table(api_key, base_key, table_name)
252
+ Class.new(Table) do |klass|
253
+ klass.table_name = table_name
254
+ klass.api_key = api_key
255
+ klass.base_key = base_key
256
+ end
257
+ end
258
+
259
+ class Client
260
+ attr_reader :api_key
261
+ attr_writer :connection
262
+
263
+ def initialize(api_key)
264
+ @api_key = api_key
265
+ end
266
+
267
+ def connection
268
+ @connection ||= Faraday.new(url: "https://api.airtable.com", headers: {
269
+ "Authorization" => "Bearer #{api_key}",
270
+ "X-API-VERSION" => "0.1.0",
271
+ }) { |conn|
272
+ conn.adapter :net_http_persistent
273
+ }
274
+ end
275
+
276
+ def escape(*args)
277
+ URI.escape(*args)
278
+ end
279
+
280
+ def parse(body)
281
+ JSON.parse(body)
282
+ rescue JSON::ParserError
283
+ nil
284
+ end
285
+
286
+ def handle_error(status, error)
287
+ if error.is_a?(Hash)
288
+ raise Error, "HTTP #{status}: #{error['error']["type"]}: #{error['error']['message']}"
289
+ else
290
+ raise Error, "HTTP #{status}: Communication error: #{error}"
291
+ end
292
+ end
293
+ end
294
+ end
@@ -0,0 +1,3 @@
1
+ module Airrecord
2
+ VERSION = "0.1.0"
3
+ end
metadata ADDED
@@ -0,0 +1,138 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: airrecord
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Simon Eskildsen
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2016-10-09 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: net-http-persistent
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: bundler
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.12'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.12'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '10.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '10.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: minitest
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '5.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '5.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: byebug
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description: Airtable client to make Airtable interactions a breeze
98
+ email:
99
+ - sirup@sirupsen.com
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - ".gitignore"
105
+ - ".travis.yml"
106
+ - ":w"
107
+ - Gemfile
108
+ - README.md
109
+ - Rakefile
110
+ - airrecord.gemspec
111
+ - bin/console
112
+ - bin/setup
113
+ - lib/airrecord.rb
114
+ - lib/airrecord/version.rb
115
+ homepage: https://github.com/sirupsen/airtable
116
+ licenses: []
117
+ metadata: {}
118
+ post_install_message:
119
+ rdoc_options: []
120
+ require_paths:
121
+ - lib
122
+ required_ruby_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ required_rubygems_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ requirements: []
133
+ rubyforge_project:
134
+ rubygems_version: 2.4.5.1
135
+ signing_key:
136
+ specification_version: 4
137
+ summary: Airtable client
138
+ test_files: []