Skip to content

Commit 7909bdc

Browse files
committed
finalize DSN parsing
1 parent 89b219e commit 7909bdc

File tree

4 files changed

+321
-0
lines changed

4 files changed

+321
-0
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ This is single repository that stores many, independent small subpackages. This
2323
- [base58](https://go.rtnl.ai/x/base58): base58 encoding package as used by Bitcoin and travel addresses
2424
- [randstr](https://go.rtnl.ai/x/randstr): generate random strings using the crypto/rand package as efficiently as possible
2525
- [api](https://go.rtnl.ai/x/api): common utilities and responses for our JSON/REST APIs that our services run.
26+
- [dsn](https://go.rtnl.ai/x/dsn): parses data source names in order to connect to both server and embedded databases easily.
2627

2728
## About
2829

dsn/README.md

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# DSN
2+
3+
A data source name (DSN) contains information about how to connect to a database in the form of a single URL string. This information makes it easy to provide connection information in a single data item without requiring multiple elements for the connection provider. This package provides parsing and handling of DSNs for database connections including both server and embedded database connections.
4+
5+
A typical DSN for a server is something like:
6+
7+
```
8+
provider[+driver]://username[:password]@host:port/db?option1=value1&option2=value2
9+
```
10+
11+
Whereas an embedded database usually just includes the provider and the path:
12+
13+
```
14+
provider:///relative/path/to/file.db
15+
provider:////absolute/path/to/file.db
16+
```
17+
18+
Use the `dsn.Parse` method to parse this provider so that you can pass the connection details easily into your connection manager of choice.

dsn/dsn.go

+153
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package dsn
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net/url"
7+
"strconv"
8+
"strings"
9+
)
10+
11+
var (
12+
ErrCannotParseDSN = errors.New("could not parse dsn: missing provider or path")
13+
ErrCannotParseProvider = errors.New("could not parse dsn: incorrect provider")
14+
ErrCannotParsePort = errors.New("could not parse dsn: invalid port number")
15+
)
16+
17+
// DSN (data source name) contains information about how to connect to a database. It
18+
// serves both as a mechanism to connect to the local database storage engine and as a
19+
// mechanism to connect to the server databases from external clients. A typical DSN is:
20+
//
21+
// provider[+driver]://username[:password]@host:port/db?option1=value1&option2=value2
22+
//
23+
// This DSN provides connection to both server and embedded datbases. An embedded
24+
// database DSN needs to specify relative vs absolute paths. Ensure an extra / is
25+
// included for absolute paths to disambiguate the path and host portion.
26+
type DSN struct {
27+
Provider string // The provider indicates the database being connected to.
28+
Driver string // An additional component of the provider, separated by a + - it indicates what dirver to use.
29+
User *UserInfo // The username and password (must be URL encoded for special chars)
30+
Host string // The hostname of the database to connect to.
31+
Port uint16 // The port of the database to connect on.
32+
Path string // The path to the database (or the database name) including the directory.
33+
Options Options // Any additional connection options for the database.
34+
}
35+
36+
// Contains user or machine login credentials.
37+
type UserInfo struct {
38+
Username string
39+
Password string
40+
}
41+
42+
// Additional options for establishing a database connection.
43+
type Options map[string]string
44+
45+
func Parse(dsn string) (_ *DSN, err error) {
46+
var uri *url.URL
47+
if uri, err = url.Parse(dsn); err != nil {
48+
return nil, ErrCannotParseDSN
49+
}
50+
51+
if uri.Scheme == "" || uri.Path == "" {
52+
return nil, ErrCannotParseDSN
53+
}
54+
55+
d := &DSN{
56+
Host: uri.Hostname(),
57+
Path: strings.TrimPrefix(uri.Path, "/"),
58+
}
59+
60+
scheme := strings.Split(uri.Scheme, "+")
61+
switch len(scheme) {
62+
case 1:
63+
d.Provider = scheme[0]
64+
case 2:
65+
d.Provider = scheme[0]
66+
d.Driver = scheme[1]
67+
default:
68+
return nil, ErrCannotParseProvider
69+
}
70+
71+
if user := uri.User; user != nil {
72+
d.User = &UserInfo{
73+
Username: user.Username(),
74+
}
75+
d.User.Password, _ = user.Password()
76+
}
77+
78+
if port := uri.Port(); port != "" {
79+
var pnum uint64
80+
if pnum, err = strconv.ParseUint(port, 10, 16); err != nil {
81+
return nil, ErrCannotParsePort
82+
}
83+
d.Port = uint16(pnum)
84+
}
85+
86+
if params := uri.Query(); len(params) > 0 {
87+
d.Options = make(Options, len(params))
88+
for key := range params {
89+
d.Options[key] = params.Get(key)
90+
}
91+
}
92+
93+
return d, nil
94+
}
95+
96+
func (d *DSN) String() string {
97+
u := &url.URL{
98+
Scheme: d.scheme(),
99+
User: d.userinfo(),
100+
Host: d.hostport(),
101+
Path: d.Path,
102+
RawQuery: d.rawquery(),
103+
}
104+
105+
if d.Host == "" {
106+
u.Path = "/" + d.Path
107+
}
108+
109+
return u.String()
110+
}
111+
112+
func (d *DSN) scheme() string {
113+
switch {
114+
case d.Provider != "" && d.Driver != "":
115+
return d.Provider + "+" + d.Driver
116+
case d.Provider != "":
117+
return d.Provider
118+
case d.Driver != "":
119+
return d.Driver
120+
default:
121+
return ""
122+
}
123+
}
124+
125+
func (d *DSN) hostport() string {
126+
if d.Port != 0 {
127+
return fmt.Sprintf("%s:%d", d.Host, d.Port)
128+
}
129+
return d.Host
130+
}
131+
132+
func (d *DSN) userinfo() *url.Userinfo {
133+
if d.User != nil {
134+
if d.User.Password != "" {
135+
return url.UserPassword(d.User.Username, d.User.Password)
136+
}
137+
if d.User.Username != "" {
138+
return url.User(d.User.Username)
139+
}
140+
}
141+
return nil
142+
}
143+
144+
func (d *DSN) rawquery() string {
145+
if len(d.Options) > 0 {
146+
query := make(url.Values)
147+
for key, val := range d.Options {
148+
query.Add(key, val)
149+
}
150+
return query.Encode()
151+
}
152+
return ""
153+
}

dsn/dsn_test.go

+149
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package dsn_test
2+
3+
import (
4+
"testing"
5+
6+
"go.rtnl.ai/x/assert"
7+
"go.rtnl.ai/x/dsn"
8+
)
9+
10+
func TestParse(t *testing.T) {
11+
t.Run("Valid", func(t *testing.T) {
12+
testCases := []struct {
13+
uri string
14+
expected *dsn.DSN
15+
}{
16+
{
17+
"sqlite3:///path/to/test.db",
18+
&dsn.DSN{Provider: "sqlite3", Path: "path/to/test.db"},
19+
},
20+
{
21+
"sqlite3:////absolute/path/test.db",
22+
&dsn.DSN{Provider: "sqlite3", Path: "/absolute/path/test.db"},
23+
},
24+
{
25+
"leveldb:///path/to/db",
26+
&dsn.DSN{Provider: "leveldb", Path: "path/to/db"},
27+
},
28+
{
29+
"leveldb:////absolute/path/db",
30+
&dsn.DSN{Provider: "leveldb", Path: "/absolute/path/db"},
31+
},
32+
{
33+
"postgresql://janedoe:mypassword@localhost:5432/mydb?schema=sample",
34+
&dsn.DSN{Provider: "postgresql", User: &dsn.UserInfo{Username: "janedoe", Password: "mypassword"}, Host: "localhost", Port: 5432, Path: "mydb", Options: dsn.Options{"schema": "sample"}},
35+
},
36+
{
37+
"postgresql+psycopg2://janedoe:mypassword@localhost:5432/mydb?schema=sample",
38+
&dsn.DSN{Provider: "postgresql", Driver: "psycopg2", User: &dsn.UserInfo{Username: "janedoe", Password: "mypassword"}, Host: "localhost", Port: 5432, Path: "mydb", Options: dsn.Options{"schema": "sample"}},
39+
},
40+
{
41+
"mysql://janedoe:mypassword@localhost:3306/mydb",
42+
&dsn.DSN{Provider: "mysql", User: &dsn.UserInfo{Username: "janedoe", Password: "mypassword"}, Host: "localhost", Port: 3306, Path: "mydb"},
43+
},
44+
{
45+
"mysql+odbc://janedoe:mypassword@localhost:3306/mydb",
46+
&dsn.DSN{Provider: "mysql", Driver: "odbc", User: &dsn.UserInfo{Username: "janedoe", Password: "mypassword"}, Host: "localhost", Port: 3306, Path: "mydb"},
47+
},
48+
{
49+
"cockroachdb+postgresql://janedoe:mypassword@localhost:26257/mydb?schema=public",
50+
&dsn.DSN{Provider: "cockroachdb", Driver: "postgresql", User: &dsn.UserInfo{Username: "janedoe", Password: "mypassword"}, Host: "localhost", Port: 26257, Path: "mydb", Options: dsn.Options{"schema": "public"}},
51+
},
52+
{
53+
"mongodb+srv://root:[email protected]/myDatabase?retryWrites=true&w=majority",
54+
&dsn.DSN{Provider: "mongodb", Driver: "srv", User: &dsn.UserInfo{Username: "root", Password: "password"}, Host: "cluster0.ab1cd.mongodb.net", Path: "myDatabase", Options: dsn.Options{"retryWrites": "true", "w": "majority"}},
55+
},
56+
}
57+
58+
for i, tc := range testCases {
59+
actual, err := dsn.Parse(tc.uri)
60+
assert.Ok(t, err, "test case %d failed", i)
61+
assert.Equal(t, tc.expected, actual, "test case %d failed", i)
62+
}
63+
64+
})
65+
66+
t.Run("Invalid", func(t *testing.T) {
67+
testCases := []struct {
68+
uri string
69+
err error
70+
}{
71+
{"", dsn.ErrCannotParseDSN},
72+
{"sqlite3://", dsn.ErrCannotParseDSN},
73+
{"postgresql://jdoe:<mypassword>@localhost:foo/mydb", dsn.ErrCannotParseDSN},
74+
{"postgresql://localhost:foo/mydb", dsn.ErrCannotParseDSN},
75+
{"mysql+odbc+sand://jdoe:mypassword@localhost:3306/mydb", dsn.ErrCannotParseProvider},
76+
{"postgresql://jdoe:mypassword@localhost:656656/mydb", dsn.ErrCannotParsePort},
77+
}
78+
79+
for i, tc := range testCases {
80+
_, err := dsn.Parse(tc.uri)
81+
assert.ErrorIs(t, err, tc.err, "test case %d failed", i)
82+
}
83+
})
84+
}
85+
86+
func TestString(t *testing.T) {
87+
testCases := []struct {
88+
expected string
89+
uri *dsn.DSN
90+
}{
91+
{
92+
"sqlite3:///path/to/test.db",
93+
&dsn.DSN{Provider: "sqlite3", Path: "path/to/test.db"},
94+
},
95+
{
96+
"sqlite3:////absolute/path/test.db",
97+
&dsn.DSN{Provider: "sqlite3", Path: "/absolute/path/test.db"},
98+
},
99+
{
100+
"leveldb:///path/to/db",
101+
&dsn.DSN{Provider: "leveldb", Path: "path/to/db"},
102+
},
103+
{
104+
"leveldb:////absolute/path/db",
105+
&dsn.DSN{Provider: "leveldb", Path: "/absolute/path/db"},
106+
},
107+
{
108+
"postgresql://localhost:5432/mydb?schema=sample",
109+
&dsn.DSN{Provider: "postgresql", Host: "localhost", Port: 5432, Path: "mydb", Options: dsn.Options{"schema": "sample"}},
110+
},
111+
{
112+
"postgresql+psycopg2://janedoe:mypassword@localhost:5432/mydb?schema=sample",
113+
&dsn.DSN{Provider: "postgresql", Driver: "psycopg2", User: &dsn.UserInfo{Username: "janedoe", Password: "mypassword"}, Host: "localhost", Port: 5432, Path: "mydb", Options: dsn.Options{"schema": "sample"}},
114+
},
115+
{
116+
"mysql://janedoe:mypassword@localhost:3306/mydb",
117+
&dsn.DSN{Provider: "mysql", User: &dsn.UserInfo{Username: "janedoe", Password: "mypassword"}, Host: "localhost", Port: 3306, Path: "mydb"},
118+
},
119+
{
120+
"mysql+odbc://janedoe@localhost:3306/mydb",
121+
&dsn.DSN{Provider: "mysql", Driver: "odbc", User: &dsn.UserInfo{Username: "janedoe"}, Host: "localhost", Port: 3306, Path: "mydb"},
122+
},
123+
{
124+
"cockroachdb+postgresql://janedoe:mypassword@localhost:26257/mydb?schema=public",
125+
&dsn.DSN{Provider: "cockroachdb", Driver: "postgresql", User: &dsn.UserInfo{Username: "janedoe", Password: "mypassword"}, Host: "localhost", Port: 26257, Path: "mydb", Options: dsn.Options{"schema": "public"}},
126+
},
127+
{
128+
"mongodb+srv://root:[email protected]/myDatabase?retryWrites=true&w=majority",
129+
&dsn.DSN{Provider: "mongodb", Driver: "srv", User: &dsn.UserInfo{Username: "root", Password: "password"}, Host: "cluster0.ab1cd.mongodb.net", Path: "myDatabase", Options: dsn.Options{"retryWrites": "true", "w": "majority"}},
130+
},
131+
{
132+
"cockroachdb://localhost:26257/mydb",
133+
&dsn.DSN{Driver: "cockroachdb", Host: "localhost", Port: 26257, Path: "mydb"},
134+
},
135+
{
136+
"//localhost:26257/mydb",
137+
&dsn.DSN{Host: "localhost", Port: 26257, Path: "mydb"},
138+
},
139+
{
140+
"/mydb",
141+
&dsn.DSN{Path: "mydb"},
142+
},
143+
}
144+
145+
for i, tc := range testCases {
146+
actual := tc.uri.String()
147+
assert.Equal(t, tc.expected, actual, "test case %d failed", i)
148+
}
149+
}

0 commit comments

Comments
 (0)