Skip to content

Commit

Permalink
feat: add json schema generation (#119)
Browse files Browse the repository at this point in the history
* feat: add json schema generation

* tidy up the command line arguments and flags for the crd and schema generate commands
  • Loading branch information
Skarlso authored Oct 13, 2024
1 parent b288180 commit 8f109c1
Show file tree
Hide file tree
Showing 3 changed files with 221 additions and 123 deletions.
147 changes: 147 additions & 0 deletions cmd/crd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package cmd

import (
"errors"
"fmt"
"io"
"os"
"path/filepath"

"github.com/spf13/cobra"
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"

"github.com/Skarlso/crd-to-sample-yaml/pkg"
)

const (
FormatHTML = "html"
FormatYAML = "yaml"
)

// crdCmd is the command that generates CRD output.
var crdCmd = &cobra.Command{
Use: "crd",
Short: "Simply generate a CRD output.",
RunE: runGenerate,
}

type crdGenArgs struct {
comments bool
minimal bool
skipRandom bool
output string
format string
stdOut bool
}

var crdArgs = &crdGenArgs{}

type Handler interface {
CRDs() ([]*v1beta1.CustomResourceDefinition, error)
}

func init() {
generateCmd.AddCommand(crdCmd)
f := crdCmd.PersistentFlags()
f.BoolVarP(&crdArgs.comments, "comments", "m", false, "If set, it will add descriptions as comments to each line where available.")
f.BoolVarP(&crdArgs.minimal, "minimal", "l", false, "If set, only the minimal required example yaml is generated.")
f.BoolVar(&crdArgs.skipRandom, "no-random", false, "Skip generating random values that satisfy the property patterns.")
f.StringVarP(&crdArgs.output, "output", "o", "", "The location of the output file. Default is next to the CRD.")
f.StringVarP(&crdArgs.format, "format", "f", FormatYAML, "The format in which to output. Default is YAML. Options are: yaml, html.")
f.BoolVarP(&crdArgs.stdOut, "stdout", "s", false, "If set, it will output the generated content to stdout.")
}

func runGenerate(_ *cobra.Command, _ []string) error {
crdHandler, err := constructHandler(args)
if err != nil {
return err
}

if crdArgs.format == FormatHTML {
if err := pkg.LoadTemplates(); err != nil {
return fmt.Errorf("failed to load templates: %w", err)
}
}

// determine location of output
if crdArgs.output == "" {
loc, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to determine executable location: %w", err)
}

crdArgs.output = filepath.Dir(loc)
}

crds, err := crdHandler.CRDs()
if err != nil {
return fmt.Errorf("failed to load CRDs: %w", err)
}

var w io.WriteCloser

if crdArgs.format == FormatHTML {
if crdArgs.stdOut {
w = os.Stdout
} else {
w, err = os.Create(crdArgs.output)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}

defer func() {
if err := w.Close(); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "failed to close output file: %s", err.Error())
}
}()
}

return pkg.RenderContent(w, crds, crdArgs.comments, crdArgs.minimal)
}

var errs []error //nolint:prealloc // nope
for _, crd := range crds {
if crdArgs.stdOut {
w = os.Stdout
} else {
outputLocation := filepath.Join(crdArgs.output, crd.Name+"_sample."+crdArgs.format)
// closed later during render
outputFile, err := os.Create(outputLocation)
if err != nil {
errs = append(errs, fmt.Errorf("failed to create file at: '%s': %w", outputLocation, err))

continue
}

w = outputFile
}

errs = append(errs, pkg.Generate(crd, w, crdArgs.comments, crdArgs.minimal, crdArgs.skipRandom))
}

return errors.Join(errs...)
}

func constructHandler(args *rootArgs) (Handler, error) {
var crdHandler Handler

switch {
case args.fileLocation != "":
crdHandler = &FileHandler{location: args.fileLocation}
case args.folderLocation != "":
crdHandler = &FolderHandler{location: args.folderLocation}
case args.url != "":
crdHandler = &URLHandler{
url: args.url,
username: args.username,
password: args.password,
token: args.token,
}
}

if crdHandler == nil {
return nil, errors.New("one of the flags (file, folder, url) must be set")
}

return crdHandler, nil
}
124 changes: 1 addition & 123 deletions cmd/generate.go
Original file line number Diff line number Diff line change
@@ -1,21 +1,7 @@
package cmd

import (
"errors"
"fmt"
"io"
"os"
"path/filepath"

"github.com/spf13/cobra"
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"

"github.com/Skarlso/crd-to-sample-yaml/pkg"
)

const (
FormatHTML = "html"
FormatYAML = "yaml"
)

