Skip to content

Commit d6b0ca5

Browse files
authored
feat: add go rtdb emulator support (#517)
- Added support for the RTDB Emulator
1 parent 606008d commit d6b0ca5

File tree

3 files changed

+233
-55
lines changed

3 files changed

+233
-55
lines changed

db/db.go

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,36 +18,58 @@ package db
1818
import (
1919
"context"
2020
"encoding/json"
21+
"errors"
2122
"fmt"
2223
"net/url"
24+
"os"
2325
"runtime"
2426
"strings"
2527

2628
"firebase.google.com/go/v4/internal"
29+
"golang.org/x/oauth2"
2730
"google.golang.org/api/option"
2831
)
2932

3033
const userAgentFormat = "Firebase/HTTP/%s/%s/AdminGo"
3134
const invalidChars = "[].#$"
3235
const authVarOverride = "auth_variable_override"
36+
const emulatorDatabaseEnvVar = "FIREBASE_DATABASE_EMULATOR_HOST"
37+
const emulatorNamespaceParam = "ns"
38+
39+
// errInvalidURL tells whether the given database url is invalid
40+
// It is invalid if it is malformed, or not of the format "host:port"
41+
var errInvalidURL = errors.New("invalid database url")
42+
43+
var emulatorToken = &oauth2.Token{
44+
AccessToken: "owner",
45+
}
3346

3447
// Client is the interface for the Firebase Realtime Database service.
3548
type Client struct {
3649
hc *internal.HTTPClient
37-
url string
50+
dbURLConfig *dbURLConfig
3851
authOverride string
3952
}
4053

54+
type dbURLConfig struct {
55+
// BaseURL can be either:
56+
// - a production url (https://foo-bar.firebaseio.com/)
57+
// - an emulator url (http://localhost:9000)
58+
BaseURL string
59+
60+
// Namespace is used in for the emulator to specify the databaseName
61+
// To specify a namespace on your url, pass ns=<database_name> (localhost:9000/?ns=foo-bar)
62+
Namespace string
63+
}
64+
4165
// NewClient creates a new instance of the Firebase Database Client.
4266
//
4367
// This function can only be invoked from within the SDK. Client applications should access the
4468
// Database service through firebase.App.
4569
func NewClient(ctx context.Context, c *internal.DatabaseConfig) (*Client, error) {
46-
p, err := url.ParseRequestURI(c.URL)
70+
urlConfig, isEmulator, err := parseURLConfig(c.URL)
4771
if err != nil {
4872
return nil, err
49-
} else if p.Scheme != "https" {
50-
return nil, fmt.Errorf("invalid database URL: %q; want scheme: %q", c.URL, "https")
5173
}
5274

5375
var ao []byte
@@ -59,6 +81,10 @@ func NewClient(ctx context.Context, c *internal.DatabaseConfig) (*Client, error)
5981
}
6082

6183
opts := append([]option.ClientOption{}, c.Opts...)
84+
if isEmulator {
85+
ts := oauth2.StaticTokenSource(emulatorToken)
86+
opts = append(opts, option.WithTokenSource(ts))
87+
}
6288
ua := fmt.Sprintf(userAgentFormat, c.Version, runtime.Version())
6389
opts = append(opts, option.WithUserAgent(ua))
6490
hc, _, err := internal.NewHTTPClient(ctx, opts...)
@@ -69,7 +95,7 @@ func NewClient(ctx context.Context, c *internal.DatabaseConfig) (*Client, error)
6995
hc.CreateErrFn = handleRTDBError
7096
return &Client{
7197
hc: hc,
72-
url: fmt.Sprintf("https://%s", p.Host),
98+
dbURLConfig: urlConfig,
7399
authOverride: string(ao),
74100
}, nil
75101
}
@@ -96,10 +122,13 @@ func (c *Client) sendAndUnmarshal(
96122
return nil, fmt.Errorf("invalid path with illegal characters: %q", req.URL)
97123
}
98124

