Skip to content

Commit

Permalink
feat: add minimal working example using required fields only (#72)
Browse files Browse the repository at this point in the history
* feat: add minimal working example using required fields only

* added tests and feature for minimal required fields only

* added wasm rebuild

* updated README and added more tests
  • Loading branch information
Skarlso authored May 23, 2024
1 parent bdfb254 commit f391280
Show file tree
Hide file tree
Showing 15 changed files with 408 additions and 46 deletions.
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,27 @@ To get an HTML output provide the format flag like this:
cty generate -c delivery.krok.app_krokcommands --comments --format html
```

### Minimal required CRD sample

It's possible to generate a sample YAML for a CRD that will make the CRD validation pass. Meaning, it will only contain
samples for fields that are actually required. All other fields will be ignored.

For example, a CRD having a single required field with an example and the rest being optional would generate something
like this:

```yaml
apiVersion: delivery.krok.app/v1alpha1
kind: KrokCommand
spec:
image: "krok-hook/slack-notification:v0.0.1"
```

To run cty with minimal required fields, pass in `--minimal` to the command like this:

```
cty generate -c delivery.krok.app_krokcommands --comments --minimal --format html
```

## WASM frontend

There is a WASM based frontend that can be started by navigating into the `wasm` folder and running the following make
Expand Down
10 changes: 6 additions & 4 deletions cmd/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ var (
format string
stdOut bool
comments bool
minimal bool
)

func init() {
Expand All @@ -45,8 +46,9 @@ func init() {
f.StringVarP(&url, "url", "u", "", "If provided, will use this URL to fetch CRD YAML content from.")
f.StringVarP(&output, "output", "o", "", "The location of the output file. Default is next to the CRD.")
f.StringVarP(&format, "format", "f", FormatYAML, "The format in which to output. Default is YAML. Options are: yaml, html.")
f.BoolVarP(&stdOut, "stdout", "s", false, "If set, it will output the generated content to stdout")
f.BoolVarP(&comments, "comments", "m", false, "If set, it will add descriptions as comments to each line where available")
f.BoolVarP(&stdOut, "stdout", "s", false, "If set, it will output the generated content to stdout.")
f.BoolVarP(&comments, "comments", "m", false, "If set, it will add descriptions as comments to each line where available.")
f.BoolVarP(&minimal, "minimal", "l", false, "If set, only the minimal required example yaml is generated.")
}

func runGenerate(_ *cobra.Command, _ []string) error {
Expand Down Expand Up @@ -95,8 +97,8 @@ func runGenerate(_ *cobra.Command, _ []string) error {
return fmt.Errorf("failed to load templates: %w", err)
}

return pkg.RenderContent(w, content, comments)
return pkg.RenderContent(w, content, comments, minimal)
}

return pkg.Generate(crd, w, comments)
return pkg.Generate(crd, w, comments, minimal)
}
22 changes: 15 additions & 7 deletions pkg/create_html_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"html/template"
"io"
"io/fs"
"slices"
"sort"

"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
Expand Down Expand Up @@ -60,20 +61,22 @@ func LoadTemplates() error {
}

// RenderContent creates an HTML website from the CRD content.
func RenderContent(w io.Writer, crdContent []byte, comments bool) error {
func RenderContent(w io.Writer, crdContent []byte, comments, minimal bool) error {
crd := &v1beta1.CustomResourceDefinition{}
if err := yaml.Unmarshal(crdContent, crd); err != nil {
return fmt.Errorf("failed to unmarshal into custom resource definition: %w", err)
}
versions := make([]Version, 0)
parser := NewParser(crd.Spec.Group, crd.Spec.Names.Kind, comments, minimal)

for _, version := range crd.Spec.Versions {
out, err := parseCRD(version.Schema.OpenAPIV3Schema.Properties, version.Name, version.Schema.OpenAPIV3Schema.Required)
out, err := parseCRD(version.Schema.OpenAPIV3Schema.Properties, version.Name, minimal, RootRequiredFields)
if err != nil {
return fmt.Errorf("failed to parse properties: %w", err)
}
var buffer []byte
buf := bytes.NewBuffer(buffer)
if err := ParseProperties(crd.Spec.Group, version.Name, crd.Spec.Names.Kind, version.Schema.OpenAPIV3Schema.Properties, buf, 0, false, comments); err != nil {
if err := parser.ParseProperties(version.Name, buf, version.Schema.OpenAPIV3Schema.Properties, RootRequiredFields); err != nil {
return fmt.Errorf("failed to generate yaml sample: %w", err)
}
versions = append(versions, Version{
Expand Down Expand Up @@ -113,7 +116,7 @@ type Property struct {

// parseCRD takes the properties and constructs a linked list out of the embedded properties that the recursive
// template can call and construct linked divs.
func parseCRD(properties map[string]v1beta1.JSONSchemaProps, version string, requiredList []string) ([]*Property, error) {
func parseCRD(properties map[string]v1beta1.JSONSchemaProps, version string, minimal bool, requiredList []string) ([]*Property, error) {
output := make([]*Property, 0, len(properties))
sortedKeys := make([]string, 0, len(properties))

Expand All @@ -123,6 +126,11 @@ func parseCRD(properties map[string]v1beta1.JSONSchemaProps, version string, req
sort.Strings(sortedKeys)

for _, k := range sortedKeys {
if minimal {
if !slices.Contains(requiredList, k) {
continue
}
}
// Create the Property with the values necessary.
// Check if there are properties for it in Properties or in Array -> Properties.
// If yes, call parseCRD and add the result to the created properties Properties list.
Expand Down Expand Up @@ -153,21 +161,21 @@ func parseCRD(properties map[string]v1beta1.JSONSchemaProps, version string, req
switch {
case len(properties[k].Properties) > 0 && properties[k].AdditionalProperties == nil:
requiredList = v.Required
out, err := parseCRD(properties[k].Properties, version, requiredList)
out, err := parseCRD(properties[k].Properties, version, minimal, requiredList)
if err != nil {
return nil, err
}
p.Properties = out
case properties[k].Type == array && properties[k].Items.Schema != nil && len(properties[k].Items.Schema.Properties) > 0:
requiredList = v.Required
out, err := parseCRD(properties[k].Items.Schema.Properties, version, requiredList)
out, err := parseCRD(properties[k].Items.Schema.Properties, version, minimal, requiredList)
if err != nil {
return nil, err
}
p.Properties = out
case properties[k].AdditionalProperties != nil:
requiredList = v.Required
out, err := parseCRD(properties[k].AdditionalProperties.Schema.Properties, version, requiredList)
out, err := parseCRD(properties[k].AdditionalProperties.Schema.Properties, version, minimal, requiredList)
if err != nil {
return nil, err
}
Expand Down
67 changes: 52 additions & 15 deletions pkg/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"io"
"slices"
"sort"
"strings"

Expand All @@ -12,16 +13,18 @@ import (

const array = "array"

var RootRequiredFields = []string{"apiVersion", "kind", "spec"}

// Generate takes a CRD content and path, and outputs.
func Generate(crd *v1beta1.CustomResourceDefinition, w io.WriteCloser, enableComments bool) (err error) {
func Generate(crd *v1beta1.CustomResourceDefinition, w io.WriteCloser, enableComments, minimal bool) (err error) {
defer func() {
if cerr := w.Close(); cerr != nil {
err = errors.Join(err, cerr)
}
}()

parser := NewParser(crd.Spec.Group, crd.Spec.Names.Kind, enableComments, minimal)
for i, version := range crd.Spec.Versions {
if err := ParseProperties(crd.Spec.Group, version.Name, crd.Spec.Names.Kind, version.Schema.OpenAPIV3Schema.Properties, w, 0, false, enableComments); err != nil {
if err := parser.ParseProperties(version.Name, w, version.Schema.OpenAPIV3Schema.Properties, RootRequiredFields); err != nil {
return fmt.Errorf("failed to parse properties: %w", err)
}

Expand All @@ -46,10 +49,29 @@ func (w *writer) write(wc io.Writer, msg string) {
_, w.err = wc.Write([]byte(msg))
}

type Parser struct {
comments bool
inArray bool
indent int
group string
kind string
onlyRequired bool
}

// NewParser creates a new parser contains most of the things that do not change over each call.
func NewParser(group, kind string, comments, requiredOnly bool) *Parser {
return &Parser{
group: group,
kind: kind,
comments: comments,
onlyRequired: requiredOnly,
}
}

// ParseProperties takes a writer and puts out any information / properties it encounters during the runs.
// It will recursively parse every "properties:" and "additionalProperties:". Using the types, it will also output
// some sample data based on those types.
func ParseProperties(group, version, kind string, properties map[string]v1beta1.JSONSchemaProps, file io.Writer, indent int, inArray, comments bool) error {
func (p *Parser) ParseProperties(version string, file io.Writer, properties map[string]v1beta1.JSONSchemaProps, requiredFields []string) error {
sortedKeys := make([]string, 0, len(properties))
for k := range properties {
sortedKeys = append(sortedKeys, k)
Expand All @@ -58,60 +80,75 @@ func ParseProperties(group, version, kind string, properties map[string]v1beta1.

w := &writer{}
for _, k := range sortedKeys {
if inArray {
// if field is not required, skip the entire flow.
if p.onlyRequired {
if !slices.Contains(requiredFields, k) {
continue
}
}

if p.inArray {
w.write(file, k+":")
inArray = false
p.inArray = false
} else {
if comments && properties[k].Description != "" {
if p.comments && properties[k].Description != "" {
comment := strings.Builder{}
multiLine := strings.Split(properties[k].Description, "\n")
for _, line := range multiLine {
comment.WriteString(fmt.Sprintf("%s# %s\n", strings.Repeat(" ", indent), line))
comment.WriteString(fmt.Sprintf("%s# %s\n", strings.Repeat(" ", p.indent), line))
}

w.write(file, comment.String())
}

w.write(file, fmt.Sprintf("%s%s:", strings.Repeat(" ", indent), k))
w.write(file, fmt.Sprintf("%s%s:", strings.Repeat(" ", p.indent), k))
}
switch {
case len(properties[k].Properties) == 0 && properties[k].AdditionalProperties == nil:
if k == "apiVersion" {
w.write(file, fmt.Sprintf(" %s/%s\n", group, version))
w.write(file, fmt.Sprintf(" %s/%s\n", p.group, version))

continue
}
if k == "kind" {
w.write(file, fmt.Sprintf(" %s\n", kind))
w.write(file, fmt.Sprintf(" %s\n", p.kind))

continue
}
// If we are dealing with an array, and we have properties to parse
// we need to reparse all of them again.
var result string
if properties[k].Type == array && properties[k].Items.Schema != nil && len(properties[k].Items.Schema.Properties) > 0 {
w.write(file, fmt.Sprintf("\n%s- ", strings.Repeat(" ", indent)))
if err := ParseProperties(group, version, kind, properties[k].Items.Schema.Properties, file, indent+2, true, comments); err != nil {
w.write(file, fmt.Sprintf("\n%s- ", strings.Repeat(" ", p.indent)))
p.indent += 2
p.inArray = true
if err := p.ParseProperties(version, file, properties[k].Items.Schema.Properties, properties[k].Items.Schema.Required); err != nil {
return err
}
p.indent -= 2
} else {
result = outputValueType(properties[k])
w.write(file, fmt.Sprintf(" %s\n", result))
}
case len(properties[k].Properties) > 0:
w.write(file, "\n")
// recursively parse all sub-properties
if err := ParseProperties(group, version, kind, properties[k].Properties, file, indent+2, false, comments); err != nil {
p.indent += 2
if err := p.ParseProperties(version, file, properties[k].Properties, properties[k].Required); err != nil {
return err
}
p.indent -= 2
case properties[k].AdditionalProperties != nil:
if len(properties[k].AdditionalProperties.Schema.Properties) == 0 {
w.write(file, " {}\n")
} else {
w.write(file, "\n")
if err := ParseProperties(group, version, kind, properties[k].AdditionalProperties.Schema.Properties, file, indent+2, false, comments); err != nil {

p.indent += 2
if err := p.ParseProperties(version, file, properties[k].AdditionalProperties.Schema.Properties, properties[k].AdditionalProperties.Schema.Required); err != nil {
return err
}
p.indent -= 2
}
}
}
Expand Down
66 changes: 64 additions & 2 deletions pkg/generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ func TestGenerate(t *testing.T) {
buffer := bytes.NewBuffer(output)

version := crd.Spec.Versions[0]
require.NoError(t, ParseProperties(crd.Spec.Group, version.Name, crd.Spec.Names.Kind, version.Schema.OpenAPIV3Schema.Properties, buffer, 0, false, false))
parser := NewParser(crd.Spec.Group, crd.Spec.Names.Kind, false, false)
require.NoError(t, parser.ParseProperties(version.Name, buffer, version.Schema.OpenAPIV3Schema.Properties, RootRequiredFields))

golden, err := os.ReadFile(filepath.Join("testdata", "sample_crd_golden.yaml"))
require.NoError(t, err)
Expand All @@ -41,11 +42,72 @@ func TestGenerateWithExample(t *testing.T) {
var output []byte
buffer := bytes.NewBuffer(output)

parser := NewParser(crd.Spec.Group, crd.Spec.Names.Kind, false, false)
version := crd.Spec.Versions[0]
require.NoError(t, ParseProperties(crd.Spec.Group, version.Name, crd.Spec.Names.Kind, version.Schema.OpenAPIV3Schema.Properties, buffer, 0, false, false))
require.NoError(t, parser.ParseProperties(version.Name, buffer, version.Schema.OpenAPIV3Schema.Properties, RootRequiredFields))

golden, err := os.ReadFile(filepath.Join("testdata", "sample_crd_with_example_golden.yaml"))
require.NoError(t, err)

assert.Equal(t, golden, buffer.Bytes())
}

func TestGenerateWithComments(t *testing.T) {
content, err := os.ReadFile(filepath.Join("testdata", "sample_crd.yaml"))
require.NoError(t, err)

crd := &v1beta1.CustomResourceDefinition{}
require.NoError(t, yaml.Unmarshal(content, crd))

var output []byte
buffer := bytes.NewBuffer(output)

parser := NewParser(crd.Spec.Group, crd.Spec.Names.Kind, true, false)
version := crd.Spec.Versions[0]
require.NoError(t, parser.ParseProperties(version.Name, buffer, version.Schema.OpenAPIV3Schema.Properties, RootRequiredFields))

golden, err := os.ReadFile(filepath.Join("testdata", "sample_crd_with_comments_golden.yaml"))
require.NoError(t, err)

assert.Equal(t, golden, buffer.Bytes())
}

func TestGenerateMinimal(t *testing.T) {
content, err := os.ReadFile(filepath.Join("testdata", "sample_crd.yaml"))
require.NoError(t, err)

crd := &v1beta1.CustomResourceDefinition{}
require.NoError(t, yaml.Unmarshal(content, crd))

var output []byte
buffer := bytes.NewBuffer(output)

parser := NewParser(crd.Spec.Group, crd.Spec.Names.Kind, false, true)
version := crd.Spec.Versions[0]
require.NoError(t, parser.ParseProperties(version.Name, buffer, version.Schema.OpenAPIV3Schema.Properties, RootRequiredFields))

golden, err := os.ReadFile(filepath.Join("testdata", "sample_crd_with_minimal_example_golden.yaml"))
require.NoError(t, err)

assert.Equal(t, golden, buffer.Bytes())
}

func TestGenerateMinimalWithExample(t *testing.T) {
content, err := os.ReadFile(filepath.Join("testdata", "sample_crd_with_example.yaml"))
require.NoError(t, err)

crd := &v1beta1.CustomResourceDefinition{}
require.NoError(t, yaml.Unmarshal(content, crd))

var output []byte
buffer := bytes.NewBuffer(output)

parser := NewParser(crd.Spec.Group, crd.Spec.Names.Kind, false, true)
version := crd.Spec.Versions[0]
require.NoError(t, parser.ParseProperties(version.Name, buffer, version.Schema.OpenAPIV3Schema.Properties, RootRequiredFields))

golden, err := os.ReadFile(filepath.Join("testdata", "sample_crd_with_minimal_example_with_example_for_field_golden.yaml"))
require.NoError(t, err)

assert.Equal(t, golden, buffer.Bytes())
}
25 changes: 25 additions & 0 deletions pkg/testdata/sample_crd_with_comments_golden.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# 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
apiVersion: delivery.krok.app/v1alpha1
# 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
kind: KrokCommand
metadata: {}
# KrokCommandSpec defines the desired state of KrokCommand
spec:
# CommandHasOutputToWrite if defined, it signals the underlying Job, to put its output into a generated and created secret.
commandHasOutputToWrite: true
# Dependencies defines a list of command names that this command depends on.
dependencies: ["string"]
# Enabled defines if this command can be executed or not.
enabled: true
# Image defines the image name and tag of the command example: krok-hook/slack-notification:v0.0.1
image: string
# Platforms holds all the platforms which this command supports.
platforms: ["string"]
# ReadInputFromSecret if defined, the command will take a list of key/value pairs in a secret and apply them as arguments to the command.
readInputFromSecret:
name: string
namespace: string
# Schedule of the command. example: 0 * * * * // follows cron job syntax.
schedule: string
# KrokCommandStatus defines the observed state of KrokCommand
status: {}
Loading

0 comments on commit f391280

Please sign in to comment.