// SPDX-FileCopyrightText: 2026 Stefan Majewsky // 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: 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 }