File Coverage

lib/Number/MuPhone.pm
Criterion Covered Total %
statement 107 115 93.0
branch 39 52 75.0
condition 6 14 42.8
subroutine 20 20 100.0
pod 2 3 66.6
total 174 204 85.2


line stmt bran cond sub pod time code
1             package Number::MuPhone;
2 1     1   78076 use strict;
  1         3  
  1         34  
3 1     1   6 use warnings;
  1         2  
  1         28  
4 1     1   14 use v5.020;
  1         3  
5 1     1   783 use Moo;
  1         12846  
  1         6  
6 1     1   2177 use Types::Standard qw( Maybe Str );
  1         80892  
  1         9  
7              
8             $Number::MuPhone::VERSION = '1.0';
9              
10             our $MUPHONE_BASE_DIR = $ENV{MUPHONE_BASE_DIR} || $ENV{HOME}.'/.muphone';
11             our $EXTENSION_REGEX = qr/(?:\*|extension|ext|x)/;
12             our $DIAL_PAUSE = ',,,';
13              
14             # if custom data module exists, load it, else use distribution default
15             # (which will most likely be out of date)
16             our $MUPHONE_DATA;
17             my $data_module_path = "$MUPHONE_BASE_DIR/lib/NumberMuPhoneData.pm";
18             if (-f $data_module_path) {
19             require $data_module_path;
20             }
21             else {
22             require Number::MuPhone::Data;
23             }
24             # Let's import the var shortcut to save typing
25             Number::MuPhone::Data->import('$MUPHONE_DATA');
26              
27             ################################################################################
28              
29             =head1 NAME
30              
31             Number::MuPhone - parsing and using phone numbers in pure Perl
32              
33             NOTE: this is a full rewrite and is not backwards compatible with earlier
34             versions of this module.
35              
36             =head1 DESCRIPTION
37              
38             Parse, validate (loosely in some cases) and display phone numbers as expected.
39              
40             This has stripped down functionality compared to libphonenumber, but it is
41             also Pure Perl (TM), is a bit simpler to use, and contains the core functionality
42             needed by common use cases.
43              
44             If you have functionality requests, please let me know:
45              
46             All number regexes are derived from the XML file supplied by:
47              
48             https://github.com/google/libphonenumber/
49              
50              
51             =head2 BASIC USAGE
52              
53             Instantiate an instance using one of the following syntaxes
54              
55             # single arg: E.123 formatted number, scalar shortcut
56             my $num = Number::MuPhone->new('+1 203 503 1199');
57              
58             # single arg: E.123 formatted number, hashref format
59             my $num = Number::MuPhone->new({
60             number => '+1 203 503 1199'
61             });
62              
63             # double arg, number and country - number can be in local or E.123 format, scalar args
64             my $num = Number::MuPhone->new('+1 203 503 1199','US");
65             my $num = Number::MuPhone->new('(203) 503-1199','US');
66              
67             # double arg, number and country - number can be in local or E.123 format, hashref args
68             my $num = Number::MuPhone->new({
69             number => '+1 203 503 1199'
70             country => 'US',
71             });
72             my $num = Number::MuPhone->new({
73             number => '(203) 503-1199'
74             country => 'US',
75             });
76              
77             # after instantiation, check all is well before using the object
78             if ($num->error) {
79             # process the error
80             }
81              
82             =head2 KEEPING UP TO DATE WITH CHANGES...
83              
84             The data used to validate and format the phone numbers comes fropm Google's libphonenumber:
85              
86             TODO: add URL
87              
88             This distribution comes with a reasonably recent copy of the libphonenumber source XML, but
89             you can also set up a cron to update your source data weekly, to ensure you don't have
90             problems with new area codes as they get added (this happens probably more often than you think).
91              
92             By default, MuPhone's update script (perl-muphone-build-data) will create a ~/.muphon
93             directory and dump everything in there if you choose to update periodically (or when
94             starting a Docker container, say)
95              
96             If you want to store the data elsewhere, set the MUPHONE_BASE_DIR env var to specify
97             where you want it stored. Wherever you store it, the directory must be writeable by
98             the user.
99              
100             Currently, the extractor script only grabs the data we need, and removes spacing, to keep the size down.
101              
102             If you want to examine all available data, set $DEBUG=1 (add in padding) and set
103             $STRIP_SUPERFLUOUS_DATA=0 in the script and run it again.
104              
105             for the following, paths are relative to the ~/.muphone or $ENV{MUPHONE_BASE_DIR} dirs as appropriate
106              
107             ./etc/PhoneNumberMetadata.xml - the libphonenumber source XML file
108             ./lib/NumberMuPhoneData.pm - the generated Number::MuPhone::Data
109             ./t/check_data_module.t - a little sanity script that runs after creating the data file
110              
111             =head3 Initial run
112              
113             Optionally, set the MUPHONE_BASE_DIR environment variable to point to your config directory (must be writeable).
114             Otherwise, ~/.muphone will get used (default).
115              
116             As the user, run:
117              
118             perl-muphone-build-data
119              
120             Confirm the tests pass and the files are created (if not error output, tests passed).
121              
122             =head3 Set up the cron to run weekly to update the data
123              
124             # using default data dir
125             0 5 * * 1 /usr/local/bin/perl-muphone-build-data
126              
127             # using user specific data dir
128             0 5 * * 1 MUPHONE_BASE_DIR=/path/to/config /usr/local/bin/perl-muphone-build-data
129              
130              
131             =head1 PUBLIC ATTRIBUTES
132              
133             =cut
134              
135             around BUILDARGS => sub {
136             my ( $orig, $class, @args ) = @_;
137              
138             # args are probably a hashref - { number => $number, country => 'US' }
139             # but can use a shortcut, if preferred
140             # ($number, 'US')
141              
142             if (ref $args[0] ne 'HASH' and @args>2) {
143             die "Bad args - must be a hashref of name args or (\$num,\$country_code)";
144             }
145              
146             if (!ref $args[0]) {
147             $args[0] = { number => $args[0] };
148              
149             $args[0]->{country} = pop @args
150             if $args[1];
151             }
152            
153             return $class->$orig(@args);
154             };
155              
156             sub BUILD {
157 17     17 0 1730 my ($self,$arg) = @_;
158              
159             # extract number and extension, determine countrycode from number,
160             # strip off possible national/international dial prefix
161             # and store attributes as needed
162 17         37 $self->_process_raw_number;
163              
164             }
165              
166             =head2 number
167              
168             The raw number sent in at instantiation - not needed (outside of logging, maybe)
169              
170             =cut
171              
172             has number => (
173             isa => Str,
174             is => 'ro',
175             required => 1,
176             );
177              
178             =head2 extension
179              
180             Extenstion number (digits only)
181              
182             =cut
183              
184             has extension => (
185             is => 'rw',
186             default => ''
187             );
188              
189             =head2 country
190              
191             The 2 character country code sent in instantiation, or inferred from an E.123 number
192              
193             =cut
194              
195             # 2 char country code - either explicitly sent, to inferred from the number / config
196             has country => (
197             isa => Maybe[Str],
198             is => 'rw',
199             lazy => 1,
200             );
201              
202             =head2 error
203              
204             If the args don't point to a valid number at instantiation, this error will be set
205              
206             =cut
207              
208             has error => (
209             isa => Str,
210             is => 'rw',
211             default => '',
212             );
213              
214             =head2 country_name
215              
216             Full text name of country()
217              
218             =cut
219              
220             has country_name => (
221             is => 'lazy',
222             );
223             sub _build_country_name {
224 3     3   4396 my $self = shift;
225 3         53 return $MUPHONE_DATA->{territories}->{ $self->country }->{TerritoryName};
226             }
227              
228             =head2 country_code
229              
230             1-3 digit country code
231              
232             =cut
233              
234             has country_code => (
235             is => 'lazy',
236             );
237             sub _build_country_code {
238 6     6   1193 my $self = shift;
239 6         106 return $MUPHONE_DATA->{territories}->{ $self->country }->{countryCode};
240             }
241              
242             =head2 national_dial
243              
244             How you would dial this number within the country (including national dial code)
245              
246             =cut
247              
248             has national_dial => (
249             is => 'lazy',
250             );
251             sub _build_national_dial {
252 4     4   32 my $self = shift;
253 4 100       44 my $dial_prefix = $self->_national_prefix_optional_when_formatting
254             ? ''
255             : $self->_national_dial_prefix;
256              
257 4         82 return $dial_prefix.$self->_cleaned_number.$self->_extension_dial;
258             }
259              
260             =head2 national_display
261              
262             Display this number in the national number format
263              
264             =cut
265              
266             # How do you display the number when you're in the country?
267             # this default should work for most countries
268             has national_display => (
269             is => 'ro',
270             lazy => 1,
271             default => sub {
272             my $self = shift;
273             my $dial_prefix = $self->_national_prefix_optional_when_formatting
274             ? ''
275             : $self->_national_dial_prefix;
276              
277             return $dial_prefix.$self->_formatted_number.$self->_extension_display;
278             }
279             );
280              
281             =head2 national_display
282              
283             Display this number in the international number format (E.123)
284              
285             =cut
286              
287             has international_display => (
288             is => 'ro',
289             lazy => 1,
290             default => sub {
291             my $self = shift;
292             return '+'.$self->country_code.' '.$self->_formatted_number.$self->_extension_display;
293             }
294             );
295              
296             =head2 e164
297              
298             The number in E.164 format (+$COUNTRY_CODE$NUMBER[;ext=$EXTENSION])
299              
300             =cut
301              
302             has e164 => (
303             is => 'lazy',
304             );
305             sub _build_e164 {
306 3     3   1756 my $self = shift;
307 3 100       18 my $ext = $self->extension
308             ? ";ext=".$self->extension
309             : '';
310 3         50 return $self->e164_no_ext.$ext;
311             }
312              
313             =head2 e164_no_ext
314              
315             The number in E.164 format, but with no extension (+$COUNTRY_CODE$NUMBER)
316              
317             =cut
318              
319             has e164_no_ext => (
320             is => 'lazy',
321             );
322             sub _build_e164_no_ext {
323 3     3   1828 my $self = shift;
324 3         50 return '+'.$self->country_code.$self->_cleaned_number;
325             }
326              
327             # number with international and national dial codes, and all non digits removed
328             has _cleaned_number => (
329             is => 'rw',
330             default => '',
331             );
332              
333             # basic validation of a number via this regex
334             has _national_number_regex => (
335             is => 'lazy',
336             );
337             sub _build__national_number_regex {
338 14     14   107 my $self = shift;
339 14         212 my $regex_string = $MUPHONE_DATA->{territories}->{ $self->country }->{generalDesc}->{nationalNumberPattern};
340 14         234 return qr/^$regex_string$/;
341             }
342              
343             # Display number without international or nation dial prefixes
344             # built by _process_raw_number
345             has _formatted_number => (
346             is => 'rw',
347             );
348              
349             # Boolean used to help determine how to display a number
350             # built in sub _process_raw_number
351             has _national_prefix_optional_when_formatting => (
352             is => 'rw',
353             );
354              
355             # add pause to extension to create dial
356             has _extension_dial => (
357             is => 'lazy',
358             );
359             sub _build__extension_dial {
360 4     4   31 my $self = shift;
361 4 100       39 return $self->extension
362             ? $DIAL_PAUSE.$self->extension
363             : '';
364             }
365              
366             # prefix you dial when dialing the _cleaned_number within the country
367             has _national_dial_prefix => (
368             is => 'lazy',
369             );
370             sub _build__national_dial_prefix {
371 8     8   2792 my $self = shift;
372 8         129 $MUPHONE_DATA->{territories}->{ $self->country }->{nationalPrefix};
373             }
374              
375             # how to display the extension text + number (currently only in English)
376             has _extension_display => (
377             is => 'lazy',
378             );
379             sub _build__extension_display {
380 10     10   1303 my $self = shift;
381 10 100       76 my $ext =
382             return $self->extension
383             ? ' '.$self->_extension_text.' '.$self->extension
384             : '';
385             }
386              
387             # text to display befor an extension
388             has _extension_text => (
389             is => 'ro',
390             default => 'ext',
391             );
392              
393             # helper method to get the country for a number, country, or object
394             sub _get_country_from {
395 15     15   25 my ($self,$str_or_obj) = @_;
396              
397             # $str_or_arg should be
398             # - Number::MuPhone instance
399             # - E.123 formatted number
400             # - 2 char country code
401              
402             # muphone num
403 15 100       71 if (ref $str_or_obj eq 'Number::MuPhone') {
    50          
    50          
404 8         191 return $str_or_obj->country;
405             }
406             # E.123
407             elsif ($str_or_obj =~ /^\s\+/) {
408 0         0 my $num = Number::MuPhone->new($str_or_obj);
409 0         0 return $num->country;
410             }
411             # it should be a country
412             elsif ( $str_or_obj =~ /^[A-Z]{2}$/ ) {
413 7         18 return $str_or_obj;
414             }
415             else {
416 0         0 die "Not a country, E.123 num, or MuPhone object: $str_or_obj";
417             }
418             }
419              
420             =head1 METHODS
421              
422             =head2 dial_from
423              
424             How to dial the number from the number/country sent in as an arg. eg
425              
426             my $uk_num1 = Number::MuPhone->new({ country => 'GB', number => '01929 552699' });
427             my $uk_num2 = Number::MuPhone->new({ country => 'GB', number => '01929 552698' });
428             my $us_num = Number::MuPhone->new({ country => 'US', number => '203 503 1234' });
429              
430             # these all have the same output (01929552699)
431             my $dial_from_uk = $uk_num1->dial_from($uk_num2);
432             my $dial_from_uk = $uk_num1->dial_from('GB');
433             my $dial_from_uk = $uk_num1->dial_from('+441929 552698');
434              
435             # similarly, dialling the number from the US (011441929552699)
436             my $dial_from_us = $uk_num1->dial_from($us_num);
437             my $dial_from_us = $uk_num1->dial_from('US');
438             my $dial_from_us = $uk_num1->dial_from('+1 203 503 1234');
439              
440             =cut
441              
442             sub dial_from {
443 7     7 1 2754 my ($self,$str_or_obj) = @_;
444 7   33     19 $str_or_obj||=$self;
445 7         15 my $from_country = $self->_get_country_from($str_or_obj);
446 7 100       151 if ( $from_country eq $self->country ) {
447 4         83 return $self->national_dial;
448             }
449             else {
450             return $MUPHONE_DATA->{territories}->{ $from_country }->{internationalPrefix}
451 3         65 .$self->country_code
452             .$self->_cleaned_number;
453             }
454             }
455              
456             =head2 display_from
457              
458             How to display the number for the number/country sent in as an arg. eg
459              
460             my $uk_num1 = Number::MuPhone->new({ country => 'GB', number => '01929 552699' });
461             my $uk_num2 = Number::MuPhone->new({ country => 'GB', number => '01929 552698' });
462             my $us_num = Number::MuPhone->new({ country => 'US', number => '203 503 1234' });
463              
464             # these all have the same output (01929 552699)
465             my $display_from_uk = $uk_num1->display_from($uk_num2);
466             my $display_from_uk = $uk_num1->display_from('GB');
467             my $display_from_uk = $uk_num1->display_from('+441929 552698');
468              
469             # similarly, dialling the number from the US (01144 1929 552699)
470             my $display_from_us = $uk_num1->display_from($us_num);
471             my $display_from_us = $uk_num1->display_from('US');
472             my $display_from_us = $uk_num1->display_from('+1 203 503 1234');
473              
474             =cut
475              
476             sub display_from {
477 8     8 1 2205 my ($self,$str_or_obj) = @_;
478 8   33     20 $str_or_obj||=$self;
479 8         19 my $from_country = $self->_get_country_from($str_or_obj);
480 8 100       172 if ( $from_country eq $self->country ) {
481 4         82 return $self->national_display;
482             }
483             else {
484             # (DIAL PREFIX) (COUNTRY CODE) (FORMATTED NUMBER) [ (EXTENSION) ]
485             return $MUPHONE_DATA->{territories}->{ $from_country }->{internationalPrefix}
486 4         105 .$self->country_code.' '
487             .$self->_formatted_number.$self->_extension_display;
488             }
489             }
490              
491              
492             # PRIVATE METHODS
493              
494             # splits off optional extension, and cleans both up for storage
495             # only place where we set error
496             sub _process_raw_number {
497 17     17   22 my $self = shift;
498              
499 17         123 my ($raw_num,$ext) = split $EXTENSION_REGEX, $self->number;
500 17   100     73 $ext||='';
501 17         23 $ext =~ s/\D//g;
502 17         43 $self->extension($ext);
503              
504             # if number begins with a '+' we can determine country from E.123 number
505 17 100       285 if ($raw_num =~ /^\s*\+/) {
    50          
506 4         13 $self->_process_from_e123($raw_num);
507             }
508             # if we have a country set, clean up raw number (ie, strip national dial code, if set)
509             elsif (my $country = $self->country) {
510 13         119 $raw_num =~ s/\D//g;
511 13         37 my $national_prefix = $MUPHONE_DATA->{territories}->{ $country }->{nationalPrefix};
512 13 50       26 if ( defined $national_prefix ) {
513 13         83 $raw_num =~ s/^$national_prefix//;
514             }
515 13         40 $self->_cleaned_number( $raw_num );
516             }
517              
518             # if no country set by the time we get here, we need to set error and bail
519 17         279 my $country = $self->country;
520 17 50       101 unless ( $country ) {
521 0         0 $self->error("Country not supplied, and I can't determine it from the number");
522 0         0 return;
523             }
524              
525             # Number must match the national number pattern, if exists
526 17         36 my $cleaned_num = $self->_cleaned_number;
527 17 50 33     75 if ( $MUPHONE_DATA->{territories}->{ $country }->{generalDesc}
528             && $MUPHONE_DATA->{territories}->{ $country }->{generalDesc}->{nationalNumberPattern} ) {
529              
530 17         169 my $regex = qr/^(?:$MUPHONE_DATA->{territories}->{ $country }->{generalDesc}->{nationalNumberPattern})$/;
531 17 100       102 unless ( $cleaned_num =~ $regex ) {
532 3         59 $self->error("Number ($cleaned_num) is not valid for country ($country)");
533 3         108 return;
534             }
535             }
536              
537             # confirm cleaned number is a valid number for the country
538 14 50       249 unless ( $self->_cleaned_number =~ $self->_national_number_regex ) {
539 0         0 $self->error("Number $raw_num is not valid for country ".$self->country);
540             }
541              
542             # don't create formatted number if we have an error
543 14 50       236 $self->error and return;
544              
545             # if no number formats, just set to the cleaned number
546 14         286 my $number_formats = $MUPHONE_DATA->{territories}->{ $self->country }->{availableFormats}->{numberFormat};
547              
548 14         91 my $num = $self->_cleaned_number;
549 14         18 my $national_prefix_optional=0;
550              
551             # iterate through the available formats until you get a match
552             # (if not set, we default to cleaned number
553 14         27 FORMAT: foreach my $format_hash (@$number_formats) {
554             # not all countries have leading digit mappings
555 49 50       105 if (my $leading_digits = $format_hash->{leadingDigits}) {
556 49 100       740 next FORMAT unless ( $num =~ /^(?:$leading_digits)/ );
557             }
558              
559 14         197 my $pattern = qr/^$format_hash->{pattern}$/;
560 14 50       90 next FORMAT unless ( $num =~ $pattern );
561              
562 14         28 my $format = $format_hash->{format};
563              
564 14         36 my $regex_statement = "\$num =~ s/$pattern/$format/;";
565 14         1339 eval $regex_statement;
566 14 50       60 if ($@) {
567 0         0 $self->error("Can't format number($num) with regex($regex_statement): $@");
568 0         0 last FORMAT;
569             }
570              
571             $national_prefix_optional = $format_hash->{nationalPrefixOptionalWhenFormatting}
572 14 100       40 ? 1 : 0;
573 14         38 last FORMAT;
574             }
575              
576 14         62 $self->_formatted_number($num);
577 14         90 $self->_national_prefix_optional_when_formatting($national_prefix_optional);
578              
579             }
580              
581             # number starts with a + ? Great, we should be able to work it out.
582             sub _process_from_e123 {
583 4     4   7 my ($self,$num) = @_;
584              
585 4         20 $num =~ s/\D//g;
586              
587 4         7 my $countries = [];
588              
589             # grab from country lookup - country code is 1-3 digits long
590 4         9 my @prefixes = map { substr($num, 0, $_) } 1..3;
  12         28  
591 4         9 PREFIX: foreach my $idd (@prefixes) {
592             # we found a match
593 6 100       20 if ($countries = $MUPHONE_DATA->{idd_codes}->{$idd}) {
594             # so strip off the IDD from the number
595 4         55 $num =~ s/^$idd//;
596 4         11 last PREFIX;
597             }
598             }
599              
600             # now find out which country the number matches
601             # (for IDD codes with multiple countries, this may not be correct, but should be
602             # good enough for this use case - just don't rely on the country
603             # TODO - maybe iterate through all regexes by number type to confirm validity?
604             # generalDesc regex is too loose for (eg) US/CA
605             # to implement this, we'd need to keep the various number type regexes around
606             # Suggest look at adding in next update
607 4         6 my $country;
608 4         9 COUNTRY: foreach my $country (@$countries) {
609             my $national_number_format_regex = $MUPHONE_DATA->{territories}->{$country}->{generalDesc} && $MUPHONE_DATA->{territories}->{$country}->{generalDesc}->{nationalNumberPattern}
610 58 50 33     1209 ? qr/^$MUPHONE_DATA->{territories}->{$country}->{generalDesc}->{nationalNumberPattern}$/
611             : '';
612 58 50       152 $national_number_format_regex
613             or next COUNTRY;
614            
615 58 100       305 $num =~ $national_number_format_regex
616             or next COUNTRY;
617            
618 6         131 $self->country($country);
619 6         206 $self->_cleaned_number($num);
620             }
621              
622             }
623              
624             1;