phonejack 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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