airrecord 0.1.2 → 0.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 6488c3737cb574a3e1e5e1cbc1d5ae665482c364
4
- data.tar.gz: 8ebba576595589c7d8e9c6558d9e2032d6953507
3
+ metadata.gz: 34c528021b09a5445d86bdf54f171f09874a1fe7
4
+ data.tar.gz: e854c5670ed5e96c1bd66effa1538f5c522becb3
5
5
  SHA512:
6
- metadata.gz: 16a9ffc9fc7ec8a69061e7b26041410c3fcdba2bd2bb1b21f3034cc9f3f60a88dd0b8370118ed167595ba3e4a7aa51780987e74d6176d737736989c0c966fbb4
7
- data.tar.gz: 998c84a7691409db02f09bb65479e7ebfe1e9a33b820dec3da333083751f4ca0031e892708e438dc63097bf4b39847c5b4143ccb631d3ed70ec76742996319a4
6
+ metadata.gz: 63b9fc0ad061aa526a5b117d8db1477e9260730e74f4656728f23c857b629ed4d5aefcde96a8f516c25a079476338087fe89711f04b93df9201607893ba6058f
7
+ data.tar.gz: 1cde11c40f0d44d55e5f2519b5505228595f67fd3343a0cdc620feb2d11ae4edeba29e4cc8ce04e9a31d3ee778e87989ffc51d09cca444a469848a04379e4881
@@ -18,8 +18,8 @@ Gem::Specification.new do |spec|
18
18
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
19
19
  spec.require_paths = ["lib"]
20
20
 
21
- spec.add_dependency 'faraday', '~> 0.10.0'
22
- spec.add_dependency "net-http-persistent", '~> 2.9.4'
21
+ spec.add_dependency 'faraday', '~> 0.10'
22
+ spec.add_dependency "net-http-persistent", '~> 2.9'
23
23
 
24
24
  spec.add_development_dependency "bundler", "~> 1.12"
25
25
  spec.add_development_dependency "rake", "~> 10.0"
@@ -1,309 +1,9 @@
1
- require "airrecord/version"
2
1
  require "json"
3
2
  require "faraday"
3
+ require "airrecord/version"
4
+ require "airrecord/client"
5
+ require "airrecord/table"
4
6
 
5
7
  module Airrecord