99-
req.URL = fmt.Sprintf("%s%s.json", c.url, req.URL)
125+
req.URL = fmt.Sprintf("%s%s.json", c.dbURLConfig.BaseURL, req.URL)
100126
if c.authOverride != "" {
101127
req.Opts = append(req.Opts, internal.WithQueryParam(authVarOverride, c.authOverride))
102128
}
129+
if c.dbURLConfig.Namespace != "" {
130+
req.Opts = append(req.Opts, internal.WithQueryParam(emulatorNamespaceParam, c.dbURLConfig.Namespace))
131+
}
103132

104133
return c.hc.DoAndUnmarshal(ctx, req, v)
105134
}
@@ -126,3 +155,65 @@ func handleRTDBError(resp *internal.Response) error {
126155

127156
return err
128157
}
158+
159+
// parseURLConfig returns the dbURLConfig for the database
160+
// dbURL may be either:
161+
// - a production url (https://foo-bar.firebaseio.com/)
162+
// - an emulator URL (localhost:9000/?ns=foo-bar)
163+
//
164+
// The following rules will apply for determining the output:
165+
// - If the url does not use an https scheme it will be assumed to be an emulator url and be used.
166+
// - else If the FIREBASE_DATABASE_EMULATOR_HOST environment variable is set it will be used.
167+
// - else the url will be assumed to be a production url and be used.
168+
func parseURLConfig(dbURL string) (*dbURLConfig, bool, error) {
169+
parsedURL, err := url.ParseRequestURI(dbURL)
170+
if err == nil && parsedURL.Scheme != "https" {
171+
cfg, err := parseEmulatorHost(dbURL, parsedURL)
172+
return cfg, true, err
173+
}
174+
175+
environmentEmulatorURL := os.Getenv(emulatorDatabaseEnvVar)
176+
if environmentEmulatorURL != "" {
177+
parsedURL, err = url.ParseRequestURI(environmentEmulatorURL)
178+
if err != nil {
179+
return nil, false, fmt.Errorf("%s: %w", environmentEmulatorURL, errInvalidURL)
180+
}
181+
cfg, err := parseEmulatorHost(environmentEmulatorURL, parsedURL)
182+
return cfg, true, err
183+
}
184+
185+
if err != nil {
186+
return nil, false, fmt.Errorf("%s: %w", dbURL, errInvalidURL)
187+
}
188+
189+
return &dbURLConfig{
190+
BaseURL: dbURL,
191+
Namespace: "",
192+
}, false, nil
193+
}
194+
195+
func parseEmulatorHost(rawEmulatorHostURL string, parsedEmulatorHost *url.URL) (*dbURLConfig, error) {
196+
if strings.Contains(rawEmulatorHostURL, "//") {
197+
return nil, fmt.Errorf(`invalid %s: "%s". It must follow format "host:port": %w`, emulatorDatabaseEnvVar, rawEmulatorHostURL, errInvalidURL)
198+
}
199+
200+
baseURL := strings.Replace(rawEmulatorHostURL, fmt.Sprintf("?%s", parsedEmulatorHost.RawQuery), "", -1)
201+
if parsedEmulatorHost.Scheme != "http" {
202+
baseURL = fmt.Sprintf("http://%s", baseURL)
203+
}
204+
205+
namespace := parsedEmulatorHost.Query().Get(emulatorNamespaceParam)
206+
if namespace == "" {
207+
if strings.Contains(rawEmulatorHostURL, ".") {
208+
namespace = strings.Split(rawEmulatorHostURL, ".")[0]
209+
}
210+
if namespace == "" {
211+
return nil, fmt.Errorf(`invalid database URL: "%s". Database URL must be a valid URL to a Firebase Realtime Database instance (include ?ns=<db-name> query param)`, parsedEmulatorHost)
212+
}
213+
}
214+
215+
return &dbURLConfig{
216+
BaseURL: baseURL,
217+
Namespace: namespace,
218+
}, nil
219+
}

db/db_test.go

Lines changed: 91 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,11 @@ import (
3333
)
3434

3535
const (
36-
testURL = "https://test-db.firebaseio.com"
37-
defaultMaxRetries = 1
36+
testURL = "https://test-db.firebaseio.com"
37+
testEmulatorNamespace = "test-db"
38+
testEmulatorBaseURL = "http://localhost:9000"
39+
testEmulatorURL = "localhost:9000?ns=test-db"
40+
defaultMaxRetries = 1
3841
)
3942

