telnyx 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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