Repository: amp-labs/connectors
Pull request #1865 (2025-07-11 18:48:00)

Revert "chore: Add a few logging statements for associations"
Reverts amp-labs/connectors#1863


Patch:
diff --git a/connector/new.go b/connector/new.go
index b6f038d1..7a9429b0 100644
--- a/connector/new.go
+++ b/connector/new.go
@@ -34,6 +34,7 @@ import (
 	"github.com/amp-labs/connectors/providers/github"
 	"github.com/amp-labs/connectors/providers/gitlab"
 	"github.com/amp-labs/connectors/providers/gong"
+	"github.com/amp-labs/connectors/providers/google"
 	"github.com/amp-labs/connectors/providers/gorgias"
 	"github.com/amp-labs/connectors/providers/groove"
 	"github.com/amp-labs/connectors/providers/helpscoutmailbox"
@@ -106,6 +107,7 @@ var connectorConstructors = map[providers.Provider]outputConstructorFunc{ // nol
 	providers.GitLab:              wrapper(newGitLabConnector),
 	providers.Github:              wrapper(newGithubConnector),
 	providers.Gong:                wrapper(newGongConnector),
+	providers.Google:              wrapper(newGoogleConnector),
 	providers.Gorgias:             wrapper(newGorgiasConnector),
 	providers.Groove:              wrapper(newGrooveConnector),
 	providers.HelpScoutMailbox:    wrapper(newHelpScoutMailboxConnector),
@@ -124,9 +126,9 @@ var connectorConstructors = map[providers.Provider]outputConstructorFunc{ // nol
 	providers.Mixmax:              wrapper(newMixmaxConnector),
 	providers.Monday:              wrapper(newMondayConnector),
 	providers.Outreach:            wrapper(newOutreachConnector),
+	providers.Pinterest:           wrapper(newPinterestConnector),
 	providers.Pipedrive:           wrapper(newPipedriveConnector),
 	providers.Pipeliner:           wrapper(newPipelinerConnector),
-	providers.Pinterest:           wrapper(newPinterestConnector),
 	providers.Podium:              wrapper(newPodiumConnector),
 	providers.Salesforce:          wrapper(newSalesforceConnector),
 	providers.Salesloft:           wrapper(newSalesloftConnector),
@@ -563,3 +565,9 @@ func newFirefliesConnector(
 ) (*fireflies.Connector, error) {
 	return fireflies.NewConnector(params)
 }
+
+func newGoogleConnector(
+	params common.ConnectorParams,
+) (*google.Connector, error) {
+	return google.NewConnector(params)
+}
diff --git a/providers/google/connector.go b/providers/google/connector.go
index 2f5b3d07..7ab1a4f8 100644
--- a/providers/google/connector.go
+++ b/providers/google/connector.go
@@ -57,3 +57,27 @@ func (c Connector) ListObjectMetadata(
 
 	return nil, common.ErrNotImplemented
 }
+
+func (c Connector) Read(ctx context.Context, params connectors.ReadParams) (*connectors.ReadResult, error) {
+	if c.Calendar != nil {
+		return c.Calendar.Read(ctx, params)
+	}
+
+	return nil, common.ErrNotImplemented
+}
+
+func (c Connector) Write(ctx context.Context, params connectors.WriteParams) (*connectors.WriteResult, error) {
+	if c.Calendar != nil {
+		return c.Calendar.Write(ctx, params)
+	}
+
+	return nil, common.ErrNotImplemented
+}
+
+func (c Connector) Delete(ctx context.Context, params connectors.DeleteParams) (*connectors.DeleteResult, error) {
+	if c.Calendar != nil {
+		return c.Calendar.Delete(ctx, params)
+	}
+
+	return nil, common.ErrNotImplemented
+}
diff --git a/providers/google/internal/calendar/adapter.go b/providers/google/internal/calendar/adapter.go
index e2af2f98..000a4ac7 100644
--- a/providers/google/internal/calendar/adapter.go
+++ b/providers/google/internal/calendar/adapter.go
@@ -1,15 +1,28 @@
 package calendar
 
 import (
+	"strings"
+
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+	"github.com/amp-labs/connectors/common/urlbuilder"
 	"github.com/amp-labs/connectors/internal/components"
+	"github.com/amp-labs/connectors/internal/components/deleter"
+	"github.com/amp-labs/connectors/internal/components/operations"
+	"github.com/amp-labs/connectors/internal/components/reader"
 	"github.com/amp-labs/connectors/internal/components/schema"
+	"github.com/amp-labs/connectors/internal/components/writer"
 	"github.com/amp-labs/connectors/providers"
 )
 
+const apiVersion = "v3"
+
 type Adapter struct {
 	*components.Connector
 	components.SchemaProvider
+	components.Reader
+	components.Writer
+	components.Deleter
 }
 
 func NewAdapter(params common.ConnectorParams) (*Adapter, error) {
@@ -21,7 +34,58 @@ func constructor(base *components.Connector) (*Adapter, error) {
 		Connector: base,
 	}
 
+	errorHandler := interpreter.ErrorHandler{
+		JSON: interpreter.NewFaultyResponder(errorFormats, nil),
+		HTML: interpreter.DirectFaultyResponder{Callback: adapter.interpretHTMLError},
+	}.Handle
+
 	adapter.SchemaProvider = schema.NewOpenAPISchemaProvider(adapter.ProviderContext.Module(), Schemas)
 
+	adapter.Reader = reader.NewHTTPReader(
+		adapter.HTTPClient().Client,
+		components.NewEmptyEndpointRegistry(),
+		adapter.ProviderContext.Module(),
+		operations.ReadHandlers{
+			BuildRequest:  adapter.buildReadRequest,
+			ParseResponse: adapter.parseReadResponse,
+			ErrorHandler:  errorHandler,
+		},
+	)
+
+	adapter.Writer = writer.NewHTTPWriter(
+		adapter.HTTPClient().Client,
+		components.NewEmptyEndpointRegistry(),
+		adapter.ProviderContext.Module(),
+		operations.WriteHandlers{
+			BuildRequest:  adapter.buildWriteRequest,
+			ParseResponse: adapter.parseWriteResponse,
+			ErrorHandler:  errorHandler,
+		},
+	)
+
+	adapter.Deleter = deleter.NewHTTPDeleter(
+		adapter.HTTPClient().Client,
+		components.NewEmptyEndpointRegistry(),
+		adapter.ProviderContext.Module(),
+		operations.DeleteHandlers{
+			BuildRequest:  adapter.buildDeleteRequest,
+			ParseResponse: adapter.parseDeleteResponse,
+			ErrorHandler:  errorHandler,
+		},
+	)
+
 	return adapter, nil
 }
+
+func (a *Adapter) getURL(objectName string) (*urlbuilder.URL, error) {
+	objectPath, err := Schemas.FindURLPath(providers.ModuleGoogleCalendar, objectName)
+	if err != nil {
+		return nil, err
+	}
+
+	// Primary keyword can be put in place of calendar ID to refer to the user's primary calendar.
+	// https://developers.google.com/workspace/calendar/api/v3/reference/events/list
+	objectPath = strings.ReplaceAll(objectPath, "{calendarId}", "primary")
+
+	return urlbuilder.New(a.ModuleInfo().BaseURL, apiVersion, objectPath)
+}
diff --git a/providers/google/internal/calendar/errors.go b/providers/google/internal/calendar/errors.go
new file mode 100644
index 00000000..bce4ef38
--- /dev/null
+++ b/providers/google/internal/calendar/errors.go
@@ -0,0 +1,77 @@
+package calendar
+
+import (
+	"bytes"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/PuerkitoBio/goquery"
+	"github.com/amp-labs/connectors/common/interpreter"
+)
+
+var errorFormats = interpreter.NewFormatSwitch( // nolint:gochecknoglobals
+	[]interpreter.FormatTemplate{
+		{
+			MustKeys: nil,
+			Template: func() interpreter.ErrorDescriptor { return &ErrorDetails{} },
+		},
+	}...,
+)
+
+// nolint:tagliatelle
+type ErrorDetails struct {
+	Error errorResponse `json:"error"`
+}
+
+type errorResponse struct {
+	Errors  []errorItem `json:"errors"`
+	Code    int         `json:"code"`
+	Message string      `json:"message"`
+}
+
+type errorItem struct {
+	Domain  string `json:"domain"`
+	Reason  string `json:"reason"`
+	Message string `json:"message"`
+}
+
+func (d ErrorDetails) CombineErr(base error) error {
+	reasons := make([]string, len(d.Error.Errors))
+	for i, item := range d.Error.Errors {
+		reasons[i] = item.Message
+	}
+
+	message := strings.Join(reasons, ",")
+	if len(message) == 0 {
+		message = d.Error.Message
+	}
+
+	return fmt.Errorf("%w: %v", base, message)
+}
+
+// Typical HTML google error message has Title with paragraph describing the problem.
+// For more format details check unit tests.
+func (a *Adapter) interpretHTMLError(res *http.Response, body []byte) error {
+	base := interpreter.DefaultStatusCodeMappingToErr(res, body)
+
+	document, err := goquery.NewDocumentFromReader(bytes.NewReader(body))
+	if err != nil {
+		// ignore HTML that cannot be understood
+		return base
+	}
+
+	secondParagraph := document.Find("p").Eq(1)
+	// Remove the <ins> part to drop "That's all we know."
+	secondParagraph.Find("ins").Remove()
+	message := strings.TrimSpace(secondParagraph.Text())
+
+	if message == "" {
+		// Just use the generic title.
+		title := document.Find("title").Text()
+
+		return fmt.Errorf("%w: %v", base, title)
+	}
+
+	return fmt.Errorf("%w: %v", base, message)
+}
diff --git a/providers/google/internal/calendar/operations.go b/providers/google/internal/calendar/operations.go
new file mode 100644
index 00000000..edee3285
--- /dev/null
+++ b/providers/google/internal/calendar/operations.go
@@ -0,0 +1,226 @@
+package calendar
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/amp-labs/connectors/internal/datautils"
+	"github.com/amp-labs/connectors/internal/jsonquery"
+	"github.com/spyzhov/ajson"
+)
+
+const (
+	objectNameCalendarList = "calendarList"
+	objectNameEvents       = "events"
+
+	// Page size references:
+	// https://developers.google.com/workspace/calendar/api/v3/reference/calendarList/list
+	// https://developers.google.com/workspace/calendar/api/v3/reference/events/list
+	defaultPageSize = 3000
+)
+
+func (a *Adapter) buildReadRequest(ctx context.Context, params common.ReadParams) (*http.Request, error) {
+	if err := params.ValidateParams(true); err != nil {
+		return nil, err
+	}
+
+	url, err := a.buildReadURL(params)
+	if err != nil {
+		return nil, err
+	}
+
+	return http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil)
+}
+
+func (a *Adapter) buildReadURL(params common.ReadParams) (*urlbuilder.URL, error) {
+	if len(params.NextPage) != 0 {
+		return urlbuilder.New(params.NextPage.String())
+	}
+
+	// First page
+	url, err := a.getURL(params.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	url.WithQueryParam("maxResults", strconv.Itoa(defaultPageSize))
+
+	if params.ObjectName == objectNameCalendarList {
+		// This is the only object to support search by deleted items.
+		// https://developers.google.com/calendar/api/v3/reference/calendarList/list
+		if params.Deleted {
+			url.WithQueryParam("showDeleted", "true")
+		}
+	}
+
+	if params.ObjectName == objectNameEvents {
+		// https://developers.google.com/workspace/calendar/api/v3/reference/events/list
+		if !params.Since.IsZero() {
+			url.WithQueryParam("updatedMin", datautils.Time.FormatRFC3339inUTCWithMilliseconds(params.Since))
+		}
+	}
+
+	return url, nil
+}
+
+func (a *Adapter) parseReadResponse(
+	ctx context.Context,
+	params common.ReadParams,
+	request *http.Request,
+	resp *common.JSONHTTPResponse,
+) (*common.ReadResult, error) {
+	responseFieldName := Schemas.LookupArrayFieldName(a.Module(), params.ObjectName)
+
+	url, err := urlbuilder.FromRawURL(request.URL)
+	if err != nil {
+		return nil, err
+	}
+
+	return common.ParseResult(resp,
+		common.ExtractOptionalRecordsFromPath(responseFieldName),
+		makeNextRecordsURL(url),
+		common.GetMarshaledData,
+		params.Fields,
+	)
+}
+
+func makeNextRecordsURL(url *urlbuilder.URL) common.NextPageFunc {
+	// Alter current request URL to progress with the next page token.
+	return func(node *ajson.Node) (string, error) {
+		pageToken, err := jsonquery.New(node).StrWithDefault("nextPageToken", "")
+		if err != nil {
+			return "", err
+		}
+
+		if len(pageToken) == 0 {
+			// Next page doesn't exist
+			return "", nil
+		}
+
+		url.AddEncodingExceptions(map[string]string{
+			"%3D": "=",
+		})
+		url.WithQueryParam("pageToken", pageToken)
+
+		return url.String(), nil
+	}
+}
+
+func (a *Adapter) buildWriteRequest(ctx context.Context, params common.WriteParams) (*http.Request, error) {
+	url, err := a.getURL(params.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	method := http.MethodPost
+	if len(params.RecordId) != 0 {
+		method = http.MethodPatch
+
+		url.AddPath(params.RecordId)
+	}
+
+	if params.ObjectName == objectNameCalendarList {
+		if needsCalendarColorQueryParam(params) {
+			url.WithQueryParam("colorRgbFormat", "true")
+		}
+	}
+
+	jsonData, err := json.Marshal(params.RecordData)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal record data: %w", err)
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, url.String(), bytes.NewReader(jsonData))
+	if err != nil {
+		return nil, fmt.Errorf("failed to create request: %w", err)
+	}
+
+	return req, nil
+}
+
+// When either background or foreground is specified this means
+// we must attach a query parameter for the Write request to succeed.
+//
+// https://developers.google.com/calendar/api/v3/reference/calendarList/insert
+func needsCalendarColorQueryParam(params common.WriteParams) bool {
+	properties, err := common.RecordDataToMap(params.RecordData)
+	if err != nil {
+		return false
+	}
+
+	triggerFields := []string{
+		"foregroundColor", "backgroundColor",
+	}
+
+	for _, field := range triggerFields {
+		if _, ok := properties[field]; ok {
+			return true
+		}
+	}
+
+	return false
+}
+
+func (a *Adapter) parseWriteResponse(ctx context.Context, params common.WriteParams,
+	request *http.Request, response *common.JSONHTTPResponse,
+) (*common.WriteResult, error) {
+	body, ok := response.Body()
+	if !ok {
+		// it is unlikely to have no payload
+		return &common.WriteResult{
+			Success: true,
+		}, nil
+	}
+
+	recordID, err := jsonquery.New(body).StrWithDefault("id", "")
+	if err != nil {
+		return nil, err
+	}
+
+	data, err := jsonquery.Convertor.ObjectToMap(body)
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.WriteResult{
+		Success:  true,
+		RecordId: recordID,
+		Errors:   nil,
+		Data:     data,
+	}, nil
+}
+
+func (a *Adapter) buildDeleteRequest(ctx context.Context, params common.DeleteParams) (*http.Request, error) {
+	url, err := a.getURL(params.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	url.AddPath(params.RecordId)
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url.String(), nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create request: %w", err)
+	}
+
+	return req, nil
+}
+
+func (a *Adapter) parseDeleteResponse(ctx context.Context, params common.DeleteParams,
+	request *http.Request, response *common.JSONHTTPResponse,
+) (*common.DeleteResult, error) {
+	if response.Code != http.StatusOK && response.Code != http.StatusNoContent {
+		return nil, fmt.Errorf("%w: failed to delete record: %d", common.ErrRequestFailed, response.Code)
+	}
+
+	// Response body is not used.
+	return &common.DeleteResult{
+		Success: true,
+	}, nil
+}
diff --git a/providers/hubspot/associations.go b/providers/hubspot/associations.go
index bdce4af4..5dfaf773 100644
--- a/providers/hubspot/associations.go
+++ b/providers/hubspot/associations.go
@@ -84,11 +84,6 @@ func (c *Connector) fillAssociations(
 		}
 
 		if len(associations) == 0 {
-			logging.Logger(ctx).Info("no associations found",
-				"fromObject", fromObjName,
-				"toObject", associatedObject,
-				"ids", ids)
-
 			continue
 		}
 
@@ -115,9 +110,6 @@ func (c *Connector) getObjectAssociations( //nolint:cyclop
 	toObject string,
 ) (map[string][]common.Association, error) {
 	if len(fromIDs) == 0 {
-		logging.Logger(ctx).Warn("no IDs provided for associations",
-			"fromObject", fromObject, "toObject", toObject)
-
 		return map[string][]common.Association{}, nil
 	}
 
diff --git a/providers/hubspot/parse.go b/providers/hubspot/parse.go
index 4116be38..6d7eb816 100644
--- a/providers/hubspot/parse.go
+++ b/providers/hubspot/parse.go
@@ -5,7 +5,6 @@ import (
 	"strconv"
 
 	"github.com/amp-labs/connectors/common"
-	"github.com/amp-labs/connectors/common/logging"
 	"github.com/amp-labs/connectors/internal/jsonquery"
 	"github.com/spyzhov/ajson"
 )
@@ -168,10 +167,6 @@ func (c *Connector) getDataMarshaller(
 		}
 
 		if len(associatedObjects) > 0 {
-			logging.Logger(ctx).Debug("fetching associations",
-				"object", objName,
-				"associatedObjects", associatedObjects)
-
 			if err := c.fillAssociations(ctx, objName, &data, associatedObjects); err != nil {
 				return nil, err
 			}


Test patch:
diff --git a/providers/google/delete_test.go b/providers/google/delete_test.go
new file mode 100644
index 00000000..3ca283b0
--- /dev/null
+++ b/providers/google/delete_test.go
@@ -0,0 +1,78 @@
+package google
+
+import (
+	"errors"
+	"net/http"
+	"testing"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockcond"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testroutines"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+)
+
+func TestDelete(t *testing.T) { // nolint:funlen,cyclop
+	t.Parallel()
+
+	errorNotFound := testutils.DataFromFile(t, "delete/calendarList/not-found.json")
+
+	tests := []testroutines.Delete{
+		{
+			Name:         "Delete object must be included",
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:         "Write object and its ID must be included",
+			Input:        common.DeleteParams{ObjectName: "calendarList"},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingRecordID},
+		},
+		{
+			Name: "Successful delete",
+			Input: common.DeleteParams{
+				ObjectName: "calendarList",
+				RecordId:   "en.italian#holiday@group.v.calendar.google.com",
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.MethodDELETE(),
+					mockcond.Path("/calendar/v3/users/me/calendarList/en.italian#holiday@group.v.calendar.google.com"), // nolint:lll
+				},
+				Then: mockserver.Response(http.StatusNoContent),
+			}.Server(),
+			Expected: &common.DeleteResult{Success: true},
+		},
+		{
+			Name: "Error on deleting missing record",
+			Input: common.DeleteParams{
+				ObjectName: "calendarList",
+				RecordId:   "en.italian#holiday@group.v.calendar.google.com",
+			},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentJSON(),
+				Always: mockserver.Response(http.StatusNotFound, errorNotFound),
+			}.Server(),
+			ExpectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New( // nolint:goerr113
+					"Not Found",
+				),
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.DeleteConnector, error) {
+				return constructTestCalendarConnector(tt.Server.URL)
+			})
+		})
+	}
+}
diff --git a/providers/google/read_test.go b/providers/google/read_test.go
new file mode 100644
index 00000000..b0761615
--- /dev/null
+++ b/providers/google/read_test.go
@@ -0,0 +1,301 @@
+package google
+
+import (
+	"errors"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockcond"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testroutines"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+)
+
+func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
+	t.Parallel()
+
+	errorPageToken := testutils.DataFromFile(t, "read/error-page-token.json")
+	errorNotFound := testutils.DataFromFile(t, "read/error-object-not-found.html")
+	responseCalendarListFirstPage := testutils.DataFromFile(t, "read/calendarList/1-first-page.json")
+	responseCalendarListLastPage := testutils.DataFromFile(t, "read/calendarList/2-last-page.json")
+	responseSettingsFirstPage := testutils.DataFromFile(t, "read/settings/1-first-page.json")
+	responseSettingsLastPage := testutils.DataFromFile(t, "read/settings/2-last-page.json")
+	responseEventsFirstPage := testutils.DataFromFile(t, "read/events/1-first-page.json")
+	responseEventsLastPage := testutils.DataFromFile(t, "read/events/2-last-page.json")
+
+	tests := []testroutines.Read{
+		{
+			Name:         "Read object must be included",
+			Input:        common.ReadParams{},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:         "At least one field is requested",
+			Input:        common.ReadParams{ObjectName: "calendarList"},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingFields},
+		},
+		{
+			Name:  "Error response is parsed",
+			Input: common.ReadParams{ObjectName: "calendarList", Fields: connectors.Fields("summary")},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentJSON(),
+				Always: mockserver.Response(http.StatusBadRequest, errorPageToken),
+			}.Server(),
+			ExpectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New("Invalid page token value."), // nolint:goerr113
+			},
+		},
+		{
+			Name:  "Error endpoint for object is not found",
+			Input: common.ReadParams{ObjectName: "calendarList", Fields: connectors.Fields("summary")},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentHTML(),
+				Always: mockserver.Response(http.StatusNotFound, errorNotFound),
+			}.Server(),
+			ExpectedErrs: []error{
+				common.ErrBadRequest,
+				common.ErrNotFound,
+				errors.New("The requested URL /calendar/v3/calendarList?maxResults=3000&showDeleted=true was not found on this server."), // nolint:goerr113,lll
+			},
+		},
+		{
+			Name: "Read calendarList first page",
+			Input: common.ReadParams{
+				ObjectName: "calendarList",
+				Fields:     connectors.Fields("summary", "description"),
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If:    mockcond.Path("/calendar/v3/users/me/calendarList"),
+				Then:  mockserver.Response(http.StatusOK, responseCalendarListFirstPage),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetRead,
+			Expected: &common.ReadResult{
+				Rows: 4,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{
+						"summary":     "Holidays in Tanzania",
+						"description": "Holidays and Observances in Tanzania",
+					},
+					Raw: map[string]any{
+						"id": "en.tz#holiday@group.v.calendar.google.com",
+					},
+				}, {
+					Fields: map[string]any{
+						"summary":     "Holidays in United States",
+						"description": "Holidays and Observances in United States",
+					},
+					Raw: map[string]any{
+						"id": "en.usa#holiday@group.v.calendar.google.com",
+					},
+				}, {
+					Fields: map[string]any{
+						"summary":     "Holidays in Ukraine",
+						"description": "Holidays and Observances in Ukraine",
+					},
+					Raw: map[string]any{
+						"id": "en.ukrainian#holiday@group.v.calendar.google.com",
+					},
+				}, {
+					Fields: map[string]any{
+						"summary":     "Holidays in India",
+						"description": "Holidays and Observances in India",
+					},
+					Raw: map[string]any{
+						"id": "en.indian#holiday@group.v.calendar.google.com",
+					},
+				}},
+				NextPage: testroutines.URLTestServer + "/calendar/v3/users/me/calendarList?maxResults=3000" +
+					"&pageToken=EjAKDAjR0u-8BhCAjJaYAxIgdTpnY2FsK2dyb3VwOi8vaG9saWRheS9lbi5pbmRpYW4aHhIJBwYs8u00rJIAqsmIjAQNEgsIk9TvvAYQgM7SWQ==", // nolint:lll
+				Done: false,
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name: "Read calendarList second page without next cursor",
+			Input: common.ReadParams{
+				ObjectName: "calendarList",
+				Fields:     connectors.Fields("summary", "description"),
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.Path("/calendar/v3/users/me/calendarList"),
+					mockcond.QueryParam("maxResults", "3000"),
+				},
+				Then: mockserver.Response(http.StatusOK, responseCalendarListLastPage),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetRead,
+			Expected: &common.ReadResult{
+				Rows: 1,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{
+						"summary": "integration@test.com",
+					},
+					Raw: map[string]any{
+						"id": "integration@test.com",
+					},
+				}},
+				NextPage: "",
+				Done:     true,
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name: "Read settings first page",
+			Input: common.ReadParams{
+				ObjectName: "settings",
+				Fields:     connectors.Fields("id"),
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If:    mockcond.Path("/calendar/v3/users/me/settings"),
+				Then:  mockserver.Response(http.StatusOK, responseSettingsFirstPage),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetRead,
+			Expected: &common.ReadResult{
+				Rows: 10,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{
+						"id": "autoAddHangouts",
+					},
+					Raw: map[string]any{
+						"value": "false",
+					},
+				}},
+				NextPage: testroutines.URLTestServer + "/calendar/v3/users/me/settings?maxResults=3000" +
+					"&pageToken=CiEKCwoJL2NhbHVzZXIvEgQIAhIAGgxzaG93RGVjbGluZWQQoJLNg62eiwM=", // nolint:lll
+				Done: false,
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name: "Read settings second page without next cursor",
+			Input: common.ReadParams{
+				ObjectName: "settings",
+				Fields:     connectors.Fields("id"),
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If:    mockcond.Path("/calendar/v3/users/me/settings"),
+				Then:  mockserver.Response(http.StatusOK, responseSettingsLastPage),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetRead,
+			Expected: &common.ReadResult{
+				Rows: 3,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{
+						"id": "timezone",
+					},
+					Raw: map[string]any{
+						"value": "America/Los_Angeles",
+					},
+				}},
+				NextPage: "",
+				Done:     true,
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name: "Read events first page using incremental approach",
+			Input: common.ReadParams{
+				ObjectName: "events",
+				Fields:     connectors.Fields("id"),
+				Since: time.Date(2024, 9, 19, 4, 30, 45, 600,
+					time.FixedZone("UTC-8", -8*60*60)),
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.Path("/calendar/v3/calendars/primary/events"),
+					mockcond.QueryParam("updatedMin", "2024-09-19T12:30:45.000Z"),
+				},
+				Then: mockserver.Response(http.StatusOK, responseEventsFirstPage),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetRead,
+			Expected: &common.ReadResult{
+				Rows: 3,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{
+						"id": "4of57j0lu6oqjdo62om7juoe3k",
+					},
+					Raw: map[string]any{
+						"summary": "[Meeting Buffer]",
+						"colorId": "8",
+						"iCalUID": "4of57j0lu6oqjdo62om7juoe3k@google.com",
+					},
+				}, {
+					Fields: map[string]any{
+						"id": "cl392f4vinvbes6dnv2r0j8ku0",
+					},
+					Raw: map[string]any{
+						"summary":     "Meeting with Integration",
+						"hangoutLink": "https://meet.google.com/puo-ojnc-wir",
+						"iCalUID":     "cl392f4vinvbes6dnv2r0j8ku0@google.com",
+					},
+				}, {
+					Fields: map[string]any{
+						"id": "md15kq7kv4p1viuta3jip2inng",
+					},
+					Raw: map[string]any{
+						"created": "2025-06-02T09:43:16.000Z",
+						"updated": "2025-06-12T18:58:09.447Z",
+						"summary": "Meeting with Integration",
+					},
+				}},
+				NextPage: testroutines.URLTestServer + "/calendar/v3/calendars/primary/events?maxResults=3000" +
+					"&updatedMin=2024-09-19T12:30:45.000Z" +
+					"&pageToken=CkAKMAouCgwIwcaswgYQmOig1QESHgocChptZDE1a3E3a3Y0cDF2aXV0YTNqaXAyaW5uZxoMCNfVwMMGENC33MkBwD4B", // nolint:lll
+				Done: false,
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name: "Read events second page without next cursor",
+			Input: common.ReadParams{
+				ObjectName: "events",
+				Fields:     connectors.Fields("id"),
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If:    mockcond.Path("/calendar/v3/calendars/primary/events"),
+				Then:  mockserver.Response(http.StatusOK, responseEventsLastPage),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetRead,
+			Expected: &common.ReadResult{
+				Rows: 1,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{
+						"id": "p3nkh1hg41683vdhlcq4k12iso",
+					},
+					Raw: map[string]any{
+						"created": "2025-06-02T09:43:48.000Z",
+						"updated": "2025-06-12T18:58:09.447Z",
+						"summary": "Meeting with Integration",
+						"iCalUID": "p3nkh1hg41683vdhlcq4k12iso@google.com",
+					},
+				}},
+				NextPage: "",
+				Done:     true,
+			},
+			ExpectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.ReadConnector, error) {
+				return constructTestCalendarConnector(tt.Server.URL)
+			})
+		})
+	}
+}
diff --git a/providers/google/test/delete/calendarList/not-found.json b/providers/google/test/delete/calendarList/not-found.json
new file mode 100644
index 00000000..e2f0bba6
--- /dev/null
+++ b/providers/google/test/delete/calendarList/not-found.json
@@ -0,0 +1,13 @@
+{
+  "error": {
+    "errors": [
+      {
+        "domain": "global",
+        "reason": "notFound",
+        "message": "Not Found"
+      }
+    ],
+    "code": 404,
+    "message": "Not Found"
+  }
+}
diff --git a/providers/google/test/read/calendarList/1-first-page.json b/providers/google/test/read/calendarList/1-first-page.json
new file mode 100644
index 00000000..a720e58c
--- /dev/null
+++ b/providers/google/test/read/calendarList/1-first-page.json
@@ -0,0 +1,83 @@
+{
+  "kind": "calendar#calendarList",
+  "etag": "\"p32g95jc3lmf8m0o\"",
+  "nextPageToken": "EjAKDAjR0u-8BhCAjJaYAxIgdTpnY2FsK2dyb3VwOi8vaG9saWRheS9lbi5pbmRpYW4aHhIJBwYs8u00rJIAqsmIjAQNEgsIk9TvvAYQgM7SWQ==",
+  "items": [
+    {
+      "kind": "calendar#calendarListEntry",
+      "etag": "\"1733416408118000\"",
+      "id": "en.tz#holiday@group.v.calendar.google.com",
+      "summary": "Holidays in Tanzania",
+      "description": "Holidays and Observances in Tanzania",
+      "timeZone": "America/Los_Angeles",
+      "colorId": "8",
+      "backgroundColor": "#16a765",
+      "foregroundColor": "#000000",
+      "selected": true,
+      "accessRole": "reader",
+      "defaultReminders": [],
+      "conferenceProperties": {
+        "allowedConferenceSolutionTypes": [
+          "hangoutsMeet"
+        ]
+      }
+    },
+    {
+      "kind": "calendar#calendarListEntry",
+      "etag": "\"1738271037848000\"",
+      "id": "en.usa#holiday@group.v.calendar.google.com",
+      "summary": "Holidays in United States",
+      "description": "Holidays and Observances in United States",
+      "timeZone": "America/Los_Angeles",
+      "colorId": "15",
+      "backgroundColor": "#9fc6e7",
+      "foregroundColor": "#000000",
+      "selected": true,
+      "accessRole": "reader",
+      "defaultReminders": [],
+      "conferenceProperties": {
+        "allowedConferenceSolutionTypes": [
+          "hangoutsMeet"
+        ]
+      }
+    },
+    {
+      "kind": "calendar#calendarListEntry",
+      "etag": "\"1738271042429000\"",
+      "id": "en.ukrainian#holiday@group.v.calendar.google.com",
+      "summary": "Holidays in Ukraine",
+      "description": "Holidays and Observances in Ukraine",
+      "timeZone": "America/Los_Angeles",
+      "colorId": "9",
+      "backgroundColor": "#7bd148",
+      "foregroundColor": "#000000",
+      "selected": true,
+      "accessRole": "reader",
+      "defaultReminders": [],
+      "conferenceProperties": {
+        "allowedConferenceSolutionTypes": [
+          "hangoutsMeet"
+        ]
+      }
+    },
+    {
+      "kind": "calendar#calendarListEntry",
+      "etag": "\"1738271057856000\"",
+      "id": "en.indian#holiday@group.v.calendar.google.com",
+      "summary": "Holidays in India",
+      "description": "Holidays and Observances in India",
+      "timeZone": "America/Los_Angeles",
+      "colorId": "12",
+      "backgroundColor": "#fad165",
+      "foregroundColor": "#000000",
+      "selected": true,
+      "accessRole": "reader",
+      "defaultReminders": [],
+      "conferenceProperties": {
+        "allowedConferenceSolutionTypes": [
+          "hangoutsMeet"
+        ]
+      }
+    }
+  ]
+}
diff --git a/providers/google/test/read/calendarList/2-last-page.json b/providers/google/test/read/calendarList/2-last-page.json
new file mode 100644
index 00000000..63ee3299
--- /dev/null
+++ b/providers/google/test/read/calendarList/2-last-page.json
@@ -0,0 +1,51 @@
+{
+  "kind": "calendar#calendarList",
+  "etag": "\"p32g95jc3lmf8m0o\"",
+  "nextSyncToken": "CKCSzYOtnosDEiJpbnRlZ3JhdGlvbi50ZXN0QHdpdGhhbXBlcnNhbmQuY29t",
+  "items": [
+    {
+      "kind": "calendar#calendarListEntry",
+      "etag": "\"1738271077677000\"",
+      "id": "integration@test.com",
+      "summary": "integration@test.com",
+      "timeZone": "America/Los_Angeles",
+      "colorId": "14",
+      "backgroundColor": "#9fe1e7",
+      "foregroundColor": "#000000",
+      "selected": true,
+      "accessRole": "owner",
+      "defaultReminders": [
+        {
+          "method": "popup",
+          "minutes": 10
+        }
+      ],
+      "notificationSettings": {
+        "notifications": [
+          {
+            "type": "eventCreation",
+            "method": "email"
+          },
+          {
+            "type": "eventChange",
+            "method": "email"
+          },
+          {
+            "type": "eventCancellation",
+            "method": "email"
+          },
+          {
+            "type": "eventResponse",
+            "method": "email"
+          }
+        ]
+      },
+      "primary": true,
+      "conferenceProperties": {
+        "allowedConferenceSolutionTypes": [
+          "hangoutsMeet"
+        ]
+      }
+    }
+  ]
+}
diff --git a/providers/google/test/read/error-object-not-found.html b/providers/google/test/read/error-object-not-found.html
new file mode 100644
index 00000000..215fef1a
--- /dev/null
+++ b/providers/google/test/read/error-object-not-found.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html lang=en>
+<meta charset=utf-8>
+<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
+<title>Error 404 (Not Found)!!1</title>
+<style>
+    *{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
+</style>
+<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
+<p><b>404.</b> <ins>That’s an error.</ins>
+<p>The requested URL <code>/calendar/v3/calendarList?maxResults=3000&amp;showDeleted=true</code> was not found on this server.  <ins>That’s all we know.</ins>
diff --git a/providers/google/test/read/error-page-token.json b/providers/google/test/read/error-page-token.json
new file mode 100644
index 00000000..36294e44
--- /dev/null
+++ b/providers/google/test/read/error-page-token.json
@@ -0,0 +1,13 @@
+{
+  "error": {
+    "errors": [
+      {
+        "domain": "global",
+        "reason": "invalid",
+        "message": "Invalid page token value."
+      }
+    ],
+    "code": 400,
+    "message": "Invalid page token value."
+  }
+}
diff --git a/providers/google/test/read/events/1-first-page.json b/providers/google/test/read/events/1-first-page.json
new file mode 100644
index 00000000..a62eb9f2
--- /dev/null
+++ b/providers/google/test/read/events/1-first-page.json
@@ -0,0 +1,223 @@
+{
+  "kind": "calendar#events",
+  "etag": "\"p33fqrv7si2pos0o\"",
+  "summary": "test@withampersand.com",
+  "description": "",
+  "updated": "2025-07-10T20:30:15.127Z",
+  "timeZone": "America/Los_Angeles",
+  "accessRole": "owner",
+  "defaultReminders": [
+    {
+      "method": "popup",
+      "minutes": 10
+    }
+  ],
+  "nextPageToken": "CkAKMAouCgwIwcaswgYQmOig1QESHgocChptZDE1a3E3a3Y0cDF2aXV0YTNqaXAyaW5uZxoMCNfVwMMGENC33MkBwD4B",
+  "items": [
+    {
+      "kind": "calendar#event",
+      "etag": "\"3498612029671422\"",
+      "id": "4of57j0lu6oqjdo62om7juoe3k",
+      "status": "confirmed",
+      "htmlLink": "https://www.google.com/calendar/event?eid=NG9mNTdqMGx1Nm9xamRvNjJvbTdqdW9lM2sgaW50ZWdyYXRpb24udGVzdEB3aXRoYW1wZXJzYW5kLmNvbQ",
+      "created": "2025-06-02T09:42:20.000Z",
+      "updated": "2025-06-12T18:58:09.447Z",
+      "summary": "[Meeting Buffer]",
+      "colorId": "8",
+      "creator": {
+        "email": "test@withampersand.com",
+        "self": true
+      },
+      "organizer": {
+        "email": "test@withampersand.com",
+        "self": true
+      },
+      "start": {
+        "dateTime": "2025-06-02T15:20:00-07:00",
+        "timeZone": "America/Los_Angeles"
+      },
+      "end": {
+        "dateTime": "2025-06-02T15:30:00-07:00",
+        "timeZone": "America/Los_Angeles"
+      },
+      "iCalUID": "4of57j0lu6oqjdo62om7juoe3k@google.com",
+      "sequence": 0,
+      "reminders": {
+        "useDefault": false
+      },
+      "eventType": "default"
+    },
+    {
+      "kind": "calendar#event",
+      "etag": "\"3499509378894462\"",
+      "id": "cl392f4vinvbes6dnv2r0j8ku0",
+      "status": "confirmed",
+      "htmlLink": "https://www.google.com/calendar/event?eid=Y2wzOTJmNHZpbnZiZXM2ZG52MnIwajhrdTAgaW50ZWdyYXRpb24udGVzdEB3aXRoYW1wZXJzYW5kLmNvbQ",
+      "created": "2025-06-02T09:42:17.000Z",
+      "updated": "2025-06-12T18:58:09.447Z",
+      "summary": "Meeting with Integration",
+      "description": "Looking forward to connecting.\nIf you need to reschedule, please use the link: https://withampersand.chilipiper.com/reschedule/82f23c0b-86f0-4c66-a634-70f28d4b938f\nThis call will be recorded for note taking and as a resource for the both of us.",
+      "creator": {
+        "email": "test@withampersand.com",
+        "self": true
+      },
+      "organizer": {
+        "email": "test@withampersand.com",
+        "self": true
+      },
+      "start": {
+        "dateTime": "2025-06-02T15:30:00-07:00",
+        "timeZone": "America/Los_Angeles"
+      },
+      "end": {
+        "dateTime": "2025-06-02T16:00:00-07:00",
+        "timeZone": "America/Los_Angeles"
+      },
+      "iCalUID": "cl392f4vinvbes6dnv2r0j8ku0@google.com",
+      "sequence": 0,
+      "attendees": [
+        {
+          "email": "bob@gmail.com",
+          "responseStatus": "needsAction"
+        },
+        {
+          "email": "test@withampersand.com",
+          "organizer": true,
+          "self": true,
+          "responseStatus": "accepted"
+        },
+        {
+          "email": "alice@gmail.com",
+          "responseStatus": "accepted"
+        }
+      ],
+      "hangoutLink": "https://meet.google.com/puo-ojnc-wir",
+      "conferenceData": {
+        "createRequest": {
+          "requestId": "e030cf8b-7caf-47e9-9a71-d2bb2361dad4",
+          "conferenceSolutionKey": {
+            "type": "hangoutsMeet"
+          },
+          "status": {
+            "statusCode": "success"
+          }
+        },
+        "entryPoints": [
+          {
+            "entryPointType": "video",
+            "uri": "https://meet.google.com/puo-ojnc-wir",
+            "label": "meet.google.com/puo-ojnc-wir"
+          },
+          {
+            "entryPointType": "more",
+            "uri": "https://tel.meet/puo-ojnc-wir?pin=8201021807998",
+            "pin": "8201021807998"
+          },
+          {
+            "regionCode": "US",
+            "entryPointType": "phone",
+            "uri": "tel:+1-601-732-5407",
+            "label": "+1 601-732-5407",
+            "pin": "503945402"
+          }
+        ],
+        "conferenceSolution": {
+          "key": {
+            "type": "hangoutsMeet"
+          },
+          "name": "Google Meet",
+          "iconUri": "https://fonts.gstatic.com/s/i/productlogos/meet_2020q4/v6/web-512dp/logo_meet_2020q4_color_2x_web_512dp.png"
+        },
+        "conferenceId": "puo-ojnc-wir"
+      },
+      "reminders": {
+        "useDefault": true
+      },
+      "eventType": "default"
+    },
+    {
+      "kind": "calendar#event",
+      "etag": "\"3499509378894462\"",
+      "id": "md15kq7kv4p1viuta3jip2inng",
+      "status": "confirmed",
+      "htmlLink": "https://www.google.com/calendar/event?eid=bWQxNWtxN2t2NHAxdml1dGEzamlwMmlubmcgaW50ZWdyYXRpb24udGVzdEB3aXRoYW1wZXJzYW5kLmNvbQ",
+      "created": "2025-06-02T09:43:16.000Z",
+      "updated": "2025-06-12T18:58:09.447Z",
+      "summary": "Meeting with Integration",
+      "description": "Looking forward to connecting.\nIf you need to reschedule, please use the link: https://withampersand.chilipiper.com/reschedule/0ee0e998-cf73-4e71-add9-06368ec231ca\nThis call will be recorded for note taking and as a resource for the both of us.",
+      "creator": {
+        "email": "test@withampersand.com",
+        "self": true
+      },
+      "organizer": {
+        "email": "test@withampersand.com",
+        "self": true
+      },
+      "start": {
+        "dateTime": "2025-06-03T16:00:00-07:00",
+        "timeZone": "America/Los_Angeles"
+      },
+      "end": {
+        "dateTime": "2025-06-03T16:30:00-07:00",
+        "timeZone": "America/Los_Angeles"
+      },
+      "iCalUID": "md15kq7kv4p1viuta3jip2inng@google.com",
+      "sequence": 0,
+      "attendees": [
+        {
+          "email": "josephbkarage@gmail.com",
+          "responseStatus": "accepted"
+        },
+        {
+          "email": "test@withampersand.com",
+          "organizer": true,
+          "self": true,
+          "responseStatus": "accepted"
+        }
+      ],
+      "hangoutLink": "https://meet.google.com/nma-qtdy-shz",
+      "conferenceData": {
+        "createRequest": {
+          "requestId": "6262b718-caeb-48bb-83be-f8896cf96548",
+          "conferenceSolutionKey": {
+            "type": "hangoutsMeet"
+          },
+          "status": {
+            "statusCode": "success"
+          }
+        },
+        "entryPoints": [
+          {
+            "entryPointType": "video",
+            "uri": "https://meet.google.com/nma-qtdy-shz",
+            "label": "meet.google.com/nma-qtdy-shz"
+          },
+          {
+            "entryPointType": "more",
+            "uri": "https://tel.meet/nma-qtdy-shz?pin=6051923753448",
+            "pin": "6051923753448"
+          },
+          {
+            "regionCode": "US",
+            "entryPointType": "phone",
+            "uri": "tel:+1-928-793-9294",
+            "label": "+1 928-793-9294",
+            "pin": "307571104"
+          }
+        ],
+        "conferenceSolution": {
+          "key": {
+            "type": "hangoutsMeet"
+          },
+          "name": "Google Meet",
+          "iconUri": "https://fonts.gstatic.com/s/i/productlogos/meet_2020q4/v6/web-512dp/logo_meet_2020q4_color_2x_web_512dp.png"
+        },
+        "conferenceId": "nma-qtdy-shz"
+      },
+      "reminders": {
+        "useDefault": true
+      },
+      "eventType": "default"
+    }
+  ]
+}
diff --git a/providers/google/test/read/events/2-last-page.json b/providers/google/test/read/events/2-last-page.json
new file mode 100644
index 00000000..d585c74d
--- /dev/null
+++ b/providers/google/test/read/events/2-last-page.json
@@ -0,0 +1,102 @@
+{
+  "kind": "calendar#events",
+  "etag": "\"p33nvj2fevmo8s0o\"",
+  "summary": "test@withampersand.com",
+  "description": "",
+  "updated": "2025-07-09T23:59:24.142Z",
+  "timeZone": "America/Los_Angeles",
+  "accessRole": "owner",
+  "defaultReminders": [
+    {
+      "method": "popup",
+      "minutes": 10
+    }
+  ],
+  "nextSyncToken": "CO_5ie79sI4DEO_5ie79sI4DGAUgpczu8wIopczu8wI=",
+  "items": [
+    {
+      "kind": "calendar#event",
+      "etag": "\"3499509378894462\"",
+      "id": "p3nkh1hg41683vdhlcq4k12iso",
+      "status": "confirmed",
+      "htmlLink": "https://www.google.com/calendar/event?eid=cDNua2gxaGc0MTY4M3ZkaGxjcTRrMTJpc28gaW50ZWdyYXRpb24udGVzdEB3aXRoYW1wZXJzYW5kLmNvbQ",
+      "created": "2025-06-02T09:43:48.000Z",
+      "updated": "2025-06-12T18:58:09.447Z",
+      "summary": "Meeting with Integration",
+      "description": "Looking forward to connecting.\nIf you need to reschedule, please use the link: https://withampersand.chilipiper.com/reschedule/e8f0be05-3332-4597-b7e8-69ebd0a90076\nThis call will be recorded for note taking and as a resource for the both of us.",
+      "creator": {
+        "email": "test@withampersand.com",
+        "self": true
+      },
+      "organizer": {
+        "email": "test@withampersand.com",
+        "self": true
+      },
+      "start": {
+        "dateTime": "2025-06-03T10:00:00-07:00",
+        "timeZone": "America/Los_Angeles"
+      },
+      "end": {
+        "dateTime": "2025-06-03T10:30:00-07:00",
+        "timeZone": "America/Los_Angeles"
+      },
+      "iCalUID": "p3nkh1hg41683vdhlcq4k12iso@google.com",
+      "sequence": 0,
+      "attendees": [
+        {
+          "email": "test@withampersand.com",
+          "organizer": true,
+          "self": true,
+          "responseStatus": "accepted"
+        },
+        {
+          "email": "alice@gmail.com",
+          "responseStatus": "accepted"
+        }
+      ],
+      "hangoutLink": "https://meet.google.com/kzg-subp-ydk",
+      "conferenceData": {
+        "createRequest": {
+          "requestId": "389b7edb-1c0a-4d04-b109-0ef7fab3c40a",
+          "conferenceSolutionKey": {
+            "type": "hangoutsMeet"
+          },
+          "status": {
+            "statusCode": "success"
+          }
+        },
+        "entryPoints": [
+          {
+            "entryPointType": "video",
+            "uri": "https://meet.google.com/kzg-subp-ydk",
+            "label": "meet.google.com/kzg-subp-ydk"
+          },
+          {
+            "entryPointType": "more",
+            "uri": "https://tel.meet/kzg-subp-ydk?pin=5648732930595",
+            "pin": "5648732930595"
+          },
+          {
+            "regionCode": "US",
+            "entryPointType": "phone",
+            "uri": "tel:+1-252-325-8496",
+            "label": "+1 252-325-8496",
+            "pin": "818432767"
+          }
+        ],
+        "conferenceSolution": {
+          "key": {
+            "type": "hangoutsMeet"
+          },
+          "name": "Google Meet",
+          "iconUri": "https://fonts.gstatic.com/s/i/productlogos/meet_2020q4/v6/web-512dp/logo_meet_2020q4_color_2x_web_512dp.png"
+        },
+        "conferenceId": "kzg-subp-ydk"
+      },
+      "reminders": {
+        "useDefault": true
+      },
+      "eventType": "default"
+    }
+  ]
+}
diff --git a/providers/google/test/read/settings/1-first-page.json b/providers/google/test/read/settings/1-first-page.json
new file mode 100644
index 00000000..bc8c671f
--- /dev/null
+++ b/providers/google/test/read/settings/1-first-page.json
@@ -0,0 +1,67 @@
+{
+  "kind": "calendar#settings",
+  "etag": "\"p32g95jc3lmf8m0o\"",
+  "nextPageToken": "CiEKCwoJL2NhbHVzZXIvEgQIAhIAGgxzaG93RGVjbGluZWQQoJLNg62eiwM=",
+  "items": [
+    {
+      "kind": "calendar#setting",
+      "etag": "\"1589814000000000\"",
+      "id": "autoAddHangouts",
+      "value": "false"
+    },
+    {
+      "kind": "calendar#setting",
+      "etag": "\"1589814000000000\"",
+      "id": "defaultEventLength",
+      "value": "60"
+    },
+    {
+      "kind": "calendar#setting",
+      "etag": "\"1738270937348000\"",
+      "id": "dateFieldOrder",
+      "value": "YMD"
+    },
+    {
+      "kind": "calendar#setting",
+      "etag": "\"1733416408118000\"",
+      "id": "weekStart",
+      "value": "0"
+    },
+    {
+      "kind": "calendar#setting",
+      "etag": "\"1733416408118000\"",
+      "id": "format24HourTime",
+      "value": "false"
+    },
+    {
+      "kind": "calendar#setting",
+      "etag": "\"1589814000000000\"",
+      "id": "hideInvitations",
+      "value": "false"
+    },
+    {
+      "kind": "calendar#setting",
+      "etag": "\"1589814000000000\"",
+      "id": "hideInvitationsSetting",
+      "value": "NONE"
+    },
+    {
+      "kind": "calendar#setting",
+      "etag": "\"1733416408118000\"",
+      "id": "locale",
+      "value": "en"
+    },
+    {
+      "kind": "calendar#setting",
+      "etag": "\"1589814000000000\"",
+      "id": "remindOnRespondedEventsOnly",
+      "value": "false"
+    },
+    {
+      "kind": "calendar#setting",
+      "etag": "\"1589814000000000\"",
+      "id": "showDeclinedEvents",
+      "value": "true"
+    }
+  ]
+}
diff --git a/providers/google/test/read/settings/2-last-page.json b/providers/google/test/read/settings/2-last-page.json
new file mode 100644
index 00000000..2cbb2531
--- /dev/null
+++ b/providers/google/test/read/settings/2-last-page.json
@@ -0,0 +1,25 @@
+{
+  "kind": "calendar#settings",
+  "etag": "\"p32g95jc3lmf8m0o\"",
+  "nextSyncToken": "00001738271251188000",
+  "items": [
+    {
+      "kind": "calendar#setting",
+      "etag": "\"1738270902917000\"",
+      "id": "timezone",
+      "value": "America/Los_Angeles"
+    },
+    {
+      "kind": "calendar#setting",
+      "etag": "\"1589814000000000\"",
+      "id": "useKeyboardShortcuts",
+      "value": "true"
+    },
+    {
+      "kind": "calendar#setting",
+      "etag": "\"1589814000000000\"",
+      "id": "hideWeekends",
+      "value": "false"
+    }
+  ]
+}
diff --git a/providers/google/test/write/calendarList/error-missing-color.json b/providers/google/test/write/calendarList/error-missing-color.json
new file mode 100644
index 00000000..fff226e7
--- /dev/null
+++ b/providers/google/test/write/calendarList/error-missing-color.json
@@ -0,0 +1,13 @@
+{
+  "error": {
+    "errors": [
+      {
+        "domain": "global",
+        "reason": "required",
+        "message": "Missing foreground color."
+      }
+    ],
+    "code": 400,
+    "message": "Missing foreground color."
+  }
+}
diff --git a/providers/google/test/write/calendarList/new.json b/providers/google/test/write/calendarList/new.json
new file mode 100644
index 00000000..f32eb6c8
--- /dev/null
+++ b/providers/google/test/write/calendarList/new.json
@@ -0,0 +1,18 @@
+{
+  "kind": "calendar#calendarListEntry",
+  "etag": "\"1738882575255000\"",
+  "id": "en.italian#holiday@group.v.calendar.google.com",
+  "summary": "Holidays in Italy",
+  "description": "Holidays and Observances in Italy",
+  "timeZone": "America/Los_Angeles",
+  "colorId": "7",
+  "backgroundColor": "#19f7f0",
+  "foregroundColor": "#ffffff",
+  "accessRole": "reader",
+  "defaultReminders": [],
+  "conferenceProperties": {
+    "allowedConferenceSolutionTypes": [
+      "hangoutsMeet"
+    ]
+  }
+}
diff --git a/providers/google/test/write/events/new.json b/providers/google/test/write/events/new.json
new file mode 100644
index 00000000..0d6c4904
--- /dev/null
+++ b/providers/google/test/write/events/new.json
@@ -0,0 +1,32 @@
+{
+  "kind": "calendar#event",
+  "etag": "\"3504368700651198\"",
+  "id": "std4ien2l4fovmbup80ov20tp8",
+  "status": "confirmed",
+  "htmlLink": "https://www.google.com/calendar/event?eid=c3RkNGllbjJsNGZvdm1idXA4MG92MjB0cDggaW50ZWdyYXRpb24udGVzdEB3aXRoYW1wZXJzYW5kLmNvbQ",
+  "created": "2025-07-10T21:52:30.000Z",
+  "updated": "2025-07-10T21:52:30.325Z",
+  "summary": "Monthly team meeting",
+  "creator": {
+    "email": "test@withampersand.com",
+    "self": true
+  },
+  "organizer": {
+    "email": "test@withampersand.com",
+    "self": true
+  },
+  "start": {
+    "dateTime": "2025-08-07T15:30:00-07:00",
+    "timeZone": "America/Los_Angeles"
+  },
+  "end": {
+    "dateTime": "2025-08-07T17:22:11-07:00",
+    "timeZone": "America/Los_Angeles"
+  },
+  "iCalUID": "std4ien2l4fovmbup80ov20tp8@google.com",
+  "sequence": 0,
+  "reminders": {
+    "useDefault": true
+  },
+  "eventType": "default"
+}
diff --git a/providers/google/write_test.go b/providers/google/write_test.go
new file mode 100644
index 00000000..3785555e
--- /dev/null
+++ b/providers/google/write_test.go
@@ -0,0 +1,136 @@
+package google
+
+import (
+	"errors"
+	"net/http"
+	"testing"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockcond"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testroutines"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+)
+
+func TestWrite(t *testing.T) { // nolint:funlen,cyclop
+	t.Parallel()
+
+	errorCalendarNoColor := testutils.DataFromFile(t, "write/calendarList/error-missing-color.json")
+	responseInsertCalendar := testutils.DataFromFile(t, "write/calendarList/new.json")
+	responseEvent := testutils.DataFromFile(t, "write/events/new.json")
+
+	tests := []testroutines.Write{
+		{
+			Name:         "Write object must be included",
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:         "Write needs data payload",
+			Input:        common.WriteParams{ObjectName: "calendarList"},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingRecordData},
+		},
+		{
+			Name:  "Bad request from provider",
+			Input: common.WriteParams{ObjectName: "calendarList", RecordData: make(map[string]any)},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentJSON(),
+				Always: mockserver.Response(http.StatusBadRequest, errorCalendarNoColor),
+			}.Server(),
+			ExpectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New( // nolint:goerr113
+					"Missing foreground color.",
+				),
+			},
+		},
+		{
+			Name: "Valid insert of a calendar item to my calendar",
+			Input: common.WriteParams{
+				ObjectName: "calendarList",
+				RecordData: map[string]any{
+					"foregroundColor": "#ffffff",
+					"backgroundColor": "#19f7f0",
+				},
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.MethodPOST(),
+					mockcond.Path("/calendar/v3/users/me/calendarList"),
+					mockcond.QueryParam("colorRgbFormat", "true"),
+				},
+				Then: mockserver.Response(http.StatusOK, responseInsertCalendar),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetWrite,
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "en.italian#holiday@group.v.calendar.google.com",
+				Errors:   nil,
+				Data: map[string]any{
+					"summary":     "Holidays in Italy",
+					"description": "Holidays and Observances in Italy",
+					"timeZone":    "America/Los_Angeles",
+				},
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name: "Write must act as an Update",
+			Input: common.WriteParams{
+				ObjectName: "calendarList",
+				RecordId:   "en.usa#holiday@group.v.calendar.google.com",
+				RecordData: make(map[string]any),
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.MethodPATCH(),
+					mockcond.Path("/calendar/v3/users/me/calendarList/en.usa#holiday@group.v.calendar.google.com"), // nolint:lll
+				},
+				Then: mockserver.Response(http.StatusOK),
+			}.Server(),
+			Expected:     &common.WriteResult{Success: true},
+			ExpectedErrs: nil,
+		},
+		{
+			Name: "Create event",
+			Input: common.WriteParams{
+				ObjectName: "events",
+				RecordData: map[string]any{},
+			},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.MethodPOST(),
+					mockcond.Path("/calendar/v3/calendars/primary/events"),
+				},
+				Then: mockserver.Response(http.StatusOK, responseEvent),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetWrite,
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "std4ien2l4fovmbup80ov20tp8",
+				Errors:   nil,
+				Data: map[string]any{
+					"status":  "confirmed",
+					"summary": "Monthly team meeting",
+				},
+			},
+			ExpectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.WriteConnector, error) {
+				return constructTestCalendarConnector(tt.Server.URL)
+			})
+		})
+	}
+}
diff --git a/test/google/calendar/read/calendarList/main.go b/test/google/calendar/read/calendarList/main.go
new file mode 100644
index 00000000..11842a43
--- /dev/null
+++ b/test/google/calendar/read/calendarList/main.go
@@ -0,0 +1,37 @@
+package main
+
+import (
+	"context"
+	"log/slog"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	connTest "github.com/amp-labs/connectors/test/google"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	conn := connTest.GetGoogleCalendarConnector(ctx)
+
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "calendarList",
+		Fields:     connectors.Fields("description", "backgroundColor"),
+		Deleted:    true,
+	})
+	if err != nil {
+		utils.Fail("error reading from conenctor", "error", err)
+	}
+
+	slog.Info("Reading...")
+	utils.DumpJSON(res, os.Stdout)
+}
diff --git a/test/google/calendar/read/events/main.go b/test/google/calendar/read/events/main.go
new file mode 100644
index 00000000..ccb6953c
--- /dev/null
+++ b/test/google/calendar/read/events/main.go
@@ -0,0 +1,37 @@
+package main
+
+import (
+	"context"
+	"log/slog"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	connTest "github.com/amp-labs/connectors/test/google"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	conn := connTest.GetGoogleCalendarConnector(ctx)
+
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "events",
+		Fields:     connectors.Fields("summary", "description"),
+		Deleted:    true,
+	})
+	if err != nil {
+		utils.Fail("error reading from conenctor", "error", err)
+	}
+
+	slog.Info("Reading...")
+	utils.DumpJSON(res, os.Stdout)
+}
diff --git a/test/google/calendar/read/settings/main.go b/test/google/calendar/read/settings/main.go
new file mode 100644
index 00000000..c4f6d9dd
--- /dev/null
+++ b/test/google/calendar/read/settings/main.go
@@ -0,0 +1,36 @@
+package main
+
+import (
+	"context"
+	"log/slog"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	connTest "github.com/amp-labs/connectors/test/google"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	conn := connTest.GetGoogleCalendarConnector(ctx)
+
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "settings",
+		Fields:     connectors.Fields("id", "value"),
+	})
+	if err != nil {
+		utils.Fail("error reading from connector", "error", err)
+	}
+
+	slog.Info("Reading...")
+	utils.DumpJSON(res, os.Stdout)
+}
diff --git a/test/google/calendar/write-delete/calendarList/main.go b/test/google/calendar/write-delete/calendarList/main.go
new file mode 100644
index 00000000..3a9e8337
--- /dev/null
+++ b/test/google/calendar/write-delete/calendarList/main.go
@@ -0,0 +1,64 @@
+package main
+
+import (
+	"context"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors/internal/datautils"
+	connTest "github.com/amp-labs/connectors/test/google"
+	"github.com/amp-labs/connectors/test/utils"
+	"github.com/amp-labs/connectors/test/utils/testscenario"
+)
+
+const (
+	italianCalendar   = "en.italian#holiday@group.v.calendar.google.com"
+	colorForeground   = "#ffffff"
+	colorBeforeUpdate = "#19f7f0"
+	colorAfterUpdate  = "#ff69b4"
+)
+
+type payload struct {
+	ID              string `json:"id"`
+	ForegroundColor string `json:"foregroundColor"`
+	BackgroundColor string `json:"backgroundColor"`
+	Selected        bool   `json:"selected"`
+}
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	conn := connTest.GetGoogleCalendarConnector(ctx)
+
+	testscenario.ValidateCreateUpdateDelete(ctx, conn,
+		"calendarList",
+		payload{
+			ID:              italianCalendar,
+			ForegroundColor: colorForeground,
+			BackgroundColor: colorBeforeUpdate,
+			Selected:        true,
+		},
+		payload{
+			ID:              italianCalendar,
+			ForegroundColor: colorForeground,
+			BackgroundColor: colorAfterUpdate,
+			Selected:        true,
+		},
+		testscenario.CRUDTestSuite{
+			ReadFields: datautils.NewSet("id", "backgroundColor"),
+			SearchBy: testscenario.Property{
+				Key:   "backgroundcolor",
+				Value: colorBeforeUpdate,
+			},
+			RecordIdentifierKey: "id",
+			UpdatedFields: map[string]string{
+				"backgroundcolor": colorAfterUpdate,
+			},
+		},
+	)
+}
diff --git a/test/google/calendar/write-delete/events/main.go b/test/google/calendar/write-delete/events/main.go
new file mode 100644
index 00000000..42180ab3
--- /dev/null
+++ b/test/google/calendar/write-delete/events/main.go
@@ -0,0 +1,77 @@
+package main
+
+import (
+	"context"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"github.com/amp-labs/connectors/internal/datautils"
+	connTest "github.com/amp-labs/connectors/test/google"
+	"github.com/amp-labs/connectors/test/utils"
+	"github.com/amp-labs/connectors/test/utils/testscenario"
+	"github.com/brianvoe/gofakeit/v6"
+)
+
+type createPayload struct {
+	Start   dateTime `json:"start"`
+	End     dateTime `json:"end"`
+	Summary string   `json:"summary"`
+}
+
+type updatePayload struct {
+	Summary string `json:"summary"`
+}
+
+type dateTime struct {
+	DateTime string `json:"dateTime"`
+	TimeZone string `json:"timeZone"`
+}
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	conn := connTest.GetGoogleCalendarConnector(ctx)
+
+	summaryBefore := gofakeit.Name()
+	summaryAfter := gofakeit.Name()
+
+	startTime := time.Now().Add(time.Hour)
+	endTime := startTime.Add(time.Hour)
+	startTimeStr := datautils.Time.FormatRFC3339inUTCWithMilliseconds(startTime)
+	endTimeStr := datautils.Time.FormatRFC3339inUTCWithMilliseconds(endTime)
+
+	testscenario.ValidateCreateUpdateDelete(ctx, conn,
+		"events",
+		createPayload{
+			Start: dateTime{
+				DateTime: startTimeStr,
+				TimeZone: "America/Los_Angeles",
+			},
+			End: dateTime{
+				DateTime: endTimeStr,
+				TimeZone: "America/Los_Angeles",
+			},
+			Summary: summaryBefore,
+		},
+		updatePayload{
+			Summary: summaryAfter,
+		},
+		testscenario.CRUDTestSuite{
+			ReadFields: datautils.NewSet("id", "summary"),
+			SearchBy: testscenario.Property{
+				Key:   "summary",
+				Value: summaryBefore,
+			},
+			RecordIdentifierKey: "id",
+			UpdatedFields: map[string]string{
+				"summary": summaryAfter,
+			},
+		},
+	)
+}