diff --git a/cmd/generate.go b/cmd/generate.go index 95e379c..104999b 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -16,6 +16,11 @@ import ( "github.com/Skarlso/crd-to-sample-yaml/pkg/fetcher" ) +const ( + FormatHTML = "html" + FormatYAML = "yaml" +) + var ( // generateCmd is root for various `generate ...` commands. generateCmd = &cobra.Command{ @@ -27,6 +32,7 @@ var ( fileLocation string url string output string + format string stdOut bool comments bool ) @@ -38,6 +44,7 @@ func init() { f.StringVarP(&fileLocation, "crd", "c", "", "The CRD file to generate a yaml from.") 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") } @@ -68,13 +75,14 @@ func runGenerate(_ *cobra.Command, _ []string) error { if err := yaml.Unmarshal(content, crd); err != nil { return errors.New("failed to unmarshal into custom resource definition") } + if stdOut { w = os.Stdout } else { if output == "" { output = filepath.Dir(fileLocation) } - outputLocation := filepath.Join(output, crd.Name+"_sample.yaml") + outputLocation := filepath.Join(output, crd.Name+"_sample."+format) outputFile, err := os.Create(outputLocation) if err != nil { return fmt.Errorf("failed to create file at: '%s': %w", outputLocation, err) @@ -82,5 +90,13 @@ func runGenerate(_ *cobra.Command, _ []string) error { w = outputFile } + if format == FormatHTML { + if err := pkg.LoadTemplates(); err != nil { + return fmt.Errorf("failed to load templates: %w", err) + } + + return pkg.RenderContent(w, content, comments) + } + return pkg.Generate(crd, w, comments) } diff --git a/pkg/create_html_output.go b/pkg/create_html_output.go new file mode 100644 index 0000000..b5d5c26 --- /dev/null +++ b/pkg/create_html_output.go @@ -0,0 +1,181 @@ +package pkg + +import ( + "bytes" + "embed" + "fmt" + "html/template" + "io" + "io/fs" + "sort" + + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + "k8s.io/apimachinery/pkg/util/yaml" +) + +// Version wraps a top level version resource which contains the underlying openAPIV3Schema. +type Version struct { + Version string + Kind string + Group string + Properties []*Property + Description string + YAML string +} + +// ViewPage is the template for view.html. +type ViewPage struct { + Versions []Version +} + +var ( + //go:embed templates + files embed.FS + templates map[string]*template.Template +) + +// LoadTemplates creates a map of loaded templates that are primed and ready to be rendered. +func LoadTemplates() error { + if templates == nil { + templates = make(map[string]*template.Template) + } + tmplFiles, err := fs.ReadDir(files, "templates") + if err != nil { + return err + } + + for _, tmpl := range tmplFiles { + if tmpl.IsDir() { + continue + } + pt, err := template.ParseFS(files, "templates/"+tmpl.Name()) + if err != nil { + return err + } + + templates[tmpl.Name()] = pt + } + + return nil +} + +// RenderContent creates an HTML website from the CRD content. +func RenderContent(w io.Writer, crdContent []byte, comments 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) + for _, version := range crd.Spec.Versions { + out, err := parseCRD(version.Schema.OpenAPIV3Schema.Properties, version.Name, version.Schema.OpenAPIV3Schema.Required) + 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 { + return fmt.Errorf("failed to generate yaml sample: %w", err) + } + versions = append(versions, Version{ + Version: version.Name, + Properties: out, + Kind: crd.Spec.Names.Kind, + Group: crd.Spec.Group, + Description: version.Schema.OpenAPIV3Schema.Description, + YAML: buf.String(), + }) + } + view := ViewPage{ + Versions: versions, + } + t := templates["view.html"] + if err := t.Execute(w, view); err != nil { + return fmt.Errorf("failed to execute template: %w", err) + } + + return nil +} + +// Property builds up a Tree structure of embedded things. +type Property struct { + Name string + Description string + Type string + Nullable bool + Patterns string + Format string + Indent int + Version string + Default string + Required bool + Properties []*Property +} + +// 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) { + output := make([]*Property, 0, len(properties)) + sortedKeys := make([]string, 0, len(properties)) + + for k := range properties { + sortedKeys = append(sortedKeys, k) + } + sort.Strings(sortedKeys) + + for _, k := range sortedKeys { + // 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. + // If not, or if we are done, add this new property to the list of properties and return it. + v := properties[k] + required := false + for _, item := range requiredList { + if item == k { + required = true + + break + } + } + p := &Property{ + Name: k, + Type: v.Type, + Description: v.Description, + Patterns: v.Pattern, + Format: v.Format, + Nullable: v.Nullable, + Version: version, + Required: required, + } + if v.Default != nil { + p.Default = string(v.Default.Raw) + } + + switch { + case len(properties[k].Properties) > 0 && properties[k].AdditionalProperties == nil: + requiredList = v.Required + out, err := parseCRD(properties[k].Properties, version, 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) + 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) + if err != nil { + return nil, err + } + p.Properties = out + } + + output = append(output, p) + } + + return output, nil +} diff --git a/pkg/generate.go b/pkg/generate.go index fa904da..1e91b99 100644 --- a/pkg/generate.go +++ b/pkg/generate.go @@ -10,6 +10,8 @@ import ( "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" ) +const array = "array" + // Generate takes a CRD content and path, and outputs. func Generate(crd *v1beta1.CustomResourceDefinition, w io.WriteCloser, enableComments bool) (err error) { defer func() { @@ -87,7 +89,7 @@ func ParseProperties(group, version, kind string, properties map[string]v1beta1. // 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 { + 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 { return err @@ -141,7 +143,7 @@ func outputValueType(v v1beta1.JSONSchemaProps) string { return "true" case "object": return "{}" - case "array": // deal with arrays of other types that weren't objects + case array: // deal with arrays of other types that weren't objects t := v.Items.Schema.Type var s string if t == st { diff --git a/pkg/templates/index.html b/pkg/templates/index.html deleted file mode 100644 index fbd730d..0000000 --- a/pkg/templates/index.html +++ /dev/null @@ -1,81 +0,0 @@ - - - -
- -