mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add cli first class validation (#8374)
* feat: add cli first class validation * feat: add required flag to cli options * Add unit test to catch invalid and missing flag
This commit is contained in:
@@ -333,6 +333,18 @@ func (inv *Invocation) run(state *runState) error {
|
||||
)
|
||||
}
|
||||
|
||||
// All options should be set. Check all required options have sources,
|
||||
// meaning they were set by the user in some way (env, flag, etc).
|
||||
var missing []string
|
||||
for _, opt := range inv.Command.Options {
|
||||
if opt.Required && opt.ValueSource == ValueSourceNone {
|
||||
missing = append(missing, opt.Flag)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return xerrors.Errorf("Missing values for the required flags: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
if inv.Command.RawArgs {
|
||||
// If we're at the root command, then the name is omitted
|
||||
// from the arguments, so we can just use the entire slice.
|
||||
|
||||
+87
-3
@@ -38,6 +38,8 @@ func TestCommand(t *testing.T) {
|
||||
verbose bool
|
||||
lower bool
|
||||
prefix string
|
||||
reqBool bool
|
||||
reqStr string
|
||||
)
|
||||
return &clibase.Cmd{
|
||||
Use: "root [subcommand]",
|
||||
@@ -54,6 +56,34 @@ func TestCommand(t *testing.T) {
|
||||
},
|
||||
},
|
||||
Children: []*clibase.Cmd{
|
||||
{
|
||||
Use: "required-flag --req-bool=true --req-string=foo",
|
||||
Short: "Example with required flags",
|
||||
Options: clibase.OptionSet{
|
||||
clibase.Option{
|
||||
Name: "req-bool",
|
||||
Flag: "req-bool",
|
||||
Value: clibase.BoolOf(&reqBool),
|
||||
Required: true,
|
||||
},
|
||||
clibase.Option{
|
||||
Name: "req-string",
|
||||
Flag: "req-string",
|
||||
Value: clibase.Validate(clibase.StringOf(&reqStr), func(value *clibase.String) error {
|
||||
ok := strings.Contains(value.String(), " ")
|
||||
if !ok {
|
||||
return xerrors.Errorf("string must contain a space")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
Handler: func(i *clibase.Invocation) error {
|
||||
_, _ = i.Stdout.Write([]byte(fmt.Sprintf("%s-%t", reqStr, reqBool)))
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Use: "toupper [word]",
|
||||
Short: "Converts a word to upper case",
|
||||
@@ -68,8 +98,8 @@ func TestCommand(t *testing.T) {
|
||||
Value: clibase.BoolOf(&lower),
|
||||
},
|
||||
},
|
||||
Handler: (func(i *clibase.Invocation) error {
|
||||
i.Stdout.Write([]byte(prefix))
|
||||
Handler: func(i *clibase.Invocation) error {
|
||||
_, _ = i.Stdout.Write([]byte(prefix))
|
||||
w := i.Args[0]
|
||||
if lower {
|
||||
w = strings.ToLower(w)
|
||||
@@ -85,7 +115,7 @@ func TestCommand(t *testing.T) {
|
||||
i.Stdout.Write([]byte("!!!"))
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -213,6 +243,60 @@ func TestCommand(t *testing.T) {
|
||||
fio := fakeIO(i)
|
||||
require.Error(t, i.Run(), fio.Stdout.String())
|
||||
})
|
||||
|
||||
t.Run("RequiredFlagsMissing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
i := cmd().Invoke(
|
||||
"required-flag",
|
||||
)
|
||||
fio := fakeIO(i)
|
||||
err := i.Run()
|
||||
require.Error(t, err, fio.Stdout.String())
|
||||
require.ErrorContains(t, err, "Missing values")
|
||||
})
|
||||
|
||||
t.Run("RequiredFlagsMissingBool", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
i := cmd().Invoke(
|
||||
"required-flag", "--req-string", "foo bar",
|
||||
)
|
||||
fio := fakeIO(i)
|
||||
err := i.Run()
|
||||
require.Error(t, err, fio.Stdout.String())
|
||||
require.ErrorContains(t, err, "Missing values for the required flags: req-bool")
|
||||
})
|
||||
|
||||
t.Run("RequiredFlagsMissingString", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
i := cmd().Invoke(
|
||||
"required-flag", "--req-bool", "true",
|
||||
)
|
||||
fio := fakeIO(i)
|
||||
err := i.Run()
|
||||
require.Error(t, err, fio.Stdout.String())
|
||||
require.ErrorContains(t, err, "Missing values for the required flags: req-string")
|
||||
})
|
||||
|
||||
t.Run("RequiredFlagsInvalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
i := cmd().Invoke(
|
||||
"required-flag", "--req-string", "nospace",
|
||||
)
|
||||
fio := fakeIO(i)
|
||||
err := i.Run()
|
||||
require.Error(t, err, fio.Stdout.String())
|
||||
require.ErrorContains(t, err, "string must contain a space")
|
||||
})
|
||||
|
||||
t.Run("RequiredFlagsOK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
i := cmd().Invoke(
|
||||
"required-flag", "--req-bool", "true", "--req-string", "foo bar",
|
||||
)
|
||||
fio := fakeIO(i)
|
||||
err := i.Run()
|
||||
require.NoError(t, err, fio.Stdout.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestCommand_DeepNest(t *testing.T) {
|
||||
|
||||
@@ -23,6 +23,10 @@ const (
|
||||
type Option struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
// Required means this value must be set by some means. It requires
|
||||
// `ValueSource != ValueSourceNone`
|
||||
// If `Default` is set, then `Required` is ignored.
|
||||
Required bool `json:"required,omitempty"`
|
||||
|
||||
// Flag is the long name of the flag used to configure this option. If unset,
|
||||
// flag configuring is disabled.
|
||||
|
||||
@@ -24,6 +24,40 @@ type NoOptDefValuer interface {
|
||||
NoOptDefValue() string
|
||||
}
|
||||
|
||||
// Validator is a wrapper around a pflag.Value that allows for validation
|
||||
// of the value after or before it has been set.
|
||||
type Validator[T pflag.Value] struct {
|
||||
Value T
|
||||
// validate is called after the value is set.
|
||||
validate func(T) error
|
||||
}
|
||||
|
||||
func Validate[T pflag.Value](opt T, validate func(value T) error) *Validator[T] {
|
||||
return &Validator[T]{Value: opt, validate: validate}
|
||||
}
|
||||
|
||||
func (i *Validator[T]) String() string {
|
||||
return i.Value.String()
|
||||
}
|
||||
|
||||
func (i *Validator[T]) Set(input string) error {
|
||||
err := i.Value.Set(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if i.validate != nil {
|
||||
err = i.validate(i.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Validator[T]) Type() string {
|
||||
return i.Value.Type()
|
||||
}
|
||||
|
||||
// values.go contains a standard set of value types that can be used as
|
||||
// Option Values.
|
||||
|
||||
@@ -329,10 +363,12 @@ type Struct[T any] struct {
|
||||
Value T
|
||||
}
|
||||
|
||||
//nolint:revive
|
||||
func (s *Struct[T]) Set(v string) error {
|
||||
return yaml.Unmarshal([]byte(v), &s.Value)
|
||||
}
|
||||
|
||||
//nolint:revive
|
||||
func (s *Struct[T]) String() string {
|
||||
byt, err := yaml.Marshal(s.Value)
|
||||
if err != nil {
|
||||
@@ -361,6 +397,7 @@ func (s *Struct[T]) UnmarshalYAML(n *yaml.Node) error {
|
||||
return n.Decode(&s.Value)
|
||||
}
|
||||
|
||||
//nolint:revive
|
||||
func (s *Struct[T]) Type() string {
|
||||
return fmt.Sprintf("struct[%T]", s.Value)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user