-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #119 from nyaruka/urns_refactor
Refactor `urns` package
- Loading branch information
Showing
7 changed files
with
461 additions
and
663 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
package urns | ||
|
||
import ( | ||
"regexp" | ||
"strconv" | ||
"strings" | ||
|
||
"github.com/nyaruka/gocommon/i18n" | ||
"github.com/nyaruka/phonenumbers" | ||
"github.com/pkg/errors" | ||
) | ||
|
||
var nonTelCharsRegex = regexp.MustCompile(`[^0-9A-Za-z]`) | ||
|
||
// ParsePhone returns a validated phone URN. If it can parse a possible number then that is used.. otherwise any value | ||
// that validates as a phone URN is used. | ||
func ParsePhone(raw string, country i18n.Country) (URN, error) { | ||
// strip all non-tel characters.. only preserving an optional leading + | ||
raw = strings.TrimSpace(raw) | ||
hasPlus := strings.HasPrefix(raw, "+") | ||
raw = nonTelCharsRegex.ReplaceAllString(raw, "") | ||
if hasPlus { | ||
raw = "+" + raw | ||
} | ||
|
||
number, err := parsePhoneOrShortcode(raw, country) | ||
if err != nil { | ||
if err == phonenumbers.ErrInvalidCountryCode { | ||
return NilURN, errors.New("invalid country code") | ||
} | ||
|
||
return NewFromParts(Phone, raw, "", "") | ||
} | ||
|
||
return NewFromParts(Phone, number, "", "") | ||
} | ||
|
||
// tries to extract a valid phone number or shortcode from the given string | ||
func parsePhoneOrShortcode(raw string, country i18n.Country) (string, error) { | ||
parsed, err := phonenumbers.Parse(raw, string(country)) | ||
if err != nil { | ||
return "", err | ||
} | ||
|
||
if phonenumbers.IsPossibleNumberWithReason(parsed) == phonenumbers.IS_POSSIBLE { | ||
return phonenumbers.Format(parsed, phonenumbers.E164), nil | ||
} | ||
|
||
if phonenumbers.IsPossibleShortNumberForRegion(parsed, string(country)) { | ||
return phonenumbers.Format(parsed, phonenumbers.NATIONAL), nil | ||
} | ||
|
||
return "", errors.New("unable to parse phone number or shortcode") | ||
} | ||
|
||
// ToLocalPhone converts a phone URN to a local phone number.. without any leading zeros. Kinda weird but used by | ||
// Courier where channels want the number in that format. | ||
func ToLocalPhone(u URN, country i18n.Country) string { | ||
_, path, _, _ := u.ToParts() | ||
|
||
parsed, err := phonenumbers.Parse(path, string(country)) | ||
if err != nil { | ||
return path | ||
} | ||
|
||
return strconv.FormatUint(parsed.GetNationalNumber(), 10) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
package urns | ||
|
||
import ( | ||
"testing" | ||
|
||
"github.com/nyaruka/gocommon/i18n" | ||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
func TestParsePhone(t *testing.T) { | ||
testCases := []struct { | ||
input string | ||
country i18n.Country | ||
expected URN | ||
}{ | ||
{" 0788383383 ", "RW", "tel:+250788383383"}, | ||
{"+250788383383 ", "RW", "tel:+250788383383"}, // already has country code and leading + | ||
{"250788383383 ", "RW", "tel:+250788383383"}, // already has country code and no leading + | ||
{"+250788383383 ", "KE", "tel:+250788383383"}, // already has a different country code | ||
{"(917)992-5253", "US", "tel:+19179925253"}, | ||
{"800-CABBAGE", "US", "tel:+18002222243"}, | ||
{"+62877747666", "ID", "tel:+62877747666"}, | ||
{"0877747666", "ID", "tel:+62877747666"}, | ||
{"07531669965", "GB", "tel:+447531669965"}, | ||
{"263780821000", "ZW", "tel:+263780821000"}, | ||
|
||
{"1", "RW", "tel:1"}, | ||
{"123456", "RW", "tel:123456"}, | ||
{"mtn", "RW", "tel:mtn"}, | ||
{"!mtn!", "RW", "tel:mtn"}, // non tel chars stripped | ||
|
||
{"0788383383", "ZZ", NilURN}, // invalid country code | ||
{"1234567890123456789012345678901234567890123456789012345678901234567890123456789", "RW", NilURN}, // too long | ||
} | ||
|
||
for i, tc := range testCases { | ||
urn, err := ParsePhone(tc.input, tc.country) | ||
|
||
if tc.expected == NilURN { | ||
assert.Error(t, err, "%d: expected error for %s, %s", i, tc.input, tc.country) | ||
} else { | ||
assert.NoError(t, err, "%d: unexpected error for %s, %s", i, tc.input, tc.country) | ||
assert.Equal(t, tc.expected, urn, "%d: created URN mismatch for %s, %s", i, tc.input, tc.country) | ||
} | ||
} | ||
} | ||
|
||
func TestParsePhoneOrShortcode(t *testing.T) { | ||
tcs := []struct { | ||
input string | ||
country i18n.Country | ||
expected string | ||
}{ | ||
{"+250788123123", "", "+250788123123"}, // international number fine without country | ||
{"+250 788 123-123", "", "+250788123123"}, // still fine if not E164 formatted | ||
|
||
{"0788123123", "RW", "+250788123123"}, // country code added | ||
{" (206)555-1212 ", "US", "+12065551212"}, // punctiation removed | ||
{"800-CABBAGE", "US", "+18002222243"}, // letters converted to numbers | ||
{"12065551212", "US", "+12065551212"}, // country code but no + | ||
{"10000", "US", "10000"}, // valid short code for US | ||
|
||
{"5912705", "US", ""}, // is only possible as a local number so ignored | ||
} | ||
|
||
for _, tc := range tcs { | ||
parsed, err := parsePhoneOrShortcode(tc.input, tc.country) | ||
|
||
if tc.expected != "" { | ||
assert.NoError(t, err, "unexpected error for '%s'", tc.input) | ||
assert.Equal(t, tc.expected, parsed, "result mismatch for '%s'", tc.input) | ||
} else { | ||
assert.Error(t, err, "expected error for '%s'", tc.input) | ||
} | ||
} | ||
} | ||
func TestToLocalPhone(t *testing.T) { | ||
tcs := []struct { | ||
urn URN | ||
country i18n.Country | ||
expected string | ||
}{ | ||
{"tel:+250788123123", "", "788123123"}, | ||
{"tel:123123", "", "123123"}, | ||
} | ||
|
||
for _, tc := range tcs { | ||
assert.Equal(t, tc.expected, ToLocalPhone(tc.urn, tc.country), "local mismatch for '%s'", tc.urn) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,181 @@ | ||
package urns | ||
|
||
import ( | ||
"regexp" | ||
"strings" | ||
|
||
"github.com/nyaruka/phonenumbers" | ||
) | ||
|
||
var allDigitsRegex = regexp.MustCompile(`^[0-9]+$`) | ||
|
||
var emailRegex = regexp.MustCompile(`^[^\s@]+@[^\s@]+$`) | ||
var freshchatRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}/[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}$`) | ||
var viberRegex = regexp.MustCompile(`^[a-zA-Z0-9_=/+]{1,24}$`) | ||
var lineRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{1,36}$`) | ||
var telRegex = regexp.MustCompile(`^\+?[a-zA-Z0-9]{1,64}$`) | ||
var twitterHandleRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{1,15}$`) | ||
var webchatRegex = regexp.MustCompile(`^[a-zA-Z0-9]{24}(:[^\s@]+@[^\s@]+)?$`) | ||
|
||
const ( | ||
// FacebookRefPrefix is prefix used for facebook referral URNs | ||
FacebookRefPrefix string = "ref:" | ||
) | ||
|
||
func init() { | ||
register(Discord) | ||
register(Email) | ||
register(External) | ||
register(Facebook) | ||
register(Firebase) | ||
register(FreshChat) | ||
register(Instagram) | ||
register(JioChat) | ||
register(Line) | ||
register(Phone) | ||
register(RocketChat) | ||
register(Slack) | ||
register(Telegram) | ||
register(Twitter) | ||
register(TwitterID) | ||
register(Viber) | ||
register(VK) | ||
register(WebChat) | ||
register(WeChat) | ||
register(WhatsApp) | ||
} | ||
|
||
var schemes = map[string]*Scheme{} | ||
var schemePrefixes = []string{} | ||
|
||
func register(s *Scheme) { | ||
schemes[s.Prefix] = s | ||
schemePrefixes = append(schemePrefixes, s.Prefix) | ||
} | ||
|
||
type Scheme struct { | ||
Prefix string | ||
Normalize func(string) string | ||
Validate func(string) bool | ||
Format func(string) string | ||
} | ||
|
||
var Discord = &Scheme{ | ||
Prefix: "discord", | ||
Validate: func(path string) bool { return allDigitsRegex.MatchString(path) }, | ||
} | ||
|
||
var Email = &Scheme{ | ||
Prefix: "mailto", | ||
Normalize: func(path string) string { return strings.ToLower(path) }, | ||
Validate: func(path string) bool { return emailRegex.MatchString(path) }, | ||
} | ||
|
||
var External = &Scheme{ | ||
Prefix: "ext", | ||
} | ||
|
||
var Facebook = &Scheme{ | ||
Prefix: "facebook", | ||
Validate: func(path string) bool { | ||
// we don't validate facebook refs since they come from the outside | ||
if strings.HasPrefix(path, FacebookRefPrefix) { | ||
return true | ||
} | ||
// otherwise, this should be an int | ||
return allDigitsRegex.MatchString(path) | ||
}, | ||
} | ||
|
||
var Firebase = &Scheme{ | ||
Prefix: "fcm", | ||
} | ||
|
||
var FreshChat = &Scheme{ | ||
Prefix: "freshchat", | ||
Validate: func(path string) bool { return freshchatRegex.MatchString(path) }, | ||
} | ||
|
||
var Instagram = &Scheme{ | ||
Prefix: "instagram", | ||
Validate: func(path string) bool { return allDigitsRegex.MatchString(path) }, | ||
} | ||
|
||
var JioChat = &Scheme{ | ||
Prefix: "jiochat", | ||
Validate: func(path string) bool { return allDigitsRegex.MatchString(path) }, | ||
} | ||
|
||
var Line = &Scheme{ | ||
Prefix: "line", | ||
Validate: func(path string) bool { return lineRegex.MatchString(path) }, | ||
} | ||
|
||
var Phone = &Scheme{ | ||
Prefix: "tel", | ||
Normalize: func(path string) string { | ||
// might have alpha characters in it | ||
return strings.ToUpper(path) | ||
}, | ||
Validate: func(path string) bool { return telRegex.MatchString(path) }, | ||
Format: func(path string) string { | ||
parsed, err := phonenumbers.Parse(path, "") | ||
if err != nil { | ||
return path | ||
} | ||
return phonenumbers.Format(parsed, phonenumbers.NATIONAL) | ||
}, | ||
} | ||
|
||
var RocketChat = &Scheme{ | ||
Prefix: "rocketchat", | ||
} | ||
|
||
var Slack = &Scheme{ | ||
Prefix: "slack", | ||
} | ||
|
||
var Telegram = &Scheme{ | ||
Prefix: "telegram", | ||
Validate: func(path string) bool { return allDigitsRegex.MatchString(path) }, | ||
} | ||
|
||
var Twitter = &Scheme{ | ||
Prefix: "twitter", | ||
Normalize: func(path string) string { | ||
// handles are case-insensitive, so we always store as lowercase | ||
path = strings.ToLower(path) | ||
|
||
// strip @ prefix if provided | ||
return strings.TrimPrefix(path, "@") | ||
}, | ||
Validate: func(path string) bool { return twitterHandleRegex.MatchString(path) }, | ||
} | ||
|
||
var TwitterID = &Scheme{ | ||
Prefix: "twitterid", | ||
Validate: func(path string) bool { return allDigitsRegex.MatchString(path) }, | ||
} | ||
|
||
var Viber = &Scheme{ | ||
Prefix: "viber", | ||
Validate: func(path string) bool { return viberRegex.MatchString(path) }, | ||
} | ||
|
||
var VK = &Scheme{ | ||
Prefix: "vk", | ||
} | ||
|
||
var WebChat = &Scheme{ | ||
Prefix: "webchat", | ||
Validate: func(path string) bool { return webchatRegex.MatchString(path) }, | ||
} | ||
|
||
var WeChat = &Scheme{ | ||
Prefix: "wechat", | ||
} | ||
|
||
var WhatsApp = &Scheme{ | ||
Prefix: "whatsapp", | ||
Validate: func(path string) bool { return allDigitsRegex.MatchString(path) }, | ||
} |
Oops, something went wrong.