Repository: amp-labs/connectors
Pull request #1849 (2025-07-08 12:52:32)

[CON-1834] feat(flatfile): add Write 
## Checklist
- [x] Connector uses `internal/components`
- [ ] Metadata uses V2 metadata format
- [ ] Read supports pagination
- [x] Raw response is returned as is, no formatting or flattening is performed.
- [x] Write payloads should accept what `ReadResults.Fields` is returning. Any unnecessary nesting around the input is removed.
- [ ] Provider errors are mapped if non-standard (errors with 200 response code are converted to 4XX)
- [x] Custom fields, if not human readable names, are resolved to readable names.
- [x] Unit tests cover read/write/metadata logic (placed in /tests/<provider>)
- [x] Appropriate object names are used. Objects need to be resources, not actions (`jobs` and not `jobs.list`).
- [ ] Modules are only being added because:
  - [ ] The same base URL cannot be used to make a proxy call to objects in all modules
  - [ ] Different base URLs (drive.google.com vs google.com)
  - [ ] Object name collisions (same object name exists in two or more modules)


## Sample Read Response
<img width="1393" alt="image" src="https://github.com/user-attachments/assets/0c12d86c-f878-4351-b7a2-ce3acab75698" />
<img width="1406" alt="image" src="https://github.com/user-attachments/assets/924d3e52-00de-47e2-aa6c-49d8a4f062b3" />



## Test cases Result
<img width="1404" alt="image" src="https://github.com/user-attachments/assets/f2dfb26c-1a44-4f28-b4cc-00c0f9279352" />




Patch:
diff --git a/providers/flatfile/connector.go b/providers/flatfile/connector.go
index 6c67e61c..01a9a727 100644
--- a/providers/flatfile/connector.go
+++ b/providers/flatfile/connector.go
@@ -8,6 +8,7 @@ import (
 	"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/internal/staticschema"
 	"github.com/amp-labs/connectors/providers"
 	"github.com/amp-labs/connectors/tools/fileconv"
@@ -35,6 +36,7 @@ type Connector struct {
 	// Supported operations
 	components.SchemaProvider
 	components.Reader
+	components.Writer
 }
 
 func NewConnector(params common.ConnectorParams) (*Connector, error) {
@@ -65,5 +67,17 @@ func constructor(base *components.Connector) (*Connector, error) {
 		},
 	)
 
+	// Set the write provider for the connector
+	connector.Writer = writer.NewHTTPWriter(
+		connector.HTTPClient().Client,
+		registry,
+		connector.ProviderContext.Module(),
+		operations.WriteHandlers{
+			BuildRequest:  connector.buildWriteRequest,
+			ParseResponse: connector.parseWriteResponse,
+			ErrorHandler:  common.InterpretError,
+		},
+	)
+
 	return connector, nil
 }
diff --git a/providers/flatfile/handlers.go b/providers/flatfile/handlers.go
index fd25e6e4..20d2362e 100644
--- a/providers/flatfile/handlers.go
+++ b/providers/flatfile/handlers.go
@@ -1,12 +1,15 @@
 package flatfile
 
 import (
+	"bytes"
 	"context"
+	"encoding/json"
 	"net/http"
 
 	"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"
 )
 
 // nolint: gochecknoglobals
@@ -56,3 +59,67 @@ func (c *Connector) parseReadResponse(
 		params.Fields,
 	)
 }
