Merge pull request #113024 from alexzielenski/explain-output-openapiv3

kubectl explain openapiv3 template foundations

Kubernetes-commit: 33bc8750fb97657d3cd995bd4b32857947f1dac1
This commit is contained in:
Kubernetes Publisher 2022-10-18 20:59:00 -07:00
commit abd5295393
7 changed files with 494 additions and 11 deletions

10
go.mod
View File

@ -32,8 +32,8 @@ require (
k8s.io/api v0.0.0-20221012115127-0184bd884c5e k8s.io/api v0.0.0-20221012115127-0184bd884c5e
k8s.io/apimachinery v0.0.0-20221017194938-70a38aaa19ef k8s.io/apimachinery v0.0.0-20221017194938-70a38aaa19ef
k8s.io/cli-runtime v0.0.0-20221015041739-b6a5653c8754 k8s.io/cli-runtime v0.0.0-20221015041739-b6a5653c8754
k8s.io/client-go v0.0.0-20221018035516-42a0e1ca70c4 k8s.io/client-go v0.0.0-20221018195526-197e4799dc56
k8s.io/component-base v0.0.0-20221017200238-034e08cbfdfb k8s.io/component-base v0.0.0-20221019000337-4f487d08c46c
k8s.io/component-helpers v0.0.0-20221017200342-f55d4a0c1767 k8s.io/component-helpers v0.0.0-20221017200342-f55d4a0c1767
k8s.io/klog/v2 v2.80.1 k8s.io/klog/v2 v2.80.1
k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280 k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280
@ -94,9 +94,9 @@ replace (
k8s.io/api => k8s.io/api v0.0.0-20221012115127-0184bd884c5e k8s.io/api => k8s.io/api v0.0.0-20221012115127-0184bd884c5e
k8s.io/apimachinery => k8s.io/apimachinery v0.0.0-20221017194938-70a38aaa19ef k8s.io/apimachinery => k8s.io/apimachinery v0.0.0-20221017194938-70a38aaa19ef
k8s.io/cli-runtime => k8s.io/cli-runtime v0.0.0-20221015041739-b6a5653c8754 k8s.io/cli-runtime => k8s.io/cli-runtime v0.0.0-20221015041739-b6a5653c8754
k8s.io/client-go => k8s.io/client-go v0.0.0-20221018035516-42a0e1ca70c4 k8s.io/client-go => k8s.io/client-go v0.0.0-20221018195526-197e4799dc56
k8s.io/code-generator => k8s.io/code-generator v0.0.0-20221017194732-d6a8b70c7bea k8s.io/code-generator => k8s.io/code-generator v0.0.0-20221018161754-557ce1f667c7
k8s.io/component-base => k8s.io/component-base v0.0.0-20221017200238-034e08cbfdfb k8s.io/component-base => k8s.io/component-base v0.0.0-20221019000337-4f487d08c46c
k8s.io/component-helpers => k8s.io/component-helpers v0.0.0-20221017200342-f55d4a0c1767 k8s.io/component-helpers => k8s.io/component-helpers v0.0.0-20221017200342-f55d4a0c1767
k8s.io/metrics => k8s.io/metrics v0.0.0-20221017201900-57e823688239 k8s.io/metrics => k8s.io/metrics v0.0.0-20221017201900-57e823688239
) )

8
go.sum
View File

