From 8ab23f02b0dd11a2a991f00a97f49e6be066313e Mon Sep 17 00:00:00 2001 From: Danny Olson Date: Wed, 27 Mar 2024 11:22:06 -0700 Subject: [PATCH 1/2] Add create flag step --- internal/quickstart/container.go | 21 +++++-- internal/quickstart/create_flag.go | 89 ++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 internal/quickstart/create_flag.go diff --git a/internal/quickstart/container.go b/internal/quickstart/container.go index a1393ba2..d51ed2f3 100644 --- a/internal/quickstart/container.go +++ b/internal/quickstart/container.go @@ -32,7 +32,9 @@ func NewContainerModel(flagsClient flags.Client) tea.Model { return ContainerModel{ currentStep: createFlagStep, flagsClient: flagsClient, - steps: []tea.Model{}, + steps: []tea.Model{ + NewCreateFlagModel(flagsClient), + }, } } @@ -47,7 +49,16 @@ func (m ContainerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, keys.Enter): switch m.currentStep { case createFlagStep: - // TODO: add createFlagModel + updated, _ := m.steps[createFlagStep].Update(msg) + if model, ok := updated.(createFlagModel); ok { + if model.err != nil { + m.err = model.err + + return m, nil + } + m.flagKey = model.flagKey + m.currentStep += 1 + } default: } case key.Matches(msg, keys.Quit): @@ -56,10 +67,8 @@ func (m ContainerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit default: // delegate all other input to the current model - - // TODO: update model once there is at least one - // updated, _ := m.steps[m.currentStep].Update(msg) - // m.steps[m.currentStep] = updated + updated, _ := m.steps[m.currentStep].Update(msg) + m.steps[m.currentStep] = updated } default: } diff --git a/internal/quickstart/create_flag.go b/internal/quickstart/create_flag.go new file mode 100644 index 00000000..681dda3f --- /dev/null +++ b/internal/quickstart/create_flag.go @@ -0,0 +1,89 @@ +package quickstart + +import ( + "context" + "fmt" + "ldcli/internal/flags" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/viper" +) + +const defaultFlagName = "my new flag" + +type createFlagModel struct { + err error + flagKey string + flagName string + client flags.Client + textInput textinput.Model +} + +func NewCreateFlagModel(client flags.Client) tea.Model { + ti := textinput.New() + ti.Focus() + ti.CharLimit = 156 + ti.Width = 20 + ti.Placeholder = defaultFlagName + + return createFlagModel{ + client: client, + textInput: ti, + } +} + +func (p createFlagModel) Init() tea.Cmd { + return nil +} + +func (m createFlagModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, keys.Enter): + input := m.textInput.Value() + if input == "" { + input = defaultFlagName + } + m.flagName = input + // TODO: validate and parse key + + _, err := m.client.Create( + context.Background(), + viper.GetString("accessToken"), + viper.GetString("baseUri"), + m.flagName, + "flagKey", // TODO: use key from name + "default", + ) + if err != nil { + m.err = err + + return m, nil + } + m.flagKey = "flagKey" + + return m, nil + case key.Matches(msg, keys.Quit): + return m, tea.Quit + default: + m.textInput, cmd = m.textInput.Update(msg) + } + } + + return m, cmd +} + +func (m createFlagModel) View() string { + style := lipgloss.NewStyle(). + MarginLeft(2) + + return fmt.Sprintf( + "Name your first feature flag (enter for default value):\n\n%s", + style.Render(m.textInput.View()), + ) + "\n" +} From 6c14162a91875b475885f19d7c6866d5663308ed Mon Sep 17 00:00:00 2001 From: Danny Olson Date: Wed, 27 Mar 2024 13:33:49 -0700 Subject: [PATCH 2/2] feat: add create flag setup validate flag name (#77) Add flag name validation for quickstart --- internal/flags/client.go | 80 ++++++++++++++ internal/flags/flags.go | 97 ++++++----------- internal/flags/flags_test.go | 116 +++++++++++++++++++++ internal/flags/{mock.go => mock_client.go} | 0 internal/quickstart/create_flag.go | 13 ++- 5 files changed, 235 insertions(+), 71 deletions(-) create mode 100644 internal/flags/client.go create mode 100644 internal/flags/flags_test.go rename internal/flags/{mock.go => mock_client.go} (100%) diff --git a/internal/flags/client.go b/internal/flags/client.go new file mode 100644 index 00000000..22a090ee --- /dev/null +++ b/internal/flags/client.go @@ -0,0 +1,80 @@ +package flags + +import ( + "context" + "encoding/json" + + ldapi "github.com/launchdarkly/api-client-go/v14" + + "ldcli/internal/client" + "ldcli/internal/errors" +) + +type Client interface { + Create(ctx context.Context, accessToken, baseURI, name, key, projKey string) ([]byte, error) + Update( + ctx context.Context, + accessToken, + baseURI, + key, + projKey string, + patch []ldapi.PatchOperation, + ) ([]byte, error) +} + +type FlagsClient struct{} + +var _ Client = FlagsClient{} + +func NewClient() FlagsClient { + return FlagsClient{} +} + +func (c FlagsClient) Create( + ctx context.Context, + accessToken, + baseURI, + name, + key, + projectKey string, +) ([]byte, error) { + client := client.New(accessToken, baseURI) + post := ldapi.NewFeatureFlagBody(name, key) + flag, _, err := client.FeatureFlagsApi.PostFeatureFlag(ctx, projectKey).FeatureFlagBody(*post).Execute() + if err != nil { + return nil, errors.NewAPIError(err) + + } + + responseJSON, err := json.Marshal(flag) + if err != nil { + return nil, err + } + + return responseJSON, nil +} + +func (c FlagsClient) Update( + ctx context.Context, + accessToken, + baseURI, + key, + projKey string, + patch []ldapi.PatchOperation, +) ([]byte, error) { + client := client.New(accessToken, baseURI) + flag, _, err := client.FeatureFlagsApi. + PatchFeatureFlag(ctx, projKey, key). + PatchWithComment(*ldapi.NewPatchWithComment(patch)). + Execute() + if err != nil { + return nil, errors.NewAPIError(err) + } + + responseJSON, err := json.Marshal(flag) + if err != nil { + return nil, err + } + + return responseJSON, nil +} diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 22a090ee..a2bf8ae6 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -1,80 +1,43 @@ package flags import ( - "context" - "encoding/json" - - ldapi "github.com/launchdarkly/api-client-go/v14" - - "ldcli/internal/client" "ldcli/internal/errors" + "regexp" + "strings" ) -type Client interface { - Create(ctx context.Context, accessToken, baseURI, name, key, projKey string) ([]byte, error) - Update( - ctx context.Context, - accessToken, - baseURI, - key, - projKey string, - patch []ldapi.PatchOperation, - ) ([]byte, error) -} - -type FlagsClient struct{} - -var _ Client = FlagsClient{} - -func NewClient() FlagsClient { - return FlagsClient{} -} +const MaxNameLength = 50 -func (c FlagsClient) Create( - ctx context.Context, - accessToken, - baseURI, - name, - key, - projectKey string, -) ([]byte, error) { - client := client.New(accessToken, baseURI) - post := ldapi.NewFeatureFlagBody(name, key) - flag, _, err := client.FeatureFlagsApi.PostFeatureFlag(ctx, projectKey).FeatureFlagBody(*post).Execute() - if err != nil { - return nil, errors.NewAPIError(err) - - } - - responseJSON, err := json.Marshal(flag) - if err != nil { - return nil, err +// NewKeyFromName creates a valid key from the name. +func NewKeyFromName(name string) (string, error) { + if len(name) < 1 { + return "", errors.NewError("Name must not be empty.") } - - return responseJSON, nil -} - -func (c FlagsClient) Update( - ctx context.Context, - accessToken, - baseURI, - key, - projKey string, - patch []ldapi.PatchOperation, -) ([]byte, error) { - client := client.New(accessToken, baseURI) - flag, _, err := client.FeatureFlagsApi. - PatchFeatureFlag(ctx, projKey, key). - PatchWithComment(*ldapi.NewPatchWithComment(patch)). - Execute() - if err != nil { - return nil, errors.NewAPIError(err) + if len(name) > MaxNameLength { + return "", errors.NewError("Name must be less than 50 characters.") } - responseJSON, err := json.Marshal(flag) - if err != nil { - return nil, err + invalid := regexp.MustCompile(`(?i)[^a-z0-9-._\s]+`) + if invalidStr := invalid.FindString(name); strings.TrimSpace(invalidStr) != "" { + return "", errors.NewError("Name must start with a letter or number and only contain letters, numbers, '.', '_' or '-'.") } - return responseJSON, nil + capitalLettersRegexp := regexp.MustCompile("[A-Z]") + spacesRegexp := regexp.MustCompile(`\s+`) + dashSpaceRegexp := regexp.MustCompile(`-\s+`) + + // change capital letters to lowercase with a prepended space + key := capitalLettersRegexp.ReplaceAllStringFunc(name, func(match string) string { + return " " + strings.ToLower(match) + }) + // change any "- " to "-" because the previous step added a space that could be preceded by a + // valid dash + key = dashSpaceRegexp.ReplaceAllString(key, " ") + // replace all spaces with a single dash + key = spacesRegexp.ReplaceAllString(key, "-") + // remove a starting dash that could have been added from converting a capital letter at the + // beginning of the string + key = strings.TrimPrefix(key, "-") + + return key, nil } diff --git a/internal/flags/flags_test.go b/internal/flags/flags_test.go new file mode 100644 index 00000000..442ae072 --- /dev/null +++ b/internal/flags/flags_test.go @@ -0,0 +1,116 @@ +package flags_test + +import ( + "fmt" + "ldcli/internal/flags" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNameToKey(t *testing.T) { + t.Run("with valid input", func(t *testing.T) { + tests := map[string]struct { + name string + expectedKey string + }{ + "converts camel case to kebab case": { + name: "myFlag", + expectedKey: "my-flag", + }, + "converts multiple uppercase to kebab case": { + name: "myNewFlag", + expectedKey: "my-new-flag", + }, + "converts leading capital camel case to kebab case": { + name: "MyFlag", + expectedKey: "my-flag", + }, + "converts multiple consecutive capitals to kebab case": { + name: "MyFLag", + expectedKey: "my-f-lag", + }, + "converts space with capital to kebab case": { + name: "My Flag", + expectedKey: "my-flag", + }, + "converts multiple spaces to kebab case": { + name: "my flag", + expectedKey: "my-flag", + }, + "converts tab to kebab case": { + name: "my\tflag", + expectedKey: "my-flag", + }, + "does not convert all lowercase": { + name: "myflag", + expectedKey: "myflag", + }, + "allows leading number": { + name: "1Flag", + expectedKey: "1-flag", + }, + "allows period": { + name: "my.Flag", + expectedKey: "my.-flag", + }, + "allows underscore": { + name: "my_Flag", + expectedKey: "my_-flag", + }, + "allows dash": { + name: "my-Flag", + expectedKey: "my-flag", + }, + "allows double dash with capital letter": { + name: "my--Flag", + expectedKey: "my--flag", + }, + "allows double dash": { + name: "my--flag", + expectedKey: "my--flag", + }, + } + for name, tt := range tests { + tt := tt + t.Run(name, func(t *testing.T) { + key, err := flags.NewKeyFromName(tt.name) + + errMsg := fmt.Sprintf("name: %s", tt.name) + require.NoError(t, err, errMsg) + assert.Equal(t, tt.expectedKey, key, errMsg) + }) + } + }) + + t.Run("with invalid input", func(t *testing.T) { + tests := map[string]struct { + name string + expectedKey string + expectedErr string + }{ + "does not allow non-alphanumeric": { + name: "my-$-flag", + expectedErr: "Name must start with a letter or number and only contain letters, numbers, '.', '_' or '-'.", + }, + "does not allow empty name": { + name: "", + expectedErr: "Name must not be empty.", + }, + "does not allow name > 50 characters": { + name: strings.Repeat("*", 51), + expectedErr: "Name must be less than 50 characters.", + }, + } + for name, tt := range tests { + tt := tt + t.Run(name, func(t *testing.T) { + _, err := flags.NewKeyFromName(tt.name) + + assert.EqualError(t, err, tt.expectedErr) + }) + } + }) +} diff --git a/internal/flags/mock.go b/internal/flags/mock_client.go similarity index 100% rename from internal/flags/mock.go rename to internal/flags/mock_client.go diff --git a/internal/quickstart/create_flag.go b/internal/quickstart/create_flag.go index 681dda3f..3b609d8c 100644 --- a/internal/quickstart/create_flag.go +++ b/internal/quickstart/create_flag.go @@ -50,14 +50,19 @@ func (m createFlagModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { input = defaultFlagName } m.flagName = input - // TODO: validate and parse key + flagKey, err := flags.NewKeyFromName(m.flagName) + if err != nil { + m.err = err + + return m, nil + } - _, err := m.client.Create( + _, err = m.client.Create( context.Background(), viper.GetString("accessToken"), viper.GetString("baseUri"), m.flagName, - "flagKey", // TODO: use key from name + flagKey, "default", ) if err != nil { @@ -65,7 +70,7 @@ func (m createFlagModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - m.flagKey = "flagKey" + m.flagKey = flagKey return m, nil case key.Matches(msg, keys.Quit):