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,110 @@
|
|
|
1
|
+
class AirtableClient
|
|
2
|
+
|
|
3
|
+
# A single Airtable record: a set of fields plus (once persisted) an id.
|
|
4
|
+
#
|
|
5
|
+
# Attributes are readable by original column name, underscored symbol, or
|
|
6
|
+
# generated accessor method:
|
|
7
|
+
#
|
|
8
|
+
# record = AirtableClient::Record.new('Full Name' => 'Sarah Jaine')
|
|
9
|
+
# record['Full Name'] # => "Sarah Jaine"
|
|
10
|
+
# record[:full_name] # => "Sarah Jaine"
|
|
11
|
+
# record.full_name # => "Sarah Jaine"
|
|
12
|
+
#
|
|
13
|
+
# Writing back to the API uses {#fields}, which preserves the original
|
|
14
|
+
# column-name capitalisation.
|
|
15
|
+
class Record
|
|
16
|
+
# @param attrs [Hash] field name => value; include +id+ for an
|
|
17
|
+
# already-persisted record
|
|
18
|
+
def initialize(attrs={})
|
|
19
|
+
override_attributes!(attrs)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [String, nil] the record id, nil until persisted
|
|
23
|
+
def id; @attrs[:id]; end
|
|
24
|
+
|
|
25
|
+
# @param val [String] the record id
|
|
26
|
+
def id=(val); @attrs[:id] = val; end
|
|
27
|
+
|
|
28
|
+
# Reads an attribute by column name or underscored symbol.
|
|
29
|
+
#
|
|
30
|
+
# @param name [String, Symbol]
|
|
31
|
+
# @return [Object] the value, or +""+ when the attribute is absent
|
|
32
|
+
def [](name)
|
|
33
|
+
@attrs.has_key?(to_key(name)) ? @attrs[to_key(name)] : ""
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Sets an attribute, defining an accessor method for it when possible.
|
|
37
|
+
#
|
|
38
|
+
# @param name [String, Symbol]
|
|
39
|
+
# @param value [Object]
|
|
40
|
+
def []=(name, value)
|
|
41
|
+
@column_keys << name
|
|
42
|
+
@attrs[to_key(name)] = value
|
|
43
|
+
define_accessor(name) unless respond_to?(name)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def inspect
|
|
47
|
+
"#<AirtableClient::Record #{attributes.map { |a, v| ":#{a}=>#{v.inspect}" }.join(", ")}>"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Hash{Symbol => Object}] attributes keyed by underscored symbol
|
|
51
|
+
def attributes; @attrs; end
|
|
52
|
+
|
|
53
|
+
# Replaces all attributes. Called by {Table#create} and {Table#update}
|
|
54
|
+
# with the API response.
|
|
55
|
+
#
|
|
56
|
+
# @param attrs [Hash] field name => value
|
|
57
|
+
def override_attributes!(attrs={})
|
|
58
|
+
@column_keys = attrs.keys
|
|
59
|
+
@attrs = attrs.to_h { |k, v| [to_key(k), v] }
|
|
60
|
+
@attrs.each_key { |k| define_accessor(k) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @return [Hash] attributes keyed by the original column names, as sent
|
|
64
|
+
# in API request bodies
|
|
65
|
+
def fields
|
|
66
|
+
@column_keys.to_h { |k| [k, @attrs[to_key(k)]] }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# {#fields} without the id in any spelling — Airtable rejects an +id+
|
|
70
|
+
# inside a request body.
|
|
71
|
+
#
|
|
72
|
+
# @return [Hash] attributes safe to send in an update request
|
|
73
|
+
def fields_for_update
|
|
74
|
+
fields.reject { |k, _| to_key(k) == :id }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def method_missing(name, *args, &blk)
|
|
78
|
+
# Accessor for attributes
|
|
79
|
+
if args.empty? && blk.nil? && @attrs.has_key?(name)
|
|
80
|
+
@attrs[name]
|
|
81
|
+
else
|
|
82
|
+
super
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def respond_to?(name, include_private = false)
|
|
87
|
+
@attrs.has_key?(name) || super
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
protected
|
|
91
|
+
|
|
92
|
+
def to_key(string)
|
|
93
|
+
string.is_a?(Symbol) ? string : underscore(string).to_sym
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def underscore(string)
|
|
97
|
+
string.gsub(/::/, '/').
|
|
98
|
+
gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
|
|
99
|
+
gsub(/([a-z\d])([A-Z])/,'\1_\2').
|
|
100
|
+
gsub(/\s/, '_').tr("-", "_").downcase
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def define_accessor(name)
|
|
104
|
+
return if self.class.method_defined?(name)
|
|
105
|
+
|
|
106
|
+
self.class.send(:define_method, name) { @attrs[name] }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
end # Record
|
|
110
|
+
end # Airtable
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
class AirtableClient
|
|
2
|
+
|
|
3
|
+
# One page of query results: the records plus the pagination offset for
|
|
4
|
+
# the next page. Delegates array behaviour to {#records}, so it can be
|
|
5
|
+
# iterated and indexed directly.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# page = table.records(limit: 50)
|
|
9
|
+
# page.each { |record| ... }
|
|
10
|
+
# next_page = table.records(offset: page.offset) if page.offset
|
|
11
|
+
class RecordSet < SimpleDelegator
|
|
12
|
+
|
|
13
|
+
# @return [Array<Record>] the records in this page
|
|
14
|
+
attr_reader :records
|
|
15
|
+
|
|
16
|
+
# @return [String, nil] pass to the next query's +offset:+ option to
|
|
17
|
+
# fetch the following page; nil on the last page
|
|
18
|
+
attr_reader :offset
|
|
19
|
+
|
|
20
|
+
# @param results [Hash] a parsed list-records response,
|
|
21
|
+
# e.g. +{ "records" => [...], "offset" => "itr..." }+
|
|
22
|
+
def initialize(results)
|
|
23
|
+
# Parse records
|
|
24
|
+
@records = results && results["records"] ?
|
|
25
|
+
results["records"].map { |r| Record.new(r["fields"].merge("id" => r["id"])) } : []
|
|
26
|
+
# Store offset
|
|
27
|
+
@offset = results["offset"] if results
|
|
28
|
+
# Assign delegation object
|
|
29
|
+
__setobj__(@records)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
end # Record
|
|
33
|
+
|
|
34
|
+
end # Airtable
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
class AirtableClient
|
|
2
|
+
# Base class for authorised resources sending network requests.
|
|
3
|
+
#
|
|
4
|
+
# Each Table instance holds its own persistent connection to the Airtable
|
|
5
|
+
# API. Table instances must not be shared across threads.
|
|
6
|
+
class Resource
|
|
7
|
+
BASE_URI = 'https://api.airtable.com'
|
|
8
|
+
BASE_PATH = '/v0'
|
|
9
|
+
DEFAULT_TIMEOUT = 52
|
|
10
|
+
|
|
11
|
+
attr_reader :api_key, :app_token, :worksheet_name
|
|
12
|
+
|
|
13
|
+
def initialize(api_key, app_token, worksheet_name, timeout: DEFAULT_TIMEOUT)
|
|
14
|
+
@api_key = api_key
|
|
15
|
+
@app_token = app_token
|
|
16
|
+
@worksheet_name = worksheet_name
|
|
17
|
+
@timeout = timeout
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def connection
|
|
23
|
+
if @connection.nil? || !connection_active?
|
|
24
|
+
@connection = build_connection
|
|
25
|
+
end
|
|
26
|
+
@connection
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def build_connection
|
|
30
|
+
reused = !@connection.nil?
|
|
31
|
+
uri = URI(BASE_URI)
|
|
32
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
33
|
+
http.use_ssl = true
|
|
34
|
+
http.open_timeout = @timeout
|
|
35
|
+
http.read_timeout = @timeout
|
|
36
|
+
http.write_timeout = @timeout
|
|
37
|
+
http.keep_alive_timeout = 30
|
|
38
|
+
http.start
|
|
39
|
+
log_connection(reused ? :reconnected : :opened)
|
|
40
|
+
http
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def connection_active?
|
|
44
|
+
@connection&.started?
|
|
45
|
+
rescue IOError
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def close_connection
|
|
50
|
+
@connection&.finish if connection_active?
|
|
51
|
+
log_connection(:closed)
|
|
52
|
+
rescue IOError
|
|
53
|
+
# already closed
|
|
54
|
+
ensure
|
|
55
|
+
@connection = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
MAX_API_RETRIES = 3
|
|
59
|
+
RETRYABLE_STATUSES = [429, 503].freeze
|
|
60
|
+
|
|
61
|
+
def perform_request(request)
|
|
62
|
+
api_attempts = 0
|
|
63
|
+
|
|
64
|
+
loop do
|
|
65
|
+
AirtableClient::RateLimiter.instance.wait!(app_token)
|
|
66
|
+
|
|
67
|
+
response = perform_request_with_connection_retry(request)
|
|
68
|
+
api_attempts += 1
|
|
69
|
+
status = response.code.to_i
|
|
70
|
+
|
|
71
|
+
if RETRYABLE_STATUSES.include?(status) && api_attempts < MAX_API_RETRIES
|
|
72
|
+
delay = backoff_delay(api_attempts)
|
|
73
|
+
log_api_retry(request, status, api_attempts, delay)
|
|
74
|
+
sleep_for_retry(delay)
|
|
75
|
+
next
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
return response
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def perform_request_with_connection_retry(request)
|
|
83
|
+
retries = 0
|
|
84
|
+
begin
|
|
85
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
86
|
+
response = connection.request(request)
|
|
87
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
|
|
88
|
+
@last_request_duration_ms = duration_ms
|
|
89
|
+
@last_request_body_size = request.body&.bytesize || 0
|
|
90
|
+
@last_response_body_size = response.body&.bytesize || 0
|
|
91
|
+
response
|
|
92
|
+
rescue IOError, Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError => e
|
|
93
|
+
close_connection
|
|
94
|
+
retries += 1
|
|
95
|
+
if retries <= 1
|
|
96
|
+
log_retry(request, e)
|
|
97
|
+
retry
|
|
98
|
+
end
|
|
99
|
+
raise e
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def sleep_for_retry(delay)
|
|
104
|
+
Kernel.sleep(delay)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def backoff_delay(attempt)
|
|
108
|
+
base = 2**(attempt - 1) # attempt 1 => 1s, attempt 2 => 2s
|
|
109
|
+
base + (rand * base) # full jitter: [base, 2*base)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def log_api_retry(request, status, attempt, delay)
|
|
113
|
+
emit_log(:warn, "[Airtable] HTTP #{status} on #{request.method} #{worksheet_name}, " \
|
|
114
|
+
"retry #{attempt}/#{MAX_API_RETRIES - 1} in #{'%.2f' % delay}s")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def log_retry(request, error)
|
|
118
|
+
emit_log(:warn, "[Airtable] Connection reset (#{error.class}: #{error.message}), retrying #{request.method} #{worksheet_name}")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def log_connection(event)
|
|
122
|
+
AirtableClient.configuration.logger&.debug("[Airtable] Connection #{event} for #{worksheet_name}")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Routes a message to the configured logger. Without a logger, info/warn
|
|
126
|
+
# fall back to $stderr and debug is suppressed.
|
|
127
|
+
def emit_log(severity, message)
|
|
128
|
+
logger = AirtableClient.configuration.logger
|
|
129
|
+
if logger
|
|
130
|
+
logger.public_send(severity, message)
|
|
131
|
+
else
|
|
132
|
+
$stderr.puts(message)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def default_headers
|
|
137
|
+
{
|
|
138
|
+
'Authorization' => "Bearer #{@api_key}",
|
|
139
|
+
'Content-Type' => 'application/json'
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def build_get_request(path, query: nil)
|
|
144
|
+
full_path = query ? "#{path}?#{encode_query(query)}" : path
|
|
145
|
+
request = Net::HTTP::Get.new(full_path)
|
|
146
|
+
default_headers.each { |key, value| request[key] = value }
|
|
147
|
+
request
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def build_post_request(path, body:)
|
|
151
|
+
request = Net::HTTP::Post.new(path)
|
|
152
|
+
default_headers.each { |key, value| request[key] = value }
|
|
153
|
+
request.body = body.to_json
|
|
154
|
+
request
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def build_put_request(path, body:)
|
|
158
|
+
request = Net::HTTP::Put.new(path)
|
|
159
|
+
default_headers.each { |key, value| request[key] = value }
|
|
160
|
+
request.body = body.to_json
|
|
161
|
+
request
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def build_patch_request(path, body:)
|
|
165
|
+
request = Net::HTTP::Patch.new(path)
|
|
166
|
+
default_headers.each { |key, value| request[key] = value }
|
|
167
|
+
request.body = body.to_json
|
|
168
|
+
request
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def build_delete_request(path)
|
|
172
|
+
request = Net::HTTP::Delete.new(path)
|
|
173
|
+
default_headers.each { |key, value| request[key] = value }
|
|
174
|
+
request
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def encode_query(params)
|
|
178
|
+
params.flat_map do |key, value|
|
|
179
|
+
if value.is_a?(Array)
|
|
180
|
+
value.map { |v| "#{URI.encode_www_form_component(key.to_s)}=#{URI.encode_www_form_component(v.to_s)}" }
|
|
181
|
+
else
|
|
182
|
+
"#{URI.encode_www_form_component(key.to_s)}=#{URI.encode_www_form_component(value.to_s)}"
|
|
183
|
+
end
|
|
184
|
+
end.join('&')
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|