Skip to content

Commit

Permalink
refactor: change folder, package structure; integrate with cobra (#27)
Browse files Browse the repository at this point in the history
<!-- Please use this template for your pull request. -->
<!-- Please use the sections that you need and delete other sections -->

## This PR
<!-- add the description of the PR here -->

refactor: changes folder, package structure; integrates CLI with cobra

### Related Issues
<!-- add here the GitHub issue that this PR resolves if applicable -->

Fixes #20 (comment)

---------

Signed-off-by: Florin-Mihai Anghel <fanghel@google.com>
Signed-off-by: Florin-Mihai Anghel <44744433+anghelflorinm@users.noreply.github.com>
Co-authored-by: Michael Beemer <beeme1mr@users.noreply.github.com>
  • Loading branch information
anghelflorinm and beeme1mr authored Oct 10, 2024
1 parent 33efe94 commit 850c694
Show file tree
Hide file tree
Showing 16 changed files with 442 additions and 272 deletions.
29 changes: 29 additions & 0 deletions cmd/generate/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package generate

import (
"codegen/cmd/generate/golang"
"codegen/internal/flagkeys"

"github.com/spf13/cobra"
"github.com/spf13/viper"
)

// Root for `generate“ sub-commands, handling code generation for flag accessors.
var Root = &cobra.Command{
Use: "generate",
Short: "Code generation for flag accessors for OpenFeature.",
Long: `Code generation for flag accessors for OpenFeature.`,
}

func init() {
// Add subcommands.
Root.AddCommand(golang.Cmd)

// Add flags.
Root.PersistentFlags().String(flagkeys.FlagManifestPath, "", "Path to the flag manifest.")
Root.MarkPersistentFlagRequired(flagkeys.FlagManifestPath)
viper.BindPFlag(flagkeys.FlagManifestPath, Root.PersistentFlags().Lookup(flagkeys.FlagManifestPath))
Root.PersistentFlags().String(flagkeys.OutputPath, "", "Output path for the codegen")
viper.BindPFlag(flagkeys.OutputPath, Root.PersistentFlags().Lookup(flagkeys.OutputPath))
Root.MarkPersistentFlagRequired(flagkeys.OutputPath)
}
32 changes: 32 additions & 0 deletions cmd/generate/golang/golang.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package golang

import (
"codegen/internal/flagkeys"
"codegen/internal/generate"
"codegen/internal/generate/plugins/golang"

"github.com/spf13/cobra"
"github.com/spf13/viper"
)

// Cmd for `generate“ command, handling code generation for flag accessors
var Cmd = &cobra.Command{
Use: "go",
Short: "Generate Golang flag accessors for OpenFeature.",
Long: `Generate Golang flag accessors for OpenFeature.`,
RunE: func(cmd *cobra.Command, args []string) error {
params := golang.Params{
GoPackage: viper.GetString(flagkeys.GoPackageName),
}
gen := golang.NewGenerator(params)
err := generate.CreateFlagAccessors(gen)
return err
},
}

func init() {
Cmd.Flags().String(flagkeys.GoPackageName, "", "Name of the Go package to be generated.")
Cmd.MarkFlagRequired(flagkeys.GoPackageName)
viper.BindPFlag(flagkeys.GoPackageName, Cmd.Flags().Lookup(flagkeys.GoPackageName))

}
30 changes: 30 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package cmd

import (
"fmt"
"os"

"codegen/cmd/generate"

"github.com/spf13/cobra"
)

// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "openfeature",
Short: "CLI for OpenFeature.",
Long: `CLI for OpenFeature related functionalities.`,
}

// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}

func init() {
rootCmd.AddCommand(generate.Root)
}
24 changes: 24 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,31 @@ require (
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
)

require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.18.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

require (
github.com/go-logr/logr v1.4.2 // indirect
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
)
56 changes: 56 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,14 +1,70 @@
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI=
github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/open-feature/go-sdk v1.13.0 h1:D5NXPhhCL0SNR/DRvrTOm/xY7uE9m0zQQEttgKHlwtI=
github.com/open-feature/go-sdk v1.13.0/go.mod h1:poPa+RFCJumHcb59wgp+tnSyNvMU2C07ykFJ0gczyaM=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4=
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
14 changes: 14 additions & 0 deletions internal/flagkeys/flagkeys.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Package commonflags contains keys for all command-line flags related to openfeature CLI.
package flagkeys

const (
// `generate` flags:
// FlagManifestPath is the key for the flag that stores the flag manifest path.
FlagManifestPath = "flag_manifest_path"
// OutputPath is the key for the flag that stores the output path.
OutputPath = "output_path"

// `generate go` flags:
// GoPackageName is the key for the flag that stores the Golang package name.
GoPackageName = "package_name"
)
61 changes: 61 additions & 0 deletions internal/generate/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Package generate contains the top level functions used for generating flag accessors.
package generate

import (
"bytes"
"codegen/internal/flagkeys"
"codegen/internal/generate/manifestutils"
"codegen/internal/generate/types"
"fmt"
"os"
"path"
"path/filepath"
"text/template"

"github.com/spf13/viper"
)

