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,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