Skip to content

feat: add cli first class validation #8374

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jul 11, 2023
Merged
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
12 changes: 12 additions & 0 deletions cli/clibase/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
90 changes: 87 additions & 3 deletions cli/clibase/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]",
Expand All @@ -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",
Expand All @@ -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)
Expand All @@ -85,7 +115,7 @@ func TestCommand(t *testing.T) {
i.Stdout.Write([]byte("!!!"))
}
return nil
}),
},
},
},
}
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions cli/clibase/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not make Required a composable Validation rule instead?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It cannot be a validation rule because the validation rule is only called on Set(). If the user never provides a value, and no default is set, then Set() is never called.

So it has to happen in the flag parsing logic.

Required bool `json:"required,omitempty"`

// Flag is the long name of the flag used to configure this option. If unset,
// flag configuring is disabled.
Expand Down
37 changes: 37 additions & 0 deletions cli/clibase/values.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that this implements pflag.Value.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe there's a function Required[T any]() ValidateFunc[T]

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe there's a function Required[T any]() ValidateFunc[T]

I was trying, but unfortunately Validate() can only validate inputs. If there is no input, Set() is never called for this to trigger on.

The only way I could think of making it work is have some post-processing step for the option? It felt easier to make it a flag on the Options and just touch it at the flag parsing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's a common enough validation requirement that making it apart of the Option field is justified.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. The downside is that if Default is set, then Required is ignored. Maybe we should throw an error somewhere if you configure an option like that.

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.

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
4 changes: 4 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/api/general.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand Down
34 changes: 19 additions & 15 deletions docs/api/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,7 @@
},
"hidden": true,
"name": "string",
"required": true,
"use_instead": [
{
"annotations": {
Expand All @@ -557,6 +558,7 @@
},
"hidden": true,
"name": "string",
"required": true,
"use_instead": [],
"value": null,
"value_source": "",
Expand All @@ -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

Expand Down Expand Up @@ -2097,6 +2100,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": "",
Expand Down
19 changes: 10 additions & 9 deletions enterprise/cli/proxyserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
},
)

Expand All @@ -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.
Expand Down
13 changes: 8 additions & 5 deletions enterprise/cli/workspaceproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down