twelvedata_ruby 0.2.2 → 0.4.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.
@@ -1,143 +1,314 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "csv"
4
+ require "json"
5
+ require "tempfile"
6
+
7
+ # Handles API responses from Twelve Data
4
8
  module TwelvedataRuby
5
9
  class Response
6
- CSV_COL_SEP = ";"
7
- BODY_MAX_BYTESIZE = 16_000
8
- HTTP_STATUSES = {http_error: (400..600), success: (200..299)}.freeze
9
- CONTENT_TYPE_HANDLERS = {
10
- json: {parser: :json_parser, dumper: :json_dumper},
11
- csv: {parser: :csv_parser, dumper: :csv_dumper},
12
- plain: {parser: :plain_parser, dumper: :to_s}
13
- }.freeze
14
-
15
- class << self
16
- def resolve(http_response, request)
17
- if http_status_codes.member?(http_response.status)
18
- new(http_response: http_response, request: request)
19
- else
20
- resolve_error(http_response, request)
21
- end
22
- end
10
+ # CSV column separator used by Twelve Data
11
+ CSV_COL_SEP = ";"
23
12
 
24
- def resolve_error(http_response, request)
25
- error_attribs = if http_response.respond_to?(:error) && http_response.error
26
- {message: http_response.error.message, code: http_response.error.class.name}
27
- else
28
- {message: http_response.body.to_s, code: http_response.status}
29
- end
30
- TwelvedataRuby::ResponseError.new(json: (error_attribs || {}), request: request)
31
- end
13
+ # Maximum response body size to keep in memory
14
+ BODY_MAX_BYTESIZE = 16_000
15
+
16
+ # HTTP status code ranges
17
+ HTTP_STATUSES = {
18
+ http_error: (400..600),
19
+ success: (200..299),
20
+ }.freeze
21
+
22
+ # Content type handlers for different response formats
23
+ CONTENT_TYPE_HANDLERS = {
24
+ json: { parser: :parse_json, dumper: :dump_json },
25
+ csv: { parser: :parse_csv, dumper: :dump_csv },
26
+ plain: { parser: :parse_plain, dumper: :to_s },
27
+ }.freeze
32
28
 
33
- def http_status_codes
34
- @http_status_codes ||= HTTP_STATUSES.values.map(&:to_a).flatten
29
+ class << self
30
+ # Resolve HTTP response into Response or ResponseError
31
+ #
32
+ # @param http_response [HTTPX::Response] HTTP response from client
33
+ # @param request [Request] Original request object
34
+ # @return [Response, ResponseError] Resolved response or error
35
+ def resolve(http_response, request)
36
+ if success_status?(http_response.status)
37
+ new(http_response: http_response, request: request)
38
+ else
39
+ create_error_from_response(http_response, request)
35
40
  end
41
+ rescue StandardError => e
42
+ ResponseError.new(
43
+ message: "Failed to resolve response: #{e.message}",
44
+ request: request,
45
+ original_error: e,
46
+ )
36
47
  end
37
48
 
38
- attr_reader :http_response, :headers, :body, :request
39
-
40
- def initialize(http_response:, request:, headers: nil, body: nil)
41
- self.http_response = http_response
42
- self.headers = headers || http_response.headers
43
- self.body = body || http_response.body
49
+ # Get all valid HTTP status codes
50
+ #
51
+ # @return [Array<Integer>] Array of valid status codes
52
+ def valid_status_codes
53
+ @valid_status_codes ||= HTTP_STATUSES.values.flat_map(&:to_a)
44
54
  end
45
55
 
46
- def attachment_filename
47
- return nil unless headers["content-disposition"]
56
+ private
48
57
 
49
- @attachment_filename ||= headers["content-disposition"].split("filename=").last.delete("\"")
58
+ def success_status?(status)
59
+ HTTP_STATUSES[:success].include?(status)
50
60
  end
51
61
 
