Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add json schema generation #119

Merged
merged 2 commits into from
Oct 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}