6
8
  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(
99
- filter: filter,
100
- sort: sort,
101
- view: view,
102
- paginate: paginate,
103
- fields: fields,
104
- offset: parsed_response["offset"],
105
- ))
106
- end
107
-
108
- records
109
- else
110
- client.handle_error(response.status, parsed_response)
111
- end
112
- end
113
- alias_method :all, :records
114
- end
115
-
116
- attr_reader :fields, :column_mappings, :id, :created_at, :updated_fields
117
-
118
- def initialize(fields, id: nil, created_at: nil)
119
- @id = id
120
- self.created_at = created_at
121
- self.fields = fields
122
- end
123
-
124
- def new_record?
125
- !id
126
- end
127
-
128
- def [](key)
129
- value = nil
130
-
131
- if fields[key]
132
- value = fields[key]
133
- elsif column_mappings[key]
134
- value = fields[column_mappings[key]]
135
- end
136
-
137
- if association = self.association(key)
138
- klass = Kernel.const_get(association[:class])
139
- associations = value.map { |id_or_obj|
140
- id_or_obj = id_or_obj.respond_to?(:id) ? id_or_obj.id : id_or_obj
141
- klass.find(id_or_obj)
142
- }
143
- return associations.first if association[:single]
144
- associations
145
- else
146
- type_cast(value)
147
- end
148
- end
149
-
150
- def []=(key, value)
151
- if fields[key]
152
- @updated_keys << key
153
- fields[key] = value
154
- elsif column_mappings[key]
155
- @updated_keys << column_mappings[key]
156
- fields[column_mappings[key]] = value
157
- else
158
- @updated_keys << key
159
- fields[key] = value
160
- end
161
- end
162
-
163
- def create
164
- raise Error, "Record already exists" unless new_record?
165
-
166
- body = { fields: serializable_fields }.to_json
167
- response = client.connection.post("/v0/#{self.class.base_key}/#{client.escape(self.class.table_name)}", body, { 'Content-Type': 'application/json' })
168
- parsed_response = client.parse(response.body)
169
-
170
- if response.success?
171
- @id = parsed_response["id"]
172
- self.created_at = parsed_response["createdTime"]
173
- self.fields = parsed_response["fields"]
174
- else
175
- client.handle_error(response.status, parsed_response)
176
- end
177
- end
178
-
179
- def save
180
- raise Error, "Unable to save a new record" if new_record?
181
-
182
- return true if @updated_keys.empty?
183
-
184
- # To avoid trying to update computed fields we *always* use PATCH
185
- body = {
186
- fields: Hash[@updated_keys.map { |key|
187
- [key, fields[key]]
188
- }]
189
- }.to_json
190
-
191
- response = client.connection.patch("/v0/#{self.class.base_key}/#{client.escape(self.class.table_name)}/#{self.id}", body, { 'Content-Type': 'application/json' })
192
- parsed_response = client.parse(response.body)
193
-
194
- if response.success?
195
- self.fields = parsed_response
196
- else
197
- client.handle_error(response.status, parsed_response)
198
- end
199
- end
200
-
201
- def destroy
202
- raise Error, "Unable to destroy new record" if new_record?
203
-
204
- response = client.connection.delete("/v0/#{self.class.base_key}/#{client.escape(self.class.table_name)}/#{self.id}")
205
- parsed_response = client.parse(response.body)
206
-
207
- if response.success?
208
- true
209
- else
210
- client.handle_error(response.status, parsed_response)
211
- end
212
- end
213
-
214
- def serializable_fields(fields = self.fields)
215
- Hash[fields.map { |(key, value)|
216
- if association(key)
217
- [key, value.map(&:id)]
218
- else
219
- [key, value]
220
- end
221
- }]
222
- end
223
-
224
- protected
225
-
226
- def association(key)
227
- if self.class.associations
228
- self.class.associations.find { |association|
229
- association[:column].to_s == column_mappings[key].to_s || association[:column].to_s == key.to_s
230
- }
231
- end
232
- end
233
-
234
- def fields=(fields)
235
- @updated_keys = []
236
- @column_mappings = Hash[fields.keys.map { |key| [underscore(key), key] }]
237
- @fields = fields
238
- end
239
-
240
- def self.underscore(key)
241
- key.to_s.strip.gsub(/\W+/, "_").downcase.to_sym
242
- end
243
-
244
- def underscore(key)
245
- self.class.underscore(key)
246
- end
247
-
248
- def created_at=(created_at)
249
- return unless created_at
250
- @created_at = Time.parse(created_at)
251
- end
252
-
253
- def client
254
- self.class.client
255
- end
256
-
257
- def type_cast(value)
258
- if value =~ /\d{4}-\d{2}-\d{2}/
259
- Time.parse(value + " UTC")
260
- else
261
- value
262
- end
263
- end
264
- end
265
-
266
- def self.table(api_key, base_key, table_name)
267
- Class.new(Table) do |klass|
268
- klass.table_name = table_name
269
- klass.api_key = api_key
270
- klass.base_key = base_key
271
- end
272
- end
273
-
274
- class Client
275
- attr_reader :api_key
276
- attr_writer :connection
277
-
278
- def initialize(api_key)
279
- @api_key = api_key
280
- end
281
-
282
- def connection
283
- @connection ||= Faraday.new(url: "https://api.airtable.com", headers: {
284
- "Authorization" => "Bearer #{api_key}",
285
- "X-API-VERSION" => "0.1.0",
286
- }) { |conn|
287
- conn.adapter :net_http_persistent
288
- }
289
- end
290
-
291
- def escape(*args)
292
- URI.escape(*args)
293
- end
294
-
295
- def parse(body)
296
- JSON.parse(body)
297
- rescue JSON::ParserError
298
- nil
299
- end
300
-
301
- def handle_error(status, error)
302
- if error.is_a?(Hash)
303
- raise Error, "HTTP #{status}: #{error['error']["type"]}: #{error['error']['message']}"
304
- else
305
- raise Error, "HTTP #{status}: Communication error: #{error}"
306
- end
307
- end
308
- end
309
9
  end