52
- def body_parser
53
- CONTENT_TYPE_HANDLERS[content_type][:parser]
62
+ def create_error_from_response(http_response, request)
63
+ json_data = extract_error_data(http_response)
64
+ error_class = determine_error_class(http_response.status)
65
+ error_class.new(json_data:, request:, status_code: http_response.status, message: json_data[:message])
54
66
  end
55
67
 
56
- def content_type
57
- @content_type ||= headers["content-type"].match(%r{^.+/([a-z]+).*$})&.send(:[], 1)&.to_sym
68
+ def extract_error_data(http_response)
69
+ if http_response.respond_to?(:error) && http_response.error
70
+ {
71
+ message: http_response.error.message,
72
+ code: http_response.error.class.name,
73
+ }
74
+ else
75
+ {
76
+ message: http_response.body.to_s,
77
+ code: http_response.status,
78
+ }
79
+ end
58
80
  end
59
81
 
60
- def csv_parser(io_str=nil)
61
- CSV.parse(io_str || body.to_s, headers: true, col_sep: CSV_COL_SEP)
62
- end
82
+ def determine_error_class(status_code)
83
+ error_class_name = ResponseError.error_class_for_code(status_code, :http) ||
84
+ ResponseError.error_class_for_code(status_code, :api)
63
85
 
64
- def csv_dumper
65
- parsed_body.is_a?(CSV::Table) ? parsed_body.to_csv(col_sep: CSV_COL_SEP) : nil
86
+ if error_class_name
87
+ TwelvedataRuby.const_get(error_class_name)
88
+ else
89
+ ResponseError
90
+ end
66
91
  end
92
+ end
67
93
 
68
- def dumped_parsed_body
69
- @dumped_parsed_body ||=
70
- parsed_body.respond_to?(parsed_body_dumper) ? parsed_body.send(parsed_body_dumper) : send(parsed_body_dumper)
71
- end
94
+ attr_reader :http_response, :headers, :body_bytesize, :request
72
95
 
73
- def error
74
- klass_name = ResponseError.error_code_klass(status_code, success_http_status? ? :api : :http)
75
- return unless klass_name
76
- TwelvedataRuby.const_get(klass_name)
77
- .new(json: parsed_body, request: request, code: status_code, message: parsed_body)
78
- end
96
+ # Initialize response with HTTP response and request
97
+ #
98
+ # @param http_response [HTTPX::Response] HTTP response object
99
+ # @param request [Request] Original request object
100
+ def initialize(http_response:, request:)
101
+ @http_response = http_response
102
+ @request = request
103
+ @headers = http_response.headers
104
+ @body_bytesize = http_response.body.bytesize
105
+ @parsed_body = nil
106
+ end
79
107
 
80
- def http_status_code
81
- http_response&.status
82
- end
108
+ # Get attachment filename from response headers
109
+ #
110
+ # @return [String, nil] Filename if present in headers
111
+ def attachment_filename
112
+ return nil unless headers["content-disposition"]
83
113
 
