Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DSN parsing #13

Merged
merged 1 commit into from
Jan 7, 2025
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
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:password@cluster0.ab1cd.mongodb.net/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:password@cluster0.ab1cd.mongodb.net/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)
}
}
Loading