telnyx 0.0.1

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.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/.gitattributes +4 -0
  3. data/.github/ISSUE_TEMPLATE.md +5 -0
  4. data/.gitignore +9 -0
  5. data/.rubocop.yml +32 -0
  6. data/.rubocop_todo.yml +50 -0
  7. data/.travis.yml +42 -0
  8. data/CHANGELOG.md +2 -0
  9. data/CONTRIBUTORS +0 -0
  10. data/Gemfile +40 -0
  11. data/Guardfile +8 -0
  12. data/LICENSE +22 -0
  13. data/README.md +173 -0
  14. data/Rakefile +28 -0
  15. data/VERSION +1 -0
  16. data/bin/telnyx-console +16 -0
  17. data/lib/telnyx.rb +151 -0
  18. data/lib/telnyx/api_operations/create.rb +12 -0
  19. data/lib/telnyx/api_operations/delete.rb +13 -0
  20. data/lib/telnyx/api_operations/list.rb +29 -0
  21. data/lib/telnyx/api_operations/nested_resource.rb +63 -0
  22. data/lib/telnyx/api_operations/request.rb +57 -0
  23. data/lib/telnyx/api_operations/save.rb +103 -0
  24. data/lib/telnyx/api_resource.rb +69 -0
  25. data/lib/telnyx/available_phone_number.rb +9 -0
  26. data/lib/telnyx/errors.rb +166 -0
  27. data/lib/telnyx/event.rb +9 -0
  28. data/lib/telnyx/list_object.rb +155 -0
  29. data/lib/telnyx/message.rb +9 -0
  30. data/lib/telnyx/messaging_phone_number.rb +10 -0
  31. data/lib/telnyx/messaging_profile.rb +32 -0
  32. data/lib/telnyx/messaging_sender_id.rb +12 -0
  33. data/lib/telnyx/messaging_short_code.rb +10 -0
  34. data/lib/telnyx/number_order.rb +11 -0
  35. data/lib/telnyx/number_reservation.rb +11 -0
  36. data/lib/telnyx/public_key.rb +7 -0
  37. data/lib/telnyx/singleton_api_resource.rb +24 -0
  38. data/lib/telnyx/telnyx_client.rb +545 -0
  39. data/lib/telnyx/telnyx_object.rb +521 -0
  40. data/lib/telnyx/telnyx_response.rb +50 -0
  41. data/lib/telnyx/util.rb +328 -0
  42. data/lib/telnyx/version.rb +5 -0
  43. data/lib/telnyx/webhook.rb +66 -0
  44. data/telnyx.gemspec +25 -0
  45. data/test/api_stub_helpers.rb +1 -0
  46. data/test/openapi/README.md +9 -0
  47. data/test/telnyx/api_operations_test.rb +85 -0
  48. data/test/telnyx/api_resource_test.rb +293 -0
  49. data/test/telnyx/available_phone_number_test.rb +14 -0
  50. data/test/telnyx/errors_test.rb +23 -0
  51. data/test/telnyx/list_object_test.rb +244 -0
  52. data/test/telnyx/message_test.rb +19 -0
  53. data/test/telnyx/messaging_phone_number_test.rb +33 -0
  54. data/test/telnyx/messaging_profile_test.rb +70 -0
  55. data/test/telnyx/messaging_sender_id_test.rb +46 -0
  56. data/test/telnyx/messaging_short_code_test.rb +33 -0
  57. data/test/telnyx/number_order_test.rb +39 -0
  58. data/test/telnyx/number_reservation_test.rb +12 -0
  59. data/test/telnyx/public_key_test.rb +13 -0
  60. data/test/telnyx/telnyx_client_test.rb +631 -0
  61. data/test/telnyx/telnyx_object_test.rb +497 -0
  62. data/test/telnyx/telnyx_response_test.rb +49 -0
  63. data/test/telnyx/util_test.rb +380 -0
  64. data/test/telnyx/webhook_test.rb +108 -0
  65. data/test/telnyx_mock.rb +78 -0
  66. data/test/telnyx_test.rb +40 -0
  67. data/test/test_data.rb +149 -0
  68. data/test/test_helper.rb +73 -0
  69. metadata +162 -0
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Telnyx
4
+ # TelnyxResponse encapsulates some vitals of a response that came back from
5
+ # the Telnyx API.
6
+ class TelnyxResponse
7
+ # The data contained by the HTTP body of the response deserialized from
8
+ # JSON.
9
+ attr_accessor :data
10
+
11
+ # The raw HTTP body of the response.
12
+ attr_accessor :http_body
13
+
14
+ # A Hash of the HTTP headers of the response.
15
+ attr_accessor :http_headers
16
+
17
+ # The integer HTTP status code of the response.
18
+ attr_accessor :http_status
19
+
20
+ # The Telnyx request ID of the response.
21
+ attr_accessor :request_id
22
+
23
+ # Initializes a TelnyxResponse object from a Hash like the kind returned as
24
+ # part of a Faraday exception.
25
+ #
26
+ # This may throw JSON::ParserError if the response body is not valid JSON.
27
+ def self.from_faraday_hash(http_resp)
28
+ resp = TelnyxResponse.new
29
+ resp.data = JSON.parse(http_resp[:body], symbolize_names: true)
30
+ resp.http_body = http_resp[:body]
31
+ resp.http_headers = http_resp[:headers]
32
+ resp.http_status = http_resp[:status]
33
+ resp.request_id = http_resp[:headers]["X-Request-Id"]
34
+ resp
35
+ end
36
+
37
+ # Initializes a TelnyxResponse object from a Faraday HTTP response object.
38
+ #
39
+ # This may throw JSON::ParserError if the response body is not valid JSON.
40
+ def self.from_faraday_response(http_resp)
41
+ resp = TelnyxResponse.new
42
+ resp.data = JSON.parse(http_resp.body, symbolize_names: true)
43
+ resp.http_body = http_resp.body
44
+ resp.http_headers = http_resp.headers
45
+ resp.http_status = http_resp.status
46
+ resp.request_id = http_resp.headers["X-Request-Id"]
47
+ resp
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,328 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Telnyx
6
+ module Util
7
+ # Options that a user is allowed to specify.
8
+ OPTS_USER_SPECIFIED = Set[
9
+ :api_key,
10
+ ].freeze
11
+
12
+ # Options that should be copyable from one TelnyxObject to another
13
+ # including options that may be internal.
14
+ OPTS_COPYABLE = (
15
+ OPTS_USER_SPECIFIED + Set[:api_base]
16
+ ).freeze
17
+
18
+ # Options that should be persisted between API requests. This includes
19
+ # client, which is an object containing an HTTP client to reuse.
20
+ OPTS_PERSISTABLE = (
21
+ OPTS_USER_SPECIFIED + Set[:client]
22
+ ).freeze
23
+
24
+ def self.objects_to_ids(h)
25
+ case h
26
+ when APIResource
27
+ h.id
28
+ when Hash
29
+ res = {}
30
+ h.each { |k, v| res[k] = objects_to_ids(v) unless v.nil? }
31
+ res
32
+ when Array
33
+ h.map { |v| objects_to_ids(v) }
34
+ else
35
+ h
36
+ end
37
+ end
38
+
39
+ def self.object_classes
40
+ @object_classes ||= {
41
+ # business objects
42
+ Message::OBJECT_NAME => Message,
43
+ MessagingProfile::OBJECT_NAME => MessagingProfile,
44
+ MessagingPhoneNumber::OBJECT_NAME => MessagingPhoneNumber,
45
+ MessagingSenderId::OBJECT_NAME => MessagingSenderId,
46
+ MessagingShortCode::OBJECT_NAME => MessagingShortCode,
47
+ AvailablePhoneNumber::OBJECT_NAME => AvailablePhoneNumber,
48
+ PublicKey::OBJECT_NAME => PublicKey,
49
+ NumberOrder::OBJECT_NAME => NumberOrder,
50
+ NumberReservation::OBJECT_NAME => NumberReservation,
51
+ }
52
+ end
53
+
54
+ # Converts a hash of fields or an array of hashes into a +TelnyxObject+ or
55
+ # array of +TelnyxObject+s. These new objects will be created as a concrete
56
+ # type as dictated by their `record_type` field (e.g. a `record_type` value of
57
+ # `messaging_profile` would create an instance of +MessagingProfile+), but if `record_type` is not
58
+ # present or of an unknown type, the newly created instance will fall back
59
+ # to being a +TelnyxObject+.
60
+ #
61
+ # ==== Attributes
62
+ #
63
+ # * +data+ - Hash of fields and values to be converted into a TelnyxObject.
64
+ # * +opts+ - Options for +TelnyxObject+ like an API key that will be reused
65
+ # on subsequent API calls.
66
+ def self.convert_to_telnyx_object(data, opts = {})
67
+ case data
68
+ when Array
69
+ data.map { |i| convert_to_telnyx_object(i, opts) }
70
+ when Hash
71
+ # Try converting to a known object class. If none available, fall back to generic TelnyxObject
72
+ if data[:data].is_a?(Array)
73
+ ListObject.construct_from(data, opts)
74
+ elsif data[:data] && data[:data][:record_type]
75
+ object_classes.fetch(data[:data][:record_type], TelnyxObject).construct_from(data[:data], opts)
76
+ elsif data[:record_type]
77
+ object_classes.fetch(data[:record_type], TelnyxObject).construct_from(data, opts)
78
+ else
79
+ TelnyxObject.construct_from(data, opts)
80
+ end
81
+ else
82
+ data
83
+ end
84
+ end
85
+
86
+ def self.log_error(message, data = {})
87
+ if !Telnyx.logger.nil? ||
88
+ !Telnyx.log_level.nil? && Telnyx.log_level <= Telnyx::LEVEL_ERROR
89
+ log_internal(message, data, color: :cyan,
90
+ level: Telnyx::LEVEL_ERROR, logger: Telnyx.logger, out: $stderr)
91
+ end
92
+ end
93
+
94
+ def self.log_info(message, data = {})
95
+ if !Telnyx.logger.nil? ||
96
+ !Telnyx.log_level.nil? && Telnyx.log_level <= Telnyx::LEVEL_INFO
97
+ log_internal(message, data, color: :cyan,
98
+ level: Telnyx::LEVEL_INFO, logger: Telnyx.logger, out: $stdout)
99
+ end
100
+ end
101
+
102
+ def self.log_debug(message, data = {})
103
+ if !Telnyx.logger.nil? ||
104
+ !Telnyx.log_level.nil? && Telnyx.log_level <= Telnyx::LEVEL_DEBUG
105
+ log_internal(message, data, color: :blue,
106
+ level: Telnyx::LEVEL_DEBUG, logger: Telnyx.logger, out: $stdout)
107
+ end
108
+ end
109
+
110
+ def self.symbolize_names(object)
111
+ case object
112
+ when Hash
113
+ new_hash = {}
114
+ object.each do |key, value|
115
+ key = (begin
116
+ key.to_sym
117
+ rescue StandardError
118
+ key
119
+ end) || key
120
+ new_hash[key] = symbolize_names(value)
121
+ end
122
+ new_hash
123
+ when Array
124
+ object.map { |value| symbolize_names(value) }
125
+ else
126
+ object
127
+ end
128
+ end
129
+
130
+ # Encodes a hash of parameters in a way that's suitable for use as query
131
+ # parameters in a URI or as form parameters in a request body. This mainly
132
+ # involves escaping special characters from parameter keys and values (e.g.
133
+ # `&`).
134
+ def self.encode_parameters(params)
135
+ Util.flatten_params(params)
136
+ .map { |k, v| "#{url_encode(k)}=#{url_encode(v)}" }.join("&")
137
+ end
138
+
139
+ # Encodes a string in a way that makes it suitable for use in a set of
140
+ # query parameters in a URI or in a set of form parameters in a request
141
+ # body.
142
+ def self.url_encode(key)
143
+ CGI.escape(key.to_s).
144
+ # Don't use strict form encoding by changing the square bracket control
145
+ # characters back to their literals. This is fine by the server, and
146
+ # makes these parameter strings easier to read.
147
+ gsub("%5B", "[").gsub("%5D", "]")
148
+ end
149
+
150
+ def self.flatten_params(params, parent_key = nil)
151
+ result = []
152
+
153
+ # do not sort the final output because arrays (and arrays of hashes
154
+ # especially) can be order sensitive, but do sort incoming parameters
155
+ params.each do |key, value|
156
+ calculated_key = parent_key ? "#{parent_key}[#{key}]" : key.to_s
157
+ if value.is_a?(Hash)
158
+ result += flatten_params(value, calculated_key)
159
+ elsif value.is_a?(Array)
160
+ result += flatten_params_array(value, calculated_key)
161
+ else
162
+ result << [calculated_key, value]
163
+ end
164
+ end
165
+
166
+ result
167
+ end
168
+
169
+ def self.flatten_params_array(value, calculated_key)
170
+ result = []
171
+ value.each_with_index do |elem, i|
172
+ if elem.is_a?(Hash)
173
+ result += flatten_params(elem, "#{calculated_key}[#{i}]")
174
+ elsif elem.is_a?(Array)
175
+ result += flatten_params_array(elem, calculated_key)
176
+ else
177
+ result << ["#{calculated_key}[#{i}]", elem]
178
+ end
179
+ end
180
+ result
181
+ end
182
+
183
+ def self.normalize_id(id)
184
+ if id.is_a?(Hash) # overloaded id
185
+ params_hash = id.dup
186
+ id = params_hash.delete(:id)
187
+ else
188
+ params_hash = {}
189
+ end
190
+ [id, params_hash]
191
+ end
192
+
193
+ # The secondary opts argument can either be a string or hash
194
+ # Turn this value into an api_key and a set of headers
195
+ def self.normalize_opts(opts)
196
+ case opts
197
+ when String
198
+ { api_key: opts }
199
+ when Hash
200
+ check_api_key!(opts.fetch(:api_key)) if opts.key?(:api_key)
201
+ opts.clone
202
+ else
203
+ raise TypeError, "normalize_opts expects a string or a hash"
204
+ end
205
+ end
206
+
207
+ def self.check_string_argument!(key)
208
+ raise TypeError, "argument must be a string" unless key.is_a?(String)
209
+ key
210
+ end
211
+
212
+ def self.check_api_key!(key)
213
+ raise TypeError, "api_key must be a string" unless key.is_a?(String)
214
+ key
215
+ end
216
+
217
+ # Normalizes header keys so that they're all lower case and each
218
+ # hyphen-delimited section starts with a single capitalized letter. For
219
+ # example, `request-id` becomes `Request-Id`. This is useful for extracting
220
+ # certain key values when the user could have set them with a variety of
221
+ # diffent naming schemes.
222
+ def self.normalize_headers(headers)
223
+ headers.each_with_object({}) do |(k, v), new_headers|
224
+ k = k.to_s.tr("_", "-") if k.is_a?(Symbol)
225
+ k = k.split("-").reject(&:empty?).map(&:capitalize).join("-")
226
+
227
+ new_headers[k] = v
228
+ end
229
+ end
230
+
231
+ # Constant time string comparison to prevent timing attacks
232
+ # Code borrowed from ActiveSupport
233
+ def self.secure_compare(a, b)
234
+ return false unless a.bytesize == b.bytesize
235
+
236
+ l = a.unpack "C#{a.bytesize}"
237
+
238
+ res = 0
239
+ b.each_byte { |byte| res |= byte ^ l.shift }
240
+ res.zero?
241
+ end
242
+
243
+ #
244
+ # private
245
+ #
246
+
247
+ COLOR_CODES = {
248
+ black: 0, light_black: 60,
249
+ red: 1, light_red: 61,
250
+ green: 2, light_green: 62,
251
+ yellow: 3, light_yellow: 63,
252
+ blue: 4, light_blue: 64,
253
+ magenta: 5, light_magenta: 65,
254
+ cyan: 6, light_cyan: 66,
255
+ white: 7, light_white: 67,
256
+ default: 9,
257
+ }.freeze
258
+ private_constant :COLOR_CODES
259
+
260
+ # Uses an ANSI escape code to colorize text if it's going to be sent to a
261
+ # TTY.
262
+ def self.colorize(val, color, isatty)
263
+ return val unless isatty
264
+
265
+ mode = 0 # default
266
+ foreground = 30 + COLOR_CODES.fetch(color)
267
+ background = 40 + COLOR_CODES.fetch(:default)
268
+
269
+ "\033[#{mode};#{foreground};#{background}m#{val}\033[0m"
270
+ end
271
+ private_class_method :colorize
272
+
273
+ # Turns an integer log level into a printable name.
274
+ def self.level_name(level)
275
+ case level
276
+ when LEVEL_DEBUG then "debug"
277
+ when LEVEL_ERROR then "error"
278
+ when LEVEL_INFO then "info"
279
+ else level
280
+ end
281
+ end
282
+ private_class_method :level_name
283
+
284
+ # TODO: Make these named required arguments when we drop support for Ruby
285
+ # 2.0.
286
+ def self.log_internal(message, data = {}, color: nil, level: nil, logger: nil, out: nil)
287
+ data_str = data.reject { |_k, v| v.nil? }
288
+ .map do |(k, v)|
289
+ format("%s=%s", colorize(k, color, logger.nil? && !out.nil? && out.isatty), wrap_logfmt_value(v))
290
+ end.join(" ")
291
+
292
+ if !logger.nil?
293
+ # the library's log levels are mapped to the same values as the
294
+ # standard library's logger
295
+ logger.log(level,
296
+ format("message=%s %s", wrap_logfmt_value(message), data_str))
297
+ elsif out.isatty
298
+ out.puts format("%s %s %s", colorize(level_name(level)[0, 4].upcase, color, out.isatty), message, data_str)
299
+ else
300
+ out.puts format("message=%s level=%s %s", wrap_logfmt_value(message), level_name(level), data_str)
301
+ end
302
+ end
303
+ private_class_method :log_internal
304
+
305
+ # Wraps a value in double quotes if it looks sufficiently complex so that
306
+ # it can be read by logfmt parsers.
307
+ def self.wrap_logfmt_value(val)
308
+ # If value is any kind of number, just allow it to be formatted directly
309
+ # to a string (this will handle integers or floats).
310
+ return val if val.is_a?(Numeric)
311
+
312
+ # Hopefully val is a string, but protect in case it's not.
313
+ val = val.to_s
314
+
315
+ if %r{[^\w\-/]} =~ val
316
+ # If the string contains any special characters, escape any double
317
+ # quotes it has, remove newlines, and wrap the whole thing in quotes.
318
+ format(%("%s"), val.gsub('"', '\"').delete("\n"))
319
+ else
320
+ # Otherwise use the basic value if it looks like a standard set of
321
+ # characters (and allow a few special characters like hyphens, and
322
+ # slashes)
323
+ val
324
+ end
325
+ end
326
+ private_class_method :wrap_logfmt_value
327
+ end
328
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Telnyx
4
+ VERSION = "0.0.1".freeze
5
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+
6
+ module Telnyx
7
+ module Webhook
8
+ DEFAULT_TOLERANCE = 300
9
+
10
+ # Initializes an Event object from a JSON payload.
11
+ #
12
+ # This may raise JSON::ParserError if the payload is not valid JSON, or
13
+ # SignatureVerificationError if the signature verification fails.
14
+ def self.construct_event(payload, signature_header, timestamp_header, tolerance: DEFAULT_TOLERANCE)
15
+ Signature.verify(payload, signature_header, timestamp_header, tolerance: tolerance)
16
+
17
+ # It's a good idea to parse the payload only after verifying it. We use
18
+ # `symbolize_names` so it would otherwise be technically possible to
19
+ # flood a target's memory if they were on an older version of Ruby that
20
+ # doesn't GC symbols. It also decreases the likelihood that we receive a
21
+ # bad payload that fails to parse and throws an exception.
22
+ data = JSON.parse(payload, symbolize_names: true)
23
+ Event.construct_from(data)
24
+ end
25
+
26
+ module Signature
27
+ # Verifies the signature for a given payload.
28
+ #
29
+ # Raises a SignatureVerificationError in the following cases:
30
+ # - the signature does not match the expected format
31
+ # - no signatures found
32
+ # - a tolerance is provided and the timestamp is not within the
33
+ # tolerance
34
+ #
35
+ # Returns true otherwise
36
+ def self.verify(payload, signature_header, timestamp, tolerance: nil)
37
+ signature = Base64.decode64(signature_header)
38
+ signed_payload = "#{timestamp}|#{payload}"
39
+
40
+ unless public_key.verify(digest, signature, signed_payload)
41
+ raise SignatureVerificationError.new(
42
+ "Signature is invalid and does not match the payload",
43
+ signature, http_body: payload
44
+ )
45
+ end
46
+
47
+ if tolerance && timestamp < Time.now.to_f - tolerance
48
+ raise SignatureVerificationError.new(
49
+ "Timestamp outside the tolerance zone (#{Time.at(timestamp)})",
50
+ signature_header, http_body: payload
51
+ )
52
+ end
53
+
54
+ true
55
+ end
56
+
57
+ def self.public_key
58
+ @public_key ||= OpenSSL::PKey::RSA.new(ENV.fetch("TELNYX_PUBLIC_KEY"))
59
+ end
60
+
61
+ def self.digest
62
+ @digest ||= OpenSSL::Digest::SHA256.new
63
+ end
64
+ end
65
+ end
66
+ end