Skip to content

Commit fb6f575

Browse files
committed
Refactor urns package
1 parent 64e21d5 commit fb6f575

File tree

7 files changed

+411
-661
lines changed

7 files changed

+411
-661
lines changed

urns/phone.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package urns
2+
3+
import (
4+
"strconv"
5+
"strings"
6+
7+
"github.com/nyaruka/phonenumbers"
8+
"github.com/pkg/errors"
9+
)
10+
11+
// FromLocalPhone returns a validated tel URN
12+
func FromLocalPhone(number string, country string) (URN, error) {
13+
path, err := ParsePhone(number, country)
14+
if err != nil {
15+
return NilURN, err
16+
}
17+
18+
return NewURNFromParts(Phone, path, "", "")
19+
}
20+
21+
// ToLocalPhone converts a phone URN to a local number in the given country
22+
func ToLocalPhone(u URN, country string) string {
23+
_, path, _, _ := u.ToParts()
24+
25+
parsed, err := phonenumbers.Parse(path, country)
26+
if err == nil {
27+
return strconv.FormatUint(parsed.GetNationalNumber(), 10)
28+
}
29+
return path
30+
}
31+
32+
// ParsePhone tries to parse the given string as a phone number and if successful returns it as E164
33+
func ParsePhone(s, country string) (string, error) {
34+
parsed, err := phonenumbers.Parse(s, country)
35+
if err != nil {
36+
return "", errors.Wrap(err, "unable to parse number")
37+
}
38+
39+
if phonenumbers.IsPossibleNumberWithReason(parsed) != phonenumbers.IS_POSSIBLE {
40+
// if it's not a possible number, try adding a + and parsing again
41+
if !strings.HasPrefix(s, "+") {
42+
return ParsePhone("+"+s, country)
43+
}
44+
45+
return "", errors.New("not a possible number")
46+
}
47+
48+
return phonenumbers.Format(parsed, phonenumbers.E164), nil
49+
}

urns/phone_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package urns_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/nyaruka/gocommon/urns"
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestFromLocalPhone(t *testing.T) {
11+
testCases := []struct {
12+
number string
13+
country string
14+
expected urns.URN
15+
hasError bool
16+
}{
17+
{"tel:0788383383", "RW", "tel:+250788383383", false},
18+
{"tel: +250788383383 ", "KE", "tel:+250788383383", false}, // already has country code
19+
{"tel:(917)992-5253", "US", "tel:+19179925253", false},
20+
{"tel:800-CABBAGE", "US", "tel:+18002222243", false},
21+
{"tel:+62877747666", "ID", "tel:+62877747666", false},
22+
{"tel:0877747666", "ID", "tel:+62877747666", false},
23+
{"tel:07531669965", "GB", "tel:+447531669965", false},
24+
{"tel:263780821000", "ZW", "tel:+263780821000", false},
25+
26+
{"0788383383", "ZZ", urns.NilURN, true}, // invalid country code
27+
{"1", "RW", urns.NilURN, true},
28+
{"123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890", "RW", urns.NilURN, true},
29+
}
30+
31+
for i, tc := range testCases {
32+
urn, err := urns.FromLocalPhone(tc.number, tc.country)
33+
34+
if tc.hasError {
35+
assert.Error(t, err, "%d: expected error for %s, %s", i, tc.number, tc.country)
36+
} else {
37+
assert.NoError(t, err, "%d: unexpected error for %s, %s", i, tc.number, tc.country)
38+
assert.Equal(t, tc.expected, urn, "%d: created URN mismatch for %s, %s", i, tc.number, tc.country)
39+
}
40+
}
41+
}
42+
43+
func TestParsePhone(t *testing.T) {
44+
tcs := []struct {
45+
input string
46+
country string
47+
parsed string
48+
}{
49+
{"+250788123123", "", "+250788123123"}, // international number fine without country
50+
{"+250 788 123-123", "", "+250788123123"}, // fine if not E164 formatted
51+
{"0788123123", "RW", "+250788123123"},
52+
{"206 555 1212", "US", "+12065551212"},
53+
{"12065551212", "US", "+12065551212"}, // country code but no +
54+
{"5912705", "US", ""}, // is only possible as a local number so ignored
55+
{"10000", "US", ""},
56+
}
57+
58+
for _, tc := range tcs {
59+
if tc.parsed != "" {
60+
parsed, err := urns.ParsePhone(tc.input, tc.country)
61+
assert.NoError(t, err, "unexpected error for '%s'", tc.input)
62+
assert.Equal(t, parsed, tc.parsed, "result mismatch for '%s'", tc.input)
63+
} else {
64+
_, err := urns.ParsePhone(tc.input, tc.country)
65+
assert.Error(t, err, "expected error for '%s'", tc.input)
66+
}
67+
}
68+
}

