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.
- checksums.yaml +7 -0
- data/.dockerignore +9 -0
- data/.env.example +12 -0
- data/CHANGELOG.md +50 -0
- data/Dockerfile +25 -0
- data/Gemfile +13 -0
- data/LICENSE.txt +21 -0
- data/README.md +503 -0
- data/Rakefile +12 -0
- data/docker-compose.yml +29 -0
- data/lib/generators/ovh/http2sms/install_generator.rb +42 -0
- data/lib/generators/ovh/http2sms/templates/initializer.rb +52 -0
- data/lib/ovh/http2sms/client.rb +253 -0
- data/lib/ovh/http2sms/configuration.rb +141 -0
- data/lib/ovh/http2sms/errors.rb +148 -0
- data/lib/ovh/http2sms/gsm_encoding.rb +169 -0
- data/lib/ovh/http2sms/phone_number.rb +134 -0
- data/lib/ovh/http2sms/railtie.rb +23 -0
- data/lib/ovh/http2sms/response.rb +259 -0
- data/lib/ovh/http2sms/validators.rb +190 -0
- data/lib/ovh/http2sms/version.rb +7 -0
- data/lib/ovh/http2sms.rb +151 -0
- data/ovh-http2sms.gemspec +47 -0
- data/sig/ovh/http2sms.rbs +6 -0
- metadata +205 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "gsm_encoder"
|
|
4
|
+
|
|
5
|
+
module Ovh
|
|
6
|
+
module Http2sms
|
|
7
|
+
# GSM 03.38 encoding utilities for SMS character counting
|
|
8
|
+
#
|
|
9
|
+
# Uses the gsm_encoder gem for character validation and provides
|
|
10
|
+
# OVH-specific SMS limits accounting for the STOP clause.
|
|
11
|
+
#
|
|
12
|
+
# Standard SMS messages use GSM 7-bit encoding which allows 160 characters.
|
|
13
|
+
# Extension characters (β¬, |, ^, {, }, [, ], ~, \) count as 2 characters.
|
|
14
|
+
# Non-GSM characters force Unicode encoding which limits messages to 70 characters.
|
|
15
|
+
#
|
|
16
|
+
# Commercial SMS must include STOP clause which uses 11 characters,
|
|
17
|
+
# reducing the first SMS limit.
|
|
18
|
+
module GsmEncoding
|
|
19
|
+
# SMS limits for GSM 7-bit encoding
|
|
20
|
+
GSM_SINGLE_SMS_LIMIT = 160
|
|
21
|
+
GSM_CONCAT_SMS_LIMIT = 153 # 7 chars used for UDH header in concatenated SMS
|
|
22
|
+
GSM_FIRST_COMMERCIAL_LIMIT = 149 # After STOP clause (11 chars)
|
|
23
|
+
GSM_CONCAT_COMMERCIAL_LIMIT = 153
|
|
24
|
+
|
|
25
|
+
# SMS limits for Unicode encoding
|
|
26
|
+
UNICODE_SINGLE_SMS_LIMIT = 70
|
|
27
|
+
UNICODE_CONCAT_SMS_LIMIT = 67 # 3 chars used for UDH header
|
|
28
|
+
UNICODE_FIRST_COMMERCIAL_LIMIT = 59 # After STOP clause (11 chars)
|
|
29
|
+
UNICODE_CONCAT_COMMERCIAL_LIMIT = 70
|
|
30
|
+
|
|
31
|
+
# Extension characters that count as 2 in GSM encoding
|
|
32
|
+
EXTENSION_CHARACTERS = Set["β¬", "|", "^", "{", "}", "[", "]", "~", "\\"].freeze
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# Check if a message contains only GSM 03.38 characters
|
|
36
|
+
#
|
|
37
|
+
# @param message [String] The message to check
|
|
38
|
+
# @return [Boolean] true if all characters are GSM compatible
|
|
39
|
+
#
|
|
40
|
+
# @example
|
|
41
|
+
# GsmEncoding.gsm_compatible?("Hello!") # => true
|
|
42
|
+
# GsmEncoding.gsm_compatible?("Hello π") # => false
|
|
43
|
+
def gsm_compatible?(message)
|
|
44
|
+
GSMEncoder.can_encode?(message)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Detect the required encoding for a message
|
|
48
|
+
#
|
|
49
|
+
# @param message [String] The message to analyze
|
|
50
|
+
# @return [Symbol] :gsm or :unicode
|
|
51
|
+
#
|
|
52
|
+
# @example
|
|
53
|
+
# GsmEncoding.detect_encoding("Hello!") # => :gsm
|
|
54
|
+
# GsmEncoding.detect_encoding("ΠΡΠΈΠ²Π΅Ρ") # => :unicode
|
|
55
|
+
def detect_encoding(message)
|
|
56
|
+
gsm_compatible?(message) ? :gsm : :unicode
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Calculate the GSM character count (extension chars count as 2)
|
|
60
|
+
#
|
|
61
|
+
# @param message [String] The message to count
|
|
62
|
+
# @return [Integer] Character count in GSM encoding
|
|
63
|
+
#
|
|
64
|
+
# @example
|
|
65
|
+
# GsmEncoding.gsm_char_count("Hello") # => 5
|
|
66
|
+
# GsmEncoding.gsm_char_count("Price: β¬10") # => 11 (β¬ counts as 2)
|
|
67
|
+
def gsm_char_count(message)
|
|
68
|
+
message.each_char.sum do |char|
|
|
69
|
+
EXTENSION_CHARACTERS.include?(char) ? 2 : 1
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Find all non-GSM characters in a message
|
|
74
|
+
#
|
|
75
|
+
# @param message [String] The message to check
|
|
76
|
+
# @return [Array<String>] Array of non-GSM characters found
|
|
77
|
+
#
|
|
78
|
+
# @example
|
|
79
|
+
# GsmEncoding.non_gsm_characters("Hello π World") # => ["π"]
|
|
80
|
+
def non_gsm_characters(message)
|
|
81
|
+
message.each_char.reject { |char| can_encode_char?(char) }.uniq
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Calculate message info including SMS count
|
|
85
|
+
#
|
|
86
|
+
# @param message [String] The message to analyze
|
|
87
|
+
# @param commercial [Boolean] Whether this is a commercial SMS (includes STOP clause)
|
|
88
|
+
# @return [Hash] Message information with keys:
|
|
89
|
+
# - :characters - Character count (accounting for extension chars in GSM)
|
|
90
|
+
# - :encoding - :gsm or :unicode
|
|
91
|
+
# - :sms_count - Number of SMS segments required
|
|
92
|
+
# - :remaining - Characters remaining in current segment
|
|
93
|
+
# - :max_single_sms - Maximum chars for single SMS with this encoding
|
|
94
|
+
# - :non_gsm_chars - Array of non-GSM characters found (empty for GSM encoding)
|
|
95
|
+
#
|
|
96
|
+
# @example
|
|
97
|
+
# GsmEncoding.message_info("Hello!")
|
|
98
|
+
# # => { characters: 6, encoding: :gsm, sms_count: 1, remaining: 143, ... }
|
|
99
|
+
def message_info(message, commercial: true)
|
|
100
|
+
encoding = detect_encoding(message)
|
|
101
|
+
|
|
102
|
+
if encoding == :gsm
|
|
103
|
+
gsm_message_info(message, commercial: commercial)
|
|
104
|
+
else
|
|
105
|
+
unicode_message_info(message, commercial: commercial)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def can_encode_char?(char)
|
|
112
|
+
GSMEncoder.can_encode?(char)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def gsm_message_info(message, commercial:)
|
|
116
|
+
char_count = gsm_char_count(message)
|
|
117
|
+
|
|
118
|
+
first_limit = commercial ? GSM_FIRST_COMMERCIAL_LIMIT : GSM_SINGLE_SMS_LIMIT
|
|
119
|
+
concat_limit = commercial ? GSM_CONCAT_COMMERCIAL_LIMIT : GSM_CONCAT_SMS_LIMIT
|
|
120
|
+
|
|
121
|
+
sms_count, remaining = calculate_sms_count(char_count, first_limit, concat_limit)
|
|
122
|
+
|
|
123
|
+
{
|
|
124
|
+
characters: char_count,
|
|
125
|
+
encoding: :gsm,
|
|
126
|
+
sms_count: sms_count,
|
|
127
|
+
remaining: remaining,
|
|
128
|
+
max_single_sms: first_limit,
|
|
129
|
+
non_gsm_chars: []
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def unicode_message_info(message, commercial:)
|
|
134
|
+
char_count = message.length
|
|
135
|
+
|
|
136
|
+
first_limit = commercial ? UNICODE_FIRST_COMMERCIAL_LIMIT : UNICODE_SINGLE_SMS_LIMIT
|
|
137
|
+
concat_limit = commercial ? UNICODE_CONCAT_COMMERCIAL_LIMIT : UNICODE_CONCAT_SMS_LIMIT
|
|
138
|
+
|
|
139
|
+
sms_count, remaining = calculate_sms_count(char_count, first_limit, concat_limit)
|
|
140
|
+
|
|
141
|
+
{
|
|
142
|
+
characters: char_count,
|
|
143
|
+
encoding: :unicode,
|
|
144
|
+
sms_count: sms_count,
|
|
145
|
+
remaining: remaining,
|
|
146
|
+
max_single_sms: first_limit,
|
|
147
|
+
non_gsm_chars: non_gsm_characters(message)
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def calculate_sms_count(char_count, first_limit, concat_limit)
|
|
152
|
+
if char_count <= first_limit
|
|
153
|
+
[1, first_limit - char_count]
|
|
154
|
+
else
|
|
155
|
+
# Multi-part SMS calculation
|
|
156
|
+
remaining_after_first = char_count - first_limit
|
|
157
|
+
additional_sms = (remaining_after_first.to_f / concat_limit).ceil
|
|
158
|
+
total_sms = 1 + additional_sms
|
|
159
|
+
|
|
160
|
+
total_capacity = first_limit + (additional_sms * concat_limit)
|
|
161
|
+
remaining = total_capacity - char_count
|
|
162
|
+
|
|
163
|
+
[total_sms, remaining]
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ovh
|
|
4
|
+
module Http2sms
|
|
5
|
+
# Phone number formatting and validation utilities
|
|
6
|
+
#
|
|
7
|
+
# Converts local phone number formats to international format required by OVH API.
|
|
8
|
+
# OVH requires the 00 prefix (e.g., 0033601020304 for French numbers).
|
|
9
|
+
# Supports configurable country codes for different regions.
|
|
10
|
+
#
|
|
11
|
+
# @example Convert French number
|
|
12
|
+
# PhoneNumber.format("0601020304") # => "0033601020304"
|
|
13
|
+
#
|
|
14
|
+
# @example Convert UK number
|
|
15
|
+
# PhoneNumber.format("07911123456", country_code: "44") # => "00447911123456"
|
|
16
|
+
module PhoneNumber
|
|
17
|
+
# Pattern for numbers starting with 0 but not 00 (local format)
|
|
18
|
+
LOCAL_FORMAT_PATTERN = /\A0(?!0)/
|
|
19
|
+
|
|
20
|
+
# Pattern for numbers starting with + (international format with plus)
|
|
21
|
+
PLUS_FORMAT_PATTERN = /\A\+/
|
|
22
|
+
|
|
23
|
+
# Pattern for numbers starting with 00 (already OVH format)
|
|
24
|
+
DOUBLE_ZERO_PATTERN = /\A00/
|
|
25
|
+
|
|
26
|
+
# Valid phone number pattern (digits only, 9-17 digits for 00 prefix format)
|
|
27
|
+
VALID_PHONE_PATTERN = /\A00\d{7,15}\z/
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# Format a phone number to OVH international format (00 prefix)
|
|
31
|
+
#
|
|
32
|
+
# @param phone [String] Phone number in local or international format
|
|
33
|
+
# @param country_code [String] Country code to use for local numbers (default: from config)
|
|
34
|
+
# @return [String] Phone number in OVH format (e.g., "0033601020304")
|
|
35
|
+
# @raise [PhoneNumberError] if phone number is invalid
|
|
36
|
+
#
|
|
37
|
+
# @example Local French number
|
|
38
|
+
# PhoneNumber.format("0601020304") # => "0033601020304"
|
|
39
|
+
#
|
|
40
|
+
# @example Already international with +
|
|
41
|
+
# PhoneNumber.format("+33601020304") # => "0033601020304"
|
|
42
|
+
#
|
|
43
|
+
# @example Already OVH format
|
|
44
|
+
# PhoneNumber.format("0033601020304") # => "0033601020304"
|
|
45
|
+
#
|
|
46
|
+
# @example UK number
|
|
47
|
+
# PhoneNumber.format("07911123456", country_code: "44") # => "00447911123456"
|
|
48
|
+
def format(phone, country_code: nil)
|
|
49
|
+
return nil if phone.nil?
|
|
50
|
+
|
|
51
|
+
country_code ||= Ovh::Http2sms.configuration.default_country_code
|
|
52
|
+
|
|
53
|
+
# Remove all non-digit characters except leading +
|
|
54
|
+
cleaned = clean_phone(phone)
|
|
55
|
+
|
|
56
|
+
# Convert to OVH international format (00 prefix)
|
|
57
|
+
formatted = to_ovh_format(cleaned, country_code)
|
|
58
|
+
|
|
59
|
+
# Validate the result
|
|
60
|
+
validate!(formatted)
|
|
61
|
+
|
|
62
|
+
formatted
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Format multiple phone numbers
|
|
66
|
+
#
|
|
67
|
+
# @param phones [Array<String>, String] Phone number(s) - can be array or comma-separated string
|
|
68
|
+
# @param country_code [String] Country code to use for local numbers
|
|
69
|
+
# @return [Array<String>] Array of formatted phone numbers
|
|
70
|
+
# @raise [PhoneNumberError] if any phone number is invalid
|
|
71
|
+
#
|
|
72
|
+
# @example Array input
|
|
73
|
+
# PhoneNumber.format_multiple(["0601020304", "0602030405"])
|
|
74
|
+
# # => ["0033601020304", "0033602030405"]
|
|
75
|
+
#
|
|
76
|
+
# @example Comma-separated string
|
|
77
|
+
# PhoneNumber.format_multiple("0601020304,0602030405")
|
|
78
|
+
# # => ["0033601020304", "0033602030405"]
|
|
79
|
+
def format_multiple(phones, country_code: nil)
|
|
80
|
+
phone_array = phones.is_a?(Array) ? phones : phones.to_s.split(",")
|
|
81
|
+
phone_array.map { |p| format(p.strip, country_code: country_code) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Validate a phone number format
|
|
85
|
+
#
|
|
86
|
+
# @param phone [String] Phone number to validate
|
|
87
|
+
# @return [Boolean] true if valid
|
|
88
|
+
def valid?(phone)
|
|
89
|
+
return false if phone.nil? || phone.empty?
|
|
90
|
+
|
|
91
|
+
phone.match?(VALID_PHONE_PATTERN)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Validate a phone number and raise error if invalid
|
|
95
|
+
#
|
|
96
|
+
# @param phone [String] Phone number to validate
|
|
97
|
+
# @raise [PhoneNumberError] if phone number is invalid
|
|
98
|
+
# @return [void]
|
|
99
|
+
def validate!(phone)
|
|
100
|
+
return if valid?(phone)
|
|
101
|
+
|
|
102
|
+
raise PhoneNumberError.new(
|
|
103
|
+
"Invalid phone number format: '#{phone}'. " \
|
|
104
|
+
"Expected OVH format with 00 prefix (e.g., 0033601020304)",
|
|
105
|
+
phone_number: phone
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def clean_phone(phone)
|
|
112
|
+
# Remove all whitespace, dashes, dots, and parentheses
|
|
113
|
+
phone.to_s.gsub(/[\s\-.()\[\]]/, "")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def to_ovh_format(phone, country_code)
|
|
117
|
+
if phone.match?(PLUS_FORMAT_PATTERN)
|
|
118
|
+
# +33601020304 -> 0033601020304
|
|
119
|
+
"00#{phone.sub(PLUS_FORMAT_PATTERN, "")}"
|
|
120
|
+
elsif phone.match?(DOUBLE_ZERO_PATTERN)
|
|
121
|
+
# Already in OVH format: 0033601020304
|
|
122
|
+
phone
|
|
123
|
+
elsif phone.match?(LOCAL_FORMAT_PATTERN)
|
|
124
|
+
# 0601020304 -> 0033601020304
|
|
125
|
+
"00#{country_code}#{phone.sub(LOCAL_FORMAT_PATTERN, "")}"
|
|
126
|
+
else
|
|
127
|
+
# Assume raw country code format: 33601020304 -> 0033601020304
|
|
128
|
+
"00#{phone}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ovh
|
|
4
|
+
module Http2sms
|
|
5
|
+
# Rails integration for OVH HTTP2SMS
|
|
6
|
+
#
|
|
7
|
+
# Automatically loads generators and sets up Rails-specific defaults.
|
|
8
|
+
class Railtie < Rails::Railtie
|
|
9
|
+
generators do
|
|
10
|
+
require_relative "../../generators/ovh/http2sms/install_generator"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
initializer "ovh_http2sms.set_defaults" do
|
|
14
|
+
# Set Rails logger as default if not already configured
|
|
15
|
+
config.after_initialize do
|
|
16
|
+
if Ovh::Http2sms.configuration.logger.nil? && defined?(Rails.logger)
|
|
17
|
+
Ovh::Http2sms.configuration.logger = Rails.logger
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ovh
|
|
6
|
+
module Http2sms
|
|
7
|
+
# Response object for OVH HTTP2SMS API responses
|
|
8
|
+
#
|
|
9
|
+
# Parses responses in all supported formats: JSON, XML, HTML, and text/plain.
|
|
10
|
+
# Provides a unified interface for accessing response data.
|
|
11
|
+
#
|
|
12
|
+
# @example Successful response
|
|
13
|
+
# response = Response.parse(body, content_type: "application/json")
|
|
14
|
+
# response.success? # => true
|
|
15
|
+
# response.sms_ids # => ["123456789"]
|
|
16
|
+
# response.credits_remaining # => 1987.0
|
|
17
|
+
#
|
|
18
|
+
# @example Error response
|
|
19
|
+
# response = Response.parse(body, content_type: "application/json")
|
|
20
|
+
# response.success? # => false
|
|
21
|
+
# response.error_message # => "Missing message"
|
|
22
|
+
class Response
|
|
23
|
+
# @return [Integer] API status code (100, 101 = success; 201, 202, 401 = error)
|
|
24
|
+
attr_reader :status
|
|
25
|
+
|
|
26
|
+
# @return [Float, nil] Remaining SMS credits
|
|
27
|
+
attr_reader :credits_remaining
|
|
28
|
+
|
|
29
|
+
# @return [Array<String>] SMS IDs for sent messages
|
|
30
|
+
attr_reader :sms_ids
|
|
31
|
+
|
|
32
|
+
# @return [String, nil] Error message if request failed
|
|
33
|
+
attr_reader :error_message
|
|
34
|
+
|
|
35
|
+
# @return [String] Raw response body
|
|
36
|
+
attr_reader :raw_response
|
|
37
|
+
|
|
38
|
+
# @return [String] Content type of the response
|
|
39
|
+
attr_reader :content_type
|
|
40
|
+
|
|
41
|
+
# Success status codes
|
|
42
|
+
SUCCESS_CODES = [100, 101].freeze
|
|
43
|
+
|
|
44
|
+
# Error status codes
|
|
45
|
+
ERROR_CODES = {
|
|
46
|
+
201 => :missing_parameter,
|
|
47
|
+
202 => :invalid_parameter,
|
|
48
|
+
241 => :sender_not_found,
|
|
49
|
+
401 => :authentication_error
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
# rubocop:disable Metrics/ParameterLists
|
|
53
|
+
def initialize(status:, credits_remaining: nil, sms_ids: [], error_message: nil,
|
|
54
|
+
raw_response: nil, content_type: nil)
|
|
55
|
+
# rubocop:enable Metrics/ParameterLists
|
|
56
|
+
@status = status
|
|
57
|
+
@credits_remaining = credits_remaining
|
|
58
|
+
@sms_ids = Array(sms_ids).map(&:to_s)
|
|
59
|
+
@error_message = error_message
|
|
60
|
+
@raw_response = raw_response
|
|
61
|
+
@content_type = content_type
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Check if the request was successful
|
|
65
|
+
#
|
|
66
|
+
# @return [Boolean] true if status code indicates success (100 or 101)
|
|
67
|
+
def success?
|
|
68
|
+
SUCCESS_CODES.include?(status)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Check if the request failed
|
|
72
|
+
#
|
|
73
|
+
# @return [Boolean] true if status code indicates failure
|
|
74
|
+
def failure?
|
|
75
|
+
!success?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Get the error type based on status code
|
|
79
|
+
#
|
|
80
|
+
# @return [Symbol, nil] Error type (:missing_parameter, :invalid_parameter, :authentication_error)
|
|
81
|
+
def error_type
|
|
82
|
+
ERROR_CODES[status]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Parse a raw API response
|
|
86
|
+
#
|
|
87
|
+
# @param body [String] Raw response body
|
|
88
|
+
# @param content_type [String] Content-Type header value
|
|
89
|
+
# @return [Response] Parsed response object
|
|
90
|
+
#
|
|
91
|
+
# @example Parse JSON response
|
|
92
|
+
# Response.parse('{"status":100,"creditLeft":"1987","SmsIds":["123"]}', content_type: "application/json")
|
|
93
|
+
def self.parse(body, content_type: "text/plain")
|
|
94
|
+
parser = ResponseParser.new(body, content_type)
|
|
95
|
+
parser.parse
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Internal parser for different response formats
|
|
100
|
+
# @api private
|
|
101
|
+
class ResponseParser
|
|
102
|
+
def initialize(body, content_type)
|
|
103
|
+
@body = body.to_s
|
|
104
|
+
@content_type = content_type.to_s.downcase
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def parse
|
|
108
|
+
case @content_type
|
|
109
|
+
when /json/
|
|
110
|
+
parse_json
|
|
111
|
+
when /xml/
|
|
112
|
+
parse_xml
|
|
113
|
+
when /html/
|
|
114
|
+
parse_html
|
|
115
|
+
else
|
|
116
|
+
parse_plain
|
|
117
|
+
end
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
# If parsing fails, raise a specific error
|
|
120
|
+
raise ResponseParseError.new(
|
|
121
|
+
"Failed to parse API response: #{e.message}",
|
|
122
|
+
content_type: @content_type,
|
|
123
|
+
raw_response: @body
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def parse_json
|
|
130
|
+
data = JSON.parse(@body)
|
|
131
|
+
|
|
132
|
+
Response.new(
|
|
133
|
+
status: data["status"].to_i,
|
|
134
|
+
credits_remaining: parse_credits(data["creditLeft"]),
|
|
135
|
+
sms_ids: data["SmsIds"] || data["smsIds"] || [],
|
|
136
|
+
error_message: data["message"],
|
|
137
|
+
raw_response: @body,
|
|
138
|
+
content_type: @content_type
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def parse_xml
|
|
143
|
+
# Simple XML parsing without additional dependencies
|
|
144
|
+
status = extract_xml_value("status").to_i
|
|
145
|
+
credits = parse_credits(extract_xml_value("creditLeft"))
|
|
146
|
+
message = extract_xml_value("message")
|
|
147
|
+
sms_ids = extract_xml_sms_ids
|
|
148
|
+
|
|
149
|
+
Response.new(
|
|
150
|
+
status: status,
|
|
151
|
+
credits_remaining: credits,
|
|
152
|
+
sms_ids: sms_ids,
|
|
153
|
+
error_message: message,
|
|
154
|
+
raw_response: @body,
|
|
155
|
+
content_type: @content_type
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def parse_html
|
|
160
|
+
# HTML format: OK/KO<br>credits/message<br>sms_id<br>
|
|
161
|
+
lines = extract_html_lines
|
|
162
|
+
|
|
163
|
+
parse_text_lines(lines)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def parse_plain
|
|
167
|
+
# Text/plain format:
|
|
168
|
+
# OK\n1987\n123456789
|
|
169
|
+
# or
|
|
170
|
+
# KO\nError message
|
|
171
|
+
lines = @body.strip.split(/\r?\n/)
|
|
172
|
+
|
|
173
|
+
parse_text_lines(lines)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def parse_text_lines(lines)
|
|
177
|
+
return empty_response if lines.empty?
|
|
178
|
+
|
|
179
|
+
status_line = lines[0].to_s.strip.upcase
|
|
180
|
+
|
|
181
|
+
if status_line == "OK"
|
|
182
|
+
parse_success_lines(lines)
|
|
183
|
+
else
|
|
184
|
+
parse_error_lines(lines, status_line)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def parse_success_lines(lines)
|
|
189
|
+
credits = lines[1] ? parse_credits(lines[1]) : nil
|
|
190
|
+
sms_ids = lines[2..].map(&:strip).reject(&:empty?)
|
|
191
|
+
|
|
192
|
+
Response.new(
|
|
193
|
+
status: 100,
|
|
194
|
+
credits_remaining: credits,
|
|
195
|
+
sms_ids: sms_ids,
|
|
196
|
+
raw_response: @body,
|
|
197
|
+
content_type: @content_type
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def parse_error_lines(lines, status_line)
|
|
202
|
+
# Status might be numeric (like in some error cases) or "KO"
|
|
203
|
+
status = status_line == "KO" ? 0 : status_line.to_i
|
|
204
|
+
error_message = lines[1..].join("\n").strip
|
|
205
|
+
|
|
206
|
+
# Try to extract status code from message if present
|
|
207
|
+
if error_message =~ /\A(\d{3})\s/
|
|
208
|
+
status = ::Regexp.last_match(1).to_i
|
|
209
|
+
error_message = error_message.sub(/\A\d{3}\s*/, "")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
Response.new(
|
|
213
|
+
status: status,
|
|
214
|
+
error_message: error_message.empty? ? "Unknown error" : error_message,
|
|
215
|
+
raw_response: @body,
|
|
216
|
+
content_type: @content_type
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def empty_response
|
|
221
|
+
Response.new(
|
|
222
|
+
status: 0,
|
|
223
|
+
error_message: "Empty response",
|
|
224
|
+
raw_response: @body,
|
|
225
|
+
content_type: @content_type
|
|
226
|
+
)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def extract_xml_value(tag)
|
|
230
|
+
match = @body.match(%r{<#{tag}>(.*?)</#{tag}>}i)
|
|
231
|
+
match ? match[1] : nil
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def extract_xml_sms_ids
|
|
235
|
+
# Handle both <smsIds><smsId>...</smsId></smsIds> format
|
|
236
|
+
ids = []
|
|
237
|
+
@body.scan(%r{<smsId>(.*?)</smsId>}i) { |match| ids << match[0] }
|
|
238
|
+
ids
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def extract_html_lines
|
|
242
|
+
# Extract content between <BODY> tags and split by <br>
|
|
243
|
+
body_match = @body.match(%r{<BODY>(.*?)</BODY>}im)
|
|
244
|
+
return [] unless body_match
|
|
245
|
+
|
|
246
|
+
body_content = body_match[1]
|
|
247
|
+
body_content.split(%r{<br\s*/?>}).map(&:strip).reject(&:empty?)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def parse_credits(value)
|
|
251
|
+
return nil if value.nil? || value.to_s.empty?
|
|
252
|
+
|
|
253
|
+
Float(value)
|
|
254
|
+
rescue ArgumentError, TypeError
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|