// GenerateFile receives data for the Go template engine and outputs the contents to the file.
// Intended to be invoked by each language generator with appropiate data.
func GenerateFile(funcs template.FuncMap, contents string, data types.TmplDataInterface) error {
contentsTmpl, err := template.New("contents").Funcs(funcs).Parse(contents)
if err != nil {
return fmt.Errorf("error initializing template: %v", err)
}

var buf bytes.Buffer
if err := contentsTmpl.Execute(&buf, data); err != nil {
return fmt.Errorf("error executing template: %v", err)
}
outputPath := data.BaseTmplDataInfo().OutputPath
if err := os.MkdirAll(filepath.Dir(outputPath), 0770); err != nil {
return err
}
f, err := os.Create(path.Join(outputPath))
if err != nil {
return fmt.Errorf("error creating file %q: %v", outputPath, err)
}
defer f.Close()
writtenBytes, err := f.Write(buf.Bytes())
if err != nil {
return fmt.Errorf("error writing contents to file %q: %v", outputPath, err)
}
if writtenBytes != buf.Len() {
return fmt.Errorf("error writing entire file %v: writtenBytes != expectedWrittenBytes", outputPath)
}

return nil
}

// Takes as input a generator and outputs file with the appropiate flag accessors.
// The flag data is taken from the provided flag manifest.
func CreateFlagAccessors(gen types.Generator) error {
bt, err := manifestutils.LoadData(viper.GetString(flagkeys.FlagManifestPath), gen.SupportedFlagTypes())
if err != nil {
return fmt.Errorf("error loading flag manifest: %v", err)
}
input := types.Input{
BaseData: bt,
}
return gen.Generate(input)
}
105 changes: 105 additions & 0 deletions internal/generate/manifestutils/manifestutils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Package manifestutils contains useful functions for loading the flag manifest.
package manifestutils

import (
flagmanifest "codegen/docs/schema/v0"
"codegen/internal/flagkeys"
"codegen/internal/generate/types"
"encoding/json"
"fmt"
"os"
"strconv"

"github.com/santhosh-tekuri/jsonschema/v5"
"github.com/spf13/viper"
)

// LoadData loads the data from the flag manifest.
func LoadData(manifestPath string, supportedFlagTypes map[types.FlagType]bool) (*types.BaseTmplData, error) {
data, err := os.ReadFile(manifestPath)
if err != nil {
return nil, fmt.Errorf("error reading contents from file %q", manifestPath)
}
unfilteredData, err := unmarshalFlagManifest(data)
if err != nil {
return nil, err
}

filteredData := filterUnsupportedFlags(unfilteredData, supportedFlagTypes)

return filteredData, nil
}

func filterUnsupportedFlags(unfilteredData *types.BaseTmplData, supportedFlagTypes map[types.FlagType]bool) *types.BaseTmplData {
filteredData := &types.BaseTmplData{
OutputPath: unfilteredData.OutputPath,
}
for _, flagData := range unfilteredData.Flags {
if supportedFlagTypes[flagData.Type] {
filteredData.Flags = append(filteredData.Flags, flagData)
}
}
return filteredData
}

var stringToFlagType = map[string]types.FlagType{
"string": types.StringType,
"boolean": types.BoolType,
"float": types.FloatType,
"integer": types.IntType,
"object": types.ObjectType,
}

func getDefaultValue(defaultValue interface{}, flagType types.FlagType) string {
switch flagType {
case types.BoolType:
return strconv.FormatBool(defaultValue.(bool))
case types.IntType:
//the conversion to float64 instead of integer typically occurs
//due to how JSON is parsed in Go. In Go's encoding/json package,
//all JSON numbers are unmarshaled into float64 by default when decoding into an interface{}.
return strconv.FormatFloat(defaultValue.(float64), 'g', -1, 64)
case types.FloatType:
return strconv.FormatFloat(defaultValue.(float64), 'g', -1, 64)
case types.StringType:
return defaultValue.(string)
default:
return ""
}
}

func unmarshalFlagManifest(data []byte) (*types.BaseTmplData, error) {
dynamic := make(map[string]interface{})
err := json.Unmarshal(data, &dynamic)
if err != nil {
return nil, fmt.Errorf("error unmarshalling JSON: %v", err)
}

sch, err := jsonschema.CompileString(flagmanifest.SchemaPath, flagmanifest.Schema)
if err != nil {
return nil, fmt.Errorf("error compiling JSON schema: %v", err)
}
if err = sch.Validate(dynamic); err != nil {
return nil, fmt.Errorf("error validating JSON schema: %v", err)
}
// All casts can be done directly since the JSON is already validated by the schema.
iFlags := dynamic["flags"]
flags := iFlags.(map[string]interface{})
btData := types.BaseTmplData{
OutputPath: viper.GetString(flagkeys.OutputPath),
}
for flagKey, iFlagData := range flags {
flagData := iFlagData.(map[string]interface{})
flagTypeString := flagData["flagType"].(string)
flagType := stringToFlagType[flagTypeString]
docs := flagData["description"].(string)
defaultValue := getDefaultValue(flagData["defaultValue"], flagType)
btData.Flags = append(btData.Flags, &types.FlagTmplData{
Name: flagKey,
Type: flagType,
DefaultValue: defaultValue,
Docs: docs,
})
}
return &btData, nil
}
Loading

0 comments on commit 850c694

Please sign in to comment.