diff --git a/.gitignore b/.gitignore index c3f0380..cef756f 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,6 @@ go.work.sum dist # openfeature cli config -.openfeature.yaml \ No newline at end of file +.openfeature.yaml + +.idea/ \ No newline at end of file diff --git a/cmd/generate.go b/cmd/generate.go index bca65d8..e327ce5 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -8,6 +8,7 @@ import ( "github.com/open-feature/cli/internal/generators" "github.com/open-feature/cli/internal/generators/csharp" "github.com/open-feature/cli/internal/generators/golang" + "github.com/open-feature/cli/internal/generators/java" "github.com/open-feature/cli/internal/generators/nestjs" "github.com/open-feature/cli/internal/generators/nodejs" "github.com/open-feature/cli/internal/generators/python" @@ -257,6 +258,58 @@ func getGenerateCSharpCmd() *cobra.Command { return csharpCmd } +func getGenerateJavaCmd() *cobra.Command { + javaCmd := &cobra.Command{ + Use: "java", + Short: "Generate typesafe Java client.", + Long: `Generate typesafe Java client compatible with the OpenFeature Java SDK.`, + Annotations: map[string]string{ + "stability": string(generators.Alpha), + }, + PreRunE: func(cmd *cobra.Command, args []string) error { + return initializeConfig(cmd, "generate.java") + }, + RunE: func(cmd *cobra.Command, args []string) error { + manifestPath := config.GetManifestPath(cmd) + javaPackageName := config.GetJavaPackageName(cmd) + outputPath := config.GetOutputPath(cmd) + + logger.Default.GenerationStarted("Java") + + params := generators.Params[java.Params]{ + OutputPath: outputPath, + Custom: java.Params{ + JavaPackage: javaPackageName, + }, + } + + flagset, err := flagset.Load(manifestPath) + if err != nil { + return err + } + + generator := java.NewGenerator(flagset) + logger.Default.Debug("Executing Java generator") + err = generator.Generate(¶ms) + if err != nil { + return err + } + + logger.Default.GenerationComplete("Java") + + return nil + + }, + } + + // Add Java specific flags + config.AddJavaGenerateFlags(javaCmd) + + addStabilityInfo(javaCmd) + + return javaCmd +} + func getGenerateGoCmd() *cobra.Command { goCmd := &cobra.Command{ Use: "go", @@ -355,6 +408,7 @@ func init() { generators.DefaultManager.Register(getGenerateGoCmd) generators.DefaultManager.Register(getGenerateNodeJSCmd) generators.DefaultManager.Register(getGeneratePythonCmd) - generators.DefaultManager.Register(getGenerateCSharpCmd) + generators.DefaultManager.Register(getGenerateCSharpCmd) generators.DefaultManager.Register(GetGenerateNestJsCmd) + generators.DefaultManager.Register(getGenerateJavaCmd) } diff --git a/cmd/generate_test.go b/cmd/generate_test.go index b2534d5..559bb88 100644 --- a/cmd/generate_test.go +++ b/cmd/generate_test.go @@ -21,7 +21,7 @@ type generateTestCase struct { outputGolden string // path to the golden output file outputPath string // output directory (optional, defaults to "output") outputFile string // output file name - packageName string // optional, used for Go (package-name) and C# (namespace) + packageName string // optional, used for Go (package-name), Java (package-name) and C# (namespace) } func TestGenerate(t *testing.T) { @@ -70,6 +70,14 @@ func TestGenerate(t *testing.T) { outputFile: "OpenFeature.g.cs", packageName: "TestNamespace", // Using packageName field for namespace }, + { + name: "Java generation success", + command: "java", + manifestGolden: "testdata/success_manifest.golden", + outputGolden: "testdata/success_java.golden", + outputFile: "OpenFeature.java", + packageName: "com.example.openfeature", + }, // Add more test cases here as needed } @@ -107,6 +115,8 @@ func TestGenerate(t *testing.T) { args = append(args, "--namespace", tc.packageName) } else if tc.command == "go" { args = append(args, "--package-name", tc.packageName) + } else if tc.command == "java" { + args = append(args, "--package-name", tc.packageName) } } @@ -146,6 +156,17 @@ func readOsFileAndWriteToMemMap(t *testing.T, inputPath string, memPath string, } } +// normalizeLines trims trailing whitespace and carriage returns from each line. +// This helps ensure consistent comparison by ignoring formatting differences like indentation or line endings. +func normalizeLines(input []string) []string { + normalized := make([]string, len(input)) + for i, line := range input { + // Trim right whitespace and convert \r\n or \r to \n + normalized[i] = strings.TrimRight(line, " \t\r") + } + return normalized +} + func compareOutput(t *testing.T, testFile, memoryOutputPath string, fs afero.Fs) { want, err := os.ReadFile(testFile) if err != nil { @@ -158,8 +179,8 @@ func compareOutput(t *testing.T, testFile, memoryOutputPath string, fs afero.Fs) } // Convert to string arrays by splitting on newlines - wantLines := strings.Split(string(want), "\n") - gotLines := strings.Split(string(got), "\n") + wantLines := normalizeLines(strings.Split(string(want), "\n")) + gotLines := normalizeLines(strings.Split(string(got), "\n")) if diff := cmp.Diff(wantLines, gotLines); diff != "" { t.Errorf("output mismatch (-want +got):\n%s", diff) diff --git a/cmd/testdata/success_java.golden b/cmd/testdata/success_java.golden new file mode 100644 index 0000000..8de4f45 --- /dev/null +++ b/cmd/testdata/success_java.golden @@ -0,0 +1,154 @@ +// AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. +package com.example.openfeature; + +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.OpenFeatureAPI; + +public final class Generated { + + private Generated() {} // prevent instantiation + + public interface GeneratedClient { + + /** + * Discount percentage applied to purchases. + * Details: + * - Flag key: discountPercentage + * - Type: Double + * - Default value: 0.15 + * Returns the flag value + */ + Double discountPercentage(EvaluationContext ctx); + + /** + * Discount percentage applied to purchases. + * Details: + * - Flag key: discountPercentage + * - Type: Double + * - Default value: 0.15 + * Returns the evaluation details containing the flag value and metadata + */ + FlagEvaluationDetails discountPercentageDetails(EvaluationContext ctx); + + /** + * Controls whether Feature A is enabled. + * Details: + * - Flag key: enableFeatureA + * - Type: Boolean + * - Default value: false + * Returns the flag value + */ + Boolean enableFeatureA(EvaluationContext ctx); + + /** + * Controls whether Feature A is enabled. + * Details: + * - Flag key: enableFeatureA + * - Type: Boolean + * - Default value: false + * Returns the evaluation details containing the flag value and metadata + */ + FlagEvaluationDetails enableFeatureADetails(EvaluationContext ctx); + + /** + * The message to use for greeting users. + * Details: + * - Flag key: greetingMessage + * - Type: String + * - Default value: Hello there! + * Returns the flag value + */ + String greetingMessage(EvaluationContext ctx); + + /** + * The message to use for greeting users. + * Details: + * - Flag key: greetingMessage + * - Type: String + * - Default value: Hello there! + * Returns the evaluation details containing the flag value and metadata + */ + FlagEvaluationDetails greetingMessageDetails(EvaluationContext ctx); + + /** + * Maximum allowed length for usernames. + * Details: + * - Flag key: usernameMaxLength + * - Type: Integer + * - Default value: 50 + * Returns the flag value + */ + Integer usernameMaxLength(EvaluationContext ctx); + + /** + * Maximum allowed length for usernames. + * Details: + * - Flag key: usernameMaxLength + * - Type: Integer + * - Default value: 50 + * Returns the evaluation details containing the flag value and metadata + */ + FlagEvaluationDetails usernameMaxLengthDetails(EvaluationContext ctx); + + } + + private static final class OpenFeatureGeneratedClient implements GeneratedClient { + private final Client client; + + private OpenFeatureGeneratedClient(Client client) { + this.client = client; + } + + + @Override + public Double discountPercentage(EvaluationContext ctx) { + return client.getDoubleValue("discountPercentage", 0.15, ctx); + } + + @Override + public FlagEvaluationDetails discountPercentageDetails(EvaluationContext ctx) { + return client.getDoubleDetails("discountPercentage", 0.15, ctx); + } + + @Override + public Boolean enableFeatureA(EvaluationContext ctx) { + return client.getBooleanValue("enableFeatureA", false, ctx); + } + + @Override + public FlagEvaluationDetails enableFeatureADetails(EvaluationContext ctx) { + return client.getBooleanDetails("enableFeatureA", false, ctx); + } + + @Override + public String greetingMessage(EvaluationContext ctx) { + return client.getStringValue("greetingMessage", "Hello there!", ctx); + } + + @Override + public FlagEvaluationDetails greetingMessageDetails(EvaluationContext ctx) { + return client.getStringDetails("greetingMessage", "Hello there!", ctx); + } + + @Override + public Integer usernameMaxLength(EvaluationContext ctx) { + return client.getIntegerValue("usernameMaxLength", 50, ctx); + } + + @Override + public FlagEvaluationDetails usernameMaxLengthDetails(EvaluationContext ctx) { + return client.getIntegerDetails("usernameMaxLength", 50, ctx); + } + + } + + public static GeneratedClient getClient() { + return new OpenFeatureGeneratedClient(OpenFeatureAPI.getInstance().getClient()); + } + + public static GeneratedClient getClient(String domain) { + return new OpenFeatureGeneratedClient(OpenFeatureAPI.getInstance().getClient(domain)); + } +} diff --git a/docs/commands/openfeature_generate.md b/docs/commands/openfeature_generate.md index 7402c7e..ce9b24d 100644 --- a/docs/commands/openfeature_generate.md +++ b/docs/commands/openfeature_generate.md @@ -28,6 +28,7 @@ openfeature generate [flags] * [openfeature](openfeature.md) - CLI for OpenFeature. * [openfeature generate csharp](openfeature_generate_csharp.md) - Generate typesafe C# client. * [openfeature generate go](openfeature_generate_go.md) - Generate typesafe accessors for OpenFeature. +* [openfeature generate java](openfeature_generate_java.md) - Generate typesafe Java client. * [openfeature generate nestjs](openfeature_generate_nestjs.md) - Generate typesafe NestJS decorators. * [openfeature generate nodejs](openfeature_generate_nodejs.md) - Generate typesafe Node.js client. * [openfeature generate python](openfeature_generate_python.md) - Generate typesafe Python client. diff --git a/docs/commands/openfeature_generate_java.md b/docs/commands/openfeature_generate_java.md new file mode 100644 index 0000000..a4c99a8 --- /dev/null +++ b/docs/commands/openfeature_generate_java.md @@ -0,0 +1,37 @@ + + +## openfeature generate java + +Generate typesafe Java client. + + +> **Stability**: alpha + +### Synopsis + +Generate typesafe Java client compatible with the OpenFeature Java SDK. + +``` +openfeature generate java [flags] +``` + +### Options + +``` + -h, --help help for java + --package-name string Name of the generated Java package (default "com.example.openfeature") +``` + +### Options inherited from parent commands + +``` + --debug Enable debug logging + -m, --manifest string Path to the flag manifest (default "flags.json") + --no-input Disable interactive prompts + -o, --output string Path to where the generated files should be saved +``` + +### SEE ALSO + +* [openfeature generate](openfeature_generate.md) - Generate typesafe OpenFeature accessors. + diff --git a/internal/config/flags.go b/internal/config/flags.go index 2bb5526..b4fd833 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -13,6 +13,7 @@ const ( GoPackageFlagName = "package-name" CSharpNamespaceName = "namespace" OverrideFlagName = "override" + JavaPackageFlagName = "package-name" ) // Default values for flags @@ -21,6 +22,7 @@ const ( DefaultOutputPath = "" DefaultGoPackageName = "openfeature" DefaultCSharpNamespace = "OpenFeature" + DefaultJavaPackageName = "com.example.openfeature" ) // AddRootFlags adds the common flags to the given command @@ -45,6 +47,11 @@ func AddCSharpGenerateFlags(cmd *cobra.Command) { cmd.Flags().String(CSharpNamespaceName, DefaultCSharpNamespace, "Namespace for the generated C# code") } +// AddJavaGenerateFlags adds the Java generator specific flags to the given command +func AddJavaGenerateFlags(cmd *cobra.Command) { + cmd.Flags().String(JavaPackageFlagName, DefaultJavaPackageName, "Name of the generated Java package") +} + // AddInitFlags adds the init command specific flags func AddInitFlags(cmd *cobra.Command) { cmd.Flags().Bool(OverrideFlagName, false, "Override an existing configuration") @@ -74,6 +81,12 @@ func GetCSharpNamespace(cmd *cobra.Command) string { return namespace } +// GetJavaPackageName gets the Java package name from the given command +func GetJavaPackageName(cmd *cobra.Command) string { + javaPackageName, _ := cmd.Flags().GetString(JavaPackageFlagName) + return javaPackageName +} + // GetNoInput gets the no-input flag from the given command func GetNoInput(cmd *cobra.Command) bool { noInput, _ := cmd.Flags().GetBool(NoInputFlagName) diff --git a/internal/generators/java/java.go b/internal/generators/java/java.go new file mode 100644 index 0000000..23ee8a5 --- /dev/null +++ b/internal/generators/java/java.go @@ -0,0 +1,74 @@ +package java + +import ( + _ "embed" + "fmt" + "text/template" + + "github.com/open-feature/cli/internal/flagset" + "github.com/open-feature/cli/internal/generators" +) + +type JavaGenerator struct { + generators.CommonGenerator +} + +type Params struct { + // Add Java parameters here if needed + JavaPackage string +} + +//go:embed java.tmpl +var javaTmpl string + +func openFeatureType(t flagset.FlagType) string { + switch t { + case flagset.IntType: + return "Integer" + case flagset.FloatType: + return "Double" //using Double as per openfeature Java-SDK + case flagset.BoolType: + return "Boolean" + case flagset.StringType: + return "String" + default: + return "" + } +} + +func formatDefaultValueForJava(flag flagset.Flag) string { + switch flag.Type { + case flagset.StringType: + return fmt.Sprintf("\"%s\"", flag.DefaultValue) + case flagset.BoolType: + if flag.DefaultValue == true { + return "true" + } + return "false" + default: + return fmt.Sprintf("%v", flag.DefaultValue) + } +} + +func (g *JavaGenerator) Generate(params *generators.Params[Params]) error { + funcs := template.FuncMap{ + "OpenFeatureType": openFeatureType, + "FormatDefaultValue": formatDefaultValueForJava, + } + + newParams := &generators.Params[any]{ + OutputPath: params.OutputPath, + Custom: params.Custom, + } + + return g.GenerateFile(funcs, javaTmpl, newParams, "OpenFeature.java") +} + +// NewGenerator creates a generator for Java. +func NewGenerator(fs *flagset.Flagset) *JavaGenerator { + return &JavaGenerator{ + CommonGenerator: *generators.NewGenerator(fs, map[flagset.FlagType]bool{ + flagset.ObjectType: true, + }), + } +} diff --git a/internal/generators/java/java.tmpl b/internal/generators/java/java.tmpl new file mode 100644 index 0000000..f15a601 --- /dev/null +++ b/internal/generators/java/java.tmpl @@ -0,0 +1,64 @@ +// AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. +package {{ .Params.Custom.JavaPackage }}; + +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.OpenFeatureAPI; + +public final class Generated { + + private Generated() {} // prevent instantiation + + public interface GeneratedClient { + {{ range .Flagset.Flags }} + /** + * {{ .Description }} + * Details: + * - Flag key: {{ .Key }} + * - Type: {{ .Type | OpenFeatureType }} + * - Default value: {{ .DefaultValue }} + * Returns the flag value + */ + {{ .Type | OpenFeatureType }} {{ .Key | ToCamel }}(EvaluationContext ctx); + + /** + * {{ .Description }} + * Details: + * - Flag key: {{ .Key }} + * - Type: {{ .Type | OpenFeatureType }} + * - Default value: {{ .DefaultValue }} + * Returns the evaluation details containing the flag value and metadata + */ + FlagEvaluationDetails<{{ .Type | OpenFeatureType }}> {{ .Key | ToCamel }}Details(EvaluationContext ctx); + {{ end }} + } + + private static final class OpenFeatureGeneratedClient implements GeneratedClient { + private final Client client; + + private OpenFeatureGeneratedClient(Client client) { + this.client = client; + } + + {{ range .Flagset.Flags }} + @Override + public {{ .Type | OpenFeatureType }} {{ .Key | ToCamel }}(EvaluationContext ctx) { + return client.get{{ .Type | OpenFeatureType | ToPascal }}Value("{{ .Key }}", {{ . | FormatDefaultValue }}, ctx); + } + + @Override + public FlagEvaluationDetails<{{ .Type | OpenFeatureType }}> {{ .Key | ToCamel }}Details(EvaluationContext ctx) { + return client.get{{ .Type | OpenFeatureType | ToPascal }}Details("{{ .Key }}", {{ . | FormatDefaultValue }}, ctx); + } + {{ end }} + } + + public static GeneratedClient getClient() { + return new OpenFeatureGeneratedClient(OpenFeatureAPI.getInstance().getClient()); + } + + public static GeneratedClient getClient(String domain) { + return new OpenFeatureGeneratedClient(OpenFeatureAPI.getInstance().getClient(domain)); + } +}