Repository: amp-labs/connectors
Pull request #1565 (2025-04-16 05:49:18)

chore: Renamed the goToWebinar connector name to goTo


Patch:
diff --git a/providers/goToWebinar.go b/providers/goTo.go
similarity index 92%
rename from providers/goToWebinar.go
rename to providers/goTo.go
index d5075b37..856c96a4 100644
--- a/providers/goToWebinar.go
+++ b/providers/goTo.go
@@ -1,10 +1,10 @@
 package providers
 
-const GoToWebinar Provider = "goToWebinar"
+const GoTo Provider = "goTo"
 
 func init() {
-	SetInfo(GoToWebinar, ProviderInfo{
-		DisplayName: "GoToWebinar",
+	SetInfo(GoTo, ProviderInfo{
+		DisplayName: "GoTo",
 		AuthType:    Oauth2,
 		BaseURL:     "https://api.getgo.com",
 		Oauth2Opts: &Oauth2Opts{
diff --git a/providers/servicenow/connector.go b/providers/servicenow/connector.go
new file mode 100644
index 00000000..93aa41c8
--- /dev/null
+++ b/providers/servicenow/connector.go
@@ -0,0 +1,81 @@
+package servicenow
+
+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/reader"
+	"github.com/amp-labs/connectors/internal/components/schema"
+	"github.com/amp-labs/connectors/internal/components/writer"
+	"github.com/amp-labs/connectors/providers"
+)
+
+const (
+	restAPIPrefix = "api"
+)
+
+type Connector struct {
+	// Basic connector
+	*components.Connector
+
+	// Require authenticated client
+	common.RequireAuthenticatedClient
+	// Require workspace
+	common.RequireWorkspace
+
+	// Supported operations
+	components.SchemaProvider
+	components.Reader
+	components.Writer
+}
+
+func NewConnector(params common.Parameters) (*Connector, error) {
+	return components.Initialize(providers.ServiceNow, params, constructor)
+}
+
+func constructor(base *components.Connector) (*Connector, error) {
+	connector := &Connector{Connector: base}
+
+	registry, err := components.NewEndpointRegistry(supportedOperations())
+	if err != nil {
+		return nil, err
+	}
+
+	// Set the metadata provider for the connector
+	connector.SchemaProvider = schema.NewObjectSchemaProvider(
+		connector.HTTPClient().Client,
+		schema.FetchModeSerial,
+		operations.SingleObjectMetadataHandlers{
+			// Retrieving metadata using individual object calls can lead to rate limiting issues.
+			// Additionally, the rate limits may vary depending on the caller's roles.
+			BuildRequest:  connector.buildSingleObjectMetadataRequest,
+			ParseResponse: connector.parseSingleObjectMetadataResponse,
+		},
+	)
+
+	// Set the read provider for the connector
+	connector.Reader = reader.NewHTTPReader(
+		connector.HTTPClient().Client,
+		registry,
+		common.ModuleRoot,
+		operations.ReadHandlers{
+			BuildRequest:  connector.buildReadRequest,
+			ParseResponse: connector.parseReadResponse,
+			ErrorHandler:  common.InterpretError,
+		},
+	)
+
+	// Set the write provider for the connector
+	connector.Writer = writer.NewHTTPWriter(
+		connector.HTTPClient().Client,
+		registry,
+		common.ModuleRoot,
+		operations.WriteHandlers{
+			BuildRequest:  connector.buildWriteRequest,
+			ParseResponse: connector.parseWriteResponse,
+			ErrorHandler:  common.InterpretError,
+		},
+	)
+
+	return connector, nil
+}
diff --git a/providers/servicenow/handlers.go b/providers/servicenow/handlers.go
new file mode 100644
index 00000000..3dcb0333
--- /dev/null
+++ b/providers/servicenow/handlers.go
@@ -0,0 +1,168 @@
+package servicenow
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/naming"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/amp-labs/connectors/internal/jsonquery"
+	"github.com/spyzhov/ajson"
+)
+
+type responseData struct {
+	Result []map[string]any `json:"result"`
+	// Other fields
+}
+
+func (c *Connector) buildSingleObjectMetadataRequest(ctx context.Context, objectName string) (*http.Request, error) {
+	url, err := urlbuilder.New(c.ProviderInfo().BaseURL, restAPIPrefix, objectName)
+	if err != nil {
+		return nil, err
+	}
+
+	return http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil)
+}
+
+func (c *Connector) parseSingleObjectMetadataResponse(
+	ctx context.Context,
+	objectName string,
+	request *http.Request,
+	response *common.JSONHTTPResponse,
+) (*common.ObjectMetadata, error) {
+	objectMetadata := common.ObjectMetadata{
+		FieldsMap:   make(map[string]string),
+		DisplayName: naming.CapitalizeFirstLetterEveryWord(objectName),
+	}
+
+	res, err := common.UnmarshalJSON[responseData](response)
+	if err != nil {
+		return nil, common.ErrFailedToUnmarshalBody
+	}
+
+	if len(res.Result) == 0 {
+		return nil, common.ErrMissingExpectedValues
+	}
+
+	// Using the first result data to generate the metadata.
+	for k := range res.Result[0] {
+		objectMetadata.FieldsMap[k] = k
+	}
+
+	return &objectMetadata, nil
+}
+
+func (c *Connector) buildReadRequest(ctx context.Context, params common.ReadParams) (*http.Request, error) {
+	url, err := urlbuilder.New(c.ProviderInfo().BaseURL, restAPIPrefix, params.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	return http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil)
+}
+
+func (c *Connector) parseReadResponse(
+	ctx context.Context,
+	params common.ReadParams,
+	request *http.Request,
+	response *common.JSONHTTPResponse,
+) (*common.ReadResult, error) {
+	return common.ParseResult(response,
+		common.ExtractRecordsFromPath("result"),
+		getNextRecordsURL(response.Headers.Get("Link")),
+		common.GetMarshaledData,
+		params.Fields,
+	)
+}
+
+func (c *Connector) buildWriteRequest(ctx context.Context, params common.WriteParams) (*http.Request, error) {
+	method := http.MethodPost
+
+	url, err := urlbuilder.New(c.ProviderInfo().BaseURL, restAPIPrefix, params.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(params.RecordId) > 0 {
+		url.AddPath(params.RecordId)
+
+		method = http.MethodPatch
+	}
+
+	jsonData, err := json.Marshal(params.RecordData)
+	if err != nil {
+		return nil, err
+	}
+
+	return http.NewRequestWithContext(ctx, method, url.String(), bytes.NewReader(jsonData))
+}
+
+func (c *Connector) parseWriteResponse(
+	ctx context.Context,
+	params common.WriteParams,
+	request *http.Request,
+	response *common.JSONHTTPResponse,
+) (*common.WriteResult, error) {
+	body, ok := response.Body()
+	if !ok {
+		return &common.WriteResult{
+			Success: true,
+		}, nil
+	}
+
+	result, err := jsonquery.New(body).ObjectRequired("result")
+	if err != nil {
+		return nil, err
+	}
+
+	data, err := jsonquery.Convertor.ObjectToMap(result)
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.WriteResult{
+		Success: true,
+		Errors:  nil,
+		Data:    data,
+	}, nil
+}
+
+func getNextRecordsURL(linkHeader string) common.NextPageFunc {
+	return func(n *ajson.Node) (string, error) {
+		return ParseNexPageLinkHeader(linkHeader)
+	}
+}
+
+// ParseNexPageLinkHeader extracts the next page URL from the Link Header response.
+func ParseNexPageLinkHeader(linkHeader string) (string, error) {
+	if linkHeader == "" {
+		return "", nil // this indicates we're done.
+	}
+
+	links := strings.Split(linkHeader, ",")
+	// [<https://dev269415.service-now.com/api/now/v2/table/incident?sysparm_limit=1&sysparm_offset=0>;rel="next" ...]
+	for _, link := range links {
+		if strings.Contains(link, `rel="next"`) {
+			parts := strings.Split(link, ";")
+			rawURL := strings.TrimSpace(parts[0])
+			rawURL = strings.TrimPrefix(rawURL, "<")
+			rawURL = strings.TrimSuffix(rawURL, ">")
+
+			// Parse the URL to ensure it's valid
+			parsedURL, err := url.Parse(rawURL)
+			if err != nil {
+				return "", fmt.Errorf("failed to parse URL: %w", err)
+			}
+
+			return parsedURL.String(), nil
+		}
+	}
+
+	return "", nil
+}
diff --git a/providers/servicenow/support.go b/providers/servicenow/support.go
new file mode 100644
index 00000000..40252843
--- /dev/null
+++ b/providers/servicenow/support.go
@@ -0,0 +1,27 @@
+package servicenow
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/internal/components"
+)
+
+func supportedOperations() components.EndpointRegistryInput {
+	readSupport := []string{"*"} // a hack before the registry code is removed.
+	writeSupport := []string{"*"}
+
+	return components.EndpointRegistryInput{
+		common.ModuleRoot: {
+			{
+				Endpoint: fmt.Sprintf("{%s}", strings.Join(readSupport, ",")),
+				Support:  components.ReadSupport,
+			},
+			{
+				Endpoint: fmt.Sprintf("{%s}", strings.Join(writeSupport, ",")),
+				Support:  components.WriteSupport,
+			},
+		},
+	}
+}


Test patch:
diff --git a/test/servicenow/connector.go b/test/servicenow/connector.go
new file mode 100644
index 00000000..9fe6ebac
--- /dev/null
+++ b/test/servicenow/connector.go
@@ -0,0 +1,55 @@
+package servicenow
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/scanning/credscanning"
+	"github.com/amp-labs/connectors/providers"
+	"github.com/amp-labs/connectors/providers/servicenow"
+	"github.com/amp-labs/connectors/test/utils"
+	"golang.org/x/oauth2"
+)
+
+func GetServiceNowConnector(ctx context.Context) *servicenow.Connector {
+	filePath := credscanning.LoadPath(providers.ServiceNow)
+	reader := utils.MustCreateProvCredJSON(filePath, true, 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 := servicenow.NewConnector(common.Parameters{
+		AuthenticatedClient: client,
+		Workspace:           reader.Get(credscanning.Fields.Workspace),
+	})
+	if err != nil {
+		utils.Fail("create servicenow connector", "error: ", err)
+	}
+
+	return conn
+}
+
+func getConfig(reader *credscanning.ProviderCredentials) *oauth2.Config {
+	workspace := reader.Get(credscanning.Fields.Workspace)
+
+	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:   fmt.Sprintf("https://%s.service-now.com/oauth_auth.do", workspace),
+			TokenURL:  fmt.Sprintf("https://%s.service-now.com/oauth_token.do", workspace),
+			AuthStyle: oauth2.AuthStyleInParams,
+		},
+	}
+
+	return cfg
+}
diff --git a/test/servicenow/metadata/metadata.go b/test/servicenow/metadata/metadata.go
new file mode 100644
index 00000000..df03a5b9
--- /dev/null
+++ b/test/servicenow/metadata/metadata.go
@@ -0,0 +1,34 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"os"
+
+	"github.com/amp-labs/connectors/test/servicenow"
+)
+
+func main() {
+	if err := run(); err != nil {
+		slog.Error(err.Error())
+		os.Exit(1)
+	}
+}
+
+func run() error {
+	ctx := context.Background()
+
+	conn := servicenow.GetServiceNowConnector(ctx)
+
+	m, err := conn.ListObjectMetadata(ctx, []string{"now/table/incident", "now/contact", "now/consumer"})
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	fmt.Println("Results: ", m.Result)
+	fmt.Println("Errors: ", m.Errors)
+
+	return nil
+}
diff --git a/test/servicenow/read/read.go b/test/servicenow/read/read.go
new file mode 100644
index 00000000..2288efe0
--- /dev/null
+++ b/test/servicenow/read/read.go
@@ -0,0 +1,105 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"log/slog"
+	"os"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/internal/datautils"
+	serviceNow "github.com/amp-labs/connectors/providers/servicenow"
+	"github.com/amp-labs/connectors/test/servicenow"
+)
+
+func main() {
+	if err := run(); err != nil {
+		slog.Error(err.Error())
+		os.Exit(1)
+	}
+}
+
+func run() error {
+	ctx := context.Background()
+
+	conn := servicenow.GetServiceNowConnector(ctx)
+
+	if err := readIncidentsList(ctx, conn); err != nil {
+		return err
+	}
+
+	if err := readEmailServer(ctx, conn); err != nil {
+		return err
+	}
+
+	if err := readContacts(ctx, conn); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func readIncidentsList(ctx context.Context, conn *serviceNow.Connector) error {
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "now/table/incident",
+		Fields:     datautils.NewStringSet("parent", "upon_reject", "child_incidents"),
+	})
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(res, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+}
+
+func readEmailServer(ctx context.Context, conn *serviceNow.Connector) error {
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "now/table/cmdb_ci_email_server",
+		Fields:     datautils.NewStringSet("operational_status", "sys_domain", "sys_class_name"),
+	})
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(res, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+
+}
+
+func readContacts(ctx context.Context, conn *serviceNow.Connector) error {
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "now/contact",
+		Fields:     datautils.NewStringSet("country", "last_login_device", "phone"),
+	})
+	if err != nil {
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(res, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+
+}
diff --git a/test/servicenow/write/main.go b/test/servicenow/write/main.go
new file mode 100644
index 00000000..54998255
--- /dev/null
+++ b/test/servicenow/write/main.go
@@ -0,0 +1,121 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"os"
+
+	"github.com/amp-labs/connectors/common"
+	ServiceNow "github.com/amp-labs/connectors/providers/servicenow"
+	"github.com/amp-labs/connectors/test/servicenow"
+)
+
+func main() {
+	if err := run(); err != nil {
+		slog.Error(err.Error())
+	}
+}
+
+func run() error {
+	ctx := context.Background()
+	conn := servicenow.GetServiceNowConnector(ctx)
+
+	err := testWriteIncidents(ctx, conn)
+	if err != nil {
+		return err
+	}
+
+	err = testUpdateIncidents(ctx, conn)
+	if err != nil {
+		return err
+	}
+
+	err = testUpdateMailServer(ctx, conn)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func testWriteIncidents(ctx context.Context, conn *ServiceNow.Connector) error {
+	params := common.WriteParams{
+		ObjectName: "now/table/incident",
+		RecordData: map[string]any{
+			"assigned_to": "1c741bd70b2322007518478d83673af3",
+			"urgency":     "1",
+			"comments":    "Elevating urgency, this is a blocking issue",
+		},
+	}
+
+	res, err := conn.Write(ctx, params)
+	if err != nil {
+		fmt.Println("ERR: ", err)
+		return err
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(res, "", "  ")
+	if err != nil {
+		return fmt.Errorf("error marshalling JSON: %w", err)
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+}
+
+func testUpdateIncidents(ctx context.Context, conn *ServiceNow.Connector) error {
+	params := common.WriteParams{
+		ObjectName: "now/table/incident",
+		RecordId:   "6a2f6fbb83f02210290fed70deaad320",
+		RecordData: map[string]any{
+			"company": "Ampersand",
+		},
+	}
+
+	res, err := conn.Write(ctx, params)
+	if err != nil {
+		slog.Error(err.Error())
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(res, "", "  ")
+	if err != nil {
+		return fmt.Errorf("error marshalling JSON: %w", err)
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+}
+
+func testUpdateMailServer(ctx context.Context, conn *ServiceNow.Connector) error {
+	params := common.WriteParams{
+		ObjectName: "now/table/cmdb_ci_email_server",
+		RecordId:   "280ffff1c0a8000b0083f5395b44bc97",
+		RecordData: map[string]any{
+			"due_in": "2025-10-10",
+		},
+	}
+
+	res, err := conn.Write(ctx, params)
+	if err != nil {
+		slog.Error(err.Error())
+	}
+
+	// Print the results
+	jsonStr, err := json.MarshalIndent(res, "", "  ")
+	if err != nil {
+		return fmt.Errorf("error marshalling JSON: %w", err)
+	}
+
+	_, _ = os.Stdout.Write(jsonStr)
+	_, _ = os.Stdout.WriteString("\n")
+
+	return nil
+}