anvil-ruby 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/AGENTS.md +123 -0
- data/API_COVERAGE.md +294 -0
- data/CHANGELOG.md +34 -0
- data/CLAUDE_README.md +98 -0
- data/GITHUB_ACTIONS_QUICKREF.md +174 -0
- data/Gemfile.lock +112 -0
- data/Gemfile.minimal +9 -0
- data/LICENSE +21 -0
- data/PROJECT_CONTEXT.md +196 -0
- data/README.md +445 -0
- data/anvil-ruby.gemspec +66 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/create_signature_direct.rb +142 -0
- data/create_signature_packet.rb +232 -0
- data/debug_env.rb +28 -0
- data/examples/create_signature.rb +194 -0
- data/examples/fill_pdf.rb +89 -0
- data/examples/generate_pdf.rb +347 -0
- data/examples/verify_webhook.rb +281 -0
- data/lib/anvil/client.rb +216 -0
- data/lib/anvil/configuration.rb +87 -0
- data/lib/anvil/env_loader.rb +30 -0
- data/lib/anvil/errors.rb +95 -0
- data/lib/anvil/rate_limiter.rb +66 -0
- data/lib/anvil/resources/base.rb +100 -0
- data/lib/anvil/resources/pdf.rb +171 -0
- data/lib/anvil/resources/signature.rb +517 -0
- data/lib/anvil/resources/webform.rb +154 -0
- data/lib/anvil/resources/webhook.rb +201 -0
- data/lib/anvil/resources/workflow.rb +169 -0
- data/lib/anvil/response.rb +98 -0
- data/lib/anvil/version.rb +5 -0
- data/lib/anvil.rb +88 -0
- data/quickstart_signature.rb +220 -0
- data/test_api_connection.rb +143 -0
- data/test_etch_signature.rb +230 -0
- data/test_gem.rb +72 -0
- data/test_signature.rb +281 -0
- data/test_signature_with_template.rb +112 -0
- metadata +247 -0
data/lib/anvil/client.rb
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Try to load multipart support but don't fail if not available
|
|
4
|
+
begin
|
|
5
|
+
require 'net/http/post/multipart'
|
|
6
|
+
rescue LoadError
|
|
7
|
+
# Multipart support is optional
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module Anvil
|
|
11
|
+
class Client
|
|
12
|
+
attr_reader :config, :rate_limiter
|
|
13
|
+
|
|
14
|
+
def initialize(api_key: nil, config: nil)
|
|
15
|
+
@config = config || Anvil.configuration.dup
|
|
16
|
+
@config.api_key = api_key if api_key
|
|
17
|
+
@config.validate!
|
|
18
|
+
@rate_limiter = RateLimiter.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def get(path, params = {}, options = {})
|
|
22
|
+
request(:get, path, params: params, **options)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def post(path, data = {}, options = {})
|
|
26
|
+
request(:post, path, body: data, **options)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def put(path, data = {}, options = {})
|
|
30
|
+
request(:put, path, body: data, **options)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def delete(path, params = {}, options = {})
|
|
34
|
+
request(:delete, path, params: params, **options)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Execute a raw GraphQL request
|
|
38
|
+
#
|
|
39
|
+
# @param query_string [String] The GraphQL query or mutation string
|
|
40
|
+
# @param variables [Hash] Variables to pass to the query
|
|
41
|
+
# @return [Hash] The parsed response data
|
|
42
|
+
def graphql(query_string, variables: {})
|
|
43
|
+
response = post(config.graphql_url, {
|
|
44
|
+
query: query_string,
|
|
45
|
+
variables: variables
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
body = response.body
|
|
49
|
+
body = response.data if body.is_a?(Anvil::Response)
|
|
50
|
+
|
|
51
|
+
if body.is_a?(Hash) && body[:errors] && !body[:errors].empty?
|
|
52
|
+
messages = body[:errors].map { |e| e[:message] || e['message'] }.compact
|
|
53
|
+
raise GraphQLError.new(
|
|
54
|
+
"GraphQL errors: #{messages.join(', ')}",
|
|
55
|
+
response,
|
|
56
|
+
graphql_errors: body[:errors]
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
body.is_a?(Hash) ? body[:data] : body
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Execute a GraphQL query
|
|
64
|
+
#
|
|
65
|
+
# @param query [String] The GraphQL query string
|
|
66
|
+
# @param variables [Hash] Variables to pass to the query
|
|
67
|
+
# @return [Hash] The query result data
|
|
68
|
+
def query(query:, variables: {})
|
|
69
|
+
graphql(query, variables: variables)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Execute a GraphQL mutation
|
|
73
|
+
#
|
|
74
|
+
# @param mutation [String] The GraphQL mutation string
|
|
75
|
+
# @param variables [Hash] Variables to pass to the mutation
|
|
76
|
+
# @return [Hash] The mutation result data
|
|
77
|
+
def mutation(mutation:, variables: {})
|
|
78
|
+
graphql(mutation, variables: variables)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def request(method, path, params: nil, body: nil, headers: {}, **_options)
|
|
84
|
+
uri = build_uri(path, params)
|
|
85
|
+
|
|
86
|
+
rate_limiter.with_retry do
|
|
87
|
+
http = build_http(uri)
|
|
88
|
+
request = build_request(method, uri, body, headers)
|
|
89
|
+
|
|
90
|
+
response = http.request(request)
|
|
91
|
+
wrapped_response = Response.new(response)
|
|
92
|
+
|
|
93
|
+
handle_response(wrapped_response)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def build_uri(path, params)
|
|
98
|
+
uri = URI.parse(path.start_with?('http') ? path : "#{config.base_url}#{path}")
|
|
99
|
+
|
|
100
|
+
uri.query = URI.encode_www_form(params) if params && !params.empty?
|
|
101
|
+
|
|
102
|
+
uri
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def build_http(uri)
|
|
106
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
107
|
+
http.use_ssl = true
|
|
108
|
+
http.open_timeout = config.open_timeout
|
|
109
|
+
http.read_timeout = config.timeout
|
|
110
|
+
|
|
111
|
+
# Debug output only if explicitly requested
|
|
112
|
+
# Uncomment for debugging:
|
|
113
|
+
# http.set_debug_output($stdout)
|
|
114
|
+
|
|
115
|
+
http
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build_request(method, uri, body, headers)
|
|
119
|
+
klass = case method.to_sym
|
|
120
|
+
when :get then Net::HTTP::Get
|
|
121
|
+
when :post then Net::HTTP::Post
|
|
122
|
+
when :put then Net::HTTP::Put
|
|
123
|
+
when :delete then Net::HTTP::Delete
|
|
124
|
+
else
|
|
125
|
+
raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
request = klass.new(uri.request_uri)
|
|
129
|
+
|
|
130
|
+
# Set authentication
|
|
131
|
+
request.basic_auth(config.api_key, '')
|
|
132
|
+
|
|
133
|
+
# Set headers
|
|
134
|
+
default_headers.merge(headers).each do |key, value|
|
|
135
|
+
request[key] = value
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Set body
|
|
139
|
+
if body
|
|
140
|
+
if body.is_a?(Hash)
|
|
141
|
+
request['Content-Type'] = 'application/json'
|
|
142
|
+
request.body = JSON.generate(body)
|
|
143
|
+
elsif body.is_a?(String)
|
|
144
|
+
request.body = body
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
request
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def default_headers
|
|
152
|
+
{
|
|
153
|
+
'User-Agent' => "Anvil Ruby/#{Anvil::VERSION}",
|
|
154
|
+
'Accept' => 'application/json'
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def handle_response(response)
|
|
159
|
+
return response if response.success?
|
|
160
|
+
|
|
161
|
+
case response.code
|
|
162
|
+
when 400
|
|
163
|
+
raise ValidationError.new(response.error_message, response)
|
|
164
|
+
when 401
|
|
165
|
+
raise AuthenticationError.new(
|
|
166
|
+
'Invalid API key. Check your API key at https://app.useanvil.com',
|
|
167
|
+
response
|
|
168
|
+
)
|
|
169
|
+
when 404
|
|
170
|
+
raise NotFoundError.new(response.error_message, response)
|
|
171
|
+
when 429
|
|
172
|
+
# This should be handled by rate_limiter, but just in case
|
|
173
|
+
raise RateLimitError.new('Rate limit exceeded', response)
|
|
174
|
+
when 500..599
|
|
175
|
+
raise ServerError.new(
|
|
176
|
+
"Server error: #{response.error_message}",
|
|
177
|
+
response
|
|
178
|
+
)
|
|
179
|
+
else
|
|
180
|
+
raise APIError.new(response.error_message, response)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Special method for multipart uploads (requires multipart-post gem as optional dependency)
|
|
185
|
+
def post_multipart(path, params = {}, files = {})
|
|
186
|
+
unless defined?(Net::HTTP::Post::Multipart)
|
|
187
|
+
raise LoadError, 'multipart-post gem is required for file uploads. Add it to your Gemfile.'
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
uri = build_uri(path, nil)
|
|
191
|
+
|
|
192
|
+
# Convert files to UploadIO objects
|
|
193
|
+
upload_params = params.dup
|
|
194
|
+
files.each do |key, file|
|
|
195
|
+
next unless file.respond_to?(:read)
|
|
196
|
+
|
|
197
|
+
upload_params[key] = UploadIO.new(
|
|
198
|
+
file,
|
|
199
|
+
'application/octet-stream',
|
|
200
|
+
File.basename(file.path)
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
rate_limiter.with_retry do
|
|
205
|
+
http = build_http(uri)
|
|
206
|
+
request = Net::HTTP::Post::Multipart.new(uri.path, upload_params)
|
|
207
|
+
request.basic_auth(config.api_key, '')
|
|
208
|
+
|
|
209
|
+
response = http.request(request)
|
|
210
|
+
wrapped_response = Response.new(response)
|
|
211
|
+
|
|
212
|
+
handle_response(wrapped_response)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anvil
|
|
4
|
+
class Configuration
|
|
5
|
+
ENVIRONMENTS = %i[development production].freeze
|
|
6
|
+
|
|
7
|
+
attr_accessor :api_key, :base_url, :graphql_url, :timeout, :open_timeout
|
|
8
|
+
attr_reader :environment
|
|
9
|
+
attr_writer :webhook_token
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@environment = default_environment
|
|
13
|
+
@base_url = 'https://app.useanvil.com/api/v1'
|
|
14
|
+
@graphql_url = 'https://graphql.useanvil.com/' # GraphQL endpoint
|
|
15
|
+
@timeout = 120 # Read timeout in seconds
|
|
16
|
+
@open_timeout = 30 # Connection open timeout
|
|
17
|
+
@api_key = ENV.fetch('ANVIL_API_KEY', nil)
|
|
18
|
+
@webhook_token = ENV.fetch('ANVIL_WEBHOOK_TOKEN', nil)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def environment=(env)
|
|
22
|
+
env = env.to_sym
|
|
23
|
+
unless ENVIRONMENTS.include?(env)
|
|
24
|
+
raise ArgumentError, "Invalid environment: #{env}. Must be one of: #{ENVIRONMENTS.join(', ')}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@environment = env
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def development?
|
|
31
|
+
environment == :development
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def production?
|
|
35
|
+
environment == :production
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def webhook_token
|
|
39
|
+
@webhook_token || ENV.fetch('ANVIL_WEBHOOK_TOKEN', nil)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Rate limits based on environment and plan
|
|
43
|
+
def rate_limit
|
|
44
|
+
return 4 if development?
|
|
45
|
+
|
|
46
|
+
# Default production rate limit
|
|
47
|
+
# Can be overridden if needed for custom plans
|
|
48
|
+
4
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate!
|
|
52
|
+
return unless api_key.nil? || api_key.empty?
|
|
53
|
+
|
|
54
|
+
raise Anvil::ConfigurationError, <<~ERROR
|
|
55
|
+
No API key configured. Please set your API key using one of these methods:
|
|
56
|
+
|
|
57
|
+
1. Rails initializer (config/initializers/anvil.rb):
|
|
58
|
+
Anvil.configure do |config|
|
|
59
|
+
config.api_key = Rails.application.credentials.anvil[:api_key]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
2. Environment variable:
|
|
63
|
+
export ANVIL_API_KEY="your_api_key_here"
|
|
64
|
+
|
|
65
|
+
3. Direct assignment:
|
|
66
|
+
Anvil.api_key = "your_api_key_here"
|
|
67
|
+
|
|
68
|
+
Get your API keys at: https://app.useanvil.com/organizations/settings/api
|
|
69
|
+
ERROR
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def default_environment
|
|
75
|
+
# Check Rails environment if Rails is defined
|
|
76
|
+
if defined?(Rails)
|
|
77
|
+
Rails.env.production? ? :production : :development
|
|
78
|
+
elsif ENV['ANVIL_ENV']
|
|
79
|
+
ENV['ANVIL_ENV'].to_sym
|
|
80
|
+
elsif ENV['RACK_ENV']
|
|
81
|
+
ENV['RACK_ENV'] == 'production' ? :production : :development
|
|
82
|
+
else
|
|
83
|
+
:production
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anvil
|
|
4
|
+
# Simple .env file loader (no dependencies required!)
|
|
5
|
+
class EnvLoader
|
|
6
|
+
def self.load(path = '.env')
|
|
7
|
+
return unless File.exist?(path)
|
|
8
|
+
|
|
9
|
+
File.readlines(path).each do |line|
|
|
10
|
+
line = line.chomp # Remove newline
|
|
11
|
+
|
|
12
|
+
# Skip comments and empty lines
|
|
13
|
+
next if line.strip.empty? || line.strip.start_with?('#')
|
|
14
|
+
|
|
15
|
+
# Parse KEY=value format
|
|
16
|
+
next unless line =~ /\A([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\z/
|
|
17
|
+
|
|
18
|
+
key = ::Regexp.last_match(1)
|
|
19
|
+
value = ::Regexp.last_match(2).strip
|
|
20
|
+
|
|
21
|
+
# Remove quotes if present
|
|
22
|
+
value = value[1..-2] if (value.start_with?('"') && value.end_with?('"')) ||
|
|
23
|
+
(value.start_with?("'") && value.end_with?("'"))
|
|
24
|
+
|
|
25
|
+
# Set environment variable
|
|
26
|
+
ENV[key] = value
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/anvil/errors.rb
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anvil
|
|
4
|
+
# Base error class for all Anvil errors
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :response, :code
|
|
7
|
+
|
|
8
|
+
def initialize(message = nil, response = nil)
|
|
9
|
+
@response = response
|
|
10
|
+
@code = response&.code
|
|
11
|
+
super(message || default_message)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def default_message
|
|
17
|
+
'An error occurred with the Anvil API'
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Configuration errors
|
|
22
|
+
class ConfigurationError < Error; end
|
|
23
|
+
|
|
24
|
+
# API errors
|
|
25
|
+
class APIError < Error
|
|
26
|
+
attr_reader :status_code, :errors
|
|
27
|
+
|
|
28
|
+
def initialize(message, response = nil)
|
|
29
|
+
@status_code = response&.code&.to_i
|
|
30
|
+
@errors = parse_errors(response) if response
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def parse_errors(response)
|
|
37
|
+
data = extract_response_data(response)
|
|
38
|
+
return [] unless data
|
|
39
|
+
|
|
40
|
+
data[:errors] || data['errors'] || data[:fields] || data['fields'] || []
|
|
41
|
+
rescue JSON::ParserError
|
|
42
|
+
[]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def extract_response_data(response)
|
|
46
|
+
return nil unless response
|
|
47
|
+
|
|
48
|
+
body = response.respond_to?(:body) ? response.body : response
|
|
49
|
+
return nil unless body.is_a?(String) || body.is_a?(Hash)
|
|
50
|
+
return nil if body.respond_to?(:empty?) && body.empty?
|
|
51
|
+
|
|
52
|
+
body.is_a?(Hash) ? body : JSON.parse(body)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Specific API error types
|
|
57
|
+
class ValidationError < APIError; end
|
|
58
|
+
class AuthenticationError < APIError; end
|
|
59
|
+
|
|
60
|
+
class RateLimitError < APIError
|
|
61
|
+
attr_reader :retry_after
|
|
62
|
+
|
|
63
|
+
def initialize(message, response = nil)
|
|
64
|
+
super
|
|
65
|
+
@retry_after = response&.fetch('retry-after', nil)&.to_i if response
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class NotFoundError < APIError; end
|
|
70
|
+
class ServerError < APIError; end
|
|
71
|
+
|
|
72
|
+
# Network errors
|
|
73
|
+
class NetworkError < Error; end
|
|
74
|
+
class TimeoutError < NetworkError; end
|
|
75
|
+
class ConnectionError < NetworkError; end
|
|
76
|
+
|
|
77
|
+
# File errors
|
|
78
|
+
class FileError < Error; end
|
|
79
|
+
class FileNotFoundError < FileError; end
|
|
80
|
+
class FileTooLargeError < FileError; end
|
|
81
|
+
|
|
82
|
+
# GraphQL errors
|
|
83
|
+
class GraphQLError < APIError
|
|
84
|
+
attr_reader :graphql_errors
|
|
85
|
+
|
|
86
|
+
def initialize(message, response = nil, graphql_errors: [])
|
|
87
|
+
@graphql_errors = graphql_errors
|
|
88
|
+
super(message, response)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Webhook errors
|
|
93
|
+
class WebhookError < Error; end
|
|
94
|
+
class WebhookVerificationError < WebhookError; end
|
|
95
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anvil
|
|
4
|
+
class RateLimiter
|
|
5
|
+
MAX_RETRIES = 3
|
|
6
|
+
BASE_DELAY = 1.0 # Base delay in seconds
|
|
7
|
+
|
|
8
|
+
attr_reader :max_retries, :base_delay
|
|
9
|
+
|
|
10
|
+
def initialize(max_retries: MAX_RETRIES, base_delay: BASE_DELAY)
|
|
11
|
+
@max_retries = max_retries
|
|
12
|
+
@base_delay = base_delay
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def with_retry
|
|
16
|
+
retries = 0
|
|
17
|
+
last_error = nil
|
|
18
|
+
|
|
19
|
+
loop do
|
|
20
|
+
response = yield
|
|
21
|
+
|
|
22
|
+
# Check if we got rate limited
|
|
23
|
+
if response.code == 429
|
|
24
|
+
retries += 1
|
|
25
|
+
if retries > max_retries
|
|
26
|
+
raise RateLimitError.new(
|
|
27
|
+
"Rate limit exceeded after #{max_retries} retries",
|
|
28
|
+
response
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
delay = calculate_delay(response, retries)
|
|
33
|
+
sleep(delay)
|
|
34
|
+
next
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
return response
|
|
38
|
+
rescue Timeout::Error, Errno::ECONNREFUSED, Errno::ECONNRESET => e
|
|
39
|
+
last_error = e
|
|
40
|
+
retries += 1
|
|
41
|
+
|
|
42
|
+
raise NetworkError, "Network error after #{max_retries} retries: #{e.message}" if retries > max_retries
|
|
43
|
+
|
|
44
|
+
delay = exponential_backoff(retries)
|
|
45
|
+
sleep(delay)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def calculate_delay(response, retry_count)
|
|
52
|
+
# Use Retry-After header if available
|
|
53
|
+
if response.retry_after&.positive?
|
|
54
|
+
response.retry_after
|
|
55
|
+
else
|
|
56
|
+
exponential_backoff(retry_count)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def exponential_backoff(retry_count)
|
|
61
|
+
# Exponential backoff with jitter
|
|
62
|
+
delay = base_delay * (2**(retry_count - 1))
|
|
63
|
+
delay + (rand * delay * 0.1) # Add 10% jitter
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anvil
|
|
4
|
+
module Resources
|
|
5
|
+
class Base
|
|
6
|
+
attr_reader :attributes, :client
|
|
7
|
+
|
|
8
|
+
def initialize(attributes = {}, client: nil)
|
|
9
|
+
@attributes = symbolize_keys(attributes)
|
|
10
|
+
@client = client || default_client
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# ActiveRecord-like attribute accessors
|
|
14
|
+
def method_missing(method_name, *args)
|
|
15
|
+
if method_name.to_s.end_with?('=')
|
|
16
|
+
attribute = method_name.to_s.chomp('=').to_sym
|
|
17
|
+
attributes[attribute] = args.first
|
|
18
|
+
elsif attributes.key?(method_name)
|
|
19
|
+
attributes[method_name]
|
|
20
|
+
else
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
26
|
+
method_name.to_s.end_with?('=') || attributes.key?(method_name) || super
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h
|
|
30
|
+
attributes
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_json(*args)
|
|
34
|
+
attributes.to_json(*args)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def inspect
|
|
38
|
+
"#<#{self.class.name} #{attributes.inspect}>"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def ==(other)
|
|
42
|
+
other.is_a?(self.class) && attributes == other.attributes
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
protected
|
|
46
|
+
|
|
47
|
+
def default_client
|
|
48
|
+
@default_client ||= Client.new
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def symbolize_keys(hash)
|
|
52
|
+
return {} unless hash.is_a?(Hash)
|
|
53
|
+
|
|
54
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
55
|
+
sym_key = key.is_a?(String) ? key.to_sym : key
|
|
56
|
+
result[sym_key] = value.is_a?(Hash) ? symbolize_keys(value) : value
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class << self
|
|
61
|
+
def client
|
|
62
|
+
@client ||= Client.new
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
attr_writer :client
|
|
66
|
+
|
|
67
|
+
# Override in subclasses to provide resource-specific client
|
|
68
|
+
def with_client(api_key: nil)
|
|
69
|
+
original_client = @client
|
|
70
|
+
@client = Client.new(api_key: api_key)
|
|
71
|
+
yield
|
|
72
|
+
ensure
|
|
73
|
+
@client = original_client
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Helper for building resource instances from API responses
|
|
77
|
+
def build_from_response(response)
|
|
78
|
+
if response.data.is_a?(Array)
|
|
79
|
+
response.data.map { |item| new(item) }
|
|
80
|
+
else
|
|
81
|
+
new(response.data)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Common API operations
|
|
86
|
+
def find(id, client: nil)
|
|
87
|
+
raise NotImplementedError, "#{self.class.name}#find must be implemented by subclass"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def create(attributes = {}, client: nil)
|
|
91
|
+
raise NotImplementedError, "#{self.class.name}#create must be implemented by subclass"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def list(params = {}, client: nil)
|
|
95
|
+
raise NotImplementedError, "#{self.class.name}#list must be implemented by subclass"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|