84
- def json_dumper
85
- parsed_body.is_a?(Hash) ? JSON.dump(parsed_body) : nil
114
+ @attachment_filename ||= extract_filename_from_headers
115
+ end
116
+
117
+ # Get content type from response
118
+ #
119
+ # @return [Symbol, nil] Content type symbol (:json, :csv, :plain)
120
+ def content_type
121
+ @content_type ||= detect_content_type
122
+ end
123
+
124
+ # Get parsed response body
125
+ #
126
+ # @return [Hash, CSV::Table, String] Parsed response data
127
+ def parsed_body
128
+ @parsed_body ||= parse_response_body
129
+ end
130
+ alias body parsed_body
131
+
132
+ # Get response error if present
133
+ #
134
+ # @return [ResponseError, nil] Error object if response contains an error
135
+ def error
136
+ return nil unless parsed_body.is_a?(Hash) && parsed_body.key?(:code)
137
+
138
+ @error ||= create_response_error
139
+ end
140
+
141
+ # Get HTTP status code
142
+ #
143
+ # @return [Integer] HTTP status code
144
+ def http_status_code
145
+ http_response.status
146
+ end
147
+
148
+ # Get API status code (from response body)
149
+ #
150
+ # @return [Integer] API status code
151
+ def status_code
152
+ @status_code ||= extract_status_code
153
+ end
154
+
155
+ # Check if HTTP response was successful
156
+ #
157
+ # @return [Boolean] True if HTTP status indicates success
158
+ def success?
159
+ HTTP_STATUSES[:success].include?(http_status_code)
160
+ end
161
+
162
+ # Save response to disk file
163
+ #
164
+ # @param file_path [String] Path to save file (defaults to attachment filename)
165
+ # @return [File, nil] File object if successful, nil otherwise
166
+ def save_to_file(file_path = nil)
167
+ file_path ||= attachment_filename
168
+ return nil unless file_path
169
+
170
+ File.open(file_path, "w") do |file|
171
+ file.write(dump_parsed_body)
86
172
  end
173
+ rescue StandardError => e
174
+ raise ResponseError.new(
175
+ message: "Failed to save response to file: #{e.message}",
176
+ original_error: e,
177
+ )
178
+ end
87
179
 
88
- def json_parser(io_obj=nil)
89
- JSON.parse(io_obj || body.to_s, symbolize_names: true)
180
+ # Get dumped (serialized) version of parsed body
181
+ #
182
+ # @return [String] Serialized response body
183
+ def dump_parsed_body
184
+ handler = CONTENT_TYPE_HANDLERS[content_type]
185
+ return parsed_body.to_s unless handler
186
+
187
+ send(handler[:dumper])
188
+ end
189
+
190
+ # String representation of response
191
+ #
192
+ # @return [String] Response summary
193
+ def to_s
194
+ status_info = "#{http_status_code} (#{success? ? "success" : "error"})"
195
+ "Response: #{status_info}, Content-Type: #{content_type}, Size: #{body_bytesize} bytes"
196
+ end
197
+
198
+ # Detailed inspection of response
199
+ #
200
+ # @return [String] Detailed response information
201
+ def inspect
202
+ "#<#{self.class.name}:#{object_id} status=#{http_status_code} content_type=#{content_type} " \
203
+ "size=#{body_bytesize} error=#{error ? "yes" : "no"}>"
204
+ end
205
+
206
+ private
207
+
208
+ def extract_filename_from_headers
209
+ disposition = headers["content-disposition"]
210
+ return nil unless disposition
211
+
212
+ # Extract filename from Content-Disposition header
213
+ match = disposition.match(/filename="([^"]+)"/)
214
+ match ? match[1] : nil
215
+ end
216
+
217
+ def detect_content_type
218
+ content_type_header = headers["content-type"]
219
+ return :plain unless content_type_header
220
+
221
+ case content_type_header
222
+ when /json/
223
+ :json
224
+ when /csv/
225
+ :csv
226
+ else
227
+ :plain
90
228
  end
229
+ end
91
230
 
92
- def parsed_body
93
- return @parsed_body if @parsed_body
94
- if body.bytesize < BODY_MAX_BYTESIZE
95
- @parsed_body = send(body_parser)
231
+ def parse_response_body
232
+ return nil unless http_response.body
233
+
234
+ begin
235
+ if body_bytesize < BODY_MAX_BYTESIZE
236
+ parse_body_content(http_response.body.to_s)
96
237
  else
97
- tmp_file = Tempfile.new
98
- begin
99
- body.copy_to(tmp_file)
100
- tmp_file.close
101
- @parsed_body = send(body_parser, IO.read(tmp_file.path))
102
- ensure
103
- body.close
104
- tmp_file.close
105
- tmp_file.unlink
106
- end
238
+ parse_large_body_content
107
239
  end
