Skip to content

Commit 7768bfa

Browse files
authored
feat: create prod-ready quickstart command (#75)
Create prod-ready quickstart command * can create a flag with a validate name and key
1 parent 6221983 commit 7768bfa

10 files changed

Lines changed: 479 additions & 71 deletions

File tree

cmd/flags/update_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
)
1414

1515
func TestUpdate(t *testing.T) {
16+
errorHelp := ". See `ldcli flags update --help` for supported flags and usage."
1617
mockArgs := []interface{}{
1718
"testAccessToken",
1819
"http://test.com",
@@ -72,7 +73,7 @@ func TestUpdate(t *testing.T) {
7273

7374
_, err := cmd.CallCmd(t, &flags.MockClient{}, nil, nil, args)
7475

75-
assert.EqualError(t, err, `required flag(s) "accessToken", "data", "key", "projKey" not set`)
76+
assert.EqualError(t, err, `required flag(s) "accessToken", "data", "key", "projKey" not set`+errorHelp)
7677
})
7778

7879
t.Run("with invalid baseUri is an error", func(t *testing.T) {
@@ -86,6 +87,6 @@ func TestUpdate(t *testing.T) {
8687

8788
_, err := cmd.CallCmd(t, &flags.MockClient{}, nil, nil, args)
8889

89-
assert.EqualError(t, err, "baseUri is invalid")
90+
assert.EqualError(t, err, "baseUri is invalid"+errorHelp)
9091
})
9192
}

cmd/quickstart.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"log"
6+
"os"
7+
8+
tea "github.com/charmbracelet/bubbletea"
9+
"github.com/spf13/cobra"
10+
11+
"ldcli/internal/flags"
12+
"ldcli/internal/quickstart"
13+
)
14+
15+
func NewQuickStartCmd(client flags.Client) *cobra.Command {
16+
return &cobra.Command{
17+
Long: "",
18+
RunE: runQuickStart(client),
19+
Short: "Setup guide to create your first feature flag",
20+
Use: "setup",
21+
}
22+
}
23+
24+
func runQuickStart(client flags.Client) func(*cobra.Command, []string) error {
25+
return func(cmd *cobra.Command, args []string) error {
26+
f, err := tea.LogToFile("debug.log", "")
27+
if err != nil {
28+
fmt.Println("could not open file for debugging:", err)
29+
os.Exit(1)
30+
}
31+
defer f.Close()
32+
33+
_, err = tea.NewProgram(quickstart.NewContainerModel(client)).Run()
34+
if err != nil {
35+
log.Fatal(err)
36+
}
37+
38+
return nil
39+
}
40+
}

cmd/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import (
1717
)
1818

