add plaintext template

Kubernetes-commit: e394364cbd1d5ace0f9cfcf10fa2bc458076d1a4
This commit is contained in:
Alexander Zielenski 2022-11-02 20:35:18 -07:00 committed by Kubernetes Publisher
parent 8ae416a4e0
commit ee7b47da7d
5 changed files with 4155 additions and 1 deletions

View File

@ -35,6 +35,193 @@ func WithBuiltinTemplateFuncs(tmpl *template.Template) *template.Template {
res, err := json.Marshal(obj)
return string(res), err
},
"toPrettyJson": func(obj any) (string, error) {
res, err := json.MarshalIndent(obj, "", " ")
if err != nil {
return "", err
}
return string(res), err
},
"fail": func(message string) (string, error) {
return "", errors.New(message)
},
"wrap": func(l int, s string) (string, error) {
buf := bytes.NewBuffer(nil)
writer := term.NewWordWrapWriter(buf, uint(l))
_, err := writer.Write([]byte(s))
if err != nil {
return "", err
}
return buf.String(), nil
},
"split": func(s string, sep string) []string {
return strings.Split(s, sep)
},
"join": func(sep string, strs ...string) string {
return strings.Join(strs, sep)
},
"include": func(name string, data interface{}) (string, error) {
buf := bytes.NewBuffer(nil)
if err := tmpl.ExecuteTemplate(buf, name, data); err != nil {
return "", err
}
return buf.String(), nil
},
"ternary": func(a, b any, condition bool) any {
if condition {
return a
}
return b
},
"first": func(list any) (any, error) {
if list == nil {
return nil, errors.New("list is empty")
}
tp := reflect.TypeOf(list).Kind()
switch tp {
case reflect.Slice, reflect.Array:
l2 := reflect.ValueOf(list)
l := l2.Len()
if l == 0 {
return nil, errors.New("list is empty")
}
return l2.Index(0).Interface(), nil
default:
return nil, fmt.Errorf("first cannot be used on type: %T", list)
}
},
"last": func(list any) (any, error) {
if list == nil {
return nil, errors.New("list is empty")
}
tp := reflect.TypeOf(list).Kind()
switch tp {
case reflect.Slice, reflect.Array:
l2 := reflect.ValueOf(list)
l := l2.Len()
if l == 0 {
return nil, errors.New("list is empty")
}
return l2.Index(l - 1).Interface(), nil
default:
return nil, fmt.Errorf("last cannot be used on type: %T", list)
}
},
"indent": func(amount int, str string) string {
pad := strings.Repeat(" ", amount)
return pad + strings.Replace(str, "\n", "\n"+pad, -1)
},
"dict": func(keysAndValues ...any) (map[string]any, error) {
if len(keysAndValues)%2 != 0 {
return nil, errors.New("expected even # of arguments")
}
res := map[string]any{}
for i := 0; i+1 < len(keysAndValues); i = i + 2 {
if key, ok := keysAndValues[i].(string); ok {
res[key] = keysAndValues[i+1]
} else {
return nil, fmt.Errorf("key of type %T is not a string as expected", key)
}
}
return res, nil
},
"contains": func(list any, value any) bool {
if list == nil {
return false
}
val := reflect.ValueOf(list)
switch val.Kind() {
case reflect.Array:
case reflect.Slice:
for i := 0; i < val.Len(); i++ {
cur := val.Index(i)
if cur.CanInterface() && reflect.DeepEqual(cur.Interface(), value) {
return true
}
}
return false
default:
return false
}
return false
},
"set": func(dict map[string]any, keysAndValues ...any) (any, error) {
if len(keysAndValues)%2 != 0 {
return nil, errors.New("expected even number of arguments")
}
copyDict := make(map[string]any, len(dict))
for k, v := range dict {
copyDict[k] = v
}
for i := 0; i < len(keysAndValues); i += 2 {
key, ok := keysAndValues[i].(string)
if !ok {
return nil, errors.New("keys must be strings")
}
copyDict[key] = keysAndValues[i+1]
}
return copyDict, nil
},
"add": func(value, operand int) int {
return value + operand
},
"sub": func(value, operand int) int {
return value - operand
},
"mul": func(value, operand int) int {
return value * operand
},
"resolveRef": func(refAny any, document map[string]any) map[string]any {
refString, ok := refAny.(string)
if !ok {
// if passed nil, or wrong type just treat the same
// way as unresolved reference (makes for easier templates)
return nil
}
// Resolve field path encoded by the ref
ref, err := jsonreference.New(refString)
if err != nil {
// Unrecognized ref format.
return nil
}
if !ref.HasFragmentOnly {
// Downloading is not supported. Treat as not found
return nil
}
fragment := ref.GetURL().Fragment
components := strings.Split(fragment, "/")
cur := document
for _, k := range components {
if len(k) == 0 {
// first component is usually empty (#/components/) , etc
continue
}
next, ok := cur[k].(map[string]any)
if !ok {
return nil
}
cur = next
}
return cur
},
})
}

