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
data/telnyx.gemspec ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(::File.join(::File.dirname(__FILE__), "lib"))
4
+
5
+ require "telnyx/version"
6
+
7
+ Gem::Specification.new do |s|
8
+ s.name = "telnyx"
9
+ s.version = Telnyx::VERSION
10
+ s.required_ruby_version = ">= 2.1.0"
11
+ s.summary = "Ruby bindings for the Telnyx API"
12
+ s.description = "Telnyx. See https://www.telnyx.com for details."
13
+ s.author = "Telnyx"
14
+ s.email = "support@telnyx.com"
15
+ s.homepage = "https://developers.telnyx.com/docs/api/ruby"
16
+ s.license = "MIT"
17
+
18
+ s.add_dependency("faraday", "~> 0.13")
19
+ s.add_dependency("net-http-persistent", "~> 3.0")
20
+
21
+ s.files = `git ls-files`.split("\n")
22
+ s.test_files = `git ls-files -- test/*`.split("\n")
23
+ s.executables = `git ls-files -- bin/*`.split("\n").map { |f| ::File.basename(f) }
24
+ s.require_paths = ["lib"]
25
+ end
@@ -0,0 +1 @@
1
+ # frozen_string_literal: true
@@ -0,0 +1,9 @@
1
+ ## Using custom OpenAPI specification and fixtures files
2
+
3
+ You can place custom OpenAPI specification and fixtures files in this
4
+ directory. The files must be in JSON format, and must be named `spec3.json`
5
+ and `fixtures3.json` respectively.
6
+
7
+ If those files are present, the test suite will start its own telnyx-mock
8
+ process on a random available port. In order for this to work, `telnyx-mock`
9
+ must be on the `PATH` in the environment used to run the test suite.
@@ -0,0 +1,85 @@
1
+ # -*- coding: utf-8 -*-
2
+ # frozen_string_literal: true
3
+
4
+ require ::File.expand_path("../../test_helper", __FILE__)
5
+
6
+ module Telnyx
7
+ class ApiOperationsTest < Test::Unit::TestCase
8
+ class UpdateableResource < APIResource
9
+ include Telnyx::APIOperations::Save
10
+
11
+ OBJECT_NAME = "updateableresource".freeze
12
+
13
+ def self.protected_fields
14
+ [:protected]
15
+ end
16
+ end
17
+
18
+ context ".update" do
19
+ should "patch the correct parameters to the resource URL" do
20
+ stub_patch = stub_request(:patch, "#{Telnyx.api_base}/v2/messaging_profiles/id")
21
+ .with(body: { foo: "bar" })
22
+ .to_return(body: JSON.generate(data: { record_type: "messaging_profile", foo: "bar" }))
23
+ resource = MessagingProfile.update("id", foo: "bar")
24
+ assert_requested(stub_patch)
25
+ assert_equal("bar", resource.foo)
26
+ end
27
+
28
+ should "error on protected fields" do
29
+ e = assert_raises do
30
+ UpdateableResource.update("id", protected: "bar")
31
+ end
32
+ assert_equal "Cannot update protected field: protected", e.message
33
+ end
34
+ end
35
+
36
+ context ".nested_resource_class_methods" do
37
+ class MainResource < APIResource
38
+ extend Telnyx::APIOperations::NestedResource
39
+ OBJECT_NAME = "mainresource".freeze
40
+ nested_resource_class_methods :nested,
41
+ operations: %i[create retrieve update delete list]
42
+ end
43
+
44
+ should "define a create method" do
45
+ stub_request(:post, "#{Telnyx.api_base}/v2/mainresources/id/nesteds")
46
+ .with(body: { foo: "bar" })
47
+ .to_return(body: JSON.generate(id: "nested_id", object: "nested", foo: "bar"))
48
+ nested_resource = MainResource.create_nested("id", foo: "bar")
49
+ assert_equal "bar", nested_resource.foo
50
+ end
51
+
52
+ should "define a retrieve method" do
53
+ stub_request(:get, "#{Telnyx.api_base}/v2/mainresources/id/nesteds/nested_id")
54
+ .to_return(body: JSON.generate(id: "nested_id", object: "nested", foo: "bar"))
55
+ nested_resource = MainResource.retrieve_nested("id", "nested_id")
56
+ assert_equal "bar", nested_resource.foo
57
+ end
58
+
59
+ should "define an update method" do
60
+ stub_patch = stub_request(:patch, "#{Telnyx.api_base}/v2/mainresources/id/nesteds/nested_id")
61
+ .with(body: { foo: "baz" })
62
+ .to_return(body: JSON.generate(id: "nested_id", object: "nested", foo: "baz"))
63
+ nested_resource = MainResource.update_nested("id", "nested_id", foo: "baz")
64
+ assert_requested(stub_patch)
65
+ assert_equal "baz", nested_resource.foo
66
+ end
67
+
68
+ should "define a delete method" do
69
+ stub_request(:delete, "#{Telnyx.api_base}/v2/mainresources/id/nesteds/nested_id")
70
+ .to_return(body: JSON.generate(id: "nested_id", object: "nested", deleted: true))
71
+ nested_resource = MainResource.delete_nested("id", "nested_id")
72
+ assert_equal true, nested_resource.deleted
73
+ end
74
+
75
+ should "define a list method" do
76
+ stub_get = stub_request(:get, "#{Telnyx.api_base}/v2/mainresources/id/nesteds")
77
+ .to_return(body: JSON.generate(data: [{ record_type: "foo" }]))
78
+ nested_resources = MainResource.list_nesteds("id")
79
+ assert_requested(stub_get)
80
+ assert nested_resources.is_a?(ListObject)
81
+ assert nested_resources.data.is_a?(Array)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,293 @@
1
+ # -*- coding: utf-8 -*-
2
+ # frozen_string_literal: true
3
+
4
+ require ::File.expand_path("../../test_helper", __FILE__)
5
+
6
+ module Telnyx
7
+ class ApiResourceTest < Test::Unit::TestCase
8
+ should "creating a new APIResource should not fetch over the network" do
9
+ Telnyx::MessagingProfile.new("someid")
10
+ assert_not_requested :get, %r{#{Telnyx.api_base}/.*}
11
+ end
12
+
13
+ should "creating a new APIResource from a hash should not fetch over the network" do
14
+ Telnyx::MessagingProfile.construct_from(id: "foo", record_type: "messaging_profile")
15
+ assert_not_requested :get, %r{#{Telnyx.api_base}/.*}
16
+ end
17
+
18
+ should "setting an attribute should not cause a network request" do
19
+ m = Telnyx::MessagingProfile.new("123")
20
+ m.name = "My New Messaging Profile"
21
+ assert_not_requested :get, %r{#{Telnyx.api_base}/.*}
22
+ assert_not_requested :post, %r{#{Telnyx.api_base}/.*}
23
+ end
24
+
25
+ should "accessing id should not issue a fetch" do
26
+ m = Telnyx::MessagingProfile.new("123")
27
+ m.id
28
+ assert_not_requested :get, "#{Telnyx.api_base}/messaging_profiles/123"
29
+ end
30
+
31
+ should "not specifying api credentials should raise an exception" do
32
+ Telnyx.api_key = nil
33
+ assert_raises Telnyx::AuthenticationError do
34
+ Telnyx::MessagingProfile.new("456").refresh
35
+ end
36
+ end
37
+
38
+ should "using a nil api key should raise an exception" do
39
+ assert_raises TypeError do
40
+ Telnyx::MessagingProfile.list({}, nil)
41
+ end
42
+ assert_raises TypeError do
43
+ Telnyx::MessagingProfile.list({}, api_key: nil)
44
+ end
45
+ end
46
+
47
+ should "specifying api credentials containing whitespace should raise an exception" do
48
+ Telnyx.api_key = "key "
49
+ assert_raises Telnyx::AuthenticationError do
50
+ Telnyx::MessagingProfile.new("123").refresh
51
+ end
52
+ end
53
+
54
+ should "get resource URL" do
55
+ m = Telnyx::MessagingProfile.new("123")
56
+ assert_match "/messaging_profiles/123", m.resource_url
57
+ end
58
+
59
+ context "when specifying per-object credentials" do
60
+ context "with no global API key set" do
61
+ should "use the per-object credential when creating" do
62
+ stub_post = stub_request(:post, "#{Telnyx.api_base}/v2/messaging_profiles")
63
+ .with(headers: { "Authorization" => "Bearer super-secret" })
64
+ .to_return(body: JSON.generate(data: messaging_profile_fixture))
65
+
66
+ Telnyx::MessagingProfile.create({ name: "New Messaging Profile" },
67
+ "super-secret")
68
+ assert_requested(stub_post)
69
+ end
70
+ end
71
+
72
+ context "with a global API key set" do
73
+ setup do
74
+ Telnyx.api_key = "global"
75
+ end
76
+
77
+ teardown do
78
+ Telnyx.api_key = nil
79
+ end
80
+
81
+ should "use the per-object credential when creating" do
82
+ stub_post = stub_request(:post, "#{Telnyx.api_base}/v2/messaging_profiles")
83
+ .with(headers: { "Authorization" => "Bearer super-secret" })
84
+ .to_return(body: JSON.generate(data: messaging_profile_fixture))
85
+
86
+ Telnyx::MessagingProfile.create({ name: "New Messaging Profile" },
87
+ "super-secret")
88
+ assert_requested(stub_post)
89
+ end
90
+ end
91
+ end
92
+
93
+ context "with valid credentials" do
94
+ should "urlencode values in GET params" do
95
+ stub_get = stub_request(:get, "#{Telnyx.api_base}/v2/messaging_profiles")
96
+ .with(query: { page: { size: 10 } })
97
+ .to_return(body: JSON.generate(data: [messaging_profile_fixture]))
98
+ messaging_profiles = Telnyx::MessagingProfile.list(page: { size: 10 }).data
99
+ assert messaging_profiles.is_a? Array
100
+ assert_requested(stub_get)
101
+ end
102
+
103
+ should "setting a nil value for a param should exclude that param from the request" do
104
+ stub_get = stub_request(:get, "#{Telnyx.api_base}/v2/messaging_profiles")
105
+ .with(query: { page: { size: 5 }, sad: false })
106
+ .to_return(body: JSON.generate(data: [messaging_profile_fixture]))
107
+ Telnyx::MessagingProfile.list(count: nil, page: { size: 5 }, sad: false)
108
+
109
+ assert_requested(stub_get)
110
+
111
+ Telnyx::MessagingProfile.create(name: nil, foo: "bar")
112
+
113
+ assert_requested(:post, "#{Telnyx.api_base}/v2/messaging_profiles", body: { "foo" => "bar" })
114
+ end
115
+
116
+ should "requesting with a unicode ID should result in a request" do
117
+ stub_request(:get, "#{Telnyx.api_base}/v2/messaging_profiles/%E2%98%83")
118
+ .to_return(body: JSON.generate(make_resource_not_found_error), status: 404)
119
+ mp = Telnyx::MessagingProfile.new("☃")
120
+ assert_raises(Telnyx::ResourceNotFoundError) { mp.refresh }
121
+ end
122
+
123
+ should "requesting with no ID should result in an InvalidRequestError with no request" do
124
+ mp = Telnyx::MessagingProfile.new
125
+ assert_raises(Telnyx::InvalidRequestError) { mp.refresh }
126
+ end
127
+
128
+ should "making a GET request with parameters should have a query string and no body" do
129
+ stub = stub_request(:get, "#{Telnyx.api_base}/v2/messaging_profiles")
130
+ .with(query: { limit: 1 })
131
+ .to_return(body: JSON.generate(data: [messaging_profile_fixture]))
132
+ Telnyx::MessagingProfile.list(limit: 1)
133
+ assert_requested(stub, body: "")
134
+ end
135
+
136
+ should "making a POST request with parameters should have a body and no query string" do
137
+ Telnyx::MessagingProfile.create(name: "New Messaging Profile")
138
+ assert_requested(:post, "#{Telnyx.api_base}/v2/messaging_profiles", body: { name: "New Messaging Profile" })
139
+ end
140
+
141
+ should "loading an object should issue a GET request" do
142
+ mp = Telnyx::MessagingProfile.new("123")
143
+ mp.refresh
144
+ assert_requested(:get, "#{Telnyx.api_base}/v2/messaging_profiles/123")
145
+ end
146
+
147
+ should "using array accessors should be the same as the method interface" do
148
+ mp = Telnyx::MessagingProfile.new("123")
149
+ mp.refresh
150
+ assert_equal mp.created_at, mp[:created_at]
151
+ assert_equal mp.created_at, mp["created_at"]
152
+ mp["created_at"] = 12_345
153
+ assert_equal mp.created_at, 12_345
154
+ assert_requested(:get, "#{Telnyx.api_base}/v2/messaging_profiles/123")
155
+ end
156
+
157
+ should "updating an object should issue a PATCH request with only the changed properties" do
158
+ mp = Telnyx::MessagingProfile.construct_from(messaging_profile_fixture)
159
+ mp.name = "new name"
160
+ mp.save
161
+ assert_requested(:patch, "#{Telnyx.api_base}/v2/messaging_profiles/123", body: { "name" => "new name" })
162
+ end
163
+
164
+ should "updating should merge in returned properties" do
165
+ mp = Telnyx::MessagingProfile.new("123")
166
+ mp.name = "new name"
167
+ mp.save
168
+
169
+ assert_requested(:patch, "#{Telnyx.api_base}/v2/messaging_profiles/123", body: { "name" => "new name" })
170
+ assert mp.simplify_characters
171
+ end
172
+
173
+ should "updating should fail if api_key is overwritten with nil" do
174
+ mp = Telnyx::MessagingProfile.new
175
+ assert_raises TypeError do
176
+ mp.save({}, api_key: nil)
177
+ end
178
+ end
179
+
180
+ should "updating should use the supplied api_key" do
181
+ stub_post = stub_request(:post, "#{Telnyx.api_base}/v2/messaging_profiles")
182
+ .with(headers: { "Authorization" => "Bearer super-secret" })
183
+ .to_return(body: JSON.generate(data: messaging_profile_fixture))
184
+ mp = Telnyx::MessagingProfile.new
185
+ mp.save({ name: "Profile for Messages" }, api_key: "super-secret")
186
+
187
+ assert_requested(stub_post)
188
+ assert_equal "Profile for Messages", mp.name
189
+ end
190
+
191
+ should "deleting should send no props and result in an object that has no props other than `deleted`" do
192
+ mp = Telnyx::MessagingProfile.construct_from(messaging_profile_fixture)
193
+ mp.delete
194
+ assert_requested(:delete, "#{Telnyx.api_base}/v2/messaging_profiles/123", body: "")
195
+ end
196
+
197
+ should "loading all of an APIResource should return an array of recursively instantiated objects" do
198
+ messaging_profiles = Telnyx::MessagingProfile.list.data
199
+
200
+ assert_requested(:get, "#{Telnyx.api_base}/v2/messaging_profiles")
201
+ assert messaging_profiles.is_a? Array
202
+ assert messaging_profiles[0].is_a? Telnyx::MessagingProfile
203
+ end
204
+
205
+ should "save nothing if nothing changes" do
206
+ messaging_profile = Telnyx::MessagingProfile.construct_from(
207
+ id: "123",
208
+ meta: {
209
+ key: "value",
210
+ }
211
+ )
212
+
213
+ messaging_profile.save
214
+ assert_requested(:patch, "#{Telnyx.api_base}/v2/messaging_profiles/123", body: {})
215
+ end
216
+
217
+ should "correctly handle array noops" do
218
+ messaging_profile = Telnyx::MessagingProfile.construct_from(
219
+ id: "myid",
220
+ legal_entity: {
221
+ additional_owners: [{ first_name: "Bob" }],
222
+ },
223
+ currencies_supported: %w[usd cad]
224
+ )
225
+
226
+ messaging_profile.save
227
+ assert_requested(:patch, "#{Telnyx.api_base}/v2/messaging_profiles/myid", body: {})
228
+ end
229
+
230
+ should "correctly handle hash noops" do
231
+ messaging_profile = Telnyx::MessagingProfile.construct_from(
232
+ id: "myid",
233
+ legal_entity: {
234
+ address: { line1: "1 Two Three" },
235
+ }
236
+ )
237
+
238
+ stub_post = stub_request(:patch, "#{Telnyx.api_base}/v2/messaging_profiles/myid")
239
+ .with(body: {})
240
+ .to_return(body: JSON.generate(data: { "id" => "myid" }))
241
+
242
+ messaging_profile.save
243
+ assert_requested(stub_post)
244
+ end
245
+
246
+ should "should create a new resource when an object without an id is saved" do
247
+ messaging_profile = Telnyx::MessagingProfile.construct_from(id: nil, name: nil)
248
+
249
+ messaging_profile.name = "my-messaging-profile"
250
+ messaging_profile.save
251
+
252
+ assert_requested(:post, "#{Telnyx.api_base}/v2/messaging_profiles", body: { name: "my-messaging-profile" })
253
+ end
254
+
255
+ should "set attributes as part of save" do
256
+ messaging_profile = Telnyx::MessagingProfile.construct_from(id: nil,
257
+ name: nil)
258
+
259
+ stub_post = stub_request(:post, "#{Telnyx.api_base}/v2/messaging_profiles")
260
+ .with(body: { name: "telnyx", meta: { key: "value" } })
261
+ .to_return(body: JSON.generate(data: { "id" => "123" }))
262
+
263
+ messaging_profile.save(name: "telnyx", meta: { key: "value" })
264
+ assert_requested(stub_post)
265
+ end
266
+ end
267
+
268
+ @@fixtures = {}
269
+ setup do
270
+ if @@fixtures.empty?
271
+ cache_fixture(:messaging_profile) do
272
+ MessagingProfile.retrieve("123")
273
+ end
274
+ end
275
+ end
276
+
277
+ private
278
+
279
+ def messaging_profile_fixture
280
+ @@fixtures[:messaging_profile]
281
+ end
282
+
283
+ # Expects to retrieve a fixture from telnyx-mock (an API call should be
284
+ # included in the block to yield to) and does very simple memoization.
285
+ def cache_fixture(key)
286
+ return @@fixtures[key] if @@fixtures.key?(key)
287
+
288
+ obj = yield
289
+ @@fixtures[key] = obj.instance_variable_get(:@values).freeze
290
+ @@fixtures[key]
291
+ end
292
+ end
293
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require ::File.expand_path("../../test_helper", __FILE__)
4
+
5
+ module Telnyx
6
+ class AvailablePhoneNumberTest < Test::Unit::TestCase
7
+ should "be listable" do
8
+ available_phone_numbers = Telnyx::AvailablePhoneNumber.list
9
+ assert_requested :get, "#{Telnyx.api_base}/v2/available_phone_numbers"
10
+ assert available_phone_numbers.data.is_a?(Array)
11
+ assert available_phone_numbers.first.is_a?(Telnyx::AvailablePhoneNumber)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require ::File.expand_path("../../test_helper", __FILE__)
4
+
5
+ module Telnyx
6
+ class TelnyxErrorTest < Test::Unit::TestCase
7
+ context "#to_s" do
8
+ should "convert to string" do
9
+ e = TelnyxError.new([{ "title" => "Missing required attributes" }])
10
+ assert_equal "Missing required attributes Full details: [{\"title\"=>\"Missing required attributes\"}]", e.to_s
11
+
12
+ e = TelnyxError.new([{ "title" => "Missing required attributes" }], http_status: 422)
13
+ assert_equal "(Status 422) Missing required attributes Full details: [{\"title\"=>\"Missing required attributes\"}]", e.to_s
14
+
15
+ e = TelnyxError.new([{ "title" => "Missing required attributes" }], http_status: nil, http_body: nil, json_body: nil, http_headers: { request_id: "request-id" })
16
+ assert_equal "(Request request-id) Missing required attributes Full details: [{\"title\"=>\"Missing required attributes\"}]", e.to_s
17
+
18
+ e = TelnyxError.new([{ "title" => "Missing required attributes" }, { "title" => "Phone number must be in +E.164 format" }], http_status: nil, http_body: nil, json_body: nil, http_headers: { request_id: "request-id" })
19
+ assert_equal "(Request request-id) Missing required attributes plus 1 other error. Full details: [{\"title\"=>\"Missing required attributes\"}, {\"title\"=>\"Phone number must be in +E.164 format\"}]", e.to_s
20
+ end
21
+ end
22
+ end
23
+ end