108
- @parsed_body
240
+ ensure
241
+ http_response.body.close if http_response.body.respond_to?(:close)
109
242
  end
243
+ end
110
244
 
111
- def parsed_body_dumper
112
- CONTENT_TYPE_HANDLERS[content_type][:dumper]
113
- end
245
+ def parse_body_content(content)
246
+ handler = CONTENT_TYPE_HANDLERS[content_type]
247
+ return content unless handler
114
248
 
115
- def plain_parser
116
- body.to_s
117
- end
249
+ send(handler[:parser], content)
250
+ end
118
251
 
119
- def status_code
120
- @status_code ||= parsed_body.is_a?(Hash) ? parsed_body[:code] : http_status_code
252
+ def parse_large_body_content
253
+ Tempfile.create do |temp_file|
254
+ http_response.body.copy_to(temp_file)
255
+ temp_file.rewind
256
+ parse_body_content(temp_file.read)
121
257
  end
258
+ end
122
259
 
123
- def success_http_status?
124
- @success_http_status ||= HTTP_STATUSES[:success].member?(http_status_code) || false
125
- end
260
+ def parse_json(content)
261
+ JSON.parse(content, symbolize_names: true)
262
+ rescue JSON::ParserError => e
263
+ raise ResponseError.new(
264
+ message: "Failed to parse JSON response: #{e.message}",
265
+ original_error: e,
266
+ )
267
+ end
126
268
 
127
- def to_disk_file(file_fullpath=attachment_filename)
128
- return nil unless file_fullpath
269
+ def parse_csv(content)
270
+ CSV.parse(content, headers: true, col_sep: CSV_COL_SEP)
271
+ rescue CSV::MalformedCSVError => e
272
+ raise ResponseError.new(
273
+ message: "Failed to parse CSV response: #{e.message}",
274
+ original_error: e,
275
+ )
276
+ end
129
277
 
130
- begin
131
- file = File.open(file_fullpath, "w")
132
- file.puts dumped_parsed_body
133
- file
134
- ensure
135
- file&.close
136
- end
278
+ def parse_plain(content)
279
+ content.to_s
280
+ end
281
+
282
+ def dump_json
283
+ return nil unless parsed_body.is_a?(Hash)
284
+
285
+ JSON.dump(parsed_body)
286
+ end
287
+
288
+ def dump_csv
289
+ return nil unless parsed_body.is_a?(CSV::Table)
290
+
291
+ parsed_body.to_csv(col_sep: CSV_COL_SEP)
292
+ end
293
+
294
+ def extract_status_code
295
+ if parsed_body.is_a?(Hash) && parsed_body[:code]
296
+ parsed_body[:code]
297
+ else
298
+ http_status_code
137
299
  end
300
+ end
138
301
 
139
- private
302
+ def create_response_error
303
+ error_class_name = ResponseError.error_class_for_code(status_code)
304
+ error_class = error_class_name ? TwelvedataRuby.const_get(error_class_name) : ResponseError
140
305
 
141
- attr_writer :http_response, :headers, :body, :request
306
+ error_class.new(
307
+ json_data: parsed_body,
308
+ request: request,
309
+ status_code: status_code,
310
+ message: parsed_body[:message],
311
+ )
312
+ end
142
313
  end
143
314
  end
@@ -1,36 +1,113 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Utility methods for common operations
3
4
  module TwelvedataRuby
4
5
  module Utils
5
- def self.demodulize(obj)
6
+ class << self
7
+ # Removes module namespace from class name
8
+ #
9
+ # @param obj [Object] Object to extract class name from
10
+ # @return [String] Class name without module namespace
11
+ #
12
+ # @example
13
+ # Utils.demodulize(TwelvedataRuby::Error) #=> "Error"
14
+ def demodulize(obj)
6
15
  obj.to_s.gsub(/^.+::/, "")
7
16
  end
8
17
 
