twelvedata_ruby 0.3.0 → 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,156 +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, :body_bytesize, :request
39
-
40
- def initialize(http_response:, request:, headers: nil, body: nil)
41
- self.http_response = http_response
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)
42
54
  end
43
55
 
44
- def attachment_filename
45
- return nil unless headers["content-disposition"]
56
+ private
46
57
 
47
- @attachment_filename ||= headers["content-disposition"].split("filename=").last.delete("\"")
58
+ def success_status?(status)
59
+ HTTP_STATUSES[:success].include?(status)
48
60
  end
49
61
 
50
- def body_parser
51
- 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])
52
66
  end
53
67
 
54
- def content_type
55
- @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
56
80
  end
57
81
 
58
- def csv_parser(io)
59
- CSV.parse(io, headers: true, col_sep: CSV_COL_SEP)
60
- 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)
61
85
 
62
- def csv_dumper
63
- 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
64
91
  end
92
+ end
65
93
 
66
- def dumped_parsed_body
67
- @dumped_parsed_body ||=
68
- parsed_body.respond_to?(parsed_body_dumper) ? parsed_body.send(parsed_body_dumper) : send(parsed_body_dumper)
69
- end
94
+ attr_reader :http_response, :headers, :body_bytesize, :request
70
95
 
71
- def error
72
- klass_name = ResponseError.error_code_klass(status_code, success_http_status? ? :api : :http)
73
- return unless klass_name
74
- TwelvedataRuby.const_get(klass_name)
75
- .new(json: parsed_body, request: request, code: status_code, message: parsed_body)
76
- 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
77
107
 
78
- def http_status_code
79
- http_response&.status
80
- 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"]
81
113
 
82
- def json_dumper
83
- parsed_body.is_a?(Hash) ? JSON.dump(parsed_body) : nil
84
- end
114
+ @attachment_filename ||= extract_filename_from_headers
115
+ end
85
116
 
86
- def json_parser(io)
87
- JSON.parse(io, symbolize_names: true)
88
- end
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
89
123
 
90
- def parsed_body
91
- return @parsed_body if @parsed_body || http_response&.body.nil? || http_response&.body.closed?
92
-
93
- begin
94
- tmp_file = nil
95
- if body_bytesize < BODY_MAX_BYTESIZE
96
- @parsed_body = send(body_parser, http_response.body.to_s)
97
- else
98
- tmp_file = Tempfile.new
99
- http_response.body.copy_to(tmp_file)
100
- @parsed_body = send(body_parser, IO.read(tmp_file.path))
101
- end
102
- ensure
103
- http_response.body.close
104
- tmp_file&.close
105
- tmp_file&.unlink
106
- end
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
107
131
 
108
- @parsed_body
109
- end
110
- alias body parsed_body
111
- alias parse_http_response_body parsed_body
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)
112
137
 
113
- def parsed_body_dumper
114
- CONTENT_TYPE_HANDLERS[content_type][:dumper]
115
- end
138
+ @error ||= create_response_error
139
+ end
116
140
 
117
- def plain_parser(io=nil)
118
- io.to_s || http_response.body.to_s
119
- end
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
120
169
 
121
- def status_code
122
- @status_code ||= parsed_body.is_a?(Hash) ? parsed_body[:code] : http_status_code
170
+ File.open(file_path, "w") do |file|
171
+ file.write(dump_parsed_body)
123
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
179
+
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
124
186
 
125
- def success_http_status?
126
- @success_http_status ||= HTTP_STATUSES[:success].member?(http_status_code) || false
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
127
228
  end
229
+ end
128
230
 
129
- def to_disk_file(file_fullpath=attachment_filename)
130
- return nil unless file_fullpath
231
+ def parse_response_body
232
+ return nil unless http_response.body
131
233
 
132
- begin
133
- file = File.open(file_fullpath, "w")
134
- file.puts dumped_parsed_body
135
- file
136
- ensure
137
- file&.close
234
+ begin
235
+ if body_bytesize < BODY_MAX_BYTESIZE
236
+ parse_body_content(http_response.body.to_s)
237
+ else
238
+ parse_large_body_content
138
239
  end
240
+ ensure
241
+ http_response.body.close if http_response.body.respond_to?(:close)
139
242
  end
243
+ end
140
244
 
141
- private
245
+ def parse_body_content(content)
246
+ handler = CONTENT_TYPE_HANDLERS[content_type]
247
+ return content unless handler
142
248
 
143
- attr_writer :request
249
+ send(handler[:parser], content)
250
+ end
144
251
 
145
- def http_response=(http_resp)
146
- @http_response = http_resp
147
- @body_bytesize = http_resp.body.bytesize
148
- self.headers = http_response.headers
149
- parse_http_response_body
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)
150
257
  end
258
+ end
151
259
 
152
- def headers=(http_resp_headers)
153
- @headers = http_response&.headers
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
268
+
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
277
+
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
154
299
  end
155
300
  end
301
+
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
305
+
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
313
+ end
156
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.3.0"
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 cd Docdefaults
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