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.
@@ -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
@@ -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