Skip to content

Commit

Permalink
feat(phone): support parsing with fallback region
Browse files Browse the repository at this point in the history
  • Loading branch information
julakmane committed Jan 16, 2025
1 parent e2affda commit 65ed802
Show file tree
Hide file tree
Showing 3 changed files with 114 additions and 15 deletions.
10 changes: 8 additions & 2 deletions grammar/grammar.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import (
)

var (
reg = regexp.MustCompile("[^a-zA-Z0-9]+")
phoneSanitizer = strings.NewReplacer(" ", "", ".", "", "_", "", "(", "", ")", "", "-", "")
reg = regexp.MustCompile("[^a-zA-Z0-9]+")
phoneSanitizer = strings.NewReplacer(" ", "", ".", "", "_", "", "(", "", ")", "", "-", "")
phoneSanitizerStrict = strings.NewReplacer(" ", "", ".", "", "_", "", "(", "", ")", "", "-", "", "+", "")
)

func Normalizer() transform.Transformer {
Expand Down Expand Up @@ -42,6 +43,11 @@ func SanitizePhone(phone string) string {
return strings.TrimSpace(phoneSanitizer.Replace(phone))
}

// SanitizePhoneStrict removes all special characters of a phone string, including the `+`.
func SanitizePhoneStrict(phone string) string {
return strings.TrimSpace(phoneSanitizerStrict.Replace(phone))
}

// Normalize normalizes a string by replacing special letter by its normalized version (e.g. : `é` -> `e`).
func Normalize(str string) string {
out, _, _ := transform.String(Normalizer(), str)
Expand Down
72 changes: 64 additions & 8 deletions phone/phone.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package phone

import (
"errors"
"fmt"

"github.com/nyaruka/phonenumbers"
"github.com/pkg/errors"
"github.com/thetreep/toolbox/grammar"
)

Expand All @@ -28,28 +30,82 @@ var (

// ErrInvalidNumber means that the provided number is invalid.
ErrInvalidNumber = errors.New("phone number is invalid")

// ErrInvalidInternationalFormat means that the provided number is not in international format.
ErrInvalidInternationalFormat = errors.New("phone number is not in international format")
)

type SanitizeMode uint8

const (
SanitizeOff SanitizeMode = iota // Keep the phone number as is
SanitizeDefault // Removes only spaces and special characters, keep "+"
SanitizeStrict // SanitizeStrict removes all characters except numbers.
)

// Parse parses a given phone number from a country to a specified format.
func Parse(number, isoCountry string, format Format, sanitize bool) (string, error) {
num, err := phonenumbers.Parse(number, isoCountry)
// parseNumber is a helper function to parse and format a phone number.
func parseNumber(number, region string, format Format, sanitizeMode SanitizeMode) (string, error) {
num, err := phonenumbers.Parse(number, region)
if err != nil {
if errors.Is(err, phonenumbers.ErrInvalidCountryCode) {
return "", errors.Wrapf(ErrInvalidCountry, err.Error())
return "", errors.Join(ErrInvalidCountry, err)
}

if errors.Is(err, phonenumbers.ErrNotANumber) ||
errors.Is(err, phonenumbers.ErrTooShortNSN) {
return "", errors.Wrap(err, err.Error())
return "", errors.Join(ErrInvalidNumber, err)
}

return "", errors.Errorf("cannot parse number : %v", err)
return "", fmt.Errorf("cannot parse number: %w", err)
}

parsed := phonenumbers.Format(num, phonenumbers.PhoneNumberFormat(format))
if sanitize {
switch sanitizeMode {
case SanitizeDefault:
return grammar.SanitizePhone(parsed), nil
case SanitizeStrict:
return grammar.SanitizePhoneStrict(parsed), nil
}

return parsed, nil
}

// Parse parses a phone number from explicit region and returns it in the desired format.
func Parse(number, region string, format Format, sanitizeMode SanitizeMode) (string, error) {
return parseNumber(number, region, format, sanitizeMode)
}

// ParseWithFallback parses a phone number and returns it in the desired format.
// If the region cannot be determined from the number, it uses the fallback region.
func ParseWithFallback(number, fallbackRegion string, format Format, sanitizeMode SanitizeMode) (string, error) {
parsed, err := parseNumber(number, "ZZ", format, sanitizeMode)
if err != nil && errors.Is(err, ErrInvalidCountry) {
// Try parsing with the fallback region
return parseNumber(number, fallbackRegion, format, sanitizeMode)
}
return parsed, err
}

// GetRegionFromInternationalNumber determines the region from an international phone number.
func GetRegionFromInternationalNumber(number string) (string, error) {
parsedNumber, err := phonenumbers.Parse(number, phonenumbers.UNKNOWN_REGION)
if err != nil {
if errors.Is(err, phonenumbers.ErrInvalidCountryCode) {
return "", ErrInvalidInternationalFormat
}
return "", fmt.Errorf("error parsing the number: %v", err)
}

// Check if the number is valid
if !phonenumbers.IsValidNumber(parsedNumber) {
return "", ErrInvalidNumber
}

// Get the region code
regionCode := phonenumbers.GetRegionCodeForNumber(parsedNumber)
if regionCode == "" {
return "", fmt.Errorf("unable to determine the region for the number")
}

return regionCode, nil
}
47 changes: 42 additions & 5 deletions phone/phone_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func TestParse(t *testing.T) {
num string
country string
format phone.Format
sanitize bool
sanitize phone.SanitizeMode
expNum string
expErr error
}{
Expand All @@ -36,14 +36,14 @@ func TestParse(t *testing.T) {
num: "0123456789",
country: "FR",
format: phone.NATIONAL,
sanitize: true,
sanitize: phone.SanitizeDefault,
expNum: "0123456789",
},
{
num: "+33123456789",
country: "FR",
format: phone.NATIONAL,
sanitize: true,
sanitize: phone.SanitizeDefault,
expNum: "0123456789",
},
{
Expand All @@ -56,7 +56,7 @@ func TestParse(t *testing.T) {
num: "0033123456789",
country: "FR",
format: phone.NATIONAL,
sanitize: true,
sanitize: phone.SanitizeDefault,
expNum: "0123456789",
},
{
Expand All @@ -65,13 +65,50 @@ func TestParse(t *testing.T) {
format: phone.INTERNATIONAL,
expNum: "+33 1 23 45 67 89",
},
{
num: "(248) 123-7654",
country: "US",
format: phone.INTERNATIONAL,
sanitize: phone.SanitizeStrict,
expNum: "12481237654",
},
{
num: "+1-248-123-7654",
country: "FR",
format: phone.INTERNATIONAL,
sanitize: phone.SanitizeStrict,
expNum: "12481237654",
},
}

for i, tc := range tcases {
name := fmt.Sprintf("case #%d : ", i+1)
num, err := phone.Parse(tc.num, tc.country, tc.format, tc.sanitize)
num, err := phone.ParseWithFallback(tc.num, tc.country, tc.format, tc.sanitize)
assert.ErrorIs(t, tc.expErr, err, name+"unexpected error")
assert.Equal(t, tc.expNum, num, name+"unexpected number")
}
})
}

func TestGetRegionFromNumber(t *testing.T) {
tests := []struct {
number string
expected string
expErr error
}{
{"+33123456789", "FR", nil},
{"+1-212-456-7890", "US", nil},
{"+442079460958", "GB", nil},
{"+34912345678", "ES", nil},
{"0123456789", "", phone.ErrInvalidInternationalFormat},
{"12481237654", "", phone.ErrInvalidInternationalFormat},
}

for _, test := range tests {
t.Run(test.number, func(t *testing.T) {
region, err := phone.GetRegionFromInternationalNumber(test.number)
assert.ErrorIs(t, err, test.expErr)
assert.Equal(t, test.expected, region)
})
}
}

0 comments on commit 65ed802

Please sign in to comment.