mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +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)
|
||||
}
|
||||
|
||||
Generated
+4
@@ -6239,6 +6239,10 @@ const docTemplate = `{
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": {
|
||||
"description": "Required means this value must be set by some means. It requires\n` + "`" + `ValueSource != ValueSourceNone` + "`" + `\nIf ` + "`" + `Default` + "`" + ` is set, then ` + "`" + `Required` + "`" + ` is ignored.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"use_instead": {
|
||||
"description": "UseInstead is a list of options that should be used instead of this one.\nThe field is used to generate a deprecation warning.",
|
||||
"type": "array",
|
||||
|
||||
Generated
+4
@@ -5529,6 +5529,10 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": {
|
||||
"description": "Required means this value must be set by some means. It requires\n`ValueSource != ValueSourceNone`\nIf `Default` is set, then `Required` is ignored.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"use_instead": {
|
||||
"description": "UseInstead is a list of options that should be used instead of this one.\nThe field is used to generate a deprecation warning.",
|
||||
"type": "array",
|
||||
|
||||
@@ -398,6 +398,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
|
||||
},
|
||||
"hidden": true,
|
||||
"name": "string",
|
||||
"required": true,
|
||||
"use_instead": [{}],
|
||||
"value": null,
|
||||
"value_source": "",
|
||||
|
||||
+19
-15
@@ -533,6 +533,7 @@
|
||||
},
|
||||
"hidden": true,
|
||||
"name": "string",
|
||||
"required": true,
|
||||
"use_instead": [
|
||||
{
|
||||
"annotations": {
|
||||
@@ -557,6 +558,7 @@
|
||||
},
|
||||
"hidden": true,
|
||||
"name": "string",
|
||||
"required": true,
|
||||
"use_instead": [],
|
||||
"value": null,
|
||||
"value_source": "",
|
||||
@@ -571,21 +573,22 @@
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ---------------- | ------------------------------------------ | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `annotations` | [clibase.Annotations](#clibaseannotations) | false | | Annotations enable extensions to clibase higher up in the stack. It's useful for help formatting and documentation generation. |
|
||||
| `default` | string | false | | Default is parsed into Value if set. |
|
||||
| `description` | string | false | | |
|
||||
| `env` | string | false | | Env is the environment variable used to configure this option. If unset, environment configuring is disabled. |
|
||||
| `flag` | string | false | | Flag is the long name of the flag used to configure this option. If unset, flag configuring is disabled. |
|
||||
| `flag_shorthand` | string | false | | Flag shorthand is the one-character shorthand for the flag. If unset, no shorthand is used. |
|
||||
| `group` | [clibase.Group](#clibasegroup) | false | | Group is a group hierarchy that helps organize this option in help, configs and other documentation. |
|
||||
| `hidden` | boolean | false | | |
|
||||
| `name` | string | false | | |
|
||||
| `use_instead` | array of [clibase.Option](#clibaseoption) | false | | Use instead is a list of options that should be used instead of this one. The field is used to generate a deprecation warning. |
|
||||
| `value` | any | false | | Value includes the types listed in values.go. |
|
||||
| `value_source` | [clibase.ValueSource](#clibasevaluesource) | false | | |
|
||||
| `yaml` | string | false | | Yaml is the YAML key used to configure this option. If unset, YAML configuring is disabled. |
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ---------------- | ------------------------------------------ | -------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `annotations` | [clibase.Annotations](#clibaseannotations) | false | | Annotations enable extensions to clibase higher up in the stack. It's useful for help formatting and documentation generation. |
|
||||
| `default` | string | false | | Default is parsed into Value if set. |
|
||||
| `description` | string | false | | |
|
||||
| `env` | string | false | | Env is the environment variable used to configure this option. If unset, environment configuring is disabled. |
|
||||
| `flag` | string | false | | Flag is the long name of the flag used to configure this option. If unset, flag configuring is disabled. |
|
||||
| `flag_shorthand` | string | false | | Flag shorthand is the one-character shorthand for the flag. If unset, no shorthand is used. |
|
||||
| `group` | [clibase.Group](#clibasegroup) | false | | Group is a group hierarchy that helps organize this option in help, configs and other documentation. |
|
||||
| `hidden` | boolean | false | | |
|
||||
| `name` | string | false | | |
|
||||
| `required` | boolean | false | | Required means this value must be set by some means. It requires `ValueSource != ValueSourceNone` If `Default` is set, then `Required` is ignored. |
|
||||
| `use_instead` | array of [clibase.Option](#clibaseoption) | false | | Use instead is a list of options that should be used instead of this one. The field is used to generate a deprecation warning. |
|
||||
| `value` | any | false | | Value includes the types listed in values.go. |
|
||||
| `value_source` | [clibase.ValueSource](#clibasevaluesource) | false | | |
|
||||
| `yaml` | string | false | | Yaml is the YAML key used to configure this option. If unset, YAML configuring is disabled. |
|
||||
|
||||
## clibase.Struct-array_codersdk_GitAuthConfig
|
||||
|
||||
@@ -2099,6 +2102,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
},
|
||||
"hidden": true,
|
||||
"name": "string",
|
||||
"required": true,
|
||||
"use_instead": [{}],
|
||||
"value": null,
|
||||
"value_source": "",
|
||||
|
||||
@@ -65,7 +65,7 @@ func (*RootCmd) proxyServer() *clibase.Cmd {
|
||||
Flag: "proxy-session-token",
|
||||
Env: "CODER_PROXY_SESSION_TOKEN",
|
||||
YAML: "proxySessionToken",
|
||||
Default: "",
|
||||
Required: true,
|
||||
Value: &proxySessionToken,
|
||||
Group: &externalProxyOptionGroup,
|
||||
Hidden: false,
|
||||
@@ -77,10 +77,15 @@ func (*RootCmd) proxyServer() *clibase.Cmd {
|
||||
Flag: "primary-access-url",
|
||||
Env: "CODER_PRIMARY_ACCESS_URL",
|
||||
YAML: "primaryAccessURL",
|
||||
Default: "",
|
||||
Value: &primaryAccessURL,
|
||||
Group: &externalProxyOptionGroup,
|
||||
Hidden: false,
|
||||
Required: true,
|
||||
Value: clibase.Validate(&primaryAccessURL, func(value *clibase.URL) error {
|
||||
if !(value.Scheme == "http" || value.Scheme == "https") {
|
||||
return xerrors.Errorf("'--primary-access-url' value must be http or https: url=%s", primaryAccessURL.String())
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
Group: &externalProxyOptionGroup,
|
||||
Hidden: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -94,10 +99,6 @@ func (*RootCmd) proxyServer() *clibase.Cmd {
|
||||
clibase.RequireNArgs(0),
|
||||
),
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
if !(primaryAccessURL.Scheme == "http" || primaryAccessURL.Scheme == "https") {
|
||||
return xerrors.Errorf("'--primary-access-url' value must be http or https: url=%s", primaryAccessURL.String())
|
||||
}
|
||||
|
||||
var closers closers
|
||||
// Main command context for managing cancellation of running
|
||||
// services.
|
||||
|
||||
@@ -235,6 +235,12 @@ func (r *RootCmd) createProxy() *clibase.Cmd {
|
||||
noPrompts bool
|
||||
formatter = newUpdateProxyResponseFormatter()
|
||||
)
|
||||
validateIcon := func(s *clibase.String) error {
|
||||
if !(strings.HasPrefix(s.Value(), "/emojis/") || strings.HasPrefix(s.Value(), "http")) {
|
||||
return xerrors.New("icon must be a relative path to an emoji or a publicly hosted image URL")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
client := new(codersdk.Client)
|
||||
cmd := &clibase.Cmd{
|
||||
@@ -271,10 +277,7 @@ func (r *RootCmd) createProxy() *clibase.Cmd {
|
||||
Text: "Icon URL:",
|
||||
Default: "/emojis/1f5fa.png",
|
||||
Validate: func(s string) error {
|
||||
if !(strings.HasPrefix(s, "/emojis/") || strings.HasPrefix(s, "http")) {
|
||||
return xerrors.New("icon must be a relative path to an emoji or a publicly hosted image URL")
|
||||
}
|
||||
return nil
|
||||
return validateIcon(clibase.StringOf(&s))
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -319,7 +322,7 @@ func (r *RootCmd) createProxy() *clibase.Cmd {
|
||||
clibase.Option{
|
||||
Flag: "icon",
|
||||
Description: "Display icon of the proxy.",
|
||||
Value: clibase.StringOf(&proxyIcon),
|
||||
Value: clibase.Validate(clibase.StringOf(&proxyIcon), validateIcon),
|
||||
},
|
||||
clibase.Option{
|
||||
Flag: "no-prompt",
|
||||
|
||||
Reference in New Issue
Block a user