@@ -0,0 +1,37 @@
1
+ module Airrecord
2
+ class Client
3
+ attr_reader :api_key
4
+ attr_writer :connection
5
+
6
+ def initialize(api_key)
7
+ @api_key = api_key
8
+ end
9
+
10
+ def connection
11
+ @connection ||= Faraday.new(url: "https://api.airtable.com", headers: {
12
+ "Authorization" => "Bearer #{api_key}",
13
+ "X-API-VERSION" => "0.1.0",
14
+ }) { |conn|
15
+ conn.adapter :net_http_persistent
16
+ }
17
+ end
18
+
19
+ def escape(*args)
20
+ URI.escape(*args)
21
+ end
22
+
23
+ def parse(body)
24
+ JSON.parse(body)
25
+ rescue JSON::ParserError
26
+ nil
27
+ end
28
+
29
+ def handle_error(status, error)
30
+ if error.is_a?(Hash)
31
+ raise Error, "HTTP #{status}: #{error['error']["type"]}: #{error['error']['message']}"
32
+ else
33
+ raise Error, "HTTP #{status}: Communication error: #{error}"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,244 @@
1
+ module Airrecord
2
+ # TODO: This would be much simplified if we had a schema instead. Hopefully
3
+ # one day Airtable will add this, but to simplify and crush the majority of
4
+ # the bugs that hide in here (which would be related to the dynamic schema) we
5
+ # may just query the first page and infer a schema from there that can be
6
+ # overridden on the specific classes.
7
+ #
8
+ # Right now I bet there's a bunch of bugs around similar named column keys (in
9
+ # terms of capitalization), it's inconsistent and non-obvious that `create`
10
+ # doesn't use the same column keys as everything else.
11
+ class Table
12
+ class << self
13
+ attr_accessor :base_key, :table_name, :api_key, :associations
14
+
15
+ def client
16
+ @@client ||= Client.new(api_key)
17
+ end
18
+
19
+ def has_many(name, options)
20
+ @associations ||= []
21
+ @associations << {
22
+ field: name.to_sym,
23
+ }.merge(options)
24
+ end
25
+
26
+ def belongs_to(name, options)
27
+ has_many(name, options.merge(single: true))
28
+ end
29
+
30
+ def find(id)
31
+ response = client.connection.get("/v0/#{base_key}/#{client.escape(table_name)}/#{id}")
32
+ parsed_response = client.parse(response.body)
33
+
34
+ if response.success?
35
+ self.new(parsed_response["fields"], id: id)
36
+ else
37
+ client.handle_error(response.status, parsed_response)
38
+ end
39
+ end
40
+
41
+ def records(filter: nil, sort: nil, view: nil, offset: nil, paginate: true, fields: nil)
42
+ options = {}
43
+ options[:filterByFormula] = filter if filter
44
+
45
+ if sort
46
+ options[:sort] = sort.map { |field, direction|
47
+ { field: field.to_s, direction: direction }
48
+ }
49
+ end
50
+
51
+ options[:view] = view if view
52
+ options[:offset] = offset if offset
53
+ options[:fields] = fields if fields
54
+
55
+ path = "/v0/#{base_key}/#{client.escape(table_name)}"
56
+ response = client.connection.get(path, options)
57
+ parsed_response = client.parse(response.body)
58
+
59
+ if response.success?
60
+ records = parsed_response["records"]
61
+ records = records.map { |record|
62
+ self.new(record["fields"], id: record["id"], created_at: record["createdTime"])
63
+ }
64
+
65
+ if paginate && parsed_response["offset"]
66
+ records.concat(records(
67
+ filter: filter,
68
+ sort: sort,
69
+ view: view,
70
+ paginate: paginate,
71
+ fields: fields,
72
+ offset: parsed_response["offset"],
73
+ ))
74
+ end
75
+
76
+ records
77
+ else
78
+ client.handle_error(response.status, parsed_response)
79
+ end
80
+ end
81
+ alias_method :all, :records
82
+ end
83
+
84
+ attr_reader :fields, :column_mappings, :id, :created_at, :updated_fields
85
+
86
+ def initialize(fields, id: nil, created_at: nil)
87
+ @id = id
88
+ self.created_at = created_at
89
+ self.fields = fields
90
+ end
91
+
92
+ def new_record?
93
+ !id
94
+ end
95
+
96
+ def [](key)
97
+ value = nil
98
+
99
+ if fields[key]
100
+ value = fields[key]
101
+ elsif column_mappings[key]
102
+ value = fields[column_mappings[key]]
103
+ end
104
+
105
+ if association = self.association(key)
106
+ klass = Kernel.const_get(association[:class])
107
+ associations = value.map { |id_or_obj|
108
+ id_or_obj = id_or_obj.respond_to?(:id) ? id_or_obj.id : id_or_obj
109
+ klass.find(id_or_obj)
110
+ }
111
+ return associations.first if association[:single]
112
+ associations
113
+ else
114
+ type_cast(value)
115
+ end
116
+ end
117
+
118
+ def []=(key, value)
119
+ if fields[key]
120
+ @updated_keys << key
121
+ fields[key] = value
122
+ elsif column_mappings[key]
123
+ @updated_keys << column_mappings[key]
124
+ fields[column_mappings[key]] = value
125
+ else
126
+ @updated_keys << key
127
+ fields[key] = value
128
+ end
129
+ end
130
+
131
+ def create
132
+ raise Error, "Record already exists (record has an id)" unless new_record?
133
+
134
+ body = { fields: serializable_fields }.to_json
135
+ response = client.connection.post("/v0/#{self.class.base_key}/#{client.escape(self.class.table_name)}", body, { 'Content-Type': 'application/json' })
136
+ parsed_response = client.parse(response.body)
137
+
138
+ if response.success?
139
+ @id = parsed_response["id"]
140
+ self.created_at = parsed_response["createdTime"]
141
+ self.fields = parsed_response["fields"]
142
+ else
143
+ client.handle_error(response.status, parsed_response)
144
+ end
145
+ end
146
+
147
+ def save
148
+ raise Error, "Unable to save a new record" if new_record?
149
+
150
+ return true if @updated_keys.empty?
151
+
152
+ # To avoid trying to update computed fields we *always* use PATCH
153
+ body = {
154
+ fields: Hash[@updated_keys.map { |key|
155
+ [key, fields[key]]
156
+ }]
157
+ }.to_json
158
+
159
+ response = client.connection.patch("/v0/#{self.class.base_key}/#{client.escape(self.class.table_name)}/#{self.id}", body, { 'Content-Type': 'application/json' })
160
+ parsed_response = client.parse(response.body)
161
+
162
+ if response.success?
163
+ self.fields = parsed_response
164
+ else
165
+ client.handle_error(response.status, parsed_response)
166
+ end
167
+ end
168
+
169
+ def destroy
170
+ raise Error, "Unable to destroy new record" if new_record?
171
+
172
+ response = client.connection.delete("/v0/#{self.class.base_key}/#{client.escape(self.class.table_name)}/#{self.id}")
173
+ parsed_response = client.parse(response.body)
174
+
175
+ if response.success?
176
+ true
177
+ else
178
+ client.handle_error(response.status, parsed_response)
179
+ end
180
+ end
181
+
182
+ def serializable_fields(fields = self.fields)
183
+ Hash[fields.map { |(key, value)|
184
+ if association(key)
185
+ assocs = value.map { |assoc|
186
+ assoc.respond_to?(:id) ? assoc.id : assoc
187
+ }
188
+ [key, assocs]
189
+ else
190
+ [key, value]
191
+ end
192
+ }]
193
+ end
194
+
195
+ protected
196
+
197
+ def association(key)
198
+ if self.class.associations
199
+ self.class.associations.find { |association|
200
+ association[:column].to_s == column_mappings[key].to_s || association[:column].to_s == key.to_s
201
+ }
202
+ end
203
+ end
204
+
205
+ def fields=(fields)
206
+ @updated_keys = []
207
+ @column_mappings = Hash[fields.keys.map { |key| [underscore(key), key] }]
208
+ @fields = fields
209
+ end
210
+
211
+ def self.underscore(key)
212
+ key.to_s.strip.gsub(/\W+/, "_").downcase.to_sym
213
+ end
214
+
215
+ def underscore(key)
216
+ self.class.underscore(key)
217
+ end
218
+
219
+ def created_at=(created_at)
220
+ return unless created_at
221
+ @created_at = Time.parse(created_at)
222
+ end
223
+
224
+ def client
225
+ self.class.client
226
+ end
227
+
228
+ def type_cast(value)
229
+ if value =~ /\d{4}-\d{2}-\d{2}/
230
+ Time.parse(value + " UTC")
231
+ else
232
+ value
233
+ end
234
+ end
235
+ end
236
+
237
+ def self.table(api_key, base_key, table_name)
238
+ Class.new(Table) do |klass|
239
+ klass.table_name = table_name
240
+ klass.api_key = api_key
241
+ klass.base_key = base_key
242
+ end
243
+ end
244
+ end
@@ -1,3 +1,3 @@
1
1
  module Airrecord
2
- VERSION = "0.1.2"
2
+ VERSION = "0.1.3"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: airrecord
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Eskildsen
@@ -16,28 +16,28 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: 0.10.0
19
+ version: '0.10'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: 0.10.0
26
+ version: '0.10'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: net-http-persistent
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: 2.9.4
33
+ version: '2.9'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: 2.9.4
40
+ version: '2.9'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: bundler
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -111,6 +111,8 @@ files:
111
111
  - bin/console
112
112
  - bin/setup
113
113
  - lib/airrecord.rb
114
+ - lib/airrecord/client.rb
115
+ - lib/airrecord/table.rb
114
116
  - lib/airrecord/version.rb
115
117
  homepage: https://github.com/sirupsen/airtable
116
118
  licenses: []