View File

@ -0,0 +1,358 @@
/*
Copyright 2022 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package v2_test
import (
"bytes"
"testing"
"text/template"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/runtime/schema"
v2 "k8s.io/kubectl/pkg/explain/v2"
)
func TestFuncs(t *testing.T) {
testcases := []struct {
Name string
FuncName string
Source string
Context any
Expect string
Error string
}{
{
Name: "err",
FuncName: "fail",
Source: `{{fail .}}`,
Context: "this is a test",
Error: "this is a test",
},
{
Name: "basic",
FuncName: "wrap",
Source: `{{wrap 3 .}}`,
Context: "this is a really good test",
Expect: "this\nis\na\nreally\ngood\ntest",
},
{
Name: "basic",
FuncName: "split",
Source: `{{split . "/"}}`,
Context: "this/is/a/slash/separated/thing",
Expect: "[this is a slash separated thing]",
},
{
Name: "basic",
FuncName: "join",
Source: `{{join "/" "this" "is" "a" "slash" "separated" "thing"}}`,
Expect: "this/is/a/slash/separated/thing",
},
{
Name: "basic",
FuncName: "include",
Source: `{{define "myTemplate"}}{{.}}{{end}}{{$var := include "myTemplate" .}}{{$var}}`,
Context: "hello, world!",
Expect: "hello, world!",
},
{
Name: "nil",
FuncName: "first",
Source: `{{first .}}`,
Context: nil,
Error: "list is empty",
},
{
Name: "empty",
FuncName: "first",
Source: `{{first .}}`,
Context: []string{},
Error: "list is empty",
},
{
Name: "basic",
FuncName: "first",
Source: `{{first .}}`,
Context: []string{"first", "second", "third"},
Expect: "first",
},
{
Name: "wrongtype",
FuncName: "first",
Source: `{{first .}}`,
Context: "test",
Error: "first cannot be used on type: string",
},
{
Name: "nil",
FuncName: "last",
Source: `{{last .}}`,
Context: nil,
Error: "list is empty",
},
{
Name: "empty",
FuncName: "last",
Source: `{{last .}}`,
Context: []string{},
Error: "list is empty",
},
{
Name: "basic",
FuncName: "last",
Source: `{{last .}}`,
Context: []string{"first", "second", "third"},
Expect: "third",
},
{
Name: "wrongtype",
FuncName: "last",
Source: `{{last .}}`,
Context: "test",
Error: "last cannot be used on type: string",
},
{
Name: "none",
FuncName: "indent",
Source: `{{indent 0 .}}`,
Context: "this is a string",
Expect: "this is a string",
},
{
Name: "some",
FuncName: "indent",
Source: `{{indent 2 .}}`,
Context: "this is a string",
Expect: " this is a string",
},
{
Name: "empty",
FuncName: "dict",
Source: `{{dict | toJson}}`,
Expect: "{}",
},
{
Name: "single value",
FuncName: "dict",
Source: `{{dict "key" "value" | toJson}}`,
Expect: `{"key":"value"}`,
},
{
Name: "twoValues",
FuncName: "dict",
Source: `{{dict "key1" "val1" "key2" "val2" | toJson}}`,
Expect: `{"key1":"val1","key2":"val2"}`,
},
{
Name: "oddNumberArgs",
FuncName: "dict",
Source: `{{dict "key1" 1 "key2" | toJson}}`,
Error: "error calling dict: expected even # of arguments",
},
{
Name: "IntegerValue",
FuncName: "dict",
Source: `{{dict "key1" 1 | toJson}}`,
Expect: `{"key1":1}`,
},
{
Name: "MixedValues",
FuncName: "dict",
Source: `{{dict "key1" 1 "key2" "val2" "key3" (dict "key1" "val1") | toJson}}`,
Expect: `{"key1":1,"key2":"val2","key3":{"key1":"val1"}}`,
},
{
Name: "nil",
FuncName: "contains",
Source: `{{contains . "value"}}`,
Context: nil,
Expect: `false`,
},
{
Name: "empty",
FuncName: "contains",
Source: `{{contains . "value"}}`,
Context: []string{},
Expect: `false`,
},
{
Name: "basic",
FuncName: "contains",
Source: `{{contains . "value"}}`,
Context: []string{"value"},
Expect: `true`,
},
{
Name: "struct",
FuncName: "contains",
Source: `{{contains $.haystack $.needle}}`,
Context: map[string]any{
"needle": schema.GroupVersionKind{"testgroup.k8s.io", "v1", "Kind"},
"haystack": []schema.GroupVersionKind{
{"randomgroup.k8s.io", "v1", "OtherKind"},
{"testgroup.k8s.io", "v1", "OtherKind"},
{"testgroup.k8s.io", "v1", "Kind"},
},
},
Expect: `true`,
},
{
Name: "nil",
FuncName: "set",
Source: `{{set nil "key" "value" | toJson}}`,
Expect: `{"key":"value"}`,
},
{
Name: "empty",
FuncName: "set",
Source: `{{set (dict) "key" "value" | toJson}}`,
Expect: `{"key":"value"}`,
},
{
Name: "OddArgs",
FuncName: "set",
Source: `{{set (dict) "key" "value" "key2" | toJson}}`,
Error: `expected even number of arguments`,
},
{
Name: "NonStringKey",
FuncName: "set",
Source: `{{set (dict) 1 "value" | toJson}}`,
Error: `keys must be strings`,
},
{
Name: "NilKey",
FuncName: "set",
Source: `{{set (dict) nil "value" | toJson}}`,
Error: `keys must be strings`,
},
{
Name: "NilValue",
FuncName: "set",
Source: `{{set (dict) "key" nil | toJson}}`,
Expect: `{"key":null}`,
},
{
Name: "OverwriteKey",
FuncName: "set",
Source: `{{set (dict "key1" "val1" "key2" "val2") "key1" nil | toJson}}`,
Expect: `{"key1":null,"key2":"val2"}`,
},
{
Name: "OverwriteKeyWithLefover",
FuncName: "set",
Source: `{{set (dict "key1" "val1" "key2" "val2" "key3" "val3") "key1" nil | toJson}}`,
Expect: `{"key1":null,"key2":"val2","key3":"val3"}`,
},
{
Name: "basic",
FuncName: "add",
Source: `{{add 1 2}}`,
Expect: `3`,
},
{
Name: "basic",
FuncName: "sub",
Source: `{{sub 1 2}}`,
Expect: `-1`,
},
{
Name: "basic",
FuncName: "mul",
Source: `{{mul 2 3}}`,
Expect: `6`,
},
{
Name: "basic",
FuncName: "resolveRef",
Source: `{{resolveRef "#/components/schemas/myTypeName" . | toJson}}`,
Context: map[string]any{
"components": map[string]any{
"schemas": map[string]any{
"myTypeName": map[string]any{
"key": "val",
},
},
},
},
Expect: `{"key":"val"}`,
},
{
Name: "basicNameWithDots",
FuncName: "resolveRef",
Source: `{{resolveRef "#/components/schemas/myTypeName.with.dots" . | toJson}}`,
Context: map[string]any{
"components": map[string]any{
"schemas": map[string]any{
"myTypeName.with.dots": map[string]any{
"key": "val",
},
},
},
},
Expect: `{"key":"val"}`,
},
{
Name: "notFound",
FuncName: "resolveRef",
Source: `{{resolveRef "#/components/schemas/otherTypeName" . | toJson}}`,
Context: map[string]any{
"components": map[string]any{
"schemas": map[string]any{
"myTypeName": map[string]any{
"key": "val",
},
},
},
},
Expect: `null`,
},
{
Name: "url",
FuncName: "resolveRef",
Source: `{{resolveRef "http://swagger.com/swagger.json#/components/schemas/myTypeName" . | toJson}}`,
Context: map[string]any{
"components": map[string]any{
"schemas": map[string]any{
"myTypeName": map[string]any{
"key": "val",
},
},
},
},
Expect: `null`,
},
}
for _, tcase := range testcases {
t.Run(tcase.FuncName+"/"+tcase.Name, func(t *testing.T) {
tmpl, err := v2.WithBuiltinTemplateFuncs(template.New("me")).Parse(tcase.Source)
require.NoError(t, err)
buf := bytes.NewBuffer(nil)
err = tmpl.Execute(buf, tcase.Context)
if len(tcase.Error) > 0 {
require.ErrorContains(t, err, tcase.Error)
} else if output := buf.String(); len(tcase.Expect) > 0 {
require.NoError(t, err)
require.Contains(t, output, tcase.Expect)
}
})
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,250 @@
{{- /* Determine if Path for requested GVR is at /api or /apis based on emptiness of group */ -}}
{{- $prefix := (ternary "/api" (join "" "/apis/" $.GVR.Group) (not $.GVR.Group)) -}}
{{- /* Search both cluster-scoped and namespaced-scoped paths for the GVR to find its GVK */ -}}
{{- /* Looks for path /apis/<group>/<version>/<resource> or /apis/<group>/<version>/<version>/namespaces/{namespace}/<resource> */ -}}
{{- $clusterScopedSearchPath := join "/" $prefix $.GVR.Version $.GVR.Resource -}}
{{- $namespaceScopedSearchPath := join "/" $prefix $.GVR.Version "namespaces" "\\{namespace\\}" $.GVR.Resource -}}
{{- $path := or (index $.Document "paths" $clusterScopedSearchPath) (index $.Document "paths" $clusterScopedSearchPath) -}}
{{- /* Pull GVK from operation */ -}}
{{- with $gvk := and $path (index $path "get" "x-kubernetes-group-version-kind") -}}
{{- if $gvk.group -}}
GROUP: {{ $gvk.group }}{{"\n" -}}
{{- end -}}
KIND: {{ $gvk.kind}}{{"\n" -}}
VERSION: {{ $gvk.version }}{{"\n" -}}
{{- "\n" -}}
{{- with include "schema" (dict "gvk" $gvk "Document" $.Document "FieldPath" $.FieldPath "Recursive" $.Recursive) -}}
{{- . -}}
{{- else -}}
error: GVK {{$gvk}} not found in OpenAPI schema
{{- end -}}
{{- else -}}
error: GVR ({{$.GVR.String}}) not found in OpenAPI schema
{{- end -}}
{{- "\n" -}}
{{- /*
Finds a schema with the given GVK and prints its explain output or empty string
if GVK was not found
Takes dictionary as argument with keys:
gvk: openapiv3 JSON schema
Document: entire doc
FieldPath: field path to follow
Recursive: print recursive
*/ -}}
{{- define "schema" -}}
{{- /* Find definition with this GVK by filtering out the components/schema with the given x-kubernetes-group-version-kind */ -}}
{{- range index $.Document "components" "schemas" -}}
{{- if contains (index . "x-kubernetes-group-version-kind") $.gvk -}}
{{- with include "output" (set $ "schema" .) -}}
{{- . -}}
{{- else -}}
error: field "{{$.FieldPath}}" does not exist
{{- end -}}
{{- break -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- /*
Follows FieldPath until the FieldPath is empty. Then prints field name and field
list of resultant schema. If field path is not found. Prints nothing.
Example output:
FIELD: spec
DESCRIPTION:
<template "description">
FIELDS:
<template "fieldList">
Takes dictionary as argument with keys:
schema: openapiv3 JSON schema
history: map[string]int
Document: entire doc
FieldPath: field path to follow
Recursive: print recursive
*/ -}}
{{- define "output" -}}
{{- $refString := or (index $.schema "$ref") "" -}}
{{- $nextContext := set $ "history" (set $.history $refString 1) -}}
{{- $resolved := or (resolveRef $refString $.Document) $.schema -}}
{{- if not $.FieldPath -}}
DESCRIPTION:{{- "\n" -}}
{{- or (include "description" (dict "schema" $resolved "Document" $.Document)) "<empty>" | wrap 76 | indent 4 -}}{{- "\n" -}}
{{- with include "fieldList" (dict "schema" $resolved "level" 1 "Document" $.Document "Recursive" $.Recursive) -}}
FIELDS:{{- "\n" -}}
{{- . -}}
{{- end -}}
{{- else if and $refString (index $.history $refString) -}}
{{- /* Stop and do nothing. Hit a cycle */ -}}
{{- else if and $resolved.properties (index $resolved.properties (first $.FieldPath)) -}}
{{- /* Schema has this property directly. Traverse to next schema */ -}}
{{- $subschema := index $resolved.properties (first $.FieldPath) -}}
{{- if eq 1 (len $.FieldPath) -}}
{{- /* TODO: The original explain would say RESOURCE instead of FIELD here under some circumstances */ -}}
FIELD: {{first $.FieldPath}} <{{ template "typeGuess" (dict "schema" $subschema) }}>{{"\n"}}
{{- "\n" -}}
{{- end -}}
{{- template "output" (set $nextContext "history" (dict) "FieldPath" (slice $.FieldPath 1) "schema" $subschema ) -}}
{{- else if $resolved.items -}}
{{- /* If the schema is an array then traverse the array item fields */ -}}
{{- template "output" (set $nextContext "schema" $resolved.items) -}}
{{- else if $resolved.additionalProperties -}}
{{- /* If the schema is a map then traverse the map item fields */ -}}
{{- template "output" (set $nextContext "schema" $resolved.additionalProperties) -}}
{{- else -}}
{{- /* Last thing to try is all the alternatives in the allOf case */ -}}
{{- /* Stop when one of the alternatives has an output at all */ -}}
{{- range $index, $subschema := $resolved.allOf -}}
{{- with include "output" (set $nextContext "schema" $subschema) -}}
{{- . -}}
{{- break -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- /*
Prints list of fields of a given open api schema in following form:
field1 <type> -required-
DESCRIPTION
field2 <type> -required-
DESCRIPTION
or if Recursive is enabled:
field1 <type> -required-
subfield1 <type>
subsubfield1 <type>
subsubfield2 <type>
subfield2 <type>
field2 <type> -required-
subfield1 <type>
subfield2 <type>
Follows refs for field traversal. If there are cycles in the schema, they are
detected and traversal ends.
Takes dictionary as argument with keys:
schema: openapiv3 JSON schema
level: indentation level
history: map[string]int containing all ref names so far
Document: entire doc
Recursive: print recursive
*/ -}}
{{- define "fieldList" -}}
{{- /* Resolve schema if it is a ref */}}
{{- /* If this is a ref seen before, then ignore it */}}
{{- $refString := or (index $.schema "$ref") "" }}
{{- if and $refString (index (or $.history (dict)) $refString) -}}
{{- /* Do nothing for cycle */}}
{{- else -}}
{{- $nextContext := set $ "history" (set $.history $refString 1) -}}
{{- $resolved := or (resolveRef $refString $.Document) $.schema -}}
{{- range $k, $v := $resolved.properties -}}
{{- template "fieldDetail" (dict "name" $k "schema" $resolved "short" $.Recursive "level" $.level) -}}
{{- if $.Recursive -}}
{{- /* Check if we already know about this element */}}
{{- template "fieldList" (set $nextContext "schema" $v "level" (add $.level 1)) -}}
{{- end -}}
{{- end -}}
{{- range $resolved.allOf -}}
{{- template "fieldList" (set $nextContext "schema" .)}}
{{- end -}}
{{- if $resolved.items}}{{- template "fieldList" (set $nextContext "schema" $resolved.items)}}{{end}}
{{- if $resolved.additionalProperties}}{{- template "fieldList" (set $nextContext "schema" $resolved.additionalProperties)}}{{end}}
{{- end -}}
{{- end -}}
{{- /*
Prints a single field of the given schema
Optionally prints in a one-line style
Takes dictionary as argument with keys:
schema: openapiv3 JSON schema which contains the field
name: name of field
short: limit printing to a single-line summary
level: indentation amount
*/ -}}
{{- define "fieldDetail" -}}
{{- $level := or $.level 0 -}}
{{- $indentAmount := mul $level 2 -}}
{{- $fieldSchema := index $.schema.properties $.name -}}
{{- $.name | indent $indentAmount -}}{{"\t"}}<{{ template "typeGuess" (dict "schema" $fieldSchema) }}>
{{- if contains $.schema.required $.name}} -required-{{- end -}}
{{- "\n" -}}
{{- if not $.short -}}
{{- or $fieldSchema.description "<no description>" | wrap (sub 78 $indentAmount) | indent (add $indentAmount 2) -}}{{- "\n" -}}
{{- "\n" -}}
{{- end -}}
{{- end -}}
{{- /*
Prints the description of the given OpenAPI v3 schema. Also walks through all
sibling schemas to the provided schema and prints the description of those schemas
too
Takes dictionary as argument with keys:
schema: openapiv3 JSON schema
Document: document to resolve refs within
*/ -}}
{{- define "description" -}}
{{- with or (resolveRef (index $.schema "$ref") $.Document) $.schema -}}
{{- if .description -}}
{{- .description -}}
{{- "\n" -}}
{{- end -}}
{{- range .allOf -}}
{{- template "description" (set $ "schema" .)}}
{{- end -}}
{{- if .items -}}
{{- template "description" (set $ "schema" .items) -}}
{{- end -}}
{{- if .additionalProperties -}}
{{- template "description" (set $ "schema" .additionalProperties) -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- /* Renders a shortstring representing an interpretation of what is the "type"
of a subschema e.g.:
`string` `number`, `Object`, `[]Object`, `map[string]string`, etc.
Serves as a more helpful type hint than raw typical openapi `type` field
Takes dictionary as argument with keys:
schema: openapiv3 JSON schema
*/ -}}
{{- define "typeGuess" -}}
{{- with $.schema -}}
{{- if .items -}}
[]{{template "typeGuess" (dict "schema" .items)}}
{{- else if .additionalProperties -}}
map[string]{{template "typeGuess" (dict "schema" .additionalProperties)}}
{{- else if and .allOf (not .properties) (eq (len .allOf) 1) -}}
{{- /* If allOf has a single element and there are no direct
properties on the schema, defer to the allOf */ -}}
{{- template "typeGuess" (dict "schema" (first .allOf)) -}}
{{- else if index . "$ref"}}
{{- /* Parse the #!/components/schemas/io.k8s.Kind string into just the Kind name */ -}}
{{- $ref := index . "$ref" -}}
{{- $name := split $ref "/" | last -}}
{{- or (split $name "." | last) "Object" -}}
{{- else -}}
{{- or .type "Object" -}}
{{- end -}}
{{- else -}}
{{- fail "expected schema argument to subtemplate 'typeguess'" -}}
{{- end -}}
{{- end -}}

