diff --git a/providers/clio/connector.go b/providers/clio/connector.go index a5af07671..7cd370903 100644 --- a/providers/clio/connector.go +++ b/providers/clio/connector.go @@ -65,3 +65,33 @@ func (c *Connector) ListObjectMetadata( return nil, common.ErrNotImplemented } + +func (c *Connector) Delete( + ctx context.Context, + params connectors.DeleteParams, +) (*connectors.DeleteResult, error) { + if c.Grow != nil { + return c.Grow.Delete(ctx, params) + } + + if c.Manage != nil { + return c.Manage.Delete(ctx, params) + } + + return nil, common.ErrNotImplemented +} + +func (c *Connector) Write( + ctx context.Context, + params connectors.WriteParams, +) (*connectors.WriteResult, error) { + if c.Grow != nil { + return c.Grow.Write(ctx, params) + } + + if c.Manage != nil { + return c.Manage.Write(ctx, params) + } + + return nil, common.ErrNotImplemented +} diff --git a/providers/clio/internal/grow/adapter.go b/providers/clio/internal/grow/adapter.go index deb7ba145..a7f8804c7 100644 --- a/providers/clio/internal/grow/adapter.go +++ b/providers/clio/internal/grow/adapter.go @@ -3,15 +3,19 @@ package grow import ( "github.com/amp-labs/connectors/common" "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/schema" + "github.com/amp-labs/connectors/internal/components/writer" "github.com/amp-labs/connectors/providers" ) -// Adapter exposes metadata operations for the Clio Grow module. type Adapter struct { *components.Connector common.RequireAuthenticatedClient components.SchemaProvider + components.Writer + components.Deleter } func NewAdapter(params common.ConnectorParams) (*Adapter, error) { @@ -28,5 +32,28 @@ func constructor(base *components.Connector) (*Adapter, error) { Schemas, ) + registry := components.NewEmptyEndpointRegistry() + adapter.Writer = writer.NewHTTPWriter( + adapter.HTTPClient().Client, + registry, + adapter.ProviderContext.Module(), + operations.WriteHandlers{ + BuildRequest: adapter.buildWriteRequest, + ParseResponse: adapter.parseWriteResponse, + ErrorHandler: common.InterpretError, + }, + ) + + adapter.Deleter = deleter.NewHTTPDeleter( + adapter.HTTPClient().Client, + registry, + adapter.ProviderContext.Module(), + operations.DeleteHandlers{ + BuildRequest: adapter.buildDeleteRequest, + ParseResponse: adapter.parseDeleteResponse, + ErrorHandler: common.InterpretError, + }, + ) + return adapter, nil } diff --git a/providers/clio/internal/grow/delete.go b/providers/clio/internal/grow/delete.go new file mode 100644 index 000000000..d83c8ed5c --- /dev/null +++ b/providers/clio/internal/grow/delete.go @@ -0,0 +1,25 @@ +package grow + +import ( + "context" + "net/http" + + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/providers/clio/internal/shared" +) + +func (c *Adapter) buildDeleteRequest(ctx context.Context, params common.DeleteParams) (*http.Request, error) { + return shared.BuildDeleteRequest(ctx, shared.BuildDeleteParams{ + BaseURL: c.ProviderInfo().BaseURL, + APIPath: clioGrowAPIPath, + Module: c.Module(), + Params: params, + FindURLPath: Schemas.FindURLPath, + }) +} + +func (c *Adapter) parseDeleteResponse(_ context.Context, params common.DeleteParams, + _ *http.Request, resp *common.JSONHTTPResponse, +) (*common.DeleteResult, error) { + return shared.ParseDeleteResponse(params, resp) +} diff --git a/providers/clio/internal/grow/write.go b/providers/clio/internal/grow/write.go new file mode 100644 index 000000000..5b1ed8cd5 --- /dev/null +++ b/providers/clio/internal/grow/write.go @@ -0,0 +1,27 @@ +package grow + +import ( + "context" + "net/http" + + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/providers/clio/internal/shared" +) + +const clioGrowAPIPath = "grow" + +func (c *Adapter) buildWriteRequest(ctx context.Context, params common.WriteParams) (*http.Request, error) { + return shared.BuildWriteRequest(ctx, shared.BuildWriteParams{ + BaseURL: c.ProviderInfo().BaseURL, + APIPath: clioGrowAPIPath, + Module: c.Module(), + Params: params, + FindURLPath: Schemas.FindURLPath, + }) +} + +func (c *Adapter) parseWriteResponse(_ context.Context, params common.WriteParams, + _ *http.Request, resp *common.JSONHTTPResponse, +) (*common.WriteResult, error) { + return shared.ParseWriteResponse(params, resp) +} diff --git a/providers/clio/internal/manage/adapter.go b/providers/clio/internal/manage/adapter.go index 0c0feab29..07928ae67 100644 --- a/providers/clio/internal/manage/adapter.go +++ b/providers/clio/internal/manage/adapter.go @@ -3,15 +3,19 @@ package manage import ( "github.com/amp-labs/connectors/common" "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/schema" + "github.com/amp-labs/connectors/internal/components/writer" "github.com/amp-labs/connectors/providers" ) -// Adapter exposes metadata operations for the Clio Manage module. type Adapter struct { *components.Connector common.RequireAuthenticatedClient components.SchemaProvider + components.Writer + components.Deleter } func NewAdapter(params common.ConnectorParams) (*Adapter, error) { @@ -28,5 +32,28 @@ func constructor(base *components.Connector) (*Adapter, error) { Schemas, ) + registry := components.NewEmptyEndpointRegistry() + adapter.Writer = writer.NewHTTPWriter( + adapter.HTTPClient().Client, + registry, + adapter.ProviderContext.Module(), + operations.WriteHandlers{ + BuildRequest: adapter.buildWriteRequest, + ParseResponse: adapter.parseWriteResponse, + ErrorHandler: common.InterpretError, + }, + ) + + adapter.Deleter = deleter.NewHTTPDeleter( + adapter.HTTPClient().Client, + registry, + adapter.ProviderContext.Module(), + operations.DeleteHandlers{ + BuildRequest: adapter.buildDeleteRequest, + ParseResponse: adapter.parseDeleteResponse, + ErrorHandler: common.InterpretError, + }, + ) + return adapter, nil } diff --git a/providers/clio/internal/manage/delete.go b/providers/clio/internal/manage/delete.go new file mode 100644 index 000000000..298018f26 --- /dev/null +++ b/providers/clio/internal/manage/delete.go @@ -0,0 +1,25 @@ +package manage + +import ( + "context" + "net/http" + + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/providers/clio/internal/shared" +) + +func (c *Adapter) buildDeleteRequest(ctx context.Context, params common.DeleteParams) (*http.Request, error) { + return shared.BuildDeleteRequest(ctx, shared.BuildDeleteParams{ + BaseURL: c.ProviderInfo().BaseURL, + APIPath: clioManageAPIPath, + Module: c.Module(), + Params: params, + FindURLPath: Schemas.FindURLPath, + }) +} + +func (c *Adapter) parseDeleteResponse(_ context.Context, params common.DeleteParams, + _ *http.Request, resp *common.JSONHTTPResponse, +) (*common.DeleteResult, error) { + return shared.ParseDeleteResponse(params, resp) +} diff --git a/providers/clio/internal/manage/test/write-groups-create.json b/providers/clio/internal/manage/test/write-groups-create.json new file mode 100644 index 000000000..7d08ddc8b --- /dev/null +++ b/providers/clio/internal/manage/test/write-groups-create.json @@ -0,0 +1,9 @@ +{ + "data": { + "id": 884001, + "name": "Scout Unit Group", + "type": "AdhocGroup", + "etag": "\"etag-create\"", + "updated_at": "2026-05-04T12:00:00Z" + } +} diff --git a/providers/clio/internal/manage/test/write-groups-update.json b/providers/clio/internal/manage/test/write-groups-update.json new file mode 100644 index 000000000..f6ae442cd --- /dev/null +++ b/providers/clio/internal/manage/test/write-groups-update.json @@ -0,0 +1,9 @@ +{ + "data": { + "id": 884001, + "name": "Scout Unit Group Updated", + "type": "AdhocGroup", + "etag": "\"etag-update\"", + "updated_at": "2026-05-04T12:05:00Z" + } +} diff --git a/providers/clio/internal/manage/write.go b/providers/clio/internal/manage/write.go new file mode 100644 index 000000000..23a6c6c9d --- /dev/null +++ b/providers/clio/internal/manage/write.go @@ -0,0 +1,27 @@ +package manage + +import ( + "context" + "net/http" + + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/providers/clio/internal/shared" +) + +const clioManageAPIPath = "api/v4" + +func (c *Adapter) buildWriteRequest(ctx context.Context, params common.WriteParams) (*http.Request, error) { + return shared.BuildWriteRequest(ctx, shared.BuildWriteParams{ + BaseURL: c.ProviderInfo().BaseURL, + APIPath: clioManageAPIPath, + Module: c.Module(), + Params: params, + FindURLPath: Schemas.FindURLPath, + }) +} + +func (c *Adapter) parseWriteResponse(_ context.Context, params common.WriteParams, + _ *http.Request, resp *common.JSONHTTPResponse, +) (*common.WriteResult, error) { + return shared.ParseWriteResponse(params, resp) +} diff --git a/providers/clio/internal/manage/write_test.go b/providers/clio/internal/manage/write_test.go new file mode 100644 index 000000000..c647f5990 --- /dev/null +++ b/providers/clio/internal/manage/write_test.go @@ -0,0 +1,114 @@ +package manage + +import ( + "net/http" + "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" +) + +func TestWriteGroups(t *testing.T) { + t.Parallel() + + responseCreate := testutils.DataFromFile(t, "write-groups-create.json") + responseUpdate := testutils.DataFromFile(t, "write-groups-update.json") + + tests := []testroutines.Write{ + { + Name: "Create group successfully", + Input: common.WriteParams{ + ObjectName: "groups", + RecordData: map[string]any{ + "name": "Scout Unit Group", + "type": "AdhocGroup", + }, + }, + Server: mockserver.Conditional{ + Setup: mockserver.ContentJSON(), + If: mockcond.And{ + mockcond.Method(http.MethodPost), + mockcond.Path("/api/v4/groups.json"), + mockcond.Body(`{"data":{"name":"Scout Unit Group","type":"AdhocGroup"}}`), + }, + Then: mockserver.Response(http.StatusOK, responseCreate), + }.Server(), + Comparator: testroutines.ComparatorSubsetWrite, + Expected: &common.WriteResult{ + Success: true, + RecordId: "884001", + Data: map[string]any{ + "id": float64(884001), + "name": "Scout Unit Group", + "type": "AdhocGroup", + }, + }, + ExpectedErrs: nil, + }, + { + Name: "Update group successfully", + Input: common.WriteParams{ + ObjectName: "groups", + RecordId: "884001", + RecordData: map[string]any{ + "name": "Scout Unit Group Updated", + "type": "AdhocGroup", + }, + }, + Server: mockserver.Conditional{ + Setup: mockserver.ContentJSON(), + If: mockcond.And{ + mockcond.Method(http.MethodPatch), + mockcond.Path("/api/v4/groups/884001.json"), + mockcond.Body(`{"data":{"name":"Scout Unit Group Updated","type":"AdhocGroup"}}`), + }, + Then: mockserver.Response(http.StatusOK, responseUpdate), + }.Server(), + Comparator: testroutines.ComparatorSubsetWrite, + Expected: &common.WriteResult{ + Success: true, + RecordId: "884001", + Data: map[string]any{ + "id": float64(884001), + "name": "Scout Unit Group Updated", + "type": "AdhocGroup", + }, + }, + ExpectedErrs: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + + tt.Run(t, func() (connectors.WriteConnector, error) { + return newWriteTestAdapter(tt.Server.URL) + }) + }) + } +} + +func newWriteTestAdapter(serverURL string) (*Adapter, error) { + adapter, err := NewAdapter(common.ConnectorParams{ + Module: providers.ModuleClioManage, + AuthenticatedClient: mockutils.NewClient(), + Workspace: "app.clio.com", + Metadata: map[string]string{ + "region": "", + }, + }) + if err != nil { + return nil, err + } + + adapter.SetUnitTestMockServerBaseURL(serverURL) + + return adapter, nil +} diff --git a/providers/clio/internal/shared/delete.go b/providers/clio/internal/shared/delete.go new file mode 100644 index 000000000..ae32806e1 --- /dev/null +++ b/providers/clio/internal/shared/delete.go @@ -0,0 +1,75 @@ +package shared //nolint:revive + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/common/urlbuilder" + "github.com/amp-labs/connectors/internal/httpkit" +) + +// BuildDeleteParams carries dependencies for constructing a Clio delete HTTP request. +type BuildDeleteParams struct { + BaseURL string + APIPath string + Module common.ModuleID + Params common.DeleteParams + FindURLPath func(common.ModuleID, string) (string, error) +} + +// BuildDeleteRequest builds DELETE for a single record (Manage `.json` paths vs Grow paths). +// +// Docs: https://docs.developers.clio.com/api-docs/ +func BuildDeleteRequest(ctx context.Context, buildParams BuildDeleteParams) (*http.Request, error) { + params := buildParams.Params + if err := params.ValidateParams(); err != nil { + return nil, err + } + + path, err := buildParams.FindURLPath(buildParams.Module, params.ObjectName) + if err != nil { + return nil, err + } + + rel := strings.TrimPrefix(path, "/") + + var requestURL *urlbuilder.URL + + switch { + case strings.HasSuffix(rel, ".json"): + basePath := strings.TrimSuffix(rel, ".json") + requestURL, err = urlbuilder.New(buildParams.BaseURL, buildParams.APIPath, basePath+"/"+params.RecordId+".json") + default: + requestURL, err = urlbuilder.New(buildParams.BaseURL, buildParams.APIPath, rel, params.RecordId) + } + + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, requestURL.String(), nil) + if err != nil { + return nil, err + } + + for _, h := range params.Headers { + req.Header.Add(h.Key, h.Value) + } + + req.Header.Set("Accept", "application/json") + + return req, nil +} + +// ParseDeleteResponse maps a successful Clio delete HTTP response into common.DeleteResult. +// Clio returns 204 No Content (empty body) for successful DELETE; common.ParseJSONResponse handles that. +func ParseDeleteResponse(_ common.DeleteParams, resp *common.JSONHTTPResponse) (*common.DeleteResult, error) { + if !httpkit.Status2xx(resp.Code) { + return nil, fmt.Errorf("%w: failed to delete record: %d", common.ErrRequestFailed, resp.Code) + } + + return &common.DeleteResult{Success: true}, nil +} diff --git a/providers/clio/internal/shared/write.go b/providers/clio/internal/shared/write.go new file mode 100644 index 000000000..64ff1be1f --- /dev/null +++ b/providers/clio/internal/shared/write.go @@ -0,0 +1,106 @@ +package shared //nolint:revive + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/common/urlbuilder" + "github.com/amp-labs/connectors/internal/jsonquery" +) + +// BuildWriteParams carries dependencies for constructing a Clio write HTTP request. +type BuildWriteParams struct { + BaseURL string + APIPath string + Module common.ModuleID + Params common.WriteParams + FindURLPath func(common.ModuleID, string) (string, error) +} + +// writeURL is Manage …/things.json vs …/things/{id}.json; Grow appends recordId as a path segment. +func writeURL(baseURL, apiPath, rel, recordID string) (*urlbuilder.URL, error) { + if recordID == "" { + return urlbuilder.New(baseURL, apiPath, rel) + } + + if before, ok := strings.CutSuffix(rel, ".json"); ok { + base := before + + return urlbuilder.New(baseURL, apiPath, base+"/"+recordID+".json") + } + + return urlbuilder.New(baseURL, apiPath, rel, recordID) +} + +// BuildWriteRequest builds POST (create) or PATCH (update) for Clio Manage / Grow JSON APIs. +// +// Docs: https://docs.developers.clio.com/api-docs/ +func BuildWriteRequest(ctx context.Context, buildParams BuildWriteParams) (*http.Request, error) { + path, err := buildParams.FindURLPath(buildParams.Module, buildParams.Params.ObjectName) + if err != nil { + return nil, err + } + + rel := strings.TrimLeft(path, "/") + + record, err := buildParams.Params.GetRecord() + if err != nil { + return nil, err + } + + body, err := json.Marshal(map[string]any{"data": record}) + if err != nil { + return nil, err + } + + u, err := writeURL(buildParams.BaseURL, buildParams.APIPath, rel, buildParams.Params.RecordId) + if err != nil { + return nil, err + } + + method := http.MethodPost + if buildParams.Params.RecordId != "" { + method = http.MethodPatch + } + + req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + return req, nil +} + +// ParseWriteResponse maps a Clio JSON write response into common.WriteResult (saved record in "data", per API docs). +func ParseWriteResponse(params common.WriteParams, resp *common.JSONHTTPResponse) (*common.WriteResult, error) { + body, ok := resp.Body() + if !ok { + return &common.WriteResult{Success: true, RecordId: params.RecordId}, nil + } + + data, err := jsonquery.New(body).ObjectRequired("data") + if err != nil { + return nil, err + } + + recordMap, err := jsonquery.Convertor.ObjectToMap(data) + if err != nil { + return nil, err + } + + recordID, err := jsonquery.New(data).TextWithDefault("id", params.RecordId) + if err != nil { + return nil, err + } + + return &common.WriteResult{ + Success: true, + RecordId: recordID, + Data: recordMap, + }, nil +} diff --git a/test/clio/manage/write/main.go b/test/clio/manage/write/main.go new file mode 100644 index 000000000..0ed8546b6 --- /dev/null +++ b/test/clio/manage/write/main.go @@ -0,0 +1,164 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/amp-labs/connectors/common" + clioConn "github.com/amp-labs/connectors/providers/clio" + connTest "github.com/amp-labs/connectors/test/clio" + "github.com/amp-labs/connectors/test/utils" + "github.com/brianvoe/gofakeit/v6" +) + +type expenseCategoryPayload struct { + Name string `json:"name"` + Rate float64 `json:"rate"` + EntryType string `json:"entry_type"` + UtbmsCode any `json:"utbms_code"` +} + +type groupPayload struct { + Name string `json:"name"` + Type string `json:"type"` +} + +func main() { + ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer done() + + utils.SetupLogging() + conn := connTest.GetClioManageConnector(ctx) + + slog.Info("Running expense_categories create/update/delete") + runExpenseCategoriesCreateUpdateDelete(ctx, conn) + + slog.Info("Running groups create/update/delete") + runGroupsCreateUpdateDelete(ctx, conn) +} + +func runExpenseCategoriesCreateUpdateDelete(ctx context.Context, conn *clioConn.Connector) { + name := "Scout Expense Category " + gofakeit.LetterN(8) + updatedName := "Scout Expense Category Updated " + gofakeit.LetterN(8) + + createRes, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "expense_categories", + RecordData: expenseCategoryPayload{ + Name: name, + Rate: 0.1, + EntryType: "hard_cost", + UtbmsCode: nil, + }, + }) + if err != nil { + utils.Fail("error creating expense_category", "error", err) + } + + if !createRes.Success { + utils.Fail("failed to create expense_category", "response", createRes) + } + + utils.DumpJSON(createRes, os.Stdout) + + recordID := createRes.RecordId + if recordID == "" { + utils.Fail("failed to create expense_category", "reason", "missing record id") + } + + updateRes, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "expense_categories", + RecordId: recordID, + RecordData: expenseCategoryPayload{ + Name: updatedName, + Rate: 0.1, + EntryType: "hard_cost", + UtbmsCode: nil, + }, + }) + if err != nil { + utils.Fail("error updating expense_category", "error", err) + } + + if !updateRes.Success { + utils.Fail("failed to update expense_category", "response", updateRes) + } + + utils.DumpJSON(updateRes, os.Stdout) + + delRes, err := conn.Delete(ctx, common.DeleteParams{ + ObjectName: "expense_categories", + RecordId: recordID, + }) + if err != nil { + utils.Fail("error deleting expense_category", "error", err) + } + + if !delRes.Success { + utils.Fail("failed to delete expense_category", "response", delRes) + } + + utils.DumpJSON(delRes, os.Stdout) +} + +func runGroupsCreateUpdateDelete(ctx context.Context, conn *clioConn.Connector) { + + name := "Scout Group" + updatedName := "Scout Group Updated" + + createRes, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "groups", + RecordData: groupPayload{ + Name: name, + Type: "AdhocGroup", + }, + }) + if err != nil { + utils.Fail("error creating group", "error", err) + } + + if !createRes.Success { + utils.Fail("failed to create group", "response", createRes) + } + + utils.DumpJSON(createRes, os.Stdout) + + recordID := createRes.RecordId + if recordID == "" { + utils.Fail("failed to create group", "reason", "missing record id") + } + + updateRes, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "groups", + RecordId: recordID, + RecordData: groupPayload{ + Name: updatedName, + Type: "AdhocGroup", + }, + }) + if err != nil { + utils.Fail("error updating group", "error", err) + } + + if !updateRes.Success { + utils.Fail("failed to update group", "response", updateRes) + } + + utils.DumpJSON(updateRes, os.Stdout) + + delRes, err := conn.Delete(ctx, common.DeleteParams{ + ObjectName: "groups", + RecordId: recordID, + }) + if err != nil { + utils.Fail("error deleting group", "error", err) + } + + if !delRes.Success { + utils.Fail("failed to delete group", "response", delRes) + } + + utils.DumpJSON(delRes, os.Stdout) +}