diff --git a/cmd/get_installation.go b/cmd/get_installation.go new file mode 100644 index 0000000..fd3c39b --- /dev/null +++ b/cmd/get_installation.go @@ -0,0 +1,141 @@ +package cmd + +import ( + "errors" + "os" + "sort" + + "github.com/amp-labs/cli/clerk" + "github.com/amp-labs/cli/flags" + "github.com/amp-labs/cli/logger" + "github.com/amp-labs/cli/request" + "github.com/amp-labs/cli/utils" + "github.com/spf13/cobra" +) + +type installationActions struct { + Read []string `json:"read"` + Write []string `json:"write"` + Subscribe []string `json:"subscribe"` + Proxy bool `json:"proxy"` +} + +type installationDetail struct { + Id string `json:"id"` + IntegrationId string `json:"integrationId"` + GroupRef string `json:"groupRef"` + ConnectionId string `json:"connectionId"` + HealthStatus string `json:"healthStatus"` + RevisionId string `json:"revisionId"` + Provider string `json:"provider"` + Actions installationActions `json:"actions"` +} + +func summarizeInstallation(installation *request.Installation) installationDetail { + detail := installationDetail{ + Id: installation.Id, + IntegrationId: installation.IntegrationId, + GroupRef: installation.GroupRef, + ConnectionId: installation.ConnectionId, + HealthStatus: installation.HealthStatus, + Actions: installationActions{ + Read: []string{}, + Write: []string{}, + Subscribe: []string{}, + }, + } + + if installation.Group != nil { + detail.GroupRef = installation.Group.GroupRef + } + + if installation.Connection != nil { + detail.ConnectionId = installation.Connection.Id + } + + if installation.Config == nil { + return detail + } + + detail.RevisionId = installation.Config.RevisionId + + content, ok := installation.Config.Content.(map[string]any) + if !ok { + return detail + } + + detail.Provider, _ = content["provider"].(string) + detail.Actions.Read = configuredObjects(content, "read") + detail.Actions.Write = configuredObjects(content, "write") + detail.Actions.Subscribe = configuredObjects(content, "subscribe") + detail.Actions.Proxy = proxyEnabled(content) + + return detail +} + +func configuredObjects(content map[string]any, action string) []string { + actionConfig, ok := content[action].(map[string]any) + if !ok { + return []string{} + } + + objects, ok := actionConfig["objects"].(map[string]any) + if !ok { + return []string{} + } + + names := make([]string, 0, len(objects)) + for name := range objects { + names = append(names, name) + } + + sort.Strings(names) + + return names +} + +func proxyEnabled(content map[string]any) bool { + proxy, ok := content["proxy"].(map[string]any) + if !ok { + return false + } + + enabled, _ := proxy["enabled"].(bool) + + return enabled +} + +var getInstallationCmd = &cobra.Command{ //nolint:gochecknoglobals + Use: "get:installation ", + Short: "Show an installation", + Long: "Show an installation's provider, health, and configured actions without connection credentials.", + Args: cobra.ExactArgs(2), //nolint:mnd + Run: func(cmd *cobra.Command, args []string) { + projectId := flags.GetProjectOrFail() + apiKey := flags.GetAPIKey() + client := request.NewAPIClient(projectId, &apiKey) + + installation, err := client.GetInstallation(cmd.Context(), args[0], args[1]) + if err != nil { + if errors.Is(err, clerk.ErrNoSessions) { + logger.FatalErr("Authenticated session has expired, please log in using amp login", err) + } else { + logger.FatalErr("Unable to get installation", err) + } + } + + err = utils.WriteStruct(os.Stdout, flags.GetOutputFormatForCommand(cmd), summarizeInstallation(installation)) + if err != nil { + logger.FatalErr("Unable to write installation", err) + } + }, +} + +func init() { + err := flags.InitAndBindFormatFlag(getInstallationCmd) + if err != nil { + logger.FatalErr("unable to initialize flags", err) + } + + rootCmd.AddCommand(getInstallationCmd) +} diff --git a/cmd/get_installation_test.go b/cmd/get_installation_test.go new file mode 100644 index 0000000..a12da00 --- /dev/null +++ b/cmd/get_installation_test.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "reflect" + "testing" + + "github.com/amp-labs/cli/request" +) + +func TestSummarizeInstallationShowsEnabledActions(t *testing.T) { + t.Parallel() + + installation := &request.Installation{ + Id: "installation-id", + IntegrationId: "integration-id", + HealthStatus: "healthy", + Group: &request.Group{GroupRef: "group-ref"}, + Connection: &request.Connection{Id: "connection-id"}, + Config: &request.Config{ + RevisionId: "revision-id", + Content: map[string]any{ + "provider": "hubspot", + "read": map[string]any{ + "objects": map[string]any{"contacts": map[string]any{}, "companies": map[string]any{}}, + }, + "write": map[string]any{ + "objects": map[string]any{"contacts": map[string]any{}}, + }, + "subscribe": map[string]any{ + "objects": map[string]any{"contacts": map[string]any{}}, + }, + "proxy": map[string]any{"enabled": true}, + }, + }, + } + + got := summarizeInstallation(installation) + want := installationDetail{ + Id: "installation-id", + IntegrationId: "integration-id", + GroupRef: "group-ref", + ConnectionId: "connection-id", + HealthStatus: "healthy", + RevisionId: "revision-id", + Provider: "hubspot", + Actions: installationActions{ + Read: []string{"companies", "contacts"}, + Write: []string{"contacts"}, + Subscribe: []string{"contacts"}, + Proxy: true, + }, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("summarizeInstallation() = %#v, want %#v", got, want) + } +} + +func TestSummarizeInstallationDoesNotExposeConnectionCredentials(t *testing.T) { + t.Parallel() + + installation := &request.Installation{ + Connection: &request.Connection{ + Id: "connection-id", + ProviderApp: &request.ProviderApp{ + ClientId: "client-id", + ClientSecret: "client-secret", + }, + }, + } + + got := summarizeInstallation(installation) + if got.ConnectionId != "connection-id" { + t.Fatalf("connection ID = %q, want connection-id", got.ConnectionId) + } + + if reflect.ValueOf(got).FieldByName("Connection").IsValid() { + t.Fatal("installation detail includes the connection object") + } +} diff --git a/cmd/update_installation.go b/cmd/update_installation.go new file mode 100644 index 0000000..5ca10d1 --- /dev/null +++ b/cmd/update_installation.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "errors" + "os" + + "github.com/amp-labs/cli/clerk" + "github.com/amp-labs/cli/flags" + "github.com/amp-labs/cli/logger" + "github.com/amp-labs/cli/request" + "github.com/amp-labs/cli/utils" + "github.com/spf13/cobra" +) + +var updateInstallationInput string //nolint:gochecknoglobals + +var updateInstallationCmd = &cobra.Command{ //nolint:gochecknoglobals + Use: "update:installation --input ", + Short: "Update an installation", + Long: "Update explicit installation fields from a JSON or YAML patch containing installation and updateMask.", + Args: cobra.ExactArgs(2), //nolint:mnd + Run: func(cmd *cobra.Command, args []string) { + var patch request.PatchInstallation + + _, err := utils.ReadStructFromFile(updateInstallationInput, &patch) + if err != nil { + logger.FatalErr("Unable to read installation patch", err) + } + + if len(patch.Installation) == 0 || len(patch.UpdateMask) == 0 { + logger.Fatal("Installation patch must contain installation and updateMask") + } + + projectId := flags.GetProjectOrFail() + apiKey := flags.GetAPIKey() + client := request.NewAPIClient(projectId, &apiKey) + + installation, err := client.PatchInstallation(cmd.Context(), args[0], args[1], &patch) + if err != nil { + if errors.Is(err, clerk.ErrNoSessions) { + logger.FatalErr("Authenticated session has expired, please log in using amp login", err) + } else { + logger.FatalErr("Unable to update installation", err) + } + } + + err = utils.WriteStruct(os.Stdout, flags.GetOutputFormatForCommand(cmd), summarizeInstallation(installation)) + if err != nil { + logger.FatalErr("Unable to write installation", err) + } + }, +} + +func init() { + updateInstallationCmd.Flags().StringVarP( + &updateInstallationInput, "input", "i", "", "Path to a JSON or YAML installation patch, or - for stdin", + ) + + err := updateInstallationCmd.MarkFlagRequired("input") + if err != nil { + logger.FatalErr("unable to require input flag", err) + } + + err = flags.InitAndBindFormatFlag(updateInstallationCmd) + if err != nil { + logger.FatalErr("unable to initialize flags", err) + } + + rootCmd.AddCommand(updateInstallationCmd) +} diff --git a/flags/config.go b/flags/config.go index 7f4ec32..e0d776a 100644 --- a/flags/config.go +++ b/flags/config.go @@ -56,7 +56,23 @@ func InitAndBindFormatFlag(cmd *cobra.Command) error { } func GetOutputFormat() utils.Format { - switch strings.ToLower(viper.GetString("format")) { + return parseOutputFormat(viper.GetString("format")) +} + +// GetOutputFormatForCommand reads the format flag from the command that owns it. +// Format flags are local to each command, so this avoids another command's Viper +// binding changing the selected output format. +func GetOutputFormatForCommand(cmd *cobra.Command) utils.Format { + format, err := cmd.Flags().GetString("format") + if err != nil { + return utils.Unknown + } + + return parseOutputFormat(format) +} + +func parseOutputFormat(format string) utils.Format { + switch strings.ToLower(format) { case "json": return utils.JSON case "yaml", "yml": diff --git a/flags/config_test.go b/flags/config_test.go new file mode 100644 index 0000000..7828e44 --- /dev/null +++ b/flags/config_test.go @@ -0,0 +1,31 @@ +package flags + +import ( + "testing" + + "github.com/amp-labs/cli/utils" + "github.com/spf13/cobra" +) + +func TestGetOutputFormatForCommandUsesOwningFlag(t *testing.T) { + t.Parallel() + + jsonCommand := &cobra.Command{Use: "json"} + jsonCommand.Flags().String("format", "json", "") + + yamlCommand := &cobra.Command{Use: "yaml"} + yamlCommand.Flags().String("format", "json", "") + + err := yamlCommand.Flags().Set("format", "yaml") + if err != nil { + t.Fatalf("set format: %v", err) + } + + if got := GetOutputFormatForCommand(jsonCommand); got != utils.JSON { + t.Fatalf("JSON command format = %q, want json", got) + } + + if got := GetOutputFormatForCommand(yamlCommand); got != utils.YAML { + t.Fatalf("YAML command format = %q, want yaml", got) + } +} diff --git a/request/api.go b/request/api.go index e7a9ecd..dd52cdc 100644 --- a/request/api.go +++ b/request/api.go @@ -183,6 +183,52 @@ func (c *APIClient) ListInstallations(ctx context.Context, integrationId string) return installations, nil } +func (c *APIClient) GetInstallation( + ctx context.Context, integrationId string, installationId string, +) (*Installation, error) { + getURL := fmt.Sprintf( + "%s/projects/%s/integrations/%s/installations/%s", + c.Root, c.ProjectId, integrationId, installationId, + ) + + auth, err := c.getAuthHeader(ctx) + if err != nil { + return nil, err + } + + var installation Installation + + _, err = c.Client.Get(ctx, getURL, &installation, auth) //nolint:bodyclose + if err != nil { + return nil, err + } + + return &installation, nil +} + +func (c *APIClient) PatchInstallation( + ctx context.Context, integrationId string, installationId string, patch *PatchInstallation, +) (*Installation, error) { + patchURL := fmt.Sprintf( + "%s/projects/%s/integrations/%s/installations/%s", + c.Root, c.ProjectId, integrationId, installationId, + ) + + auth, err := c.getAuthHeader(ctx) + if err != nil { + return nil, err + } + + var installation Installation + + _, err = c.Client.Patch(ctx, patchURL, patch, &installation, auth) //nolint:bodyclose + if err != nil { + return nil, err + } + + return &installation, nil +} + func (c *APIClient) ListConnections(ctx context.Context) ([]*Connection, error) { listURL := fmt.Sprintf("%s/projects/%s/connections", c.Root, c.ProjectId) diff --git a/request/installation_test.go b/request/installation_test.go new file mode 100644 index 0000000..a71b626 --- /dev/null +++ b/request/installation_test.go @@ -0,0 +1,100 @@ +package request + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" +) + +func TestGetInstallation(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet || + req.URL.Path != "/v1/projects/test-project/integrations/integration-id/installations/installation-id" { + http.NotFound(writer, req) + + return + } + + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"id":"installation-id","integrationId":"integration-id"}`)) + })) + t.Cleanup(server.Close) + + client := newInstallationTestClient(server.URL) + + installation, err := client.GetInstallation(t.Context(), "integration-id", "installation-id") + if err != nil { + t.Fatalf("GetInstallation() error = %v", err) + } + + if installation.Id != "installation-id" { + t.Fatalf("installation ID = %q, want installation-id", installation.Id) + } +} + +func TestPatchInstallation(t *testing.T) { + t.Parallel() + + wantPatch := PatchInstallation{ + Installation: map[string]any{ + "config": map[string]any{ + "content": map[string]any{ + "proxy": map[string]any{"enabled": true}, + }, + }, + }, + UpdateMask: []string{"config.content.proxy.enabled"}, + } + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPatch || + req.URL.Path != "/v1/projects/test-project/integrations/integration-id/installations/installation-id" { + http.NotFound(writer, req) + + return + } + + var gotPatch PatchInstallation + + err := json.NewDecoder(req.Body).Decode(&gotPatch) + if err != nil { + t.Fatalf("decode patch: %v", err) + } + + if !reflect.DeepEqual(gotPatch, wantPatch) { + t.Fatalf("patch = %#v, want %#v", gotPatch, wantPatch) + } + + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"id":"installation-id","integrationId":"integration-id"}`)) + })) + t.Cleanup(server.Close) + + client := newInstallationTestClient(server.URL) + + installation, err := client.PatchInstallation( + t.Context(), "integration-id", "installation-id", &wantPatch, + ) + if err != nil { + t.Fatalf("PatchInstallation() error = %v", err) + } + + if installation.Id != "installation-id" { + t.Fatalf("installation ID = %q, want installation-id", installation.Id) + } +} + +func newInstallationTestClient(root string) *APIClient { + apiKey := "test-key" + + return &APIClient{ + Root: root + "/v1", + ProjectId: "test-project", + APIKey: &apiKey, + Client: NewRequestClient(), + } +} diff --git a/request/types.go b/request/types.go index 540388c..805d28e 100644 --- a/request/types.go +++ b/request/types.go @@ -20,6 +20,11 @@ type Installation struct { HealthStatus string `json:"healthStatus"` } +type PatchInstallation struct { + Installation map[string]any `json:"installation"` + UpdateMask []string `json:"updateMask"` +} + type Group struct { GroupRef string `json:"groupRef"` GroupName string `json:"groupName"`