diff --git a/Readme.md b/Readme.md index 33e36f0..4a31b6c 100644 --- a/Readme.md +++ b/Readme.md @@ -30,8 +30,11 @@ import ( func main() { // Create a normalizer with Malawi as default country - norm := sanja.NewNormalizer("MW") - + norm, err := sanja.NewNormalizer("MW") + if err != nil { + panic(err) + } + // Normalize a local Malawian number normalized, err := norm.Normalize("0886392814") if err != nil { @@ -47,31 +50,46 @@ func main() { ### Basic Normalization ```go -norm := sanja.NewNormalizer("US") +norm, _ := sanja.NewNormalizer("US") // Local number gets US country code normalized, _ := norm.Normalize("555-123-4567") // Result: +15551234567 // Already international - unchanged -normalized, _ := norm.Normalize("+442079460000") +normalized, _ = norm.Normalize("+442079460000") // Result: +442079460000 -// Number with country code but no + prefix -normalized, _ := norm.Normalize("265886392814") -// Result: +265886392814 +// Number with the default country's code but no + prefix +normalized, _ = norm.Normalize("12025550123") +// Result: +12025550123 +``` + +### Validation + +`Normalize` only formats a number. It does not check that the number has the right number of digits for its country. Use `NormalizeAndValidate` for both, or `ValidatePhoneNumber` to check a number against a country you name. + +```go +norm, _ := sanja.NewNormalizer("MW") + +norm.Normalize("099123456") // → "+26599123456", no error +norm.NormalizeAndValidate("099123456") // error: Malawi requires at least 9 local digits, got 8 +norm.NormalizeAndValidate("+447911123456") // → "+447911123456", checked against the United Kingdom + +norm.ValidatePhoneNumber("0886392814", "MW") // nil: local numbers are read as Malawian +norm.ValidatePhoneNumber("+447911123456", "MW") // error: the number is not Malawian ``` ### Bulk Processing ```go -norm := sanja.NewNormalizer("MW") +norm, _ := sanja.NewNormalizer("MW") phones := []string{ "0886392814", - "265886392814", + "265886392814", "+265886392814", - "00886392814", + "00265886392814", } results, errors := norm.NormalizeBulk(phones) @@ -88,7 +106,7 @@ for i, phone := range results { ### Country Information ```go -norm := sanja.NewNormalizer("US") +norm, _ := sanja.NewNormalizer("US") // Get country by ISO A2 code country := norm.GetCountryByA2("MW") @@ -152,32 +170,34 @@ When a number doesn't have an international prefix, Sanja uses the default count ```go // With US as default -norm := sanja.NewNormalizer("US") -norm.Normalize("4151234567") // → "+14151234567" +usNorm, _ := sanja.NewNormalizer("US") +usNorm.Normalize("4151234567") // → "+14151234567" // With Malawi as default -norm := sanja.NewNormalizer("MW") -norm.Normalize("886392814") // → "+265886392814" +mwNorm, _ := sanja.NewNormalizer("MW") +mwNorm.Normalize("886392814") // → "+265886392814" // The default country's prefix for calling abroad works like "+" -norm.Normalize("00447911123456") // → "+447911123456" (Malawi dials 00) -usNorm := sanja.NewNormalizer("US") +mwNorm.Normalize("00447911123456") // → "+447911123456" (Malawi dials 00) usNorm.Normalize("011265886392814") // → "+265886392814" (the US dials 011) ``` ## Error Handling ```go -norm := sanja.NewNormalizer("US") +norm, _ := sanja.NewNormalizer("US") // Empty string _, err := norm.Normalize("") // err: "invalid phone number" -// Invalid default country -norm := sanja.NewNormalizer("INVALID") -_, err := norm.Normalize("123456789") -// err: "no default country set" +// A plus sign anywhere but the start +_, err = norm.Normalize("+1+2025550123") +// err: "invalid phone number" + +// Unknown default country +_, err = sanja.NewNormalizer("XX") +// err: "invalid or unknown country: XX" ``` ## Testing for testing i used this package by stretchr: [Assert](https://www.github.com/stretchr/testify/assert) diff --git a/countries.go b/countries.go index 3c42943..4071b35 100644 --- a/countries.go +++ b/countries.go @@ -16,5 +16,15 @@ func parseCountries() []Country { if err := json.Unmarshal(countriesData, &result); err != nil { panic("sanja: failed to parse countries data: " + err.Error()) } + + for i := range result { + pattern, err := compileInternationalPrefix(result[i].InternationalPrefix) + if err != nil { + panic("sanja: international prefix for " + result[i].A2 + ": " + err.Error()) + } + + result[i].internationalPrefixPattern = pattern + } + return result } diff --git a/country.go b/country.go index 866518b..b6e466a 100644 --- a/country.go +++ b/country.go @@ -2,6 +2,7 @@ package sanja import ( "fmt" + "regexp" "strings" ) @@ -16,6 +17,8 @@ type Country struct { MainCountryForCode bool `json:"mainCountryForCode"` MinLocalDigits int `json:"minLocalDigits"` MaxLocalDigits int `json:"maxLocalDigits"` + + internationalPrefixPattern *regexp.Regexp } const longestDialingCodeDigits = 4 @@ -65,43 +68,40 @@ func (n *Normalizer) ValidatePhoneNumber(phone, countryA2 string) error { return fmt.Errorf("%w: %s", ErrUnknownCountry, countryA2) } - cleaned, err := cleanPhone(phone) + e164, err := normalize(phone, country) if err != nil { return err } - if cleaned == "" { - return ErrInvalidPhoneNumber - } - - var matchedCode string - for _, code := range splitDialingCodes(country.DialingCode) { - if strings.HasPrefix(cleaned, "+"+code) || strings.HasPrefix(cleaned, code) { - matchedCode = code - break - } - } + return checkLocalDigits(e164, country) +} - if matchedCode == "" { +func checkLocalDigits(e164 string, country *Country) error { + local, ok := localDigits(e164, country) + if !ok { return fmt.Errorf("%w: expected code for %s", ErrPhoneNumberAndCountryCodeMismatch, country.Name) } - withoutPlus := strings.TrimPrefix(cleaned, "+") - subscriber := withoutPlus[len(matchedCode):] - - if len(matchedCode)+len(subscriber) > 15 { - return fmt.Errorf("%w: exceeds E.164 maximum of 15 digits", ErrPhoneNumberTooLong) - } - - if len(subscriber) < country.MinLocalDigits { + if len(local) < country.MinLocalDigits { return fmt.Errorf("%w: %s requires at least %d local digits, got %d", - ErrInvalidPhoneNumber, country.Name, country.MinLocalDigits, len(subscriber)) + ErrInvalidPhoneNumber, country.Name, country.MinLocalDigits, len(local)) } - if len(subscriber) > country.MaxLocalDigits { + if len(local) > country.MaxLocalDigits { return fmt.Errorf("%w: %s allows at most %d local digits, got %d", - ErrInvalidPhoneNumber, country.Name, country.MaxLocalDigits, len(subscriber)) + ErrInvalidPhoneNumber, country.Name, country.MaxLocalDigits, len(local)) } return nil } + +func localDigits(e164 string, country *Country) (string, bool) { + digits := strings.TrimPrefix(e164, "+") + for _, code := range splitDialingCodes(country.DialingCode) { + if local, found := strings.CutPrefix(digits, code); found { + return local, true + } + } + + return "", false +} diff --git a/normalizer.go b/normalizer.go index 4e26522..dfc259d 100644 --- a/normalizer.go +++ b/normalizer.go @@ -7,20 +7,15 @@ import ( ) const ( - // MinimumLocalDigitsLength is the minimum number of subscriber digits accepted. - MinimumLocalDigitsLength int = 7 - // MaximumLocalDigitsLength is the E.164 maximum subscriber digits (15 total minus a 1-digit country code minimum). - MaximumLocalDigitsLength int = 12 // e164MaxDigits is the E.164 hard cap: country code + subscriber <= 15 digits. e164MaxDigits int = 15 ) // Normalizer handles phone number normalization to E.164 format. type Normalizer struct { - countries []Country - defaultCountry *Country - codeMap map[string]*Country - internationalPrefix *regexp.Regexp + countries []Country + defaultCountry *Country + codeMap map[string]*Country } // NewNormalizer creates a Normalizer with the given ISO 3166-1 alpha-2 code as the default country. @@ -45,13 +40,6 @@ func NewNormalizer(defaultCountryA2 string) (*Normalizer, error) { return nil, fmt.Errorf("%w: %s", ErrUnknownCountry, defaultCountryA2) } - internationalPrefix, err := compileInternationalPrefix(n.defaultCountry.InternationalPrefix) - if err != nil { - return nil, fmt.Errorf("sanja: international prefix for %s: %w", n.defaultCountry.A2, err) - } - - n.internationalPrefix = internationalPrefix - return n, nil } @@ -67,46 +55,58 @@ func NewNormalizer(defaultCountryA2 string) (*Normalizer, error) { // Returns ErrInvalidPhoneNumber for numbers that are too short, // and ErrPhoneNumberTooLong for numbers that exceed the E.164 15-digit cap. func (n *Normalizer) Normalize(phone string) (string, error) { - cleaned, err := cleanPhone(phone) + return normalize(phone, n.defaultCountry) +} + +func (n *Normalizer) NormalizeAndValidate(phone string) (string, error) { + e164, err := n.Normalize(phone) if err != nil { return "", err } - cleaned = n.replaceInternationalPrefix(cleaned) - - if strings.HasPrefix(cleaned, "+") { - digits := cleaned[1:] - if len(digits) < MinimumLocalDigitsLength { - return "", ErrInvalidPhoneNumber - } - if len(digits) > e164MaxDigits { - return "", ErrPhoneNumberTooLong + country := n.defaultCountry + if _, ok := localDigits(e164, country); !ok { + country, err = n.CountryForNumber(e164) + if err != nil { + return "", err } - return cleaned, nil } - if cleaned == "" || len(cleaned) < MinimumLocalDigitsLength { - return "", ErrInvalidPhoneNumber + if err := checkLocalDigits(e164, country); err != nil { + return "", err + } + + return e164, nil +} + +func normalize(phone string, defaultCountry *Country) (string, error) { + cleaned, err := cleanPhone(phone) + if err != nil { + return "", err } - if n.hasCountryCode(cleaned) { - if len(cleaned) > e164MaxDigits { - return "", ErrPhoneNumberTooLong + cleaned = replaceInternationalPrefix(cleaned, defaultCountry) + + var result string + switch { + case strings.HasPrefix(cleaned, "+"): + result = cleaned + case hasCountryCode(cleaned, defaultCountry): + result = "+" + cleaned + default: + local := strings.TrimLeft(cleaned, "0") + if local == "" { + return "", ErrInvalidPhoneNumber } - return "+" + cleaned, nil + + result = "+" + splitDialingCodes(defaultCountry.DialingCode)[0] + local } - local := strings.TrimLeft(cleaned, "0") - if len(local) < MinimumLocalDigitsLength { + if result == "+" { return "", ErrInvalidPhoneNumber } - if len(local) > MaximumLocalDigitsLength { - return "", ErrPhoneNumberTooLong - } - defaultCode := splitDialingCodes(n.defaultCountry.DialingCode)[0] - result := "+" + defaultCode + local - if len(result)-1 > e164MaxDigits { + if len(result)-len("+") > e164MaxDigits { return "", ErrPhoneNumberTooLong } @@ -128,15 +128,18 @@ func (n *Normalizer) NormalizeBulk(phones []string) ([]string, []error) { // hasCountryCode reports whether phone (no "+" prefix) begins with the default country's // dialing code. the leading "0" is treated as a local dial prefix and returns false immediately, // preventing false matches against country codes that share digits with local number prefixes. -func (n *Normalizer) hasCountryCode(phone string) bool { - if strings.HasPrefix(phone, "0") { +func hasCountryCode(phone string, country *Country) bool { + if strings.HasPrefix(phone, "0") || len(phone) <= country.MaxLocalDigits { return false } - for _, code := range splitDialingCodes(n.defaultCountry.DialingCode) { - if strings.HasPrefix(phone, code) { + + for _, code := range splitDialingCodes(country.DialingCode) { + local, found := strings.CutPrefix(phone, code) + if found && len(local) >= country.MinLocalDigits && len(local) <= country.MaxLocalDigits { return true } } + return false } @@ -148,12 +151,12 @@ func compileInternationalPrefix(pattern string) (*regexp.Regexp, error) { return regexp.Compile("^(?:" + pattern + ")") } -func (n *Normalizer) replaceInternationalPrefix(phone string) string { - if n.internationalPrefix == nil { +func replaceInternationalPrefix(phone string, country *Country) string { + if country.internationalPrefixPattern == nil { return phone } - prefix := n.internationalPrefix.FindString(phone) + prefix := country.internationalPrefixPattern.FindString(phone) rest := phone[len(prefix):] if prefix == "" || rest == "" || rest[0] == '0' { return phone diff --git a/normalizer_test.go b/normalizer_test.go index 87f0c70..e564efc 100644 --- a/normalizer_test.go +++ b/normalizer_test.go @@ -59,7 +59,9 @@ func TestNormalize(t *testing.T) { {name: "empty string", input: "", expectError: true, errorIs: ErrInvalidPhoneNumber}, {name: "only special characters", input: "+-() ", expectError: true, errorIs: ErrInvalidPhoneNumber}, - {name: "too short number", input: "123", expectError: true, errorIs: ErrInvalidPhoneNumber}, + {name: "only zeros", input: "0000", expectError: true, errorIs: ErrInvalidPhoneNumber}, + {name: "short number is formatted, not checked", input: "123", expected: "+265123"}, + {name: "local number starting with the country code", input: "265123456", expected: "+265265123456"}, {name: "exceeds E.164 max", input: "08863928149999999", expectError: true, errorIs: ErrPhoneNumberTooLong}, } @@ -177,7 +179,7 @@ func TestNormalizeBulk(t *testing.T) { "+265886392814", "00265886392814", "", - "123", + "0000", "+447911123456", }, errorCount: 2, @@ -209,7 +211,7 @@ func TestNormalizeBulk(t *testing.T) { }, { name: "all invalid numbers", - input: []string{"", "abc", "123"}, + input: []string{"", "abc", "0000"}, errorCount: 3, expectedOutput: []string{"", "", ""}, }, @@ -442,6 +444,38 @@ func TestValidatePhoneNumber(t *testing.T) { country: "MW", wantErr: ErrInvalidPhoneNumber, }, + { + name: "local number", + phone: "0886392814", + country: "MW", + }, + { + name: "country code without plus", + phone: "265886392814", + country: "MW", + }, + { + name: "local number starting with the country code digits", + phone: "265123456", + country: "MW", + }, + { + name: "international prefix of the given country", + phone: "011265886392814", + country: "US", + wantErr: ErrPhoneNumberAndCountryCodeMismatch, + }, + { + name: "local number too short", + phone: "099123456", + country: "MW", + wantErr: ErrInvalidPhoneNumber, + }, + { + name: "lower case country", + phone: "+265886392814", + country: "mw", + }, } for _, tc := range tests { @@ -456,6 +490,48 @@ func TestValidatePhoneNumber(t *testing.T) { } } +func TestNormalizeAndValidate(t *testing.T) { + testCases := []struct { + name string + defaultCountry string + input string + expected string + expectedErr error + }{ + {name: "local number", defaultCountry: "MW", input: "0886392814", expected: "+265886392814"}, + {name: "international number", defaultCountry: "MW", input: "+447911123456", expected: "+447911123456"}, + {name: "international prefix", defaultCountry: "MW", input: "00447911123456", expected: "+447911123456"}, + {name: "one digit short with plus", defaultCountry: "MW", input: "+2659912345", expectedErr: ErrInvalidPhoneNumber}, + {name: "one digit short local", defaultCountry: "MW", input: "099123456", expectedErr: ErrInvalidPhoneNumber}, + {name: "one digit too many", defaultCountry: "MW", input: "+2658863928140", expectedErr: ErrInvalidPhoneNumber}, + {name: "short number", defaultCountry: "MW", input: "123", expectedErr: ErrInvalidPhoneNumber}, + {name: "other country too short", defaultCountry: "MW", input: "+4479111", expectedErr: ErrInvalidPhoneNumber}, + {name: "unassigned code", defaultCountry: "MW", input: "+80812345678", expectedErr: ErrUnknownCountry}, + {name: "over the E.164 cap", defaultCountry: "MW", input: "+2658863928149999", expectedErr: ErrPhoneNumberTooLong}, + {name: "Germany allows 3 local digits", defaultCountry: "DE", input: "+49301", expected: "+49301"}, + {name: "Finland allows 5 local digits", defaultCountry: "FI", input: "+35891234", expected: "+35891234"}, + {name: "Niue allows 4 local digits", defaultCountry: "MW", input: "+6834002", expected: "+6834002"}, + {name: "Austria allows 13 local digits", defaultCountry: "AT", input: "01234567890123", expected: "+431234567890123"}, + {name: "shared code checked against the default country", defaultCountry: "KZ", input: "+77011234567", expected: "+77011234567"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + norm := mustNormalizer(t, tc.defaultCountry) + + result, err := norm.NormalizeAndValidate(tc.input) + if tc.expectedErr != nil { + assert.ErrorIs(t, err, tc.expectedErr) + assert.Empty(t, result) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expected, result) + }) + } +} + func BenchmarkNormalize(b *testing.B) { norm, _ := NewNormalizer("MW") phoneNumbers := []string{