buda_api 1.0.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,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require "json"
6
+ require "openssl"
7
+ require "base64"
8
+ require "time"
9
+
10
+ module BudaApi
11
+ # Base client class providing core HTTP functionality and error handling
12
+ class Client
13
+ include Constants
14
+
15
+ attr_reader :base_url, :timeout, :retries, :debug_mode
16
+
17
+ DEFAULT_OPTIONS = {
18
+ base_url: "https://www.buda.com/api/v2/",
19
+ timeout: 30,
20
+ retries: 3,
21
+ debug_mode: false
22
+ }.freeze
23
+
24
+ def initialize(options = {})
25
+ @options = DEFAULT_OPTIONS.merge(options)
26
+ @base_url = @options[:base_url]
27
+ @timeout = @options[:timeout]
28
+ @retries = @options[:retries]
29
+ @debug_mode = @options[:debug_mode]
30
+
31
+ setup_logger
32
+ setup_http_client
33
+ end
34
+
35
+ protected
36
+
37
+ # Make a GET request
38
+ # @param path [String] API endpoint path
39
+ # @param params [Hash] query parameters
40
+ # @return [Hash] parsed response body
41
+ def get(path, params = {})
42
+ make_request(:get, path, params: params)
43
+ end
44
+
45
+ # Make a POST request
46
+ # @param path [String] API endpoint path
47
+ # @param body [Hash] request body
48
+ # @param params [Hash] query parameters
49
+ # @return [Hash] parsed response body
50
+ def post(path, body: {}, params: {})
51
+ make_request(:post, path, body: body, params: params)
52
+ end
53
+
54
+ # Make a PUT request
55
+ # @param path [String] API endpoint path
56
+ # @param body [Hash] request body
57
+ # @param params [Hash] query parameters
58
+ # @return [Hash] parsed response body
59
+ def put(path, body: {}, params: {})
60
+ make_request(:put, path, body: body, params: params)
61
+ end
62
+
63
+ # Make a DELETE request
64
+ # @param path [String] API endpoint path
65
+ # @param params [Hash] query parameters
66
+ # @return [Hash] parsed response body
67
+ def delete(path, params = {})
68
+ make_request(:delete, path, params: params)
69
+ end
70
+
71
+ private
72
+
73
+ def setup_logger
74
+ log_level = @debug_mode ? :debug : BudaApi.configuration.logger_level
75
+ BudaApi::Logger.setup(level: log_level)
76
+ end
77
+
78
+ def setup_http_client
79
+ @http_client = Faraday.new(url: @base_url) do |f|
80
+ f.options.timeout = @timeout
81
+ f.options.open_timeout = @timeout / 2
82
+
83
+ f.request :json
84
+ f.request :retry,
85
+ max: @retries,
86
+ interval: 0.5,
87
+ backoff_factor: 2,
88
+ retry_statuses: [408, 429, 500, 502, 503, 504],
89
+ methods: [:get, :post, :put, :delete]
90
+
91
+ f.response :json, content_type: /\bjson$/
92
+ f.adapter :net_http
93
+ end
94
+ end
95
+
96
+ def make_request(method, path, body: nil, params: {}, headers: {})
97
+ start_time = Time.now
98
+ full_url = build_url(path, params)
99
+
100
+ # Add authentication headers if this is an authenticated client
101
+ headers = add_authentication_headers(method, path, body, headers) if respond_to?(:add_authentication_headers, true)
102
+
103
+ BudaApi::Logger.log_request(method, full_url, headers: headers, body: body)
104
+
105
+ response = @http_client.public_send(method) do |req|
106
+ req.url path
107
+ req.params = params if params.any?
108
+ req.headers.merge!(headers)
109
+ req.body = body.to_json if body && !body.empty?
110
+ end
111
+
112
+ duration = ((Time.now - start_time) * 1000).round(2)
113
+ BudaApi::Logger.log_response(
114
+ response.status,
115
+ headers: response.headers,
116
+ body: response.body,
117
+ duration: duration
118
+ )
119
+
120
+ handle_response(response)
121
+
122
+ rescue Faraday::ConnectionFailed => e
123
+ error = ConnectionError.new("Connection failed: #{e.message}")
124
+ BudaApi::Logger.log_error(error, context: { method: method, path: path })
125
+ raise error
126
+
127
+ rescue Faraday::TimeoutError => e
128
+ error = TimeoutError.new("Request timed out: #{e.message}")
129
+ BudaApi::Logger.log_error(error, context: { method: method, path: path })
130
+ raise error
131
+
132
+ rescue StandardError => e
133
+ error = ApiError.new("Unexpected error: #{e.message}")
134
+ BudaApi::Logger.log_error(error, context: { method: method, path: path })
135
+ raise error
136
+ end
137
+
138
+ def build_url(path, params)
139
+ url = @base_url + path.to_s
140
+ return url if params.empty?
141
+
142
+ query_string = params.map { |k, v| "#{k}=#{v}" }.join("&")
143
+ "#{url}?#{query_string}"
144
+ end
145
+
146
+ def handle_response(response)
147
+ case response.status
148
+ when HttpStatus::OK, HttpStatus::CREATED
149
+ validate_and_parse_response(response)
150
+ when HttpStatus::BAD_REQUEST
151
+ handle_error_response(BadRequestError, response, "Bad request")
152
+ when HttpStatus::UNAUTHORIZED
153
+ handle_error_response(AuthenticationError, response, "Authentication failed")
154
+ when HttpStatus::FORBIDDEN
155
+ handle_error_response(AuthorizationError, response, "Forbidden - insufficient permissions")
156
+ when HttpStatus::NOT_FOUND
157
+ handle_error_response(NotFoundError, response, "Resource not found")
158
+ when HttpStatus::UNPROCESSABLE_ENTITY
159
+ handle_error_response(ValidationError, response, "Validation failed")
160
+ when HttpStatus::RATE_LIMITED
161
+ handle_error_response(RateLimitError, response, "Rate limit exceeded")
162
+ when HttpStatus::INTERNAL_SERVER_ERROR..HttpStatus::GATEWAY_TIMEOUT
163
+ handle_error_response(ServerError, response, "Server error")
164
+ else
165
+ handle_error_response(ApiError, response, "Unknown error")
166
+ end
167
+ end
168
+
169
+ def validate_and_parse_response(response)
170
+ body = response.body
171
+
172
+ if body.nil? || body.empty?
173
+ raise InvalidResponseError.new("Empty response body", status_code: response.status)
174
+ end
175
+
176
+ unless body.is_a?(Hash)
177
+ raise InvalidResponseError.new("Invalid response format",
178
+ status_code: response.status,
179
+ response_body: body)
180
+ end
181
+
182
+ # Check for API error in successful HTTP response
183
+ if body.key?("message") && body["message"]&.match?(/error/i)
184
+ raise ApiError.new(body["message"],
185
+ status_code: response.status,
186
+ response_body: body)
187
+ end
188
+
189
+ body
190
+ end
191
+
192
+ def handle_error_response(error_class, response, default_message)
193
+ error_message = extract_error_message(response.body) || default_message
194
+
195
+ raise error_class.new(
196
+ error_message,
197
+ status_code: response.status,
198
+ response_body: response.body,
199
+ response_headers: response.headers.to_h
200
+ )
201
+ end
202
+
203
+ def extract_error_message(body)
204
+ return nil unless body.is_a?(Hash)
205
+
206
+ # Try different common error message fields
207
+ %w[message error error_message detail details].each do |field|
208
+ return body[field] if body[field].is_a?(String) && !body[field].empty?
209
+ end
210
+
211
+ # Handle nested error structures
212
+ if body["errors"].is_a?(Array) && body["errors"].any?
213
+ return body["errors"].first if body["errors"].first.is_a?(String)
214
+ return body["errors"].first["message"] if body["errors"].first.is_a?(Hash)
215
+ end
216
+
217
+ nil
218
+ end
219
+
220
+ # Validate required parameters
221
+ def validate_required_params(params, required_fields)
222
+ missing = required_fields.select { |field| params[field].nil? || params[field].to_s.empty? }
223
+ return if missing.empty?
224
+
225
+ raise ValidationError, "Missing required parameters: #{missing.join(', ')}"
226
+ end
227
+
228
+ # Validate parameter values against allowed options
229
+ def validate_param_values(params, validations)
230
+ validations.each do |param, allowed_values|
231
+ value = params[param]
232
+ next if value.nil?
233
+
234
+ unless allowed_values.include?(value)
235
+ raise ValidationError,
236
+ "Invalid #{param}: '#{value}'. Must be one of: #{allowed_values.join(', ')}"
237
+ end
238
+ end
239
+ end
240
+
241
+ # Clean and normalize parameters
242
+ def normalize_params(params)
243
+ params.reject { |_, v| v.nil? || v.to_s.empty? }
244
+ .transform_keys(&:to_s)
245
+ .transform_values { |v| v.is_a?(Symbol) ? v.to_s : v }
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BudaApi
4
+ # Constants and enums for the Buda API
5
+ module Constants
6
+ # Supported currencies
7
+ module Currency
8
+ ARS = "ARS" # Argentine Peso
9
+ BCH = "BCH" # Bitcoin Cash
10
+ BTC = "BTC" # Bitcoin
11
+ CLP = "CLP" # Chilean Peso
12
+ COP = "COP" # Colombian Peso
13
+ ETH = "ETH" # Ethereum
14
+ LTC = "LTC" # Litecoin
15
+ PEN = "PEN" # Peruvian Sol
16
+ USDC = "USDC" # USD Coin
17
+
18
+ ALL = [ARS, BCH, BTC, CLP, COP, ETH, LTC, PEN, USDC].freeze
19
+
20
+ # Currency decimal places
21
+ DECIMALS = {
22
+ ARS => 2,
23
+ BCH => 8,
24
+ BTC => 8,
25
+ CLP => 2,
26
+ COP => 2,
27
+ ETH => 9,
28
+ LTC => 8,
29
+ PEN => 2,
30
+ USDC => 2
31
+ }.freeze
32
+ end
33
+
34
+ # Supported trading pairs
35
+ module Market
36
+ # Bitcoin pairs
37
+ BTC_ARS = "BTC-ARS"
38
+ BTC_CLP = "BTC-CLP"
39
+ BTC_COP = "BTC-COP"
40
+ BTC_PEN = "BTC-PEN"
41
+ BTC_USDC = "BTC-USDC"
42
+
43
+ # Ethereum pairs
44
+ ETH_ARS = "ETH-ARS"
45
+ ETH_BTC = "ETH-BTC"
46
+ ETH_CLP = "ETH-CLP"
47
+ ETH_COP = "ETH-COP"
48
+ ETH_PEN = "ETH-PEN"
49
+
50
+ # Bitcoin Cash pairs
51
+ BCH_ARS = "BCH-ARS"
52
+ BCH_BTC = "BCH-BTC"
53
+ BCH_CLP = "BCH-CLP"
54
+ BCH_COP = "BCH-COP"
55
+ BCH_PEN = "BCH-PEN"
56
+
57
+ # Litecoin pairs
58
+ LTC_ARS = "LTC-ARS"
59
+ LTC_BTC = "LTC-BTC"
60
+ LTC_CLP = "LTC-CLP"
61
+ LTC_COP = "LTC-COP"
62
+ LTC_PEN = "LTC-PEN"
63
+
64
+ # USDC pairs
65
+ USDC_ARS = "USDC-ARS"
66
+ USDC_CLP = "USDC-CLP"
67
+ USDC_COP = "USDC-COP"
68
+ USDC_PEN = "USDC-PEN"
69
+
70
+ ALL = [
71
+ BTC_ARS, BTC_CLP, BTC_COP, BTC_PEN, BTC_USDC,
72
+ ETH_ARS, ETH_BTC, ETH_CLP, ETH_COP, ETH_PEN,
73
+ BCH_ARS, BCH_BTC, BCH_CLP, BCH_COP, BCH_PEN,
74
+ LTC_ARS, LTC_BTC, LTC_CLP, LTC_COP, LTC_PEN,
75
+ USDC_ARS, USDC_CLP, USDC_COP, USDC_PEN
76
+ ].freeze
77
+ end
78
+
79
+ # Order types
80
+ module OrderType
81
+ ASK = "Ask" # Sell order
82
+ BID = "Bid" # Buy order
83
+
84
+ ALL = [ASK, BID].freeze
85
+ end
86
+
87
+ # Price types for orders
88
+ module PriceType
89
+ MARKET = "market"
90
+ LIMIT = "limit"
91
+
92
+ ALL = [MARKET, LIMIT].freeze
93
+ end
94
+
95
+ # Order states
96
+ module OrderState
97
+ RECEIVED = "received"
98
+ PENDING = "pending"
99
+ TRADED = "traded"
100
+ CANCELING = "canceling"
101
+ CANCELED = "canceled"
102
+
103
+ ALL = [RECEIVED, PENDING, TRADED, CANCELING, CANCELED].freeze
104
+ end
105
+
106
+ # Quotation types
107
+ module QuotationType
108
+ BID_GIVEN_SIZE = "bid_given_size"
109
+ BID_GIVEN_EARNED_BASE = "bid_given_earned_base"
110
+ BID_GIVEN_SPENT_QUOTE = "bid_given_spent_quote"
111
+ ASK_GIVEN_SIZE = "ask_given_size"
112
+ ASK_GIVEN_EARNED_QUOTE = "ask_given_earned_quote"
113
+ ASK_GIVEN_SPENT_BASE = "ask_given_spent_base"
114
+
115
+ ALL = [
116
+ BID_GIVEN_SIZE, BID_GIVEN_EARNED_BASE, BID_GIVEN_SPENT_QUOTE,
117
+ ASK_GIVEN_SIZE, ASK_GIVEN_EARNED_QUOTE, ASK_GIVEN_SPENT_BASE
118
+ ].freeze
119
+ end
120
+
121
+ # Balance event types
122
+ module BalanceEvent
123
+ DEPOSIT_CONFIRM = "deposit_confirm"
124
+ WITHDRAWAL_CONFIRM = "withdrawal_confirm"
125
+ TRANSACTION = "transaction"
126
+ TRANSFER_CONFIRMATION = "transfer_confirmation"
127
+
128
+ ALL = [DEPOSIT_CONFIRM, WITHDRAWAL_CONFIRM, TRANSACTION, TRANSFER_CONFIRMATION].freeze
129
+ end
130
+
131
+ # Report types
132
+ module ReportType
133
+ AVERAGE_PRICES = "average_prices"
134
+ CANDLESTICK = "candlestick"
135
+
136
+ ALL = [AVERAGE_PRICES, CANDLESTICK].freeze
137
+ end
138
+
139
+ # API limits
140
+ module Limits
141
+ ORDERS_PER_PAGE = 300
142
+ TRANSFERS_PER_PAGE = 300
143
+ DEFAULT_TIMEOUT = 30
144
+ MAX_RETRIES = 3
145
+ end
146
+
147
+ # HTTP status codes
148
+ module HttpStatus
149
+ OK = 200
150
+ CREATED = 201
151
+ BAD_REQUEST = 400
152
+ UNAUTHORIZED = 401
153
+ FORBIDDEN = 403
154
+ NOT_FOUND = 404
155
+ UNPROCESSABLE_ENTITY = 422
156
+ RATE_LIMITED = 429
157
+ INTERNAL_SERVER_ERROR = 500
158
+ BAD_GATEWAY = 502
159
+ SERVICE_UNAVAILABLE = 503
160
+ GATEWAY_TIMEOUT = 504
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BudaApi
4
+ # Custom error classes for the Buda API SDK
5
+ module Errors
6
+ # Base error class for all API errors
7
+ class ApiError < StandardError
8
+ attr_reader :status_code, :response_body, :response_headers
9
+
10
+ def initialize(message, status_code: nil, response_body: nil, response_headers: nil)
11
+ super(message)
12
+ @status_code = status_code
13
+ @response_body = response_body
14
+ @response_headers = response_headers
15
+ end
16
+
17
+ def to_s
18
+ msg = super
19
+ msg += " (HTTP #{@status_code})" if @status_code
20
+ msg
21
+ end
22
+ end
23
+
24
+ # Authentication related errors
25
+ class AuthenticationError < ApiError; end
26
+
27
+ # Authorization/permission related errors
28
+ class AuthorizationError < ApiError; end
29
+
30
+ # Rate limiting errors
31
+ class RateLimitError < ApiError; end
32
+
33
+ # Invalid request errors (4xx)
34
+ class BadRequestError < ApiError; end
35
+
36
+ # Resource not found errors
37
+ class NotFoundError < ApiError; end
38
+
39
+ # Server errors (5xx)
40
+ class ServerError < ApiError; end
41
+
42
+ # Network/connection related errors
43
+ class ConnectionError < ApiError; end
44
+
45
+ # Request timeout errors
46
+ class TimeoutError < ApiError; end
47
+
48
+ # Invalid response format errors
49
+ class InvalidResponseError < ApiError; end
50
+
51
+ # Configuration errors
52
+ class ConfigurationError < StandardError; end
53
+
54
+ # Validation errors for request parameters
55
+ class ValidationError < StandardError; end
56
+ end
57
+
58
+ # Include all error classes at the module level for convenience
59
+ include Errors
60
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module BudaApi
6
+ # Centralized logging functionality for the SDK
7
+ class Logger
8
+ LOG_LEVELS = {
9
+ debug: ::Logger::DEBUG,
10
+ info: ::Logger::INFO,
11
+ warn: ::Logger::WARN,
12
+ error: ::Logger::ERROR,
13
+ fatal: ::Logger::FATAL
14
+ }.freeze
15
+
16
+ class << self
17
+ attr_accessor :logger
18
+
19
+ # Initialize the logger
20
+ def setup(level: :info, output: $stdout)
21
+ @logger = ::Logger.new(output)
22
+ @logger.level = LOG_LEVELS[level] || LOG_LEVELS[:info]
23
+ @logger.formatter = proc do |severity, datetime, progname, msg|
24
+ "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity.ljust(5)} BudaApi: #{msg}\n"
25
+ end
26
+ @logger
27
+ end
28
+
29
+ # Log debug messages
30
+ def debug(message)
31
+ current_logger.debug(message)
32
+ end
33
+
34
+ # Log info messages
35
+ def info(message)
36
+ current_logger.info(message)
37
+ end
38
+
39
+ # Log warning messages
40
+ def warn(message)
41
+ current_logger.warn(message)
42
+ end
43
+
44
+ # Log error messages
45
+ def error(message)
46
+ current_logger.error(message)
47
+ end
48
+
49
+ # Log fatal messages
50
+ def fatal(message)
51
+ current_logger.fatal(message)
52
+ end
53
+
54
+ # Log HTTP requests in debug mode
55
+ def log_request(method, url, headers: {}, body: nil)
56
+ return unless debug_enabled?
57
+
58
+ debug("→ #{method.upcase} #{url}")
59
+ debug("→ Headers: #{headers}") if headers&.any?
60
+ debug("→ Body: #{body}") if body
61
+ end
62
+
63
+ # Log HTTP responses in debug mode
64
+ def log_response(status, headers: {}, body: nil, duration: nil)
65
+ return unless debug_enabled?
66
+
67
+ debug("← #{status}")
68
+ debug("← Headers: #{headers}") if headers&.any?
69
+ debug("← Body: #{truncate_body(body)}") if body
70
+ debug("← Duration: #{duration}ms") if duration
71
+ end
72
+
73
+ # Log errors with full context
74
+ def log_error(error, context: {})
75
+ error_msg = "Error: #{error.class.name} - #{error.message}"
76
+ error_msg += "\nContext: #{context}" if context&.any?
77
+ error_msg += "\nBacktrace:\n #{error.backtrace.join("\n ")}" if error.backtrace
78
+
79
+ error(error_msg)
80
+ end
81
+
82
+ private
83
+
84
+ def current_logger
85
+ @logger || setup
86
+ end
87
+
88
+ def debug_enabled?
89
+ current_logger.level <= ::Logger::DEBUG
90
+ end
91
+
92
+ def truncate_body(body, limit: 1000)
93
+ return body unless body.is_a?(String) && body.length > limit
94
+
95
+ "#{body[0...limit]}... (truncated)"
96
+ end
97
+ end
98
+ end
99
+ end