From 7edaf1423dfe7d9bfb42d9c955eafe7b6307c5ea Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 16 Sep 2025 14:10:23 +0000 Subject: [PATCH 1/9] feat(fdc): Add Firebase Data Connect service This commit introduces the Firebase Data Connect service to the Go Admin SDK. It adds a new `dataconnect` package with a `Client` for interacting with the Data Connect API. The client supports executing GraphQL queries and mutations via the `ExecuteGraphql` and `ExecuteGraphqlRead` methods. Key features include: - A new `DataConnect` method on the `firebase.App` to get a service client. - Custom error handling to parse GraphQL errors from successful (200 OK) HTTP responses. - A public `IsQueryError` function to check for this specific error type. - Unit tests covering the new functionality. - An integration test demonstrating usage with placeholder values. --- dataconnect/dataconnect.go | 169 ++++++++++++++ dataconnect/dataconnect_test.go | 246 ++++++++++++++++++++ dataconnect/types.go | 18 ++ firebase.go | 13 ++ integration/dataconnect/dataconnect_test.go | 75 ++++++ internal/internal.go | 9 + 6 files changed, 530 insertions(+) create mode 100644 dataconnect/dataconnect.go create mode 100644 dataconnect/dataconnect_test.go create mode 100644 dataconnect/types.go create mode 100644 integration/dataconnect/dataconnect_test.go diff --git a/dataconnect/dataconnect.go b/dataconnect/dataconnect.go new file mode 100644 index 00000000..b8fbb100 --- /dev/null +++ b/dataconnect/dataconnect.go @@ -0,0 +1,169 @@ +// Package dataconnect provides functions for interacting with the Firebase Data Connect service. +package dataconnect + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + + "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" + queryErrorCode = "query-error" +) + +// 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 !internal.HasSuccessStatus(r) { + return false + } + var errResp graphqlErrorResponse + if err := json.Unmarshal(r.Body, &errResp); err != nil { + return true // Cannot parse, assume 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) (*ExecuteGraphqlResponse, error) { + return c.execute(ctx, executeGraphqlEndpoint, query, options) +} + +// ExecuteGraphqlRead executes a GraphQL read-only query. +func (c *Client) ExecuteGraphqlRead(ctx context.Context, query string, options *GraphqlOptions) (*ExecuteGraphqlResponse, error) { + return c.execute(ctx, executeGraphqlReadEndpoint, query, options) +} + +func (c *Client) execute(ctx context.Context, endpoint, query string, options *GraphqlOptions) (*ExecuteGraphqlResponse, 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 ExecuteGraphqlResponse + request := &internal.Request{ + Method: http.MethodPost, + URL: url, + Body: internal.NewJSONEntity(req), + } + _, err := c.client.DoAndUnmarshal(ctx, request, &result) + if err != nil { + return nil, err + } + + return &result, 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 graphqlError struct { + Message string `json:"message"` +} + +type graphqlErrorResponse struct { + Errors []graphqlError `json:"errors"` +} + +func handleError(resp *internal.Response) error { + if resp.Status == 200 { + var errResp graphqlErrorResponse + // This will be called only when SuccessFn returns false, so we know there's an errors field. + // We can ignore the unmarshal error here as it's handled in SuccessFn. + json.Unmarshal(resp.Body, &errResp) + + var messages []string + for _, e := range errResp.Errors { + messages = append(messages, e.Message) + } + fe := internal.NewFirebaseError(resp) + fe.ErrorCode = internal.InvalidArgument + fe.String = fmt.Sprintf("GraphQL query failed: %s", strings.Join(messages, "; ")) + if fe.Ext == nil { + fe.Ext = make(map[string]interface{}) + } + fe.Ext["dataconnectErrorCode"] = queryErrorCode + return fe + } + return internal.NewFirebaseError(resp) +} + +// IsQueryError checks if the given error is a query error. +func IsQueryError(err error) bool { + fe, ok := err.(*internal.FirebaseError) + if !ok { + return false + } + + got, ok := fe.Ext["dataconnectErrorCode"] + return ok && got == queryErrorCode +} diff --git a/dataconnect/dataconnect_test.go b/dataconnect/dataconnect_test.go new file mode 100644 index 00000000..28dab460 --- /dev/null +++ b/dataconnect/dataconnect_test.go @@ -0,0 +1,246 @@ +// Package dataconnect provides functions for interacting with the Firebase Data Connect service. +package dataconnect + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "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" +) + +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 := ioutil.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 := &ExecuteGraphqlResponse{ + Data: map[string]interface{}{"foo": "bar"}, + } + json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + + client, err := newTestClient(ts) + if err != nil { + t.Fatal(err) + } + + resp, err := client.ExecuteGraphql(context.Background(), "test query", nil) + if err != nil { + t.Fatalf("ExecuteGraphql() error = %v", err) + } + + want := &ExecuteGraphqlResponse{ + Data: map[string]interface{}{"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 := &ExecuteGraphqlResponse{ + Data: map[string]interface{}{"foo": "bar"}, + } + json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + + client, err := newTestClient(ts) + if err != nil { + t.Fatal(err) + } + + resp, err := client.ExecuteGraphqlRead(context.Background(), "test query", nil) + if err != nil { + t.Fatalf("ExecuteGraphqlRead() error = %v", err) + } + + want := &ExecuteGraphqlResponse{ + Data: map[string]interface{}{"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) + 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) + 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) { + queryError := &internal.FirebaseError{ + ErrorCode: internal.InvalidArgument, + String: "GraphQL query failed: test", + Ext: map[string]interface{}{ + "dataconnectErrorCode": queryErrorCode, + }, + } + + otherFirebaseError := &internal.FirebaseError{ + ErrorCode: internal.Unknown, + String: "Unknown error", + } + + otherError := fmt.Errorf("some other error") + + if !IsQueryError(queryError) { + 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/dataconnect/types.go b/dataconnect/types.go new file mode 100644 index 00000000..207dcee6 --- /dev/null +++ b/dataconnect/types.go @@ -0,0 +1,18 @@ +package dataconnect + +// 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 map[string]interface{} `json:"variables,omitempty"` + OperationName string `json:"operationName,omitempty"` +} + +// ExecuteGraphqlResponse is the response from a GraphQL query. +type ExecuteGraphqlResponse struct { + Data map[string]interface{} `json:"data"` +} 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..8d7edf0f --- /dev/null +++ b/integration/dataconnect/dataconnect_test.go @@ -0,0 +1,75 @@ +// 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_test contains integration tests for the dataconnect package. +package dataconnect_test + +import ( + "context" + "testing" + + "firebase.google.com/go/v4" + "firebase.google.com/go/v4/dataconnect" + "firebase.google.com/go/v4/integration/internal" +) + +// This test is not expected to run in the CI environment. It is provided +// as a usage example and for manual testing. +func TestDataConnect(t *testing.T) { + app, err := internal.NewTestApp(context.Background()) + if err != nil { + t.Fatalf("internal.NewTestApp() = %v", err) + } + + connectorConfig := &dataconnect.ConnectorConfig{ + Location: "us-central1", + ServiceID: "my-service", + } + + client, err := app.DataConnect(context.Background(), connectorConfig) + if err != nil { + t.Fatalf("app.DataConnect() = %v", err) + } + + query := ` + query { + users { + id + name + } + } + ` + + // Test ExecuteGraphqlRead + _, err = client.ExecuteGraphqlRead(context.Background(), query, nil) + if err != nil { + // We expect an error here as we are not running against a real backend. + // The purpose of this test is to ensure the API is wired up correctly. + t.Logf("ExecuteGraphqlRead() returned an expected error: %v", err) + } + + mutation := ` + mutation { + createUser(name: "test-user") { + id + } + } + ` + // Test ExecuteGraphql + _, err = client.ExecuteGraphql(context.Background(), mutation, nil) + if err != nil { + // We expect an error here as we are not running against a real backend. + t.Logf("ExecuteGraphql() returned an expected error: %v", 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 From 8984b88922050ccfd5e1f12dfd2decda1e5a25ea Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Tue, 16 Sep 2025 16:31:28 -0400 Subject: [PATCH 2/9] fix(fdc): Refactor Dataconnect types into and update query error code --- dataconnect/dataconnect.go | 25 ++++++++++++++++++++++--- dataconnect/dataconnect_test.go | 2 +- dataconnect/types.go | 18 ------------------ 3 files changed, 23 insertions(+), 22 deletions(-) delete mode 100644 dataconnect/types.go diff --git a/dataconnect/dataconnect.go b/dataconnect/dataconnect.go index b8fbb100..33411afe 100644 --- a/dataconnect/dataconnect.go +++ b/dataconnect/dataconnect.go @@ -21,9 +21,28 @@ const ( apiVersion = "v1alpha" executeGraphqlEndpoint = "executeGraphql" executeGraphqlReadEndpoint = "executeGraphqlRead" - queryErrorCode = "query-error" + + // 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 map[string]interface{} `json:"variables,omitempty"` + OperationName string `json:"operationName,omitempty"` +} + +// ExecuteGraphqlResponse is the response from a GraphQL query. +type ExecuteGraphqlResponse struct { + Data map[string]interface{} `json:"data"` +} + // Client is the interface for the Firebase Data Connect service. type Client struct { client *internal.HTTPClient @@ -151,7 +170,7 @@ func handleError(resp *internal.Response) error { if fe.Ext == nil { fe.Ext = make(map[string]interface{}) } - fe.Ext["dataconnectErrorCode"] = queryErrorCode + fe.Ext["dataconnectErrorCode"] = queryError return fe } return internal.NewFirebaseError(resp) @@ -165,5 +184,5 @@ func IsQueryError(err error) bool { } got, ok := fe.Ext["dataconnectErrorCode"] - return ok && got == queryErrorCode + return ok && got == queryError } diff --git a/dataconnect/dataconnect_test.go b/dataconnect/dataconnect_test.go index 28dab460..73743beb 100644 --- a/dataconnect/dataconnect_test.go +++ b/dataconnect/dataconnect_test.go @@ -206,7 +206,7 @@ func TestIsQueryError(t *testing.T) { ErrorCode: internal.InvalidArgument, String: "GraphQL query failed: test", Ext: map[string]interface{}{ - "dataconnectErrorCode": queryErrorCode, + "dataconnectErrorCode": queryError, }, } diff --git a/dataconnect/types.go b/dataconnect/types.go deleted file mode 100644 index 207dcee6..00000000 --- a/dataconnect/types.go +++ /dev/null @@ -1,18 +0,0 @@ -package dataconnect - -// 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 map[string]interface{} `json:"variables,omitempty"` - OperationName string `json:"operationName,omitempty"` -} - -// ExecuteGraphqlResponse is the response from a GraphQL query. -type ExecuteGraphqlResponse struct { - Data map[string]interface{} `json:"data"` -} From a40c03fb23fc0412afaba81b854d9503bd9b6d48 Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Tue, 16 Sep 2025 16:34:45 -0400 Subject: [PATCH 3/9] chore(fdc): Add integration tests --- integration/dataconnect/dataconnect_test.go | 282 +++++++++++++++++--- 1 file changed, 248 insertions(+), 34 deletions(-) diff --git a/integration/dataconnect/dataconnect_test.go b/integration/dataconnect/dataconnect_test.go index 8d7edf0f..36abff25 100644 --- a/integration/dataconnect/dataconnect_test.go +++ b/integration/dataconnect/dataconnect_test.go @@ -13,63 +13,277 @@ // limitations under the License. // Package dataconnect_test contains integration tests for the dataconnect package. -package dataconnect_test +package dataconnect import ( "context" + "flag" + "log" + "os" + "reflect" "testing" - "firebase.google.com/go/v4" "firebase.google.com/go/v4/dataconnect" + "firebase.google.com/go/v4/errorutils" "firebase.google.com/go/v4/integration/internal" ) -// This test is not expected to run in the CI environment. It is provided -// as a usage example and for manual testing. -func TestDataConnect(t *testing.T) { - app, err := internal.NewTestApp(context.Background()) +var client *dataconnect.Client + +var connectorConfig = &dataconnect.ConnectorConfig{ + Location: "us-west2", + ServiceID: "my-service", +} + +const ( + userId string = "QVBJcy5ndXJ3" + + queryListUsers string = "query ListUsers @auth(level: PUBLIC) { users { uid, name, address } }" + queryListEmails string = "query ListEmails @auth(level: NO_ACCESS) { emails { id subject text date from { name } } }" + queryGetUserById string = "query GetUser($id: User_Key!) { user(key: $id) { uid name } }" + mutation string = "mutation user { user_insert(data: {uid: \"" + userId + "\", address: \"32 St\", name: \"Fred Car\"}) }" + upsertUser string = "mutation UpsertUser($id: String) { user_upsert(data: { uid: $id, address: \"32 St.\", name: \"Fred\" }) }" + multipleQueries string = queryListUsers + "\n" + queryListEmails +) + +var ( + testUser = map[string]interface{}{ + "name": "Fred", + "address": "32 St.", + "uid": userId, + } + + expectedUsers = []map[string]interface{}{ + testUser, + { + "name": "Jeff", + "address": "99 Oak St. N", + "uid": "QVBJcy5ndXJ1", + }, + } +) + +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 { - t.Fatalf("internal.NewTestApp() = %v", err) + log.Fatalln(err) } - connectorConfig := &dataconnect.ConnectorConfig{ - Location: "us-central1", - ServiceID: "my-service", + client, err = app.DataConnect(context.Background(), connectorConfig) + if err != nil { + log.Fatalf("app.DataConnect() = %v", err) + } + os.Exit(m.Run()) +} + +// type User struct { +// UID string `json:"uid"` +// Name string `json:"name"` +// Address string `json:"address"` + +// // Generated +// EmailsOnFrom []Email `json:"emails_on_from"` +// } + +// type Email struct { +// Subject string `json:"subject"` +// Date string `json:"date"` +// Text string `json:"text"` +// From string `json:"from"` + +// // Generated +// ID string `json:"id"` +// } + +func containsExpectedUser(usersSlice []interface{}, expectedUser map[string]interface{}) bool { + for _, item := range usersSlice { + userMap, ok := item.(map[string]interface{}) + if !ok { + // Item in slice is not the expected type, so it can't be a match + continue + } + if reflect.DeepEqual(userMap, expectedUser) { + return true + } } + return false +} - client, err := app.DataConnect(context.Background(), connectorConfig) +func TestExecuteGraphqlRead(t *testing.T) { + resp, err := client.ExecuteGraphqlRead(context.Background(), queryListUsers, nil) if err != nil { - t.Fatalf("app.DataConnect() = %v", err) + t.Fatalf("ExecuteGraphqlRead() error = %v", err) + } + + if resp.Data == nil { + t.Errorf("resp.Data is empty") + } + users, ok := resp.Data["users"] + if !ok { + t.Fatal("response data does not contain 'users' key") + } + usersSlice, ok := users.([]interface{}) + if !ok { + t.Fatal("'users' field is not a slice") + } + if len(usersSlice) <= 1 { + t.Errorf("len(resp.Data[\"users\"]) = %d; want > 1", len(usersSlice)) } - query := ` - query { - users { - id - name - } + for _, expectedUser := range expectedUsers { + if !containsExpectedUser(usersSlice, expectedUser) { + t.Errorf("ExecuteGraphqlRead() response data does not contain expected user: %#v", expectedUser) } - ` + } +} + +func TestExecuteGraphqlReadMutation(t *testing.T) { + _, err := client.ExecuteGraphqlRead(context.Background(), mutation, nil) + 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 TestExecuteGraphqlQueryError(t *testing.T) { + _, err := client.ExecuteGraphql(context.Background(), mutation, nil) + 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) + } +} - // Test ExecuteGraphqlRead - _, err = client.ExecuteGraphqlRead(context.Background(), query, nil) +func TestExecuteGraphqlMutation(t *testing.T) { + opts := &dataconnect.GraphqlOptions{ + Variables: map[string]interface{}{ + "id": userId, + }, + } + resp, err := client.ExecuteGraphql(context.Background(), upsertUser, opts) if err != nil { - // We expect an error here as we are not running against a real backend. - // The purpose of this test is to ensure the API is wired up correctly. - t.Logf("ExecuteGraphqlRead() returned an expected error: %v", err) + t.Fatalf("ExecuteGraphql() error = %v", err) + } + want := &dataconnect.ExecuteGraphqlResponse{ + Data: map[string]interface{}{ + "user_upsert": map[string]interface{}{ + "uid": userId, + }, + }, + } + if resp.Data == nil { + t.Errorf("resp.Data is empty") + } + if !reflect.DeepEqual(resp, want) { + t.Errorf("ExecuteGraphql() response = %#v; want = %#v", resp, want) } +} - mutation := ` - mutation { - createUser(name: "test-user") { - id - } +func TestExecuteGraphqlListUsers(t *testing.T) { + resp, err := client.ExecuteGraphql(context.Background(), queryListUsers, nil) + if err != nil { + t.Fatalf("ExecuteGraphql() error = %v", err) + } + if resp.Data == nil { + t.Errorf("resp.Data is empty") + } + users, ok := resp.Data["users"] + if !ok { + t.Fatal("response data does not contain 'users' key") + } + usersSlice, ok := users.([]interface{}) + if !ok { + t.Fatal("'users' field is not a slice") + } + if len(usersSlice) <= 1 { + t.Errorf("len(resp.Data[\"users\"]) = %d; want > 1", len(usersSlice)) + } + + for _, expectedUser := range expectedUsers { + if !containsExpectedUser(usersSlice, expectedUser) { + t.Errorf("ExecuteGraphql() response data does not contain expected user: %#v", expectedUser) } - ` - // Test ExecuteGraphql - _, err = client.ExecuteGraphql(context.Background(), mutation, nil) + } +} + +func TestExecuteGraphqlWithVariables(t *testing.T) { + opts := &dataconnect.GraphqlOptions{ + Variables: map[string]interface{}{ + "id": map[string]interface{}{ + "uid": userId, + }, + }, + } + resp, err := client.ExecuteGraphql(context.Background(), queryGetUserById, opts) + if err != nil { + t.Fatalf("ExecuteGraphql() with variables error = %v", err) + } + + want := &dataconnect.ExecuteGraphqlResponse{ + Data: map[string]interface{}{ + "user": map[string]interface{}{ + "uid": testUser["uid"], + "name": testUser["name"], + }, + }, + } + + if resp.Data == nil { + t.Errorf("resp.Data is empty") + } + if !reflect.DeepEqual(resp, want) { + t.Errorf("ExecuteGraphql() response = %#v; want = %#v", resp, want) + } +} + +func TestExecuteGraphqlWithOperationName(t *testing.T) { + opts := &dataconnect.GraphqlOptions{ + OperationName: "ListEmails", + } + resp, err := client.ExecuteGraphql(context.Background(), multipleQueries, opts) if err != nil { - // We expect an error here as we are not running against a real backend. - t.Logf("ExecuteGraphql() returned an expected error: %v", err) + t.Fatalf("ExecuteGraphql() with operationName error = %v", err) + } + + if resp.Data == nil { + t.Errorf("resp.Data is empty") + } + + emails, ok := resp.Data["emails"] + if !ok { + t.Fatal("response data does not contain 'emails' key") + } + emailsSlice, ok := emails.([]interface{}) + if !ok { + t.Fatal("'emails' field is not a slice") + } + if len(emailsSlice) != 1 { + t.Fatalf("len(emails) = %d; want 1", len(emailsSlice)) + } + email, ok := emailsSlice[0].(map[string]interface{}) + if !ok { + t.Fatal("email item is not a map") + } + + if email["id"] == nil { + t.Error("email.id is nil, expected not undefined") + } + from, ok := email["from"].(map[string]interface{}) + if !ok { + t.Fatal("email.from is not a map") + } + if from["name"] != "Jeff" { + t.Errorf("email.from.name = %q; want \"Jeff\"", from["name"]) } } From 0e147580e255e5e82e73797e74b0c1d7e72b7b0d Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Wed, 17 Sep 2025 12:13:31 -0400 Subject: [PATCH 4/9] fix(fdc): Simplify error logic --- dataconnect/dataconnect.go | 43 ++++++++++----------------------- dataconnect/dataconnect_test.go | 9 +++---- 2 files changed, 16 insertions(+), 36 deletions(-) diff --git a/dataconnect/dataconnect.go b/dataconnect/dataconnect.go index 33411afe..26a4099b 100644 --- a/dataconnect/dataconnect.go +++ b/dataconnect/dataconnect.go @@ -7,7 +7,6 @@ import ( "fmt" "net/http" "os" - "strings" "firebase.google.com/go/v4/internal" "google.golang.org/api/option" @@ -75,12 +74,14 @@ func NewClient(ctx context.Context, conf *internal.DataConnectConfig) (*Client, 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 } - var errResp graphqlErrorResponse + // 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 success + return true // Cannot parse, assume no query errors and thus success } return len(errResp.Errors) == 0 } @@ -145,35 +146,18 @@ func (c *Client) buildURL(endpoint string) string { return fmt.Sprintf(dataConnectProdURLFormat, apiVersion, c.projectID, c.location, c.serviceID, endpoint) } -type graphqlError struct { - Message string `json:"message"` -} - -type graphqlErrorResponse struct { - Errors []graphqlError `json:"errors"` +type graphqlQueryErrorResponse struct { + Errors []map[string]interface{} `json:"errors"` } func handleError(resp *internal.Response) error { - if resp.Status == 200 { - var errResp graphqlErrorResponse - // This will be called only when SuccessFn returns false, so we know there's an errors field. - // We can ignore the unmarshal error here as it's handled in SuccessFn. - json.Unmarshal(resp.Body, &errResp) - - var messages []string - for _, e := range errResp.Errors { - messages = append(messages, e.Message) - } - fe := internal.NewFirebaseError(resp) - fe.ErrorCode = internal.InvalidArgument - fe.String = fmt.Sprintf("GraphQL query failed: %s", strings.Join(messages, "; ")) - if fe.Ext == nil { - fe.Ext = make(map[string]interface{}) - } - fe.Ext["dataconnectErrorCode"] = queryError - return fe + 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 internal.NewFirebaseError(resp) + return fe } // IsQueryError checks if the given error is a query error. @@ -183,6 +167,5 @@ func IsQueryError(err error) bool { return false } - got, ok := fe.Ext["dataconnectErrorCode"] - return ok && got == queryError + return fe.ErrorCode == queryError } diff --git a/dataconnect/dataconnect_test.go b/dataconnect/dataconnect_test.go index 73743beb..91d4f450 100644 --- a/dataconnect/dataconnect_test.go +++ b/dataconnect/dataconnect_test.go @@ -202,12 +202,9 @@ func TestExecuteGraphqlQueryError(t *testing.T) { } func TestIsQueryError(t *testing.T) { - queryError := &internal.FirebaseError{ - ErrorCode: internal.InvalidArgument, + testQueryError := &internal.FirebaseError{ + ErrorCode: queryError, String: "GraphQL query failed: test", - Ext: map[string]interface{}{ - "dataconnectErrorCode": queryError, - }, } otherFirebaseError := &internal.FirebaseError{ @@ -217,7 +214,7 @@ func TestIsQueryError(t *testing.T) { otherError := fmt.Errorf("some other error") - if !IsQueryError(queryError) { + if !IsQueryError(testQueryError) { t.Error("IsQueryError(queryError) = false; want = true") } if IsQueryError(otherFirebaseError) { From 7bcdb31374ee04ad3c0c11475d8c886645ed196d Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Wed, 17 Sep 2025 12:36:37 -0400 Subject: [PATCH 5/9] fix(fdc): Fixed lint --- integration/dataconnect/dataconnect_test.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/integration/dataconnect/dataconnect_test.go b/integration/dataconnect/dataconnect_test.go index 36abff25..f6b73a2c 100644 --- a/integration/dataconnect/dataconnect_test.go +++ b/integration/dataconnect/dataconnect_test.go @@ -36,12 +36,12 @@ var connectorConfig = &dataconnect.ConnectorConfig{ } const ( - userId string = "QVBJcy5ndXJ3" + userID string = "QVBJcy5ndXJ3" queryListUsers string = "query ListUsers @auth(level: PUBLIC) { users { uid, name, address } }" queryListEmails string = "query ListEmails @auth(level: NO_ACCESS) { emails { id subject text date from { name } } }" - queryGetUserById string = "query GetUser($id: User_Key!) { user(key: $id) { uid name } }" - mutation string = "mutation user { user_insert(data: {uid: \"" + userId + "\", address: \"32 St\", name: \"Fred Car\"}) }" + queryGetUserByID string = "query GetUser($id: User_Key!) { user(key: $id) { uid name } }" + mutation string = "mutation user { user_insert(data: {uid: \"" + userID + "\", address: \"32 St\", name: \"Fred Car\"}) }" upsertUser string = "mutation UpsertUser($id: String) { user_upsert(data: { uid: $id, address: \"32 St.\", name: \"Fred\" }) }" multipleQueries string = queryListUsers + "\n" + queryListEmails ) @@ -50,7 +50,7 @@ var ( testUser = map[string]interface{}{ "name": "Fred", "address": "32 St.", - "uid": userId, + "uid": userID, } expectedUsers = []map[string]interface{}{ @@ -168,7 +168,7 @@ func TestExecuteGraphqlQueryError(t *testing.T) { func TestExecuteGraphqlMutation(t *testing.T) { opts := &dataconnect.GraphqlOptions{ Variables: map[string]interface{}{ - "id": userId, + "id": userID, }, } resp, err := client.ExecuteGraphql(context.Background(), upsertUser, opts) @@ -178,7 +178,7 @@ func TestExecuteGraphqlMutation(t *testing.T) { want := &dataconnect.ExecuteGraphqlResponse{ Data: map[string]interface{}{ "user_upsert": map[string]interface{}{ - "uid": userId, + "uid": userID, }, }, } @@ -221,11 +221,11 @@ func TestExecuteGraphqlWithVariables(t *testing.T) { opts := &dataconnect.GraphqlOptions{ Variables: map[string]interface{}{ "id": map[string]interface{}{ - "uid": userId, + "uid": userID, }, }, } - resp, err := client.ExecuteGraphql(context.Background(), queryGetUserById, opts) + resp, err := client.ExecuteGraphql(context.Background(), queryGetUserByID, opts) if err != nil { t.Fatalf("ExecuteGraphql() with variables error = %v", err) } From 756ff9a7a875cbcd1fdd3cd17082bbef17931667 Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Thu, 25 Sep 2025 11:19:11 -0400 Subject: [PATCH 6/9] chore: Fix copyright headers --- dataconnect/dataconnect.go | 16 +++++++++++++++- dataconnect/dataconnect_test.go | 15 ++++++++++++++- integration/dataconnect/dataconnect_test.go | 1 - 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/dataconnect/dataconnect.go b/dataconnect/dataconnect.go index 26a4099b..3f7e4839 100644 --- a/dataconnect/dataconnect.go +++ b/dataconnect/dataconnect.go @@ -1,4 +1,18 @@ -// Package dataconnect provides functions for interacting with the Firebase Data Connect service. +// 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 ( diff --git a/dataconnect/dataconnect_test.go b/dataconnect/dataconnect_test.go index 91d4f450..68f49f12 100644 --- a/dataconnect/dataconnect_test.go +++ b/dataconnect/dataconnect_test.go @@ -1,4 +1,17 @@ -// Package dataconnect provides functions for interacting with the Firebase Data Connect service. +// 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 ( diff --git a/integration/dataconnect/dataconnect_test.go b/integration/dataconnect/dataconnect_test.go index f6b73a2c..2038d7fc 100644 --- a/integration/dataconnect/dataconnect_test.go +++ b/integration/dataconnect/dataconnect_test.go @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package dataconnect_test contains integration tests for the dataconnect package. package dataconnect import ( From 99644dfaba7ab8abd11f46eddf55df40cdd095ba Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Thu, 25 Sep 2025 12:14:06 -0400 Subject: [PATCH 7/9] chore: Add updated schema --- .gitignore | 3 + .../dataconnect/dataconnect/dataconnect.yaml | 13 +++ .../dataconnect/my-connector/connector.yaml | 1 + .../dataconnect/my-connector/mutations.gql | 95 +++++++++++++++++++ .../dataconnect/my-connector/queries.gql | 74 +++++++++++++++ .../dataconnect/dataconnect/schema/schema.gql | 13 +++ testdata/dataconnect/firebase.json | 5 + 7 files changed, 204 insertions(+) create mode 100644 testdata/dataconnect/dataconnect/dataconnect.yaml create mode 100644 testdata/dataconnect/dataconnect/my-connector/connector.yaml create mode 100644 testdata/dataconnect/dataconnect/my-connector/mutations.gql create mode 100644 testdata/dataconnect/dataconnect/my-connector/queries.gql create mode 100644 testdata/dataconnect/dataconnect/schema/schema.gql create mode 100644 testdata/dataconnect/firebase.json 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/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" + } +} From f91b4c09edcf0a8fede6288af93dd8cc48c90070 Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Thu, 2 Oct 2025 13:41:00 -0400 Subject: [PATCH 8/9] feat: Allow passing of an interface to unmarshal custom responses and update tests to new schema --- dataconnect/dataconnect.go | 29 +- dataconnect/dataconnect_test.go | 34 +- integration/dataconnect/dataconnect_test.go | 415 ++++++++++++-------- 3 files changed, 284 insertions(+), 194 deletions(-) diff --git a/dataconnect/dataconnect.go b/dataconnect/dataconnect.go index 3f7e4839..be5e62fa 100644 --- a/dataconnect/dataconnect.go +++ b/dataconnect/dataconnect.go @@ -47,13 +47,13 @@ type ConnectorConfig struct { // GraphqlOptions represents the options for a GraphQL query. type GraphqlOptions struct { - Variables map[string]interface{} `json:"variables,omitempty"` - OperationName string `json:"operationName,omitempty"` + Variables interface{} `json:"variables,omitempty"` + OperationName string `json:"operationName,omitempty"` } // ExecuteGraphqlResponse is the response from a GraphQL query. -type ExecuteGraphqlResponse struct { - Data map[string]interface{} `json:"data"` +type internalExecuteGraphqlResponse struct { + Data json.RawMessage `json:"data"` } // Client is the interface for the Firebase Data Connect service. @@ -115,16 +115,16 @@ func NewClient(ctx context.Context, conf *internal.DataConnectConfig) (*Client, } // ExecuteGraphql executes a GraphQL query or mutation. -func (c *Client) ExecuteGraphql(ctx context.Context, query string, options *GraphqlOptions) (*ExecuteGraphqlResponse, error) { - return c.execute(ctx, executeGraphqlEndpoint, query, options) +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) (*ExecuteGraphqlResponse, error) { - return c.execute(ctx, executeGraphqlReadEndpoint, query, options) +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) (*ExecuteGraphqlResponse, error) { +func (c *Client) execute(ctx context.Context, endpoint, query string, options *GraphqlOptions, response interface{}) error { url := c.buildURL(endpoint) req := map[string]interface{}{ @@ -139,7 +139,7 @@ func (c *Client) execute(ctx context.Context, endpoint, query string, options *G } } - var result ExecuteGraphqlResponse + var result internalExecuteGraphqlResponse request := &internal.Request{ Method: http.MethodPost, URL: url, @@ -147,10 +147,15 @@ func (c *Client) execute(ctx context.Context, endpoint, query string, options *G } _, err := c.client.DoAndUnmarshal(ctx, request, &result) if err != nil { - return nil, err + return err + } + if response != nil { + if err := json.Unmarshal(result.Data, &response); err != nil { + return fmt.Errorf("error while parsing response: %v", err) + } } - return &result, nil + return nil } func (c *Client) buildURL(endpoint string) string { diff --git a/dataconnect/dataconnect_test.go b/dataconnect/dataconnect_test.go index 68f49f12..ad29c51d 100644 --- a/dataconnect/dataconnect_test.go +++ b/dataconnect/dataconnect_test.go @@ -18,7 +18,7 @@ import ( "context" "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "net/http/httptest" "os" @@ -37,6 +37,10 @@ const ( testVersion = "test-version" ) +type FooBar struct { + Foo string `json:"foo"` +} + func TestNewClient(t *testing.T) { conf := &internal.DataConnectConfig{ ProjectID: testProjectID, @@ -97,7 +101,7 @@ func TestExecuteGraphql(t *testing.T) { t.Errorf("Path = %q; want = %q", r.URL.Path, wantPath) } - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { t.Fatal(err) } @@ -111,8 +115,8 @@ func TestExecuteGraphql(t *testing.T) { t.Errorf("req.query = %q; want = %q", req["query"], "test query") } - resp := &ExecuteGraphqlResponse{ - Data: map[string]interface{}{"foo": "bar"}, + resp := &internalExecuteGraphqlResponse{ + Data: []byte(`{"foo": "bar"}`), } json.NewEncoder(w).Encode(resp) })) @@ -123,13 +127,14 @@ func TestExecuteGraphql(t *testing.T) { t.Fatal(err) } - resp, err := client.ExecuteGraphql(context.Background(), "test query", nil) + var resp FooBar + err = client.ExecuteGraphql(context.Background(), "test query", nil, &resp) if err != nil { t.Fatalf("ExecuteGraphql() error = %v", err) } - want := &ExecuteGraphqlResponse{ - Data: map[string]interface{}{"foo": "bar"}, + want := FooBar{ + Foo: "bar", } if !reflect.DeepEqual(resp, want) { t.Errorf("ExecuteGraphql() response = %#v; want = %#v", resp, want) @@ -145,8 +150,8 @@ func TestExecuteGraphqlRead(t *testing.T) { if r.URL.Path != wantPath { t.Errorf("Path = %q; want = %q", r.URL.Path, wantPath) } - resp := &ExecuteGraphqlResponse{ - Data: map[string]interface{}{"foo": "bar"}, + resp := &internalExecuteGraphqlResponse{ + Data: []byte(`{"foo": "bar"}`), } json.NewEncoder(w).Encode(resp) })) @@ -157,13 +162,14 @@ func TestExecuteGraphqlRead(t *testing.T) { t.Fatal(err) } - resp, err := client.ExecuteGraphqlRead(context.Background(), "test query", nil) + var resp FooBar + err = client.ExecuteGraphqlRead(context.Background(), "test query", nil, &resp) if err != nil { t.Fatalf("ExecuteGraphqlRead() error = %v", err) } - want := &ExecuteGraphqlResponse{ - Data: map[string]interface{}{"foo": "bar"}, + want := FooBar{ + Foo: "bar", } if !reflect.DeepEqual(resp, want) { t.Errorf("ExecuteGraphqlRead() response = %#v; want = %#v", resp, want) @@ -182,7 +188,7 @@ func TestExecuteGraphqlError(t *testing.T) { t.Fatal(err) } - _, err = client.ExecuteGraphql(context.Background(), "test query", nil) + err = client.ExecuteGraphql(context.Background(), "test query", nil, nil) if err == nil { t.Fatal("ExecuteGraphql() error = nil; want error") } @@ -200,7 +206,7 @@ func TestExecuteGraphqlQueryError(t *testing.T) { t.Fatal(err) } - _, err = client.ExecuteGraphql(context.Background(), "test query", nil) + err = client.ExecuteGraphql(context.Background(), "test query", nil, nil) if err == nil { t.Fatal("ExecuteGraphql() error = nil; want error") } diff --git a/integration/dataconnect/dataconnect_test.go b/integration/dataconnect/dataconnect_test.go index 2038d7fc..43d25601 100644 --- a/integration/dataconnect/dataconnect_test.go +++ b/integration/dataconnect/dataconnect_test.go @@ -34,32 +34,118 @@ var connectorConfig = &dataconnect.ConnectorConfig{ ServiceID: "my-service", } -const ( - userID string = "QVBJcy5ndXJ3" - - queryListUsers string = "query ListUsers @auth(level: PUBLIC) { users { uid, name, address } }" - queryListEmails string = "query ListEmails @auth(level: NO_ACCESS) { emails { id subject text date from { name } } }" - queryGetUserByID string = "query GetUser($id: User_Key!) { user(key: $id) { uid name } }" - mutation string = "mutation user { user_insert(data: {uid: \"" + userID + "\", address: \"32 St\", name: \"Fred Car\"}) }" - upsertUser string = "mutation UpsertUser($id: String) { user_upsert(data: { uid: $id, address: \"32 St.\", name: \"Fred\" }) }" - multipleQueries string = queryListUsers + "\n" + queryListEmails -) +/** + * // 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 ( - testUser = map[string]interface{}{ - "name": "Fred", - "address": "32 St.", - "uid": userID, - } - - expectedUsers = []map[string]interface{}{ - testUser, - { - "name": "Jeff", - "address": "99 Oak St. N", - "uid": "QVBJcy5ndXJ1", - }, + 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) { @@ -83,206 +169,199 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } -// type User struct { -// UID string `json:"uid"` -// Name string `json:"name"` -// Address string `json:"address"` - -// // Generated -// EmailsOnFrom []Email `json:"emails_on_from"` -// } - -// type Email struct { -// Subject string `json:"subject"` -// Date string `json:"date"` -// Text string `json:"text"` -// From string `json:"from"` - -// // Generated -// ID string `json:"id"` -// } - -func containsExpectedUser(usersSlice []interface{}, expectedUser map[string]interface{}) bool { - for _, item := range usersSlice { - userMap, ok := item.(map[string]interface{}) - if !ok { - // Item in slice is not the expected type, so it can't be a match - continue - } - if reflect.DeepEqual(userMap, expectedUser) { - return true - } - } - return false -} - -func TestExecuteGraphqlRead(t *testing.T) { - resp, err := client.ExecuteGraphqlRead(context.Background(), queryListUsers, nil) +func initializeDatabase(t *testing.T) { + var resp1 UserUpsertResponse + err := client.ExecuteGraphql(context.Background(), upsertFredUser, nil, &resp1) if err != nil { - t.Fatalf("ExecuteGraphqlRead() error = %v", err) - } - - if resp.Data == nil { - t.Errorf("resp.Data is empty") - } - users, ok := resp.Data["users"] - if !ok { - t.Fatal("response data does not contain 'users' key") + t.Fatalf("ExecuteGraphql() error = %v", err) } - usersSlice, ok := users.([]interface{}) - if !ok { - t.Fatal("'users' field is not a slice") + if resp1.UserUpsert.ID != fredUser.ID { + t.Errorf("ExecuteGraphql() User = %#v; want = %#v", resp1.UserUpsert.ID, fredUser.ID) } - if len(usersSlice) <= 1 { - t.Errorf("len(resp.Data[\"users\"]) = %d; want > 1", len(usersSlice)) + + var resp2 UserUpsertResponse + err = client.ExecuteGraphql(context.Background(), upsertJeffUser, nil, &resp2) + if err != nil { + t.Fatalf("ExecuteGraphql() error = %v", err) } - for _, expectedUser := range expectedUsers { - if !containsExpectedUser(usersSlice, expectedUser) { - t.Errorf("ExecuteGraphqlRead() response data does not contain expected user: %#v", expectedUser) - } + var resp3 EmailUpsertResponse + err = client.ExecuteGraphql(context.Background(), upsertFredEmail, nil, &resp3) + if err != nil { + t.Fatalf("ExecuteGraphql() error = %v", err) } } -func TestExecuteGraphqlReadMutation(t *testing.T) { - _, err := client.ExecuteGraphqlRead(context.Background(), mutation, nil) - 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 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 TestExecuteGraphqlQueryError(t *testing.T) { - _, err := client.ExecuteGraphql(context.Background(), mutation, nil) - 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) +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) -func TestExecuteGraphqlMutation(t *testing.T) { - opts := &dataconnect.GraphqlOptions{ - Variables: map[string]interface{}{ - "id": userID, - }, - } - resp, err := client.ExecuteGraphql(context.Background(), upsertUser, opts) + var resp ListUsersResponse + err := client.ExecuteGraphql(context.Background(), queryListUsers, nil, &resp) if err != nil { t.Fatalf("ExecuteGraphql() error = %v", err) } - want := &dataconnect.ExecuteGraphqlResponse{ - Data: map[string]interface{}{ - "user_upsert": map[string]interface{}{ - "uid": userID, - }, - }, - } - if resp.Data == nil { - t.Errorf("resp.Data is empty") + + if len(resp.Users) != len(initialState.Users) { + t.Errorf("len(resp.Users) = %d; want > %d", len(resp.Users), len(initialState.Users)) } - if !reflect.DeepEqual(resp, want) { - t.Errorf("ExecuteGraphql() response = %#v; want = %#v", resp, want) + + 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 TestExecuteGraphqlListUsers(t *testing.T) { - resp, err := client.ExecuteGraphql(context.Background(), queryListUsers, nil) +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("ExecuteGraphql() error = %v", err) - } - if resp.Data == nil { - t.Errorf("resp.Data is empty") + t.Fatalf("ExecuteGraphqlRead() error = %v", err) } - users, ok := resp.Data["users"] - if !ok { + + if resp.Users == nil { t.Fatal("response data does not contain 'users' key") } - usersSlice, ok := users.([]interface{}) - if !ok { - t.Fatal("'users' field is not a slice") - } - if len(usersSlice) <= 1 { - t.Errorf("len(resp.Data[\"users\"]) = %d; want > 1", len(usersSlice)) + if len(resp.Users) != len(initialState.Users) { + t.Errorf("len(resp.Users) = %d; want > %d", len(resp.Users), len(initialState.Users)) } - for _, expectedUser := range expectedUsers { - if !containsExpectedUser(usersSlice, expectedUser) { - t.Errorf("ExecuteGraphql() response data does not contain expected user: %#v", expectedUser) + 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: map[string]interface{}{ - "id": map[string]interface{}{ - "uid": userID, + Variables: GetUserVariables{ + ID: struct { + ID string `json:"id"` + }{ + ID: initialState.Users[0].ID, }, }, } - resp, err := client.ExecuteGraphql(context.Background(), queryGetUserByID, opts) + err := client.ExecuteGraphql(context.Background(), queryGetUserById, opts, &resp) if err != nil { - t.Fatalf("ExecuteGraphql() with variables error = %v", err) + t.Fatalf("ExecuteGraphql() error = %v", err) } - want := &dataconnect.ExecuteGraphqlResponse{ - Data: map[string]interface{}{ - "user": map[string]interface{}{ - "uid": testUser["uid"], - "name": testUser["name"], - }, - }, + if !reflect.DeepEqual(resp.User, initialState.Users[0]) { + t.Errorf("ExecuteGraphql() User = %#v; want = %#v", resp.User, initialState.Users[0]) } +} - if resp.Data == nil { - t.Errorf("resp.Data is empty") +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 !reflect.DeepEqual(resp, want) { - t.Errorf("ExecuteGraphql() response = %#v; want = %#v", resp, want) + if resp1.UserUpsert.ID != fredUser.ID { + t.Errorf("ExecuteGraphql() User = %#v; want = %#v", resp1.UserUpsert.ID, fredUser.ID) } -} -func TestExecuteGraphqlWithOperationName(t *testing.T) { - opts := &dataconnect.GraphqlOptions{ - OperationName: "ListEmails", - } - resp, err := client.ExecuteGraphql(context.Background(), multipleQueries, opts) + var resp2 UserUpsertResponse + err = client.ExecuteGraphql(context.Background(), upsertJeffUser, nil, &resp2) if err != nil { - t.Fatalf("ExecuteGraphql() with operationName error = %v", err) + t.Fatalf("ExecuteGraphql() error = %v", err) + } + if resp2.UserUpsert.ID != jeffUser.ID { + t.Errorf("ExecuteGraphql() User = %#v; want = %#v", resp2.UserUpsert.ID, jeffUser.ID) } - if resp.Data == nil { - t.Errorf("resp.Data is empty") + 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) } - emails, ok := resp.Data["emails"] - if !ok { - t.Fatal("response data does not contain 'emails' key") + var resp4 DeleteResponse + err = client.ExecuteGraphql(context.Background(), deleteAll, nil, &resp4) + if err != nil { + t.Fatalf("ExecuteGraphql() error = %v", err) } - emailsSlice, ok := emails.([]interface{}) - if !ok { - t.Fatal("'emails' field is not a slice") + if resp4.UserDeleteMany == 0 { + t.Errorf("ExecuteGraphql() Expected non-zero users deleted") } - if len(emailsSlice) != 1 { - t.Fatalf("len(emails) = %d; want 1", len(emailsSlice)) + if resp4.EmailDeleteMany == 0 { + t.Errorf("ExecuteGraphql() Expected non-zero emails deleted") } - email, ok := emailsSlice[0].(map[string]interface{}) - if !ok { - t.Fatal("email item is not a map") +} + +func TestExecuteGraphqlOperationNameWithMultipleQueries(t *testing.T) { + initializeDatabase(t) + defer cleanupDatabase(t) + + opts := &dataconnect.GraphqlOptions{ + OperationName: "ListEmails", } - if email["id"] == nil { - t.Error("email.id is nil, expected not undefined") + var resp ListEmailsResponse + err := client.ExecuteGraphql(context.Background(), multipleQueries, opts, &resp) + if err != nil { + t.Fatalf("ExecuteGraphql() error = %v", err) } - from, ok := email["from"].(map[string]interface{}) - if !ok { - t.Fatal("email.from is not a map") + if !reflect.DeepEqual(resp.Emails, initialState.Emails) { + t.Errorf("ExecuteGraphql() Emails = %#v; want = %#v", resp.Emails, initialState.Emails) } - if from["name"] != "Jeff" { - t.Errorf("email.from.name = %q; want \"Jeff\"", from["name"]) +} + +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) } } From dcac8f9583811c42bfc84b90f46a89110f8538c0 Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Thu, 2 Oct 2025 15:11:24 -0400 Subject: [PATCH 9/9] fix: lint --- integration/dataconnect/dataconnect_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/integration/dataconnect/dataconnect_test.go b/integration/dataconnect/dataconnect_test.go index 43d25601..dd359576 100644 --- a/integration/dataconnect/dataconnect_test.go +++ b/integration/dataconnect/dataconnect_test.go @@ -140,7 +140,7 @@ var ( 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 } }" + 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 + "\"})}" @@ -267,7 +267,7 @@ func TestExecuteGraphqlWithVariables(t *testing.T) { }, }, } - err := client.ExecuteGraphql(context.Background(), queryGetUserById, opts, &resp) + err := client.ExecuteGraphql(context.Background(), queryGetUserByID, opts, &resp) if err != nil { t.Fatalf("ExecuteGraphql() error = %v", err) } @@ -357,7 +357,7 @@ func TestExecuteGraphqlQueryErrorWithoutVariables(t *testing.T) { defer cleanupDatabase(t) var resp GetUserResponse - err := client.ExecuteGraphql(context.Background(), queryGetUserById, nil, &resp) + err := client.ExecuteGraphql(context.Background(), queryGetUserByID, nil, &resp) if err == nil { t.Fatalf("ExecuteGraphql() expected error for bad query, got nil") }