airrecord 0.1.2 → 0.1.3

Sign up to get free protection for your applications and to get access to all the features.
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: []