diff --git a/.gitignore b/.gitignore index c50ffc81..a438d570 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ testdata/integration_* *~ \#*\# .DS_Store + +# Dataconnect integration test artifacts should not be checked in +testdata/dataconnect/dataconnect/.dataconnect diff --git a/dataconnect/dataconnect.go b/dataconnect/dataconnect.go new file mode 100644 index 00000000..be5e62fa --- /dev/null +++ b/dataconnect/dataconnect.go @@ -0,0 +1,190 @@ +// Copyright 2025 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package dataconnect contains functions for interacting with the Firebase Data Connect service. +package dataconnect + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + + "firebase.google.com/go/v4/internal" + "google.golang.org/api/option" + "google.golang.org/api/transport" +) + +const ( + dataConnectProdURLFormat = "https://firebasedataconnect.googleapis.com/%s/projects/%s/locations/%s/services/%s:%s" + dataConnectEmulatorURLFormat = "http://%s/%s/projects/%s/locations/%s/services/%s:%s" + emulatorHostEnvVar = "FIREBASE_DATA_CONNECT_EMULATOR_HOST" + apiVersion = "v1alpha" + executeGraphqlEndpoint = "executeGraphql" + executeGraphqlReadEndpoint = "executeGraphqlRead" + + // SDK-generated error codes + queryError = "QUERY_ERROR" +) + +// ConnectorConfig is the configuration for the Data Connect service. +type ConnectorConfig struct { + Location string `json:"location"` + ServiceID string `json:"serviceId"` +} + +// GraphqlOptions represents the options for a GraphQL query. +type GraphqlOptions struct { + Variables interface{} `json:"variables,omitempty"` + OperationName string `json:"operationName,omitempty"` +} + +// ExecuteGraphqlResponse is the response from a GraphQL query. +type internalExecuteGraphqlResponse struct { + Data json.RawMessage `json:"data"` +} + +// Client is the interface for the Firebase Data Connect service. +type Client struct { + client *internal.HTTPClient + projectID string + location string + serviceID string + isEmulator bool + emulatorHost string +} + +// NewClient creates a new instance of the Data Connect client. +// +// This function can only be invoked from within the SDK. Client applications should access the +// Data Connect service through firebase.App. +func NewClient(ctx context.Context, conf *internal.DataConnectConfig) (*Client, error) { + var opts []option.ClientOption + opts = append(opts, conf.Opts...) + + var isEmulator bool + emulatorHost := os.Getenv(emulatorHostEnvVar) + if emulatorHost != "" { + isEmulator = true + } + + transport, _, err := transport.NewHTTPClient(ctx, opts...) + if err != nil { + return nil, err + } + + hc := internal.WithDefaultRetryConfig(transport) + hc.CreateErrFn = handleError + hc.SuccessFn = func(r *internal.Response) bool { + // If the status isn't already a know success status we handle these responses normally + if !internal.HasSuccessStatus(r) { + return false + } + // Otherwise we check the successful response body for error + var errResp graphqlQueryErrorResponse + if err := json.Unmarshal(r.Body, &errResp); err != nil { + return true // Cannot parse, assume no query errors and thus success + } + return len(errResp.Errors) == 0 + } + hc.Opts = []internal.HTTPOption{ + internal.WithHeader("X-Client-Version", fmt.Sprintf("Go/Admin/%s", conf.Version)), + internal.WithHeader("x-goog-api-client", internal.GetMetricsHeader(conf.Version)), + } + + return &Client{ + client: hc, + projectID: conf.ProjectID, + location: conf.Location, + serviceID: conf.ServiceID, + isEmulator: isEmulator, + emulatorHost: emulatorHost, + }, nil +} + +// ExecuteGraphql executes a GraphQL query or mutation. +func (c *Client) ExecuteGraphql(ctx context.Context, query string, options *GraphqlOptions, response interface{}) error { + return c.execute(ctx, executeGraphqlEndpoint, query, options, response) +} + +// ExecuteGraphqlRead executes a GraphQL read-only query. +func (c *Client) ExecuteGraphqlRead(ctx context.Context, query string, options *GraphqlOptions, response interface{}) error { + return c.execute(ctx, executeGraphqlReadEndpoint, query, options, response) +} + +func (c *Client) execute(ctx context.Context, endpoint, query string, options *GraphqlOptions, response interface{}) error { + url := c.buildURL(endpoint) + + req := map[string]interface{}{ + "query": query, + } + if options != nil { + if options.Variables != nil { + req["variables"] = options.Variables + } + if options.OperationName != "" { + req["operationName"] = options.OperationName + } + } + + var result internalExecuteGraphqlResponse + request := &internal.Request{ + Method: http.MethodPost, + URL: url, + Body: internal.NewJSONEntity(req), + } + _, err := c.client.DoAndUnmarshal(ctx, request, &result) + if err != nil { + return err + } + if response != nil { + if err := json.Unmarshal(result.Data, &response); err != nil { + return fmt.Errorf("error while parsing response: %v", err) + } + } + + return nil +} + +func (c *Client) buildURL(endpoint string) string { + if c.isEmulator { + return fmt.Sprintf(dataConnectEmulatorURLFormat, c.emulatorHost, apiVersion, c.projectID, c.location, c.serviceID, endpoint) + } + return fmt.Sprintf(dataConnectProdURLFormat, apiVersion, c.projectID, c.location, c.serviceID, endpoint) +} + +type graphqlQueryErrorResponse struct { + Errors []map[string]interface{} `json:"errors"` +} + +func handleError(resp *internal.Response) error { + fe := internal.NewFirebaseError(resp) + var errResp graphqlQueryErrorResponse + if err := json.Unmarshal(resp.Body, &errResp); err == nil && len(errResp.Errors) > 0 { + // Unmarshalling here verifies query error exists + fe.ErrorCode = queryError + } + return fe +} + +// IsQueryError checks if the given error is a query error. +func IsQueryError(err error) bool { + fe, ok := err.(*internal.FirebaseError) + if !ok { + return false + } + + return fe.ErrorCode == queryError +} diff --git a/dataconnect/dataconnect_test.go b/dataconnect/dataconnect_test.go new file mode 100644 index 00000000..ad29c51d --- /dev/null +++ b/dataconnect/dataconnect_test.go @@ -0,0 +1,262 @@ +// Copyright 2025 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dataconnect + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "reflect" + "strings" + "testing" + + "firebase.google.com/go/v4/internal" + "google.golang.org/api/option" +) + +const ( + testProjectID = "test-project-id" + testLocation = "test-location" + testServiceID = "test-service-id" + testVersion = "test-version" +) + +type FooBar struct { + Foo string `json:"foo"` +} + +func TestNewClient(t *testing.T) { + conf := &internal.DataConnectConfig{ + ProjectID: testProjectID, + Location: testLocation, + ServiceID: testServiceID, + Version: testVersion, + Opts: []option.ClientOption{option.WithoutAuthentication()}, + } + + client, err := NewClient(context.Background(), conf) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + if client.projectID != testProjectID { + t.Errorf("client.projectID = %q; want = %q", client.projectID, testProjectID) + } + if client.location != testLocation { + t.Errorf("client.location = %q; want = %q", client.location, testLocation) + } + if client.serviceID != testServiceID { + t.Errorf("client.serviceID = %q; want = %q", client.serviceID, testServiceID) + } +} + +func TestNewClientEmulator(t *testing.T) { + os.Setenv(emulatorHostEnvVar, "localhost:9099") + defer os.Unsetenv(emulatorHostEnvVar) + + conf := &internal.DataConnectConfig{ + ProjectID: testProjectID, + Location: testLocation, + ServiceID: testServiceID, + Version: testVersion, + Opts: []option.ClientOption{option.WithoutAuthentication()}, + } + + client, err := NewClient(context.Background(), conf) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + if !client.isEmulator { + t.Error("client.isEmulator = false; want = true") + } + if client.emulatorHost != "localhost:9099" { + t.Errorf("client.emulatorHost = %q; want = %q", client.emulatorHost, "localhost:9099") + } +} + +func TestExecuteGraphql(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wantPath := fmt.Sprintf("/%s/projects/%s/locations/%s/services/%s:%s", apiVersion, testProjectID, testLocation, testServiceID, executeGraphqlEndpoint) + if r.Method != http.MethodPost { + t.Errorf("Method = %q; want = %q", r.Method, http.MethodPost) + } + if r.URL.Path != wantPath { + t.Errorf("Path = %q; want = %q", r.URL.Path, wantPath) + } + + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + + var req map[string]interface{} + if err := json.Unmarshal(body, &req); err != nil { + t.Fatal(err) + } + + if req["query"] != "test query" { + t.Errorf("req.query = %q; want = %q", req["query"], "test query") + } + + resp := &internalExecuteGraphqlResponse{ + Data: []byte(`{"foo": "bar"}`), + } + json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + + client, err := newTestClient(ts) + if err != nil { + t.Fatal(err) + } + + var resp FooBar + err = client.ExecuteGraphql(context.Background(), "test query", nil, &resp) + if err != nil { + t.Fatalf("ExecuteGraphql() error = %v", err) + } + + want := FooBar{ + Foo: "bar", + } + if !reflect.DeepEqual(resp, want) { + t.Errorf("ExecuteGraphql() response = %#v; want = %#v", resp, want) + } +} + +func TestExecuteGraphqlRead(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wantPath := fmt.Sprintf("/%s/projects/%s/locations/%s/services/%s:%s", apiVersion, testProjectID, testLocation, testServiceID, executeGraphqlReadEndpoint) + if r.Method != http.MethodPost { + t.Errorf("Method = %q; want = %q", r.Method, http.MethodPost) + } + if r.URL.Path != wantPath { + t.Errorf("Path = %q; want = %q", r.URL.Path, wantPath) + } + resp := &internalExecuteGraphqlResponse{ + Data: []byte(`{"foo": "bar"}`), + } + json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + + client, err := newTestClient(ts) + if err != nil { + t.Fatal(err) + } + + var resp FooBar + err = client.ExecuteGraphqlRead(context.Background(), "test query", nil, &resp) + if err != nil { + t.Fatalf("ExecuteGraphqlRead() error = %v", err) + } + + want := FooBar{ + Foo: "bar", + } + if !reflect.DeepEqual(resp, want) { + t.Errorf("ExecuteGraphqlRead() response = %#v; want = %#v", resp, want) + } +} + +func TestExecuteGraphqlError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":{"message":"test error"}}`)) + })) + defer ts.Close() + + client, err := newTestClient(ts) + if err != nil { + t.Fatal(err) + } + + err = client.ExecuteGraphql(context.Background(), "test query", nil, nil) + if err == nil { + t.Fatal("ExecuteGraphql() error = nil; want error") + } +} + +func TestExecuteGraphqlQueryError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"errors":[{"message":"test query error"}]}`)) + })) + defer ts.Close() + + client, err := newTestClient(ts) + if err != nil { + t.Fatal(err) + } + + err = client.ExecuteGraphql(context.Background(), "test query", nil, nil) + if err == nil { + t.Fatal("ExecuteGraphql() error = nil; want error") + } + + if !IsQueryError(err) { + t.Error("IsQueryError() = false; want = true") + } + + if !strings.Contains(err.Error(), "test query error") { + t.Errorf("error message = %q; want to contain %q", err.Error(), "test query error") + } +} + +func TestIsQueryError(t *testing.T) { + testQueryError := &internal.FirebaseError{ + ErrorCode: queryError, + String: "GraphQL query failed: test", + } + + otherFirebaseError := &internal.FirebaseError{ + ErrorCode: internal.Unknown, + String: "Unknown error", + } + + otherError := fmt.Errorf("some other error") + + if !IsQueryError(testQueryError) { + t.Error("IsQueryError(queryError) = false; want = true") + } + if IsQueryError(otherFirebaseError) { + t.Error("IsQueryError(otherFirebaseError) = true; want = false") + } + if IsQueryError(otherError) { + t.Error("IsQueryError(otherError) = true; want = false") + } +} + +func newTestClient(ts *httptest.Server) (*Client, error) { + emulatorHost := strings.TrimPrefix(ts.URL, "http://") + os.Setenv(emulatorHostEnvVar, emulatorHost) + + conf := &internal.DataConnectConfig{ + ProjectID: testProjectID, + Location: testLocation, + ServiceID: testServiceID, + Version: testVersion, + Opts: []option.ClientOption{option.WithoutAuthentication()}, + } + + client, err := NewClient(context.Background(), conf) + os.Unsetenv(emulatorHostEnvVar) // Clean up env var + return client, err +} diff --git a/firebase.go b/firebase.go index 6101a8d9..d74dc9ce 100644 --- a/firebase.go +++ b/firebase.go @@ -27,6 +27,7 @@ import ( "cloud.google.com/go/firestore" "firebase.google.com/go/v4/appcheck" "firebase.google.com/go/v4/auth" + "firebase.google.com/go/v4/dataconnect" "firebase.google.com/go/v4/db" "firebase.google.com/go/v4/iid" "firebase.google.com/go/v4/internal" @@ -149,6 +150,18 @@ func (a *App) RemoteConfig(ctx context.Context) (*remoteconfig.Client, error) { return remoteconfig.NewClient(ctx, conf) } +// DataConnect returns an instance of the Data Connect client. +func (a *App) DataConnect(ctx context.Context, connectorConfig *dataconnect.ConnectorConfig) (*dataconnect.Client, error) { + conf := &internal.DataConnectConfig{ + ProjectID: a.projectID, + Opts: a.opts, + Version: Version, + Location: connectorConfig.Location, + ServiceID: connectorConfig.ServiceID, + } + return dataconnect.NewClient(ctx, conf) +} + // NewApp creates a new App from the provided config and client options. // // If the client options contain a valid credential (a service account file, a refresh token diff --git a/integration/dataconnect/dataconnect_test.go b/integration/dataconnect/dataconnect_test.go new file mode 100644 index 00000000..dd359576 --- /dev/null +++ b/integration/dataconnect/dataconnect_test.go @@ -0,0 +1,367 @@ +// Copyright 2025 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dataconnect + +import ( + "context" + "flag" + "log" + "os" + "reflect" + "testing" + + "firebase.google.com/go/v4/dataconnect" + "firebase.google.com/go/v4/errorutils" + "firebase.google.com/go/v4/integration/internal" +) + +var client *dataconnect.Client + +var connectorConfig = &dataconnect.ConnectorConfig{ + Location: "us-west2", + ServiceID: "my-service", +} + +/** + * // Schema + * type User @table(key: ["id"]) { + * id: String! + * name: String! + * address: String! + * } + */ +type User struct { + ID string `json:"id"` + Address string `json:"address"` + Name string `json:"name"` + // Generated + EmailsOnFrom []Email `json:"emails_on_from"` +} + +/** + * // Schema + * type Email @table { + * id: String! + * subject: String! + * date: Date! + * text: String! + * from: User! + * } + */ +type Email struct { + ID string `json:"id"` + Subject string `json:"subject"` + Date string `json:"date"` + Text string `json:"text"` + From User `json:"from"` +} + +type GetUserResponse struct { + User User `json:"user"` +} + +type ListUsersResponse struct { + Users []User `json:"users"` +} + +type UserUpsertResponse struct { + UserUpsert struct { + ID string `json:"id"` + } `json:"user_upsert"` +} + +type UserUpdateResponse struct { + UserUpdate struct { + ID string `json:"id"` + } `json:"user_update"` +} + +type EmailUpsertResponse struct { + EmailUpsert struct { + ID string `json:"id"` + } `json:"email_upsert"` +} + +type ListEmailsResponse struct { + Emails []Email `json:"emails"` +} + +type GetUserVariables struct { + ID struct { + ID string `json:"id"` + } `json:"id"` +} + +type DeleteResponse struct { + EmailDeleteMany int `json:"email_deleteMany"` + UserDeleteMany int `json:"user_deleteMany"` +} + +var ( + fredUser = User{ + ID: "fred_id", + Address: "32 Elm St.", + Name: "Fred", + } + + jeffUser = User{ + ID: "jeff_id", + Address: "99 Oak St.", + Name: "Jeff", + } + + fredEmail = Email{ + ID: "email_id", + Subject: "free bitcoin inside", + Date: "1999-12-31", + Text: "get pranked! LOL!", + From: User{ID: fredUser.ID}, + } + + initialState = struct { + Users []User `json:"users"` + Emails []Email `json:"emails"` + }{ + Users: []User{fredUser, jeffUser}, + Emails: []Email{fredEmail}, + } + + queryListUsers string = "query ListUsers @auth(level: PUBLIC) { users { id, name, address } }" + queryListEmails string = "query ListEmails @auth(level: NO_ACCESS) { emails { id subject text date from { id } } }" + queryGetUserByID string = "query GetUser($id: User_Key!) { user(key: $id) { id name address } }" + multipleQueries string = queryListUsers + "\n" + queryListEmails + upsertFredUser string = "mutation user { user_upsert(data: {id: \"" + fredUser.ID + "\", address: \"" + fredUser.Address + "\", name: \"" + fredUser.Name + "\"})}" + upsertJeffUser string = "mutation user { user_upsert(data: {id: \"" + jeffUser.ID + "\", address: \"" + jeffUser.Address + "\", name: \"" + jeffUser.Name + "\"})}" + upsertFredEmail string = "mutation email {" + "email_upsert(data: {" + "id:\"" + fredEmail.ID + "\"," + "subject: \"" + fredEmail.Subject + "\"," + "date: \"" + fredEmail.Date + "\"," + "text: \"" + fredEmail.Text + "\"," + "fromId: \"" + fredEmail.From.ID + "\"" + "})}" + deleteAll string = `mutation delete { email_deleteMany(all: true) user_deleteMany(all: true) }` +) + +func TestMain(m *testing.M) { + flag.Parse() + if testing.Short() { + log.Println("Skipping dataconnect integration tests in short mode.") + return + } + + ctx := context.Background() + var err error + app, err := internal.NewTestApp(ctx, nil) + if err != nil { + log.Fatalln(err) + } + + client, err = app.DataConnect(context.Background(), connectorConfig) + if err != nil { + log.Fatalf("app.DataConnect() = %v", err) + } + os.Exit(m.Run()) +} + +func initializeDatabase(t *testing.T) { + var resp1 UserUpsertResponse + err := client.ExecuteGraphql(context.Background(), upsertFredUser, nil, &resp1) + if err != nil { + t.Fatalf("ExecuteGraphql() error = %v", err) + } + if resp1.UserUpsert.ID != fredUser.ID { + t.Errorf("ExecuteGraphql() User = %#v; want = %#v", resp1.UserUpsert.ID, fredUser.ID) + } + + var resp2 UserUpsertResponse + err = client.ExecuteGraphql(context.Background(), upsertJeffUser, nil, &resp2) + if err != nil { + t.Fatalf("ExecuteGraphql() error = %v", err) + } + + var resp3 EmailUpsertResponse + err = client.ExecuteGraphql(context.Background(), upsertFredEmail, nil, &resp3) + if err != nil { + t.Fatalf("ExecuteGraphql() error = %v", err) + } +} + +func cleanupDatabase(t *testing.T) { + var resp1 DeleteResponse + err := client.ExecuteGraphql(context.Background(), deleteAll, nil, &resp1) + if err != nil { + t.Fatalf("ExecuteGraphql() error = %v", err) + } +} + +func containsExpectedUser(users []User, expectedUser User) bool { + for _, user := range users { + if reflect.DeepEqual(user, expectedUser) { + return true + } + } + return false +} +func TestExecuteGraphql(t *testing.T) { + initializeDatabase(t) + // defer cleanupDatabase(t) + + var resp ListUsersResponse + err := client.ExecuteGraphql(context.Background(), queryListUsers, nil, &resp) + if err != nil { + t.Fatalf("ExecuteGraphql() error = %v", err) + } + + if len(resp.Users) != len(initialState.Users) { + t.Errorf("len(resp.Users) = %d; want > %d", len(resp.Users), len(initialState.Users)) + } + + for _, user := range resp.Users { + if !containsExpectedUser(initialState.Users, user) { + t.Errorf("User from response was not found in expected initial state: %#v", user) + } + } +} + +func TestExecuteGraphqlRead(t *testing.T) { + initializeDatabase(t) + defer cleanupDatabase(t) + + var resp ListUsersResponse + err := client.ExecuteGraphqlRead(context.Background(), queryListUsers, nil, &resp) + if err != nil { + t.Fatalf("ExecuteGraphqlRead() error = %v", err) + } + + if resp.Users == nil { + t.Fatal("response data does not contain 'users' key") + } + if len(resp.Users) != len(initialState.Users) { + t.Errorf("len(resp.Users) = %d; want > %d", len(resp.Users), len(initialState.Users)) + } + + for _, user := range resp.Users { + if !containsExpectedUser(initialState.Users, user) { + t.Errorf("User from response was not found in expected initial state: %#v", user) + } + } +} + +func TestExecuteGraphqlWithVariables(t *testing.T) { + initializeDatabase(t) + defer cleanupDatabase(t) + + var resp GetUserResponse + opts := &dataconnect.GraphqlOptions{ + Variables: GetUserVariables{ + ID: struct { + ID string `json:"id"` + }{ + ID: initialState.Users[0].ID, + }, + }, + } + err := client.ExecuteGraphql(context.Background(), queryGetUserByID, opts, &resp) + if err != nil { + t.Fatalf("ExecuteGraphql() error = %v", err) + } + + if !reflect.DeepEqual(resp.User, initialState.Users[0]) { + t.Errorf("ExecuteGraphql() User = %#v; want = %#v", resp.User, initialState.Users[0]) + } +} + +func TestExecuteGraphqlMutation(t *testing.T) { + initializeDatabase(t) + defer cleanupDatabase(t) + + var resp1 UserUpsertResponse + err := client.ExecuteGraphql(context.Background(), upsertFredUser, nil, &resp1) + if err != nil { + t.Fatalf("ExecuteGraphql() error = %v", err) + } + if resp1.UserUpsert.ID != fredUser.ID { + t.Errorf("ExecuteGraphql() User = %#v; want = %#v", resp1.UserUpsert.ID, fredUser.ID) + } + + var resp2 UserUpsertResponse + err = client.ExecuteGraphql(context.Background(), upsertJeffUser, nil, &resp2) + if err != nil { + t.Fatalf("ExecuteGraphql() error = %v", err) + } + if resp2.UserUpsert.ID != jeffUser.ID { + t.Errorf("ExecuteGraphql() User = %#v; want = %#v", resp2.UserUpsert.ID, jeffUser.ID) + } + + var resp3 EmailUpsertResponse + err = client.ExecuteGraphql(context.Background(), upsertFredEmail, nil, &resp3) + if err != nil { + t.Fatalf("ExecuteGraphql() error = %v", err) + } + if resp3.EmailUpsert.ID == "" { + t.Errorf("ExecuteGraphql() Email = %#v; Expected non-empty ID string", resp3.EmailUpsert.ID) + } + + var resp4 DeleteResponse + err = client.ExecuteGraphql(context.Background(), deleteAll, nil, &resp4) + if err != nil { + t.Fatalf("ExecuteGraphql() error = %v", err) + } + if resp4.UserDeleteMany == 0 { + t.Errorf("ExecuteGraphql() Expected non-zero users deleted") + } + if resp4.EmailDeleteMany == 0 { + t.Errorf("ExecuteGraphql() Expected non-zero emails deleted") + } +} + +func TestExecuteGraphqlOperationNameWithMultipleQueries(t *testing.T) { + initializeDatabase(t) + defer cleanupDatabase(t) + + opts := &dataconnect.GraphqlOptions{ + OperationName: "ListEmails", + } + + var resp ListEmailsResponse + err := client.ExecuteGraphql(context.Background(), multipleQueries, opts, &resp) + if err != nil { + t.Fatalf("ExecuteGraphql() error = %v", err) + } + if !reflect.DeepEqual(resp.Emails, initialState.Emails) { + t.Errorf("ExecuteGraphql() Emails = %#v; want = %#v", resp.Emails, initialState.Emails) + } +} + +func TestExecuteGraphqlReadMutationError(t *testing.T) { + initializeDatabase(t) + defer cleanupDatabase(t) + var resp UserUpsertResponse + err := client.ExecuteGraphqlRead(context.Background(), upsertFredUser, nil, &resp) + if err == nil { + t.Fatalf("ExecuteGraphqlRead() expected error for read mutation, got nil") + } + if !errorutils.IsPermissionDenied(err) { + t.Fatalf("ExecuteGraphqlRead() expected Permission Denied error for read mutation, got %s", err) + } +} + +func TestExecuteGraphqlQueryErrorWithoutVariables(t *testing.T) { + initializeDatabase(t) + defer cleanupDatabase(t) + + var resp GetUserResponse + err := client.ExecuteGraphql(context.Background(), queryGetUserByID, nil, &resp) + if err == nil { + t.Fatalf("ExecuteGraphql() expected error for bad query, got nil") + } + if !dataconnect.IsQueryError(err) { + t.Fatalf("ExecuteGraphql() expected query error, got %s", err) + } +} diff --git a/internal/internal.go b/internal/internal.go index a6eb1294..54fa720b 100644 --- a/internal/internal.go +++ b/internal/internal.go @@ -86,6 +86,15 @@ type AppCheckConfig struct { ProjectID string } +// DataConnectConfig represents the configuration of the Data Connect service. +type DataConnectConfig struct { + Opts []option.ClientOption + ProjectID string + Version string + Location string + ServiceID string +} + // MockTokenSource is a TokenSource implementation that can be used for testing. type MockTokenSource struct { AccessToken string diff --git a/testdata/dataconnect/dataconnect/dataconnect.yaml b/testdata/dataconnect/dataconnect/dataconnect.yaml new file mode 100644 index 00000000..2f5b815b --- /dev/null +++ b/testdata/dataconnect/dataconnect/dataconnect.yaml @@ -0,0 +1,13 @@ +specVersion: "v1" +serviceId: "my-service" +location: "us-west2" +schema: + source: "./schema" + datasource: + postgresql: + database: "my-database" + cloudSql: + instanceId: "my-instance" + # schemaValidation: "STRICT" # STRICT mode makes Postgres schema match Data Connect exactly. + # schemaValidation: "COMPATIBLE" # COMPATIBLE mode makes Postgres schema compatible with Data Connect. +connectorDirs: ["./my-connector"] diff --git a/testdata/dataconnect/dataconnect/my-connector/connector.yaml b/testdata/dataconnect/dataconnect/my-connector/connector.yaml new file mode 100644 index 00000000..3b1bcdcc --- /dev/null +++ b/testdata/dataconnect/dataconnect/my-connector/connector.yaml @@ -0,0 +1 @@ +connectorId: "my-connector" diff --git a/testdata/dataconnect/dataconnect/my-connector/mutations.gql b/testdata/dataconnect/dataconnect/my-connector/mutations.gql new file mode 100644 index 00000000..10a87088 --- /dev/null +++ b/testdata/dataconnect/dataconnect/my-connector/mutations.gql @@ -0,0 +1,95 @@ +mutation upsertFredUser @auth(level: NO_ACCESS) { + user_upsert(data: { id: "fred_id", address: "32 Elm St.", name: "Fred" }) +} +mutation updateFredrickUserImpersonation @auth(level: USER) { + user_update( + key: { id_expr: "auth.uid" } + data: { address: "64 Elm St. North", name: "Fredrick" } + ) +} +mutation upsertJeffUser @auth(level: NO_ACCESS) { + user_upsert(data: { id: "jeff_id", address: "99 Oak St.", name: "Jeff" }) +} + +mutation upsertJeffEmail @auth(level: NO_ACCESS) { + email_upsert( + data: { + id: "jeff_email_id" + subject: "free bitcoin inside" + date: "1999-12-31" + text: "get pranked! LOL!" + fromId: "jeff_id" + } + ) +} + +mutation InsertEmailPublic($id: String!) +@auth(level: PUBLIC, insecureReason: "test") { + email_insert( + data: { + id: $id + subject: "PublicEmail" + date: "1999-12-31" + text: "PublicEmail" + fromId: "jeff_id" + } + ) +} +mutation InsertEmailUserAnon($id: String!) +@auth(level: USER_ANON, insecureReason: "test") { + email_insert( + data: { + id: $id + subject: "UserAnonEmail" + date: "1999-12-31" + text: "UserAnonEmail" + fromId: "jeff_id" + } + ) +} +mutation InsertEmailUser($id: String!) +@auth(level: USER, insecureReason: "test") { + email_insert( + data: { + id: $id + subject: "UserEmail" + date: "1999-12-31" + text: "UserEmail" + fromId: "jeff_id" + } + ) +} +mutation InsertEmailUserEmailVerified($id: String!) +@auth(level: USER_EMAIL_VERIFIED, insecureReason: "test") { + email_insert( + data: { + id: $id + subject: "UserEmailVerifiedEmail" + date: "1999-12-31" + text: "UserEmailVerifiedEmail" + fromId: "jeff_id" + } + ) +} +mutation InsertEmailNoAccess($id: String!) @auth(level: NO_ACCESS) { + email_insert( + data: { + id: $id + subject: "NoAccessEmail" + date: "1999-12-31" + text: "NoAccessEmail" + fromId: "jeff_id" + } + ) +} +mutation InsertEmailImpersonation($id: String!) @auth(level: NO_ACCESS) { + email_insert( + data: { + id: $id + subject: "ImpersonatedEmail" + date: "1999-12-31" + text: "ImpersonatedEmail" + fromId_expr: "auth.uid" + } + ) +} diff --git a/testdata/dataconnect/dataconnect/my-connector/queries.gql b/testdata/dataconnect/dataconnect/my-connector/queries.gql new file mode 100644 index 00000000..b93da418 --- /dev/null +++ b/testdata/dataconnect/dataconnect/my-connector/queries.gql @@ -0,0 +1,74 @@ +query ListUsersPublic @auth(level: PUBLIC, insecureReason: "test") { + users { + id + name + address + } +} +query ListUsersUserAnon @auth(level: USER_ANON, insecureReason: "test") { + users { + id + name + address + } +} +query ListUsersUser @auth(level: USER, insecureReason: "test") { + users { + id + name + address + } +} +query ListUsersUserEmailVerified +@auth(level: USER_EMAIL_VERIFIED, insecureReason: "test") { + users { + id + name + address + } +} +query ListUsersNoAccess @auth(level: NO_ACCESS) { + users { + id + name + address + } +} +query ListUsersImpersonationAnon @auth(level: USER_ANON) { + users(where: { id: { eq_expr: "auth.uid" } }) { + id + name + address + } +} +query GetUser($id: User_Key!) @auth(level: NO_ACCESS) { + user(key: $id) { + id + name + } +} + +query ListEmails @auth(level: NO_ACCESS) { + emails { + id + subject + text + date + from { + name + } + } +} +query GetEmail($id: String!) @auth(level: NO_ACCESS) { + email(id: $id) { + id + subject + date + text + from { + id + name + address + } + } +} diff --git a/testdata/dataconnect/dataconnect/schema/schema.gql b/testdata/dataconnect/dataconnect/schema/schema.gql new file mode 100644 index 00000000..1f390b31 --- /dev/null +++ b/testdata/dataconnect/dataconnect/schema/schema.gql @@ -0,0 +1,13 @@ +type User @table(key: ["id"]) { + id: String! + name: String! + address: String! +} + +type Email @table { + id: String! + subject: String! + date: Date! + text: String! + from: User! +} diff --git a/testdata/dataconnect/firebase.json b/testdata/dataconnect/firebase.json new file mode 100644 index 00000000..73f59971 --- /dev/null +++ b/testdata/dataconnect/firebase.json @@ -0,0 +1,5 @@ +{ + "dataconnect": { + "source": "dataconnect" + } +}