phonejack 1.2.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,9 @@
1
+ class PhonejackValidator < ActiveModel::EachValidator
2
+ def validate_each(record, attribute, value)
3
+ country = options[:country].call(record) if options.key?(:country)
4
+ valid_types = options.fetch(:types, [])
5
+ args = [value, country, valid_types]
6
+
7
+ record.errors.add(attribute, :invalid) if Phonejack.invalid?(*args)
8
+ end
9
+ end
data/lib/phonejack.rb ADDED
@@ -0,0 +1,17 @@
1
+ require 'phonejack/version'
2
+ require 'utilities/hash'
3
+ require 'active_model/phonejack_validator' if defined?(ActiveModel)
4
+
5
+ module Phonejack
6
+ autoload :DataImporter, 'phonejack/data_importer'
7
+ autoload :TestDataGenerator, 'phonejack/test_data_generator'
8
+ autoload :Parser, 'phonejack/parser'
9
+ autoload :Number, 'phonejack/number'
10
+ autoload :Formatter, 'phonejack/formatter'
11
+ autoload :Country, 'phonejack/country'
12
+ autoload :NumberFormat, 'phonejack/number_format'
13
+ autoload :NumberValidation, 'phonejack/number_validation'
14
+ autoload :ClassMethods, 'phonejack/class_methods'
15
+
16
+ extend ClassMethods
17
+ end
@@ -0,0 +1,40 @@
1
+ module Phonejack
2
+ module ClassMethods
3
+ attr_accessor :override_file, :default_format_string
4
+ attr_reader :default_format_pattern
5
+
6
+ def default_format_pattern=(format_string)
7
+ @default_format_pattern = Regexp.new(format_string)
8
+ end
9
+
10
+ def parse(number, country = detect_country(number))
11
+ Phonejack::Number.new(sanitize(number), country)
12
+ end
13
+
14
+ def valid?(number, country = detect_country(number), keys = [])
15
+ parse(number, country).valid?(keys)
16
+ end
17
+
18
+ def invalid?(*args)
19
+ !valid?(*args)
20
+ end
21
+
22
+ def sanitize(input_number)
23
+ input_number.to_s.gsub(/\D/, '')
24
+ end
25
+
26
+ def detect_country(number)
27
+ sanitized_number = sanitize(number)
28
+ detected_country = Country.all_countries.detect do |country|
29
+ sanitized_number.start_with?(country.country_code) && valid?(sanitized_number, country.country_id)
30
+ end
31
+
32
+ detected_country.country_id.to_sym if detected_country
33
+ end
34
+
35
+ # generates binary file from xml that user gives us
36
+ def generate_override_file(file)
37
+ DataImporter.new(file, override: true).import!
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,67 @@
1
+ module Phonejack
2
+ class Country
3
+ attr_reader :country_code, :national_prefix, :national_prefix_formatting_rule,
4
+ :national_prefix_for_parsing, :national_prefix_transform_rule, :international_prefix,
5
+ :formats, :validations, :mobile_token, :country_id, :general_validation, :main_country_for_code
6
+
7
+ MOBILE_TOKEN_COUNTRIES = { AR: '9' }
8
+
9
+ def initialize(data_hash)
10
+ @country_code = data_hash[:country_code]
11
+ @country_id = data_hash[:id]
12
+ @formats = data_hash.fetch(:formats, []).map { |format| NumberFormat.new(format) }
13
+ @general_validation = NumberValidation.new(:general_desc, data_hash[:validations][:general_desc]) if data_hash.fetch(:validations, {})[:general_desc]
14
+ @international_prefix = Regexp.new(data_hash[:international_prefix]) if data_hash[:international_prefix]
15
+ @main_country_for_code = data_hash[:main_country_for_code] == 'true'
16
+ @mobile_token = MOBILE_TOKEN_COUNTRIES[@country_id.to_sym]
17
+ @national_prefix = data_hash[:national_prefix]
18
+ @national_prefix_formatting_rule = data_hash[:national_prefix_formatting_rule]
19
+ @national_prefix_for_parsing = Regexp.new(data_hash[:national_prefix_for_parsing]) if data_hash[:national_prefix_for_parsing]
20
+ @national_prefix_transform_rule = data_hash[:national_prefix_transform_rule]
21
+ @validations = data_hash.fetch(:validations, {})
22
+ .except(:general_desc, :area_code_optional)
23
+ .map { |name, data| NumberValidation.new(name, data) }
24
+ end
25
+
26
+ def detect_format(number)
27
+ native_format = formats.detect do |format|
28
+ number =~ Regexp.new("^(#{format.leading_digits})") && number =~ Regexp.new("^(#{format.pattern})$")
29
+ end
30
+
31
+ return native_format if native_format || main_country_for_code
32
+ parent_country.detect_format(number) if parent_country
33
+ end
34
+
35
+ def parent_country
36
+ return if main_country_for_code
37
+ Country.all_countries.detect do |country|
38
+ country.country_code == self.country_code && country.main_country_for_code
39
+ end
40
+ end
41
+
42
+ def full_general_pattern
43
+ %r{^(#{country_code})?(#{national_prefix})?(?<national_num>#{general_validation.pattern})$}
44
+ end
45
+
46
+ def self.phone_data
47
+ @phone_data ||= load_data
48
+ end
49
+
50
+ def self.find(country_id)
51
+ data = phone_data[country_id.to_s.upcase.to_sym]
52
+ new(data) if data
53
+ end
54
+
55
+ def self.load_data
56
+ data_file = "#{File.dirname(__FILE__)}/../../data/telephone_number_data_file.dat"
57
+ main_data = Marshal.load(File.binread(data_file))
58
+ override_data = {}
59
+ override_data = Marshal.load(File.binread(Phonejack.override_file)) if Phonejack.override_file
60
+ return main_data.deep_deep_merge!(override_data)
61
+ end
62
+
63
+ def self.all_countries
64
+ @all_countries ||= phone_data.values.map {|data| new(data)}
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,96 @@
1
+ module Phonejack
2
+ require 'nokogiri'
3
+ class DataImporter
4
+ attr_reader :data, :file, :override
5
+
6
+ def initialize(file_name, override: false)
7
+ @data = {}
8
+ @file = File.open(file_name) { |f| Nokogiri::XML(f) }
9
+ @override = override
10
+ end
11
+
12
+ def import!
13
+ parse_main_data
14
+ save_data_file
15
+ end
16
+
17
+ def parse_main_data
18
+ file.css('territories territory').each do |territory|
19
+ country_code = territory.attributes['id'].value.to_sym
20
+ @data[country_code] ||= {}
21
+
22
+ load_base_attributes(@data[country_code], territory)
23
+ load_references(@data[country_code], territory)
24
+ load_validations(@data[country_code], territory)
25
+ load_formats(@data[country_code], territory)
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def load_formats(country_data, territory)
32
+ formats_arr = territory.css('availableFormats numberFormat').map do |format|
33
+ {}.tap do |fhash|
34
+ format.attributes.values.each do |attr|
35
+ key = underscore(attr.name).to_sym
36
+ fhash[key] = if key == :national_prefix_formatting_rule
37
+ attr.value
38
+ else
39
+ attr.value.delete("\n ")
40
+ end
41
+ end
42
+ format.elements.each do |child|
43
+ key = underscore(child.name).to_sym
44
+ fhash[key] = [:format, :intl_format].include?(key) ? child.text : child.text.delete("\n ")
45
+ end
46
+ end
47
+ end
48
+
49
+ return if override && formats_arr.empty?
50
+ country_data[:formats] = formats_arr
51
+ end
52
+
53
+ def load_validations(country_data, territory)
54
+ country_data[:validations] = {}
55
+ territory.elements.each do |element|
56
+ next if element.name == 'references' || element.name == 'availableFormats'
57
+ country_data[:validations][underscore(element.name).to_sym] = {}.tap do |validation_hash|
58
+ element.elements.each { |child| validation_hash[underscore(child.name).to_sym] = child.text.delete("\n ") }
59
+ end
60
+ end
61
+ country_data.delete(:validations) if country_data[:validations].empty? && override
62
+ end
63
+
64
+ def load_base_attributes(country_data, territory)
65
+ territory.attributes.each do |key, value_object|
66
+ underscored_key = underscore(key).to_sym
67
+ country_data[underscored_key] = if underscored_key == :national_prefix_for_parsing
68
+ value_object.value.delete("\n ")
69
+ else
70
+ value_object.value
71
+ end
72
+ end
73
+ end
74
+
75
+ def load_references(country_data, territory)
76
+ ref_arr = territory.css('references sourceUrl').map(&:text)
77
+ return if override && ref_arr.empty?
78
+ country_data[:references] = ref_arr
79
+ end
80
+
81
+ def underscore(camel_cased_word)
82
+ return camel_cased_word unless camel_cased_word =~ /[A-Z-]|::/
83
+ word = camel_cased_word.to_s.gsub(/::/, '/')
84
+ word.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
85
+ word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
86
+ word.tr!('-', '_')
87
+ word.downcase!
88
+ word
89
+ end
90
+
91
+ def save_data_file
92
+ data_file = override ? 'telephone_number_data_override_file.dat' : "#{File.dirname(__FILE__)}/../../data/telephone_number_data_file.dat"
93
+ File.open(data_file, 'wb+') { |f| Marshal.dump(@data, f) }
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,76 @@
1
+ module Phonejack
2
+ class Formatter
3
+
4
+ attr_reader :normalized_number, :country, :valid, :original_number
5
+
6
+ def initialize(number_obj)
7
+ @normalized_number = number_obj.normalized_number
8
+ @country = number_obj.country
9
+ @valid = number_obj.valid?
10
+ @original_number = number_obj.original_number
11
+ end
12
+
13
+ def national_number(formatted: true)
14
+ return original_or_default if !valid? || !number_format
15
+ build_national_number(formatted: formatted)
16
+ end
17
+
18
+ def e164_number(formatted: true)
19
+ return original_or_default if !valid?
20
+ build_e164_number(formatted: formatted)
21
+ end
22
+
23
+ def international_number(formatted: true)
24
+ return original_or_default if !valid? || !number_format
25
+ build_international_number(formatted: formatted)
26
+ end
27
+
28
+ alias_method :valid?, :valid
29
+
30
+ private
31
+
32
+ def number_format
33
+ @number_format ||= country.detect_format(normalized_number)
34
+ end
35
+
36
+ def build_national_number(formatted: true)
37
+ captures = normalized_number.match(number_format.pattern).captures
38
+ national_prefix_formatting_rule = number_format.national_prefix_formatting_rule || country.national_prefix_formatting_rule
39
+
40
+ formatted_string = format(ruby_format_string(number_format.format), *captures)
41
+ captures.delete(country.mobile_token)
42
+
43
+ if national_prefix_formatting_rule
44
+ national_prefix_string = national_prefix_formatting_rule.dup
45
+ national_prefix_string.gsub!(/\$NP/, country.national_prefix)
46
+ national_prefix_string.gsub!(/\$FG/, captures[0])
47
+ formatted_string.sub!(captures[0], national_prefix_string)
48
+ end
49
+
50
+ formatted ? formatted_string : Phonejack.sanitize(formatted_string)
51
+ end
52
+
53
+ def build_e164_number(formatted: true)
54
+ formatted_string = "+#{country.country_code}#{normalized_number}"
55
+ formatted ? formatted_string : Phonejack.sanitize(formatted_string)
56
+ end
57
+
58
+ def build_international_number(formatted: true)
59
+ return original_or_default if !valid? || number_format.nil?
60
+ captures = normalized_number.match(number_format.pattern).captures
61
+ key = number_format.intl_format || number_format.format
62
+ formatted_string = "+#{country.country_code} #{format(ruby_format_string(key), *captures)}"
63
+ formatted ? formatted_string : Phonejack.sanitize(formatted_string)
64
+ end
65
+
66
+ def ruby_format_string(format_string)
67
+ format_string.gsub(/(\$\d)/) { |cap| "%#{cap.reverse}s" }
68
+ end
69
+
70
+ def original_or_default
71
+ return original_number unless Phonejack.default_format_string && Phonejack.default_format_pattern
72
+ captures = original_number.match(Phonejack.default_format_pattern).captures
73
+ format(ruby_format_string(Phonejack.default_format_string), *captures)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,17 @@
1
+ module Phonejack
2
+ class Number
3
+ extend Forwardable
4
+
5
+ attr_reader :country, :parser, :formatter, :original_number
6
+
7
+ delegate [:valid?, :valid_types, :normalized_number] => :parser
8
+ delegate [:national_number, :e164_number, :international_number] => :formatter
9
+
10
+ def initialize(number, country)
11
+ @original_number = number
12
+ @country = Country.find(country)
13
+ @parser = Parser.new(self)
14
+ @formatter = Formatter.new(self)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,14 @@
1
+ module Phonejack
2
+ class NumberFormat
3
+
4
+ attr_reader :pattern, :leading_digits, :format, :national_prefix_formatting_rule, :intl_format
5
+
6
+ def initialize(data_hash)
7
+ @pattern = Regexp.new(data_hash[:pattern]) if data_hash[:pattern]
8
+ @leading_digits = Regexp.new(data_hash[:leading_digits]) if data_hash[:leading_digits]
9
+ @format = data_hash[:format]
10
+ @intl_format = data_hash[:intl_format]
11
+ @national_prefix_formatting_rule = data_hash[:national_prefix_formatting_rule]
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,10 @@
1
+ module Phonejack
2
+ class NumberValidation
3
+ attr_reader :name, :pattern
4
+
5
+ def initialize(name, data_hash)
6
+ @name = name
7
+ @pattern = data_hash[:national_number_pattern]
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,68 @@
1
+ module Phonejack
2
+ class Parser
3
+ attr_reader :original_number, :normalized_number, :country
4
+
5
+ def initialize(number_obj)
6
+ @original_number = number_obj.original_number
7
+ @country = number_obj.country
8
+ @normalized_number = build_normalized_number if @country
9
+ end
10
+
11
+ def valid_types
12
+ @valid_types ||= validate
13
+ end
14
+
15
+ def valid?(keys = [])
16
+ keys.empty? ? !valid_types.empty? : !(valid_types & keys.map(&:to_sym)).empty?
17
+ end
18
+
19
+ private
20
+
21
+ # normalized_number is basically a "best effort" at national number without
22
+ # any formatting. This is what we will use to derive formats, validations and
23
+ # basically anything else that uses google data
24
+ def build_normalized_number
25
+ match_result = parse_prefix.match(country.full_general_pattern)
26
+ match_result ? match_result[:national_num] : original_number
27
+ end
28
+
29
+ # returns an array of valid types for the normalized number
30
+ # if array is empty, we can assume that the number is invalid
31
+ def validate
32
+ return [] unless country
33
+ country.validations.select do |validation|
34
+ normalized_number =~ Regexp.new("^(#{validation.pattern})$")
35
+ end.map(&:name)
36
+ end
37
+
38
+ def parse_prefix
39
+ return original_number unless country.national_prefix_for_parsing
40
+ duped = original_number.dup
41
+ match_object = duped.match(country.national_prefix_for_parsing)
42
+
43
+ # we need to do the "start_with?" here because we need to make sure it's not finding
44
+ # something in the middle of the number. However, we can't modify the regex to do this
45
+ # for us because it will offset the match groups that are referenced in the transform rules
46
+ return original_number unless match_object && duped.start_with?(match_object[0])
47
+ if country.national_prefix_transform_rule
48
+ transform_national_prefix(duped, match_object)
49
+ else
50
+ duped.sub!(match_object[0], '')
51
+ end
52
+ end
53
+
54
+ def transform_national_prefix(duped, match_object)
55
+ if country.mobile_token && match_object.captures.any?
56
+ format(build_format_string, duped.sub!(match_object[0], match_object[1]))
57
+ elsif match_object.captures.none?
58
+ duped.sub!(match_object[0], '')
59
+ else
60
+ format(build_format_string, *match_object.captures)
61
+ end
62
+ end
63
+
64
+ def build_format_string
65
+ country.national_prefix_transform_rule.gsub(/(\$\d)/) { |cap| "%#{cap.reverse}s" }
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,116 @@
1
+ module Phonejack
2
+ require 'nokogiri'
3
+ require 'httparty'
4
+ require 'yaml'
5
+
6
+ class TestDataGenerator
7
+ URL = "https://libphonenumber.appspot.com/phonenumberparser?number=%s&country=%s".freeze
8
+ COUNTRIES = {
9
+ AE: %w(971529933171 971553006144 971551000291),
10
+ AR: %w(111512345678 380151234567 299154104587 01112345678),
11
+ AU: %w(0467703037),
12
+ BE: %w(32498485960 32477702206 32474095692),
13
+ BO: %w(59178500348 59178006138 59178006139),
14
+ BR: %w(011992339376 1123456789 11961234567),
15
+ BY: %w(80152450911 294911911 152450911),
16
+ CA: %w(16135550119 16135550171 16135550112 16135550194
17
+ 16135550122 16135550131 15146708700 14169158200),
18
+ CH: %w(41794173875 41795061129 41795820985),
19
+ CL: %w(961234567 221234567),
20
+ CN: %w(15694876068 13910503766 15845989469 05523245954 04717158875 03748086894),
21
+ CO: %w(3211234567 12345678),
22
+ CR: %w(22123456 83123456),
23
+ DE: %w(15222503070),
24
+ DK: %w(4524453744 4551622172 4542428484),
25
+ EC: %w(593992441504 593984657155 593984015053),
26
+ EE: %w(37253629280 37253682997 37254004517),
27
+ ES: %w(34606217800 34667751353 34646570628),
28
+ FR: %w(0607114556),
29
+ GB: %w(448444156790 442079308181 442076139800 442076361000
30
+ 07780991912 442076299400 442072227888),
31
+ HK: %w(64636251),
32
+ HU: %w(36709311285 36709311279 36709311206),
33
+ IE: %w(0863634875),
34
+ IN: %w(915622231515 912942433300 912912510101 911126779191
35
+ 912224818000 917462223999 912266653366 912266325757
36
+ 914066298585 911242451234 911166566162 911123890606
37
+ 911123583754 5622231515 2942433300 2912510101
38
+ 1126779191 2224818000 7462223999 2266653366
39
+ 2266325757 4066298585 1242451234 1166566162
40
+ 1123890606 1123583754 09176642499),
41
+ IT: %w(393478258998 393440161350),
42
+ JP: %w(312345678 9012345678),
43
+ KR: %w(821036424812 821053812833 821085894820),
44
+ MX: %w(4423593227 14423593227),
45
+ NL: %w(31610958780 31610000852 31611427604),
46
+ NO: %w(4792272668 4797065876 4792466013),
47
+ NZ: %w(64212715077 6421577017 64212862111),
48
+ PE: %w(51994156035 51987527881 51972737259),
49
+ PH: %w(639285588185 639285588262 639285548190),
50
+ PL: %w(48665666003 48885882321 48885889958),
51
+ QA: %w(97470482288 97474798678),
52
+ RO: %w(40724242563 40727798526 40727735377),
53
+ SA: %w(966503891468 966501543349 966500939012),
54
+ SE: %w(46708922920 46723985268 46761001966),
55
+ SG: %w(96924755),
56
+ TR: %w(905497728782 905497728780 905497728781),
57
+ TT: %w(18687804765 18687804843 18687804752),
58
+ TW: %w(886905627933 886905627901 886905627925),
59
+ US: %w(16502530000 14044879000 15123435283 13032450086 16175751300
60
+ 3175083345 13128404100 12485934000 19497941600 14257395600 13103106000
61
+ 16086699600 12125650000 14123456700 14157360000 12068761800
62
+ 12023461100 3175082333),
63
+ VE: %w(584149993108 584248407260 584248271518),
64
+ ZA: %w(27826187617 27823014578 27828840632)
65
+ }.freeze
66
+ def self.import!
67
+ output_hash = {}
68
+ fetch_data(output_hash)
69
+ write_file(output_hash)
70
+ true
71
+ end
72
+
73
+ def self.fetch_data(output_hash)
74
+ COUNTRIES.each do |key, value|
75
+ output_hash[key] = {}
76
+
77
+ value.each_with_index do |num, counter|
78
+ page = HTTParty.get(format(URL, num, key.to_s))
79
+ parsed_page = Nokogiri::HTML.parse(page)
80
+ body = parsed_page.elements.first.elements.css('body').first
81
+ parsed_data = parse_remote_data(counter, body.elements.css('table')[2])
82
+ output_hash[key].merge!(parsed_data)
83
+ end
84
+ end
85
+ return output_hash
86
+ end
87
+
88
+ def self.parse_remote_data(counter, table)
89
+ output = { counter.to_s => {} }
90
+ table.elements.each do |row|
91
+ next if row.elements.one?
92
+ key = case row.elements.css('th').text
93
+ when 'E164 format'
94
+ :e164_formatted
95
+ when 'National format'
96
+ :national_formatted
97
+ when 'International format'
98
+ :international_formatted
99
+ end
100
+
101
+ output[counter.to_s][key] = row.elements.css('td').text if key
102
+ end
103
+ return output
104
+ end
105
+
106
+ def self.write_file(data)
107
+ File.open('test/valid_numbers.yml', 'w') do |file|
108
+ file.write "# This file is generated automatically by TestDataGenerator. \n" \
109
+ "# Any changes made to this file will be overridden next time test data is generated. \n" \
110
+ "# Please edit TestDataGenerator if you need to add test cases. \n"
111
+
112
+ file.write data.to_yaml
113
+ end
114
+ end
115
+ end
116
+ end