diff --git a/providers/goTo.go b/providers/goTo.go index 856c96a46..1a2f945c4 100644 --- a/providers/goTo.go +++ b/providers/goTo.go @@ -1,7 +1,17 @@ package providers -const GoTo Provider = "goTo" +import "github.com/amp-labs/connectors/common" +const ( + GoTo Provider = "goTo" + // ModuleGoTo covers the api.getgo.com base URL, which serves multiple GoTo + // products (admin, meetings, webinars, etc). We name it just "goTo" so users + // don't have to guess which specific product it maps to. + ModuleGoTo common.ModuleID = "goTo" + ModuleGoToConnect common.ModuleID = "goToConnect" +) + +// nolint: funlen func init() { SetInfo(GoTo, ProviderInfo{ DisplayName: "GoTo", @@ -39,5 +49,38 @@ func init() { Subscribe: false, Write: false, }, + PostAuthInfoNeeded: true, + Metadata: &ProviderMetadata{ + PostAuthentication: []MetadataItemPostAuthentication{ + { + Name: "accountKey", + ModuleDependencies: &ModuleDependencies{ + ModuleGoTo: ModuleDependency{}, + ModuleGoToConnect: ModuleDependency{}, + }, + }, + }, + }, + DefaultModule: ModuleGoTo, + Modules: &Modules{ + ModuleGoTo: { + BaseURL: "https://api.getgo.com", + DisplayName: "GoTo", + Support: Support{ + Read: false, + Subscribe: false, + Write: false, + }, + }, + ModuleGoToConnect: { + BaseURL: "https://api.goto.com", + DisplayName: "GoTo Connect", + Support: Support{ + Read: false, + Subscribe: false, + Write: false, + }, + }, + }, }) } diff --git a/providers/goto/authmetadata.go b/providers/goto/authmetadata.go new file mode 100644 index 000000000..37abdd90e --- /dev/null +++ b/providers/goto/authmetadata.go @@ -0,0 +1,75 @@ +package gotoconn + +import ( + "context" + "strconv" + + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/common/urlbuilder" + "github.com/amp-labs/connectors/providers" +) + +func (c *Connector) GetPostAuthInfo(ctx context.Context) (*common.PostAuthInfo, error) { + accountKey, err := c.retrieveAccountKey(ctx) + if err != nil { + return nil, err + } + + if accountKey == "" { + return nil, common.ErrMissingExpectedValues + } + + c.accountKey = accountKey + + catalogVars := map[string]string{ + "accountKey": accountKey, + } + + return &common.PostAuthInfo{ + CatalogVars: &catalogVars, + }, nil +} + +func (c *Connector) retrieveAccountKey(ctx context.Context) (string, error) { + url, err := c.getMeURL() + if err != nil { + return "", err + } + + resp, err := c.JSONHTTPClient().Get(ctx, url.String()) + if err != nil { + return "", err + } + + data, err := common.UnmarshalJSON[map[string]any](resp) + if err != nil { + return "", common.ErrFailedToUnmarshalBody + } + + if data == nil { + return "", common.ErrMissingExpectedValues + } + + rawAccountKey, ok := (*data)["accountKey"] + if !ok { + return "", common.ErrMissingExpectedValues + } + + switch v := rawAccountKey.(type) { + case string: + return v, nil + case float64: + return strconv.FormatInt(int64(v), 10), nil + default: + return "", common.ErrMissingExpectedValues + } +} + +func (c *Connector) getMeURL() (*urlbuilder.URL, error) { + // /me lives on the goTo (api.getgo.com) module, so we resolve that + // module's BaseURL explicitly — even when the connector was created + // with the goToConnect module selected. + baseURL := c.ProviderInfo().ReadModuleInfo(providers.ModuleGoTo).BaseURL + + return urlbuilder.New(baseURL, "/admin/rest/v1/me") +} diff --git a/providers/goto/authmetamodel.go b/providers/goto/authmetamodel.go new file mode 100644 index 000000000..12253b7a3 --- /dev/null +++ b/providers/goto/authmetamodel.go @@ -0,0 +1,18 @@ +package gotoconn + +type AuthMetadataVars struct { + AccountKey string +} + +// NewAuthMetadataVars parses map into the model. +func NewAuthMetadataVars(dictionary map[string]string) *AuthMetadataVars { + return &AuthMetadataVars{ + AccountKey: dictionary["accountKey"], + } +} + +func (v AuthMetadataVars) AsMap() *map[string]string { + return &map[string]string{ + "accountKey": v.AccountKey, + } +} diff --git a/providers/goto/connector.go b/providers/goto/connector.go new file mode 100644 index 000000000..b7e9e0e2b --- /dev/null +++ b/providers/goto/connector.go @@ -0,0 +1,103 @@ +// Package gotoconn implements the GoTo connector. +// +// The package is named "gotoconn" instead of "goto" because "goto" is a +// reserved keyword in Go and cannot be used as a package identifier. The +// "conn" suffix is short for "connector". +package gotoconn + +import ( + "context" + + "github.com/amp-labs/connectors" + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/internal/components" + "github.com/amp-labs/connectors/providers" + "github.com/amp-labs/connectors/providers/goto/internal/gotocore" +) + +type Connector struct { + // Basic connector + *components.Connector + + // Require authenticated client & account + common.RequireAuthenticatedClient + common.PostAuthInfo + + // gotoCore handles api.getgo.com endpoints (Webinar, etc). + gotoCore *gotocore.Adapter + + // TODO: We don't have sandbox access to api.goto.com, + // so the gotoconnect is not implemented yet. + // gotoConnect *gotocore.Adapter + + accountKey string +} + +func NewConnector(params common.ConnectorParams) (*Connector, error) { + if params.Module == "" { + params.Module = providers.ModuleGoTo + } + + conn, err := components.Initialize(providers.GoTo, params, + func(base *components.Connector) (*Connector, error) { + return &Connector{Connector: base}, nil + }, + ) + if err != nil { + return nil, err + } + + authMetadata := NewAuthMetadataVars(params.Metadata) + conn.accountKey = authMetadata.AccountKey + + if err := initModuleAdapters(conn, params); err != nil { + return nil, err + } + + return conn, nil +} + +func initModuleAdapters(conn *Connector, params common.ConnectorParams) error { + switch conn.Module() { //nolint:exhaustive + case providers.ModuleGoTo: + adapter, err := gotocore.NewAdapter(params, conn.accountKey) + if err != nil { + return err + } + + conn.gotoCore = adapter + case providers.ModuleGoToConnect: + return common.ErrUnsupportedModule + // adapter, err := gotocore.NewAdapter(params, conn.accountKey) + // if err != nil { + // return err + // } + + // conn.gotoConnect = adapter + default: + return common.ErrUnsupportedModule + } + + return nil +} + +// SetBaseURL fans the override out to any active module adapter so that +// unit tests pointing at a mock server reach the same host the top-level +// connector now uses. +func (c *Connector) SetBaseURL(newURL string) { + c.Connector.SetBaseURL(newURL) + + if c.gotoCore != nil { + c.gotoCore.SetBaseURL(newURL) + } +} + +func (c *Connector) ListObjectMetadata( + ctx context.Context, objectNames []string, +) (*connectors.ListObjectMetadataResult, error) { + if c.gotoCore != nil { + return c.gotoCore.ListObjectMetadata(ctx, objectNames) + } + + return nil, common.ErrNotImplemented +} diff --git a/providers/goto/internal/gotocore/adapter.go b/providers/goto/internal/gotocore/adapter.go new file mode 100644 index 000000000..b02564bf1 --- /dev/null +++ b/providers/goto/internal/gotocore/adapter.go @@ -0,0 +1,46 @@ +// Package gotocore handles GoTo's core API functionality. +// This includes endpoints for managing webinars and other products. +// The package name "gotocore" is internal shorthand — +// it does not imply core-only access. +package gotocore + +import ( + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/internal/components" + "github.com/amp-labs/connectors/internal/components/operations" + "github.com/amp-labs/connectors/internal/components/schema" + "github.com/amp-labs/connectors/providers" +) + +type Adapter struct { + *components.Connector + components.SchemaProvider + + accountKey string +} + +func NewAdapter(params common.ConnectorParams, accountKey string) (*Adapter, error) { + adapter, err := components.Initialize(providers.GoTo, params, constructor) + if err != nil { + return nil, err + } + + adapter.accountKey = accountKey + + return adapter, nil +} + +func constructor(base *components.Connector) (*Adapter, error) { + adapter := &Adapter{Connector: base} + + adapter.SchemaProvider = schema.NewObjectSchemaProvider( + adapter.HTTPClient().Client, + schema.FetchModeParallel, + operations.SingleObjectMetadataHandlers{ + BuildRequest: adapter.buildSingleObjectMetadataRequest, + ParseResponse: adapter.parseSingleObjectMetadataResponse, + }, + ) + + return adapter, nil +} diff --git a/providers/goto/internal/gotocore/handlers.go b/providers/goto/internal/gotocore/handlers.go new file mode 100644 index 000000000..d7c2fce02 --- /dev/null +++ b/providers/goto/internal/gotocore/handlers.go @@ -0,0 +1,94 @@ +package gotocore + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/common/naming" + "github.com/amp-labs/connectors/common/urlbuilder" +) + +const ( + queryParamSize = "size" + queryParamPageSize = "pageSize" + sampleSize = "1" + + // metadataSampleWindowDays is the size in days of the time-range filter + // applied when sampling records for schema. Wide enough to + // catch at least one record on endpoints that mandate a + // time-range filter. + metadataSampleWindowDays = 120 +) + +func (a *Adapter) buildSingleObjectMetadataRequest(ctx context.Context, objectName string) (*http.Request, error) { + url, err := a.buildObjectURL(objectName) + if err != nil { + return nil, err + } + + applyMetadataTimeFilter(url, objectName) + + return http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil) +} + +// applyMetadataTimeFilter adds the mandatory time-range query params for +// endpoints that require them. The window is wide enough (past 120 days, +// plus 120 days into the future for endpoints that accept upcoming records) +// to maximize the chance of sampling at least one record for schema +// inference. +func applyMetadataTimeFilter(url *urlbuilder.URL, objectName string) { + setWindow := func(startParam, endParam string, pastDays, futureDays int) { + now := time.Now().UTC() + url.WithQueryParam(startParam, now.AddDate(0, 0, -pastDays).Format(time.RFC3339)) + url.WithQueryParam(endParam, now.AddDate(0, 0, futureDays).Format(time.RFC3339)) + } + + switch objectName { + case "historicalMeetings": + setWindow("startDate", "endDate", metadataSampleWindowDays, 0) + case "webinars": + setWindow("fromTime", "toTime", metadataSampleWindowDays, metadataSampleWindowDays) + case "sessions": + setWindow("fromTime", "toTime", metadataSampleWindowDays, 0) + } +} + +func (a *Adapter) parseSingleObjectMetadataResponse( + ctx context.Context, + objectName string, + request *http.Request, + response *common.JSONHTTPResponse, +) (*common.ObjectMetadata, error) { + objectMetadata := common.ObjectMetadata{ + Fields: make(map[string]common.FieldMetadata), + DisplayName: naming.CapitalizeFirstLetterEveryWord(naming.SeparateUnderscoreWords(objectName)), + } + + records, err := extractRecords(response, objectName) + if err != nil { + return nil, err + } + + if len(records) == 0 { + return nil, common.ErrMissingExpectedValues + } + + firstRecord, ok := records[0].(map[string]any) + if !ok { + return nil, fmt.Errorf("couldn't convert the first record to a map: %w", common.ErrMissingExpectedValues) + } + + for field, value := range firstRecord { + valueType := analyzeValue(value) + objectMetadata.Fields[field] = common.FieldMetadata{ + DisplayName: field, + ValueType: valueType, + ProviderType: string(valueType), + } + } + + return &objectMetadata, nil +} diff --git a/providers/goto/internal/gotocore/support.go b/providers/goto/internal/gotocore/support.go new file mode 100644 index 000000000..171f9c669 --- /dev/null +++ b/providers/goto/internal/gotocore/support.go @@ -0,0 +1,80 @@ +package gotocore + +import ( + "github.com/amp-labs/connectors/internal/datautils" +) + +// objectService identifies which GoTo API surface an object belongs to. +// "Service" rather than "module" so it does not collide with the +// connector-level common.ModuleID concept (ModuleGoTo / ModuleGoToConnect), +// and rather than "product" because not every entry maps to a GoTo product — +// SCIM and Admin are cross-cutting APIs, while Webinar/Meetings/Assist are +// product APIs. +type objectService string + +const ( + serviceSCIM objectService = "scim" + serviceAdmin objectService = "admin" + serviceWebinar objectService = "webinar" + serviceMeetings objectService = "meetings" + serviceRemoteSupport objectService = "assistRemoteSupport" + serviceCorporate objectService = "assistCorporate" +) + +// objectConfig describes how to fetch a sample record for a GoTo object on +// the api.getgo.com host. Both metadata and (eventually) read operations +// consult this registry. +type objectConfig struct { + // path is the URL template under the BaseURL. The literal {accountKey} + // is substituted with the connector's account key at resolve time. + path string + + // service identifies which GoTo API surface hosts this object (Webinar, + // Meeting, Admin, SCIM, etc). Different services share the api.getgo.com + // host but use different path prefixes (G2W/, G2M/, admin/, identity/) + // and return their records under different response keys, so request + // building and pagination depend on this value. + service objectService +} + +const accountKeyPlaceholder = "{accountKey}" + +// objectRegistry maps object names to their endpoint metadata. +var objectRegistry = datautils.Map[string, objectConfig]{ //nolint:gochecknoglobals + // GoToMeeting API + "historicalMeetings": {path: "G2M/rest/historicalMeetings", service: serviceMeetings}, + "upcomingMeetings": {path: "G2M/rest/upcomingMeetings", service: serviceMeetings}, + + // GoToWebinar API + "webinars": {path: "G2W/rest/v2/organizers/{accountKey}/webinars", service: serviceWebinar}, + // For webhooks and userSubscriptions, the productType query parameter is required + // and must be set to "g2w" to retrieve webinar webhooks. + // Ref: https://developer.goto.com/GoToWebinarV2#tag/Webhooks/operation/getWebhooks + "webhooks": {path: "G2W/rest/v2/webhooks?productType=g2w", service: serviceWebinar}, + "userSubscriptions": {path: "G2W/rest/v2/userSubscriptions?productType=g2w", service: serviceWebinar}, + + // GoToAssist Corporate API + "representatives": {path: "G2AC/rest/v1/representatives/pages", service: serviceCorporate}, + "teams": {path: "G2AC/rest/v1/teams/pages", service: serviceCorporate}, + "portals": {path: "G2AC/rest/v1/portals/pages", service: serviceCorporate}, + + // GoToAssist Remote Support API + // We use "sessions" as the object name for extended sessions because this is + // just an extended version of the normal sessions endpoint. The normal sessions + // endpoint requires us to specify the type of sessions and only returns that + // type, while the extended sessions endpoint returns all types of sessions + // without requiring us to specify the type. + "sessions": {path: "G2A/rest/v1/extendedsessions", service: serviceRemoteSupport}, + + // Admin API + "attributes": {path: "admin/rest/v1/accounts/{accountKey}/attributes", service: serviceAdmin}, + "licenses": {path: "admin/rest/v1/accounts/{accountKey}/licenses", service: serviceAdmin}, + "rolesets": {path: "admin/rest/v1/accounts/{accountKey}/rolesets", service: serviceAdmin}, + "templates": {path: "admin/rest/v1/accounts/{accountKey}/templates", service: serviceAdmin}, + "admin/users": {path: "admin/rest/v1/accounts/{accountKey}/users", service: serviceAdmin}, + "admin/groups": {path: "admin/rest/v1/accounts/{accountKey}/groups", service: serviceAdmin}, + + // SCIM API + "users": {path: "identity/v1/Users", service: serviceSCIM}, + "groups": {path: "identity/v1/Groups", service: serviceSCIM}, +} diff --git a/providers/goto/internal/gotocore/utils.go b/providers/goto/internal/gotocore/utils.go new file mode 100644 index 000000000..1b1c7c67d --- /dev/null +++ b/providers/goto/internal/gotocore/utils.go @@ -0,0 +1,108 @@ +package gotocore + +import ( + "fmt" + "reflect" + "strings" + + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/common/urlbuilder" +) + +func (a *Adapter) buildObjectURL(objectName string) (*urlbuilder.URL, error) { + spec, ok := objectRegistry[objectName] + if !ok || spec.path == "" { + spec.path = objectName + } + + path := strings.ReplaceAll(spec.path, accountKeyPlaceholder, a.accountKey) + + url, err := urlbuilder.New(a.ModuleInfo().BaseURL, path) + if err != nil { + return nil, fmt.Errorf("error building URL for object %s: %w", objectName, err) + } + + if spec.service == serviceAdmin { + // Admin API uses "pageSize" for pagination, while other services use "size". + url.WithQueryParam(queryParamPageSize, sampleSize) + } else { + url.WithQueryParam(queryParamSize, sampleSize) + } + + return url, nil +} + +// extractRecords pulls the records array out of a GoTo response. Response +// shapes vary by service: SCIM wraps under "resources", Admin under +// "results", G2W under "_embedded.", and some endpoints (e.g. +// G2M historicalMeetings) return a bare top-level JSON array. +func extractRecords(response *common.JSONHTTPResponse, objectName string) ([]any, error) { + if records, err := common.UnmarshalJSON[[]any](response); err == nil && records != nil { + return *records, nil + } + + body, err := common.UnmarshalJSON[map[string]any](response) + if err != nil || body == nil { + return nil, common.ErrFailedToUnmarshalBody + } + + cfg, ok := objectRegistry[objectName] + if !ok { + return nil, fmt.Errorf("%w: no object config for %s", common.ErrMissingExpectedValues, objectName) + } + + return extractRecordsByService(*body, cfg.service, objectName) +} + +func extractRecordsByService(body map[string]any, service objectService, objectName string) ([]any, error) { + switch service { //nolint:exhaustive // _embedded shape is the default; remaining services fall through. + case serviceSCIM: + return readArrayKey(body, "resources", objectName) + case serviceAdmin: + return readArrayKey(body, "results", objectName) + case serviceRemoteSupport: + return readArrayKey(body, objectName, objectName) + default: + // Webinar or any future GoTo services that + // share the standard HAL-style envelope return records under + // _embedded.. + embedded, ok := body["_embedded"].(map[string]any) + if !ok { + return nil, fmt.Errorf("%w: unrecognized response shape for object %s", + common.ErrMissingExpectedValues, objectName) + } + + return readArrayKey(embedded, objectName, objectName) + } +} + +func readArrayKey(m map[string]any, key, objectName string) ([]any, error) { + records, ok := m[key].([]any) + if !ok { + return nil, fmt.Errorf("%w: %s response is missing %q key", common.ErrMissingExpectedValues, objectName, key) + } + + return records, nil +} + +func analyzeValue(value any) common.ValueType { + if value == nil { + return common.ValueTypeOther + } + + v := reflect.ValueOf(value) + if !v.IsValid() { + return common.ValueTypeOther + } + + switch v.Kind() { //nolint:exhaustive + case reflect.String: + return common.ValueTypeString + case reflect.Float64: + return common.ValueTypeFloat + case reflect.Bool: + return common.ValueTypeBoolean + default: + return common.ValueTypeOther + } +} diff --git a/providers/goto/metadata_test.go b/providers/goto/metadata_test.go new file mode 100644 index 000000000..d8b3c0b66 --- /dev/null +++ b/providers/goto/metadata_test.go @@ -0,0 +1,117 @@ +package gotoconn + +import ( + "testing" + + "github.com/amp-labs/connectors" + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/providers" + "github.com/amp-labs/connectors/test/utils/mockutils" + "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" +) + +const testAccountKey = "8967235839898" + +func TestListObjectMetadata(t *testing.T) { //nolint:funlen + t.Parallel() + + webinarsResponse := testutils.DataFromFile(t, "webinars.json") + sessionsResponse := testutils.DataFromFile(t, "sessions.json") + + tests := []testroutines.Metadata{ + { + Name: "At least one object name must be queried", + Input: nil, + Server: mockserver.Dummy(), + ExpectedErrs: []error{common.ErrMissingObjects}, + }, + { + Name: "Webinars metadata is sampled from organizer-scoped endpoint", + Input: []string{"webinars"}, + Server: mockserver.Conditional{ + Setup: mockserver.ContentJSON(), + If: mockcond.And{ + mockcond.Path("/G2W/rest/v2/organizers/" + testAccountKey + "/webinars"), + mockcond.QueryParam("size", "1"), + }, + Then: mockserver.Response(200, webinarsResponse), + }.Server(), + Comparator: testroutines.ComparatorSubsetMetadata, + Expected: &common.ListObjectMetadataResult{ + Result: map[string]common.ObjectMetadata{ + "webinars": { + DisplayName: "Webinars", + Fields: map[string]common.FieldMetadata{ + "webinarKey": {DisplayName: "webinarKey", ValueType: common.ValueTypeString, ProviderType: "string"}, + "subject": {DisplayName: "subject", ValueType: common.ValueTypeString, ProviderType: "string"}, + "numberOfRegistrants": {DisplayName: "numberOfRegistrants", ValueType: common.ValueTypeFloat, ProviderType: "float"}, + "inSession": {DisplayName: "inSession", ValueType: common.ValueTypeBoolean, ProviderType: "boolean"}, + "times": {DisplayName: "times", ValueType: common.ValueTypeOther, ProviderType: "other"}, + }, + }, + }, + Errors: map[string]error{}, + }, + ExpectedErrs: nil, + }, + { + Name: "Sessions metadata is sampled from GoToAssist extended sessions endpoint", + Input: []string{"sessions"}, + Server: mockserver.Conditional{ + Setup: mockserver.ContentJSON(), + If: mockcond.And{ + mockcond.Path("/G2A/rest/v1/extendedsessions"), + mockcond.QueryParam("size", "1"), + }, + Then: mockserver.Response(200, sessionsResponse), + }.Server(), + Comparator: testroutines.ComparatorSubsetMetadata, + Expected: &common.ListObjectMetadataResult{ + Result: map[string]common.ObjectMetadata{ + "sessions": { + DisplayName: "Sessions", + Fields: map[string]common.FieldMetadata{ + "sessionId": {DisplayName: "sessionId", ValueType: common.ValueTypeString, ProviderType: "string"}, + "sessionType": {DisplayName: "sessionType", ValueType: common.ValueTypeString, ProviderType: "string"}, + "status": {DisplayName: "status", ValueType: common.ValueTypeString, ProviderType: "string"}, + "expertName": {DisplayName: "expertName", ValueType: common.ValueTypeString, ProviderType: "string"}, + }, + }, + }, + Errors: map[string]error{}, + }, + ExpectedErrs: nil, + }, + } + + for _, tt := range tests { + //nolint:varnamelen + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + + tt.Run(t, func() (connectors.ObjectMetadataConnector, error) { + return constructTestConnector(tt.Server.URL) + }) + }) + } +} + +func constructTestConnector(serverURL string) (*Connector, error) { + connector, err := NewConnector(common.ConnectorParams{ + AuthenticatedClient: mockutils.NewClient(), + Metadata: map[string]string{ + "accountKey": testAccountKey, + }, + Module: providers.ModuleGoTo, + }) + if err != nil { + return nil, err + } + + connector.SetBaseURL(mockutils.ReplaceURLOrigin(connector.HTTPClient().Base, serverURL)) + + return connector, nil +} diff --git a/providers/goto/test/sessions.json b/providers/goto/test/sessions.json new file mode 100644 index 000000000..07e10e5e4 --- /dev/null +++ b/providers/goto/test/sessions.json @@ -0,0 +1,24 @@ +{ + "totalNumSessions": 500, + "sessions": [ + { + "sessionId": "SS-87555", + "sessionType": "screen_sharing", + "sessionStartToken": 8555444, + "customerJoinUrl": "https://logmein.com/join", + "partnerObject": 7559966, + "partnerObjectUrl": "https://logmein.com/partners/view", + "status": "complete", + "startedAt": "2021-04-30T06:00:00Z", + "customerJoinedAt": "2021-04-30T06:01:00Z", + "endedAt": "2021-04-30T06:05:00Z", + "expertName": "John Doe", + "expertEmail": "john.doe@logmein.com", + "expertUserKey": "31416", + "customerName": "Jane Doe", + "customerEmail": "jane.doe@logmein.com", + "accountKey": 741599, + "sessionRecordingUrl": "https://logmein.com/session" + } + ] +} \ No newline at end of file diff --git a/providers/goto/test/webinars.json b/providers/goto/test/webinars.json new file mode 100644 index 000000000..4657657f6 --- /dev/null +++ b/providers/goto/test/webinars.json @@ -0,0 +1,32 @@ +{ + "_embedded": { + "webinars": [ + { + "webinarKey": "7878787878787878", + "webinarID": "123456789012345678", + "subject": "Introduction to GoToWebinar", + "description": "Learn how to use GoToWebinar", + "organizerKey": "8967235839898", + "registrationUrl": "https://attendee.gotowebinar.com/register/7878787878787878", + "inSession": false, + "numberOfRegistrants": 42, + "type": "single_session", + "locale": "en_US", + "approvalType": "automatic", + "isOndemand": false, + "times": [ + { + "startTime": "2024-10-15T14:00:00Z", + "endTime": "2024-10-15T15:00:00Z" + } + ] + } + ] + }, + "page": { + "size": 1, + "totalElements": 12, + "totalPages": 12, + "number": 0 + } +} diff --git a/test/goto/auth-metadata/main.go b/test/goto/auth-metadata/main.go new file mode 100644 index 000000000..3d249b71e --- /dev/null +++ b/test/goto/auth-metadata/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "context" + "log/slog" + "os/signal" + "syscall" + + "github.com/amp-labs/connectors/providers" + gotoconn "github.com/amp-labs/connectors/providers/goto" + connTest "github.com/amp-labs/connectors/test/goto" + "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.GetGoToConnector(ctx, providers.ModuleGoTo) + + info, err := conn.GetPostAuthInfo(ctx) + if err != nil || info.CatalogVars == nil { + utils.Fail("error obtaining auth info", "error", err) + } + + accountKey := gotoconn.NewAuthMetadataVars(*info.CatalogVars).AccountKey + + // Log the retrieved account key. + slog.Info("retrieved auth metadata", "account key", accountKey) +} diff --git a/test/goto/connector.go b/test/goto/connector.go new file mode 100644 index 000000000..3c7fd4f7c --- /dev/null +++ b/test/goto/connector.go @@ -0,0 +1,55 @@ +package goto_test + +import ( + "context" + "net/http" + + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/common/scanning/credscanning" + "github.com/amp-labs/connectors/providers" + gotoconn "github.com/amp-labs/connectors/providers/goto" + "github.com/amp-labs/connectors/test/utils" + "golang.org/x/oauth2" +) + +func GetGoToConnector(ctx context.Context, module common.ModuleID) *gotoconn.Connector { + filePath := credscanning.LoadPath(providers.GoTo) + reader := utils.MustCreateProvCredJSON(filePath, true) + + client, err := common.NewOAuthHTTPClient(ctx, + common.WithOAuthClient(http.DefaultClient), + common.WithOAuthConfig(getConfig(reader)), + common.WithOAuthToken(reader.GetOauthToken()), + ) + if err != nil { + utils.Fail(err.Error()) + } + + conn, err := gotoconn.NewConnector(common.ConnectorParams{ + AuthenticatedClient: client, + Module: module, + Metadata: map[string]string{ + "accountKey": "5276072959790856388", + }, + }) + if err != nil { + utils.Fail("create goto connector", "error: ", err) + } + + return conn +} + +func getConfig(reader *credscanning.ProviderCredentials) *oauth2.Config { + cfg := &oauth2.Config{ + ClientID: reader.Get(credscanning.Fields.ClientId), + ClientSecret: reader.Get(credscanning.Fields.ClientSecret), + RedirectURL: "https://dev-api.withampersand.com/callbacks/v1/oauth", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://authentication.logmeininc.com/oauth/authorize", + TokenURL: "https://authentication.logmeininc.com/oauth/token", + AuthStyle: oauth2.AuthStyleInParams, + }, + } + + return cfg +} diff --git a/test/goto/metadata/main.go b/test/goto/metadata/main.go new file mode 100644 index 000000000..072d57e0a --- /dev/null +++ b/test/goto/metadata/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/amp-labs/connectors/providers" + connTest "github.com/amp-labs/connectors/test/goto" + "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() + + utils.SetupLogging() + + conn := connTest.GetGoToConnector(ctx, providers.ModuleGoTo) + + m, err := conn.ListObjectMetadata(ctx, []string{"historicalMeetings", "webinars"}) + if err != nil { + utils.Fail("error listing metadata for GoTo", "error", err) + } + + utils.DumpJSON(m.Result, os.Stdout) + fmt.Println("Errors: ", m.Errors) +}