airrecord 0.1.0

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: 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: []