Skip to content
Open
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
74 changes: 74 additions & 0 deletions codegen/jennies/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 16 additions & 2 deletions codegen/templates/schema.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand Down Expand Up @@ -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}},
Expand Down
14 changes: 14 additions & 0 deletions codegen/templates/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ type SchemaMetadata struct {
Plural string
Scope string
SelectableFields []SchemaMetadataSelectableField
TableColumns []SchemaMetadataTableColumn
FuncPrefix string
}

Expand All @@ -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] == '.' {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
12 changes: 12 additions & 0 deletions examples/apiserver/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
10 changes: 10 additions & 0 deletions examples/apiserver/apis/example/v1alpha1/testkind_schema_gen.go

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

1 change: 1 addition & 0 deletions examples/apiserver/kinds/manifest.cue
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ v1alpha1: {
routes: namespaced: {
"/foobar": {
"GET": {
name: "getFoobar"
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Drive-by to fix codegen, as name is now required for all routes.

response: {
foo: string
shared: #SharedType
Expand Down
7 changes: 7 additions & 0 deletions examples/apiserver/kinds/testkind.cue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
17 changes: 17 additions & 0 deletions examples/apiserver/kubeconfig
Original file line number Diff line number Diff line change
@@ -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
9 changes: 8 additions & 1 deletion k8s/apiserver/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)}
Expand Down
98 changes: 98 additions & 0 deletions k8s/apiserver/tableconvertor.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading