From 53240fe92403bab92660a08b0f3cc9f9867016f3 Mon Sep 17 00:00:00 2001 From: Jason Salaber Date: Tue, 11 Mar 2025 12:34:34 -0400 Subject: [PATCH 1/5] feat: added functionality to create yaml config file and preliminary pull command Signed-off-by: Jason Salaber --- .gitignore | 6 +++- cmd/init.go | 20 +++++++++++++ cmd/pull.go | 48 +++++++++++++++++++++++++++++++ cmd/root.go | 1 + go.sum | 2 ++ internal/filesystem/filesystem.go | 30 +++++++++++++++++++ 6 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 cmd/pull.go diff --git a/.gitignore b/.gitignore index c3f0380..b549f7c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ *.dll *.so *.dylib +cli # Test binary, built with `go test -c` *.test @@ -26,4 +27,7 @@ go.work.sum dist # openfeature cli config -.openfeature.yaml \ No newline at end of file +.openfeature.yaml + +# generated files from running the CLI +flags.json diff --git a/cmd/init.go b/cmd/init.go index d650940..4276776 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -22,6 +22,7 @@ func GetInitCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { manifestPath := config.GetManifestPath(cmd) override := config.GetOverride(cmd) + flagSourceUrl, _ := cmd.Flags().GetString("flagSourceUrl") manifestExists, _ := filesystem.Exists(manifestPath) if manifestExists && !override { @@ -45,6 +46,25 @@ func GetInitCmd() *cobra.Command { return err } + configFileExists, _ := filesystem.Exists(".openfeature.yaml") + if !configFileExists { + err = filesystem.WriteFile(".openfeature.yaml", []byte("")) + if err != nil { + return err + } + } + + if flagSourceUrl != "" { + pterm.Info.Println("Writing flag source URL to .openfeature.yaml", pterm.LightWhite(flagSourceUrl)) + err = filesystem.WriteFile(".openfeature.yaml", []byte("flagSourceUrl: " + flagSourceUrl)) + if err != nil { + return err + } + } + + pterm.Info.Printfln("Manifest created at %s", pterm.LightWhite(manifestPath)) + pterm.Success.Println("Project initialized.") + logger.Default.FileCreated(manifestPath) logger.Default.Success("Project initialized.") return nil diff --git a/cmd/pull.go b/cmd/pull.go new file mode 100644 index 0000000..b8431d8 --- /dev/null +++ b/cmd/pull.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "fmt" + + "github.com/open-feature/cli/internal/filesystem" + "github.com/spf13/cobra" +) + +func GetPullCmd() *cobra.Command { + pullCmd := &cobra.Command{ + Use: "pull", + Short: "Pull a flag manifest from a remote source", + Long: "Pull a flag manifest from a remote source.", + PreRunE: func(cmd *cobra.Command, args []string) error { + return initializeConfig(cmd, "pull") + }, + RunE: func(cmd *cobra.Command, args []string) error { + flagSourceUrl, err := cmd.Flags().GetString("flagSourceUrl") + if err != nil { + flagSourceUrl, err = filesystem.GetFromYaml("flagSourceUrl") + if err != nil { + return fmt.Errorf("error getting flagSourceUrl from config: %w", err) + } + } + + fmt.Println(flagSourceUrl) + + // // fetch the flags from the remote source + // resp, err := http.Get(flagSourceUrl) + // if err != nil { + // return fmt.Errorf("error fetching flags: %w", err) + // } + // defer resp.Body.Close() + + // flags, err := io.ReadAll(resp.Body) + // if err != nil { + // return fmt.Errorf("error reading response body: %w", err) + // } + + return nil + }, + } + + pullCmd.Flags().String("flagSourceUrl", "", "The URL of the flag source") + + return pullCmd +} diff --git a/cmd/root.go b/cmd/root.go index 4c9c3e4..195077f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -62,6 +62,7 @@ func GetRootCmd() *cobra.Command { rootCmd.AddCommand(GetVersionCmd()) rootCmd.AddCommand(GetInitCmd()) rootCmd.AddCommand(GetGenerateCmd()) + rootCmd.AddCommand(GetPullCmd()) // Add a custom error handler after the command is created rootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { diff --git a/go.sum b/go.sum index 993563b..0fa9af6 100644 --- a/go.sum +++ b/go.sum @@ -176,6 +176,8 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogR gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/filesystem/filesystem.go b/internal/filesystem/filesystem.go index cf7fad0..7f66613 100644 --- a/internal/filesystem/filesystem.go +++ b/internal/filesystem/filesystem.go @@ -8,10 +8,15 @@ import ( "github.com/spf13/afero" "github.com/spf13/viper" + "gopkg.in/yaml.v3" ) var viperKey = "filesystem" +type Config struct { + FlagSourceUrl string `yaml:"flagSourceUrl"` +} + // Get the filesystem interface from the viper configuration. // If the filesystem interface is not set, the default filesystem interface is returned. func FileSystem() afero.Fs { @@ -48,6 +53,31 @@ func WriteFile(path string, data []byte) error { return nil } +func ReadFile(path string) ([]byte, error) { + fs := FileSystem() + return afero.ReadFile(fs, path) +} + +func GetFromYaml(key string) (string, error) { + var config Config + fs, err := ReadFile(".openfeature.yaml") + if err != nil { + return "", err + } + + err = yaml.Unmarshal(fs, &config) + if err != nil { + return "", err + } + + switch key { + case "flagSourceUrl": + return config.FlagSourceUrl, nil + default: + return "", fmt.Errorf("unknown key: %s", key) + } +} + // Checks if a file exists at the given path using the filesystem interface. func Exists(path string) (bool, error) { fs := FileSystem() From a7ca9eae7a55e6b45b49a86c9e2f846d4c2e9829 Mon Sep 17 00:00:00 2001 From: Jason Salaber Date: Wed, 12 Mar 2025 11:24:33 -0400 Subject: [PATCH 2/5] feat: added prompts for default values if not defined Signed-off-by: Jason Salaber --- cmd/generate.go | 8 +-- cmd/init.go | 2 +- cmd/pull.go | 87 +++++++++++++++++++++++----- go.sum | 2 - internal/config/flags.go | 21 +++++++ internal/flagset/flagset.go | 71 ++++++++++++++--------- internal/generators/react/react.tmpl | 5 +- internal/manifest/manage.go | 48 +++++++++++++++ internal/requests/fetchFlags.go | 42 ++++++++++++++ 9 files changed, 235 insertions(+), 51 deletions(-) create mode 100644 internal/requests/fetchFlags.go diff --git a/cmd/generate.go b/cmd/generate.go index bca65d8..afa98df 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -4,7 +4,6 @@ import ( "strings" "github.com/open-feature/cli/internal/config" - "github.com/open-feature/cli/internal/flagset" "github.com/open-feature/cli/internal/generators" "github.com/open-feature/cli/internal/generators/csharp" "github.com/open-feature/cli/internal/generators/golang" @@ -13,6 +12,7 @@ import ( "github.com/open-feature/cli/internal/generators/python" "github.com/open-feature/cli/internal/generators/react" "github.com/open-feature/cli/internal/logger" + "github.com/open-feature/cli/internal/manifest" "github.com/spf13/cobra" ) @@ -86,7 +86,7 @@ func getGenerateNodeJSCmd() *cobra.Command { OutputPath: outputPath, Custom: nodejs.Params{}, } - flagset, err := flagset.Load(manifestPath) + flagset, err := manifest.LoadFlagSet(manifestPath) if err != nil { return err } @@ -130,7 +130,7 @@ func getGenerateReactCmd() *cobra.Command { OutputPath: outputPath, Custom: react.Params{}, } - flagset, err := flagset.Load(manifestPath) + flagset, err := manifest.LoadFlagSet(manifestPath) if err != nil { return err } @@ -282,7 +282,7 @@ func getGenerateGoCmd() *cobra.Command { }, } - flagset, err := flagset.Load(manifestPath) + flagset, err := manifest.LoadFlagSet(manifestPath) if err != nil { return err } diff --git a/cmd/init.go b/cmd/init.go index 4276776..949bf63 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -22,7 +22,7 @@ func GetInitCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { manifestPath := config.GetManifestPath(cmd) override := config.GetOverride(cmd) - flagSourceUrl, _ := cmd.Flags().GetString("flagSourceUrl") + flagSourceUrl := config.GetFlagSourceUrl(cmd) manifestExists, _ := filesystem.Exists(manifestPath) if manifestExists && !override { diff --git a/cmd/pull.go b/cmd/pull.go index b8431d8..6be32db 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -1,12 +1,61 @@ package cmd import ( + "errors" "fmt" + "strconv" + "github.com/open-feature/cli/internal/config" "github.com/open-feature/cli/internal/filesystem" + "github.com/open-feature/cli/internal/flagset" + "github.com/open-feature/cli/internal/manifest" + "github.com/open-feature/cli/internal/requests" + "github.com/pterm/pterm" "github.com/spf13/cobra" ) +func promptForDefaultValue(flag *flagset.Flag) (any) { + var prompt string + switch flag.Type { + case flagset.BoolType: + var options []string = []string{"false", "true"} + prompt = fmt.Sprintf("Enter default value for flag '%s' (%s)", flag.Key, flag.Type) + boolStr, _ := pterm.DefaultInteractiveSelect.WithOptions(options).WithFilter(false).Show(prompt) + boolValue, _ := strconv.ParseBool(boolStr) + return boolValue + case flagset.IntType: + var err error = errors.New("Input a valid integer") + prompt = fmt.Sprintf("Enter default value for flag '%s' (%s)", flag.Key, flag.Type) + var defaultValue int + for err != nil { + defaultValueString, _ := pterm.DefaultInteractiveTextInput.WithDefaultText("0").Show(prompt) + defaultValue, err = strconv.Atoi(defaultValueString) + } + return defaultValue + case flagset.FloatType: + var err error = errors.New("Input a valid float") + prompt = fmt.Sprintf("Enter default value for flag '%s' (%s)", flag.Key, flag.Type) + var defaultValue float64 + for err != nil { + defaultValueString, _ := pterm.DefaultInteractiveTextInput.WithDefaultText("0.0").Show(prompt) + defaultValue, err = strconv.ParseFloat(defaultValueString, 64) + if err != nil { + pterm.Error.Println("Input a valid float") + } + } + return defaultValue + case flagset.StringType: + prompt = fmt.Sprintf("Enter default value for flag '%s' (%s)", flag.Key, flag.Type) + defaultValue, _ := pterm.DefaultInteractiveTextInput.WithDefaultText("").Show(prompt) + return defaultValue + // TODO: Add proper support for object type + case flagset.ObjectType: + return map[string]any{} + default: + return nil + } +} + func GetPullCmd() *cobra.Command { pullCmd := &cobra.Command{ Use: "pull", @@ -16,33 +65,43 @@ func GetPullCmd() *cobra.Command { return initializeConfig(cmd, "pull") }, RunE: func(cmd *cobra.Command, args []string) error { - flagSourceUrl, err := cmd.Flags().GetString("flagSourceUrl") - if err != nil { + flagSourceUrl := config.GetFlagSourceUrl(cmd) + manifestPath := config.GetManifestPath(cmd) + authToken := config.GetAuthToken(cmd) + + var err error + if flagSourceUrl == "" { flagSourceUrl, err = filesystem.GetFromYaml("flagSourceUrl") if err != nil { return fmt.Errorf("error getting flagSourceUrl from config: %w", err) } } - fmt.Println(flagSourceUrl) + // fetch the flags from the remote source + flags, err := requests.FetchFlags(flagSourceUrl, authToken) + if err != nil { + return fmt.Errorf("error fetching flags: %w", err) + } - // // fetch the flags from the remote source - // resp, err := http.Get(flagSourceUrl) - // if err != nil { - // return fmt.Errorf("error fetching flags: %w", err) - // } - // defer resp.Body.Close() + // Check each flag for null defaultValue + for index, flag := range flags.Flags { + if flag.DefaultValue == nil { + defaultValue := promptForDefaultValue(&flag) + flags.Flags[index].DefaultValue = defaultValue + } + } - // flags, err := io.ReadAll(resp.Body) - // if err != nil { - // return fmt.Errorf("error reading response body: %w", err) - // } + pterm.Success.Printf("Successfully fetched flags from %s", flagSourceUrl) + err = manifest.Write(manifestPath, flags) + if err != nil { + return fmt.Errorf("error writing manifest: %w", err) + } return nil }, } - pullCmd.Flags().String("flagSourceUrl", "", "The URL of the flag source") + config.AddPullFlags(pullCmd) return pullCmd } diff --git a/go.sum b/go.sum index 0fa9af6..993563b 100644 --- a/go.sum +++ b/go.sum @@ -176,8 +176,6 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogR gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/config/flags.go b/internal/config/flags.go index 2bb5526..fc6bd3e 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -13,6 +13,8 @@ const ( GoPackageFlagName = "package-name" CSharpNamespaceName = "namespace" OverrideFlagName = "override" + FlagSourceUrlFlagName = "flag-source-url" + AuthTokenFlagName = "auth-token" ) // Default values for flags @@ -48,6 +50,13 @@ func AddCSharpGenerateFlags(cmd *cobra.Command) { // AddInitFlags adds the init command specific flags func AddInitFlags(cmd *cobra.Command) { cmd.Flags().Bool(OverrideFlagName, false, "Override an existing configuration") + cmd.Flags().String(FlagSourceUrlFlagName, "", "The URL of the flag source") +} + +// AddPullFlags adds the pull command specific flags +func AddPullFlags(cmd *cobra.Command) { + cmd.Flags().String(FlagSourceUrlFlagName, "", "The URL of the flag source") + cmd.Flags().String(AuthTokenFlagName, "", "The auth token for the flag source") } // GetManifestPath gets the manifest path from the given command @@ -85,3 +94,15 @@ func GetOverride(cmd *cobra.Command) bool { override, _ := cmd.Flags().GetBool(OverrideFlagName) return override } + +// GetFlagSourceUrl gets the flag source URL from the given command +func GetFlagSourceUrl(cmd *cobra.Command) string { + flagSourceUrl, _ := cmd.Flags().GetString(FlagSourceUrlFlagName) + return flagSourceUrl +} + +// GetAuthToken gets the auth token from the given command +func GetAuthToken(cmd *cobra.Command) string { + authToken, _ := cmd.Flags().GetString(AuthTokenFlagName) + return authToken +} diff --git a/internal/flagset/flagset.go b/internal/flagset/flagset.go index 33d3155..e173ac4 100644 --- a/internal/flagset/flagset.go +++ b/internal/flagset/flagset.go @@ -5,10 +5,6 @@ import ( "errors" "fmt" "sort" - - "github.com/open-feature/cli/internal/filesystem" - "github.com/open-feature/cli/internal/manifest" - "github.com/spf13/afero" ) // FlagType are the primitive types of flags. @@ -31,7 +27,7 @@ func (f FlagType) String() string { case FloatType: return "float" case BoolType: - return "bool" + return "boolean" case StringType: return "string" case ObjectType: @@ -52,29 +48,6 @@ type Flagset struct { Flags []Flag } -// Loads, validates, and unmarshals the manifest file at the given path into a flagset -func Load(manifestPath string) (*Flagset, error) { - fs := filesystem.FileSystem() - data, err := afero.ReadFile(fs, manifestPath) - if err != nil { - return nil, fmt.Errorf("error reading contents from file %q", manifestPath) - } - - validationErrors, err := manifest.Validate(data) - if err != nil { - return nil, err - } else if len(validationErrors) > 0 { - return nil, fmt.Errorf("validation failed: %v", validationErrors) - } - - var flagset Flagset - if err := json.Unmarshal(data, &flagset); err != nil { - return nil, fmt.Errorf("error unmarshaling JSON: %v", validationErrors) - } - - return &flagset, nil -} - // Filter removes flags from the Flagset that are of unsupported types. func (fs *Flagset) Filter(unsupportedFlagTypes map[FlagType]bool) *Flagset { var filtered Flagset @@ -132,3 +105,45 @@ func (fs *Flagset) UnmarshalJSON(data []byte) error { return nil } + +func LoadFromSourceFlags(data []byte) (*[]Flag, error) { + type SourceFlag struct { + Key string `json:"key"` + Type string `json:"type"` + Description string `json:"description"` + DefaultValue any `json:"defaultValue"` + } + + var sourceFlags []SourceFlag + if err := json.Unmarshal(data, &sourceFlags); err != nil { + return nil, err + } + + var flags []Flag + for _, sf := range sourceFlags { + var flagType FlagType + switch sf.Type { + case "integer", "Integer": + flagType = IntType + case "float", "Float", "Number": + flagType = FloatType + case "boolean", "bool", "Boolean": + flagType = BoolType + case "string", "String": + flagType = StringType + case "object", "Object", "JSON": + flagType = ObjectType + default: + return nil, fmt.Errorf("unknown flag type: %s", sf.Type) + } + + flags = append(flags, Flag{ + Key: sf.Key, + Type: flagType, + Description: sf.Description, + DefaultValue: sf.DefaultValue, + }) + } + + return &flags, nil +} diff --git a/internal/generators/react/react.tmpl b/internal/generators/react/react.tmpl index e1c40cf..bb8f7e1 100644 --- a/internal/generators/react/react.tmpl +++ b/internal/generators/react/react.tmpl @@ -3,6 +3,7 @@ import { type ReactFlagEvaluationOptions, type ReactFlagEvaluationNoSuspenseOptions, + type FlagQuery, useFlag, useSuspenseFlag, } from "@openfeature/react-sdk"; @@ -15,7 +16,7 @@ import { * - default value: `{{ .DefaultValue }}` * - type: `{{ .Type | OpenFeatureType }}` */ -export const use{{ .Key | ToPascal }} = (options?: ReactFlagEvaluationOptions) => { +export const use{{ .Key | ToPascal }} = (options?: ReactFlagEvaluationOptions): FlagQuery<{{ .Type | OpenFeatureType }}> => { return useFlag({{ .Key | Quote }}, {{ .DefaultValue | QuoteString }}, options); }; @@ -30,7 +31,7 @@ export const use{{ .Key | ToPascal }} = (options?: ReactFlagEvaluationOptions) = * Equivalent to useFlag with options: `{ suspend: true }` * @experimental — Suspense is an experimental feature subject to change in future versions. */ -export const useSuspense{{ .Key | ToPascal }} = (options?: ReactFlagEvaluationNoSuspenseOptions) => { +export const useSuspense{{ .Key | ToPascal }} = (options?: ReactFlagEvaluationNoSuspenseOptions): FlagQuery<{{ .Type | OpenFeatureType }}> => { return useSuspenseFlag({{ .Key | Quote }}, {{ .DefaultValue | QuoteString }}, options); }; {{ end}} \ No newline at end of file diff --git a/internal/manifest/manage.go b/internal/manifest/manage.go index 5dacbea..34b18ae 100644 --- a/internal/manifest/manage.go +++ b/internal/manifest/manage.go @@ -2,8 +2,11 @@ package manifest import ( "encoding/json" + "fmt" "github.com/open-feature/cli/internal/filesystem" + "github.com/open-feature/cli/internal/flagset" + "github.com/spf13/afero" ) type initManifest struct { @@ -25,3 +28,48 @@ func Create(path string) error { } return filesystem.WriteFile(path, formattedInitManifest) } + +// Loads, validates, and unmarshals the manifest file at the given path into a flagset +func LoadFlagSet(manifestPath string) (*flagset.Flagset, error) { + fs := filesystem.FileSystem() + data, err := afero.ReadFile(fs, manifestPath) + if err != nil { + return nil, fmt.Errorf("error reading contents from file %q", manifestPath) + } + + validationErrors, err := Validate(data) + if err != nil { + return nil, err + } else if len(validationErrors) > 0 { + // TODO tease running manifest validate command + return nil, fmt.Errorf("validation failed: %v", validationErrors) + } + + var flagset flagset.Flagset + if err := json.Unmarshal(data, &flagset); err != nil { + return nil, fmt.Errorf("error unmarshaling JSON: %v", validationErrors) + } + + return &flagset, nil +} + +func Write(path string, flagset flagset.Flagset) error { + m := &initManifest{ + Schema: "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag_manifest.json", + Manifest: Manifest{ + Flags: map[string]any{}, + }, + } + for _, flag := range flagset.Flags { + m.Manifest.Flags[flag.Key] = map[string]any{ + "flagType": flag.Type.String(), + "description": flag.Description, + "defaultValue": flag.DefaultValue, + } + } + formattedInitManifest, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + return filesystem.WriteFile(path, formattedInitManifest) +} \ No newline at end of file diff --git a/internal/requests/fetchFlags.go b/internal/requests/fetchFlags.go new file mode 100644 index 0000000..ea91860 --- /dev/null +++ b/internal/requests/fetchFlags.go @@ -0,0 +1,42 @@ +package requests + +import ( + "fmt" + "io" + "net/http" + + "github.com/open-feature/cli/internal/flagset" +) + +func FetchFlags(flagSourceUrl string, authToken string) (flagset.Flagset, error) { + flags := flagset.Flagset{} + req, err := http.NewRequest("GET", flagSourceUrl, nil) + if err != nil { + return flags, err + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", authToken)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return flags, err + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return flags, err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return flags, fmt.Errorf("Received error response from flag source: %s", string(body)) + } + + loadedFlags, err := flagset.LoadFromSourceFlags(body) + if err != nil { + return flags, err + } + flags.Flags = *loadedFlags + + + return flags, nil +} From 8c2aec6c1f04bc415a1cb28bb5dc5f6b1ae59e66 Mon Sep 17 00:00:00 2001 From: Jason Salaber Date: Tue, 18 Mar 2025 10:20:47 -0400 Subject: [PATCH 3/5] chore: conditionally add auth header if auth token is not empty Signed-off-by: Jason Salaber --- cmd/pull.go | 6 +----- internal/config/flags.go | 12 ++++++++++++ internal/filesystem/filesystem.go | 21 --------------------- internal/requests/fetchFlags.go | 4 +++- 4 files changed, 16 insertions(+), 27 deletions(-) diff --git a/cmd/pull.go b/cmd/pull.go index 6be32db..cc3b18b 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -6,7 +6,6 @@ import ( "strconv" "github.com/open-feature/cli/internal/config" - "github.com/open-feature/cli/internal/filesystem" "github.com/open-feature/cli/internal/flagset" "github.com/open-feature/cli/internal/manifest" "github.com/open-feature/cli/internal/requests" @@ -71,10 +70,7 @@ func GetPullCmd() *cobra.Command { var err error if flagSourceUrl == "" { - flagSourceUrl, err = filesystem.GetFromYaml("flagSourceUrl") - if err != nil { - return fmt.Errorf("error getting flagSourceUrl from config: %w", err) - } + return fmt.Errorf("flagSourceUrl not set in config") } // fetch the flags from the remote source diff --git a/internal/config/flags.go b/internal/config/flags.go index fc6bd3e..5ae49e9 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -2,6 +2,7 @@ package config import ( "github.com/spf13/cobra" + "github.com/spf13/viper" ) // Flag name constants to avoid duplication @@ -98,6 +99,17 @@ func GetOverride(cmd *cobra.Command) bool { // GetFlagSourceUrl gets the flag source URL from the given command func GetFlagSourceUrl(cmd *cobra.Command) string { flagSourceUrl, _ := cmd.Flags().GetString(FlagSourceUrlFlagName) + if flagSourceUrl == "" { + viper.SetConfigName(".openfeature") + viper.AddConfigPath(".") + if err := viper.ReadInConfig(); err != nil { + return "" + } + if !viper.IsSet("flagSourceUrl") { + return "" + } + flagSourceUrl = viper.GetString("flagSourceUrl") + } return flagSourceUrl } diff --git a/internal/filesystem/filesystem.go b/internal/filesystem/filesystem.go index 7f66613..4d6a14b 100644 --- a/internal/filesystem/filesystem.go +++ b/internal/filesystem/filesystem.go @@ -8,7 +8,6 @@ import ( "github.com/spf13/afero" "github.com/spf13/viper" - "gopkg.in/yaml.v3" ) var viperKey = "filesystem" @@ -58,26 +57,6 @@ func ReadFile(path string) ([]byte, error) { return afero.ReadFile(fs, path) } -func GetFromYaml(key string) (string, error) { - var config Config - fs, err := ReadFile(".openfeature.yaml") - if err != nil { - return "", err - } - - err = yaml.Unmarshal(fs, &config) - if err != nil { - return "", err - } - - switch key { - case "flagSourceUrl": - return config.FlagSourceUrl, nil - default: - return "", fmt.Errorf("unknown key: %s", key) - } -} - // Checks if a file exists at the given path using the filesystem interface. func Exists(path string) (bool, error) { fs := FileSystem() diff --git a/internal/requests/fetchFlags.go b/internal/requests/fetchFlags.go index ea91860..81c8259 100644 --- a/internal/requests/fetchFlags.go +++ b/internal/requests/fetchFlags.go @@ -15,7 +15,9 @@ func FetchFlags(flagSourceUrl string, authToken string) (flagset.Flagset, error) return flags, err } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", authToken)) + if authToken != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", authToken)) + } resp, err := http.DefaultClient.Do(req) if err != nil { From ffeb339c5d6ce3a9d57aa581468b8b828a9f57ec Mon Sep 17 00:00:00 2001 From: Jason Salaber Date: Tue, 18 Mar 2025 12:18:30 -0400 Subject: [PATCH 4/5] chore: added tests for pull command Signed-off-by: Jason Salaber --- cmd/pull_test.go | 117 +++++++++++++++++++++++++++++ cmd/testdata/empty_manifest.golden | 3 + cmd/testdata/success_react.golden | 17 +++-- go.mod | 2 + go.sum | 5 ++ 5 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 cmd/pull_test.go create mode 100644 cmd/testdata/empty_manifest.golden diff --git a/cmd/pull_test.go b/cmd/pull_test.go new file mode 100644 index 0000000..38195b3 --- /dev/null +++ b/cmd/pull_test.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "encoding/json" + "testing" + + "github.com/h2non/gock" + "github.com/open-feature/cli/internal/config" + "github.com/open-feature/cli/internal/filesystem" + + "github.com/spf13/afero" + + "github.com/stretchr/testify/assert" +) + +func setupTest(t *testing.T) (afero.Fs) { + fs := afero.NewMemMapFs() + filesystem.SetFileSystem(fs) + readOsFileAndWriteToMemMap(t, "testdata/empty_manifest.golden", "manifest/path.json", fs) + return fs +} + +func TestPull(t *testing.T) { + t.Run("pull no flag source url", func(t *testing.T) { + setupTest(t) + cmd := GetPullCmd() + + // Prepare command arguments + args := []string{ + "pull", + } + + cmd.SetArgs(args) + + // Run command + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "flagSourceUrl not set in config") + }) + + t.Run("pull with flag source url", func(t *testing.T) { + fs := setupTest(t) + defer gock.Off() + + flags := []map[string]any{ + {"key": "testFlag", "type": "boolean", "defaultValue": true}, + {"key": "testFlag2", "type": "string", "defaultValue": "string value"}, + } + + gock.New("https://example.com/flags"). + Get("/"). + Reply(200). + JSON(flags) + + cmd := GetPullCmd() + + // global flag exists on root only. + config.AddRootFlags(cmd) + + // Prepare command arguments + args := []string{ + "pull", + "--flag-source-url", "https://example.com/flags", + "--manifest", "manifest/path.json", + } + + cmd.SetArgs(args) + + + // Run command + err := cmd.Execute() + assert.NoError(t, err) + + // check if the file content is correct + content, err := afero.ReadFile(fs, "manifest/path.json") + assert.NoError(t, err) + + var manifestFlags map[string]interface{} + err = json.Unmarshal(content, &manifestFlags) + assert.NoError(t, err) + + // Compare actual content with expected flags + for _, flag := range flags { + flagKey := flag["key"].(string) + _, exists := manifestFlags["flags"].(map[string]interface{})[flagKey] + assert.True(t, exists, "Flag %s should exist in manifest", flagKey) + } + }) + + t.Run("error when endpoint returns error", func(t *testing.T) { + setupTest(t) + defer gock.Off() + + gock.New("https://example.com/flags"). + Get("/"). + Reply(404) + + cmd := GetPullCmd() + + // global flag exists on root only. + config.AddRootFlags(cmd) + + // Prepare command arguments + args := []string{ + "pull", + "--flag-source-url", "https://example.com/flags", + "--manifest", "manifest/path.json", + } + + cmd.SetArgs(args) + + // Run command + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "Received error response from flag source") + }) +} diff --git a/cmd/testdata/empty_manifest.golden b/cmd/testdata/empty_manifest.golden new file mode 100644 index 0000000..fad4c2a --- /dev/null +++ b/cmd/testdata/empty_manifest.golden @@ -0,0 +1,3 @@ +{ + "flags": {} +} \ No newline at end of file diff --git a/cmd/testdata/success_react.golden b/cmd/testdata/success_react.golden index c00172f..ce12c7f 100644 --- a/cmd/testdata/success_react.golden +++ b/cmd/testdata/success_react.golden @@ -3,6 +3,7 @@ import { type ReactFlagEvaluationOptions, type ReactFlagEvaluationNoSuspenseOptions, + type FlagQuery, useFlag, useSuspenseFlag, } from "@openfeature/react-sdk"; @@ -15,7 +16,7 @@ import { * - default value: `0.15` * - type: `number` */ -export const useDiscountPercentage = (options?: ReactFlagEvaluationOptions) => { +export const useDiscountPercentage = (options?: ReactFlagEvaluationOptions): FlagQuery => { return useFlag("discountPercentage", 0.15, options); }; @@ -30,7 +31,7 @@ export const useDiscountPercentage = (options?: ReactFlagEvaluationOptions) => { * Equivalent to useFlag with options: `{ suspend: true }` * @experimental — Suspense is an experimental feature subject to change in future versions. */ -export const useSuspenseDiscountPercentage = (options?: ReactFlagEvaluationNoSuspenseOptions) => { +export const useSuspenseDiscountPercentage = (options?: ReactFlagEvaluationNoSuspenseOptions): FlagQuery => { return useSuspenseFlag("discountPercentage", 0.15, options); }; @@ -42,7 +43,7 @@ export const useSuspenseDiscountPercentage = (options?: ReactFlagEvaluationNoSus * - default value: `false` * - type: `boolean` */ -export const useEnableFeatureA = (options?: ReactFlagEvaluationOptions) => { +export const useEnableFeatureA = (options?: ReactFlagEvaluationOptions): FlagQuery => { return useFlag("enableFeatureA", false, options); }; @@ -57,7 +58,7 @@ export const useEnableFeatureA = (options?: ReactFlagEvaluationOptions) => { * Equivalent to useFlag with options: `{ suspend: true }` * @experimental — Suspense is an experimental feature subject to change in future versions. */ -export const useSuspenseEnableFeatureA = (options?: ReactFlagEvaluationNoSuspenseOptions) => { +export const useSuspenseEnableFeatureA = (options?: ReactFlagEvaluationNoSuspenseOptions): FlagQuery => { return useSuspenseFlag("enableFeatureA", false, options); }; @@ -69,7 +70,7 @@ export const useSuspenseEnableFeatureA = (options?: ReactFlagEvaluationNoSuspens * - default value: `Hello there!` * - type: `string` */ -export const useGreetingMessage = (options?: ReactFlagEvaluationOptions) => { +export const useGreetingMessage = (options?: ReactFlagEvaluationOptions): FlagQuery => { return useFlag("greetingMessage", "Hello there!", options); }; @@ -84,7 +85,7 @@ export const useGreetingMessage = (options?: ReactFlagEvaluationOptions) => { * Equivalent to useFlag with options: `{ suspend: true }` * @experimental — Suspense is an experimental feature subject to change in future versions. */ -export const useSuspenseGreetingMessage = (options?: ReactFlagEvaluationNoSuspenseOptions) => { +export const useSuspenseGreetingMessage = (options?: ReactFlagEvaluationNoSuspenseOptions): FlagQuery => { return useSuspenseFlag("greetingMessage", "Hello there!", options); }; @@ -96,7 +97,7 @@ export const useSuspenseGreetingMessage = (options?: ReactFlagEvaluationNoSuspen * - default value: `50` * - type: `number` */ -export const useUsernameMaxLength = (options?: ReactFlagEvaluationOptions) => { +export const useUsernameMaxLength = (options?: ReactFlagEvaluationOptions): FlagQuery => { return useFlag("usernameMaxLength", 50, options); }; @@ -111,6 +112,6 @@ export const useUsernameMaxLength = (options?: ReactFlagEvaluationOptions) => { * Equivalent to useFlag with options: `{ suspend: true }` * @experimental — Suspense is an experimental feature subject to change in future versions. */ -export const useSuspenseUsernameMaxLength = (options?: ReactFlagEvaluationNoSuspenseOptions) => { +export const useSuspenseUsernameMaxLength = (options?: ReactFlagEvaluationNoSuspenseOptions): FlagQuery => { return useSuspenseFlag("usernameMaxLength", 50, options); }; diff --git a/go.mod b/go.mod index 7178c5a..e647ad1 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,8 @@ require ( github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/gookit/color v1.5.4 // indirect + github.com/h2non/gock v1.2.0 // indirect + github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/mailru/easyjson v0.7.7 // indirect diff --git a/go.sum b/go.sum index 993563b..6de13d3 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,10 @@ github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQ github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= +github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= +github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= 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= @@ -66,6 +70,7 @@ github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= From e4adc07912f568e0391bf1da307b996126710873 Mon Sep 17 00:00:00 2001 From: Jason Salaber Date: Tue, 8 Apr 2025 11:46:39 -0400 Subject: [PATCH 5/5] feat: added ability to pull from local file for source flags Signed-off-by: Jason Salaber --- cmd/pull.go | 24 ++++++++++++++++++++++- internal/flagset/flagset.go | 7 +++++-- sample/sample_source_flags.json | 34 +++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 sample/sample_source_flags.json diff --git a/cmd/pull.go b/cmd/pull.go index cc3b18b..2352a14 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -4,8 +4,10 @@ import ( "errors" "fmt" "strconv" + "strings" "github.com/open-feature/cli/internal/config" + "github.com/open-feature/cli/internal/filesystem" "github.com/open-feature/cli/internal/flagset" "github.com/open-feature/cli/internal/manifest" "github.com/open-feature/cli/internal/requests" @@ -73,8 +75,28 @@ func GetPullCmd() *cobra.Command { return fmt.Errorf("flagSourceUrl not set in config") } + flags := flagset.Flagset{} // fetch the flags from the remote source - flags, err := requests.FetchFlags(flagSourceUrl, authToken) + // Check if the URL is a local file path + if strings.HasPrefix(flagSourceUrl, "file://") { + localPath := strings.TrimPrefix(flagSourceUrl, "file://") + var data, err = filesystem.ReadFile(localPath) + if err != nil { + return fmt.Errorf("error reading local flags file: %w", err) + } + loadedFlags, err := flagset.LoadFromSourceFlags(data) + if err != nil { + return fmt.Errorf("error loading flags from local file: %w", err) + } + flags.Flags = *loadedFlags + } else if strings.HasPrefix(flagSourceUrl, "http://") && !strings.HasPrefix(flagSourceUrl, "https://") { + flags, err = requests.FetchFlags(flagSourceUrl, authToken) + if err != nil { + return fmt.Errorf("error reading local flags file: %w", err) + } + return nil + } + if err != nil { return fmt.Errorf("error fetching flags: %w", err) } diff --git a/internal/flagset/flagset.go b/internal/flagset/flagset.go index e173ac4..34ffae7 100644 --- a/internal/flagset/flagset.go +++ b/internal/flagset/flagset.go @@ -114,13 +114,16 @@ func LoadFromSourceFlags(data []byte) (*[]Flag, error) { DefaultValue any `json:"defaultValue"` } - var sourceFlags []SourceFlag + var sourceFlags struct { + Flags []SourceFlag `json:"flags"` + } + if err := json.Unmarshal(data, &sourceFlags); err != nil { return nil, err } var flags []Flag - for _, sf := range sourceFlags { + for _, sf := range sourceFlags.Flags { var flagType FlagType switch sf.Type { case "integer", "Integer": diff --git a/sample/sample_source_flags.json b/sample/sample_source_flags.json new file mode 100644 index 0000000..fcfd3b1 --- /dev/null +++ b/sample/sample_source_flags.json @@ -0,0 +1,34 @@ +{ + "flags": [ + { + "key": "flag1", + "type": "boolean", + "description": "A boolean flag", + "defaultValue": false + }, + { + "key": "flag2", + "type": "string", + "description": "A string flag", + "defaultValue": "default" + }, + { + "key": "flag3", + "type": "integer", + "description": "An integer flag", + "defaultValue": 0 + }, + { + "key": "flag4", + "type": "float", + "description": "A float flag", + "defaultValue": 0.0 + }, + { + "key": "flag5", + "type": "object", + "description": "An object flag", + "defaultValue": {} + } + ] +}