Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 42 additions & 22 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions countries.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
48 changes: 24 additions & 24 deletions country.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package sanja

import (
"fmt"
"regexp"
"strings"
)

Expand All @@ -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
Expand Down Expand Up @@ -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
}
97 changes: 50 additions & 47 deletions normalizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}

Expand All @@ -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
}

Expand All @@ -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
}

Expand All @@ -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
Expand Down
Loading
Loading