View File

@ -0,0 +1,547 @@
/*
Copyright 2022 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package templates_test
import (
"bytes"
_ "embed"
"encoding/json"
"fmt"
"strings"
"testing"
"text/template"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/runtime/schema"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/kube-openapi/pkg/spec3"
"k8s.io/kube-openapi/pkg/validation/spec"
v2 "k8s.io/kubectl/pkg/explain/v2"
)
var (
//go:embed plaintext.tmpl
plaintextSource string
//go:embed apiextensions.k8s.io_v1.json
apiextensionsJSON string
apiExtensionsV1OpenAPI map[string]interface{} = func() map[string]interface{} {
var res map[string]interface{}
utilruntime.Must(json.Unmarshal([]byte(apiextensionsJSON), &res))
return res
}()
apiExtensionsV1OpenAPISpec spec3.OpenAPI = func() spec3.OpenAPI {
var res spec3.OpenAPI
utilruntime.Must(json.Unmarshal([]byte(apiextensionsJSON), &res))
return res
}()
)
type testCase struct {
// test case name
Name string
// if empty uses main template
Subtemplate string
// context to render withi
Context any
// checks to perform on rendered output
Checks []check
}
type check interface {
doCheck(output string) error
}
type checkError string
func (c checkError) doCheck(output string) error {
if !strings.Contains(output, "error: "+string(c)) {
return fmt.Errorf("expected error: '%v' in string:\n%v", string(c), output)
}
return nil
}
type checkContains string
func (c checkContains) doCheck(output string) error {
if !strings.Contains(output, string(c)) {
return fmt.Errorf("expected substring: '%v' in string:\n%v", string(c), output)
}
return nil
}
type checkEquals string
func (c checkEquals) doCheck(output string) error {
if output != string(c) {
return fmt.Errorf("output is not equal to expectation:\n%v", cmp.Diff(string(c), output))
}
return nil
}
func MapDict[K comparable, V any, N any](accum map[K]V, mapper func(V) N) map[K]N {
res := make(map[K]N, len(accum))
for k, v := range accum {
res[k] = mapper(v)
}
return res
}
func ReduceDict[K comparable, V any, N any](val map[K]V, accum N, mapper func(N, K, V) N) N {
for k, v := range val {
accum = mapper(accum, k, v)
}
return accum
}
func TestPlaintext(t *testing.T) {
testcases := []testCase{
{
// Test case where resource being rendered is not found in OpenAPI schema
Name: "ResourceNotFound",
Context: v2.TemplateContext{
Document: apiExtensionsV1OpenAPI,
GVR: schema.GroupVersionResource{},
FieldPath: nil,
Recursive: false,
},
Checks: []check{
checkError("GVR (/, Resource=) not found in OpenAPI schema\n"),
},
},
{
// Test basic ability to find a GVR and print its description
Name: "SchemaFound",
Context: v2.TemplateContext{
Document: apiExtensionsV1OpenAPI,
GVR: schema.GroupVersionResource{
Group: "apiextensions.k8s.io",
Version: "v1",
Resource: "customresourcedefinitions",
},
FieldPath: nil,
Recursive: false,
},
Checks: []check{
checkContains("CustomResourceDefinition represents a resource that should be exposed"),
},
},
{
// Test that shows trying to find a non-existent field path of an existing
// schema
Name: "SchemaFieldPathNotFound",
Context: v2.TemplateContext{
Document: apiExtensionsV1OpenAPI,
GVR: schema.GroupVersionResource{
Group: "apiextensions.k8s.io",
Version: "v1",
Resource: "customresourcedefinitions",
},
FieldPath: []string{"does", "not", "exist"},
Recursive: false,
},
Checks: []check{
checkError(`field "[does not exist]" does not exist`),
},
},
{
// Test traversing a single level for scalar field path
Name: "SchemaFieldPathShallow",
Context: v2.TemplateContext{
Document: apiExtensionsV1OpenAPI,
GVR: schema.GroupVersionResource{
Group: "apiextensions.k8s.io",
Version: "v1",
Resource: "customresourcedefinitions",
},
FieldPath: []string{"kind"},
Recursive: false,
},
Checks: []check{
checkContains("FIELD: kind <string>"),
},
},
{
// Test traversing a multiple levels for scalar field path
Name: "SchemaFieldPathDeep",
Context: v2.TemplateContext{
Document: apiExtensionsV1OpenAPI,
GVR: schema.GroupVersionResource{
Group: "apiextensions.k8s.io",
Version: "v1",
Resource: "customresourcedefinitions",
},
FieldPath: []string{"spec", "names", "singular"},
Recursive: false,
},
Checks: []check{
checkContains("FIELD: singular <string>"),
},
},
{
// Test traversing a multiple levels for scalar field path
// through an array field
Name: "SchemaFieldPathViaList",
Context: v2.TemplateContext{
Document: apiExtensionsV1OpenAPI,
GVR: schema.GroupVersionResource{
Group: "apiextensions.k8s.io",
Version: "v1",
Resource: "customresourcedefinitions",
},
FieldPath: []string{"spec", "versions", "name"},
Recursive: false,
},
Checks: []check{
checkContains("FIELD: name <string>"),
},
},
{
// Test traversing a multiple levels for scalar field path
// through a map[string]T field.
Name: "SchemaFieldPathViaMap",
Context: v2.TemplateContext{
Document: apiExtensionsV1OpenAPI,
GVR: schema.GroupVersionResource{
Group: "apiextensions.k8s.io",
Version: "v1",
Resource: "customresourcedefinitions",
},
FieldPath: []string{"spec", "versions", "schema", "openAPIV3Schema", "properties", "default"},
Recursive: false,
},
Checks: []check{
checkContains("default is a default value for undefined object fields"),
},
},
{
// Shows that walking through a recursively specified schema is A-OK!
Name: "SchemaFieldPathRecursive",
Context: v2.TemplateContext{
Document: apiExtensionsV1OpenAPI,
GVR: schema.GroupVersionResource{
Group: "apiextensions.k8s.io",
Version: "v1",
Resource: "customresourcedefinitions",
},
FieldPath: []string{"spec", "versions", "schema", "openAPIV3Schema", "properties", "properties", "properties", "properties", "properties", "default"},
Recursive: false,
},
Checks: []check{
checkContains("default is a default value for undefined object fields"),
},
},
{
// Shows that all fields are included
Name: "SchemaAllFields",
Context: v2.TemplateContext{
Document: apiExtensionsV1OpenAPI,
GVR: schema.GroupVersionResource{
Group: "apiextensions.k8s.io",
Version: "v1",
Resource: "customresourcedefinitions",
},
FieldPath: []string{"spec", "versions", "schema"},
Recursive: false,
},
Checks: ReduceDict(apiExtensionsV1OpenAPISpec.Components.Schemas["io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1.CustomResourceValidation"].Properties, []check{}, func(checks []check, k string, v spec.Schema) []check {
return append(checks, checkContains(k), checkContains(v.Description))
}),
},
{
// Shows that all fields are included
Name: "SchemaAllFieldsRecursive",
Context: v2.TemplateContext{
Document: apiExtensionsV1OpenAPI,
GVR: schema.GroupVersionResource{
Group: "apiextensions.k8s.io",
Version: "v1",
Resource: "customresourcedefinitions",
},
FieldPath: []string{"spec", "versions", "schema"},
Recursive: true,
},
Checks: ReduceDict(apiExtensionsV1OpenAPISpec.Components.Schemas["io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1.CustomResourceValidation"].Properties, []check{}, func(checks []check, k string, v spec.Schema) []check {
return append(checks, checkContains(k))
}),
},
{
// Shows that the typeguess template works with scalars
Name: "Scalar",
Subtemplate: "typeGuess",
Context: map[string]any{
"schema": map[string]any{
"type": "string",
},
},
Checks: []check{
checkEquals("string"),
},
},
{
// Shows that the typeguess template behaves correctly given an
// array with unknown items
Name: "ArrayUnknown",
Subtemplate: "typeGuess",
Context: map[string]any{
"schema": map[string]any{
"description": "a cool field",
"type": "array",
},
},
Checks: []check{
checkEquals("array"),
},
},
{
// Shows that the typeguess template works with scalars
Name: "ArrayOfScalar",
Subtemplate: "typeGuess",
Context: map[string]any{
"schema": map[string]any{
"description": "a cool field",
"type": "array",
"items": map[string]any{
"type": "number",
},
},
},
Checks: []check{
checkEquals("[]number"),
},
},
{
// Shows that the typeguess template works with arrays containing
// a items which are a schema specified by a single-element allOf
// pointing to a $ref
Name: "ArrayOfAllOfRef",
Subtemplate: "typeGuess",
Context: map[string]any{
"schema": map[string]any{
"description": "a cool field",
"type": "array",
"items": map[string]any{
"type": "object",
"allOf": []map[string]any{
{
"$ref": "io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1.CustomResourceValidation",
},
},
},
},
},
Checks: []check{
checkEquals("[]CustomResourceValidation"),
},
},
{
// Shows that the typeguess template works with arrays containing
// a items which are a schema pointing to a $ref
Name: "ArrayOfRef",
Subtemplate: "typeGuess",
Context: map[string]any{
"schema": map[string]any{
"description": "a cool field",
"type": "array",
"items": map[string]any{
"type": "object",
"$ref": "io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1.CustomResourceValidation",
},
},
},
Checks: []check{
checkEquals("[]CustomResourceValidation"),
},
},
{
// Shows that the typeguess template works with arrays of maps of scalars
Name: "ArrayOfMap",
Subtemplate: "typeGuess",
Context: map[string]any{
"schema": map[string]any{
"description": "a cool field",
"type": "array",
"items": map[string]any{
"type": "object",
"additionalProperties": map[string]any{
"type": "string",
},
},
},
},
Checks: []check{
checkEquals("[]map[string]string"),
},
},
{
// Shows that the typeguess template works with maps of arrays of scalars
Name: "MapOfArrayOfScalar",
Subtemplate: "typeGuess",
Context: map[string]any{
"schema": map[string]any{
"description": "a cool field",
"type": "object",
"additionalProperties": map[string]any{
"type": "array",
"items": map[string]any{
"type": "string",
},
},
},
},
Checks: []check{
checkEquals("map[string][]string"),
},
},
{
// Shows that the typeguess template works with maps of ref types
Name: "MapOfRef",
Subtemplate: "typeGuess",
Context: map[string]any{
"schema": map[string]any{
"description": "a cool field",
"type": "object",
"additionalProperties": map[string]any{
"type": "string",
"allOf": []map[string]any{
{
"$ref": "io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1.CustomResourceValidation",
},
},
},
},
},
Checks: []check{
checkEquals("map[string]CustomResourceValidation"),
},
},
{
// Shows that the typeguess template prints `Object` if there
// is absolutely no type information
Name: "Unknown",
Subtemplate: "typeGuess",
Context: map[string]any{
"schema": map[string]any{
"description": "a cool field",
},
},
Checks: []check{
checkEquals("Object"),
},
},
{
Name: "Required",
Subtemplate: "fieldDetail",
Context: map[string]any{
"schema": map[string]any{
"type": "object",
"description": "a description that should not be printed",
"properties": map[string]any{
"thefield": map[string]any{
"type": "string",
"description": "a description that should not be printed",
},
},
"required": []string{"thefield"},
},
"name": "thefield",
"short": true,
},
Checks: []check{
checkEquals("thefield\t<string> -required-\n"),
},
},
{
Name: "Description",
Subtemplate: "fieldDetail",
Context: map[string]any{
"schema": map[string]any{
"type": "object",
"description": "a description that should not be printed",
"properties": map[string]any{
"thefield": map[string]any{
"type": "string",
"description": "a description that should be printed",
},
},
"required": []string{"thefield"},
},
"name": "thefield",
"short": false,
},
Checks: []check{
checkEquals("thefield\t<string> -required-\n a description that should be printed\n\n"),
},
},
{
Name: "Indent",
Subtemplate: "fieldDetail",
Context: map[string]any{
"schema": map[string]any{
"type": "object",
"description": "a description that should not be printed",
"properties": map[string]any{
"thefield": map[string]any{
"type": "string",
"description": "a description that should not be printed",
},
},
"required": []string{"thefield"},
},
"name": "thefield",
"short": true,
"level": 5,
},
Checks: []check{
checkEquals(" thefield\t<string> -required-\n"),
},
},
}
tmpl, err := v2.WithBuiltinTemplateFuncs(template.New("")).Parse(plaintextSource)
require.NoError(t, err)
for _, tcase := range testcases {
testName := tcase.Name
if len(tcase.Subtemplate) > 0 {
testName = tcase.Subtemplate + "/" + testName
}
t.Run(testName, func(t *testing.T) {
buf := bytes.NewBuffer(nil)
if len(tcase.Subtemplate) == 0 {
tmpl.Execute(buf, tcase.Context)
} else {
tmpl.ExecuteTemplate(buf, tcase.Subtemplate, tcase.Context)
}
require.NoError(t, err)
output := buf.String()
for _, check := range tcase.Checks {
err = check.doCheck(output)
if err != nil {
t.Log("test failed on output:\n" + output)
require.NoError(t, err)
}
}
})
}
}