+
+func (c *Connector) buildWriteRequest(ctx context.Context, params common.WriteParams) (*http.Request, error) { //nolint:lll
+	method := http.MethodPost
+
+	url, err := urlbuilder.New(c.ProviderInfo().BaseURL, version, 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
+	}
+
+	dataNode, err := jsonquery.New(body).ObjectOptional("data")
+	if err != nil {
+		return nil, err
+	}
+
+	if dataNode == nil {
+		// If the "data" field does not exist, use the root body as the data node.
+		dataNode = body
+	}
+
+	recordID, err := jsonquery.New(dataNode).StringRequired("id")
+	if err != nil {
+		return nil, err
+	}
+
+	respMap, err := jsonquery.Convertor.ObjectToMap(dataNode)
+	if err != nil {
+		return nil, err
+	}
+
+	return &common.WriteResult{
+		Success:  true,
+		RecordId: recordID,
+		Errors:   nil,
+		Data:     respMap,
+	}, nil
+}
diff --git a/providers/flatfile/support.go b/providers/flatfile/support.go
index fce6347d..fbefc01c 100644
--- a/providers/flatfile/support.go
+++ b/providers/flatfile/support.go
@@ -17,12 +17,29 @@ var supportObjectSince = datautils.NewSet(
 func supportedOperations() components.EndpointRegistryInput {
 	readSupport := schemas.ObjectNames().GetList(common.ModuleRoot)
 
+	writeSupport := []string{
+		"apps",
+		"prompts",
+		"environments",
+		"events",
+		"files",
+		"jobs",
+		"mapping",
+		"spaces",
+		"workbooks",
+	}
+
 	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/providers/flatfile/test/create-events.json b/providers/flatfile/test/create-events.json
new file mode 100644
index 00000000..05678380
--- /dev/null
+++ b/providers/flatfile/test/create-events.json
@@ -0,0 +1,17 @@
+{
+  "data": {
+    "topic": "workbook:updated",
+    "context": {
+      "accountId": "us_acc_YOUR_ID",
+      "environmentId": "us_env_YOUR_ID",
+      "spaceId": "us_sp_YOUR_ID",
+      "workbookId": "us_wb_YOUR_ID",
+      "actorId": "us_key_SOME_KEY"
+    },
+    "createdAt": "2023-11-07T20:46:04.300Z",
+    "domain": "workbook",
+    "id": "us_wb_YOUR_ID_event_1234567890",
+    "callbackUrl": "",
+    "dataUrl": ""
+  }
+}
\ No newline at end of file
diff --git a/providers/flatfile/test/update-environments.json b/providers/flatfile/test/update-environments.json
new file mode 100644
index 00000000..c573ad6e
--- /dev/null
+++ b/providers/flatfile/test/update-environments.json
@@ -0,0 +1,9 @@
+{
+  "id": "4784189d-610b-4488-b3a5-5f324f752417",
+  "accountId": "us_acc_YOUR_ID",
+  "name": "updated environment",
+  "isProd": false,
+	"guestAuthentication": [
+			"magic_link"
+		]
+}
\ No newline at end of file
diff --git a/providers/flatfile/write_test.go b/providers/flatfile/write_test.go
new file mode 100644
index 00000000..34516bd1
--- /dev/null
+++ b/providers/flatfile/write_test.go
@@ -0,0 +1,113 @@
+package flatfile
+
+import (
+	"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,gocognit,cyclop
+	t.Parallel()
+
+	createEventsResponse := testutils.DataFromFile(t, "create-events.json")
+	updateEnvironmentsResponse := testutils.DataFromFile(t, "update-environments.json")
+
+	tests := []testroutines.Write{
+		{
+			Name:         "Object Name is required",
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:         "RecordData is required",
+			Input:        common.WriteParams{ObjectName: "leads"},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingRecordData},
+		},
+
+		{
+			Name: "Successfully creation of an event",
+			Input: common.WriteParams{ObjectName: "events", RecordData: map[string]any{
+				"context": map[string]any{
+					"accountId":     "us_acc_YOUR_ID",
+					"environmentId": "us_env_YOUR_ID",
+					"spaceId":       "us_sp_YOUR_ID",
+					"workbookId":    "us_wb_YOUR_ID",
+					"actorId":       "us_key_SOME_KEY",
+				},
+				"domain": "workbook",
+				"payload": map[string]any{
+					"recordsAdded": 100,
+				},
+				"topic": "workbook:updated",
+			}},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If:    mockcond.MethodPOST(),
+				Then:  mockserver.Response(http.StatusOK, createEventsResponse),
+			}.Server(),
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "us_wb_YOUR_ID_event_1234567890",
+				Data: map[string]any{
+					"id":     "us_wb_YOUR_ID_event_1234567890",
+					"topic":  "workbook:updated",
+					"domain": "workbook",
+					"context": map[string]any{
+						"accountId":     "us_acc_YOUR_ID",
+						"environmentId": "us_env_YOUR_ID",
+						"spaceId":       "us_sp_YOUR_ID",
+						"workbookId":    "us_wb_YOUR_ID",
+						"actorId":       "us_key_SOME_KEY",
+					},
+					"dataUrl":     "",
+					"createdAt":   "2023-11-07T20:46:04.300Z",
+					"callbackUrl": "",
+				},
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name: "Successfully update of an environment",
+			Input: common.WriteParams{ObjectName: "environments", RecordId: "4784189d-610b-4488-b3a5-5f324f752417", RecordData: map[string]any{ //nolint:lll
+				"name": "updated environment",
+			}},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If:    mockcond.MethodPATCH(),
+				Then:  mockserver.Response(http.StatusOK, updateEnvironmentsResponse),
+			}.Server(),
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "4784189d-610b-4488-b3a5-5f324f752417",
+				Data: map[string]any{
+					"id":        "4784189d-610b-4488-b3a5-5f324f752417",
+					"accountId": "us_acc_YOUR_ID",
+					"name":      "updated environment",
+					"isProd":    false,
+					"guestAuthentication": []any{
+						"magic_link",
+					},
+				},
+			},
+			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 constructTestConnector(tt.Server.URL)
+			})
+		})
+	}
+}
diff --git a/test/flatfile/write/main.go b/test/flatfile/write/main.go
new file mode 100644
index 00000000..96f1ca2b
--- /dev/null
+++ b/test/flatfile/write/main.go
@@ -0,0 +1,133 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"os"
+
+	"github.com/amp-labs/connectors/common"
+	cc "github.com/amp-labs/connectors/providers/flatfile"
+	"github.com/amp-labs/connectors/test/flatfile"
+)
+
+func main() {
+	if err := run(); err != nil {
+		slog.Error(err.Error())
+	}
+}
+
+func run() error {
+	ctx := context.Background()
+
+	conn := flatfile.GetConnector(ctx)
+
+	appId, err := testCreatingApps(ctx, conn)
+	if err != nil {
+		return err
+	}
+
+	err = testUpdateApps(ctx, conn, appId)
+	if err != nil {
+		return err
+	}
+
+	err = testCreateSpaces(ctx, conn)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func testCreatingApps(ctx context.Context, conn *cc.Connector) (string, error) {
+	params := common.WriteParams{
+		ObjectName: "apps",
+		RecordData: map[string]any{
+			"name":         "Nightly Data Loads",
+			"namespace":    "nightly-data",
+			"type":         "CUSTOM",
+			"entity":       "Sync",
+			"entityPlural": "Syncs",
+			"icon":         "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"currentColor\" class=\"bi bi-bar-chart-fill\" viewBox=\"0 0 16 16\">\n  <path d=\"M1 11a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1zm5-4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1zm5-5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1z\"/>\n</svg>",
+			"metadata": map[string]any{
+				"foo": "bar",
+			},
+		},
+	}
+
+	slog.Info("Creating an app...")
+	res, err := conn.Write(ctx, params)
+	if err != nil {
+		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 res.RecordId, nil
+}
+
+func testUpdateApps(ctx context.Context, conn *cc.Connector, appId string) error {
+	params := common.WriteParams{
+		ObjectName: "apps",
+		RecordId:   appId,
+		RecordData: map[string]any{
+			"name": "Updated Nightly Data Loads",
+		},
+	}
+
+	slog.Info("Updating app...")
+	res, err := conn.Write(ctx, params)
+	if err != nil {
+		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 testCreateSpaces(ctx context.Context, conn *cc.Connector) error {
+	params := common.WriteParams{
+		ObjectName: "spaces",
+		RecordData: map[string]any{
+			"displayOrder":      1,
+			"environmentId":     "us_env_cEqqBV0z",
+			"name":              "My First Workbook",
+			"primaryWorkbookId": "us_wb_YOUR_ID",
+		},
+	}
+
+	slog.Info("Creating a space...")
+	res, err := conn.Write(ctx, params)
+	if err != nil {
+		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
+}