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
22 changes: 16 additions & 6 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,15 @@ fmt.Printf("Malawi dialing code: %s\n", country.DialingCode)
country = norm.GetCountryByCode("44")
fmt.Printf("Country with code 44: %s\n", country.Name)
// Output: Country with code 44: United Kingdom

// Get the country an international number belongs to
country, err := norm.CountryForNumber("+16845551234")
fmt.Printf("Country for +1 684 555 1234: %s\n", country.Name)
// Output: Country for +1 684 555 1234: American Samoa
```

Some dialling codes are shared by several countries, such as `1` (the United States, Canada and much of the Caribbean) and `7` (Russia and Kazakhstan). For those codes, `GetCountryByCode` and `CountryForNumber` return the main country, so `+1 416 555 1234` (Toronto) comes back as the United States.

## Supported Countries

Sanja includes comprehensive country data for **250+ countries and territories** with:
Expand All @@ -111,19 +118,22 @@ Sanja includes comprehensive country data for **250+ countries and territories**
- **International dialing codes** (e.g., `1`, `44`, `265`)

### Data Source
The country data used in this package was sourced from [Kaggle - Country 2ISO3UN Digit Code and Dialing Code](https://www.kaggle.com/datasets/migeruj/country-2iso3un-digit-code-and-dialing-code).
The country data used in this package was sourced from [Kaggle - Country 2ISO3UN Digit Code and Dialing Code](https://www.kaggle.com/datasets/migeruj/country-2iso3un-digit-code-and-dialing-code). The main country for each shared dialling code follows `mainCountryForCode` in Google’s [libphonenumber metadata](https://github.com/google/libphonenumber/blob/master/resources/PhoneNumberMetadata.xml).

## API Reference

### Types

```go
type Country struct {
Name string
A2 string // ISO Alpha-2 code (e.g., "US")
A3 string // ISO Alpha-3 code (e.g., "USA")
NumCode int // ISO Numeric code (e.g., 840)
DialingCode string // International dialing code (e.g., "1")
Name string
A2 string // ISO Alpha-2 code (e.g., "US")
A3 string // ISO Alpha-3 code (e.g., "USA")
NumCode int // ISO Numeric code (e.g., 840)
DialingCode string // International dialing code (e.g., "1"), or several separated by commas
MainCountryForCode bool // True for the country returned when several share a dialing code
MinLocalDigits int // Fewest digits after the dialing code
MaxLocalDigits int // Most digits after the dialing code
}

