airtable_client 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 +7 -0
- data/CHANGELOG.md +57 -0
- data/LICENSE.txt +23 -0
- data/README.md +211 -0
- data/lib/airtable_client/batch_result.rb +55 -0
- data/lib/airtable_client/configuration.rb +75 -0
- data/lib/airtable_client/error.rb +87 -0
- data/lib/airtable_client/rate_limiter.rb +110 -0
- data/lib/airtable_client/record.rb +110 -0
- data/lib/airtable_client/record_set.rb +34 -0
- data/lib/airtable_client/resource.rb +187 -0
- data/lib/airtable_client/table.rb +402 -0
- data/lib/airtable_client/version.rb +3 -0
- data/lib/airtable_client.rb +56 -0
- metadata +114 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
class AirtableClient
|
|
2
|
+
|
|
3
|
+
# A table in an Airtable base: querying, single-record CRUD, and batch
|
|
4
|
+
# operations. Obtain instances via {AirtableClient#table}.
|
|
5
|
+
#
|
|
6
|
+
# Each Table holds its own persistent HTTP connection (opened lazily), so
|
|
7
|
+
# instances are cheap to create and efficient to reuse — but they must not
|
|
8
|
+
# be shared across threads. All requests pass through the process-global
|
|
9
|
+
# {RateLimiter} and retry automatically on HTTP 429/503.
|
|
10
|
+
class Table < Resource
|
|
11
|
+
# Records fetched per request when paginating with {#all}.
|
|
12
|
+
LIMIT_MAX = 100
|
|
13
|
+
|
|
14
|
+
# Fetches every record in the table, following pagination offsets until
|
|
15
|
+
# the collection is exhausted (one request per {LIMIT_MAX} records).
|
|
16
|
+
#
|
|
17
|
+
# Loads the entire table into memory; for bounded queries use {#select}.
|
|
18
|
+
#
|
|
19
|
+
# @param options [Hash] the same options as {#records}
|
|
20
|
+
# @return [Array<Record>] every record in the table
|
|
21
|
+
# @raise [Error] when the API returns an error
|
|
22
|
+
# @example
|
|
23
|
+
# table.all(sort: ["Name", :asc])
|
|
24
|
+
def all(options={})
|
|
25
|
+
offset = nil
|
|
26
|
+
results = []
|
|
27
|
+
begin
|
|
28
|
+
options.merge!(:limit => LIMIT_MAX, :offset => offset)
|
|
29
|
+
response = records(options)
|
|
30
|
+
results += response.records
|
|
31
|
+
offset = response.offset
|
|
32
|
+
end until offset.nil? || offset.empty? || results.empty?
|
|
33
|
+
results
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Fetches one page of records.
|
|
37
|
+
#
|
|
38
|
+
# @param options [Hash] query options; any other keys pass through as
|
|
39
|
+
# raw query parameters
|
|
40
|
+
# @option options [Array(String, Symbol)] :sort field name and direction,
|
|
41
|
+
# e.g. +["Name", :asc]+
|
|
42
|
+
# @option options [Integer] :limit maximum records to return (API cap 100)
|
|
43
|
+
# @option options [String] :offset pagination offset from a previous
|
|
44
|
+
# {RecordSet#offset}
|
|
45
|
+
# @option options [Array<String>] :fields only return these fields
|
|
46
|
+
# @option options [String] :view scope the query to a view
|
|
47
|
+
# @return [RecordSet] the page of records plus the next pagination offset
|
|
48
|
+
# @raise [Error] when the API returns an error
|
|
49
|
+
# @example
|
|
50
|
+
# page = table.records(sort: ["Name", :asc], limit: 50)
|
|
51
|
+
# more = table.records(offset: page.offset)
|
|
52
|
+
def records(options={})
|
|
53
|
+
options["sortField"], options["sortDirection"] = options.delete(:sort) if options[:sort]
|
|
54
|
+
options['fields[]'] = options.delete(:fields) if options[:fields]
|
|
55
|
+
options['view'] = options.delete(:view) if options[:view]
|
|
56
|
+
request = build_get_request(worksheet_url, query: options)
|
|
57
|
+
response = perform_request(request)
|
|
58
|
+
result = parse_response(response)
|
|
59
|
+
log_response(response, 'GET', parsed_result: result)
|
|
60
|
+
check_and_raise_error(result, status_code: response.code.to_i)
|
|
61
|
+
RecordSet.new(result)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Queries records, optionally filtered by an Airtable formula.
|
|
65
|
+
#
|
|
66
|
+
# @param options [Hash] query options; any other keys pass through as
|
|
67
|
+
# raw query parameters
|
|
68
|
+
# @option options [String] :formula an Airtable formula, sent as
|
|
69
|
+
# +filterByFormula+ — escape user input with
|
|
70
|
+
# {AirtableClient.escape_formula_value}
|
|
71
|
+
# @option options [Array(String, Symbol)] :sort field name and direction
|
|
72
|
+
# @option options [Integer] :limit maximum records, sent as +maxRecords+
|
|
73
|
+
# @option options [Array<String>] :fields only return these fields
|
|
74
|
+
# @option options [String] :view scope the query to a view
|
|
75
|
+
# @return [RecordSet] the matching records
|
|
76
|
+
# @raise [ArgumentError] when +:formula+ is not a String
|
|
77
|
+
# @raise [Error] when the API returns an error
|
|
78
|
+
# @example
|
|
79
|
+
# table.select(formula: "Active = 1", fields: %w[Name Email], limit: 10)
|
|
80
|
+
def select(options={})
|
|
81
|
+
options['sortField'], options['sortDirection'] = options.delete(:sort) if options[:sort]
|
|
82
|
+
options['maxRecords'] = options.delete(:limit) if options[:limit]
|
|
83
|
+
|
|
84
|
+
if options[:formula]
|
|
85
|
+
raise_bad_formula_error unless options[:formula].is_a? String
|
|
86
|
+
options['filterByFormula'] = options.delete(:formula)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
options['fields[]'] = options.delete(:fields) if options[:fields]
|
|
90
|
+
options['view'] = options.delete(:view) if options[:view]
|
|
91
|
+
|
|
92
|
+
request = build_get_request(worksheet_url, query: options)
|
|
93
|
+
response = perform_request(request)
|
|
94
|
+
result = parse_response(response)
|
|
95
|
+
log_response(response, 'GET', parsed_result: result)
|
|
96
|
+
check_and_raise_error(result, status_code: response.code.to_i)
|
|
97
|
+
RecordSet.new(result)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def raise_bad_formula_error
|
|
101
|
+
raise ArgumentError.new("The value for filter should be a String.")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Fetches a single record by id.
|
|
105
|
+
#
|
|
106
|
+
# @param id [String] the record id, e.g. +"rec02sKGVIzU65eV2"+
|
|
107
|
+
# @return [Record, nil] the record, or nil when the response has no id
|
|
108
|
+
# @raise [Error] when the API returns an error (type +"NOT_FOUND"+ for
|
|
109
|
+
# a missing record)
|
|
110
|
+
def find(id)
|
|
111
|
+
request = build_get_request("#{worksheet_url}/#{id}")
|
|
112
|
+
response = perform_request(request)
|
|
113
|
+
result = parse_response(response)
|
|
114
|
+
log_response(response, 'GET', parsed_result: result)
|
|
115
|
+
check_and_raise_error(result, status_code: response.code.to_i)
|
|
116
|
+
Record.new(result_attributes(result)) if result.is_a?(Hash) && result["id"]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Creates a record.
|
|
120
|
+
#
|
|
121
|
+
# @param record [Record] the record to create
|
|
122
|
+
# @return [Record] the same record, mutated in place with the assigned
|
|
123
|
+
# id and any server-computed fields
|
|
124
|
+
# @raise [Error] when the API returns an error
|
|
125
|
+
# @example
|
|
126
|
+
# record = AirtableClient::Record.new('Name' => 'Sarah Jaine')
|
|
127
|
+
# table.create(record)
|
|
128
|
+
# record.id # => "rec03sKOVIzU65eV4"
|
|
129
|
+
def create(record)
|
|
130
|
+
request = build_post_request(worksheet_url, body: { "fields" => record.fields })
|
|
131
|
+
response = perform_request(request)
|
|
132
|
+
result = parse_response(response)
|
|
133
|
+
log_response(response, 'POST', parsed_result: result)
|
|
134
|
+
|
|
135
|
+
check_and_raise_error(result, status_code: response.code.to_i)
|
|
136
|
+
|
|
137
|
+
record.override_attributes!(result_attributes(result))
|
|
138
|
+
record
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Replaces a record (HTTP PUT — destructive). Fields not present on the
|
|
142
|
+
# given record are cleared server-side; for a partial update use
|
|
143
|
+
# {#update_record_fields}.
|
|
144
|
+
#
|
|
145
|
+
# @param record [Record] a record with an id and the full desired fields
|
|
146
|
+
# @return [Record] the same record, mutated in place with the response
|
|
147
|
+
# @raise [Error] when the API returns an error
|
|
148
|
+
def update(record)
|
|
149
|
+
request = build_put_request("#{worksheet_url}/#{record.id}", body: { "fields" => record.fields_for_update })
|
|
150
|
+
response = perform_request(request)
|
|
151
|
+
result = parse_response(response)
|
|
152
|
+
log_response(response, 'PUT', parsed_result: result)
|
|
153
|
+
|
|
154
|
+
check_and_raise_error(result, status_code: response.code.to_i)
|
|
155
|
+
|
|
156
|
+
record.override_attributes!(result_attributes(result))
|
|
157
|
+
record
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Partially updates a record (HTTP PATCH). Only the given fields change;
|
|
161
|
+
# everything else is left untouched.
|
|
162
|
+
#
|
|
163
|
+
# @param record_id [String] the record id
|
|
164
|
+
# @param fields_for_update [Hash{String => Object}] field name => new value
|
|
165
|
+
# @return [Record] a new Record built from the response
|
|
166
|
+
# @raise [Error] when the API returns an error
|
|
167
|
+
# @example
|
|
168
|
+
# table.update_record_fields('rec123', 'Email' => 'new@example.com')
|
|
169
|
+
def update_record_fields(record_id, fields_for_update)
|
|
170
|
+
request = build_patch_request("#{worksheet_url}/#{record_id}", body: { "fields" => fields_for_update })
|
|
171
|
+
response = perform_request(request)
|
|
172
|
+
result = parse_response(response)
|
|
173
|
+
log_response(response, 'PATCH', parsed_result: result)
|
|
174
|
+
|
|
175
|
+
check_and_raise_error(result, status_code: response.code.to_i)
|
|
176
|
+
|
|
177
|
+
Record.new(result_attributes(result))
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Deletes a record by id.
|
|
181
|
+
#
|
|
182
|
+
# @param id [String] the record id
|
|
183
|
+
# @return [Hash] the API response, e.g. +{"deleted" => true, "id" => ...}+
|
|
184
|
+
# @raise [Error] when the API returns an error
|
|
185
|
+
def destroy(id)
|
|
186
|
+
request = build_delete_request("#{worksheet_url}/#{id}")
|
|
187
|
+
response = perform_request(request)
|
|
188
|
+
result = parse_response(response)
|
|
189
|
+
log_response(response, 'DELETE', parsed_result: result)
|
|
190
|
+
check_and_raise_error(result, status_code: response.code.to_i)
|
|
191
|
+
result
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Creates records in bulk, auto-chunking into groups of
|
|
195
|
+
# {Configuration#batch_size} (default 10) — one request per chunk.
|
|
196
|
+
#
|
|
197
|
+
# Does not raise on per-chunk API errors; inspect the returned
|
|
198
|
+
# {BatchResult} for partial failures.
|
|
199
|
+
#
|
|
200
|
+
# @param records [Array<Record>] the records to create
|
|
201
|
+
# @return [BatchResult] successes and failures across all chunks
|
|
202
|
+
# @example
|
|
203
|
+
# result = table.create_batch(records)
|
|
204
|
+
# result.failures.each { |f| warn "#{f[:record].inspect}: #{f[:error].message}" }
|
|
205
|
+
def create_batch(records)
|
|
206
|
+
batch_operation(records) do |chunk|
|
|
207
|
+
body = { "records" => chunk.map { |r| { "fields" => r.fields } } }
|
|
208
|
+
build_post_request(worksheet_url, body: body)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Partially updates records in bulk (HTTP PATCH), auto-chunking like
|
|
213
|
+
# {#create_batch}.
|
|
214
|
+
#
|
|
215
|
+
# @param records [Array<Record>] records to update; each must have an id
|
|
216
|
+
# @return [BatchResult] successes and failures across all chunks
|
|
217
|
+
def update_batch(records)
|
|
218
|
+
batch_operation(records) do |chunk|
|
|
219
|
+
body = { "records" => chunk.map { |r| { "id" => r.id, "fields" => r.fields_for_update } } }
|
|
220
|
+
build_patch_request(worksheet_url, body: body)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Deletes records in bulk by id, auto-chunking like {#create_batch}.
|
|
225
|
+
#
|
|
226
|
+
# @param record_ids [Array<String>] ids of the records to delete
|
|
227
|
+
# @return [BatchResult] successes and failures across all chunks
|
|
228
|
+
def destroy_batch(record_ids)
|
|
229
|
+
batch_result = BatchResult.new
|
|
230
|
+
record_ids = Array(record_ids)
|
|
231
|
+
return batch_result if record_ids.empty?
|
|
232
|
+
|
|
233
|
+
record_ids.each_slice(batch_size) do |chunk|
|
|
234
|
+
query_string = chunk.map { |id| "records[]=#{URI.encode_www_form_component(id)}" }.join("&")
|
|
235
|
+
request = build_delete_request("#{worksheet_url}?#{query_string}")
|
|
236
|
+
response = perform_request(request)
|
|
237
|
+
result = parse_response(response)
|
|
238
|
+
log_response(response, 'DELETE', parsed_result: result)
|
|
239
|
+
|
|
240
|
+
if result.is_a?(Hash) && result['error'].is_a?(Hash)
|
|
241
|
+
error = Error.new(result['error'], status_code: response.code.to_i)
|
|
242
|
+
chunk.each { |id| batch_result.add_failure(id, error) }
|
|
243
|
+
elsif result['records']
|
|
244
|
+
result['records'].each do |r|
|
|
245
|
+
if r['deleted']
|
|
246
|
+
batch_result.add_success(r)
|
|
247
|
+
else
|
|
248
|
+
batch_result.add_failure(r['id'], Error.new({ 'type' => 'DELETE_FAILED', 'message' => "Record #{r['id']} was not deleted" }))
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
batch_result
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Upserts records — find-or-create in one call via Airtable's
|
|
257
|
+
# +performUpsert+. Existing records matching on the merge fields are
|
|
258
|
+
# updated; the rest are created. Auto-chunks like {#create_batch}.
|
|
259
|
+
#
|
|
260
|
+
# @param records [Array<Record>] the records to upsert
|
|
261
|
+
# @param fields_to_merge_on [Array<String>] 1–3 field names used to match
|
|
262
|
+
# existing records
|
|
263
|
+
# @return [BatchResult] all resulting records in
|
|
264
|
+
# {BatchResult#successes}, with {BatchResult#created_record_ids}
|
|
265
|
+
# listing the ones that were newly created
|
|
266
|
+
# @example
|
|
267
|
+
# result = table.upsert(records, fields_to_merge_on: ['Email'])
|
|
268
|
+
# result.created_record_ids # => ids created rather than updated
|
|
269
|
+
def upsert(records, fields_to_merge_on:)
|
|
270
|
+
batch_result = BatchResult.new
|
|
271
|
+
records = Array(records)
|
|
272
|
+
return batch_result if records.empty?
|
|
273
|
+
|
|
274
|
+
records.each_slice(batch_size) do |chunk|
|
|
275
|
+
body = {
|
|
276
|
+
"performUpsert" => { "fieldsToMergeOn" => fields_to_merge_on },
|
|
277
|
+
"records" => chunk.map { |r| { "fields" => r.fields } }
|
|
278
|
+
}
|
|
279
|
+
request = build_patch_request(worksheet_url, body: body)
|
|
280
|
+
response = perform_request(request)
|
|
281
|
+
result = parse_response(response)
|
|
282
|
+
log_response(response, 'PATCH', parsed_result: result)
|
|
283
|
+
|
|
284
|
+
if result.is_a?(Hash) && result['error'].is_a?(Hash)
|
|
285
|
+
error = Error.new(result['error'], status_code: response.code.to_i)
|
|
286
|
+
chunk.each { |r| batch_result.add_failure(r, error) }
|
|
287
|
+
else
|
|
288
|
+
batch_result.add_created_ids(result['createdRecords'] || [])
|
|
289
|
+
(result['records'] || []).each do |r|
|
|
290
|
+
batch_result.add_success(Record.new(result_attributes(r)))
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
batch_result
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
protected
|
|
298
|
+
|
|
299
|
+
def batch_size
|
|
300
|
+
AirtableClient.configuration.batch_size
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def check_and_raise_error(result, status_code: nil)
|
|
304
|
+
if result.is_a?(Hash) && result['error'].is_a?(Hash)
|
|
305
|
+
error_hash = result['error']
|
|
306
|
+
# Preserve Airtable's specific type if present, otherwise classify by status code
|
|
307
|
+
error_hash['type'] ||= Error::STATUS_CODE_ERROR_TYPES.fetch(status_code, 'UNKNOWN_ERROR')
|
|
308
|
+
raise Error.new(error_hash, status_code: status_code)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Raise on non-2xx status codes even when the parsed result has no error hash
|
|
312
|
+
# (or has a non-Hash error value like a string).
|
|
313
|
+
if status_code && status_code >= 400
|
|
314
|
+
raise Error.from_response(status_code, nil)
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def log_response(response, http_method, parsed_result: nil)
|
|
319
|
+
status_code = response.code.to_i
|
|
320
|
+
duration_ms = @last_request_duration_ms || 0
|
|
321
|
+
request_body_size = @last_request_body_size || 0
|
|
322
|
+
response_body_size = @last_response_body_size || 0
|
|
323
|
+
error_hash = parsed_result.is_a?(Hash) && parsed_result['error'].is_a?(Hash) ? parsed_result['error'] : nil
|
|
324
|
+
error_type = error_hash&.dig('type')
|
|
325
|
+
error_message = error_hash&.dig('message')
|
|
326
|
+
|
|
327
|
+
log_line = "[Airtable] #{status_code} #{http_method} #{worksheet_name} #{duration_ms}ms request=#{request_body_size}b response=#{response_body_size}b"
|
|
328
|
+
log_line += " error_type=#{error_type} error_message=#{error_message}" if error_type
|
|
329
|
+
|
|
330
|
+
emit_log(:info, log_line)
|
|
331
|
+
|
|
332
|
+
AirtableClient.configuration.on_request&.call({
|
|
333
|
+
status_code: status_code,
|
|
334
|
+
table: worksheet_name,
|
|
335
|
+
http_method: http_method,
|
|
336
|
+
duration_ms: duration_ms,
|
|
337
|
+
request_body_size: request_body_size,
|
|
338
|
+
response_body_size: response_body_size,
|
|
339
|
+
error_type: error_type,
|
|
340
|
+
error_message: error_message
|
|
341
|
+
})
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def parse_response(response)
|
|
345
|
+
body = response.body
|
|
346
|
+
return {} if body.nil? || body.empty?
|
|
347
|
+
|
|
348
|
+
JSON.parse(body)
|
|
349
|
+
rescue JSON::ParserError
|
|
350
|
+
raise Error.from_response(response.code.to_i, body)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def result_attributes(res)
|
|
354
|
+
res["fields"].merge("id" => res["id"]) if res.is_a?(Hash) && res["id"]
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def worksheet_url
|
|
358
|
+
"#{BASE_PATH}/#{app_token}/#{url_encode(worksheet_name)}"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Shared batch operation logic for create_batch and update_batch.
|
|
362
|
+
# Yields each chunk to the block which builds the request, then
|
|
363
|
+
# processes the response into the BatchResult.
|
|
364
|
+
def batch_operation(records)
|
|
365
|
+
batch_result = BatchResult.new
|
|
366
|
+
records = Array(records)
|
|
367
|
+
return batch_result if records.empty?
|
|
368
|
+
|
|
369
|
+
records.each_slice(batch_size) do |chunk|
|
|
370
|
+
begin
|
|
371
|
+
request = yield(chunk)
|
|
372
|
+
response = perform_request(request)
|
|
373
|
+
result = parse_response(response)
|
|
374
|
+
http_method = request.method
|
|
375
|
+
log_response(response, http_method, parsed_result: result)
|
|
376
|
+
|
|
377
|
+
if result.is_a?(Hash) && result['error'].is_a?(Hash)
|
|
378
|
+
error = Error.new(result['error'], status_code: response.code.to_i)
|
|
379
|
+
chunk.each { |r| batch_result.add_failure(r, error) }
|
|
380
|
+
elsif result.is_a?(Hash) && result['records']
|
|
381
|
+
result['records'].each do |r|
|
|
382
|
+
batch_result.add_success(Record.new(result_attributes(r)))
|
|
383
|
+
end
|
|
384
|
+
else
|
|
385
|
+
chunk.each { |r| batch_result.add_failure(r, Error.new({ 'type' => 'UNEXPECTED_RESPONSE', 'message' => 'Response contained no records or error' })) }
|
|
386
|
+
end
|
|
387
|
+
rescue AirtableClient::Error => e
|
|
388
|
+
chunk.each { |r| batch_result.add_failure(r, e) }
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
batch_result
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# From http://apidock.com/ruby/ERB/Util/url_encode
|
|
395
|
+
def url_encode(s)
|
|
396
|
+
s.to_s.dup.force_encoding("ASCII-8BIT").gsub(/[^a-zA-Z0-9_\-.]/) {
|
|
397
|
+
sprintf("%%%02X", $&.unpack("C")[0])
|
|
398
|
+
}
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
require 'net/http'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'uri'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'delegate'
|
|
6
|
+
|
|
7
|
+
# Entry point to the library. Holds the access token and builds {Table}
|
|
8
|
+
# instances.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# client = AirtableClient.new(ENV.fetch('AIRTABLE_ACCESS_TOKEN'))
|
|
12
|
+
# table = client.table('appXXXXXXXXXXXXXX', 'Table Name')
|
|
13
|
+
# table.all
|
|
14
|
+
class AirtableClient
|
|
15
|
+
# @param api_key [String] an Airtable personal access token
|
|
16
|
+
# @param timeout [Integer] HTTP open/read/write timeout in seconds for
|
|
17
|
+
# tables built by this client
|
|
18
|
+
def initialize(api_key, timeout: Resource::DEFAULT_TIMEOUT)
|
|
19
|
+
@api_key = api_key
|
|
20
|
+
@timeout = timeout
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Builds a {Table} for one table in one base.
|
|
24
|
+
#
|
|
25
|
+
# @param app_token [String] the base id, e.g. +"appXXXXXXXXXXXXXX"+
|
|
26
|
+
# @param worksheet_name [String] the table name (or table id)
|
|
27
|
+
# @return [Table] a new Table — not thread-safe, so build one per thread
|
|
28
|
+
def table(app_token, worksheet_name)
|
|
29
|
+
Table.new(@api_key, app_token, worksheet_name, timeout: @timeout)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Escapes a value for safe interpolation into an Airtable formula string.
|
|
33
|
+
# Airtable formulas use single-quoted strings; this escapes backslashes
|
|
34
|
+
# and single quotes, then wraps the value in single quotes. Always use it
|
|
35
|
+
# when a formula includes user input.
|
|
36
|
+
#
|
|
37
|
+
# @param value [#to_s] the raw value
|
|
38
|
+
# @return [String] the quoted, escaped formula literal
|
|
39
|
+
# @example
|
|
40
|
+
# formula = "{Email} = #{AirtableClient.escape_formula_value(user.email)}"
|
|
41
|
+
# table.select(formula: formula)
|
|
42
|
+
def self.escape_formula_value(value)
|
|
43
|
+
escaped = value.to_s.gsub('\\', '\\\\\\\\').gsub("'", "\\\\'")
|
|
44
|
+
"'#{escaped}'"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
require 'airtable_client/version'
|
|
49
|
+
require 'airtable_client/configuration'
|
|
50
|
+
require 'airtable_client/resource'
|
|
51
|
+
require 'airtable_client/record'
|
|
52
|
+
require 'airtable_client/record_set'
|
|
53
|
+
require 'airtable_client/table'
|
|
54
|
+
require 'airtable_client/error'
|
|
55
|
+
require 'airtable_client/rate_limiter'
|
|
56
|
+
require 'airtable_client/batch_result'
|
metadata
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: airtable_client
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- David Silva
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: logger
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.6'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.6'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.25'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.25'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rake
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '13.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '13.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: webmock
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '3.24'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.24'
|
|
68
|
+
description: 'Ruby client for the Airtable Web API with zero runtime dependencies:
|
|
69
|
+
persistent Net::HTTP connections, client-side rate limiting, automatic retries with
|
|
70
|
+
exponential backoff, error classification, batch operations with auto-chunking,
|
|
71
|
+
and upsert support.'
|
|
72
|
+
executables: []
|
|
73
|
+
extensions: []
|
|
74
|
+
extra_rdoc_files: []
|
|
75
|
+
files:
|
|
76
|
+
- CHANGELOG.md
|
|
77
|
+
- LICENSE.txt
|
|
78
|
+
- README.md
|
|
79
|
+
- lib/airtable_client.rb
|
|
80
|
+
- lib/airtable_client/batch_result.rb
|
|
81
|
+
- lib/airtable_client/configuration.rb
|
|
82
|
+
- lib/airtable_client/error.rb
|
|
83
|
+
- lib/airtable_client/rate_limiter.rb
|
|
84
|
+
- lib/airtable_client/record.rb
|
|
85
|
+
- lib/airtable_client/record_set.rb
|
|
86
|
+
- lib/airtable_client/resource.rb
|
|
87
|
+
- lib/airtable_client/table.rb
|
|
88
|
+
- lib/airtable_client/version.rb
|
|
89
|
+
homepage: https://github.com/Davidslv/airtable_client
|
|
90
|
+
licenses:
|
|
91
|
+
- MIT
|
|
92
|
+
metadata:
|
|
93
|
+
homepage_uri: https://github.com/Davidslv/airtable_client
|
|
94
|
+
changelog_uri: https://github.com/Davidslv/airtable_client/blob/main/CHANGELOG.md
|
|
95
|
+
bug_tracker_uri: https://github.com/Davidslv/airtable_client/issues
|
|
96
|
+
rubygems_mfa_required: 'true'
|
|
97
|
+
rdoc_options: []
|
|
98
|
+
require_paths:
|
|
99
|
+
- lib
|
|
100
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
101
|
+
requirements:
|
|
102
|
+
- - ">="
|
|
103
|
+
- !ruby/object:Gem::Version
|
|
104
|
+
version: '3.1'
|
|
105
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - ">="
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '0'
|
|
110
|
+
requirements: []
|
|
111
|
+
rubygems_version: 4.0.20
|
|
112
|
+
specification_version: 4
|
|
113
|
+
summary: A resilient Ruby client for the Airtable Web API
|
|
114
|
+
test_files: []
|