9
- # Converts a string to integer an all integer or nothing
10
- def self.to_d(obj, default_value=nil)
11
- return obj if obj.is_a?(Integer)
12
-
13
- obj.to_s.match(/^\d+$/) {|m| Integer(m[0]) } || default_value
18
+ # Converts string to integer with default fallback
19
+ #
20
+ # @param obj [Object] Object to convert
21
+ # @param default_value [Integer, nil] Default value if conversion fails
22
+ # @return [Integer, nil] Converted integer or default value
23
+ #
24
+ # @example
25
+ # Utils.to_integer("123") #=> 123
26
+ # Utils.to_integer("abc", 0) #=> 0
27
+ def to_integer(obj, default_value = nil)
28
+ obj.is_a?(Integer) ? obj : Integer(obj.to_s)
29
+ rescue ArgumentError
30
+ default_value
14
31
  end
15
32
 
16
- def self.camelize(str)
33
+ # Converts snake_case to CamelCase
34
+ #
35
+ # @param str [String] String to convert
36
+ # @return [String] CamelCase string
37
+ #
38
+ # @example
39
+ # Utils.camelize("snake_case") #=> "SnakeCase"
40
+ def camelize(str)
17
41
  str.to_s.split("_").map(&:capitalize).join
18
42
  end
19
43
 
20
- def self.empty_to_nil(obj)
21
- !obj.nil? && obj.empty? ? nil : obj
44
+ # Converts empty values to nil
45
+ #
46
+ # @param obj [Object] Object to check
47
+ # @return [Object, nil] Original object or nil if empty
48
+ #
49
+ # @example
50
+ # Utils.empty_to_nil("") #=> nil
51
+ # Utils.empty_to_nil("test") #=> "test"
52
+ def empty_to_nil(obj)
53
+ return nil if obj.nil?
54
+ return nil if obj.respond_to?(:empty?) && obj.empty?
55
+
56
+ obj
22
57
  end
23
58
 
24
- def self.to_a(objects)
59
+ # Ensures return value is an array
60
+ #
61
+ # @param objects [Object] Single object or array
62
+ # @return [Array] Array containing the objects
63
+ #
64
+ # @example
65
+ # Utils.to_array("test") #=> ["test"]
66
+ # Utils.to_array(["a", "b"]) #=> ["a", "b"]
67
+ def to_array(objects)
25
68
  objects.is_a?(Array) ? objects : [objects]
26
69
  end
27
70
 
28
- def self.call_block_if_truthy(truthy_val, return_this=nil, &block)
29
- truthy_val ? block.call : return_this
71
+ # Executes block if condition is truthy
72
+ #
73
+ # @param condition [Object] Condition to evaluate
74
+ # @param default_return [Object] Default return value
75
+ # @yield Block to execute if condition is truthy
76
+ # @return [Object] Block result or default value
77
+ def execute_if_truthy(condition, default_return = nil)
78
+ return default_return unless condition && block_given?
79
+
80
+ yield
81
+ end
82
+
83
+ # Executes block only if condition is exactly true
84
+ #
85
+ # @param condition [Object] Condition to evaluate
86
+ # @yield Block to execute if condition is true
87
+ # @return [Object, nil] Block result or nil
88
+ def execute_if_true(condition, &block)
89
+ execute_if_truthy(condition == true, &block)
30
90
  end
31
91
 
32
- def self.return_nil_unless_true(is_true, &block)
33
- call_block_if_truthy(is_true == true) { block.call }
92
+ # Validates that a value is not blank
93
+ #
94
+ # @param value [Object] Value to validate
95
+ # @return [Boolean] True if value is present
96
+ def present?(value)
97
+ !blank?(value)
34
98
  end
99
+
100
+ # Checks if a value is blank (nil, empty, or whitespace-only)
101
+ #
102
+ # @param value [Object] Value to check
103
+ # @return [Boolean] True if value is blank
104
+ def blank?(value)
105
+ return true if value.nil?
106
+ return true if value.respond_to?(:empty?) && value.empty?
107
+ return true if value.is_a?(String) && value.strip.empty?
108
+
109
+ false
110
+ end
111
+ end
35
112
  end
