diff options
Diffstat (limited to 'pgruntime/target.go')
| -rw-r--r-- | pgruntime/target.go | 144 |
1 files changed, 144 insertions, 0 deletions
diff --git a/pgruntime/target.go b/pgruntime/target.go new file mode 100644 index 0000000..a03c8c5 --- /dev/null +++ b/pgruntime/target.go @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package pgruntime + +import ( + "fmt" + "maps" + "net" + "net/url" + "strings" +) + +// ConnectionTarget describes the location of and credentials and other connection options for the database server that [Connector.Connect] connects to. +// +// - The HostName, UserName and DatabaseName fields are required. All other fields are optional. +// - The ConnectionOptions field is intended for options coming in via config. +// Its value must be formatted in the syntax accepted by [url.ParseQuery]. +// - The ExtraConnectionOptions field is intended for options coming in via code. +// - Both ConnectionOptions fields accept all query parameters that are allowed in [libpq-style connection URIs]. +// +// This type is intended to be retrieved from environment variables, for example: +// +// ct := pgruntime.ConnectionTarget{ +// HostName: cmp.Or(os.Getenv("FOOBAR_DB_HOSTNAME"), "localhost"), +// Port: cmp.Or(os.Getenv("FOOBAR_DB_PORT"), "5432"), +// UserName: cmp.Or(os.Getenv("FOOBAR_DB_USERNAME"), "postgres"), +// Password: os.Getenv("FOOBAR_DB_PASSWORD"), +// ConnectionOptions: os.Getenv("FOOBAR_DB_CONNECTION_OPTIONS"), +// DatabaseName: cmp.Or(os.Getenv("FOOBAR_DB_NAME"), "foobar"), +// })) +// +// [libpq-style connection URIs]: https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS +type ConnectionTarget struct { + HostName string + Port string + UserName string + Password string // default: <no password> + DatabaseName string + ConnectionOptions string + ExtraConnectionOptions url.Values +} + +const ( + connectionURISchemeShort = "postgres" + connectionURISchemeLong = "postgresql" +) + +// ParseConnectionTargetFromURL parses a [libpq-style connection URI] into a [ConnectionTarget] instance. +// If there are connection options, they will be placed in the ConnectionOptions field, not in ExtraConnectionOptions. +// +// The special libpq syntax with multiple host:port pairs is not supported by this function and will result in an error. +// +// [libpq-style connection URI]: https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS +func ParseConnectionTargetFromURL(u *url.URL) (ConnectionTarget, error) { + var ( + result ConnectionTarget + none ConnectionTarget // shorthand for error return paths + ) + + if u.Scheme != connectionURISchemeShort && u.Scheme != connectionURISchemeLong { + return none, fmt.Errorf(`in ParseConnectionTargetFromURL: expected scheme %q or %q, but got %q`, connectionURISchemeShort, connectionURISchemeLong, u.Scheme) + } + if u.Opaque != "" { + return none, fmt.Errorf(`in ParseConnectionTargetFromURL: expected authority component, but got rootless path %q`, u.Opaque) + } + if u.Fragment != "" { + return none, fmt.Errorf(`in ParseConnectionTargetFromURL: unexpected fragment %q`, u.Fragment) + } + + var err error + if strings.Contains(u.Host, ":") { + result.HostName, result.Port, err = net.SplitHostPort(u.Host) + if err != nil { + return none, fmt.Errorf(`in ParseConnectionTargetFromURL: malformed host %q`, u.Host) + } + } else { + result.HostName = u.Host + } + result.UserName = u.User.Username() + result.Password, _ = u.User.Password() + + result.DatabaseName = strings.TrimPrefix(u.Path, "/") + if strings.Contains(result.DatabaseName, "/") { + return none, fmt.Errorf(`in ParseConnectionTargetFromURL: malformed database name %q`, result.DatabaseName) + } + + if u.RawQuery != "" { + _, err := url.ParseQuery(u.RawQuery) + if err != nil { + return none, fmt.Errorf(`in ParseConnectionTargetFromURL: malformed connection options: %w`, err) + } + result.ConnectionOptions = u.RawQuery + } + + return result, nil +} + +// IntoURL formats the ConnectionTarget as a libpq-style connection URI. +func (t ConnectionTarget) IntoURL() (*url.URL, error) { + rawQuery, err := mergeConnectionOptions(t.ConnectionOptions, t.ExtraConnectionOptions) + if err != nil { + return nil, fmt.Errorf("in ConnectionTarget.IntoURL: malformed connection options: %w", err) + } + return &url.URL{ + Scheme: connectionURISchemeShort, // could also use `connectionURISchemeLong`, but go-bits/easypg made this choice + User: t.buildUserInfo(), + Host: t.buildHostPort(), + Path: "/" + t.DatabaseName, + RawQuery: rawQuery, + }, nil +} + +func (t ConnectionTarget) buildUserInfo() *url.Userinfo { + if t.Password == "" { + return url.User(t.UserName) + } else { + return url.UserPassword(t.UserName, t.Password) + } +} + +func (t ConnectionTarget) buildHostPort() string { + if t.Port == "" { + return t.HostName + } else { + return net.JoinHostPort(t.HostName, t.Port) + } +} + +func mergeConnectionOptions(opts string, extraOpts url.Values) (string, error) { + if len(extraOpts) == 0 { + return opts, nil + } + if opts == "" { + return extraOpts.Encode(), nil + } + + values, err := url.ParseQuery(opts) + if err != nil { + return "", err + } + maps.Copy(values, extraOpts) + return values.Encode(), nil +} |