@ -545,10 +545,10 @@ k8s.io/apimachinery v0.0.0-20221017194938-70a38aaa19ef h1:4lKLUMgwg9xi0UV4qjwS2N
k8s.io/apimachinery v0.0.0-20221017194938-70a38aaa19ef/go.mod h1:/x4E+/xaA5ap3q0tWNh5IPFt63dzY1I2qP8zT4sr5Yg= k8s.io/apimachinery v0.0.0-20221017194938-70a38aaa19ef/go.mod h1:/x4E+/xaA5ap3q0tWNh5IPFt63dzY1I2qP8zT4sr5Yg=
k8s.io/cli-runtime v0.0.0-20221015041739-b6a5653c8754 h1:DFW+3b/iGJ7pDcxg0WxRwXIGzHh32tkKoGW7zvl/SFE= k8s.io/cli-runtime v0.0.0-20221015041739-b6a5653c8754 h1:DFW+3b/iGJ7pDcxg0WxRwXIGzHh32tkKoGW7zvl/SFE=
k8s.io/cli-runtime v0.0.0-20221015041739-b6a5653c8754/go.mod h1:VhlC3GpPPnhI7o+8McywZaDeruyDfbOeYgSxxChhlto= k8s.io/cli-runtime v0.0.0-20221015041739-b6a5653c8754/go.mod h1:VhlC3GpPPnhI7o+8McywZaDeruyDfbOeYgSxxChhlto=
k8s.io/client-go v0.0.0-20221018035516-42a0e1ca70c4 h1:c2OVHhALzI2exr09upcz5xYJJJIYE7/CvAmj7UuTiIg= k8s.io/client-go v0.0.0-20221018195526-197e4799dc56 h1:RW41+G8r/tFt+qUnYILOeGV9WAyGtjKGeOvuv0TYJzI=
k8s.io/client-go v0.0.0-20221018035516-42a0e1ca70c4/go.mod h1:r+Jiu2RH1zXcJsmml1qRHg9oBq4sHHcMRaiEV0GN0ME= k8s.io/client-go v0.0.0-20221018195526-197e4799dc56/go.mod h1:r+Jiu2RH1zXcJsmml1qRHg9oBq4sHHcMRaiEV0GN0ME=
k8s.io/component-base v0.0.0-20221017200238-034e08cbfdfb h1:xi+Q1olbNRt1UKQO/NTBEwCql1vBI7y48dUACoSnYMc= k8s.io/component-base v0.0.0-20221019000337-4f487d08c46c h1:JEJlkqGJMe1WCIri8MWhUc1vryNLbP6SFmspoSwMbt8=
k8s.io/component-base v0.0.0-20221017200238-034e08cbfdfb/go.mod h1:C6bKv2+Cn5xLxGAlDAFSkSqGtDiPEUrEA4y0BKdUO2I= k8s.io/component-base v0.0.0-20221019000337-4f487d08c46c/go.mod h1:GzuOBZLTCAovez80ouX6fNL7eTjPnb0b2WDJkqL1k5c=
k8s.io/component-helpers v0.0.0-20221017200342-f55d4a0c1767 h1:1NIJhCNc61Jv0Opi1E0ke8c5fwNsy2ZU0r/zXzcoh00= k8s.io/component-helpers v0.0.0-20221017200342-f55d4a0c1767 h1:1NIJhCNc61Jv0Opi1E0ke8c5fwNsy2ZU0r/zXzcoh00=
k8s.io/component-helpers v0.0.0-20221017200342-f55d4a0c1767/go.mod h1:QwKUXpBsJXPkBJiSLYaWUEvPyq0O7yWEvqo4/RXeRsc= k8s.io/component-helpers v0.0.0-20221017200342-f55d4a0c1767/go.mod h1:QwKUXpBsJXPkBJiSLYaWUEvPyq0O7yWEvqo4/RXeRsc=
k8s.io/klog/v2 v2.80.1 h1:atnLQ121W371wYYFawwYx1aEY2eUfs4l3J72wtgAwV4= k8s.io/klog/v2 v2.80.1 h1:atnLQ121W371wYYFawwYx1aEY2eUfs4l3J72wtgAwV4=

View File

