diff --git a/cli/cliui/table.go b/cli/cliui/table.go index 66ba29acaa..6a07e5d7c9 100644 --- a/cli/cliui/table.go +++ b/cli/cliui/table.go @@ -1,9 +1,14 @@ package cliui import ( + "fmt" + "reflect" "strings" + "time" + "github.com/fatih/structtag" "github.com/jedib0t/go-pretty/v6/table" + "golang.org/x/xerrors" ) // Table creates a new table with standardized styles. @@ -41,3 +46,258 @@ func FilterTableColumns(header table.Row, columns []string) []table.ColumnConfig } return columnConfigs } + +// DisplayTable renders a table as a string. The input argument must be a slice +// of structs. At least one field in the struct must have a `table:""` tag +// containing the name of the column in the outputted table. +// +// Nested structs are processed if the field has the `table:"$NAME,recursive"` +// tag and their fields will be named as `$PARENT_NAME $NAME`. If the tag is +// malformed or a field is marked as recursive but does not contain a struct or +// a pointer to a struct, this function will return an error (even with an empty +// input slice). +// +// If sort is empty, the input order will be used. If filterColumns is empty or +// nil, all available columns are included. +func DisplayTable(out any, sort string, filterColumns []string) (string, error) { + v := reflect.Indirect(reflect.ValueOf(out)) + + if v.Kind() != reflect.Slice { + return "", xerrors.Errorf("DisplayTable called with a non-slice type") + } + + // Get the list of table column headers. + headersRaw, err := typeToTableHeaders(v.Type().Elem()) + if err != nil { + return "", xerrors.Errorf("get table headers recursively for type %q: %w", v.Type().Elem().String(), err) + } + if len(headersRaw) == 0 { + return "", xerrors.New(`no table headers found on the input type, make sure there is at least one "table" struct tag`) + } + headers := make(table.Row, len(headersRaw)) + for i, header := range headersRaw { + headers[i] = header + } + + // Verify that the given sort column and filter columns are valid. + if sort != "" || len(filterColumns) != 0 { + headersMap := make(map[string]string, len(headersRaw)) + for _, header := range headersRaw { + headersMap[strings.ToLower(header)] = header + } + + if sort != "" { + sort = strings.ToLower(strings.ReplaceAll(sort, "_", " ")) + h, ok := headersMap[sort] + if !ok { + return "", xerrors.Errorf("specified sort column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`)) + } + + // Autocorrect + sort = h + } + + for i, column := range filterColumns { + column := strings.ToLower(strings.ReplaceAll(column, "_", " ")) + h, ok := headersMap[column] + if !ok { + return "", xerrors.Errorf("specified filter column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`)) + } + + // Autocorrect + filterColumns[i] = h + } + } + + // Verify that the given sort column is valid. + if sort != "" { + sort = strings.ReplaceAll(sort, "_", " ") + found := false + for _, header := range headersRaw { + if strings.EqualFold(sort, header) { + found = true + sort = header + break + } + } + if !found { + return "", xerrors.Errorf("specified sort column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`)) + } + } + + // Setup the table formatter. + tw := Table() + tw.AppendHeader(headers) + tw.SetColumnConfigs(FilterTableColumns(headers, filterColumns)) + if sort != "" { + tw.SortBy([]table.SortBy{{ + Name: sort, + }}) + } + + // Write each struct to the table. + for i := 0; i < v.Len(); i++ { + // Format the row as a slice. + rowMap, err := valueToTableMap(v.Index(i)) + if err != nil { + return "", xerrors.Errorf("get table row map %v: %w", i, err) + } + + rowSlice := make([]any, len(headers)) + for i, h := range headersRaw { + v, ok := rowMap[h] + if !ok { + v = nil + } + + // Special type formatting. + switch val := v.(type) { + case time.Time: + v = val.Format(time.Stamp) + case *time.Time: + if val != nil { + v = val.Format(time.Stamp) + } + } + + rowSlice[i] = v + } + + tw.AppendRow(table.Row(rowSlice)) + } + + return tw.Render(), nil +} + +// parseTableStructTag returns the name of the field according to the `table` +// struct tag. If the table tag does not exist or is "-", an empty string is +// returned. If the table tag is malformed, an error is returned. +// +// The returned name is transformed from "snake_case" to "normal text". +func parseTableStructTag(field reflect.StructField) (name string, recurse bool, err error) { + tags, err := structtag.Parse(string(field.Tag)) + if err != nil { + return "", false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err) + } + + tag, err := tags.Get("table") + if err != nil || tag.Name == "-" { + // tags.Get only returns an error if the tag is not found. + return "", false, nil + } + + recursive := false + for _, opt := range tag.Options { + if opt == "recursive" { + recursive = true + continue + } + + return "", false, xerrors.Errorf("unknown option %q in struct field tag", opt) + } + + return strings.ReplaceAll(tag.Name, "_", " "), recursive, nil +} + +func isStructOrStructPointer(t reflect.Type) bool { + return t.Kind() == reflect.Struct || (t.Kind() == reflect.Pointer && t.Elem().Kind() == reflect.Struct) +} + +// typeToTableHeaders converts a type to a slice of column names. If the given +// type is invalid (not a struct or a pointer to a struct, has invalid table +// tags, etc.), an error is returned. +func typeToTableHeaders(t reflect.Type) ([]string, error) { + if !isStructOrStructPointer(t) { + return nil, xerrors.Errorf("typeToTableHeaders called with a non-struct or a non-pointer-to-a-struct type") + } + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + + headers := []string{} + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + name, recursive, err := parseTableStructTag(field) + if err != nil { + return nil, xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err) + } + if name == "" { + continue + } + + fieldType := field.Type + if recursive { + if !isStructOrStructPointer(fieldType) { + return nil, xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, t.String()) + } + + childNames, err := typeToTableHeaders(fieldType) + if err != nil { + return nil, xerrors.Errorf("get child field header names for field %q in type %q: %w", field.Name, fieldType.String(), err) + } + for _, childName := range childNames { + headers = append(headers, fmt.Sprintf("%s %s", name, childName)) + } + continue + } + + headers = append(headers, name) + } + + return headers, nil +} + +// valueToTableMap converts a struct to a map of column name to value. If the +// given type is invalid (not a struct or a pointer to a struct, has invalid +// table tags, etc.), an error is returned. +func valueToTableMap(val reflect.Value) (map[string]any, error) { + if !isStructOrStructPointer(val.Type()) { + return nil, xerrors.Errorf("valueToTableMap called with a non-struct or a non-pointer-to-a-struct type") + } + if val.Kind() == reflect.Pointer { + if val.IsNil() { + // No data for this struct, so return an empty map. All values will + // be rendered as nil in the resulting table. + return map[string]any{}, nil + } + + val = val.Elem() + } + + row := map[string]any{} + for i := 0; i < val.NumField(); i++ { + field := val.Type().Field(i) + fieldVal := val.Field(i) + name, recursive, err := parseTableStructTag(field) + if err != nil { + return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err) + } + if name == "" { + continue + } + + // Recurse if it's a struct. + fieldType := field.Type + if recursive { + if !isStructOrStructPointer(fieldType) { + return nil, xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, fieldType.String()) + } + + // valueToTableMap does nothing on pointers so we don't need to + // filter here. + childMap, err := valueToTableMap(fieldVal) + if err != nil { + return nil, xerrors.Errorf("get child field values for field %q in type %q: %w", field.Name, fieldType.String(), err) + } + for childName, childValue := range childMap { + row[fmt.Sprintf("%s %s", name, childName)] = childValue + } + continue + } + + // Otherwise, we just use the field value. + row[name] = val.Field(i).Interface() + } + + return row, nil +} diff --git a/cli/cliui/table_test.go b/cli/cliui/table_test.go new file mode 100644 index 0000000000..1c1fc184a0 --- /dev/null +++ b/cli/cliui/table_test.go @@ -0,0 +1,341 @@ +package cliui_test + +import ( + "log" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/cliui" +) + +type tableTest1 struct { + Name string `table:"name"` + NotIncluded string // no table tag + Age int `table:"age"` + Roles []string `table:"roles"` + Sub1 tableTest2 `table:"sub_1,recursive"` + Sub2 *tableTest2 `table:"sub_2,recursive"` + Sub3 tableTest3 `table:"sub 3,recursive"` + Sub4 tableTest2 `table:"sub 4"` // not recursive + + // Types with special formatting. + Time time.Time `table:"time"` + TimePtr *time.Time `table:"time_ptr"` +} + +type tableTest2 struct { + Name string `table:"name"` + Age int `table:"age"` + NotIncluded string `table:"-"` +} + +type tableTest3 struct { + NotIncluded string // no table tag + Sub tableTest2 `table:"inner,recursive"` +} + +func Test_DisplayTable(t *testing.T) { + t.Parallel() + + someTime := time.Date(2022, 8, 2, 15, 49, 10, 0, time.Local) + in := []tableTest1{ + { + Name: "foo", + Age: 10, + Roles: []string{"a", "b", "c"}, + Sub1: tableTest2{ + Name: "foo1", + Age: 11, + }, + Sub2: &tableTest2{ + Name: "foo2", + Age: 12, + }, + Sub3: tableTest3{ + Sub: tableTest2{ + Name: "foo3", + Age: 13, + }, + }, + Sub4: tableTest2{ + Name: "foo4", + Age: 14, + }, + Time: someTime, + TimePtr: &someTime, + }, + { + Name: "bar", + Age: 20, + Roles: []string{"a"}, + Sub1: tableTest2{ + Name: "bar1", + Age: 21, + }, + Sub2: nil, + Sub3: tableTest3{ + Sub: tableTest2{ + Name: "bar3", + Age: 23, + }, + }, + Sub4: tableTest2{ + Name: "bar4", + Age: 24, + }, + Time: someTime, + TimePtr: nil, + }, + { + Name: "baz", + Age: 30, + Roles: nil, + Sub1: tableTest2{ + Name: "baz1", + Age: 31, + }, + Sub2: nil, + Sub3: tableTest3{ + Sub: tableTest2{ + Name: "baz3", + Age: 33, + }, + }, + Sub4: tableTest2{ + Name: "baz4", + Age: 34, + }, + Time: someTime, + TimePtr: nil, + }, + } + + // This test tests skipping fields without table tags, recursion, pointer + // dereferencing, and nil pointer skipping. + t.Run("OK", func(t *testing.T) { + t.Parallel() + + expected := ` +NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR +foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } Aug 2 15:49:10 Aug 2 15:49:10 +bar 20 [a] bar1 21 bar3 23 {bar4 24 } Aug 2 15:49:10 +baz 30 [] baz1 31 baz3 33 {baz4 34 } Aug 2 15:49:10 + ` + + // Test with non-pointer values. + out, err := cliui.DisplayTable(in, "", nil) + log.Println("rendered table:\n" + out) + require.NoError(t, err) + compareTables(t, expected, out) + + // Test with pointer values. + inPtr := make([]*tableTest1, len(in)) + for i, v := range in { + v := v + inPtr[i] = &v + } + out, err = cliui.DisplayTable(inPtr, "", nil) + require.NoError(t, err) + compareTables(t, expected, out) + }) + + t.Run("Sort", func(t *testing.T) { + t.Parallel() + + expected := ` +NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR +bar 20 [a] bar1 21 bar3 23 {bar4 24 } Aug 2 15:49:10 +baz 30 [] baz1 31 baz3 33 {baz4 34 } Aug 2 15:49:10 +foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } Aug 2 15:49:10 Aug 2 15:49:10 + ` + + out, err := cliui.DisplayTable(in, "name", nil) + log.Println("rendered table:\n" + out) + require.NoError(t, err) + compareTables(t, expected, out) + }) + + t.Run("Filter", func(t *testing.T) { + t.Parallel() + + expected := ` +NAME SUB 1 NAME SUB 3 INNER NAME TIME +foo foo1 foo3 Aug 2 15:49:10 +bar bar1 bar3 Aug 2 15:49:10 +baz baz1 baz3 Aug 2 15:49:10 + ` + + out, err := cliui.DisplayTable(in, "", []string{"name", "sub_1_name", "sub_3 inner name", "time"}) + log.Println("rendered table:\n" + out) + require.NoError(t, err) + compareTables(t, expected, out) + }) + + // This test ensures that safeties against invalid use of `table` tags + // causes errors (even without data). + t.Run("Errors", func(t *testing.T) { + t.Parallel() + + t.Run("NotSlice", func(t *testing.T) { + t.Parallel() + + var in string + _, err := cliui.DisplayTable(in, "", nil) + require.Error(t, err) + }) + + t.Run("BadSortColumn", func(t *testing.T) { + t.Parallel() + + _, err := cliui.DisplayTable(in, "bad_column_does_not_exist", nil) + require.Error(t, err) + }) + + t.Run("BadFilterColumns", func(t *testing.T) { + t.Parallel() + + _, err := cliui.DisplayTable(in, "", []string{"name", "bad_column_does_not_exist"}) + require.Error(t, err) + }) + + t.Run("Interfaces", func(t *testing.T) { + t.Parallel() + + t.Run("WithoutData", func(t *testing.T) { + t.Parallel() + + var in []any + _, err := cliui.DisplayTable(in, "", nil) + require.Error(t, err) + }) + + t.Run("WithData", func(t *testing.T) { + t.Parallel() + + in := []any{tableTest1{}} + _, err := cliui.DisplayTable(in, "", nil) + require.Error(t, err) + }) + }) + + t.Run("NotStruct", func(t *testing.T) { + t.Parallel() + + t.Run("WithoutData", func(t *testing.T) { + t.Parallel() + + var in []string + _, err := cliui.DisplayTable(in, "", nil) + require.Error(t, err) + }) + + t.Run("WithData", func(t *testing.T) { + t.Parallel() + + in := []string{"foo", "bar", "baz"} + _, err := cliui.DisplayTable(in, "", nil) + require.Error(t, err) + }) + }) + + t.Run("NoTableTags", func(t *testing.T) { + t.Parallel() + + type noTableTagsTest struct { + Field string `json:"field"` + } + + t.Run("WithoutData", func(t *testing.T) { + t.Parallel() + + var in []noTableTagsTest + _, err := cliui.DisplayTable(in, "", nil) + require.Error(t, err) + }) + + t.Run("WithData", func(t *testing.T) { + t.Parallel() + + in := []noTableTagsTest{{Field: "hi"}} + _, err := cliui.DisplayTable(in, "", nil) + require.Error(t, err) + }) + }) + + t.Run("InvalidTag/NoName", func(t *testing.T) { + t.Parallel() + + type noNameTest struct { + Field string `table:""` + } + + t.Run("WithoutData", func(t *testing.T) { + t.Parallel() + + var in []noNameTest + _, err := cliui.DisplayTable(in, "", nil) + require.Error(t, err) + }) + + t.Run("WithData", func(t *testing.T) { + t.Parallel() + + in := []noNameTest{{Field: "test"}} + _, err := cliui.DisplayTable(in, "", nil) + require.Error(t, err) + }) + }) + + t.Run("InvalidTag/BadSyntax", func(t *testing.T) { + t.Parallel() + + type invalidSyntaxTest struct { + Field string `table:"asda,asdjada"` + } + + t.Run("WithoutData", func(t *testing.T) { + t.Parallel() + + var in []invalidSyntaxTest + _, err := cliui.DisplayTable(in, "", nil) + require.Error(t, err) + }) + + t.Run("WithData", func(t *testing.T) { + t.Parallel() + + in := []invalidSyntaxTest{{Field: "test"}} + _, err := cliui.DisplayTable(in, "", nil) + require.Error(t, err) + }) + }) + }) +} + +// compareTables normalizes the incoming table lines +func compareTables(t *testing.T, expected, out string) { + t.Helper() + + expectedLines := strings.Split(strings.TrimSpace(expected), "\n") + gotLines := strings.Split(strings.TrimSpace(out), "\n") + assert.Equal(t, len(expectedLines), len(gotLines), "expected line count does not match generated line count") + + // Map the expected and got lines to normalize them. + expectedNormalized := make([]string, len(expectedLines)) + gotNormalized := make([]string, len(gotLines)) + normalizeLine := func(s string) string { + return strings.Join(strings.Fields(strings.TrimSpace(s)), " ") + } + for i, s := range expectedLines { + expectedNormalized[i] = normalizeLine(s) + } + for i, s := range gotLines { + gotNormalized[i] = normalizeLine(s) + } + + require.Equal(t, expectedNormalized, gotNormalized, "expected lines to match generated lines") +} diff --git a/cli/userlist.go b/cli/userlist.go index dcd3ecb7d3..1d4a86daa4 100644 --- a/cli/userlist.go +++ b/cli/userlist.go @@ -38,7 +38,10 @@ func userList() *cobra.Command { out := "" switch outputFormat { case "table", "": - out = displayUsers(columns, users...) + out, err = cliui.DisplayTable(users, "Username", columns) + if err != nil { + return xerrors.Errorf("render table: %w", err) + } case "json": outBytes, err := json.Marshal(users) if err != nil { diff --git a/cli/users.go b/cli/users.go index 009c4216dc..daadc2007f 100644 --- a/cli/users.go +++ b/cli/users.go @@ -1,12 +1,8 @@ package cli import ( - "time" - - "github.com/jedib0t/go-pretty/v6/table" "github.com/spf13/cobra" - "github.com/coder/coder/cli/cliui" "github.com/coder/coder/codersdk" ) @@ -25,26 +21,3 @@ func users() *cobra.Command { ) return cmd } - -// displayUsers will return a table displaying all users passed in. -// filterColumns must be a subset of the user fields and will determine which -// columns to display -func displayUsers(filterColumns []string, users ...codersdk.User) string { - tableWriter := cliui.Table() - header := table.Row{"id", "username", "email", "created at", "status"} - tableWriter.AppendHeader(header) - tableWriter.SetColumnConfigs(cliui.FilterTableColumns(header, filterColumns)) - tableWriter.SortBy([]table.SortBy{{ - Name: "username", - }}) - for _, user := range users { - tableWriter.AppendRow(table.Row{ - user.ID.String(), - user.Username, - user.Email, - user.CreatedAt.Format(time.Stamp), - user.Status, - }) - } - return tableWriter.Render() -} diff --git a/cli/userstatus.go b/cli/userstatus.go index 2eed093607..577be8e91f 100644 --- a/cli/userstatus.go +++ b/cli/userstatus.go @@ -59,7 +59,11 @@ func createUserStatusCommand(sdkStatus codersdk.UserStatus) *cobra.Command { } // Display the user - _, _ = fmt.Fprintln(cmd.OutOrStdout(), displayUsers(columns, user)) + table, err := cliui.DisplayTable([]codersdk.User{user}, "", columns) + if err != nil { + return xerrors.Errorf("render user table: %w", err) + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), table) // User status is already set to this if user.Status == sdkStatus { diff --git a/codersdk/users.go b/codersdk/users.go index 17252c2040..fce37545a0 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -42,11 +42,11 @@ type UsersRequest struct { // User represents a user in Coder. type User struct { - ID uuid.UUID `json:"id" validate:"required"` - Email string `json:"email" validate:"required"` - CreatedAt time.Time `json:"created_at" validate:"required"` - Username string `json:"username" validate:"required"` - Status UserStatus `json:"status"` + ID uuid.UUID `json:"id" validate:"required" table:"id"` + Username string `json:"username" validate:"required" table:"username"` + Email string `json:"email" validate:"required" table:"email"` + CreatedAt time.Time `json:"created_at" validate:"required" table:"created at"` + Status UserStatus `json:"status" table:"status"` OrganizationIDs []uuid.UUID `json:"organization_ids"` Roles []Role `json:"roles"` } diff --git a/go.mod b/go.mod index 76fbf6d64c..4b8c596c4b 100644 --- a/go.mod +++ b/go.mod @@ -64,6 +64,7 @@ require ( github.com/elastic/go-sysinfo v1.8.1 github.com/fatih/color v1.13.0 github.com/fatih/structs v1.1.0 + github.com/fatih/structtag v1.2.0 github.com/fergusstrange/embedded-postgres v1.16.0 github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa github.com/gen2brain/beeep v0.0.0-20220402123239-6a3042f4b71a @@ -162,7 +163,7 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/bep/godartsass v0.14.0 // indirect github.com/bep/golibsass v1.1.0 // indirect - github.com/cenkalti/backoff/v4 v4.1.3 // indirect + github.com/cenkalti/backoff/v4 v4.1.3 github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/charmbracelet/bubbles v0.10.3 // indirect github.com/charmbracelet/bubbletea v0.20.0 // indirect diff --git a/go.sum b/go.sum index fcc13af995..f43ade42c0 100644 --- a/go.sum +++ b/go.sum @@ -589,6 +589,7 @@ github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.2/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index df1b51c18e..6be2515c4f 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -366,9 +366,9 @@ export interface UploadResponse { // From codersdk/users.go export interface User { readonly id: string + readonly username: string readonly email: string readonly created_at: string - readonly username: string readonly status: UserStatus readonly organization_ids: string[] readonly roles: Role[]