Skip to content

Commit

Permalink
DSN parsing (#13)
Browse files Browse the repository at this point in the history
  • Loading branch information
bbengfort authored Jan 7, 2025
1 parent 89b219e commit f775c12
Show file tree
Hide file tree
Showing 4 changed files with 321 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ This is single repository that stores many, independent small subpackages. This
- [base58](https://go.rtnl.ai/x/base58): base58 encoding package as used by Bitcoin and travel addresses
- [randstr](https://go.rtnl.ai/x/randstr): generate random strings using the crypto/rand package as efficiently as possible
- [api](https://go.rtnl.ai/x/api): common utilities and responses for our JSON/REST APIs that our services run.
- [dsn](https://go.rtnl.ai/x/dsn): parses data source names in order to connect to both server and embedded databases easily.

## About

Expand Down
18 changes: 18 additions & 0 deletions dsn/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# DSN

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.

A typical DSN for a server is something like:

```
provider[+driver]://username[:password]@host:port/db?option1=value1&option2=value2
```

Whereas an embedded database usually just includes the provider and the path:

```
provider:///relative/path/to/file.db
provider:////absolute/path/to/file.db
```

Use the `dsn.Parse` method to parse this provider so that you can pass the connection details easily into your connection manager of choice.
153 changes: 153 additions & 0 deletions dsn/dsn.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package dsn

import (
"errors"
"fmt"
"net/url"
"strconv"
"strings"
)

var (
ErrCannotParseDSN = errors.New("could not parse dsn: missing provider or path")
ErrCannotParseProvider = errors.New("could not parse dsn: incorrect provider")
ErrCannotParsePort = errors.New("could not parse dsn: invalid port number")
)

// DSN (data source name) contains information about how to connect to a database. It
// serves both as a mechanism to connect to the local database storage engine and as a
// mechanism to connect to the server databases from external clients. A typical DSN is:
//
// provider[+driver]://username[:password]@host:port/db?option1=value1&option2=value2
//
// This DSN provides connection to both server and embedded datbases. An embedded
// database DSN needs to specify relative vs absolute paths. Ensure an extra / is
// included for absolute paths to disambiguate the path and host portion.
type DSN struct {
Provider string // The provider indicates the database being connected to.
Driver string // An additional component of the provider, separated by a + - it indicates what dirver to use.
User *UserInfo // The username and password (must be URL encoded for special chars)
Host string // The hostname of the database to connect to.
Port uint16 // The port of the database to connect on.
Path string // The path to the database (or the database name) including the directory.
Options Options // Any additional connection options for the database.
}

// Contains user or machine login credentials.
type UserInfo struct {
Username string
Password string
}

// Additional options for establishing a database connection.
type Options map[string]string

func Parse(dsn string) (_ *DSN, err error) {
var uri *url.URL
if uri, err = url.Parse(dsn); err != nil {
return nil, ErrCannotParseDSN
}

if uri.Scheme == "" || uri.Path == "" {
return nil, ErrCannotParseDSN
}

d := &DSN{
Host: uri.Hostname(),
Path: strings.TrimPrefix(uri.Path, "/"),
}

scheme := strings.Split(uri.Scheme, "+")
switch len(scheme) {
case 1:
d.Provider = scheme[0]
case 2:
d.Provider = scheme[0]
d.Driver = scheme[1]
default:
return nil, ErrCannotParseProvider
}

if user := uri.User; user != nil {
d.User = &UserInfo{
Username: user.Username(),
}
d.User.Password, _ = user.Password()
}

if port := uri.Port(); port != "" {
var pnum uint64
if pnum, err = strconv.ParseUint(port, 10, 16); err != nil {
return nil, ErrCannotParsePort
}
d.Port = uint16(pnum)
}

if params := uri.Query(); len(params) > 0 {
d.Options = make(Options, len(params))
for key := range params {
d.Options[key] = params.Get(key)
}
}

return d, nil
}

func (d *DSN) String() string {
u := &url.URL{
Scheme: d.scheme(),
User: d.userinfo(),
Host: d.hostport(),
Path: d.Path,
RawQuery: d.rawquery(),
}

if d.Host == "" {
u.Path = "/" + d.Path
}

return u.String()
}

func (d *DSN) scheme() string {
switch {
case d.Provider != "" && d.Driver != "":
return d.Provider + "+" + d.Driver
case d.Provider != "":
return d.Provider
case d.Driver != "":
return d.Driver
default:
return ""
}
}

func (d *DSN) hostport() string {
if d.Port != 0 {
return fmt.Sprintf("%s:%d", d.Host, d.Port)
}
return d.Host
}

func (d *DSN) userinfo() *url.Userinfo {
if d.User != nil {
if d.User.Password != "" {
return url.UserPassword(d.User.Username, d.User.Password)
}
if d.User.Username != "" {
return url.User(d.User.Username)
}
}
return nil
}

func (d *DSN) rawquery() string {
if len(d.Options) > 0 {
query := make(url.Values)
for key, val := range d.Options {
query.Add(key, val)
}
return query.Encode()
}
return ""
}
149 changes: 149 additions & 0 deletions dsn/dsn_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package dsn_test

import (
"testing"

"go.rtnl.ai/x/assert"
"go.rtnl.ai/x/dsn"
)

func TestParse(t *testing.T) {
t.Run("Valid", func(t *testing.T) {
testCases := []struct {
uri string
expected *dsn.DSN
}{
{
"sqlite3:///path/to/test.db",
&dsn.DSN{Provider: "sqlite3", Path: "path/to/test.db"},
},
{
"sqlite3:////absolute/path/test.db",
&dsn.DSN{Provider: "sqlite3", Path: "/absolute/path/test.db"},
},
{
"leveldb:///path/to/db",
&dsn.DSN{Provider: "leveldb", Path: "path/to/db"},
},
{
"leveldb:////absolute/path/db",
&dsn.DSN{Provider: "leveldb", Path: "/absolute/path/db"},
},
{
"postgresql://janedoe:mypassword@localhost:5432/mydb?schema=sample",
&dsn.DSN{Provider: "postgresql", User: &dsn.UserInfo{Username: "janedoe", Password: "mypassword"}, Host: "localhost", Port: 5432, Path: "mydb", Options: dsn.Options{"schema": "sample"}},
},
{
"postgresql+psycopg2://janedoe:mypassword@localhost:5432/mydb?schema=sample",
&dsn.DSN{Provider: "postgresql", Driver: "psycopg2", User: &dsn.UserInfo{Username: "janedoe", Password: "mypassword"}, Host: "localhost", Port: 5432, Path: "mydb", Options: dsn.Options{"schema": "sample"}},
},
{
"mysql://janedoe:mypassword@localhost:3306/mydb",
&dsn.DSN{Provider: "mysql", User: &dsn.UserInfo{Username: "janedoe", Password: "mypassword"}, Host: "localhost", Port: 3306, Path: "mydb"},
},
{
"mysql+odbc://janedoe:mypassword@localhost:3306/mydb",
&dsn.DSN{Provider: "mysql", Driver: "odbc", User: &dsn.UserInfo{Username: "janedoe", Password: "mypassword"}, Host: "localhost", Port: 3306, Path: "mydb"},
},
{
"cockroachdb+postgresql://janedoe:mypassword@localhost:26257/mydb?schema=public",
&dsn.DSN{Provider: "cockroachdb", Driver: "postgresql", User: &dsn.UserInfo{Username: "janedoe", Password: "mypassword"}, Host: "localhost", Port: 26257, Path: "mydb", Options: dsn.Options{"schema": "public"}},
},
{
"mongodb+srv://root:[email protected]/myDatabase?retryWrites=true&w=majority",
&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"}},
},
}

for i, tc := range testCases {
actual, err := dsn.Parse(tc.uri)
assert.Ok(t, err, "test case %d failed", i)
assert.Equal(t, tc.expected, actual, "test case %d failed", i)
}

})

t.Run("Invalid", func(t *testing.T) {
testCases := []struct {
uri string
err error
}{
{"", dsn.ErrCannotParseDSN},
{"sqlite3://", dsn.ErrCannotParseDSN},
{"postgresql://jdoe:<mypassword>@localhost:foo/mydb", dsn.ErrCannotParseDSN},
{"postgresql://localhost:foo/mydb", dsn.ErrCannotParseDSN},
{"mysql+odbc+sand://jdoe:mypassword@localhost:3306/mydb", dsn.ErrCannotParseProvider},
{"postgresql://jdoe:mypassword@localhost:656656/mydb", dsn.ErrCannotParsePort},
}

for i, tc := range testCases {
_, err := dsn.Parse(tc.uri)
assert.ErrorIs(t, err, tc.err, "test case %d failed", i)
}
})
}

func TestString(t *testing.T) {
testCases := []struct {
expected string
uri *dsn.DSN
}{
{
"sqlite3:///path/to/test.db",
&dsn.DSN{Provider: "sqlite3", Path: "path/to/test.db"},
},
{
"sqlite3:////absolute/path/test.db",
&dsn.DSN{Provider: "sqlite3", Path: "/absolute/path/test.db"},
},
{
"leveldb:///path/to/db",
&dsn.DSN{Provider: "leveldb", Path: "path/to/db"},
},
{
"leveldb:////absolute/path/db",
&dsn.DSN{Provider: "leveldb", Path: "/absolute/path/db"},
},
{
"postgresql://localhost:5432/mydb?schema=sample",
&dsn.DSN{Provider: "postgresql", Host: "localhost", Port: 5432, Path: "mydb", Options: dsn.Options{"schema": "sample"}},
},
{
"postgresql+psycopg2://janedoe:mypassword@localhost:5432/mydb?schema=sample",
&dsn.DSN{Provider: "postgresql", Driver: "psycopg2", User: &dsn.UserInfo{Username: "janedoe", Password: "mypassword"}, Host: "localhost", Port: 5432, Path: "mydb", Options: dsn.Options{"schema": "sample"}},
},
{
"mysql://janedoe:mypassword@localhost:3306/mydb",
&dsn.DSN{Provider: "mysql", User: &dsn.UserInfo{Username: "janedoe", Password: "mypassword"}, Host: "localhost", Port: 3306, Path: "mydb"},
},
{
"mysql+odbc://janedoe@localhost:3306/mydb",
&dsn.DSN{Provider: "mysql", Driver: "odbc", User: &dsn.UserInfo{Username: "janedoe"}, Host: "localhost", Port: 3306, Path: "mydb"},
},
{
"cockroachdb+postgresql://janedoe:mypassword@localhost:26257/mydb?schema=public",
&dsn.DSN{Provider: "cockroachdb", Driver: "postgresql", User: &dsn.UserInfo{Username: "janedoe", Password: "mypassword"}, Host: "localhost", Port: 26257, Path: "mydb", Options: dsn.Options{"schema": "public"}},
},
{
"mongodb+srv://root:[email protected]/myDatabase?retryWrites=true&w=majority",
&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"}},
},
{
"cockroachdb://localhost:26257/mydb",
&dsn.DSN{Driver: "cockroachdb", Host: "localhost", Port: 26257, Path: "mydb"},
},
{
"//localhost:26257/mydb",
&dsn.DSN{Host: "localhost", Port: 26257, Path: "mydb"},
},
{
"/mydb",
&dsn.DSN{Path: "mydb"},
},
}

for i, tc := range testCases {
actual := tc.uri.String()
assert.Equal(t, tc.expected, actual, "test case %d failed", i)
}
}

0 comments on commit f775c12

Please sign in to comment.