4043
var (
@@ -87,52 +90,96 @@ func TestMain(m *testing.M) {
8790
}
8891

8992
func TestNewClient(t *testing.T) {
90-
c, err := NewClient(context.Background(), &internal.DatabaseConfig{
91-
Opts: testOpts,
92-
URL: testURL,
93-
AuthOverride: make(map[string]interface{}),
94-
})
95-
if err != nil {
96-
t.Fatal(err)
97-
}
98-
if c.url != testURL {
99-
t.Errorf("NewClient().url = %q; want = %q", c.url, testURL)
100-
}
101-
if c.hc == nil {
102-
t.Errorf("NewClient().hc = nil; want non-nil")
93+
cases := []*struct {
94+
Name string
95+
URL string
96+
EnvURL string
97+
ExpectedBaseURL string
98+
ExpectedNamespace string
99+
ExpectError bool
100+
}{
101+
{Name: "production url", URL: testURL, ExpectedBaseURL: testURL, ExpectedNamespace: ""},
102+
{Name: "emulator - success", URL: testEmulatorURL, ExpectedBaseURL: testEmulatorBaseURL, ExpectedNamespace: testEmulatorNamespace},
103+
{Name: "emulator - missing namespace should error", URL: "localhost:9000", ExpectError: true},
104+
{Name: "emulator - if url contains hostname it uses the primary domain", URL: "rtdb-go.emulator:9000", ExpectedBaseURL: "http://rtdb-go.emulator:9000", ExpectedNamespace: "rtdb-go"},
105+
{Name: "emulator env - success", EnvURL: testEmulatorURL, ExpectedBaseURL: testEmulatorBaseURL, ExpectedNamespace: testEmulatorNamespace},
103106
}
104-
if c.authOverride != "" {
105-
t.Errorf("NewClient().ao = %q; want = %q", c.authOverride, "")
107+
for _, tc := range cases {
108+
t.Run(tc.Name, func(t *testing.T) {
109+
t.Setenv(emulatorDatabaseEnvVar, tc.EnvURL)
110+
fromEnv := os.Getenv(emulatorDatabaseEnvVar)
111+
fmt.Printf(fromEnv)
112+
c, err := NewClient(context.Background(), &internal.DatabaseConfig{
113+
Opts: testOpts,
114+
URL: tc.URL,
115+
AuthOverride: make(map[string]interface{}),
116+
})
117+
if err != nil && tc.ExpectError {
118+
return
119+
}
120+
if err != nil && !tc.ExpectError {
121+
t.Fatal(err)
122+
}
123+
if err == nil && tc.ExpectError {
124+
t.Fatal("expected error")
125+
}
126+
if c.dbURLConfig.BaseURL != tc.ExpectedBaseURL {
127+
t.Errorf("NewClient().dbURLConfig.BaseURL = %q; want = %q", c.dbURLConfig.BaseURL, tc.ExpectedBaseURL)
128+
}
129+
if c.dbURLConfig.Namespace != tc.ExpectedNamespace {
130+
t.Errorf("NewClient(%v).Namespace = %q; want = %q", tc, c.dbURLConfig.Namespace, tc.ExpectedNamespace)
131+
}
132+
if c.hc == nil {
133+
t.Errorf("NewClient().hc = nil; want non-nil")
134+
}
135+
if c.authOverride != "" {
136+
t.Errorf("NewClient().ao = %q; want = %q", c.authOverride, "")
137+
}
138+
})
106139
}
107140
}
108141

109142
func TestNewClientAuthOverrides(t *testing.T) {
110-
cases := []map[string]interface{}{
111-
nil,
112-
{"uid": "user1"},
143+
cases := []*struct {
144+
Name string
145+
Params map[string]interface{}
146+
URL string
147+
ExpectedBaseURL string
148+
ExpectedNamespace string
149+
}{
150+
{Name: "production - without override", Params: nil, URL: testURL, ExpectedBaseURL: testURL, ExpectedNamespace: ""},
151+
{Name: "production - with override", Params: map[string]interface{}{"uid": "user1"}, URL: testURL, ExpectedBaseURL: testURL, ExpectedNamespace: ""},
152+
153+
{Name: "emulator - with no query params", Params: nil, URL: testEmulatorURL, ExpectedBaseURL: testEmulatorBaseURL, ExpectedNamespace: testEmulatorNamespace},
154+
{Name: "emulator - with override", Params: map[string]interface{}{"uid": "user1"}, URL: testEmulatorURL, ExpectedBaseURL: testEmulatorBaseURL, ExpectedNamespace: testEmulatorNamespace},
113155
}
114156
for _, tc := range cases {
115-
c, err := NewClient(context.Background(), &internal.DatabaseConfig{
116-
Opts: testOpts,
117-
URL: testURL,
118-
AuthOverride: tc,
157+
t.Run(tc.Name, func(t *testing.T) {
158+
c, err := NewClient(context.Background(), &internal.DatabaseConfig{
159+
Opts: testOpts,
160+
URL: tc.URL,
161+
AuthOverride: tc.Params,
162+
})
163+
if err != nil {
164+
t.Fatal(err)
165+
}
166+
if c.dbURLConfig.BaseURL != tc.ExpectedBaseURL {
167+
t.Errorf("NewClient(%v).baseURL = %q; want = %q", tc, c.dbURLConfig.BaseURL, tc.ExpectedBaseURL)
168+
}
169+
if c.dbURLConfig.Namespace != tc.ExpectedNamespace {
170+
t.Errorf("NewClient(%v).Namespace = %q; want = %q", tc, c.dbURLConfig.Namespace, tc.ExpectedNamespace)
171+
}
172+
if c.hc == nil {
173+
t.Errorf("NewClient(%v).hc = nil; want non-nil", tc)
174+
}
175+
b, err := json.Marshal(tc.Params)
176+
if err != nil {
177+
t.Fatal(err)
178+
}
179+
if c.authOverride != string(b) {
180+
t.Errorf("NewClient(%v).ao = %q; want = %q", tc, c.authOverride, string(b))
181+
}
119182
})
120-
if err != nil {
121-
t.Fatal(err)
122-
}
123-
if c.url != testURL {
124-
t.Errorf("NewClient(%v).url = %q; want = %q", tc, c.url, testURL)
125-
}
126-
if c.hc == nil {
127-
t.Errorf("NewClient(%v).hc = nil; want non-nil", tc)
128-
}
129-
b, err := json.Marshal(tc)
130-
if err != nil {
131-
t.Fatal(err)
132-
}
133-
if c.authOverride != string(b) {
134-
t.Errorf("NewClient(%v).ao = %q; want = %q", tc, c.authOverride, string(b))
135-
}
136183
}
137184
}
138185

@@ -149,8 +196,8 @@ func TestValidURLS(t *testing.T) {
149196
if err != nil {
150197
t.Fatal(err)
151198
}
152-
if c.url != tc {
153-
t.Errorf("NewClient(%v).url = %q; want = %q", tc, c.url, testURL)
199+
if c.dbURLConfig.BaseURL != tc {
200+
t.Errorf("NewClient(%v).url = %q; want = %q", tc, c.dbURLConfig.BaseURL, testURL)
154201
}
155202
}
156203
}
@@ -161,6 +208,7 @@ func TestInvalidURL(t *testing.T) {
161208
"foo",
162209
"http://db.firebaseio.com",
163210
"http://firebase.google.com",
211+
"http://localhost:9000",
164212
}
165213
for _, tc := range cases {
166214
c, err := NewClient(context.Background(), &internal.DatabaseConfig{
@@ -402,7 +450,7 @@ func (s *mockServer) Start(c *Client) *httptest.Server {
402450
w.Write(b)
403451
})
404452
s.srv = httptest.NewServer(handler)
405-
c.url = s.srv.URL
453+
c.dbURLConfig.BaseURL = s.srv.URL
406454
return s.srv
407455
}
408456

0 commit comments

Comments
 (0)