Repository: amp-labs/connectors (go)

Issue:
Add Jira deep connector
- GetPostAuthInfo
- ListObjectMetadata
- Read
- Write


Pull request description:
feat: Deep connector Jira (Write)
Closes #709

# Description
Implementation of Write/Delete Jira issues.

# Testing
![image](https://github.com/user-attachments/assets/4f4723b4-c79f-4610-8a08-35032ce1b5e5)



Patch:
diff --git a/atlassian/delete.go b/atlassian/delete.go
new file mode 100644
index 00000000..7daf8bbf
--- /dev/null
+++ b/atlassian/delete.go
@@ -0,0 +1,31 @@
+package atlassian
+
+import (
+	"context"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+// Delete removes Jira issue.
+func (c *Connector) Delete(ctx context.Context, config common.DeleteParams) (*common.DeleteResult, error) {
+	if len(config.RecordId) == 0 {
+		return nil, common.ErrMissingRecordID
+	}
+
+	url, err := c.getJiraRestApiURL("issue")
+	if err != nil {
+		return nil, err
+	}
+
+	url.AddPath(config.RecordId)
+
+	// 204 NoContent is expected
+	_, err = c.Client.Delete(ctx, url.String())
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.DeleteResult{
+		Success: true,
+	}, nil
+}
diff --git a/atlassian/errors.go b/atlassian/errors.go
index 9408f2d0..887aa89d 100644
--- a/atlassian/errors.go
+++ b/atlassian/errors.go
@@ -2,6 +2,7 @@ package atlassian
 
 import (
 	"encoding/json"
+	"errors"
 	"fmt"
 	"net/http"
 	"strings"
@@ -41,18 +42,41 @@ func (*Connector) interpretJSONError(res *http.Response, body []byte) error { //
 }
 
 type ResponseMessagesError struct {
-	ErrorMessages   []string `json:"errorMessages"`
-	WarningMessages []string `json:"warningMessages"`
+	ErrorMessages   []string          `json:"errorMessages"`
+	WarningMessages []string          `json:"warningMessages"`
+	Errors          map[string]string `json:"errors"`
 }
 
+// CombineErr will produce dynamic error from server response body.
+// The base error serves as a main static error on top of stacked errors.
+// That static error should be used in conditional decisions. Ex: common.ErrBadRequest.
 func (r ResponseMessagesError) CombineErr(base error) error {
-	if len(r.ErrorMessages) == 0 {
-		return base
+	result := base
+
+	if len(r.ErrorMessages) != 0 {
+		result = errors.Join(result, errors.New( // nolint:goerr113
+			strings.Join(r.ErrorMessages, ","),
+		))
+	}
+
+	if len(r.WarningMessages) != 0 {
+		result = errors.Join(result, errors.New( // nolint:goerr113
+			strings.Join(r.WarningMessages, ","),
+		))
 	}
 
-	message := strings.Join(r.ErrorMessages, ",")
+	if len(r.Errors) != 0 {
+		messages := make([]string, 0)
+		for k, v := range r.Errors {
+			messages = append(messages, fmt.Sprintf("%v:%v", k, v))
+		}
+
+		result = errors.Join(result, errors.New( // nolint:goerr113
+			strings.Join(messages, ","),
+		))
+	}
 
-	return fmt.Errorf("%w: %v", base, message)
+	return result
 }
 
 type ResponseStatusError struct {
diff --git a/atlassian/write.go b/atlassian/write.go
new file mode 100644
index 00000000..c94459d0
--- /dev/null
+++ b/atlassian/write.go
@@ -0,0 +1,61 @@
+package atlassian
+
+import (
+	"context"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/jsonquery"
+	"github.com/spyzhov/ajson"
+)
+
+// Write will either create or update a Jira issue.
+// Create issue docs:
+// https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/#api-rest-api-3-issue-post
+// Update issue docs:
+// https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/#api-rest-api-3-issue-issueidorkey-put
+func (c *Connector) Write(ctx context.Context, config common.WriteParams) (*common.WriteResult, error) {
+	url, err := c.getJiraRestApiURL("issue")
+	if err != nil {
+		return nil, err
+	}
+
+	var write common.WriteMethod
+	if len(config.RecordId) == 0 {
+		// writing to the entity without id means
+		// that we are extending 'List' resource and creating a new record
+		write = c.Client.Post
+	} else {
+		// only put is supported for updating 'Single' resource
+		write = c.Client.Put
+
+		url.AddPath(config.RecordId)
+	}
+
+	res, err := write(ctx, url.String(), config.RecordData)
+	if err != nil {
+		return nil, err
+	}
+
+	if res == nil || res.Body == nil {
+		return &common.WriteResult{
+			Success: true,
+		}, nil
+	}
+
+	// Write response has a reference to the resource but no payload data.
+	return constructWriteResult(res.Body)
+}
+
+func constructWriteResult(body *ajson.Node) (*common.WriteResult, error) {
+	recordID, err := jsonquery.New(body).Str("id", false)
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.WriteResult{
+		Success:  true,
+		RecordId: *recordID,
+		Errors:   nil,
+		Data:     nil,
+	}, nil
+}


Test patch:
diff --git a/atlassian/delete_test.go b/atlassian/delete_test.go
new file mode 100644
index 00000000..d3fe7b92
--- /dev/null
+++ b/atlassian/delete_test.go
@@ -0,0 +1,142 @@
+package atlassian
+
+import (
+	"context"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+	"github.com/amp-labs/connectors/test/utils/mockutils"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+	"github.com/go-test/deep"
+)
+
+func TestDelete(t *testing.T) { // nolint:funlen,cyclop
+	t.Parallel()
+
+	responseErrorFormat := testutils.DataFromFile(t, "delete-issue-not-found.json")
+
+	tests := []struct {
+		name         string
+		input        common.DeleteParams
+		server       *httptest.Server
+		connector    Connector
+		expected     *common.DeleteResult
+		expectedErrs []error
+	}{
+		{
+			name:  "Write issue must include ID",
+			input: common.DeleteParams{},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusTeapot)
+			})),
+			expectedErrs: []error{common.ErrMissingRecordID},
+		},
+		{
+			name:  "Mime response header expected",
+			input: common.DeleteParams{RecordId: "10010"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusTeapot)
+			})),
+			expectedErrs: []error{interpreter.ErrMissingContentType},
+		},
+		{
+			name:  "Not found returned on removing missing entry",
+			input: common.DeleteParams{RecordId: "10010"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusNotFound)
+				_, _ = w.Write(responseErrorFormat)
+			})),
+			expectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New("Issue does not exist or you do not have permission to see it"), // nolint:goerr113
+			},
+		},
+		{
+			name:  "Successful delete",
+			input: common.DeleteParams{RecordId: "10010"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondNoContentForMethod(w, r, "DELETE")
+			})),
+			expected:     &common.DeleteResult{Success: true},
+			expectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests { // nolint:dupl
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			defer tt.server.Close()
+
+			ctx := context.Background()
+
+			connector, err := NewConnector(
+				WithAuthenticatedClient(http.DefaultClient),
+				WithWorkspace("test-workspace"),
+				WithModule(ModuleJira),
+				WithMetadata(map[string]string{
+					"cloudId": "ebc887b2-7e61-4059-ab35-71f15cc16e12", // any value will work for the test
+				}),
+			)
+			if err != nil {
+				t.Fatalf("%s: error in test while constructing connector %v", tt.name, err)
+			}
+
+			// for testing we want to redirect calls to our server
+			connector.setBaseURL(tt.server.URL)
+
+			if err != nil {
+				t.Fatalf("%s: failed to setup auth metadata connector %v", tt.name, err)
+			}
+
+			// start of tests
+			output, err := connector.Delete(ctx, tt.input)
+			if len(tt.expectedErrs) == 0 && err != nil {
+				t.Fatalf("%s: expected no errors, got: (%v)", tt.name, err)
+			}
+
+			if len(tt.expectedErrs) != 0 && err == nil {
+				t.Fatalf("%s: expected errors (%v), but got nothing", tt.name, tt.expectedErrs)
+			}
+
+			for _, expectedErr := range tt.expectedErrs {
+				if !errors.Is(err, expectedErr) && !strings.Contains(err.Error(), expectedErr.Error()) {
+					t.Fatalf("%s: expected Error: (%v), got: (%v)", tt.name, expectedErr, err)
+				}
+			}
+
+			if !reflect.DeepEqual(output, tt.expected) {
+				diff := deep.Equal(output, tt.expected)
+				t.Fatalf("%s:, \nexpected: (%v), \ngot: (%v), \ndiff: (%v)", tt.name, tt.expected, output, diff)
+			}
+		})
+	}
+}
+
+func TestDeleteWithoutMetadata(t *testing.T) {
+	t.Parallel()
+
+	connector, err := NewConnector(
+		WithAuthenticatedClient(http.DefaultClient),
+		WithWorkspace("test-workspace"),
+		WithModule(ModuleJira),
+	)
+	if err != nil {
+		t.Fatal("failed to create connector")
+	}
+
+	_, err = connector.Delete(context.Background(), common.DeleteParams{RecordId: "123"})
+	if !errors.Is(err, ErrMissingCloudId) {
+		t.Fatalf("expected Delete method to complain about missing cloud id")
+	}
+}
diff --git a/atlassian/test/create-issue-invalid-project.json b/atlassian/test/create-issue-invalid-project.json
new file mode 100644
index 00000000..891cf95a
--- /dev/null
+++ b/atlassian/test/create-issue-invalid-project.json
@@ -0,0 +1,6 @@
+{
+  "errorMessages": [],
+  "errors": {
+    "project": "Specify a valid project ID or key"
+  }
+}
diff --git a/atlassian/test/create-issue-invalid-type.json b/atlassian/test/create-issue-invalid-type.json
new file mode 100644
index 00000000..705002e6
--- /dev/null
+++ b/atlassian/test/create-issue-invalid-type.json
@@ -0,0 +1,6 @@
+{
+  "errorMessages": [],
+  "errors": {
+    "issuetype": "Specify an issue type"
+  }
+}
diff --git a/atlassian/test/create-issue.json b/atlassian/test/create-issue.json
new file mode 100644
index 00000000..1b01a867
--- /dev/null
+++ b/atlassian/test/create-issue.json
@@ -0,0 +1,5 @@
+{
+  "id": "10004",
+  "key": "AM-4",
+  "self": "https://api.atlassian.com/ex/jira/ebc887b2-7e61-4059-ab35-71f15cc16e12/rest/api/3/issue/10004"
+}
diff --git a/atlassian/test/delete-issue-not-found.json b/atlassian/test/delete-issue-not-found.json
new file mode 100644
index 00000000..7bdba561
--- /dev/null
+++ b/atlassian/test/delete-issue-not-found.json
@@ -0,0 +1,6 @@
+{
+  "errorMessages": [
+    "Issue does not exist or you do not have permission to see it."
+  ],
+  "errors": {}
+}
diff --git a/atlassian/write_test.go b/atlassian/write_test.go
new file mode 100644
index 00000000..b66c2d23
--- /dev/null
+++ b/atlassian/write_test.go
@@ -0,0 +1,193 @@
+package atlassian
+
+import (
+	"context"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+	"github.com/amp-labs/connectors/test/utils/mockutils"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+	"github.com/go-test/deep"
+)
+
+func TestWrite(t *testing.T) { // nolint:funlen,cyclop
+	t.Parallel()
+
+	responseInvalidProjectError := testutils.DataFromFile(t, "create-issue-invalid-project.json")
+	responseInvalidTypeError := testutils.DataFromFile(t, "create-issue-invalid-type.json")
+	createIssueResponse := testutils.DataFromFile(t, "create-issue.json")
+
+	tests := []struct {
+		name         string
+		input        common.WriteParams
+		server       *httptest.Server
+		connector    Connector
+		comparator   func(actual, expected *common.WriteResult) bool // custom comparison
+		expected     *common.WriteResult
+		expectedErrs []error
+	}{
+		{
+			name:         "Mime response header expected",
+			input:        common.WriteParams{},
+			server:       mockserver.Dummy(),
+			expectedErrs: []error{interpreter.ErrMissingContentType},
+		},
+		{
+			name:  "Error missing project during write",
+			input: common.WriteParams{RecordId: "10003"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusBadRequest)
+				_, _ = w.Write(responseInvalidProjectError)
+			})),
+			expectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New("project:Specify a valid project ID or key"), // nolint:goerr113
+			},
+		},
+		{
+			name:  "Error missing issue type during write",
+			input: common.WriteParams{RecordId: "10003"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusBadRequest)
+				_, _ = w.Write(responseInvalidTypeError)
+			})),
+			expectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New("issuetype:Specify an issue type"), // nolint:goerr113
+			},
+		},
+		{
+			name:  "Write must act as a Create",
+			input: common.WriteParams{},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "POST", func() {
+					w.WriteHeader(http.StatusOK)
+				})
+			})),
+			expected:     &common.WriteResult{Success: true},
+			expectedErrs: nil,
+		},
+		{
+			name:  "Write must act as an Update",
+			input: common.WriteParams{RecordId: "10003"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "PUT", func() {
+					w.WriteHeader(http.StatusNoContent)
+				})
+			})),
+			expected:     &common.WriteResult{Success: true},
+			expectedErrs: nil,
+		},
+		{
+			name:  "Valid creation of an Issue",
+			input: common.WriteParams{ObjectName: "accounts"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "POST", func() {
+					w.WriteHeader(http.StatusOK)
+					_, _ = w.Write(createIssueResponse)
+				})
+			})),
+			comparator: func(actual, expected *common.WriteResult) bool {
+				return mockutils.WriteResultComparator.SubsetData(actual, expected)
+			},
+			expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "10004",
+				Errors:   nil,
+				Data:     nil,
+			},
+			expectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests { // nolint:dupl
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			defer tt.server.Close()
+
+			ctx := context.Background()
+
+			connector, err := NewConnector(
+				WithAuthenticatedClient(http.DefaultClient),
+				WithWorkspace("test-workspace"),
+				WithModule(ModuleJira),
+				WithMetadata(map[string]string{
+					"cloudId": "ebc887b2-7e61-4059-ab35-71f15cc16e12", // any value will work for the test
+				}),
+			)
+			if err != nil {
+				t.Fatalf("%s: error in test while constructing connector %v", tt.name, err)
+			}
+
+			// for testing we want to redirect calls to our server
+			connector.setBaseURL(tt.server.URL)
+
+			if err != nil {
+				t.Fatalf("%s: failed to setup auth metadata connector %v", tt.name, err)
+			}
+
+			// start of tests
+			output, err := connector.Write(ctx, tt.input)
+			if len(tt.expectedErrs) == 0 && err != nil {
+				t.Fatalf("%s: expected no errors, got: (%v)", tt.name, err)
+			}
+
+			if len(tt.expectedErrs) != 0 && err == nil {
+				t.Fatalf("%s: expected errors (%v), but got nothing", tt.name, tt.expectedErrs)
+			}
+
+			for _, expectedErr := range tt.expectedErrs {
+				if !errors.Is(err, expectedErr) && !strings.Contains(err.Error(), expectedErr.Error()) {
+					t.Fatalf("%s: expected Error: (%v), got: (%v)", tt.name, expectedErr, err)
+				}
+			}
+
+			// compare desired output
+			var ok bool
+			if tt.comparator == nil {
+				// default comparison is concerned about all fields
+				ok = reflect.DeepEqual(output, tt.expected)
+			} else {
+				ok = tt.comparator(output, tt.expected)
+			}
+
+			if !ok {
+				diff := deep.Equal(output, tt.expected)
+				t.Fatalf("%s:, \nexpected: (%v), \ngot: (%v), \ndiff: (%v)", tt.name, tt.expected, output, diff)
+			}
+		})
+	}
+}
+
+func TestWriteWithoutMetadata(t *testing.T) {
+	t.Parallel()
+
+	connector, err := NewConnector(
+		WithAuthenticatedClient(http.DefaultClient),
+		WithWorkspace("test-workspace"),
+		WithModule(ModuleJira),
+	)
+	if err != nil {
+		t.Fatal("failed to create connector")
+	}
+
+	_, err = connector.Write(context.Background(), common.WriteParams{})
+	if !errors.Is(err, ErrMissingCloudId) {
+		t.Fatalf("expected Write method to complain about missing cloud id")
+	}
+}
diff --git a/test/atlassian/write-delete/main.go b/test/atlassian/write-delete/main.go
new file mode 100644
index 00000000..e60cf73e
--- /dev/null
+++ b/test/atlassian/write-delete/main.go
@@ -0,0 +1,165 @@
+package main
+
+import (
+	"context"
+	"log/slog"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors/atlassian"
+	"github.com/amp-labs/connectors/common"
+	connTest "github.com/amp-labs/connectors/test/atlassian"
+	"github.com/amp-labs/connectors/test/utils"
+	"github.com/amp-labs/connectors/test/utils/mockutils"
+)
+
+type issuePayload struct {
+	Fields issueFields `json:"fields"`
+}
+
+type issueFields struct {
+	Project   identifier `json:"project"`
+	Issuetype identifier `json:"issuetype"`
+	Summary   string     `json:"summary"`
+}
+
+type identifier struct {
+	Id string `json:"id"`
+}
+
+const (
+	projectID   = "10001"
+	issueTypeID = "10005"
+)
+
+// For this script replace project id and issue types with your values.
+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.GetAtlassianConnector(ctx)
+	defer utils.Close(conn)
+
+	slog.Info("> TEST Create/Update/Delete issue")
+	slog.Info("Creating issue")
+
+	view := createIssue(ctx, conn, &issuePayload{
+		Fields: issueFields{
+			Project: identifier{
+				Id: projectID,
+			},
+			Issuetype: identifier{
+				Id: issueTypeID,
+			},
+			Summary: "The very new title of Jira issue",
+		},
+	})
+
+	slog.Info("Updating some issue properties")
+
+	newTitle := "Fix button dropdown in main menu"
+	updateIssue(ctx, conn, view.RecordId, &issuePayload{
+		Fields: issueFields{
+			Project: identifier{
+				Id: projectID,
+			},
+			Issuetype: identifier{
+				Id: issueTypeID,
+			},
+			Summary: newTitle,
+		},
+	})
+
+	slog.Info("View that issue has changed accordingly")
+
+	res := readIssue(ctx, conn)
+
+	updatedView := searchIssue(res, "id", view.RecordId)
+
+	summaryProperty, ok := updatedView["summary"]
+	if !ok {
+		utils.Fail("couldn't find summary property")
+	}
+
+	if summaryProperty != newTitle {
+		utils.Fail("error updated Jira Issue Title does not match")
+	}
+
+	slog.Info("Removing this issue")
+	removeIssue(ctx, conn, view.RecordId)
+	slog.Info("> Successful test completion")
+}
+
+func searchIssue(res *common.ReadResult, key, value string) map[string]any {
+	for _, data := range res.Data {
+		if mockutils.DoesObjectCorrespondToString(data.Fields[key], value) {
+			return data.Raw
+		}
+	}
+
+	utils.Fail("error finding issue")
+
+	return nil
+}
+
+func readIssue(ctx context.Context, conn *atlassian.Connector) *common.ReadResult {
+	res, err := conn.Read(ctx, common.ReadParams{
+		Fields: []string{
+			"id", "fields",
+		},
+	})
+	if err != nil {
+		utils.Fail("error reading from Atlassian", "error", err)
+	}
+
+	return res
+}
+
+func createIssue(ctx context.Context, conn *atlassian.Connector, payload *issuePayload) *common.WriteResult {
+	res, err := conn.Write(ctx, common.WriteParams{
+		RecordId:   "",
+		RecordData: payload,
+	})
+	if err != nil {
+		utils.Fail("error writing to Atlassian", "error", err)
+	}
+
+	if !res.Success {
+		utils.Fail("failed to create a issue")
+	}
+
+	return res
+}
+
+func updateIssue(ctx context.Context, conn *atlassian.Connector, viewID string, payload *issuePayload) *common.WriteResult {
+	res, err := conn.Write(ctx, common.WriteParams{
+		RecordId:   viewID,
+		RecordData: payload,
+	})
+	if err != nil {
+		utils.Fail("error writing to Atlassian", "error", err)
+	}
+
+	if !res.Success {
+		utils.Fail("failed to update a issue")
+	}
+
+	return res
+}
+
+func removeIssue(ctx context.Context, conn *atlassian.Connector, viewID string) {
+	res, err := conn.Delete(ctx, common.DeleteParams{
+		RecordId: viewID,
+	})
+	if err != nil {
+		utils.Fail("error deleting for Atlassian", "error", err)
+	}
+
+	if !res.Success {
+		utils.Fail("failed to remove a issue")
+	}
+}