type Normalizer struct {
Expand Down
11 changes: 11 additions & 0 deletions countries.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
"a3": "AUS",
"numCode": 36,
"dialingCode": "61",
"mainCountryForCode": true,
"minLocalDigits": 9,
"maxLocalDigits": 9
},
Expand Down Expand Up @@ -500,6 +501,7 @@
"a3": "CUW",
"numCode": 531,
"dialingCode": "599",
"mainCountryForCode": true,
"minLocalDigits": 7,
"maxLocalDigits": 8
},
Expand Down Expand Up @@ -635,6 +637,7 @@
"a3": "FLK",
"numCode": 238,
"dialingCode": "500",
"mainCountryForCode": true,
"minLocalDigits": 5,
"maxLocalDigits": 5
},
Expand Down Expand Up @@ -788,6 +791,7 @@
"a3": "GLP",
"numCode": 312,
"dialingCode": "590",
"mainCountryForCode": true,
"minLocalDigits": 9,
"maxLocalDigits": 9
},
Expand Down Expand Up @@ -1346,6 +1350,7 @@
"a3": "MAR",
"numCode": 504,
"dialingCode": "212",
"mainCountryForCode": true,
"minLocalDigits": 9,
"maxLocalDigits": 9
},
Expand Down Expand Up @@ -1463,6 +1468,7 @@
"a3": "NFK",
"numCode": 574,
"dialingCode": "672",
"mainCountryForCode": true,
"minLocalDigits": 6,
"maxLocalDigits": 12
},
Expand All @@ -1481,6 +1487,7 @@
"a3": "NOR",
"numCode": 578,
"dialingCode": "47",
"mainCountryForCode": true,
"minLocalDigits": 8,
"maxLocalDigits": 8
},
Expand Down Expand Up @@ -1625,6 +1632,7 @@
"a3": "RUS",
"numCode": 643,
"dialingCode": "7",
"mainCountryForCode": true,
"minLocalDigits": 10,
"maxLocalDigits": 10
},
Expand All @@ -1643,6 +1651,7 @@
"a3": "REU",
"numCode": 638,
"dialingCode": "262",
"mainCountryForCode": true,
"minLocalDigits": 9,
"maxLocalDigits": 9
},
Expand Down Expand Up @@ -2102,6 +2111,7 @@
"a3": "GBR",
"numCode": 826,
"dialingCode": "44",
"mainCountryForCode": true,
"minLocalDigits": 9,
"maxLocalDigits": 10
},
Expand All @@ -2111,6 +2121,7 @@
"a3": "USA",
"numCode": 840,
"dialingCode": "1",
"mainCountryForCode": true,
"minLocalDigits": 10,
"maxLocalDigits": 10
},
Expand Down
34 changes: 26 additions & 8 deletions country.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,22 @@ import (

// Country represents a country with its dialing codes and E.164 digit constraints.
type Country struct {
Name string `json:"name"`
A2 string `json:"a2"`
A3 string `json:"a3"`
NumCode int `json:"numCode"`
DialingCode string `json:"dialingCode"`
MinLocalDigits int `json:"minLocalDigits"`
MaxLocalDigits int `json:"maxLocalDigits"`
Name string `json:"name"`
A2 string `json:"a2"`
A3 string `json:"a3"`
NumCode int `json:"numCode"`
DialingCode string `json:"dialingCode"`
MainCountryForCode bool `json:"mainCountryForCode"`
MinLocalDigits int `json:"minLocalDigits"`
MaxLocalDigits int `json:"maxLocalDigits"`
}

const longestDialingCodeDigits = 4

// GetCountryByA2 returns a country by its ISO 3166-1 alpha-2 code, or nil if not found.
func (n *Normalizer) GetCountryByA2(a2 string) *Country {
for i := range n.countries {
if n.countries[i].A2 == a2 {
if strings.EqualFold(n.countries[i].A2, a2) {
return &n.countries[i]
}
}
Expand All @@ -32,6 +35,21 @@ func (n *Normalizer) GetCountryByCode(code string) *Country {
return n.codeMap[code]
}

func (n *Normalizer) CountryForNumber(e164 string) (*Country, error) {
digits, hasPlus := strings.CutPrefix(cleanPhone(e164), "+")
if !hasPlus || digits == "" {
return nil, ErrInvalidPhoneNumber
}

for length := min(len(digits), longestDialingCodeDigits); length > 0; length-- {
if country := n.GetCountryByCode(digits[:length]); country != nil {
return country, nil
}
}

return nil, fmt.Errorf("%w: %s", ErrUnknownCountry, e164)
}

// ValidatePhoneNumber validates that phone is a valid E.164 number for the given country.
// The number must include the country code prefix (with or without leading +).
// It checks E.164 total length (<= 15 digits) and the country's known subscriber digit range.
Expand Down
4 changes: 3 additions & 1 deletion normalizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ func NewNormalizer(defaultCountryA2 string) (*Normalizer, error) {
for i := range n.countries {
country := &n.countries[i]
for _, code := range splitDialingCodes(country.DialingCode) {
n.codeMap[code] = country
if _, taken := n.codeMap[code]; !taken || country.MainCountryForCode {
n.codeMap[code] = country
}
}
}

Expand Down
85 changes: 85 additions & 0 deletions normalizer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ func TestNewNormalizer_ValidCountry(t *testing.T) {
assert.NotNil(t, n)
}

func TestNewNormalizer_LowerCaseCountry(t *testing.T) {
n, err := NewNormalizer("mw")
require.NoError(t, err)
assert.Equal(t, "MW", n.defaultCountry.A2)
}

func TestNormalize(t *testing.T) {
norm := mustNormalizer(t, "MW")

Expand Down Expand Up @@ -190,6 +196,12 @@ func TestGetCountryByA2(t *testing.T) {
assert.Equal(t, "1", c.DialingCode)
})

t.Run("lower case", func(t *testing.T) {
c := norm.GetCountryByA2("us")
require.NotNil(t, c)
assert.Equal(t, "US", c.A2)
})

t.Run("not found returns nil", func(t *testing.T) {
c := norm.GetCountryByA2("XX")
assert.Nil(t, c)
Expand Down Expand Up @@ -217,6 +229,79 @@ func TestGetCountryByCode(t *testing.T) {
})
}

func TestGetCountryByCode_SharedCodes(t *testing.T) {
norm := mustNormalizer(t, "MW")

testCases := []struct {
code string
expected string
}{
{code: "1", expected: "US"},
{code: "7", expected: "RU"},
{code: "44", expected: "GB"},
{code: "47", expected: "NO"},
{code: "61", expected: "AU"},
{code: "212", expected: "MA"},
{code: "262", expected: "RE"},
{code: "500", expected: "FK"},
{code: "590", expected: "GP"},
{code: "599", expected: "CW"},
{code: "672", expected: "NF"},
}

for _, tc := range testCases {
t.Run(tc.code, func(t *testing.T) {
c := norm.GetCountryByCode(tc.code)
require.NotNil(t, c)
assert.Equal(t, tc.expected, c.A2)
})
}
}

func TestDialingCodesFitLongestDialingCodeDigits(t *testing.T) {
for _, country := range getCountries() {
for _, code := range splitDialingCodes(country.DialingCode) {
assert.LessOrEqual(t, len(code), longestDialingCodeDigits, country.A2)
}
}
}

func TestCountryForNumber(t *testing.T) {
norm := mustNormalizer(t, "MW")

testCases := []struct {
name string
phone string
expected string
expectedErr error
}{
{name: "three-digit code", phone: "+265991234567", expected: "MW"},
{name: "formatted number", phone: "+265 99 123 4567", expected: "MW"},
{name: "longer code wins over shared code", phone: "+16845551234", expected: "AS"},
{name: "second code of a multi-code country", phone: "+18295551234", expected: "DO"},
{name: "shared code gives main country", phone: "+12025551234", expected: "US"},
{name: "shared code 44", phone: "+447911123456", expected: "GB"},
{name: "no plus", phone: "265991234567", expectedErr: ErrInvalidPhoneNumber},
{name: "empty", phone: "", expectedErr: ErrInvalidPhoneNumber},
{name: "plus only", phone: "+", expectedErr: ErrInvalidPhoneNumber},
{name: "unassigned code", phone: "+8081234567", expectedErr: ErrUnknownCountry},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
c, err := norm.CountryForNumber(tc.phone)
if tc.expectedErr != nil {
assert.ErrorIs(t, err, tc.expectedErr)
assert.Nil(t, c)
return
}

require.NoError(t, err)
assert.Equal(t, tc.expected, c.A2)
})
}
}

func TestValidatePhoneNumber(t *testing.T) {
norm := mustNormalizer(t, "MW")

Expand Down
Loading