diff --git a/cmd/create_installation.go b/cmd/create_installation.go new file mode 100644 index 0000000..fcc14a8 --- /dev/null +++ b/cmd/create_installation.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "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/spf13/cobra" +) + +var errInvalidInstallationConfig = errors.New("config file must contain a JSON object") + +type installationCreator interface { + CreateInstallation( + ctx context.Context, + integrationId string, + params *request.CreateInstallationParams, + ) (*request.Installation, error) +} + +type installationCreatorFactory func(projectId string, apiKey string) installationCreator + +type installationProjectResolver func() string + +func readInstallationConfig(path string) (json.RawMessage, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config file: %w", err) + } + + var content map[string]json.RawMessage + + err = json.Unmarshal(data, &content) + if err != nil || content == nil { + return nil, errInvalidInstallationConfig + } + + return data, nil +} + +func newCreateInstallationCmd( + newClient installationCreatorFactory, + getProjectId installationProjectResolver, +) *cobra.Command { + var ( + groupRef string + connectionId string + configPath string + ) + + cmd := &cobra.Command{ + Use: "create:installation ", + Short: "Create an installation", + Long: "Create an installation from a JSON file containing the installation config content.", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + content, err := readInstallationConfig(configPath) + if err != nil { + logger.FatalErr("Unable to read installation config", err) + } + + projectId := getProjectId() + client := newClient(projectId, flags.GetAPIKey()) + + installation, err := client.CreateInstallation( + cmd.Context(), + args[0], + &request.CreateInstallationParams{ + GroupRef: groupRef, + ConnectionId: connectionId, + Config: request.CreateInstallationConfig{ + Content: content, + }, + }, + ) + 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 create installation", err) + } + } + + logger.Infof("Installation ID: %s, Group Ref: %s", installation.Id, groupRef) + }, + } + + cmd.Flags().StringVar(&groupRef, "group-ref", "", "Identifier for the group that owns the installation") + cmd.Flags().StringVar(&connectionId, "connection-id", "", "Connection ID to use") + cmd.Flags().StringVar(&configPath, "config", "", "Path to a JSON file containing config content") + + for _, flag := range []string{"group-ref", "connection-id", "config"} { + err := cmd.MarkFlagRequired(flag) + if err != nil { + logger.FatalErr("unable to require "+flag+" flag", err) + } + } + + return cmd +} + +var createInstallationCmd = newCreateInstallationCmd( //nolint:gochecknoglobals + func(projectId string, apiKey string) installationCreator { + return request.NewAPIClient(projectId, &apiKey) + }, + flags.GetProjectOrFail, +) + +func init() { + rootCmd.AddCommand(createInstallationCmd) +} diff --git a/cmd/create_installation_test.go b/cmd/create_installation_test.go new file mode 100644 index 0000000..2715fb7 --- /dev/null +++ b/cmd/create_installation_test.go @@ -0,0 +1,111 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/amp-labs/cli/request" +) + +type fakeInstallationCreator struct { + integrationId string + createdWith *request.CreateInstallationParams +} + +func (f *fakeInstallationCreator) CreateInstallation( + _ context.Context, + integrationId string, + params *request.CreateInstallationParams, +) (*request.Installation, error) { + f.integrationId = integrationId + f.createdWith = params + + return &request.Installation{Id: "installation-id"}, nil +} + +func TestCreateInstallationCommandReadsConfigFile(t *testing.T) { + t.Parallel() + + configPath := filepath.Join(t.TempDir(), "config.json") + wantContent := json.RawMessage(`{"provider":"asana","read":{"objects":{"projects":{"objectName":"projects"}}}}`) + + err := os.WriteFile(configPath, wantContent, 0o600) + if err != nil { + t.Fatalf("write config: %v", err) + } + + client := &fakeInstallationCreator{} + cmd := newCreateInstallationCmd( + func(projectId string, _ string) installationCreator { + if projectId != "test-project" { + t.Fatalf("project ID = %q, want test-project", projectId) + } + + return client + }, + func() string { return "test-project" }, + ) + cmd.SetArgs([]string{ + "integration-id", + "--group-ref", "test-group", + "--connection-id", "connection-id", + "--config", configPath, + }) + + err = cmd.Execute() + if err != nil { + t.Fatalf("execute create installation command: %v", err) + } + + if client.integrationId != "integration-id" { + t.Fatalf("integration ID = %q, want integration-id", client.integrationId) + } + + if client.createdWith == nil { + t.Fatal("installation was not created") + } + + if client.createdWith.GroupRef != "test-group" || client.createdWith.ConnectionId != "connection-id" { + t.Fatalf("create installation params = %#v", client.createdWith) + } + + if string(client.createdWith.Config.Content) != string(wantContent) { + t.Fatalf("config content = %s, want %s", client.createdWith.Config.Content, wantContent) + } +} + +func TestReadInstallationConfigRejectsInvalidJSON(t *testing.T) { + t.Parallel() + + configPath := filepath.Join(t.TempDir(), "config.json") + + err := os.WriteFile(configPath, []byte(`{"provider":`), 0o600) + if err != nil { + t.Fatalf("write config: %v", err) + } + + _, err = readInstallationConfig(configPath) + if !errors.Is(err, errInvalidInstallationConfig) { + t.Fatalf("error = %v, want errInvalidInstallationConfig", err) + } +} + +func TestReadInstallationConfigRejectsNonObject(t *testing.T) { + t.Parallel() + + configPath := filepath.Join(t.TempDir(), "config.json") + + err := os.WriteFile(configPath, []byte(`[]`), 0o600) + if err != nil { + t.Fatalf("write config: %v", err) + } + + _, err = readInstallationConfig(configPath) + if !errors.Is(err, errInvalidInstallationConfig) { + t.Fatalf("error = %v, want errInvalidInstallationConfig", err) + } +} diff --git a/request/api.go b/request/api.go index e7a9ecd..502f277 100644 --- a/request/api.go +++ b/request/api.go @@ -183,6 +183,33 @@ func (c *APIClient) ListInstallations(ctx context.Context, integrationId string) return installations, nil } +func (c *APIClient) CreateInstallation( + ctx context.Context, + integrationId string, + params *CreateInstallationParams, +) (*Installation, error) { + createURL := fmt.Sprintf( + "%s/projects/%s/integrations/%s/installations", + c.Root, + c.ProjectId, + integrationId, + ) + + auth, err := c.getAuthHeader(ctx) + if err != nil { + return nil, err + } + + var installation Installation + + _, err = c.Client.Post(ctx, createURL, params, &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/create_installation_test.go b/request/create_installation_test.go new file mode 100644 index 0000000..472bf10 --- /dev/null +++ b/request/create_installation_test.go @@ -0,0 +1,74 @@ +package request + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestCreateInstallation(t *testing.T) { + t.Parallel() + + wantParams := CreateInstallationParams{ + GroupRef: "test-group", + ConnectionId: "connection-id", + Config: CreateInstallationConfig{ + Content: json.RawMessage(`{"provider":"asana"}`), + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost || + req.URL.Path != "/v1/projects/test-project/integrations/integration-id/installations" { + t.Errorf("request = %s %s, want POST installation path", req.Method, req.URL.Path) + } + + if req.Header.Get("X-Api-Key") != "test-api-key" { + t.Errorf("X-Api-Key = %q, want test-api-key", req.Header.Get("X-Api-Key")) + } + + var params CreateInstallationParams + + err := json.NewDecoder(req.Body).Decode(¶ms) + if err != nil { + t.Errorf("decode request: %v", err) + } + + if params.GroupRef != wantParams.GroupRef || params.ConnectionId != wantParams.ConnectionId { + t.Errorf("request body = %#v, want %#v", params, wantParams) + } + + if string(params.Config.Content) != string(wantParams.Config.Content) { + t.Errorf("config content = %s, want %s", params.Config.Content, wantParams.Config.Content) + } + + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(writer).Encode(Installation{ + Id: "installation-id", + ProjectId: "test-project", + IntegrationId: "integration-id", + GroupRef: params.GroupRef, + ConnectionId: params.ConnectionId, + }) + })) + defer server.Close() + + apiKey := "test-api-key" + client := &APIClient{ + Root: server.URL + "/v1", + ProjectId: "test-project", + APIKey: &apiKey, + Client: &Client{Client: server.Client()}, + } + + installation, err := client.CreateInstallation(t.Context(), "integration-id", &wantParams) + if err != nil { + t.Fatalf("create installation: %v", err) + } + + if installation.Id != "installation-id" || installation.GroupRef != wantParams.GroupRef { + t.Fatalf("installation = %#v", installation) + } +} diff --git a/request/types.go b/request/types.go index 540388c..a41e939 100644 --- a/request/types.go +++ b/request/types.go @@ -1,6 +1,7 @@ package request import ( + "encoding/json" "time" "github.com/amp-labs/cli/openapi" @@ -20,6 +21,16 @@ type Installation struct { HealthStatus string `json:"healthStatus"` } +type CreateInstallationParams struct { + GroupRef string `json:"groupRef"` + ConnectionId string `json:"connectionId"` + Config CreateInstallationConfig `json:"config"` +} + +type CreateInstallationConfig struct { + Content json.RawMessage `json:"content"` +} + type Group struct { GroupRef string `json:"groupRef"` GroupName string `json:"groupName"`