36
113
  end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TwelvedataRuby
4
+ # Current version of the TwelvedataRuby gem
5
+ VERSION = "0.4.0"
6
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "twelvedata_ruby/version"
3
4
  require_relative "twelvedata_ruby/utils"
4
5
  require_relative "twelvedata_ruby/error"
5
6
  require_relative "twelvedata_ruby/endpoint"
@@ -7,37 +8,47 @@ require_relative "twelvedata_ruby/request"
7
8
  require_relative "twelvedata_ruby/response"
8
9
  require_relative "twelvedata_ruby/client"
9
10
 
10
- # The one module that all the classes and modules of this gem are namespaced
11
-
11
+ # TwelvedataRuby provides a Ruby interface for accessing Twelve Data's financial API
12
+ #
13
+ # @example Basic usage
14
+ # client = TwelvedataRuby.client(apikey: "your-api-key")
15
+ # response = client.quote(symbol: "AAPL")
16
+ # puts response.parsed_body
17
+ #
18
+ # @example Using environment variable for API key
19
+ # ENV['TWELVEDATA_API_KEY'] = 'your-api-key'
20
+ # client = TwelvedataRuby.client
21
+ # response = client.price(symbol: "GOOGL")
12
22
  module TwelvedataRuby
13
- # Holds the current version
14
- # @return [String] version number
15
- VERSION = "0.2.2"
23
+ class << self
24
+ # Creates and configures a client instance
25
+ #
26
+ # @param options [Hash] Configuration options
27
+ # @option options [String] :apikey The Twelve Data API key
28
+ # @option options [Integer] :connect_timeout Connection timeout in milliseconds
29
+ # @option options [String] :apikey_env_var_name Environment variable name for API key
30
+ #
31
+ # @return [Client] Configured client instance
32
+ #
33
+ # @example Basic client creation
34
+ # client = TwelvedataRuby.client(apikey: "your-key")
35
+ #
36
+ # @example With custom timeout
37
+ # client = TwelvedataRuby.client(
38
+ # apikey: "your-key",
39
+ # connect_timeout: 5000
40
+ # )
41
+ def client(**options)
42
+ client_instance = Client.instance
43
+ client_instance.configure(**options) if options.any?
44
+ client_instance
45
+ end
16
46
 
17
- # A convenient and clearer way of getting and overriding default attribute values of the singleton `Client.instance`
18
- #
19
- # @param [Hash] options the optional Hash object that may contain values to override the defaults
20
- # @option options [Symbol, String] :apikey the private key from Twelvedata API key
21
- # @option options [Integer, String] :connect_timeout milliseconds
22
- #
23
- # @example Passing a nil options
24
- # TwelvedataRuby.client
25
- #
26
- # The singleton instance object returned will use the default values for its attributes
27
- #
28
- # @example Passing values of `:apikey` and `:connect_timeout`
29
- # TwelvedataRuby.client(apikey: "my-twelvedata-apikey", connect_timeout: 3000)
30
- #
31
- # @example or, chain with other Client instance method
32
- # TwelvedataRuby.client(apikey: "my-twelvedata-apikey", connect_timeout: 3000).quote(symbol: "IBM")
33
- #
34
- # In the last example, calling `#quote`, a valid API endpoint, an instance method with the same name
35
- # was dynamically defined and then fired up an API request to Twelvedata.
36
- #
37
- # @return [Client] singleton instance
38
- def self.client(**options)
39
- client = Client.instance
40
- client.options = (client.options || {}).merge(options)
41
- client
47
+ # Returns the current version
48
+ #
49
+ # @return [String] Version string
50
+ def version
51
+ VERSION
52
+ end
42
53
  end
43
54
  end