diff --git a/README.md b/README.md index 191973f..2559c09 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/dsn/README.md b/dsn/README.md new file mode 100644 index 0000000..e57fa79 --- /dev/null +++ b/dsn/README.md @@ -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. \ No newline at end of file diff --git a/dsn/dsn.go b/dsn/dsn.go new file mode 100644 index 0000000..5320645 --- /dev/null +++ b/dsn/dsn.go @@ -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 "" +} diff --git a/dsn/dsn_test.go b/dsn/dsn_test.go new file mode 100644 index 0000000..bfb1a20 --- /dev/null +++ b/dsn/dsn_test.go @@ -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:@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) + } +}