ovh-http2sms 0.1.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.
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Ovh
6
+ module Http2sms
7
+ module Generators
8
+ # Rails generator for OVH HTTP2SMS initializer
9
+ #
10
+ # @example
11
+ # rails g ovh:http2sms:install
12
+ class InstallGenerator < Rails::Generators::Base
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ desc "Creates an OVH HTTP2SMS initializer file"
16
+
17
+ # Create the initializer file
18
+ def create_initializer_file
19
+ template "initializer.rb", "config/initializers/ovh_http2sms.rb"
20
+ end
21
+
22
+ # Show instructions after installation
23
+ def show_instructions
24
+ say ""
25
+ say "OVH HTTP2SMS initializer created!", :green
26
+ say ""
27
+ say "Next steps:"
28
+ say " 1. Edit config/initializers/ovh_http2sms.rb with your credentials"
29
+ say " 2. Or set environment variables: OVH_SMS_ACCOUNT, OVH_SMS_LOGIN, OVH_SMS_PASSWORD"
30
+ say " 3. Or use Rails credentials: rails credentials:edit"
31
+ say ""
32
+ say "Example credentials structure:"
33
+ say " ovh_sms:"
34
+ say " account: sms-xx11111-1"
35
+ say " login: your_login"
36
+ say " password: your_password"
37
+ say ""
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ # OVH HTTP2SMS configuration
4
+ #
5
+ # You can configure the gem using:
6
+ # 1. This initializer (direct values or Rails credentials)
7
+ # 2. Environment variables: OVH_SMS_ACCOUNT, OVH_SMS_LOGIN, OVH_SMS_PASSWORD, etc.
8
+ #
9
+ # Environment variables take precedence if this block doesn't set values.
10
+
11
+ Ovh::Http2sms.configure do |config|
12
+ # Required credentials
13
+ # Option 1: Direct configuration (not recommended for production)
14
+ # config.account = "sms-xx11111-1"
15
+ # config.login = "your_login"
16
+ # config.password = "your_password"
17
+
18
+ # Option 2: Using Rails credentials (recommended)
19
+ # Run: rails credentials:edit
20
+ # Add:
21
+ # ovh_sms:
22
+ # account: sms-xx11111-1
23
+ # login: your_login
24
+ # password: your_password
25
+ #
26
+ if Rails.application.credentials.ovh_sms.present?
27
+ config.account = Rails.application.credentials.dig(:ovh_sms, :account)
28
+ config.login = Rails.application.credentials.dig(:ovh_sms, :login)
29
+ config.password = Rails.application.credentials.dig(:ovh_sms, :password)
30
+ config.default_sender = Rails.application.credentials.dig(:ovh_sms, :default_sender)
31
+ end
32
+
33
+ # Optional settings
34
+
35
+ # Default sender name (must be registered in OVH account)
36
+ # config.default_sender = "MyApp"
37
+
38
+ # Default country code for phone number formatting (default: "33" for France)
39
+ # config.default_country_code = "33"
40
+
41
+ # Response content type: application/json, text/xml, text/plain, text/html
42
+ # config.default_content_type = "application/json"
43
+
44
+ # HTTP timeout in seconds (default: 15)
45
+ # config.timeout = 15
46
+
47
+ # Raise error if message exceeds SMS length limits (default: true)
48
+ # config.raise_on_length_error = true
49
+
50
+ # Logger for debugging (uses Rails.logger by default in Rails apps)
51
+ config.logger = Rails.logger if defined?(Rails.logger)
52
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "uri"
5
+
6
+ module Ovh
7
+ module Http2sms
8
+ # HTTP client for OVH HTTP2SMS API
9
+ #
10
+ # Handles building requests, making HTTP calls, and processing responses.
11
+ # Thread-safe for use in multi-threaded environments.
12
+ #
13
+ # @example Direct usage
14
+ # client = Client.new(account: "sms-xx111-1", login: "user", password: "pass")
15
+ # response = client.deliver(to: "33601020304", message: "Hello!")
16
+ class Client
17
+ # @return [Configuration] Client configuration
18
+ attr_reader :config
19
+
20
+ # Initialize a new client
21
+ #
22
+ # @param options [Hash] Configuration options (overrides global config)
23
+ # @option options [String] :account SMS account identifier
24
+ # @option options [String] :login SMS user login
25
+ # @option options [String] :password SMS user password
26
+ # @option options [String] :default_sender Default sender name
27
+ # @option options [String] :default_content_type Response format
28
+ # @option options [Integer] :timeout HTTP timeout in seconds
29
+ # @option options [Logger] :logger Logger for debugging
30
+ # @option options [String] :default_country_code Default country code
31
+ def initialize(**options)
32
+ @config = build_config(options)
33
+ end
34
+
35
+ # Send an SMS message
36
+ #
37
+ # @param to [String, Array<String>] Recipient phone number(s)
38
+ # @param message [String] SMS content
39
+ # @param sender [String, nil] Sender name (uses default if nil)
40
+ # @param deferred [Time, String, nil] Scheduled send time
41
+ # @param tag [String, nil] Custom tag for tracking (max 20 chars)
42
+ # @param sms_class [Integer, nil] SMS class (0-3)
43
+ # @param sms_coding [Integer, nil] Encoding (1=7bit, 2=Unicode)
44
+ # @param no_stop [Boolean] Set to true for non-commercial SMS
45
+ # @param sender_for_response [Boolean] Enable reply capability
46
+ # @param content_type [String, nil] Response format (uses default if nil)
47
+ # @return [Response] Parsed response object
48
+ # @raise [ConfigurationError] if configuration is invalid
49
+ # @raise [ValidationError] if parameters are invalid
50
+ # @raise [NetworkError] if HTTP request fails
51
+ # @raise [AuthenticationError] if IP is not authorized
52
+ # @raise [MissingParameterError] if required parameter is missing
53
+ # @raise [InvalidParameterError] if parameter value is invalid
54
+ #
55
+ # @example Simple send
56
+ # client.deliver(to: "33601020304", message: "Hello!")
57
+ #
58
+ # @example With options
59
+ # client.deliver(
60
+ # to: ["33601020304", "33602030405"],
61
+ # message: "Meeting at 3pm",
62
+ # sender: "MyCompany",
63
+ # deferred: 1.hour.from_now,
64
+ # tag: "reminders"
65
+ # )
66
+ # rubocop:disable Metrics/ParameterLists
67
+ def deliver(to:, message:, sender: nil, deferred: nil, tag: nil,
68
+ sms_class: nil, sms_coding: nil, no_stop: false,
69
+ sender_for_response: false, content_type: nil)
70
+ # rubocop:enable Metrics/ParameterLists
71
+ @config.validate!
72
+
73
+ params = build_delivery_params(to, message, sender, deferred, tag, sms_class,
74
+ sms_coding, no_stop, sender_for_response)
75
+ Validators.validate!(params)
76
+
77
+ query_params = build_query_params(params, content_type)
78
+ execute_request(query_params, content_type)
79
+ end
80
+
81
+ ERROR_HANDLERS = {
82
+ 401 => AuthenticationError,
83
+ 201 => MissingParameterError,
84
+ 202 => InvalidParameterError,
85
+ 241 => SenderNotFoundError
86
+ }.freeze
87
+ private_constant :ERROR_HANDLERS
88
+
89
+ private
90
+
91
+ # rubocop:disable Metrics/ParameterLists
92
+ def build_delivery_params(to, message, sender, deferred, tag, sms_class,
93
+ sms_coding, no_stop, sender_for_response)
94
+ # rubocop:enable Metrics/ParameterLists
95
+ {
96
+ to: to, message: message, sender: sender, deferred: deferred, tag: tag,
97
+ sms_class: sms_class, sms_coding: sms_coding, no_stop: no_stop,
98
+ sender_for_response: sender_for_response
99
+ }
100
+ end
101
+
102
+ def build_config(options)
103
+ return Ovh::Http2sms.configuration.dup if options.empty?
104
+
105
+ Configuration.new.tap { |config| merge_config_options(config, options) }
106
+ end
107
+
108
+ def merge_config_options(config, options)
109
+ global = Ovh::Http2sms.configuration
110
+ merge_credentials(config, options, global)
111
+ merge_defaults(config, options, global)
112
+ end
113
+
114
+ def merge_credentials(config, options, global)
115
+ config.account = options[:account] || global.account
116
+ config.login = options[:login] || global.login
117
+ config.password = options[:password] || global.password
118
+ end
119
+
120
+ def merge_defaults(config, options, global)
121
+ config.default_sender = options[:default_sender] || global.default_sender
122
+ config.default_content_type = options[:default_content_type] || global.default_content_type
123
+ config.timeout = options[:timeout] || global.timeout
124
+ config.logger = options[:logger] || global.logger
125
+ config.default_country_code = options[:default_country_code] || global.default_country_code
126
+ config.raise_on_length_error = options.fetch(:raise_on_length_error, global.raise_on_length_error)
127
+ config.api_endpoint = options[:api_endpoint] || global.api_endpoint
128
+ end
129
+
130
+ def build_query_params(params, content_type)
131
+ query = build_base_params(params)
132
+ add_sender_params(query, params)
133
+ add_optional_params(query, params)
134
+ query[:contentType] = content_type || @config.default_content_type
135
+ query
136
+ end
137
+
138
+ def build_base_params(params)
139
+ {
140
+ account: @config.account,
141
+ login: @config.login,
142
+ password: @config.password,
143
+ to: format_recipients(params[:to]),
144
+ message: encode_message(params[:message])
145
+ }
146
+ end
147
+
148
+ def add_sender_params(query, params)
149
+ if params[:sender_for_response]
150
+ query[:from] = ""
151
+ query[:senderForResponse] = "1"
152
+ else
153
+ sender = params[:sender] || @config.default_sender
154
+ query[:from] = sender if sender
155
+ end
156
+ end
157
+
158
+ def add_optional_params(query, params)
159
+ query[:deferred] = format_deferred(params[:deferred]) if params[:deferred]
160
+ query[:tag] = params[:tag] if params[:tag]
161
+ query[:class] = params[:sms_class].to_s if params[:sms_class]
162
+ query[:smsCoding] = params[:sms_coding].to_s if params[:sms_coding]
163
+ query[:noStop] = "1" if params[:no_stop]
164
+ end
165
+
166
+ def format_recipients(to)
167
+ phones = PhoneNumber.format_multiple(to, country_code: @config.default_country_code)
168
+ phones.join(",")
169
+ end
170
+
171
+ def encode_message(message)
172
+ # URL encoding is handled by Faraday, but we need to handle line breaks
173
+ # OVH uses %0d for line breaks in the URL
174
+ message.to_s.gsub("\n", "%0d").gsub("\r", "")
175
+ end
176
+
177
+ def format_deferred(deferred)
178
+ if deferred.respond_to?(:strftime)
179
+ # Format Time/DateTime as hhmmddMMYYYY
180
+ deferred.strftime("%H%M%d%m%Y")
181
+ else
182
+ deferred.to_s
183
+ end
184
+ end
185
+
186
+ def execute_request(query_params, content_type)
187
+ log_request(query_params)
188
+
189
+ response = make_http_request(query_params)
190
+ parsed_response = parse_response(response, content_type)
191
+
192
+ log_response(parsed_response)
193
+ handle_error_response(parsed_response) if parsed_response.failure?
194
+
195
+ parsed_response
196
+ rescue Faraday::Error => e
197
+ raise NetworkError.new(
198
+ "HTTP request failed: #{e.message}",
199
+ original_error: e
200
+ )
201
+ end
202
+
203
+ def make_http_request(query_params)
204
+ connection.get do |req|
205
+ req.params = query_params
206
+ end
207
+ end
208
+
209
+ def connection
210
+ @connection ||= Faraday.new(url: @config.api_endpoint) do |faraday|
211
+ faraday.options.timeout = @config.timeout
212
+ faraday.options.open_timeout = @config.timeout
213
+ faraday.adapter Faraday.default_adapter
214
+ end
215
+ end
216
+
217
+ def parse_response(http_response, content_type)
218
+ Response.parse(
219
+ http_response.body,
220
+ content_type: content_type || @config.default_content_type
221
+ )
222
+ end
223
+
224
+ def handle_error_response(response)
225
+ error_class = ERROR_HANDLERS[response.status]
226
+ return unless error_class
227
+
228
+ message = response.error_message || default_error_message(response.status)
229
+ raise error_class.new(message, raw_response: response.raw_response)
230
+ end
231
+
232
+ def default_error_message(status)
233
+ status == 401 ? "IP not authorized" : nil
234
+ end
235
+
236
+ def log_request(params)
237
+ return unless @config.logger
238
+
239
+ safe_params = params.dup
240
+ safe_params[:password] = "[FILTERED]"
241
+ @config.logger.debug("[OVH HTTP2SMS] Request: #{safe_params}")
242
+ end
243
+
244
+ def log_response(response)
245
+ return unless @config.logger
246
+
247
+ @config.logger.debug(
248
+ "[OVH HTTP2SMS] Response: status=#{response.status} success=#{response.success?}"
249
+ )
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ovh
4
+ module Http2sms
5
+ # Configuration class for OVH HTTP2SMS gem
6
+ #
7
+ # @example Block configuration
8
+ # Ovh::Http2sms.configure do |config|
9
+ # config.account = "sms-xx11111-1"
10
+ # config.login = "user"
11
+ # config.password = "secret"
12
+ # end
13
+ #
14
+ # @example Environment variables
15
+ # # Set OVH_SMS_ACCOUNT, OVH_SMS_LOGIN, OVH_SMS_PASSWORD
16
+ # Ovh::Http2sms.deliver(to: "33601020304", message: "Hello!")
17
+ #
18
+ class Configuration
19
+ # @return [String, nil] SMS account identifier (ex: sms-xx11111-1)
20
+ attr_accessor :account
21
+
22
+ # @return [String, nil] SMS user login
23
+ attr_accessor :login
24
+
25
+ # @return [String, nil] SMS user password
26
+ attr_accessor :password
27
+
28
+ # @return [String, nil] Default sender name
29
+ attr_accessor :default_sender
30
+
31
+ # @return [String] Default response content type
32
+ attr_accessor :default_content_type
33
+
34
+ # @return [Integer] HTTP request timeout in seconds
35
+ attr_accessor :timeout
36
+
37
+ # @return [Logger, nil] Optional logger for debugging
38
+ attr_accessor :logger
39
+
40
+ # @return [String] Default country code for phone number formatting
41
+ attr_accessor :default_country_code
42
+
43
+ # @return [Boolean] Whether to raise errors on message length violations
44
+ attr_accessor :raise_on_length_error
45
+
46
+ # @return [String] API endpoint URL
47
+ attr_accessor :api_endpoint
48
+
49
+ # Environment variable prefix
50
+ ENV_PREFIX = "OVH_SMS_"
51
+
52
+ # Default values
53
+ DEFAULTS = {
54
+ default_content_type: "application/json",
55
+ timeout: 15,
56
+ default_country_code: "33",
57
+ raise_on_length_error: true,
58
+ api_endpoint: "https://www.ovh.com/cgi-bin/sms/http2sms.cgi"
59
+ }.freeze
60
+
61
+ def initialize
62
+ reset!
63
+ end
64
+
65
+ # Reset configuration to defaults and environment variables
66
+ #
67
+ # @return [void]
68
+ def reset!
69
+ # Set defaults
70
+ DEFAULTS.each do |key, value|
71
+ send("#{key}=", value)
72
+ end
73
+
74
+ # Clear credentials
75
+ self.account = nil
76
+ self.login = nil
77
+ self.password = nil
78
+ self.default_sender = nil
79
+ self.logger = nil
80
+
81
+ # Load from environment variables
82
+ load_from_env
83
+ end
84
+
85
+ # Check if the configuration is valid for making API requests
86
+ #
87
+ # @return [Boolean] true if required fields are present
88
+ def valid?
89
+ !account.nil? && !account.empty? &&
90
+ !login.nil? && !login.empty? &&
91
+ !password.nil? && !password.empty?
92
+ end
93
+
94
+ # Validate configuration and raise error if invalid
95
+ #
96
+ # @raise [ConfigurationError] if configuration is invalid
97
+ # @return [void]
98
+ def validate!
99
+ missing = find_missing_credentials
100
+ return if missing.empty?
101
+
102
+ raise ConfigurationError, build_validation_error_message(missing)
103
+ end
104
+
105
+ def find_missing_credentials
106
+ missing = []
107
+ missing << "account" if blank?(account)
108
+ missing << "login" if blank?(login)
109
+ missing << "password" if blank?(password)
110
+ missing
111
+ end
112
+
113
+ def blank?(value)
114
+ value.nil? || value.empty?
115
+ end
116
+
117
+ def build_validation_error_message(missing)
118
+ env_vars = missing.map { |m| "#{ENV_PREFIX}#{m.upcase}" }.join(", ")
119
+ "Missing required configuration: #{missing.join(", ")}. " \
120
+ "Set via Ovh::Http2sms.configure block or environment variables (#{env_vars})"
121
+ end
122
+
123
+ private
124
+
125
+ def load_from_env
126
+ self.account ||= ENV.fetch("#{ENV_PREFIX}ACCOUNT", nil)
127
+ self.login ||= ENV.fetch("#{ENV_PREFIX}LOGIN", nil)
128
+ self.password ||= ENV.fetch("#{ENV_PREFIX}PASSWORD", nil)
129
+ self.default_sender ||= ENV.fetch("#{ENV_PREFIX}DEFAULT_SENDER", nil)
130
+ self.default_content_type = ENV.fetch("#{ENV_PREFIX}DEFAULT_CONTENT_TYPE", default_content_type)
131
+ self.default_country_code = ENV.fetch("#{ENV_PREFIX}DEFAULT_COUNTRY_CODE", default_country_code)
132
+
133
+ env_timeout = ENV.fetch("#{ENV_PREFIX}TIMEOUT", nil)
134
+ self.timeout = env_timeout.to_i if env_timeout
135
+
136
+ env_raise_on_length = ENV.fetch("#{ENV_PREFIX}RAISE_ON_LENGTH_ERROR", nil)
137
+ self.raise_on_length_error = env_raise_on_length != "false" if env_raise_on_length
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ovh
4
+ module Http2sms
5
+ # Base error class for all OVH HTTP2SMS errors
6
+ class Error < StandardError
7
+ # @return [Integer, nil] API status code if available
8
+ attr_reader :status_code
9
+
10
+ # @return [String, nil] Raw API response if available
11
+ attr_reader :raw_response
12
+
13
+ def initialize(message = nil, status_code: nil, raw_response: nil)
14
+ @status_code = status_code
15
+ @raw_response = raw_response
16
+ super(message)
17
+ end
18
+ end
19
+
20
+ # Raised when configuration is missing or invalid
21
+ #
22
+ # @example
23
+ # raise ConfigurationError, "Missing required configuration: account"
24
+ class ConfigurationError < Error; end
25
+
26
+ # Raised when authentication fails (API status 401)
27
+ #
28
+ # @example
29
+ # raise AuthenticationError.new("IP not authorized", status_code: 401)
30
+ class AuthenticationError < Error
31
+ def initialize(message = "Authentication failed: IP not authorized", **options)
32
+ super(message, **options.merge(status_code: 401))
33
+ end
34
+ end
35
+
36
+ # Raised when a required parameter is missing (API status 201)
37
+ #
38
+ # @example
39
+ # raise MissingParameterError.new("Missing message")
40
+ class MissingParameterError < Error
41
+ # @return [String, nil] Name of the missing parameter
42
+ attr_reader :parameter
43
+
44
+ def initialize(message = "Missing required parameter", parameter: nil, **options)
45
+ @parameter = parameter
46
+ super(message, **options.merge(status_code: 201))
47
+ end
48
+ end
49
+
50
+ # Raised when a parameter value is invalid (API status 202)
51
+ #
52
+ # @example
53
+ # raise InvalidParameterError.new("Invalid tag: is too long", parameter: "tag")
54
+ class InvalidParameterError < Error
55
+ # @return [String, nil] Name of the invalid parameter
56
+ attr_reader :parameter
57
+
58
+ def initialize(message = "Invalid parameter", parameter: nil, **options)
59
+ @parameter = parameter
60
+ super(message, **options.merge(status_code: 202))
61
+ end
62
+ end
63
+
64
+ # Raised when a network error occurs (timeout, connection failure, etc.)
65
+ #
66
+ # @example
67
+ # raise NetworkError.new("Connection timed out")
68
+ class NetworkError < Error
69
+ # @return [Exception, nil] Original exception that caused the network error
70
+ attr_reader :original_error
71
+
72
+ def initialize(message = "Network error occurred", original_error: nil, **options)
73
+ @original_error = original_error
74
+ super(message, **options)
75
+ end
76
+ end
77
+
78
+ # Raised when message length exceeds SMS limits
79
+ #
80
+ # @example
81
+ # raise MessageLengthError.new("Message exceeds GSM encoding limit", encoding: :gsm, length: 161)
82
+ class MessageLengthError < Error
83
+ # @return [Symbol] Encoding type (:gsm or :unicode)
84
+ attr_reader :encoding
85
+
86
+ # @return [Integer] Actual message length
87
+ attr_reader :length
88
+
89
+ # @return [Integer] Maximum allowed length
90
+ attr_reader :max_length
91
+
92
+ def initialize(message = "Message length error", encoding: nil, length: nil, max_length: nil, **options)
93
+ @encoding = encoding
94
+ @length = length
95
+ @max_length = max_length
96
+ super(message, **options)
97
+ end
98
+ end
99
+
100
+ # Raised when phone number format is invalid
101
+ #
102
+ # @example
103
+ # raise PhoneNumberError.new("Invalid phone number format", phone_number: "abc123")
104
+ class PhoneNumberError < Error
105
+ # @return [String, nil] The invalid phone number
106
+ attr_reader :phone_number
107
+
108
+ def initialize(message = "Invalid phone number", phone_number: nil, **options)
109
+ @phone_number = phone_number
110
+ super(message, **options)
111
+ end
112
+ end
113
+
114
+ # Raised when validation fails before sending
115
+ #
116
+ # @example
117
+ # raise ValidationError.new("Tag exceeds maximum length of 20 characters")
118
+ class ValidationError < Error; end
119
+
120
+ # Raised when the sender does not exist (API status 241)
121
+ #
122
+ # @example
123
+ # raise SenderNotFoundError.new("Sender 'MyApp' not found")
124
+ class SenderNotFoundError < Error
125
+ # @return [String, nil] The sender that was not found
126
+ attr_reader :sender
127
+
128
+ def initialize(message = "Sender not found", sender: nil, **options)
129
+ @sender = sender
130
+ super(message, **options.merge(status_code: 241))
131
+ end
132
+ end
133
+
134
+ # Raised when the API response cannot be parsed
135
+ #
136
+ # @example
137
+ # raise ResponseParseError.new("Invalid JSON", raw_response: "not json")
138
+ class ResponseParseError < Error
139
+ # @return [String, nil] The content type that failed to parse
140
+ attr_reader :content_type
141
+
142
+ def initialize(message = "Failed to parse API response", content_type: nil, **options)
143
+ @content_type = content_type
144
+ super(message, **options)
145
+ end
146
+ end
147
+ end
148
+ end