aboutsummaryrefslogtreecommitdiff
path: root/pgruntime/target.go
blob: a03c8c5479691a129b0bcdc9bfb571a460e7f27a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
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
}