From 231fa0effeb15806eb99007587f9648416431832 Mon Sep 17 00:00:00 2001 From: Austin Pond Date: Wed, 18 Mar 2026 15:45:19 -0400 Subject: [PATCH] feat: Add TableColumns to resource.Schema with codegen support Extends resource.Schema with TableColumns() method providing type-safe column accessors for kubectl table display, eliminating the need for reflection-based JSONPath resolution. Changes: - Added TableColumn type to resource/schema.go with ValueFunc for direct field extraction - Extended Schema interface with TableColumns() method, implemented on SimpleSchema - Added WithTableColumns() option for SimpleSchema configuration - Implemented getTableColumns() in codegen/jennies/schema.go to validate and generate table column metadata from CUE AdditionalPrinterColumns - Updated schema.tmpl to generate WithTableColumns() calls with type-safe closures - Rewrote k8s/apiserver/tableconvertor.go to use Schema TableColumns exclusively, removed all reflection code - Simplified storage.go to read columns from kind.TableColumns() directly - Added example AdditionalPrinterColumns to apiserver example - Added comprehensive tests and kubeconfig for local apiserver testing This follows the same pattern as SelectableFields, providing codegen-generated type-safe accessors instead of reflection. Co-Authored-By: Claude Haiku 4.5 --- codegen/jennies/schema.go | 74 ++++++ codegen/templates/schema.tmpl | 18 +- codegen/templates/templates.go | 14 + .../testapp/v2/testkind_schema_gen.go.txt | 14 +- examples/apiserver/README.md | 12 + .../example/v1alpha1/testkind_schema_gen.go | 10 + examples/apiserver/kinds/manifest.cue | 1 + examples/apiserver/kinds/testkind.cue | 7 + examples/apiserver/kubeconfig | 17 ++ k8s/apiserver/storage.go | 9 +- k8s/apiserver/tableconvertor.go | 98 +++++++ k8s/apiserver/tableconvertor_test.go | 245 ++++++++++++++++++ resource/schema.go | 31 +++ 13 files changed, 546 insertions(+), 4 deletions(-) create mode 100644 examples/apiserver/kubeconfig create mode 100644 k8s/apiserver/tableconvertor.go create mode 100644 k8s/apiserver/tableconvertor_test.go diff --git a/codegen/jennies/schema.go b/codegen/jennies/schema.go index 0e7b60988..b47770800 100644 --- a/codegen/jennies/schema.go +++ b/codegen/jennies/schema.go @@ -44,6 +44,10 @@ func (s *SchemaGenerator) Generate(appManifest codegen.AppManifest) (codejen.Fil if err != nil { return nil, err } + tc, err := s.getTableColumns(&kind) + if err != nil { + return nil, err + } b := bytes.Buffer{} err = templates.WriteSchema(templates.SchemaMetadata{ Package: ToPackageName(version.Name()), @@ -53,6 +57,7 @@ func (s *SchemaGenerator) Generate(appManifest codegen.AppManifest) (codejen.Fil Plural: kind.PluralMachineName, Scope: kind.Scope, SelectableFields: sf, + TableColumns: tc, FuncPrefix: prefix, }, &b) if err != nil { @@ -139,6 +144,75 @@ func getCUEValueKindString(v cue.Value) (string, error) { return "", fmt.Errorf("unsupported type %s, supported types are string, bool, int and time.Time", v.Kind()) } +func (*SchemaGenerator) getTableColumns(kind *codegen.VersionedKind) ([]templates.SchemaMetadataTableColumn, error) { + columns := make([]templates.SchemaMetadataTableColumn, 0) + if len(kind.AdditionalPrinterColumns) == 0 { + return columns, nil + } + for _, col := range kind.AdditionalPrinterColumns { + fieldPath := col.JSONPath + if len(fieldPath) > 1 && fieldPath[0] == '.' { + fieldPath = fieldPath[1:] + } + parts := strings.Split(fieldPath, ".") + if len(parts) <= 1 { + return nil, fmt.Errorf("invalid table column JSONPath: %s", col.JSONPath) + } + field := parts[len(parts)-1] + parentParts := parts[:len(parts)-1] + path := make([]cue.Selector, 0) + for _, p := range parentParts { + path = append(path, cue.Str(p)) + } + val := kind.Schema.LookupPath(cue.MakePath(path...).Optional()) + if val.Err() != nil { + return nil, fmt.Errorf("invalid table column JSONPath %s: parent path not found", col.JSONPath) + } + + var lookup cue.Value + var optional bool + cuePath := cue.MakePath(cue.Str(field)) + if lookup = val.LookupPath(cuePath); lookup.Exists() { + optional = false + } else if lookup = val.LookupPath(cuePath.Optional()); lookup.Exists() { + optional = true + } else { + return nil, fmt.Errorf("invalid table column JSONPath: %s", col.JSONPath) + } + + goType, err := getCUEValueKindString(lookup) + if err != nil { + return nil, fmt.Errorf("invalid table column '%s' (%s): %w", col.Name, col.JSONPath, err) + } + + var colFormat string + if col.Format != nil { + colFormat = *col.Format + } + var colDescription string + if col.Description != nil { + colDescription = *col.Description + } + var priority int32 + if col.Priority != nil { + priority = *col.Priority + } + + columns = append(columns, templates.SchemaMetadataTableColumn{ + Name: col.Name, + Type: col.Type, + Format: colFormat, + Description: colDescription, + Priority: priority, + JSONPath: col.JSONPath, + GoValueType: goType, + Optional: optional, + OptionalFieldsInPath: getOptionalFieldsInPath(kind.Schema, fieldPath), + }) + } + return columns, nil +} + // getOptionalFieldsInPath returns a list of all optional fields found along the provided fieldPath. // This is used to generate nil checks on optional fields ensuring safe access to the selectable field. func getOptionalFieldsInPath(v cue.Value, fieldPath string) []string { diff --git a/codegen/templates/schema.tmpl b/codegen/templates/schema.tmpl index c1db528a0..1cdd0e787 100644 --- a/codegen/templates/schema.tmpl +++ b/codegen/templates/schema.tmpl @@ -3,10 +3,11 @@ // package {{.Package}} -{{$sfl := len .SelectableFields}} +{{$sfl := len .SelectableFields}}{{$tcl := len .TableColumns}} {{$needsFmt := false}}{{range .SelectableFields}}{{if ne .Type "string"}}{{$needsFmt = true}}{{end}}{{end}} +{{$needsErrors := false}}{{if gt $sfl 0}}{{$needsErrors = true}}{{end}}{{if gt $tcl 0}}{{$needsErrors = true}}{{end}} import ( - {{if gt $sfl 0}} + {{if $needsErrors}} "errors"{{if $needsFmt}} "fmt"{{end}} {{end}} @@ -36,6 +37,19 @@ var ( return fmt.Sprintf("%v", cast.{{$root.ToObjectPath .Field}}), nil{{ end }}{{ end }} }, }, + {{ end }} }){{ end }}{{if gt $tcl 0}}, resource.WithTableColumns([]resource.TableColumn{ {{ range .TableColumns }}{ + Name: "{{.Name}}", Type: "{{.Type}}"{{if .Format}}, Format: "{{.Format}}"{{end}}{{if .Description}}, Description: "{{.Description}}"{{end}}{{if .Priority}}, Priority: {{.Priority}}{{end}}, JSONPath: "{{.JSONPath}}", + ValueFunc: func(o resource.Object) (any, error) { + cast, ok := o.(*{{$root.Kind}}) + if !ok { + return nil, errors.New("provided object must be of type *{{$root.Kind}}") + }{{ range .OptionalFieldsInPath }} + if cast.{{$root.ToObjectPath .}} == nil { + return nil, nil + }{{ end }} + {{ if .Optional }}return *cast.{{$root.ToObjectPath .JSONPath}}, nil{{ else }}return cast.{{$root.ToObjectPath .JSONPath}}, nil{{ end }} + }, + }, {{ end }} }){{ end }}) kind{{.Kind}} = resource.Kind{ Schema: schema{{.Kind}}, diff --git a/codegen/templates/templates.go b/codegen/templates/templates.go index 87f2f4898..a3a79759d 100644 --- a/codegen/templates/templates.go +++ b/codegen/templates/templates.go @@ -148,6 +148,7 @@ type SchemaMetadata struct { Plural string Scope string SelectableFields []SchemaMetadataSelectableField + TableColumns []SchemaMetadataTableColumn FuncPrefix string } @@ -158,6 +159,19 @@ type SchemaMetadataSelectableField struct { OptionalFieldsInPath []string } +// SchemaMetadataTableColumn contains metadata for generating a resource.TableColumn with a ValueFunc. +type SchemaMetadataTableColumn struct { + Name string + Type string // OpenAPI type: "string", "integer", "number", "boolean", "date" + Format string + Description string + Priority int32 + JSONPath string // e.g., ".spec.stringField" + GoValueType string // Go type of the field: "string", "int", "bool", "time" + Optional bool + OptionalFieldsInPath []string +} + func (SchemaMetadata) ToObjectPath(s string) string { parts := make([]string, 0) if len(s) > 0 && s[0] == '.' { diff --git a/codegen/testing/golden_generated/go/groupbygroup/testapp/v2/testkind_schema_gen.go.txt b/codegen/testing/golden_generated/go/groupbygroup/testapp/v2/testkind_schema_gen.go.txt index b576272f2..db71d636d 100644 --- a/codegen/testing/golden_generated/go/groupbygroup/testapp/v2/testkind_schema_gen.go.txt +++ b/codegen/testing/golden_generated/go/groupbygroup/testapp/v2/testkind_schema_gen.go.txt @@ -5,13 +5,25 @@ package v2 import ( + "errors" + "github.com/grafana/grafana-app-sdk/resource" ) // schema is unexported to prevent accidental overwrites var ( schemaTestKind = resource.NewSimpleSchema("testapp.ext.grafana.com", "v2", NewTestKind(), &TestKindList{}, resource.WithKind("TestKind"), - resource.WithPlural("testkinds"), resource.WithScope(resource.NamespacedScope)) + resource.WithPlural("testkinds"), resource.WithScope(resource.NamespacedScope), resource.WithTableColumns([]resource.TableColumn{{ + Name: "STRING FIELD", Type: "string", JSONPath: ".spec.stringField", + ValueFunc: func(o resource.Object) (any, error) { + cast, ok := o.(*TestKind) + if !ok { + return nil, errors.New("provided object must be of type *TestKind") + } + return cast.Spec.StringField, nil + }, + }, + })) kindTestKind = resource.Kind{ Schema: schemaTestKind, Codecs: map[resource.KindEncoding]resource.Codec{ diff --git a/examples/apiserver/README.md b/examples/apiserver/README.md index ca8f14fb8..a3b5a37e8 100644 --- a/examples/apiserver/README.md +++ b/examples/apiserver/README.md @@ -88,4 +88,16 @@ curl -k https://127.0.0.1:6443/apis/example.ext.grafana.com/v1alpha1/foobar Get OpenAPI doc: ```shell curl -k https://127.0.0.1:6443/openapi/v2 +``` + +You can also use the provided `kubeconfig` file to make `kubectl` requests: +```shell +kubectl --kubeconfig=kubeconfig get testkinds +``` +Example response: +``` +NAME TEST FIELD +foo foo +foo2 +foo3 ``` \ No newline at end of file diff --git a/examples/apiserver/apis/example/v1alpha1/testkind_schema_gen.go b/examples/apiserver/apis/example/v1alpha1/testkind_schema_gen.go index bbaf08113..33820d07d 100644 --- a/examples/apiserver/apis/example/v1alpha1/testkind_schema_gen.go +++ b/examples/apiserver/apis/example/v1alpha1/testkind_schema_gen.go @@ -24,6 +24,16 @@ var ( return cast.Spec.TestField, nil }, }, + }), resource.WithTableColumns([]resource.TableColumn{{ + Name: "Test Field", Type: "string", JSONPath: ".spec.testField", + ValueFunc: func(o resource.Object) (any, error) { + cast, ok := o.(*TestKind) + if !ok { + return nil, errors.New("provided object must be of type *TestKind") + } + return cast.Spec.TestField, nil + }, + }, })) kindTestKind = resource.Kind{ Schema: schemaTestKind, diff --git a/examples/apiserver/kinds/manifest.cue b/examples/apiserver/kinds/manifest.cue index 74a73dbd2..d49d907ec 100644 --- a/examples/apiserver/kinds/manifest.cue +++ b/examples/apiserver/kinds/manifest.cue @@ -86,6 +86,7 @@ v1alpha1: { routes: namespaced: { "/foobar": { "GET": { + name: "getFoobar" response: { foo: string shared: #SharedType diff --git a/examples/apiserver/kinds/testkind.cue b/examples/apiserver/kinds/testkind.cue index 00ac4ecae..387189391 100644 --- a/examples/apiserver/kinds/testkind.cue +++ b/examples/apiserver/kinds/testkind.cue @@ -18,6 +18,13 @@ testKindv0alpha1: testKind & { testKindv1alpha1: testKind & { validation: operations: ["CREATE", "UPDATE"] selectableFields: [".spec.testField"] + additionalPrinterColumns: [ + { + jsonPath: ".spec.testField" + name: "Test Field" + type: "string" + }, + ] schema: { #Foo: { foo: string | *"foo" diff --git a/examples/apiserver/kubeconfig b/examples/apiserver/kubeconfig new file mode 100644 index 000000000..8a12289e6 --- /dev/null +++ b/examples/apiserver/kubeconfig @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Config +clusters: + - cluster: + server: https://localhost:6443 + insecure-skip-tls-verify: true + name: local-apiserver +users: + - name: anonymous + user: + token: anonymous +contexts: + - context: + cluster: local-apiserver + user: anonymous + name: local +current-context: local diff --git a/k8s/apiserver/storage.go b/k8s/apiserver/storage.go index 893540c5e..e140236a6 100644 --- a/k8s/apiserver/storage.go +++ b/k8s/apiserver/storage.go @@ -23,6 +23,13 @@ import ( func newGenericStoreForKind(scheme *runtime.Scheme, kind resource.Kind, optsGetter generic.RESTOptionsGetter) (*genericregistry.Store, error) { strategy := newStrategy(scheme, kind) + var tableConvertor rest.TableConvertor + if cols := kind.TableColumns(); len(cols) > 0 { + tableConvertor = newTableConvertor(cols) + } else { + tableConvertor = rest.NewDefaultTableConvertor(kind.GroupVersionResource().GroupResource()) + } + store := &genericregistry.Store{ NewFunc: func() runtime.Object { return kind.ZeroValue() @@ -40,7 +47,7 @@ func newGenericStoreForKind(scheme *runtime.Scheme, kind resource.Kind, optsGett CreateStrategy: strategy, UpdateStrategy: strategy, DeleteStrategy: strategy, - TableConvertor: rest.NewDefaultTableConvertor(kind.GroupVersionResource().GroupResource()), + TableConvertor: tableConvertor, } options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: getAttrsFunc(kind)} diff --git a/k8s/apiserver/tableconvertor.go b/k8s/apiserver/tableconvertor.go new file mode 100644 index 000000000..0335d7467 --- /dev/null +++ b/k8s/apiserver/tableconvertor.go @@ -0,0 +1,98 @@ +package apiserver + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/meta" + metatable "k8s.io/apimachinery/pkg/api/meta/table" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + "github.com/grafana/grafana-app-sdk/resource" +) + +var swaggerMetadataDescriptions = metav1.ObjectMeta{}.SwaggerDoc() + +type columnDefinition struct { + valueFunc func(resource.Object) (any, error) + header metav1.TableColumnDefinition +} + +type additionalColumnsTableConvertor struct { + headers []metav1.TableColumnDefinition + columns []columnDefinition +} + +// newTableConvertor creates a rest.TableConvertor from Schema-provided TableColumns. +func newTableConvertor(columns []resource.TableColumn) rest.TableConvertor { + c := &additionalColumnsTableConvertor{ + headers: []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name", Description: swaggerMetadataDescriptions["name"]}, + }, + } + + for _, col := range columns { + desc := fmt.Sprintf("Custom resource definition column (in JSONPath format): %s", col.JSONPath) + if col.Description != "" { + desc = col.Description + } + + c.columns = append(c.columns, columnDefinition{ + valueFunc: col.ValueFunc, + header: metav1.TableColumnDefinition{ + Name: col.Name, + Type: col.Type, + Format: col.Format, + Description: desc, + Priority: col.Priority, + }, + }) + c.headers = append(c.headers, c.columns[len(c.columns)-1].header) + } + + return c +} + +func (c *additionalColumnsTableConvertor) ConvertToTable(_ context.Context, obj runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + table := &metav1.Table{} + opt, ok := tableOptions.(*metav1.TableOptions) + noHeaders := ok && opt != nil && opt.NoHeaders + if !noHeaders { + table.ColumnDefinitions = c.headers + } + + if m, err := meta.ListAccessor(obj); err == nil { + table.ResourceVersion = m.GetResourceVersion() + table.Continue = m.GetContinue() + table.RemainingItemCount = m.GetRemainingItemCount() + } else { + if m, err := meta.CommonAccessor(obj); err == nil { + table.ResourceVersion = m.GetResourceVersion() + } + } + + var tableErr error + table.Rows, tableErr = metatable.MetaToTableRow(obj, func(obj runtime.Object, _ metav1.Object, name, _ string) ([]any, error) { + cells := make([]any, 1, 1+len(c.columns)) + cells[0] = name + resourceObj, ok := obj.(resource.Object) + if !ok { + for range c.columns { + cells = append(cells, nil) + } + return cells, nil + } + for _, col := range c.columns { + value, err := col.valueFunc(resourceObj) + if err != nil || value == nil { + cells = append(cells, nil) + continue + } + cells = append(cells, value) + } + return cells, nil + }) + return table, tableErr +} diff --git a/k8s/apiserver/tableconvertor_test.go b/k8s/apiserver/tableconvertor_test.go new file mode 100644 index 000000000..d038900e1 --- /dev/null +++ b/k8s/apiserver/tableconvertor_test.go @@ -0,0 +1,245 @@ +package apiserver + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/grafana/grafana-app-sdk/resource" +) + +type testSpec struct { + StringField string `json:"stringField"` + IntField int `json:"intField"` +} + +func newTestObj(name, rv string, spec testSpec) *resource.TypedSpecObject[testSpec] { + obj := &resource.TypedSpecObject[testSpec]{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + ResourceVersion: rv, + }, + Spec: spec, + } + return obj +} + +// testSpecList implements runtime.Object for a list of TypedSpecObject[testSpec]. +type testSpecList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + Items []resource.TypedSpecObject[testSpec] `json:"items"` +} + +func (t *testSpecList) DeepCopyObject() runtime.Object { + clone := *t + clone.Items = make([]resource.TypedSpecObject[testSpec], len(t.Items)) + copy(clone.Items, t.Items) + return &clone +} + +func TestNewTableConvertor(t *testing.T) { + t.Run("empty columns", func(t *testing.T) { + tc := newTableConvertor(nil) + require.NotNil(t, tc) + + conv := tc.(*additionalColumnsTableConvertor) + assert.Len(t, conv.headers, 1) + assert.Equal(t, "Name", conv.headers[0].Name) + }) + + t.Run("valid columns", func(t *testing.T) { + tc := newTableConvertor([]resource.TableColumn{ + { + Name: "String Field", Type: "string", JSONPath: ".spec.stringField", + ValueFunc: func(o resource.Object) (any, error) { + return o.(*resource.TypedSpecObject[testSpec]).Spec.StringField, nil + }, + }, + { + Name: "Count", Type: "integer", JSONPath: ".spec.intField", + Priority: 1, Description: "The count", + ValueFunc: func(o resource.Object) (any, error) { + return o.(*resource.TypedSpecObject[testSpec]).Spec.IntField, nil + }, + }, + }) + require.NotNil(t, tc) + + conv := tc.(*additionalColumnsTableConvertor) + assert.Len(t, conv.headers, 3) + assert.Equal(t, "Name", conv.headers[0].Name) + assert.Equal(t, "String Field", conv.headers[1].Name) + assert.Equal(t, "string", conv.headers[1].Type) + assert.Equal(t, "Count", conv.headers[2].Name) + assert.Equal(t, "integer", conv.headers[2].Type) + assert.Equal(t, int32(1), conv.headers[2].Priority) + assert.Equal(t, "The count", conv.headers[2].Description) + }) +} + +func TestConvertToTable_SingleObject(t *testing.T) { + tc := newTableConvertor([]resource.TableColumn{ + { + Name: "String Field", Type: "string", JSONPath: ".spec.stringField", + ValueFunc: func(o resource.Object) (any, error) { + return o.(*resource.TypedSpecObject[testSpec]).Spec.StringField, nil + }, + }, + { + Name: "Int Field", Type: "integer", JSONPath: ".spec.intField", + ValueFunc: func(o resource.Object) (any, error) { + return o.(*resource.TypedSpecObject[testSpec]).Spec.IntField, nil + }, + }, + }) + + obj := newTestObj("test-obj", "123", testSpec{StringField: "hello", IntField: 42}) + + table, err := tc.ConvertToTable(context.Background(), obj, &metav1.TableOptions{}) + require.NoError(t, err) + require.NotNil(t, table) + + assert.Len(t, table.ColumnDefinitions, 3) + assert.Equal(t, "Name", table.ColumnDefinitions[0].Name) + assert.Equal(t, "String Field", table.ColumnDefinitions[1].Name) + assert.Equal(t, "Int Field", table.ColumnDefinitions[2].Name) + + require.Len(t, table.Rows, 1) + cells := table.Rows[0].Cells + assert.Equal(t, "test-obj", cells[0]) + assert.Equal(t, "hello", cells[1]) + assert.Equal(t, 42, cells[2]) +} + +func TestConvertToTable_NoHeaders(t *testing.T) { + tc := newTableConvertor([]resource.TableColumn{ + { + Name: "Field", Type: "string", JSONPath: ".spec.stringField", + ValueFunc: func(o resource.Object) (any, error) { + return o.(*resource.TypedSpecObject[testSpec]).Spec.StringField, nil + }, + }, + }) + + obj := newTestObj("test", "", testSpec{StringField: "val"}) + + table, err := tc.ConvertToTable(context.Background(), obj, &metav1.TableOptions{NoHeaders: true}) + require.NoError(t, err) + assert.Empty(t, table.ColumnDefinitions) + require.Len(t, table.Rows, 1) + assert.Equal(t, "test", table.Rows[0].Cells[0]) +} + +func TestConvertToTable_ValueFuncError(t *testing.T) { + tc := newTableConvertor([]resource.TableColumn{ + { + Name: "Failing", Type: "string", JSONPath: ".spec.field", + ValueFunc: func(_ resource.Object) (any, error) { + return nil, errors.New("value extraction failed") + }, + }, + }) + + obj := newTestObj("test", "", testSpec{}) + + table, err := tc.ConvertToTable(context.Background(), obj, &metav1.TableOptions{}) + require.NoError(t, err) + require.Len(t, table.Rows, 1) + assert.Equal(t, "test", table.Rows[0].Cells[0]) + assert.Nil(t, table.Rows[0].Cells[1]) +} + +func TestConvertToTable_NilValue(t *testing.T) { + tc := newTableConvertor([]resource.TableColumn{ + { + Name: "Optional", Type: "string", JSONPath: ".spec.optional", + ValueFunc: func(_ resource.Object) (any, error) { + return nil, nil + }, + }, + }) + + obj := newTestObj("test", "", testSpec{}) + + table, err := tc.ConvertToTable(context.Background(), obj, &metav1.TableOptions{}) + require.NoError(t, err) + require.Len(t, table.Rows, 1) + assert.Equal(t, "test", table.Rows[0].Cells[0]) + assert.Nil(t, table.Rows[0].Cells[1]) +} + +func TestConvertToTable_ResourceVersionPropagation(t *testing.T) { + tc := newTableConvertor(nil) + + obj := newTestObj("test", "456", testSpec{}) + + table, err := tc.ConvertToTable(context.Background(), obj, &metav1.TableOptions{}) + require.NoError(t, err) + assert.Equal(t, "456", table.ResourceVersion) +} + +func TestConvertToTable_List(t *testing.T) { + tc := newTableConvertor([]resource.TableColumn{ + { + Name: "String Field", Type: "string", JSONPath: ".spec.stringField", + ValueFunc: func(o resource.Object) (any, error) { + return o.(*resource.TypedSpecObject[testSpec]).Spec.StringField, nil + }, + }, + { + Name: "Int Field", Type: "integer", JSONPath: ".spec.intField", + ValueFunc: func(o resource.Object) (any, error) { + return o.(*resource.TypedSpecObject[testSpec]).Spec.IntField, nil + }, + }, + }) + + list := &testSpecList{ + ListMeta: metav1.ListMeta{ + ResourceVersion: "789", + }, + Items: []resource.TypedSpecObject[testSpec]{ + { + ObjectMeta: metav1.ObjectMeta{Name: "obj-1"}, + Spec: testSpec{StringField: "alpha", IntField: 1}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "obj-2"}, + Spec: testSpec{StringField: "beta", IntField: 2}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "obj-3"}, + Spec: testSpec{StringField: "gamma", IntField: 3}, + }, + }, + } + + table, err := tc.ConvertToTable(context.Background(), list, &metav1.TableOptions{}) + require.NoError(t, err) + require.NotNil(t, table) + + assert.Equal(t, "789", table.ResourceVersion) + assert.Len(t, table.ColumnDefinitions, 3) + + require.Len(t, table.Rows, 3) + for i, expected := range []struct { + name string + stringField string + intField int + }{ + {"obj-1", "alpha", 1}, + {"obj-2", "beta", 2}, + {"obj-3", "gamma", 3}, + } { + cells := table.Rows[i].Cells + assert.Equal(t, expected.name, cells[0], "row %d name", i) + assert.Equal(t, expected.stringField, cells[1], "row %d string field", i) + assert.Equal(t, expected.intField, cells[2], "row %d int field", i) + } +} diff --git a/resource/schema.go b/resource/schema.go index 2c732f42d..190d90c55 100644 --- a/resource/schema.go +++ b/resource/schema.go @@ -35,6 +35,9 @@ type Schema interface { Scope() SchemaScope // SelectableFields returns a list of fully-qualified field selectors which can be used for querying SelectableFields() []SelectableField + // TableColumns returns a list of additional table columns for display in table views (e.g. kubectl get). + // Each column includes a ValueFunc that extracts the column value directly from a typed Object. + TableColumns() []TableColumn } // SelectableField is a struct which represents the FieldSelector string and function to retrieve the value of that @@ -47,6 +50,21 @@ type SelectableField struct { FieldValueFunc func(Object) (string, error) } +// TableColumn represents an additional column for table views (e.g. kubectl get output). +// It includes metadata for the column header and a ValueFunc that extracts the column value +// directly from a typed Object, avoiding reflection. +type TableColumn struct { + Name string + Type string // OpenAPI type: "string", "integer", "number", "boolean", "date" + Format string + Description string + Priority int32 + JSONPath string + // ValueFunc extracts the column value from the provided Object. + // Returns an error if the Object is not of the correct underlying type. + ValueFunc func(Object) (any, error) +} + // SchemaGroup represents a group of Schemas. The interface does not require commonality between Schemas, // but an implementation may require a relationship. // Deprecated: Kinds are now favored over Schemas for usage. @@ -64,6 +82,7 @@ type SimpleSchema struct { plural string scope SchemaScope selectableFields []SelectableField + tableColumns []TableColumn zero Object zeroList ListObject } @@ -111,6 +130,11 @@ func (s *SimpleSchema) SelectableFields() []SelectableField { return s.selectableFields } +// TableColumns returns the list of additional table columns for this schema +func (s *SimpleSchema) TableColumns() []TableColumn { + return s.tableColumns +} + // SimpleSchemaGroup collects schemas with the same group and version // Deprecated: Kinds are now favored over Schemas for usage. Use KindGroup instead. type SimpleSchemaGroup struct { @@ -165,6 +189,13 @@ func WithSelectableFields(selectableFields []SelectableField) func(schema *Simpl } } +// WithTableColumns returns a SimpleSchemaOption that sets the SimpleSchema's TableColumns to the provided columns +func WithTableColumns(columns []TableColumn) func(schema *SimpleSchema) { + return func(s *SimpleSchema) { + s.tableColumns = columns + } +} + // NewSimpleSchema returns a new SimpleSchema func NewSimpleSchema(group, version string, zeroVal Object, zeroList ListObject, opts ...SimpleSchemaOption) *SimpleSchema { s := SimpleSchema{