1919
func NewRootCommand(flagsClient flags.Client, membersClient members.Client, projectsClient projects.Client) (*cobra.Command, error) {
20-
2120
cmd := &cobra.Command{
2221
Use: "ldcli",
2322
Short: "LaunchDarkly CLI",
@@ -70,6 +69,7 @@ func NewRootCommand(flagsClient flags.Client, membersClient members.Client, proj
7069
return nil, err
7170
}
7271

72+
cmd.AddCommand(NewQuickStartCmd(flagsClient))
7373
cmd.AddCommand(flagsCmd)
7474
cmd.AddCommand(membersCmd)
7575
cmd.AddCommand(projectsCmd)

cmd/setup.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
)
1313

1414
var setupCmd = &cobra.Command{
15-
Use: "setup",
15+
Use: "setup-TOREMOVE",
1616
Short: "Setup guide to create your first feature flag",
1717
Long: "",
1818
RunE: runSetup,

internal/flags/client.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package flags
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
7+
ldapi "github.com/launchdarkly/api-client-go/v14"
8+
9+
"ldcli/internal/client"
10+
"ldcli/internal/errors"
11+
)
12+
13+
type Client interface {
14+
Create(ctx context.Context, accessToken, baseURI, name, key, projKey string) ([]byte, error)
15+
Update(
16+
ctx context.Context,
17+
accessToken,
18+
baseURI,
19+
key,
20+
projKey string,
21+
patch []ldapi.PatchOperation,
22+
) ([]byte, error)
23+
}
24+
25+
type FlagsClient struct{}
26+
27+
var _ Client = FlagsClient{}
28+
29+
func NewClient() FlagsClient {
30+
return FlagsClient{}
31+
}
32+
33+
func (c FlagsClient) Create(
34+
ctx context.Context,
35+
accessToken,
36+
baseURI,
37+
name,
38+
key,
39+
projectKey string,
40+
) ([]byte, error) {
41+
client := client.New(accessToken, baseURI)
42+
post := ldapi.NewFeatureFlagBody(name, key)
43+
flag, _, err := client.FeatureFlagsApi.PostFeatureFlag(ctx, projectKey).FeatureFlagBody(*post).Execute()
44+
if err != nil {
45+
return nil, errors.NewAPIError(err)
46+
47+
}
48+
49+
responseJSON, err := json.Marshal(flag)
50+
if err != nil {
51+
return nil, err
52+
}
53+
54+
return responseJSON, nil
55+
}
56+
57+
func (c FlagsClient) Update(
58+
ctx context.Context,
59+
accessToken,
60+
baseURI,
61+
key,
62+
projKey string,
63+
patch []ldapi.PatchOperation,
64+
) ([]byte, error) {
65+
client := client.New(accessToken, baseURI)
66+
flag, _, err := client.FeatureFlagsApi.
67+
PatchFeatureFlag(ctx, projKey, key).
68+
PatchWithComment(*ldapi.NewPatchWithComment(patch)).
69+
Execute()
70+
if err != nil {
71+
return nil, errors.NewAPIError(err)
72+
}
73+
74+
responseJSON, err := json.Marshal(flag)
75+
if err != nil {
76+
return nil, err
77+
}
78+
79+
return responseJSON, nil
80+
}

internal/flags/flags.go

Lines changed: 30 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,43 @@
11
package flags
22

33
import (
4-
"context"
5-
"encoding/json"
6-
7-
ldapi "github.com/launchdarkly/api-client-go/v14"
8-
9-
"ldcli/internal/client"
104
"ldcli/internal/errors"
5+
"regexp"
6+
"strings"
117
)
128

13-
type Client interface {
14-
Create(ctx context.Context, accessToken, baseURI, name, key, projKey string) ([]byte, error)
15-
Update(
16-
ctx context.Context,
17-
accessToken,
18-
baseURI,
19-
key,
20-
projKey string,
21-
patch []ldapi.PatchOperation,
22-
) ([]byte, error)
23-
}
24-
25-
type FlagsClient struct{}
26-
27-
var _ Client = FlagsClient{}
28-
29-
func NewClient() FlagsClient {
30-
return FlagsClient{}
31-
}
9+
const MaxNameLength = 50
3210

33-
func (c FlagsClient) Create(
34-
ctx context.Context,
35-
accessToken,
36-
baseURI,
37-
name,
38-
key,
39-
projectKey string,
40-
) ([]byte, error) {
41-
client := client.New(accessToken, baseURI)
42-
post := ldapi.NewFeatureFlagBody(name, key)
43-
flag, _, err := client.FeatureFlagsApi.PostFeatureFlag(ctx, projectKey).FeatureFlagBody(*post).Execute()
44-
if err != nil {
45-
return nil, errors.NewAPIError(err)
46-
47-
}
48-
49-
responseJSON, err := json.Marshal(flag)
50-
if err != nil {
51-
return nil, err
11+
// NewKeyFromName creates a valid key from the name.
12+
func NewKeyFromName(name string) (string, error) {
13+
if len(name) < 1 {
14+
return "", errors.NewError("Name must not be empty.")
5215
}
53-
54-
return responseJSON, nil
55-
}
56-
57-
func (c FlagsClient) Update(
58-
ctx context.Context,
59-
accessToken,
60-
baseURI,
61-
key,
62-
projKey string,
63-
patch []ldapi.PatchOperation,
64-
) ([]byte, error) {
65-
client := client.New(accessToken, baseURI)
66-
flag, _, err := client.FeatureFlagsApi.
67-
PatchFeatureFlag(ctx, projKey, key).
68-
PatchWithComment(*ldapi.NewPatchWithComment(patch)).
69-
Execute()
70-
if err != nil {
71-
return nil, errors.NewAPIError(err)
16+
if len(name) > MaxNameLength {
17+
return "", errors.NewError("Name must be less than 50 characters.")
7218
}
7319

74-
responseJSON, err := json.Marshal(flag)
75-
if err != nil {
76-
return nil, err
20+
invalid := regexp.MustCompile(`(?i)[^a-z0-9-._\s]+`)
21+
if invalidStr := invalid.FindString(name); strings.TrimSpace(invalidStr) != "" {
22+
return "", errors.NewError("Name must start with a letter or number and only contain letters, numbers, '.', '_' or '-'.")
7723
}
7824

79-
return responseJSON, nil
25+
capitalLettersRegexp := regexp.MustCompile("[A-Z]")
26+
spacesRegexp := regexp.MustCompile(`\s+`)
27+
dashSpaceRegexp := regexp.MustCompile(`-\s+`)
28+
29+
// change capital letters to lowercase with a prepended space
30+
key := capitalLettersRegexp.ReplaceAllStringFunc(name, func(match string) string {
31+
return " " + strings.ToLower(match)
32+
})
33+
// change any "- " to "-" because the previous step added a space that could be preceded by a
34+
// valid dash
35+
key = dashSpaceRegexp.ReplaceAllString(key, " ")
36+
// replace all spaces with a single dash
37+
key = spacesRegexp.ReplaceAllString(key, "-")
38+
// remove a starting dash that could have been added from converting a capital letter at the
39+
// beginning of the string
40+
key = strings.TrimPrefix(key, "-")
41+
42+
return key, nil
8043
}

internal/flags/flags_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package flags_test
2+
3+
import (
4+
"fmt"
5+
"ldcli/internal/flags"
6+
"strings"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestNameToKey(t *testing.T) {
14+
t.Run("with valid input", func(t *testing.T) {
15+
tests := map[string]struct {
16+
name string
17+
expectedKey string
18+
}{
19+
"converts camel case to kebab case": {
20+
name: "myFlag",
21+
expectedKey: "my-flag",
22+
},
23+
"converts multiple uppercase to kebab case": {
24+
name: "myNewFlag",
25+
expectedKey: "my-new-flag",
26+
},
27+
"converts leading capital camel case to kebab case": {
28+
name: "MyFlag",
29+
expectedKey: "my-flag",
30+
},
31+
"converts multiple consecutive capitals to kebab case": {
32+
name: "MyFLag",
33+
expectedKey: "my-f-lag",
34+
},
35+
"converts space with capital to kebab case": {
36+
name: "My Flag",
37+
expectedKey: "my-flag",
38+
},
39+
"converts multiple spaces to kebab case": {
40+
name: "my flag",
41+
expectedKey: "my-flag",
42+
},
43+
"converts tab to kebab case": {
44+
name: "my\tflag",
45+
expectedKey: "my-flag",
46+
},
47+
"does not convert all lowercase": {
48+
name: "myflag",
49+
expectedKey: "myflag",
50+
},
51+
"allows leading number": {
52+
name: "1Flag",
53+
expectedKey: "1-flag",
54+
},
55+
"allows period": {
56+
name: "my.Flag",
57+
expectedKey: "my.-flag",
58+
},
59+
"allows underscore": {
60+
name: "my_Flag",
61+
expectedKey: "my_-flag",
62+
},
63+
"allows dash": {
64+
name: "my-Flag",
65+
expectedKey: "my-flag",
66+
},
67+
"allows double dash with capital letter": {
68+
name: "my--Flag",
69+
expectedKey: "my--flag",
70+
},
71+
"allows double dash": {
72+
name: "my--flag",
73+
expectedKey: "my--flag",
74+
},
75+
}
76+
for name, tt := range tests {
77+
tt := tt
78+
t.Run(name, func(t *testing.T) {
79+
key, err := flags.NewKeyFromName(tt.name)
80+
81+
errMsg := fmt.Sprintf("name: %s", tt.name)
82+
require.NoError(t, err, errMsg)
83+
assert.Equal(t, tt.expectedKey, key, errMsg)
84+
})
85+
}
86+
})
87+
88+
t.Run("with invalid input", func(t *testing.T) {
89+
tests := map[string]struct {
90+
name string
91+
expectedKey string
92+
expectedErr string
93+
}{
94+
"does not allow non-alphanumeric": {
95+
name: "my-$-flag",
96+
expectedErr: "Name must start with a letter or number and only contain letters, numbers, '.', '_' or '-'.",
97+
},
98+
"does not allow empty name": {
99+
name: "",
100+
expectedErr: "Name must not be empty.",
101+
},
102+
"does not allow name > 50 characters": {
103+
name: strings.Repeat("*", 51),
104+
expectedErr: "Name must be less than 50 characters.",
105+
},
106+
}
107+
for name, tt := range tests {
108+
tt := tt
109+
t.Run(name, func(t *testing.T) {
110+
_, err := flags.NewKeyFromName(tt.name)
111+
112+
assert.EqualError(t, err, tt.expectedErr)
113+
})
114+
}
115+
})
116+
}

0 commit comments

Comments
 (0)