sendly 1.5.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8287e0f6c55aa228285671746446a7eb04577569e56515ed76571da61a0392b0
4
+ data.tar.gz: 62017808d07348e8d6e5809b367300e11976a754e7c3323c8155d2d9140befb4
5
+ SHA512:
6
+ metadata.gz: 2c3a234e3caa740975c00ad3c1454dbe24f423385f480f180e33f1ac0ef890bbe019f58606ff3f50bb0b508280d9352997dd20b88a6705d277d493235d3ecc57
7
+ data.tar.gz: 5e7cba5b80ec78994f3de8b0dfe5c3f63f6c00cede6f5a98c3d7a679755386ee543477f588c672c248181dbfc279bd8834c5108f1a1071378f9e51b23ea94c40
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.4.5
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,95 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ sendly (1.5.1)
5
+ faraday (~> 2.0)
6
+ faraday-retry (~> 2.0)
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ addressable (2.8.8)
12
+ public_suffix (>= 2.0.2, < 8.0)
13
+ ast (2.4.3)
14
+ bigdecimal (3.3.1)
15
+ crack (1.0.1)
16
+ bigdecimal
17
+ rexml
18
+ diff-lcs (1.6.2)
19
+ faraday (2.14.0)
20
+ faraday-net_http (>= 2.0, < 3.5)
21
+ json
22
+ logger
23
+ faraday-net_http (3.4.2)
24
+ net-http (~> 0.5)
25
+ faraday-retry (2.3.2)
26
+ faraday (~> 2.0)
27
+ hashdiff (1.2.1)
28
+ json (2.18.0)
29
+ language_server-protocol (3.17.0.5)
30
+ lint_roller (1.1.0)
31
+ logger (1.7.0)
32
+ net-http (0.8.0)
33
+ uri (>= 0.11.1)
34
+ parallel (1.27.0)
35
+ parser (3.3.10.0)
36
+ ast (~> 2.4.1)
37
+ racc
38
+ prism (1.6.0)
39
+ public_suffix (7.0.0)
40
+ racc (1.8.1)
41
+ rainbow (3.1.1)
42
+ rake (13.3.1)
43
+ regexp_parser (2.11.3)
44
+ rexml (3.4.4)
45
+ rspec (3.13.2)
46
+ rspec-core (~> 3.13.0)
47
+ rspec-expectations (~> 3.13.0)
48
+ rspec-mocks (~> 3.13.0)
49
+ rspec-core (3.13.6)
50
+ rspec-support (~> 3.13.0)
51
+ rspec-expectations (3.13.5)
52
+ diff-lcs (>= 1.2.0, < 2.0)
53
+ rspec-support (~> 3.13.0)
54
+ rspec-mocks (3.13.7)
55
+ diff-lcs (>= 1.2.0, < 2.0)
56
+ rspec-support (~> 3.13.0)
57
+ rspec-support (3.13.6)
58
+ rubocop (1.81.7)
59
+ json (~> 2.3)
60
+ language_server-protocol (~> 3.17.0.2)
61
+ lint_roller (~> 1.1.0)
62
+ parallel (~> 1.10)
63
+ parser (>= 3.3.0.2)
64
+ rainbow (>= 2.2.2, < 4.0)
65
+ regexp_parser (>= 2.9.3, < 3.0)
66
+ rubocop-ast (>= 1.47.1, < 2.0)
67
+ ruby-progressbar (~> 1.7)
68
+ unicode-display_width (>= 2.4.0, < 4.0)
69
+ rubocop-ast (1.48.0)
70
+ parser (>= 3.3.7.2)
71
+ prism (~> 1.4)
72
+ ruby-progressbar (1.13.0)
73
+ unicode-display_width (3.2.0)
74
+ unicode-emoji (~> 4.1)
75
+ unicode-emoji (4.1.0)
76
+ uri (1.1.1)
77
+ webmock (3.26.1)
78
+ addressable (>= 2.8.0)
79
+ crack (>= 0.3.2)
80
+ hashdiff (>= 0.4.0, < 2.0.0)
81
+
82
+ PLATFORMS
83
+ arm64-darwin-24
84
+ ruby
85
+
86
+ DEPENDENCIES
87
+ bundler (~> 2.0)
88
+ rake (~> 13.0)
89
+ rspec (~> 3.0)
90
+ rubocop (~> 1.0)
91
+ sendly!
92
+ webmock (~> 3.0)
93
+
94
+ BUNDLED WITH
95
+ 2.6.9
data/README.md ADDED
@@ -0,0 +1,234 @@
1
+ # Sendly Ruby SDK
2
+
3
+ Official Ruby SDK for the Sendly SMS API.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem 'sendly'
11
+ ```
12
+
13
+ Then run:
14
+
15
+ ```bash
16
+ bundle install
17
+ ```
18
+
19
+ Or install directly:
20
+
21
+ ```bash
22
+ gem install sendly
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ```ruby
28
+ require 'sendly'
29
+
30
+ # Create a client
31
+ client = Sendly::Client.new("sk_live_v1_your_api_key")
32
+
33
+ # Send an SMS
34
+ message = client.messages.send(
35
+ to: "+15551234567",
36
+ text: "Hello from Sendly!"
37
+ )
38
+
39
+ puts message.id # => "msg_abc123"
40
+ puts message.status # => "queued"
41
+ ```
42
+
43
+ ## Prerequisites for Live Messaging
44
+
45
+ Before sending live SMS messages, you need:
46
+
47
+ 1. **Business Verification** - Complete verification in the [Sendly dashboard](https://sendly.live/dashboard)
48
+ - **International**: Instant approval (just provide Sender ID)
49
+ - **US/Canada**: Requires carrier approval (3-7 business days)
50
+
51
+ 2. **Credits** - Add credits to your account
52
+ - Test keys (`sk_test_*`) work without credits (sandbox mode)
53
+ - Live keys (`sk_live_*`) require credits for each message
54
+
55
+ 3. **Live API Key** - Generate after verification + credits
56
+ - Dashboard → API Keys → Create Live Key
57
+
58
+ ### Test vs Live Keys
59
+
60
+ | Key Type | Prefix | Credits Required | Verification Required | Use Case |
61
+ |----------|--------|------------------|----------------------|----------|
62
+ | Test | `sk_test_v1_*` | No | No | Development, testing |
63
+ | Live | `sk_live_v1_*` | Yes | Yes | Production messaging |
64
+
65
+ > **Note**: You can start development immediately with a test key. Messages to sandbox test numbers are free and don't require verification.
66
+
67
+ ## Configuration
68
+
69
+ ### Global Configuration
70
+
71
+ ```ruby
72
+ Sendly.configure do |config|
73
+ config.api_key = "sk_live_v1_xxx"
74
+ end
75
+
76
+ # Use the default client
77
+ Sendly.send_message(to: "+15551234567", text: "Hello!")
78
+ ```
79
+
80
+ ### Client Options
81
+
82
+ ```ruby
83
+ client = Sendly::Client.new(
84
+ "sk_live_v1_xxx",
85
+ base_url: "https://api.sendly.live/v1",
86
+ timeout: 60,
87
+ max_retries: 5
88
+ )
89
+ ```
90
+
91
+ ## Messages
92
+
93
+ ### Send an SMS
94
+
95
+ ```ruby
96
+ message = client.messages.send(
97
+ to: "+15551234567",
98
+ text: "Hello from Sendly!"
99
+ )
100
+
101
+ puts message.id
102
+ puts message.status
103
+ puts message.credits_used
104
+ ```
105
+
106
+ ### List Messages
107
+
108
+ ```ruby
109
+ # Basic listing
110
+ messages = client.messages.list(limit: 50)
111
+ messages.each { |m| puts m.to }
112
+
113
+ # With filters
114
+ messages = client.messages.list(
115
+ status: "delivered",
116
+ to: "+15551234567",
117
+ limit: 20,
118
+ offset: 0
119
+ )
120
+
121
+ # Pagination info
122
+ puts messages.total
123
+ puts messages.has_more
124
+ ```
125
+
126
+ ### Get a Message
127
+
128
+ ```ruby
129
+ message = client.messages.get("msg_abc123")
130
+
131
+ puts message.to
132
+ puts message.text
133
+ puts message.status
134
+ puts message.delivered_at
135
+ ```
136
+
137
+ ### Iterate All Messages
138
+
139
+ ```ruby
140
+ # Auto-pagination
141
+ client.messages.each do |message|
142
+ puts "#{message.id}: #{message.to}"
143
+ end
144
+
145
+ # With filters
146
+ client.messages.each(status: "delivered") do |message|
147
+ puts "Delivered: #{message.id}"
148
+ end
149
+ ```
150
+
151
+ ## Error Handling
152
+
153
+ ```ruby
154
+ begin
155
+ message = client.messages.send(
156
+ to: "+15551234567",
157
+ text: "Hello!"
158
+ )
159
+ rescue Sendly::AuthenticationError => e
160
+ puts "Invalid API key"
161
+ rescue Sendly::RateLimitError => e
162
+ puts "Rate limited, retry after #{e.retry_after} seconds"
163
+ rescue Sendly::InsufficientCreditsError => e
164
+ puts "Add more credits to your account"
165
+ rescue Sendly::ValidationError => e
166
+ puts "Invalid request: #{e.message}"
167
+ rescue Sendly::NotFoundError => e
168
+ puts "Resource not found"
169
+ rescue Sendly::NetworkError => e
170
+ puts "Network error: #{e.message}"
171
+ rescue Sendly::Error => e
172
+ puts "Error: #{e.message} (#{e.code})"
173
+ end
174
+ ```
175
+
176
+ ## Message Object
177
+
178
+ ```ruby
179
+ message.id # Unique identifier
180
+ message.to # Recipient phone number
181
+ message.text # Message content
182
+ message.status # queued, sending, sent, delivered, failed
183
+ message.credits_used # Credits consumed
184
+ message.created_at # Creation time
185
+ message.updated_at # Last update time
186
+ message.delivered_at # Delivery time (if delivered)
187
+ message.error_code # Error code (if failed)
188
+ message.error_message # Error message (if failed)
189
+
190
+ # Helper methods
191
+ message.delivered? # => true/false
192
+ message.failed? # => true/false
193
+ message.pending? # => true/false
194
+ ```
195
+
196
+ ## Message Status
197
+
198
+ | Status | Description |
199
+ |--------|-------------|
200
+ | `queued` | Message is queued for delivery |
201
+ | `sending` | Message is being sent |
202
+ | `sent` | Message was sent to carrier |
203
+ | `delivered` | Message was delivered |
204
+ | `failed` | Message delivery failed |
205
+
206
+ ## Pricing Tiers
207
+
208
+ | Tier | Countries | Credits per SMS |
209
+ |------|-----------|-----------------|
210
+ | Domestic | US, CA | 1 |
211
+ | Tier 1 | GB, PL, IN, etc. | 8 |
212
+ | Tier 2 | FR, JP, AU, etc. | 12 |
213
+ | Tier 3 | DE, IT, MX, etc. | 16 |
214
+
215
+ ## Sandbox Testing
216
+
217
+ Use test API keys (`sk_test_v1_xxx`) with these test numbers:
218
+
219
+ | Number | Behavior |
220
+ |--------|----------|
221
+ | +15550001234 | Success |
222
+ | +15550001001 | Invalid number |
223
+ | +15550001002 | Carrier rejected |
224
+ | +15550001003 | No credits |
225
+ | +15550001004 | Rate limited |
226
+
227
+ ## Requirements
228
+
229
+ - Ruby 3.0+
230
+ - Faraday 2.0+
231
+
232
+ ## License
233
+
234
+ MIT
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "sendly"
5
+
6
+ client = Sendly::Client.new(ENV["SENDLY_API_KEY"] || "sk_test_v1_example")
7
+
8
+ # List recent messages
9
+ puts "=== Recent Messages ==="
10
+ messages = client.messages.list(limit: 10)
11
+ puts "Total: #{messages.total}"
12
+ puts "Has more: #{messages.has_more}"
13
+ puts
14
+
15
+ messages.each do |msg|
16
+ puts "#{msg.id}: #{msg.to} - #{msg.status}"
17
+ end
18
+
19
+ # List with filters
20
+ puts "\n=== Delivered Messages ==="
21
+ delivered = client.messages.list(status: "delivered", limit: 5)
22
+ delivered.each do |msg|
23
+ puts "#{msg.id}: Delivered at #{msg.delivered_at}"
24
+ end
25
+
26
+ # Iterate all with auto-pagination
27
+ puts "\n=== All Messages (paginated) ==="
28
+ count = 0
29
+ client.messages.each(batch_size: 50) do |msg|
30
+ puts "#{msg.id}: #{msg.to}"
31
+ count += 1
32
+ break if count >= 20 # Limit for demo
33
+ end
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "sendly"
5
+
6
+ # Configure with your API key
7
+ client = Sendly::Client.new(ENV["SENDLY_API_KEY"] || "sk_test_v1_example")
8
+
9
+ # Send an SMS
10
+ begin
11
+ message = client.messages.send(
12
+ to: "+15551234567",
13
+ text: "Hello from Sendly Ruby SDK!"
14
+ )
15
+
16
+ puts "Message sent successfully!"
17
+ puts " ID: #{message.id}"
18
+ puts " To: #{message.to}"
19
+ puts " Status: #{message.status}"
20
+ puts " Credits used: #{message.credits_used}"
21
+ rescue Sendly::AuthenticationError => e
22
+ puts "Authentication failed: #{e.message}"
23
+ rescue Sendly::InsufficientCreditsError => e
24
+ puts "Insufficient credits: #{e.message}"
25
+ rescue Sendly::ValidationError => e
26
+ puts "Validation error: #{e.message}"
27
+ rescue Sendly::RateLimitError => e
28
+ puts "Rate limited. Retry after: #{e.retry_after} seconds"
29
+ rescue Sendly::Error => e
30
+ puts "Error: #{e.message}"
31
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Sendly
8
+ # Main Sendly API client
9
+ class Client
10
+ # @return [String] API key
11
+ attr_reader :api_key
12
+
13
+ # @return [String] Base URL
14
+ attr_reader :base_url
15
+
16
+ # @return [Integer] Request timeout in seconds
17
+ attr_reader :timeout
18
+
19
+ # @return [Integer] Maximum retry attempts
20
+ attr_reader :max_retries
21
+
22
+ # Create a new Sendly client
23
+ #
24
+ # @param api_key [String] Your Sendly API key
25
+ # @param base_url [String] API base URL (optional)
26
+ # @param timeout [Integer] Request timeout in seconds (default: 30)
27
+ # @param max_retries [Integer] Maximum retry attempts (default: 3)
28
+ #
29
+ # @example
30
+ # client = Sendly::Client.new("sk_live_v1_xxx")
31
+ # client = Sendly::Client.new("sk_live_v1_xxx", timeout: 60, max_retries: 5)
32
+ def initialize(api_key:, base_url: nil, timeout: 30, max_retries: 3)
33
+ @api_key = api_key
34
+ @base_url = (base_url || Sendly.base_url).chomp("/")
35
+ @timeout = timeout
36
+ @max_retries = max_retries
37
+
38
+ validate_api_key!
39
+ end
40
+
41
+ # Access the Messages resource
42
+ #
43
+ # @return [Sendly::Messages]
44
+ def messages
45
+ @messages ||= Messages.new(self)
46
+ end
47
+
48
+ # Make a GET request
49
+ #
50
+ # @param path [String] API path
51
+ # @param params [Hash] Query parameters
52
+ # @return [Hash] Response body
53
+ def get(path, params = {})
54
+ request(:get, path, params: params)
55
+ end
56
+
57
+ # Make a POST request
58
+ #
59
+ # @param path [String] API path
60
+ # @param body [Hash] Request body
61
+ # @return [Hash] Response body
62
+ def post(path, body = {})
63
+ request(:post, path, body: body)
64
+ end
65
+
66
+ # Make a DELETE request
67
+ #
68
+ # @param path [String] API path
69
+ # @return [Hash] Response body
70
+ def delete(path)
71
+ request(:delete, path)
72
+ end
73
+
74
+ private
75
+
76
+ def validate_api_key!
77
+ raise AuthenticationError, "API key is required" if api_key.nil? || api_key.empty?
78
+
79
+ unless api_key.match?(/^sk_(test|live)_v1_[a-zA-Z0-9_-]+$/)
80
+ raise AuthenticationError, "Invalid API key format. Expected sk_test_v1_xxx or sk_live_v1_xxx"
81
+ end
82
+ end
83
+
84
+ def request(method, path, params: {}, body: nil)
85
+ uri = build_uri(path, params)
86
+ http = build_http(uri)
87
+ req = build_request(method, uri, body)
88
+
89
+ attempt = 0
90
+ begin
91
+ response = http.request(req)
92
+ handle_response(response)
93
+ rescue Net::OpenTimeout, Net::ReadTimeout
94
+ raise TimeoutError, "Request timed out after #{timeout} seconds"
95
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError => e
96
+ raise NetworkError, "Connection failed: #{e.message}"
97
+ rescue RateLimitError => e
98
+ attempt += 1
99
+ if attempt <= max_retries && e.retry_after
100
+ sleep(e.retry_after)
101
+ retry
102
+ end
103
+ raise
104
+ rescue ServerError => e
105
+ attempt += 1
106
+ if attempt <= max_retries
107
+ sleep(2 ** attempt) # Exponential backoff
108
+ retry
109
+ end
110
+ raise
111
+ end
112
+ end
113
+
114
+ def build_uri(path, params)
115
+ url = "#{base_url}#{path}"
116
+ uri = URI.parse(url)
117
+
118
+ if params.any?
119
+ query = params.map { |k, v| "#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v)}" }.join("&")
120
+ uri.query = query
121
+ end
122
+
123
+ uri
124
+ end
125
+
126
+ def build_http(uri)
127
+ http = Net::HTTP.new(uri.host, uri.port)
128
+ http.use_ssl = uri.scheme == "https"
129
+ http.open_timeout = 10
130
+ http.read_timeout = timeout
131
+ http
132
+ end
133
+
134
+ def build_request(method, uri, body)
135
+ req = case method
136
+ when :get
137
+ Net::HTTP::Get.new(uri)
138
+ when :post
139
+ Net::HTTP::Post.new(uri)
140
+ when :delete
141
+ Net::HTTP::Delete.new(uri)
142
+ else
143
+ raise ArgumentError, "Unsupported HTTP method: #{method}"
144
+ end
145
+
146
+ req["Authorization"] = "Bearer #{api_key}"
147
+ req["Content-Type"] = "application/json"
148
+ req["Accept"] = "application/json"
149
+ req["User-Agent"] = "sendly-ruby/#{VERSION}"
150
+
151
+ req.body = body.to_json if body
152
+
153
+ req
154
+ end
155
+
156
+ def handle_response(response)
157
+ body = parse_body(response.body)
158
+ status = response.code.to_i
159
+
160
+ return body if status >= 200 && status < 300
161
+
162
+ raise ErrorFactory.from_response(status, body)
163
+ end
164
+
165
+ def parse_body(body)
166
+ return {} if body.nil? || body.empty?
167
+
168
+ JSON.parse(body)
169
+ rescue JSON::ParserError
170
+ { "message" => body }
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendly
4
+ # Base error class for all Sendly errors
5
+ class Error < StandardError
6
+ # @return [String, nil] Error code from the API
7
+ attr_reader :code
8
+
9
+ # @return [Hash, nil] Additional error details
10
+ attr_reader :details
11
+
12
+ # @return [Integer, nil] HTTP status code
13
+ attr_reader :status_code
14
+
15
+ def initialize(message = nil, code: nil, details: nil, status_code: nil)
16
+ @code = code
17
+ @details = details
18
+ @status_code = status_code
19
+ super(message)
20
+ end
21
+ end
22
+
23
+ # Raised when the API key is invalid or missing
24
+ class AuthenticationError < Error
25
+ def initialize(message = "Invalid or missing API key")
26
+ super(message, code: "AUTHENTICATION_ERROR", status_code: 401)
27
+ end
28
+ end
29
+
30
+ # Raised when the rate limit is exceeded
31
+ class RateLimitError < Error
32
+ # @return [Integer, nil] Seconds to wait before retrying
33
+ attr_reader :retry_after
34
+
35
+ def initialize(message = "Rate limit exceeded", retry_after: nil)
36
+ @retry_after = retry_after
37
+ super(message, code: "RATE_LIMIT_EXCEEDED", status_code: 429)
38
+ end
39
+ end
40
+
41
+ # Raised when the account has insufficient credits
42
+ class InsufficientCreditsError < Error
43
+ def initialize(message = "Insufficient credits")
44
+ super(message, code: "INSUFFICIENT_CREDITS", status_code: 402)
45
+ end
46
+ end
47
+
48
+ # Raised when the request contains invalid parameters
49
+ class ValidationError < Error
50
+ # @return [Hash, nil] Field-specific validation errors
51
+ attr_reader :field_errors
52
+
53
+ def initialize(message = "Validation failed", field_errors: nil, details: nil)
54
+ @field_errors = field_errors
55
+ super(message, code: "VALIDATION_ERROR", details: details, status_code: 400)
56
+ end
57
+ end
58
+
59
+ # Raised when the requested resource is not found
60
+ class NotFoundError < Error
61
+ def initialize(message = "Resource not found")
62
+ super(message, code: "NOT_FOUND", status_code: 404)
63
+ end
64
+ end
65
+
66
+ # Raised when a network error occurs
67
+ class NetworkError < Error
68
+ def initialize(message = "Network error occurred")
69
+ super(message, code: "NETWORK_ERROR")
70
+ end
71
+ end
72
+
73
+ # Raised when a timeout occurs
74
+ class TimeoutError < NetworkError
75
+ def initialize(message = "Request timed out")
76
+ super(message)
77
+ @code = "TIMEOUT_ERROR"
78
+ end
79
+ end
80
+
81
+ # Raised for unexpected API errors
82
+ class APIError < Error
83
+ def initialize(message = "An unexpected error occurred", status_code: nil, code: nil, details: nil)
84
+ super(message, code: code || "API_ERROR", status_code: status_code, details: details)
85
+ end
86
+ end
87
+
88
+ # Raised for server errors (5xx)
89
+ class ServerError < Error
90
+ def initialize(message = "Server error occurred", status_code: 500)
91
+ super(message, code: "SERVER_ERROR", status_code: status_code)
92
+ end
93
+ end
94
+
95
+ # Convert API response to appropriate error
96
+ class ErrorFactory
97
+ def self.from_response(status, body)
98
+ message = body["message"] || body["error"] || "Unknown error"
99
+ code = body["code"]
100
+ details = body["details"]
101
+
102
+ case status
103
+ when 400, 422
104
+ ValidationError.new(message, details: details)
105
+ when 401
106
+ AuthenticationError.new(message)
107
+ when 402
108
+ InsufficientCreditsError.new(message)
109
+ when 404
110
+ NotFoundError.new(message)
111
+ when 429
112
+ retry_after = body["retry_after"] || body["retryAfter"]
113
+ RateLimitError.new(message, retry_after: retry_after)
114
+ when 500..599
115
+ ServerError.new(message, status_code: status)
116
+ else
117
+ APIError.new(message, status_code: status, code: code, details: details)
118
+ end
119
+ end
120
+ end
121
+ end