phonelib 0.10.25 → 0.10.26
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 +4 -4
- data/README.md +120 -0
- data/data/phone_data.dat +0 -0
- data/lib/phonelib/core.rb +6 -0
- data/lib/phonelib/data_importer.rb +35 -2
- data/lib/phonelib/data_importer_helper.rb +59 -6
- data/lib/phonelib/errors.rb +42 -0
- data/lib/phonelib/locale/en.yml +13 -0
- data/lib/phonelib/phone.rb +12 -1
- data/lib/phonelib/phone_validation.rb +338 -0
- data/lib/phonelib/railtie.rb +12 -0
- data/lib/phonelib/validation_error.rb +58 -0
- data/lib/phonelib/validation_result.rb +27 -0
- data/lib/phonelib/version.rb +1 -1
- data/lib/phonelib.rb +9 -10
- data/lib/validators/phone_validator.rb +113 -17
- metadata +9 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e167c9f5d91a48e5bd9b336f41f95da24b5d147b2a2c5fc22ce9c860e7c105e7
|
|
4
|
+
data.tar.gz: db640f3955e5c5d4fd907bb25d3c4fa1c0bbb783a5245a5515366e4721e01994
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 81acdf0374d5fb09daa882cb27ec95f79f39bab2ea8539c67f55aca156eb2516ddaa29d6757cc38ce8860feef5aa74654be96cbdc1c83cc000002495195250e8
|
|
7
|
+
data.tar.gz: 7080901f2d6d0a235f76fc9f1094775190f32e7036b336e1c719f5b2fc1b8412c66d9edea4fa357863a04f916d5c86b20489b2ab36cd3c9b1d2ea35677784fcb
|
data/README.md
CHANGED
|
@@ -154,6 +154,59 @@ if mixed with <tt>possible</tt> will check if number is possible for specified c
|
|
|
154
154
|
|
|
155
155
|
<tt>extensions: false</tt> - set to perform check for phone extension to be blank
|
|
156
156
|
|
|
157
|
+
<<<<<<< HEAD
|
|
158
|
+
<tt>require_international_prefix: true</tt> - requires the original value to use <tt>+</tt> or <tt>00</tt> as its explicit international marker. The marker may follow leading formatting characters recognized by the parser. This option does not change the parser or the formatted output.
|
|
159
|
+
|
|
160
|
+
<tt>format: :e164</tt> - requires the original value to match Phonelib's canonical <tt>+</tt>-prefixed E.164 representation: 2 to 15 total ASCII digits, beginning with 1-9, with no spaces, punctuation, or extension. Unsupported formats raise an <tt>ArgumentError</tt> when the validator is configured.
|
|
161
|
+
|
|
162
|
+
||||||| ec5e850
|
|
163
|
+
=======
|
|
164
|
+
<tt>require_international_prefix: true</tt> - requires the original value to use <tt>+</tt> or <tt>00</tt> as its explicit international marker. The marker may follow leading formatting characters recognized by the parser. This option does not change the parser or the formatted output.
|
|
165
|
+
|
|
166
|
+
<tt>format: :e164</tt> - requires the original value to match Phonelib's canonical <tt>+</tt>-prefixed E.164 representation: 2 to 15 total ASCII digits, beginning with 1-9, with no spaces, punctuation, or extension. Unsupported formats raise an <tt>ArgumentError</tt> when the validator is configured.
|
|
167
|
+
|
|
168
|
+
<tt>detailed_errors: true</tt> - adds a specific, namespaced ActiveModel error
|
|
169
|
+
for each failed phone rule. It is opt-in; without it the validator keeps adding
|
|
170
|
+
the single <tt>:invalid</tt> error used by previous Phonelib versions. Enabling
|
|
171
|
+
it changes error reporting only, not which values the existing validator
|
|
172
|
+
accepts.
|
|
173
|
+
|
|
174
|
+
``` ruby
|
|
175
|
+
validates :phone, phone: {
|
|
176
|
+
types: :mobile,
|
|
177
|
+
countries: :us,
|
|
178
|
+
extensions: false,
|
|
179
|
+
detailed_errors: true
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
record.errors.details[:phone] # ActiveModel 5+
|
|
183
|
+
# [{ error: :phone_type_not_allowed, allowed_types: [:mobile], ... }]
|
|
184
|
+
|
|
185
|
+
record.errors.messages[:phone]
|
|
186
|
+
# ["has a phone type that is not allowed"]
|
|
187
|
+
|
|
188
|
+
record.errors.full_messages
|
|
189
|
+
# ["Phone has a phone type that is not allowed"]
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
On older ActiveModel versions, the detailed types still drive translated
|
|
193
|
+
<tt>errors.messages</tt>, but <tt>Errors#details</tt> is not provided by the
|
|
194
|
+
framework.
|
|
195
|
+
|
|
196
|
+
Detailed ActiveModel error types are prefixed with <tt>phone_</tt> so they do
|
|
197
|
+
not collide with generic Rails errors such as <tt>:too_short</tt>. Phonelib
|
|
198
|
+
ships English defaults for every detailed error. Applications can override
|
|
199
|
+
them through the normal I18n hierarchy, for example:
|
|
200
|
+
|
|
201
|
+
``` yaml
|
|
202
|
+
es:
|
|
203
|
+
errors:
|
|
204
|
+
messages:
|
|
205
|
+
phone_too_short: "es demasiado corto"
|
|
206
|
+
phone_invalid: "no es un número telefónico válido"
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
>>>>>>> salasebas-salasebas/structured-validation-errors
|
|
157
210
|
### Basic usage
|
|
158
211
|
|
|
159
212
|
To check if phone number is valid simply run:
|
|
@@ -187,6 +240,73 @@ phone = Phonelib.parse('123456789')
|
|
|
187
240
|
phone = Phonelib.parse('+1 (972) 123-4567', 'US')
|
|
188
241
|
```
|
|
189
242
|
|
|
243
|
+
Parsed phones expose immutable validation diagnostics without requiring Rails:
|
|
244
|
+
|
|
245
|
+
``` ruby
|
|
246
|
+
phone = Phonelib.parse('+1 253000')
|
|
247
|
+
|
|
248
|
+
phone.errors.details
|
|
249
|
+
# [{ error: :too_short, actual_length: 6, expected_lengths: [10] }]
|
|
250
|
+
|
|
251
|
+
phone.errors.messages
|
|
252
|
+
# ["is too short"]
|
|
253
|
+
|
|
254
|
+
phone.errors.full_messages
|
|
255
|
+
# ["is too short"] # no model attribute exists in the core API
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Use <tt>validation</tt> when the result or a validation policy is also needed:
|
|
259
|
+
|
|
260
|
+
``` ruby
|
|
261
|
+
result = phone.validation(
|
|
262
|
+
possible: true,
|
|
263
|
+
types: :mobile,
|
|
264
|
+
countries: [:us, :ca],
|
|
265
|
+
extensions: false
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
result.valid? # whether all requested rules passed
|
|
269
|
+
result.invalid?
|
|
270
|
+
result.phone # the same parsed Phone instance
|
|
271
|
+
result.errors # Phonelib::Errors
|
|
272
|
+
result.error # first ValidationError, or nil
|
|
273
|
+
result.possibility # :possible, :possible_local_only, or :impossible
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
<tt>possible_local_only</tt> means the number has a length that can be dialed
|
|
277
|
+
within its local area but lacks the information required for general dialing.
|
|
278
|
+
It is exposed as diagnostic context; <tt>validation(possible: true)</tt> still
|
|
279
|
+
follows the existing <tt>Phone#possible?</tt> result and therefore reports
|
|
280
|
+
<tt>:not_possible</tt> for a local-only number.
|
|
281
|
+
|
|
282
|
+
Core diagnostic codes are deliberately short because they already live in
|
|
283
|
+
<tt>Phonelib::ValidationError</tt>:
|
|
284
|
+
|
|
285
|
+
* <tt>:not_a_number</tt>
|
|
286
|
+
* <tt>:invalid_country_code</tt>
|
|
287
|
+
* <tt>:too_short</tt>
|
|
288
|
+
* <tt>:too_long</tt>
|
|
289
|
+
* <tt>:invalid_length</tt>
|
|
290
|
+
* <tt>:not_possible</tt>
|
|
291
|
+
* <tt>:invalid</tt>
|
|
292
|
+
* <tt>:type_not_allowed</tt>
|
|
293
|
+
* <tt>:country_not_allowed</tt>
|
|
294
|
+
* <tt>:extension_not_allowed</tt>
|
|
295
|
+
|
|
296
|
+
Successful validity and possibility are represented by the result, not by
|
|
297
|
+
error codes.
|
|
298
|
+
|
|
299
|
+
Messages can be translated in plain Ruby without adding an I18n dependency to
|
|
300
|
+
Phonelib. Pass any callable that accepts an I18n-style key and keyword values:
|
|
301
|
+
|
|
302
|
+
``` ruby
|
|
303
|
+
translator = -> key, default:, **values do
|
|
304
|
+
I18n.t(key, default: default, **values)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
phone.errors.messages(translator: translator)
|
|
308
|
+
```
|
|
309
|
+
|
|
190
310
|
You can pass phone number with extension, it should be separated with <tt>;</tt> or <tt>#</tt> signs from the phone number.
|
|
191
311
|
|
|
192
312
|
Returned value is object of <tt>Phonelib::Phone</tt> class which have following methods:
|
data/data/phone_data.dat
CHANGED
|
Binary file
|
data/lib/phonelib/core.rb
CHANGED
|
@@ -184,6 +184,8 @@ module Phonelib
|
|
|
184
184
|
# setter for data file to use
|
|
185
185
|
def override_phone_data=(file_path)
|
|
186
186
|
@@override_phone_data = file_path
|
|
187
|
+
@@phone_data = @@data_by_country_codes = @@all_int_prefixes = nil
|
|
188
|
+
@@phone_regexp_cache.clear
|
|
187
189
|
end
|
|
188
190
|
|
|
189
191
|
def override_phone_data
|
|
@@ -308,6 +310,10 @@ module Phonelib
|
|
|
308
310
|
VALID_PATTERN = :national_number_pattern
|
|
309
311
|
# @private Possible regex pattern key
|
|
310
312
|
POSSIBLE_PATTERN = :possible_number_pattern
|
|
313
|
+
# @private Possible national number lengths imported from libphonenumber
|
|
314
|
+
POSSIBLE_LENGTHS = :possible_lengths
|
|
315
|
+
# @private Possible local-only number lengths imported from libphonenumber
|
|
316
|
+
POSSIBLE_LOCAL_ONLY_LENGTHS = :possible_local_only_lengths
|
|
311
317
|
# @private National prefix key
|
|
312
318
|
NATIONAL_PREFIX = :national_prefix
|
|
313
319
|
# @private National prefix for parsing key
|
|
@@ -243,19 +243,29 @@ module Phonelib
|
|
|
243
243
|
@data[country_id][:types][type][Core::SHORT] ||= {}
|
|
244
244
|
data.each do |k, v|
|
|
245
245
|
if @data[country_id][:types][type][Core::SHORT][k]
|
|
246
|
-
|
|
246
|
+
if possible_lengths_key?(k)
|
|
247
|
+
@data[country_id][:types][type][Core::SHORT][k] =
|
|
248
|
+
(@data[country_id][:types][type][Core::SHORT][k] + v).uniq.sort
|
|
249
|
+
else
|
|
250
|
+
@data[country_id][:types][type][Core::SHORT][k] += "|#{v}"
|
|
251
|
+
end
|
|
247
252
|
else
|
|
248
253
|
@data[country_id][:types][type][Core::SHORT][k] = v
|
|
249
254
|
end
|
|
250
255
|
end
|
|
251
256
|
end
|
|
252
257
|
|
|
258
|
+
def possible_lengths_key?(key)
|
|
259
|
+
[Core::POSSIBLE_LENGTHS, Core::POSSIBLE_LOCAL_ONLY_LENGTHS].include?(key)
|
|
260
|
+
end
|
|
261
|
+
|
|
253
262
|
# adds possible pattern in case it doesn't exists
|
|
254
263
|
def fill_possible_to_types_if_nil(result)
|
|
255
264
|
result[:types].each do |type, data|
|
|
256
265
|
if data[Core::VALID_PATTERN] && !data[Core::POSSIBLE_PATTERN]
|
|
257
266
|
result[:types][type][Core::POSSIBLE_PATTERN] = case type
|
|
258
267
|
when Core::GENERAL
|
|
268
|
+
fill_general_possible_lengths(result[:types], data)
|
|
259
269
|
national_possible result[:types]
|
|
260
270
|
else
|
|
261
271
|
data[Core::VALID_PATTERN]
|
|
@@ -265,10 +275,33 @@ module Phonelib
|
|
|
265
275
|
result
|
|
266
276
|
end
|
|
267
277
|
|
|
278
|
+
def fill_general_possible_lengths(types, general)
|
|
279
|
+
national = concrete_possible_lengths(types, Core::POSSIBLE_LENGTHS)
|
|
280
|
+
local_only = concrete_possible_lengths(
|
|
281
|
+
types,
|
|
282
|
+
Core::POSSIBLE_LOCAL_ONLY_LENGTHS
|
|
283
|
+
) - national
|
|
284
|
+
general[Core::POSSIBLE_LENGTHS] = national unless national.empty?
|
|
285
|
+
unless local_only.empty?
|
|
286
|
+
general[Core::POSSIBLE_LOCAL_ONLY_LENGTHS] = local_only
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def concrete_possible_lengths(types, key)
|
|
291
|
+
lengths = []
|
|
292
|
+
types.each do |type, data|
|
|
293
|
+
next if type == Core::GENERAL || !data[key]
|
|
294
|
+
|
|
295
|
+
lengths.concat(data[key].select { |length| length > 0 })
|
|
296
|
+
end
|
|
297
|
+
lengths.uniq.sort
|
|
298
|
+
end
|
|
299
|
+
|
|
268
300
|
# take all possible patters from all types
|
|
269
301
|
def national_possible(types)
|
|
270
302
|
types.map { |k, v| v[:possible_number_pattern] }.
|
|
271
|
-
compact.
|
|
303
|
+
compact.reject { |pattern| pattern == '(?!)' }.
|
|
304
|
+
map { |e| e.split('|') }.flatten.uniq.join('|')
|
|
272
305
|
end
|
|
273
306
|
|
|
274
307
|
# method parses xml for formats data
|
|
@@ -7,6 +7,8 @@ module Phonelib
|
|
|
7
7
|
XML_COMMENT_ATTRIBUTES = %w(text comment)
|
|
8
8
|
# xml format attributes names
|
|
9
9
|
XML_FORMAT_NAMES = %w(intlFormat format)
|
|
10
|
+
# libphonenumber's maximum supported national significant number length
|
|
11
|
+
MAX_POSSIBLE_LENGTH = 17
|
|
10
12
|
|
|
11
13
|
def file_path(file)
|
|
12
14
|
"#{File.dirname(__FILE__)}/../../#{file}"
|
|
@@ -83,8 +85,16 @@ module Phonelib
|
|
|
83
85
|
when :element
|
|
84
86
|
data.elements.each do |child|
|
|
85
87
|
if child.name == 'possibleLengths'
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
attributes = hash_from_xml(child, :attributes)
|
|
89
|
+
hash[Core::POSSIBLE_PATTERN] = possible_length_regex(attributes)
|
|
90
|
+
|
|
91
|
+
national_lengths = possible_lengths(attributes[:national])
|
|
92
|
+
hash[Core::POSSIBLE_LENGTHS] = national_lengths unless national_lengths.empty?
|
|
93
|
+
|
|
94
|
+
local_only_lengths = possible_lengths(attributes[:local_only])
|
|
95
|
+
unless local_only_lengths.empty?
|
|
96
|
+
hash[Core::POSSIBLE_LOCAL_ONLY_LENGTHS] = local_only_lengths
|
|
97
|
+
end
|
|
88
98
|
else
|
|
89
99
|
hash[name2sym(child.name)] = str_clean(child.children.first)
|
|
90
100
|
end
|
|
@@ -95,15 +105,58 @@ module Phonelib
|
|
|
95
105
|
|
|
96
106
|
def possible_length_regex(attributes)
|
|
97
107
|
return '' unless attributes[:national]
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
108
|
+
|
|
109
|
+
possible_length_ranges(attributes[:national]).map do |first, last|
|
|
110
|
+
if first == -1
|
|
111
|
+
'(?!)'
|
|
112
|
+
elsif first == last
|
|
113
|
+
"\\d{#{first}}"
|
|
101
114
|
else
|
|
102
|
-
"\\d{#{
|
|
115
|
+
"\\d{#{first},#{last}}"
|
|
103
116
|
end
|
|
104
117
|
end.join('|')
|
|
105
118
|
end
|
|
106
119
|
|
|
120
|
+
def possible_lengths(lengths)
|
|
121
|
+
return [] unless lengths
|
|
122
|
+
|
|
123
|
+
possible_length_ranges(lengths).flat_map do |first, last|
|
|
124
|
+
first == -1 ? [-1] : (first..last).to_a
|
|
125
|
+
end.uniq.sort
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def possible_length_ranges(lengths)
|
|
129
|
+
lengths.split(',').map do |length|
|
|
130
|
+
raw_token = length
|
|
131
|
+
token = if raw_token.include?('[') || raw_token.include?(']')
|
|
132
|
+
unless raw_token =~ /\A\[\d+(?:-\d+)?\]\z/
|
|
133
|
+
raise ArgumentError, "invalid possible length: #{raw_token}"
|
|
134
|
+
end
|
|
135
|
+
raw_token[1..-2]
|
|
136
|
+
else
|
|
137
|
+
raw_token
|
|
138
|
+
end
|
|
139
|
+
unless token =~ /\A(?:-1|\d+(?:-\d+)?)\z/
|
|
140
|
+
raise ArgumentError, "invalid possible length: #{raw_token}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
next [-1, -1] if token == '-1'
|
|
144
|
+
|
|
145
|
+
first, last = token.split('-').map(&:to_i)
|
|
146
|
+
last ||= first
|
|
147
|
+
[first, last].each do |value|
|
|
148
|
+
unless (1..MAX_POSSIBLE_LENGTH).include?(value)
|
|
149
|
+
raise ArgumentError, "possible length out of range: #{value}"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
if first > last
|
|
153
|
+
raise ArgumentError, "invalid possible length range: #{token}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
[first, last]
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
107
160
|
# method parses raw data file
|
|
108
161
|
def parse_raw_file(file)
|
|
109
162
|
data = {}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phonelib
|
|
4
|
+
# Read-only collection of phone validation errors.
|
|
5
|
+
class Errors
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
def initialize(errors = [])
|
|
9
|
+
@errors = errors.dup.freeze
|
|
10
|
+
freeze
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def each(&block)
|
|
14
|
+
@errors.each(&block)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def empty?
|
|
18
|
+
@errors.empty?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def size
|
|
22
|
+
@errors.size
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def first
|
|
26
|
+
@errors.first
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def details
|
|
30
|
+
@errors.map { |error| { error: error.code }.merge(error.details).freeze }.freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def messages(options = {})
|
|
34
|
+
translator = options.is_a?(Hash) ? options[:translator] : options
|
|
35
|
+
@errors.map { |error| error.message(translator) }.freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def full_messages(options = {})
|
|
39
|
+
messages(options)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
en:
|
|
2
|
+
errors:
|
|
3
|
+
messages:
|
|
4
|
+
phone_not_a_number: "is not a phone number"
|
|
5
|
+
phone_invalid_country_code: "has an invalid country calling code"
|
|
6
|
+
phone_too_short: "is too short"
|
|
7
|
+
phone_too_long: "is too long"
|
|
8
|
+
phone_invalid_length: "has an invalid length"
|
|
9
|
+
phone_not_possible: "is not a possible phone number"
|
|
10
|
+
phone_invalid: "is not a valid phone number"
|
|
11
|
+
phone_type_not_allowed: "has a phone type that is not allowed"
|
|
12
|
+
phone_country_not_allowed: "is from a country that is not allowed"
|
|
13
|
+
phone_extension_not_allowed: "must not include an extension"
|
data/lib/phonelib/phone.rb
CHANGED
|
@@ -17,6 +17,7 @@ module Phonelib
|
|
|
17
17
|
include Phonelib::PhoneAnalyzer
|
|
18
18
|
include Phonelib::PhoneExtendedData
|
|
19
19
|
include Phonelib::PhoneFormatter
|
|
20
|
+
include Phonelib::PhoneValidation
|
|
20
21
|
|
|
21
22
|
# class initialization method
|
|
22
23
|
# @param phone [String] Phone number for parsing
|
|
@@ -30,12 +31,15 @@ module Phonelib
|
|
|
30
31
|
|
|
31
32
|
if sanitized.empty?
|
|
32
33
|
@data = {}
|
|
34
|
+
@diagnostic_possibility = :impossible
|
|
33
35
|
else
|
|
34
|
-
@
|
|
36
|
+
@passed_country = passed_country(country)
|
|
37
|
+
@data = analyze(sanitized, @passed_country)
|
|
35
38
|
# the national number must come from the country #country reports:
|
|
36
39
|
# another entry can be a possible-only match, and mixing entries makes
|
|
37
40
|
# e164 splice one country's code onto another's number (issue #355)
|
|
38
41
|
@national_number = country_data ? country_data[:national] : sanitized
|
|
42
|
+
@diagnostic_possibility = diagnostic_possibility
|
|
39
43
|
end
|
|
40
44
|
end
|
|
41
45
|
|
|
@@ -62,6 +66,13 @@ module Phonelib
|
|
|
62
66
|
'')
|
|
63
67
|
end
|
|
64
68
|
|
|
69
|
+
# Returns whether the original input begins with an explicit + or 00
|
|
70
|
+
# international prefix after parser-recognized leading formatting.
|
|
71
|
+
# @return [Boolean] original input has an explicit international prefix
|
|
72
|
+
def explicit_international_prefix?
|
|
73
|
+
!!original_starts_with_plus_or_double_zero?
|
|
74
|
+
end
|
|
75
|
+
|
|
65
76
|
# Returns all phone types that matched valid patterns
|
|
66
77
|
# @return [Array] all valid phone types
|
|
67
78
|
def types
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phonelib
|
|
4
|
+
# Framework-independent diagnostics and policy validation for a parsed phone.
|
|
5
|
+
module PhoneValidation
|
|
6
|
+
VALIDATION_OPTIONS = [:possible, :types, :countries, :extensions].freeze
|
|
7
|
+
VALID_TYPE_NAMES = Core::TYPES_DESC_KEYS.map(&:to_s).freeze
|
|
8
|
+
MAX_VALIDATION_VALUES = 300
|
|
9
|
+
private_constant :VALIDATION_OPTIONS, :VALID_TYPE_NAMES,
|
|
10
|
+
:MAX_VALIDATION_VALUES
|
|
11
|
+
|
|
12
|
+
# Returns an immutable result explaining whether this phone satisfies the
|
|
13
|
+
# requested rules and, when it does not, why.
|
|
14
|
+
# @param options [Hash] phone validation rules
|
|
15
|
+
# @return [Phonelib::ValidationResult]
|
|
16
|
+
def validation(options = {})
|
|
17
|
+
validate_options!(options)
|
|
18
|
+
possible = options.fetch(:possible, false)
|
|
19
|
+
possibility = diagnostic_possibility
|
|
20
|
+
validation_errors = intrinsic_validation_errors(possible, possibility)
|
|
21
|
+
if validation_errors.empty?
|
|
22
|
+
validation_errors.concat(policy_validation_errors(options, possible))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
ValidationResult.new(self, validation_errors, possibility)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Convenience accessor for validation diagnostics.
|
|
29
|
+
# @param options [Hash] phone validation rules
|
|
30
|
+
# @return [Phonelib::Errors]
|
|
31
|
+
def errors(options = {})
|
|
32
|
+
validation(options).errors
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def validate_options!(options)
|
|
38
|
+
unknown_options = options.keys - VALIDATION_OPTIONS
|
|
39
|
+
if unknown_options.any?
|
|
40
|
+
raise ArgumentError, "unknown validation option: #{unknown_options.first}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
validate_boolean_option!(:possible, options.fetch(:possible, false))
|
|
44
|
+
validate_boolean_option!(:extensions, options.fetch(:extensions, true))
|
|
45
|
+
validate_collection_option!(:types, options[:types])
|
|
46
|
+
validate_collection_option!(:countries, options[:countries])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def validate_boolean_option!(name, value)
|
|
50
|
+
return if value == true || value == false
|
|
51
|
+
|
|
52
|
+
raise ArgumentError, "#{name} must be true or false"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_collection_option!(name, value)
|
|
56
|
+
return if value.nil?
|
|
57
|
+
|
|
58
|
+
values = value.is_a?(Array) ? value : [value]
|
|
59
|
+
if values.size > MAX_VALIDATION_VALUES
|
|
60
|
+
raise ArgumentError,
|
|
61
|
+
"#{name} must contain at most #{MAX_VALIDATION_VALUES} values"
|
|
62
|
+
end
|
|
63
|
+
unless values.all? { |item| item.is_a?(String) || item.is_a?(Symbol) }
|
|
64
|
+
raise ArgumentError, "#{name} must be a symbol, string, or array of them"
|
|
65
|
+
end
|
|
66
|
+
validate_type_names!(values) if name == :types
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate_type_names!(values)
|
|
70
|
+
unknown = values.find { |item| !VALID_TYPE_NAMES.include?(item.to_s) }
|
|
71
|
+
raise ArgumentError, "unknown phone type: #{unknown}" if unknown
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def intrinsic_validation_errors(possible, possibility)
|
|
75
|
+
if possible
|
|
76
|
+
return [] if possible?
|
|
77
|
+
if possibility != :impossible
|
|
78
|
+
return [ValidationError.new(:not_possible, possibility: possibility)]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
return [] if !possible && valid?
|
|
82
|
+
return [ValidationError.new(:invalid)] if possibility != :impossible
|
|
83
|
+
|
|
84
|
+
diagnostic_error = intrinsic_diagnostic_error
|
|
85
|
+
return [diagnostic_error] if diagnostic_error
|
|
86
|
+
|
|
87
|
+
[ValidationError.new(:not_possible)]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def intrinsic_diagnostic_error
|
|
91
|
+
return ValidationError.new(:not_a_number) unless viable_input?
|
|
92
|
+
parser_length = parser_national_length
|
|
93
|
+
if parser_length && parser_length > 17
|
|
94
|
+
return ValidationError.new(
|
|
95
|
+
:too_long,
|
|
96
|
+
actual_length: parser_length
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
return ValidationError.new(:invalid_country_code) if passed_country_invalid?
|
|
100
|
+
|
|
101
|
+
idd_remainder = digits_after_international_dialing_prefix
|
|
102
|
+
if idd_remainder && idd_remainder.length <= 2
|
|
103
|
+
return ValidationError.new(
|
|
104
|
+
:too_short,
|
|
105
|
+
actual_length: idd_remainder.length
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
international_digits = international_digits_for_diagnostics
|
|
110
|
+
if international_digits && country_calling_code_for(international_digits).nil?
|
|
111
|
+
return ValidationError.new(:invalid_country_code)
|
|
112
|
+
end
|
|
113
|
+
national_length_error = short_national_number_error(international_digits)
|
|
114
|
+
return national_length_error if national_length_error
|
|
115
|
+
|
|
116
|
+
possibility_length_error
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def viable_input?
|
|
120
|
+
candidate = significant_original_s.sub(cr('[^0-9A-Za-z]+\z'), '')
|
|
121
|
+
return false if Phonelib.strict_check && !sanitized.match?(cr('\A[0-9]+\z'))
|
|
122
|
+
|
|
123
|
+
minimum_digits = candidate.match?(cr('\A[0-9]+\z')) ? 2 : 3
|
|
124
|
+
sanitized.length >= minimum_digits
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def parser_national_length
|
|
128
|
+
international_digits = international_digits_for_diagnostics
|
|
129
|
+
return sanitized.length unless international_digits
|
|
130
|
+
|
|
131
|
+
country_code = country_calling_code_for(international_digits)
|
|
132
|
+
international_digits.delete_prefix(country_code).length if country_code
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def passed_country_invalid?
|
|
136
|
+
return false if significant_original_s.start_with?(Core::PLUS_SIGN)
|
|
137
|
+
|
|
138
|
+
passed_countries = Array(@passed_country).compact
|
|
139
|
+
passed_countries.any? && passed_countries.none? do |country|
|
|
140
|
+
Phonelib.phone_data.key?(normalize_passed_country(country))
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def short_national_number_error(international_digits)
|
|
145
|
+
return unless international_digits
|
|
146
|
+
|
|
147
|
+
country_code = country_calling_code_for(international_digits)
|
|
148
|
+
national_digits = international_digits.delete_prefix(country_code)
|
|
149
|
+
return unless national_digits.length < 2
|
|
150
|
+
|
|
151
|
+
ValidationError.new(
|
|
152
|
+
:too_short,
|
|
153
|
+
actual_length: national_digits.length
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def possibility_length_error
|
|
158
|
+
context = diagnostic_number_context
|
|
159
|
+
return unless context
|
|
160
|
+
|
|
161
|
+
national_digits, data = context
|
|
162
|
+
lengths = possible_lengths_for(data)
|
|
163
|
+
local_only_lengths = possible_local_only_lengths_for(data)
|
|
164
|
+
return if lengths.empty? || lengths.include?(national_digits.length) ||
|
|
165
|
+
local_only_lengths.include?(national_digits.length)
|
|
166
|
+
|
|
167
|
+
code = if national_digits.length < lengths.min
|
|
168
|
+
:too_short
|
|
169
|
+
elsif national_digits.length > lengths.max
|
|
170
|
+
:too_long
|
|
171
|
+
else
|
|
172
|
+
:invalid_length
|
|
173
|
+
end
|
|
174
|
+
ValidationError.new(
|
|
175
|
+
code,
|
|
176
|
+
actual_length: national_digits.length,
|
|
177
|
+
expected_lengths: lengths
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def diagnostic_possibility
|
|
182
|
+
return @diagnostic_possibility if defined?(@diagnostic_possibility)
|
|
183
|
+
|
|
184
|
+
return :impossible if passed_country_invalid?
|
|
185
|
+
return :impossible unless viable_input?
|
|
186
|
+
return :possible if possible_special_number?
|
|
187
|
+
|
|
188
|
+
context = diagnostic_number_context
|
|
189
|
+
return possible? ? :possible : :impossible unless context
|
|
190
|
+
|
|
191
|
+
national_digits, data = context
|
|
192
|
+
length = national_digits.length
|
|
193
|
+
return :possible if possible_lengths_for(data).include?(length)
|
|
194
|
+
return :possible if additional_regex_possible?(national_digits, data)
|
|
195
|
+
return :possible_local_only if possible_local_only_lengths_for(data).include?(length)
|
|
196
|
+
|
|
197
|
+
:impossible
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def possible_special_number?
|
|
201
|
+
Phonelib.parse_special && (possible_types & Core::SHORT_CODES).any?
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def additional_regex_possible?(national_digits, data)
|
|
205
|
+
candidate_countries = countries.any? ? countries : [data[:id]]
|
|
206
|
+
candidate_countries.any? do |country|
|
|
207
|
+
regexes = Phonelib.additional_regexes[country]
|
|
208
|
+
regexes && regexes.values.flatten.any? do |regex|
|
|
209
|
+
national_digits.match?(cr("^(?:#{regex})$"))
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def diagnostic_number_context
|
|
215
|
+
international_digits = international_digits_for_diagnostics
|
|
216
|
+
if international_digits
|
|
217
|
+
country_code = country_calling_code_for(international_digits)
|
|
218
|
+
return unless country_code
|
|
219
|
+
|
|
220
|
+
countries_data = Phonelib.data_by_country_codes[country_code]
|
|
221
|
+
data = countries_data.find do |candidate|
|
|
222
|
+
candidate[Core::MAIN_COUNTRY_FOR_CODE] == 'true'
|
|
223
|
+
end || countries_data.first
|
|
224
|
+
national_digits = if valid? || possible?
|
|
225
|
+
@national_number
|
|
226
|
+
else
|
|
227
|
+
international_digits.delete_prefix(country_code)
|
|
228
|
+
end
|
|
229
|
+
return [national_digits, data]
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
resolved_country = self.country
|
|
233
|
+
data = resolved_country && Phonelib.phone_data[resolved_country]
|
|
234
|
+
unless data
|
|
235
|
+
supplied_country = Array(
|
|
236
|
+
@passed_country || Phonelib.default_country
|
|
237
|
+
).compact.first
|
|
238
|
+
data = supplied_country && Phonelib.phone_data[
|
|
239
|
+
normalize_passed_country(supplied_country)
|
|
240
|
+
]
|
|
241
|
+
end
|
|
242
|
+
[@national_number || sanitized, data] if data
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def international_digits_for_diagnostics
|
|
246
|
+
if !Phonelib.ignore_plus &&
|
|
247
|
+
significant_original_s.start_with?(Core::PLUS_SIGN)
|
|
248
|
+
return sanitized
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
digits_after_international_dialing_prefix
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def possible_lengths_for(data)
|
|
255
|
+
general = data[Core::TYPES][Core::GENERAL]
|
|
256
|
+
lengths = general[Core::POSSIBLE_LENGTHS]
|
|
257
|
+
return lengths if lengths.is_a?(Array)
|
|
258
|
+
|
|
259
|
+
regex = general[Core::POSSIBLE_PATTERN]
|
|
260
|
+
return [] unless regex
|
|
261
|
+
|
|
262
|
+
(1..17).select { |length| ('0' * length).match?(cr("^(?:#{regex})$")) }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def possible_local_only_lengths_for(data)
|
|
266
|
+
lengths = data[Core::TYPES][Core::GENERAL][Core::POSSIBLE_LOCAL_ONLY_LENGTHS]
|
|
267
|
+
lengths.is_a?(Array) ? lengths : []
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def digits_after_international_dialing_prefix
|
|
271
|
+
diagnostic_countries.each do |country|
|
|
272
|
+
data = Phonelib.phone_data[country]
|
|
273
|
+
next unless data && data[Core::INTERNATIONAL_PREFIX]
|
|
274
|
+
|
|
275
|
+
prefix = sanitized.match(cr("^(?:#{data[Core::INTERNATIONAL_PREFIX]})"))
|
|
276
|
+
return sanitized[prefix[0].length..-1] if prefix
|
|
277
|
+
end
|
|
278
|
+
nil
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def diagnostic_countries
|
|
282
|
+
Array(@passed_country || Phonelib.default_country).compact.map do |country|
|
|
283
|
+
normalize_passed_country(country)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def country_calling_code_for(digits)
|
|
288
|
+
(1..3).map { |length| digits[0, length] }.find do |code|
|
|
289
|
+
Phonelib.data_by_country_codes.key?(code)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def policy_validation_errors(options, possible)
|
|
294
|
+
errors = []
|
|
295
|
+
allowed_types = normalize_validation_values(options[:types]) { |type| type.to_sym }
|
|
296
|
+
matched_types = possible ? possible_types : types
|
|
297
|
+
expanded_types = matched_types.dup
|
|
298
|
+
if expanded_types.include?(Core::FIXED_OR_MOBILE)
|
|
299
|
+
expanded_types += Core::FIXED_LINE_OR_MOBILE_ARRAY
|
|
300
|
+
end
|
|
301
|
+
if !options[:types].nil? && (expanded_types & allowed_types).empty?
|
|
302
|
+
errors << ValidationError.new(
|
|
303
|
+
:type_not_allowed,
|
|
304
|
+
allowed_types: allowed_types,
|
|
305
|
+
matched_types: matched_types
|
|
306
|
+
)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
allowed_countries = normalize_validation_values(options[:countries]) do |country|
|
|
310
|
+
country.to_s.upcase
|
|
311
|
+
end
|
|
312
|
+
matched_countries = possible ? possible_policy_countries : valid_countries
|
|
313
|
+
if !options[:countries].nil? && (matched_countries & allowed_countries).empty?
|
|
314
|
+
errors << ValidationError.new(
|
|
315
|
+
:country_not_allowed,
|
|
316
|
+
allowed_countries: allowed_countries,
|
|
317
|
+
matched_countries: matched_countries
|
|
318
|
+
)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
if options.fetch(:extensions, true) == false && !extension.to_s.empty?
|
|
322
|
+
errors << ValidationError.new(:extension_not_allowed, extension_present: true)
|
|
323
|
+
end
|
|
324
|
+
errors
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def possible_policy_countries
|
|
328
|
+
return countries if countries.any?
|
|
329
|
+
|
|
330
|
+
context = diagnostic_number_context
|
|
331
|
+
context ? [context.last[:id]].compact : []
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def normalize_validation_values(value)
|
|
335
|
+
Array(value).map { |item| yield(item) }.uniq.freeze
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phonelib
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
initializer 'phonelib' do |app|
|
|
6
|
+
app.config.eager_load_namespaces << Phonelib
|
|
7
|
+
|
|
8
|
+
locale_files = Dir[File.expand_path('phonelib/locale/*.yml', __dir__)]
|
|
9
|
+
app.config.i18n.load_path = locale_files | app.config.i18n.load_path if defined?(I18n)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phonelib
|
|
4
|
+
# A framework-independent, immutable phone validation diagnostic.
|
|
5
|
+
class ValidationError
|
|
6
|
+
DEFAULT_MESSAGES = {
|
|
7
|
+
not_a_number: 'is not a phone number',
|
|
8
|
+
invalid_country_code: 'has an invalid country calling code',
|
|
9
|
+
too_short: 'is too short',
|
|
10
|
+
too_long: 'is too long',
|
|
11
|
+
invalid_length: 'has an invalid length',
|
|
12
|
+
not_possible: 'is not a possible phone number',
|
|
13
|
+
invalid: 'is not a valid phone number',
|
|
14
|
+
type_not_allowed: 'has a phone type that is not allowed',
|
|
15
|
+
country_not_allowed: 'is from a country that is not allowed',
|
|
16
|
+
extension_not_allowed: 'must not include an extension'
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :code, :details
|
|
20
|
+
|
|
21
|
+
def initialize(code, details = {})
|
|
22
|
+
@code = DEFAULT_MESSAGES.keys.find { |key| key.to_s == code.to_s }
|
|
23
|
+
unless @code
|
|
24
|
+
raise ArgumentError, "unknown validation error code: #{code}"
|
|
25
|
+
end
|
|
26
|
+
@details = immutable_copy(details)
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def i18n_key
|
|
31
|
+
"phonelib.errors.#{code}".to_sym
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def message(translator = nil)
|
|
35
|
+
default = DEFAULT_MESSAGES.fetch(code, code.to_s.tr('_', ' '))
|
|
36
|
+
return default unless translator
|
|
37
|
+
|
|
38
|
+
translator.call(i18n_key, **{ default: default }.merge(details))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def immutable_copy(value)
|
|
44
|
+
case value
|
|
45
|
+
when Hash
|
|
46
|
+
value.each_with_object({}) do |(key, item), copy|
|
|
47
|
+
copy[key] = immutable_copy(item)
|
|
48
|
+
end.freeze
|
|
49
|
+
when Array
|
|
50
|
+
value.map { |item| immutable_copy(item) }.freeze
|
|
51
|
+
when String
|
|
52
|
+
value.dup.freeze
|
|
53
|
+
else
|
|
54
|
+
value
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phonelib
|
|
4
|
+
# Immutable outcome of validating a parsed phone against a rule set.
|
|
5
|
+
class ValidationResult
|
|
6
|
+
attr_reader :phone, :errors, :possibility
|
|
7
|
+
|
|
8
|
+
def initialize(phone, errors, possibility)
|
|
9
|
+
@phone = phone
|
|
10
|
+
@errors = Errors.new(errors)
|
|
11
|
+
@possibility = possibility
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def valid?
|
|
16
|
+
errors.empty?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def invalid?
|
|
20
|
+
!valid?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def error
|
|
24
|
+
errors.first
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/phonelib/version.rb
CHANGED
data/lib/phonelib.rb
CHANGED
|
@@ -8,19 +8,18 @@ module Phonelib
|
|
|
8
8
|
require 'phonelib/phone_analyzer_helper'
|
|
9
9
|
require 'phonelib/phone_analyzer'
|
|
10
10
|
require 'phonelib/phone_extended_data'
|
|
11
|
+
require 'phonelib/validation_error'
|
|
12
|
+
require 'phonelib/errors'
|
|
13
|
+
require 'phonelib/validation_result'
|
|
14
|
+
require 'phonelib/phone_validation'
|
|
11
15
|
require 'phonelib/phone'
|
|
12
16
|
|
|
13
17
|
extend Core
|
|
14
18
|
end
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
autoload :PhoneValidator, 'validators/phone_validator'
|
|
20
|
+
autoload :PhoneValidator, 'validators/phone_validator'
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
22
|
+
locale_files = Dir[File.expand_path('phonelib/locale/*.yml', __dir__)]
|
|
23
|
+
I18n.load_path = locale_files | I18n.load_path if defined?(I18n)
|
|
24
|
+
|
|
25
|
+
require 'phonelib/railtie' if defined?(Rails::Railtie)
|
|
@@ -53,18 +53,51 @@
|
|
|
53
53
|
# validates :number, phone: { extensions: false }
|
|
54
54
|
# end
|
|
55
55
|
#
|
|
56
|
+
# Validates that the original value includes an explicit international prefix.
|
|
57
|
+
# Both + and 00 prefixes are accepted.
|
|
58
|
+
#
|
|
59
|
+
# class Phone < ActiveRecord::Base
|
|
60
|
+
# validates :number, phone: { require_international_prefix: true }
|
|
61
|
+
# end
|
|
62
|
+
#
|
|
63
|
+
# Validates that the original value uses canonical E.164 input format.
|
|
64
|
+
#
|
|
65
|
+
# class Phone < ActiveRecord::Base
|
|
66
|
+
# validates :number, phone: { format: :e164 }
|
|
67
|
+
# end
|
|
68
|
+
#
|
|
69
|
+
|
|
70
|
+
if defined?(I18n)
|
|
71
|
+
locale_files = Dir[File.expand_path('../phonelib/locale/*.yml', __dir__)]
|
|
72
|
+
I18n.load_path = locale_files | I18n.load_path
|
|
73
|
+
end
|
|
74
|
+
|
|
56
75
|
class PhoneValidator < ActiveModel::EachValidator
|
|
76
|
+
E164_PATTERN = /\A\+[1-9][0-9]{1,14}\z/
|
|
77
|
+
SUPPORTED_FORMATS = [:e164, 'e164'].freeze
|
|
78
|
+
|
|
57
79
|
# Include all core methods
|
|
58
80
|
include Phonelib::Core
|
|
59
81
|
|
|
82
|
+
def check_validity!
|
|
83
|
+
return unless options.has_key?(:format)
|
|
84
|
+
return if SUPPORTED_FORMATS.include?(options[:format])
|
|
85
|
+
|
|
86
|
+
raise ArgumentError, "Unsupported phone format: #{options[:format]}"
|
|
87
|
+
end
|
|
88
|
+
|
|
60
89
|
# Validation method
|
|
61
90
|
def validate_each(record, attribute, value)
|
|
62
91
|
return if options[:allow_blank] && value.blank?
|
|
63
92
|
|
|
64
|
-
|
|
65
|
-
|
|
93
|
+
phone = parse(value, specified_country(record))
|
|
94
|
+
if detailed_errors?
|
|
95
|
+
return validate_with_details(record, attribute, phone)
|
|
96
|
+
end
|
|
66
97
|
|
|
67
|
-
|
|
98
|
+
valid = phone_valid?(phone) && valid_types?(phone) && valid_country?(phone) &&
|
|
99
|
+
valid_extensions?(phone) && valid_input_format?(value)
|
|
100
|
+
record.errors.add(attribute, message, **legacy_error_options) unless valid
|
|
68
101
|
end
|
|
69
102
|
|
|
70
103
|
private
|
|
@@ -73,23 +106,84 @@ class PhoneValidator < ActiveModel::EachValidator
|
|
|
73
106
|
options[:message] || :invalid
|
|
74
107
|
end
|
|
75
108
|
|
|
76
|
-
def
|
|
77
|
-
|
|
109
|
+
def detailed_errors?
|
|
110
|
+
options[:detailed_errors]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def legacy_error_options
|
|
114
|
+
options.reject { |key, _value| key == :detailed_errors }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def validate_with_details(record, attribute, phone)
|
|
118
|
+
result = phone.validation(
|
|
119
|
+
possible: !!options[:possible],
|
|
120
|
+
types: detailed_types,
|
|
121
|
+
countries: detailed_countries,
|
|
122
|
+
extensions: extensions_allowed?
|
|
123
|
+
)
|
|
124
|
+
return if result.valid?
|
|
125
|
+
|
|
126
|
+
result.errors.each do |error|
|
|
127
|
+
record.errors.add(
|
|
128
|
+
attribute,
|
|
129
|
+
"phone_#{error.code}".to_sym,
|
|
130
|
+
**detailed_error_options(error)
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def detailed_error_options(error)
|
|
136
|
+
details = error.details.dup
|
|
137
|
+
details.delete(:error)
|
|
138
|
+
details.delete('error')
|
|
139
|
+
details[:message] = options[:message] if options.key?(:message)
|
|
140
|
+
details[:strict] = options[:strict] if options.key?(:strict)
|
|
141
|
+
details
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def detailed_types
|
|
145
|
+
types.uniq if options[:types]
|
|
78
146
|
end
|
|
79
147
|
|
|
80
|
-
def
|
|
148
|
+
def detailed_countries
|
|
149
|
+
return unless options[:countries]
|
|
150
|
+
|
|
151
|
+
countries.uniq.select { |country| Phonelib.phone_data.key?(country) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def phone_valid?(phone)
|
|
155
|
+
phone.send(options[:possible] ? :possible? : :valid?)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def valid_types?(phone)
|
|
81
159
|
return true unless options[:types]
|
|
82
|
-
(phone_types & types).size > 0
|
|
160
|
+
(phone_types(phone) & types).size > 0
|
|
83
161
|
end
|
|
84
162
|
|
|
85
|
-
def valid_country?
|
|
163
|
+
def valid_country?(phone)
|
|
86
164
|
return true unless options[:countries]
|
|
87
|
-
(phone_countries & countries).size > 0
|
|
165
|
+
(phone_countries(phone) & countries).size > 0
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def valid_extensions?(phone)
|
|
169
|
+
return true if extensions_allowed?
|
|
170
|
+
phone.extension.empty?
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def extensions_allowed?
|
|
174
|
+
!options.has_key?(:extensions) || !!options[:extensions]
|
|
88
175
|
end
|
|
89
176
|
|
|
90
|
-
def
|
|
91
|
-
return true
|
|
92
|
-
|
|
177
|
+
def valid_international_prefix?
|
|
178
|
+
return true unless options[:require_international_prefix]
|
|
179
|
+
|
|
180
|
+
@phone.explicit_international_prefix?
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def valid_input_format?(value)
|
|
184
|
+
return true unless options[:format]
|
|
185
|
+
|
|
186
|
+
SUPPORTED_FORMATS.include?(options[:format]) && value.is_a?(String) && value.match?(E164_PATTERN)
|
|
93
187
|
end
|
|
94
188
|
|
|
95
189
|
def specified_country(record)
|
|
@@ -103,9 +197,9 @@ class PhoneValidator < ActiveModel::EachValidator
|
|
|
103
197
|
end
|
|
104
198
|
|
|
105
199
|
# @private
|
|
106
|
-
def phone_types
|
|
200
|
+
def phone_types(phone)
|
|
107
201
|
method = options[:possible] ? :possible_types : :types
|
|
108
|
-
phone_types =
|
|
202
|
+
phone_types = phone.send(method)
|
|
109
203
|
if (phone_types & [Phonelib::Core::FIXED_OR_MOBILE]).size > 0
|
|
110
204
|
phone_types += [Phonelib::Core::FIXED_LINE, Phonelib::Core::MOBILE]
|
|
111
205
|
end
|
|
@@ -113,15 +207,17 @@ class PhoneValidator < ActiveModel::EachValidator
|
|
|
113
207
|
end
|
|
114
208
|
|
|
115
209
|
# @private
|
|
116
|
-
def phone_countries
|
|
210
|
+
def phone_countries(phone)
|
|
117
211
|
method = options[:possible] ? :countries : :valid_countries
|
|
118
|
-
|
|
212
|
+
phone.send(method)
|
|
119
213
|
end
|
|
120
214
|
|
|
121
215
|
# @private
|
|
122
216
|
def types
|
|
123
217
|
types = options[:types].is_a?(Array) ? options[:types] : [options[:types]]
|
|
124
|
-
types.map
|
|
218
|
+
types.map do |type|
|
|
219
|
+
Phonelib::Core::TYPES_DESC_KEYS.find { |known| known.to_s == type.to_s }
|
|
220
|
+
end.compact
|
|
125
221
|
end
|
|
126
222
|
|
|
127
223
|
# @private
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: phonelib
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.10.
|
|
4
|
+
version: 0.10.26
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Vadim Senderovich
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: |2
|
|
14
14
|
Google libphonenumber library was taken as a basis for
|
|
@@ -28,11 +28,17 @@ files:
|
|
|
28
28
|
- lib/phonelib/core.rb
|
|
29
29
|
- lib/phonelib/data_importer.rb
|
|
30
30
|
- lib/phonelib/data_importer_helper.rb
|
|
31
|
+
- lib/phonelib/errors.rb
|
|
32
|
+
- lib/phonelib/locale/en.yml
|
|
31
33
|
- lib/phonelib/phone.rb
|
|
32
34
|
- lib/phonelib/phone_analyzer.rb
|
|
33
35
|
- lib/phonelib/phone_analyzer_helper.rb
|
|
34
36
|
- lib/phonelib/phone_extended_data.rb
|
|
35
37
|
- lib/phonelib/phone_formatter.rb
|
|
38
|
+
- lib/phonelib/phone_validation.rb
|
|
39
|
+
- lib/phonelib/railtie.rb
|
|
40
|
+
- lib/phonelib/validation_error.rb
|
|
41
|
+
- lib/phonelib/validation_result.rb
|
|
36
42
|
- lib/phonelib/version.rb
|
|
37
43
|
- lib/tasks/phonelib_tasks.rake
|
|
38
44
|
- lib/validators/phone_validator.rb
|
|
@@ -40,7 +46,7 @@ homepage: https://github.com/daddyz/phonelib
|
|
|
40
46
|
licenses:
|
|
41
47
|
- MIT
|
|
42
48
|
metadata:
|
|
43
|
-
changelog_uri: https://github.com/daddyz/phonelib/releases/tag/v0.10.
|
|
49
|
+
changelog_uri: https://github.com/daddyz/phonelib/releases/tag/v0.10.26
|
|
44
50
|
post_install_message:
|
|
45
51
|
rdoc_options:
|
|
46
52
|
- " --no-private - CHANGELOG.md --readme README.md"
|