Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions cmd/create_installation.go
Original file line number Diff line number Diff line change
@@ -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 <integrationId>",
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)
}
111 changes: 111 additions & 0 deletions cmd/create_installation_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
27 changes: 27 additions & 0 deletions request/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
74 changes: 74 additions & 0 deletions request/create_installation_test.go
Original file line number Diff line number Diff line change
@@ -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(&params)
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)
}
}
11 changes: 11 additions & 0 deletions request/types.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package request

import (
"encoding/json"
"time"

"github.com/amp-labs/cli/openapi"
Expand All @@ -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"`
Expand Down
Loading