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.
@@ -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,3 @@
1
+ class AirtableClient
2
+ VERSION = "0.1.0"
3
+ 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: []