@ -18,14 +18,17 @@ package explain
import ( import (
"fmt" "fmt"
"os"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/client-go/discovery"
cmdutil "k8s.io/kubectl/pkg/cmd/util" cmdutil "k8s.io/kubectl/pkg/cmd/util"
"k8s.io/kubectl/pkg/explain" "k8s.io/kubectl/pkg/explain"
explainv2 "k8s.io/kubectl/pkg/explain/v2"
"k8s.io/kubectl/pkg/util/i18n" "k8s.io/kubectl/pkg/util/i18n"
"k8s.io/kubectl/pkg/util/openapi" "k8s.io/kubectl/pkg/util/openapi"
"k8s.io/kubectl/pkg/util/templates" "k8s.io/kubectl/pkg/util/templates"
@ -62,12 +65,25 @@ type ExplainOptions struct {
Mapper meta.RESTMapper Mapper meta.RESTMapper
Schema openapi.Resources Schema openapi.Resources
// Toggles whether the OpenAPI v3 template-based renderer should be used to show
// output.
EnableOpenAPIV3 bool
// Name of the template to use with the openapiv3 template renderer. If
// `EnableOpenAPIV3` is disabled, this does nothing
OutputFormat string
// Client capable of fetching openapi documents from the user's cluster
DiscoveryClient discovery.DiscoveryInterface
} }
func NewExplainOptions(parent string, streams genericclioptions.IOStreams) *ExplainOptions { func NewExplainOptions(parent string, streams genericclioptions.IOStreams) *ExplainOptions {
return &ExplainOptions{ return &ExplainOptions{
IOStreams: streams, IOStreams: streams,
CmdParent: parent, CmdParent: parent,
EnableOpenAPIV3: os.Getenv("KUBECTL_EXPLAIN_OPENAPIV3") == "true",
OutputFormat: "plaintext",
} }
} }
@ -89,6 +105,12 @@ func NewCmdExplain(parent string, f cmdutil.Factory, streams genericclioptions.I
} }
cmd.Flags().BoolVar(&o.Recursive, "recursive", o.Recursive, "Print the fields of fields (Currently only 1 level deep)") cmd.Flags().BoolVar(&o.Recursive, "recursive", o.Recursive, "Print the fields of fields (Currently only 1 level deep)")
cmd.Flags().StringVar(&o.APIVersion, "api-version", o.APIVersion, "Get different explanations for particular API version (API group/version)") cmd.Flags().StringVar(&o.APIVersion, "api-version", o.APIVersion, "Get different explanations for particular API version (API group/version)")
// Only enable --output as a valid flag if the feature is enabled
if o.EnableOpenAPIV3 {
cmd.Flags().StringVar(&o.OutputFormat, "output", o.OutputFormat, "Format in which to render the schema")
}
return cmd return cmd
} }
@ -104,6 +126,15 @@ func (o *ExplainOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []
return err return err
} }
// Only openapi v3 needs the discovery client.
if o.EnableOpenAPIV3 {
clientset, err := f.KubernetesClientSet()
if err != nil {
return err
}
o.DiscoveryClient = clientset.DiscoveryClient
}
o.args = args o.args = args
return nil return nil
} }
@ -142,6 +173,17 @@ func (o *ExplainOptions) Run() error {
} }
} }
if o.EnableOpenAPIV3 {
return explainv2.PrintModelDescription(
fieldsPath,
o.Out,
o.DiscoveryClient.OpenAPIV3(),
fullySpecifiedGVR,
recursive,
o.OutputFormat,
)
}
gvk, _ := o.Mapper.KindFor(fullySpecifiedGVR) gvk, _ := o.Mapper.KindFor(fullySpecifiedGVR)
if gvk.Empty() { if gvk.Empty() {
gvk, err = o.Mapper.KindFor(fullySpecifiedGVR.GroupResource().WithVersion("")) gvk, err = o.Mapper.KindFor(fullySpecifiedGVR.GroupResource().WithVersion(""))

84
pkg/explain/v2/explain.go Normal file
View File

@ -0,0 +1,84 @@
/*
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
import (
"encoding/json"
"fmt"
"io"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/openapi"
)
// PrintModelDescription prints the description of a specific model or dot path.
// If recursive, all components nested within the fields of the schema will be
// printed.
func PrintModelDescription(
fieldsPath []string,
w io.Writer,
client openapi.Client,
gvr schema.GroupVersionResource,
recursive bool,
outputFormat string,
) error {
return printModelDescriptionWithGenerator(
NewGenerator(), fieldsPath, w, client, gvr, recursive, outputFormat)
}
// Factored out for testability
func printModelDescriptionWithGenerator(
generator *generator,
fieldsPath []string,
w io.Writer,
client openapi.Client,
gvr schema.GroupVersionResource,
recursive bool,
outputFormat string,
) error {
paths, err := client.Paths()
if err != nil {
return fmt.Errorf("failed to fetch list of groupVersions: %w", err)
}
var resourcePath string
if len(gvr.Group) == 0 {
resourcePath = fmt.Sprintf("api/%s", gvr.Version)
} else {
resourcePath = fmt.Sprintf("apis/%s/%s", gvr.Group, gvr.Version)
}
gv, exists := paths[resourcePath]
if !exists {
return fmt.Errorf("could not locate schema for %s", resourcePath)
}
openAPISchemaBytes, err := gv.Schema(runtime.ContentTypeJSON)
if err != nil {
return fmt.Errorf("failed to fetch openapi schema for %s: %w", resourcePath, err)
}
var parsedV3Schema map[string]interface{}
if err := json.Unmarshal(openAPISchemaBytes, &parsedV3Schema); err != nil {
return fmt.Errorf("failed to parse openapi schema for %s: %w", resourcePath, err)
}
return generator.Render(outputFormat, parsedV3Schema, gvr, fieldsPath, recursive, w)
}

View File

@ -0,0 +1,184 @@
/*
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
import (
"bytes"
"encoding/json"
"errors"
"sync"
"testing"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/openapi"
)
var apiDiscoveryJSON string = `{"openapi":"3.0.0","info":{"title":"Kubernetes","version":"v1.26.0"},"paths":{"/apis/discovery.k8s.io/":{"get":{"tags":["discovery"],"description":"get information of a group","operationId":"getDiscoveryAPIGroup","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.APIGroup"}},"application/vnd.kubernetes.protobuf":{"schema":{"$ref":"#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.APIGroup"}},"application/yaml":{"schema":{"$ref":"#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.APIGroup"}}}},"401":{"description":"Unauthorized"}}}}},"components":{"schemas":{"io.k8s.apimachinery.pkg.apis.meta.v1.APIGroup":{"description":"APIGroup contains the name, the supported versions, and the preferred version of a group.","type":"object","required":["name","versions"],"properties":{"apiVersion":{"description":"APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources","type":"string"},"kind":{"description":"Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds","type":"string"},"name":{"description":"name is the name of the group.","type":"string","default":""},"preferredVersion":{"description":"preferredVersion is the version preferred by the API server, which probably is the storage version.","default":{},"allOf":[{"$ref":"#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.GroupVersionForDiscovery"}]},"serverAddressByClientCIDRs":{"description":"a map of client CIDR to server address that is serving this group. This is to help clients reach servers in the most network-efficient way possible. Clients can use the appropriate server address as per the CIDR that they match. In case of multiple matches, clients should use the longest matching CIDR. The server returns only those CIDRs that it thinks that the client can match. For example: the master will return an internal IP CIDR only, if the client reaches the server using an internal IP. Server looks at X-Forwarded-For header or X-Real-Ip header or request.RemoteAddr (in that order) to get the client IP.","type":"array","items":{"default":{},"allOf":[{"$ref":"#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ServerAddressByClientCIDR"}]}},"versions":{"description":"versions are the versions supported in this group.","type":"array","items":{"default":{},"allOf":[{"$ref":"#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.GroupVersionForDiscovery"}]}}},"x-kubernetes-group-version-kind":[{"group":"","kind":"APIGroup","version":"v1"}]},"io.k8s.apimachinery.pkg.apis.meta.v1.GroupVersionForDiscovery":{"description":"GroupVersion contains the \"group/version\" and \"version\" string of a version. It is made a struct to keep extensibility.","type":"object","required":["groupVersion","version"],"properties":{"groupVersion":{"description":"groupVersion specifies the API group and version in the form \"group/version\"","type":"string","default":""},"version":{"description":"version specifies the version in the form of \"version\". This is to save the clients the trouble of splitting the GroupVersion.","type":"string","default":""}}},"io.k8s.apimachinery.pkg.apis.meta.v1.ServerAddressByClientCIDR":{"description":"ServerAddressByClientCIDR helps the client to determine the server address that they should use, depending on the clientCIDR that they match.","type":"object","required":["clientCIDR","serverAddress"],"properties":{"clientCIDR":{"description":"The CIDR with which clients can match their IP to figure out the server address that they should use.","type":"string","default":""},"serverAddress":{"description":"Address of this server, suitable for a client that matches the above CIDR. This can be a hostname, hostname:port, IP or IP:port.","type":"string","default":""}}}},"securitySchemes":{"BearerToken":{"type":"apiKey","description":"Bearer Token authentication","name":"authorization","in":"header"}}}}`
var apiGroupsGVR schema.GroupVersionResource = schema.GroupVersionResource{
Group: "discovery.k8s.io",
Version: "v1",
Resource: "apigroups",
}
var apiGroupsDocument map[string]interface{} = func() map[string]interface{} {
var doc map[string]interface{}
err := json.Unmarshal([]byte(apiDiscoveryJSON), &doc)
if err != nil {
panic(err)
}
return doc
}()
type FakeOpenAPIV3Client struct {
// Path:
// ContentType:
// OpenAPIV3 Schema bytes
Values map[string]map[string][]byte
FetchCounts map[string]map[string]int
lock sync.Mutex
}
type FakeGroupVersion struct {
Data map[string][]byte
FetchCounts map[string]int
Lock *sync.Mutex
}
func (f *FakeGroupVersion) Schema(contentType string) ([]byte, error) {
f.Lock.Lock()
defer f.Lock.Unlock()
if count, ok := f.FetchCounts[contentType]; ok {
f.FetchCounts[contentType] = count + 1
} else {
f.FetchCounts[contentType] = 1
}
data, ok := f.Data[contentType]
if !ok {
return nil, errors.New("not found")
}
return data, nil
}
func (f *FakeOpenAPIV3Client) Paths() (map[string]openapi.GroupVersion, error) {
if f.Values == nil {
return nil, errors.New("values is nil")
}
res := map[string]openapi.GroupVersion{}
if f.FetchCounts == nil {
f.FetchCounts = map[string]map[string]int{}
}
for k, v := range f.Values {
counts, ok := f.FetchCounts[k]
if !ok {
counts = map[string]int{}
f.FetchCounts[k] = counts
}
res[k] = &FakeGroupVersion{Data: v, FetchCounts: counts, Lock: &f.lock}
}
return res, nil
}
func TestExplainErrors(t *testing.T) {
var buf bytes.Buffer
// A client with nil `Values` will return error on returning paths
failFetchPaths := &FakeOpenAPIV3Client{}
err := PrintModelDescription(nil, &buf, failFetchPaths, apiGroupsGVR, false, "unknown-format")
require.ErrorContains(t, err, "failed to fetch list of groupVersions")
// Missing Schema
fakeClient := &FakeOpenAPIV3Client{
Values: map[string]map[string][]byte{
"apis/test1.example.com/v1": {
"unknown/content-type": []byte(apiDiscoveryJSON),
},
"apis/test2.example.com/v1": {
runtime.ContentTypeJSON: []byte(`<some invalid json!>`),
},
"apis/discovery.k8s.io/v1": {
runtime.ContentTypeJSON: []byte(apiDiscoveryJSON),
},
},
}
err = PrintModelDescription(nil, &buf, fakeClient, schema.GroupVersionResource{
Group: "test0.example.com",
Version: "v1",
Resource: "doesntmatter",
}, false, "unknown-format")
require.ErrorContains(t, err, "could not locate schema")
// Missing JSON
err = PrintModelDescription(nil, &buf, fakeClient, schema.GroupVersionResource{
Group: "test1.example.com",
Version: "v1",
Resource: "doesntmatter",
}, false, "unknown-format")
require.ErrorContains(t, err, "failed to fetch openapi schema ")
err = PrintModelDescription(nil, &buf, fakeClient, schema.GroupVersionResource{
Group: "test2.example.com",
Version: "v1",
Resource: "doesntmatter",
}, false, "unknown-format")
require.ErrorContains(t, err, "failed to parse openapi schema")
err = PrintModelDescription(nil, &buf, fakeClient, apiGroupsGVR, false, "unknown-format")
require.ErrorContains(t, err, "unrecognized format: unknown-format")
}
// Shows that the correct GVR is fetched from the open api client when
// given to explain
func TestExplainOpenAPIClient(t *testing.T) {
var buf bytes.Buffer
fakeClient := &FakeOpenAPIV3Client{
Values: map[string]map[string][]byte{
"apis/discovery.k8s.io/v1": {
runtime.ContentTypeJSON: []byte(apiDiscoveryJSON),
},
},
}
gen := NewGenerator()
err := gen.AddTemplate("Context", "{{ toJson . }}")
require.NoError(t, err)
expectedContext := TemplateContext{
Document: apiGroupsDocument,
GVR: apiGroupsGVR,
Recursive: false,
FieldPath: nil,
}
err = printModelDescriptionWithGenerator(gen, nil, &buf, fakeClient, apiGroupsGVR, false, "Context")
require.NoError(t, err)
var actualContext TemplateContext
err = json.Unmarshal(buf.Bytes(), &actualContext)
require.NoError(t, err)
require.Equal(t, expectedContext, actualContext)
require.Equal(t, fakeClient.FetchCounts["apis/discovery.k8s.io/v1"][runtime.ContentTypeJSON], 1)
}

View File

@ -0,0 +1,91 @@
/*
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
import (
"encoding/json"
"fmt"
"io"
"text/template"
"k8s.io/apimachinery/pkg/runtime/schema"
)
type TemplateContext struct {
GVR schema.GroupVersionResource
Document map[string]interface{}
Recursive bool
FieldPath []string
}
type generator struct {
templates map[string]*template.Template
}
func NewGenerator() *generator {
return &generator{
templates: make(map[string]*template.Template),
}
}
func (g *generator) AddTemplate(name string, contents string) error {
compiled, err := template.
New(name).
Funcs(map[string]interface{}{
"toJson": func(obj any) (string, error) {
res, err := json.Marshal(obj)
return string(res), err
},
}).
Parse(contents)
if err != nil {
return err
}
g.templates[name] = compiled
return nil
}
func (g *generator) Render(
// Template to use for rendering
templateName string,
// Self-Contained OpenAPI Document Containing all schemas used by $ref
// Only OpenAPI V3 documents are supported
document map[string]interface{},
// Resource within OpenAPI document for which to render explain schema
gvr schema.GroupVersionResource,
// Field path of child of resource to focus output onto
fieldSelector []string,
// Boolean indicating whether the fields should be rendered recursively/deeply
recursive bool,
// Output writer
writer io.Writer,
) error {
compiledTemplate, ok := g.templates[templateName]
if !ok {
return fmt.Errorf("unrecognized format: %s", templateName)
}
err := compiledTemplate.Execute(writer, TemplateContext{
Document: document,
Recursive: recursive,
FieldPath: fieldSelector,
GVR: gvr,
})
return err
}

View File

@ -0,0 +1,82 @@
/*
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
import (
"bytes"
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
// Shows generic throws error when attempting to `Render“ an invalid output name
// And if it is then added as a template, no error is thrown upon `Render`
func TestGeneratorMissingOutput(t *testing.T) {
var buf bytes.Buffer
var doc map[string]interface{}
err := json.Unmarshal([]byte(apiDiscoveryJSON), &doc)
require.NoError(t, err)
gen := NewGenerator()
badTemplateName := "bad-template"
err = gen.Render(badTemplateName, doc, apiGroupsGVR, nil, false, &buf)
require.ErrorContains(t, err, "unrecognized format: "+badTemplateName)
require.Zero(t, buf.Len())
err = gen.AddTemplate(badTemplateName, "ok")
require.NoError(t, err)
err = gen.Render(badTemplateName, doc, apiGroupsGVR, nil, false, &buf)
require.NoError(t, err)
require.Equal(t, "ok", buf.String())
}
// Shows that correct context with the passed object is passed to the template
func TestGeneratorContext(t *testing.T) {
var buf bytes.Buffer
var doc map[string]interface{}
err := json.Unmarshal([]byte(apiDiscoveryJSON), &doc)
require.NoError(t, err)
gen := NewGenerator()
err = gen.AddTemplate("Context", "{{ toJson . }}")
require.NoError(t, err)
expectedContext := TemplateContext{
Document: doc,
GVR: apiGroupsGVR,
Recursive: false,
FieldPath: nil,
}
err = gen.Render("Context",
expectedContext.Document,
expectedContext.GVR,
expectedContext.FieldPath,
expectedContext.Recursive,
&buf)
require.NoError(t, err)
var actualContext TemplateContext
err = json.Unmarshal(buf.Bytes(), &actualContext)
require.NoError(t, err)
require.Equal(t, expectedContext, actualContext)
}