urns/schemes.go

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package urns
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
7+
"github.com/nyaruka/phonenumbers"
8+
)
9+
10+
var allDigitsRegex = regexp.MustCompile(`^[0-9]+$`)
11+
var nonTelCharsRegex = regexp.MustCompile(`[^0-9A-Z]`)
12+
13+
var emailRegex = regexp.MustCompile(`^[^\s@]+@[^\s@]+$`)
14+
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}$`)
15+
var viberRegex = regexp.MustCompile(`^[a-zA-Z0-9_=/+]{1,24}$`)
16+
var lineRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{1,36}$`)
17+
var telRegex = regexp.MustCompile(`^\+?[a-zA-Z0-9]{1,64}$`)
18+
var twitterHandleRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{1,15}$`)
19+
var webchatRegex = regexp.MustCompile(`^[a-zA-Z0-9]{24}(:[^\s@]+@[^\s@]+)?$`)
20+
21+
const (
22+
// FacebookRefPrefix is prefix used for facebook referral URNs
23+
FacebookRefPrefix string = "ref:"
24+
)
25+
26+
func init() {
27+
register(Discord)
28+
register(Email)
29+
register(External)
30+
register(Facebook)
31+
register(Firebase)
32+
register(FreshChat)
33+
register(Instagram)
34+
register(JioChat)
35+
register(Line)
36+
register(Phone)
37+
register(RocketChat)
38+
register(Slack)
39+
register(Telegram)
40+
register(Twitter)
41+
register(TwitterID)
42+
register(Viber)
43+
register(VK)
44+
register(WebChat)
45+
register(WeChat)
46+
register(WhatsApp)
47+
}
48+
49+
var schemes = map[string]*Scheme{}
50+
51+
func register(s *Scheme) {
52+
schemes[s.Prefix] = s
53+
}
54+
55+
type Scheme struct {
56+
Prefix string
57+
Normalize func(string) string
58+
Validate func(string) bool
59+
Format func(string) string
60+
}
61+
62+
var Discord = &Scheme{
63+
Prefix: "discord",
64+
Validate: func(path string) bool { return allDigitsRegex.MatchString(path) },
65+
}
66+
67+
var Email = &Scheme{
68+
Prefix: "mailto",
69+
Normalize: func(path string) string { return strings.ToLower(path) },
70+
Validate: func(path string) bool { return emailRegex.MatchString(path) },
71+
}
72+
73+
var External = &Scheme{
74+
Prefix: "ext",
75+
}
76+
77+
var Facebook = &Scheme{
78+
Prefix: "facebook",
79+
Validate: func(path string) bool {
80+
// we don't validate facebook refs since they come from the outside
81+
if strings.HasPrefix(path, FacebookRefPrefix) {
82+
return true
83+
}
84+
// otherwise, this should be an int
85+
return allDigitsRegex.MatchString(path)
86+
},
87+
}
88+
89+
var Firebase = &Scheme{
90+
Prefix: "fcm",
91+
}
92+
93+
var FreshChat = &Scheme{
94+
Prefix: "freshchat",
95+
Validate: func(path string) bool { return freshchatRegex.MatchString(path) },
96+
}
97+
98+
var Instagram = &Scheme{
99+
Prefix: "instagram",
100+
Validate: func(path string) bool { return allDigitsRegex.MatchString(path) },
101+
}
102+
103+
var JioChat = &Scheme{
104+
Prefix: "jiochat",
105+
Validate: func(path string) bool { return allDigitsRegex.MatchString(path) },
106+
}
107+
108+
var Line = &Scheme{
109+
Prefix: "line",
110+
Validate: func(path string) bool { return lineRegex.MatchString(path) },
111+
}
112+
113+
var Phone = &Scheme{
114+
Prefix: "tel",
115+
Normalize: func(path string) string {
116+
e164, err := ParsePhone(path, "")
117+
if err != nil {
118+
// could be a short code so uppercase and remove non alphanumeric characters
119+
return nonTelCharsRegex.ReplaceAllString(strings.ToUpper(path), "")
120+
}
121+
122+
return e164
123+
},
124+
Validate: func(path string) bool { return telRegex.MatchString(path) },
125+
Format: func(path string) string {
126+
parsed, err := phonenumbers.Parse(path, "")
127+
if err != nil {
128+
return path
129+
}
130+
return phonenumbers.Format(parsed, phonenumbers.NATIONAL)
131+
},
132+
}
133+
134+
var RocketChat = &Scheme{
135+
Prefix: "rocketchat",
136+
}
137+
138+
var Slack = &Scheme{
139+
Prefix: "slack",
140+
}
141+
142+
var Telegram = &Scheme{
143+
Prefix: "telegram",
144+
Validate: func(path string) bool { return allDigitsRegex.MatchString(path) },
145+
}
146+
147+
var Twitter = &Scheme{
148+
Prefix: "twitter",
149+
Normalize: func(path string) string {
150+
// handles are case-insensitive, so we always store as lowercase
151+
path = strings.ToLower(path)
152+
153+
// strip @ prefix if provided
154+
return strings.TrimPrefix(path, "@")
155+
},
156+
Validate: func(path string) bool { return twitterHandleRegex.MatchString(path) },
157+
}
158+
159+
var TwitterID = &Scheme{
160+
Prefix: "twitterid",
161+
Validate: func(path string) bool { return allDigitsRegex.MatchString(path) },
162+
}
163+
164+
var Viber = &Scheme{
165+
Prefix: "viber",
166+
Validate: func(path string) bool { return viberRegex.MatchString(path) },
167+
}
168+
169+
var VK = &Scheme{
170+
Prefix: "vk",
171+
}
172+
173+
var WebChat = &Scheme{
174+
Prefix: "webchat",
175+
Validate: func(path string) bool { return webchatRegex.MatchString(path) },
176+
}
177+
178+
var WeChat = &Scheme{
179+
Prefix: "wechat",
180+
}
181+
182+
var WhatsApp = &Scheme{
183+
Prefix: "whatsapp",
184+
Validate: func(path string) bool { return allDigitsRegex.MatchString(path) },
185+
}

0 commit comments

Comments
 (0)