type rootArgs struct {
Expand All @@ -25,134 +11,26 @@ type rootArgs struct {
username string
password string
token string
output string
format string
stdOut bool
comments bool
minimal bool
skipRandom bool
}

var (
// generateCmd is root for various `generate ...` commands.
generateCmd = &cobra.Command{
Use: "generate",
Short: "Simply generate a CRD output.",
RunE: runGenerate,
}

args = &rootArgs{}
)

type Handler interface {
CRDs() ([]*v1beta1.CustomResourceDefinition, error)
}

func init() {
rootCmd.AddCommand(generateCmd)

// using persistent flags so all flags will be available for all sub commands.
f := generateCmd.PersistentFlags()
f.StringVarP(&args.fileLocation, "crd", "c", "", "The CRD file to generate a yaml from.")
f.StringVarP(&args.folderLocation, "folder", "r", "", "A folder from which to parse a series of CRDs.")
f.StringVarP(&args.url, "url", "u", "", "If provided, will use this URL to fetch CRD YAML content from.")
f.StringVar(&args.username, "username", "", "Optional username to authenticate a URL.")
f.StringVar(&args.password, "password", "", "Optional password to authenticate a URL.")
f.StringVar(&args.token, "token", "", "A bearer token to authenticate a URL.")
f.StringVarP(&args.output, "output", "o", "", "The location of the output file. Default is next to the CRD.")
f.StringVarP(&args.format, "format", "f", FormatYAML, "The format in which to output. Default is YAML. Options are: yaml, html.")
f.BoolVarP(&args.stdOut, "stdout", "s", false, "If set, it will output the generated content to stdout.")
f.BoolVarP(&args.comments, "comments", "m", false, "If set, it will add descriptions as comments to each line where available.")
f.BoolVarP(&args.minimal, "minimal", "l", false, "If set, only the minimal required example yaml is generated.")
f.BoolVar(&args.skipRandom, "no-random", false, "Skip generating random values that satisfy the property patterns.")
}

func runGenerate(_ *cobra.Command, _ []string) error {
crdHandler, err := constructHandler(args)
if err != nil {
return err
}

if args.format == FormatHTML {
if err := pkg.LoadTemplates(); err != nil {
return fmt.Errorf("failed to load templates: %w", err)
}
}

// determine location of output
if args.output == "" {
loc, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to determine executable location: %w", err)
}

args.output = filepath.Dir(loc)
}

crds, err := crdHandler.CRDs()
if err != nil {
return fmt.Errorf("failed to load CRDs: %w", err)
}

var w io.WriteCloser

if args.format == FormatHTML {
if args.stdOut {
w = os.Stdout
} else {
w, err = os.Create(args.output)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}

defer w.Close()
}

return pkg.RenderContent(w, crds, args.comments, args.minimal)
}

var errs []error //nolint:prealloc // nope
for _, crd := range crds {
if args.stdOut {
w = os.Stdout
} else {
outputLocation := filepath.Join(args.output, crd.Name+"_sample."+args.format)
// closed later during render
outputFile, err := os.Create(outputLocation)
if err != nil {
errs = append(errs, fmt.Errorf("failed to create file at: '%s': %w", outputLocation, err))

continue
}

w = outputFile
}

errs = append(errs, pkg.Generate(crd, w, args.comments, args.minimal, args.skipRandom))
}

return errors.Join(errs...)
}

func constructHandler(args *rootArgs) (Handler, error) {
var crdHandler Handler

switch {
case args.fileLocation != "":
crdHandler = &FileHandler{location: args.fileLocation}
case args.folderLocation != "":
crdHandler = &FolderHandler{location: args.folderLocation}
case args.url != "":
crdHandler = &URLHandler{
url: args.url,
username: args.username,
password: args.password,
token: args.token,
}
}

if crdHandler == nil {
return nil, errors.New("one of the flags (file, folder, url) must be set")
}

return crdHandler, nil
}
73 changes: 73 additions & 0 deletions cmd/schema.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package cmd

import (
"fmt"
"os"
"path/filepath"

"github.com/spf13/cobra"
"k8s.io/apimachinery/pkg/util/json"
)

// schemaCmd is a command that can generate json schemas.
var schemaCmd = &cobra.Command{
Use: "schema",
Short: "Simply generate a JSON schema from the CRD.",
RunE: runGenerateSchema,
}

type schemaCmdArgs struct {
outputFolder string
}

var schemaArgs = &schemaCmdArgs{}

func init() {
generateCmd.AddCommand(schemaCmd)
f := schemaCmd.PersistentFlags()
f.StringVarP(&schemaArgs.outputFolder, "output", "o", ".", "output location of the generated schema files")
}

func runGenerateSchema(_ *cobra.Command, _ []string) error {
crdHandler, err := constructHandler(args)
if err != nil {
return err
}

// determine location of output
if schemaArgs.outputFolder == "" {
loc, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to determine executable location: %w", err)
}

schemaArgs.outputFolder = filepath.Dir(loc)
}

crds, err := crdHandler.CRDs()
if err != nil {
return fmt.Errorf("failed to load CRDs: %w", err)
}

for _, crd := range crds {
for _, v := range crd.Spec.Versions {
if v.Schema.OpenAPIV3Schema.ID == "" {
v.Schema.OpenAPIV3Schema.ID = "https://crdtoyaml.com/" + crd.Spec.Names.Kind + "." + crd.Spec.Group + "." + v.Name + ".schema.json"
}
if v.Schema.OpenAPIV3Schema.Schema == "" {
v.Schema.OpenAPIV3Schema.Schema = "https://json-schema.org/draft/2020-12/schema"
}
content, err := json.Marshal(v.Schema.OpenAPIV3Schema)
if err != nil {
return fmt.Errorf("failed to marshal schema: %w", err)
}

const perm = 0o600
if err := os.WriteFile(filepath.Join(schemaArgs.outputFolder, crd.Spec.Names.Kind+"."+crd.Spec.Group+"."+v.Name+".schema.json"), content, perm); err != nil {
return fmt.Errorf("failed to write schema: %w", err)
}
}
}

return nil
}

0 comments on commit 8f109c1

Please sign in to comment.