diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index 15098d9..050257d 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -43,4 +43,21 @@ jobs: echo "::error file=Makefile::Doc generation produced diff. Run 'make generate-docs' and commit results." git diff exit 1 - fi \ No newline at end of file + fi + + csharp-test: + name: 'C# Generator Test' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + + - name: Set up Docker + uses: docker/setup-buildx-action@v2 + + - name: 'Run C# integration test' + run: ./test/csharp-integration/test-compilation.sh \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f64d84..4f0c385 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,6 +32,23 @@ We welcome contributions for new generators to extend the functionality of the O 11. **Address Feedback**: Be responsive to feedback from the maintainers. Make any necessary changes and update your pull request as needed. +### Integration Tests + +To verify that generated code compiles correctly, the project includes integration tests, for example, for c#: + +```bash +# Test the C# generator output +make test-csharp +``` + +This will: +1. Build the CLI +2. Generate a C# client +3. Compile the C# code in a Docker container +4. Validate that the code compiles correctly + +Consider adding more integration tests for new generators. + ## Templates ### Data diff --git a/Makefile b/Makefile index 010c97e..5e87ad0 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,11 @@ test: @go test -v ./... @echo "Tests passed successfully!" +.PHONY: test-csharp +test-csharp: + @echo "Running C# integration test..." + @./test/csharp-integration/test-compilation.sh + generate-docs: @echo "Generating documentation..." @go run ./docs/generate-commands.go diff --git a/cmd/config.go b/cmd/config.go index 54dc44b..c2665c3 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -18,7 +18,7 @@ func initializeConfig(cmd *cobra.Command, bindPrefix string) error { // Set the config file name and path v.SetConfigName(".openfeature") v.AddConfigPath(".") - + logger.Default.Debug("Looking for .openfeature config file in current directory") // Read the config file @@ -31,7 +31,6 @@ func initializeConfig(cmd *cobra.Command, bindPrefix string) error { } else { logger.Default.Debug(fmt.Sprintf("Using config file: %s", v.ConfigFileUsed())) } - // Track which flags were set directly via command line cmdLineFlags := make(map[string]bool) @@ -50,11 +49,11 @@ func initializeConfig(cmd *cobra.Command, bindPrefix string) error { // Build configuration paths from most specific to least specific configPaths := []string{} - + // Check the most specific path (e.g., generate.go.package-name) if bindPrefix != "" { - configPaths = append(configPaths, bindPrefix + "." + f.Name) - + configPaths = append(configPaths, bindPrefix+"."+f.Name) + // Check parent paths (e.g., generate.package-name) parts := strings.Split(bindPrefix, ".") for i := len(parts) - 1; i > 0; i-- { @@ -62,12 +61,12 @@ func initializeConfig(cmd *cobra.Command, bindPrefix string) error { configPaths = append(configPaths, parentPath) } } - + // Check the base path (e.g., package-name) configPaths = append(configPaths, f.Name) - + logger.Default.Debug(fmt.Sprintf("Looking for config value for flag %s in paths: %s", f.Name, strings.Join(configPaths, ", "))) - + // Try each path in order until we find a match for _, path := range configPaths { if v.IsSet(path) { @@ -81,7 +80,7 @@ func initializeConfig(cmd *cobra.Command, bindPrefix string) error { } } } - + // Log the final value for the flag logger.Default.Debug(fmt.Sprintf("Final flag value: %s=%s", f.Name, f.Value.String())) }) diff --git a/cmd/generate.go b/cmd/generate.go index 8a8b600..d89f9b4 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -6,6 +6,7 @@ import ( "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" "github.com/open-feature/cli/internal/generators/nodejs" "github.com/open-feature/cli/internal/generators/python" @@ -14,6 +15,32 @@ import ( "github.com/spf13/cobra" ) +func GetGenerateCmd() *cobra.Command { + generateCmd := &cobra.Command{ + Use: "generate", + Short: "Generate typesafe OpenFeature accessors.", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return initializeConfig(cmd, "generate") + }, + RunE: func(cmd *cobra.Command, args []string) error { + cmd.Println("Available generators:") + return generators.DefaultManager.PrintGeneratorsTable() + }, + } + + // Add generate flags using the config package + config.AddGenerateFlags(generateCmd) + + // Add all registered generator commands + for _, subCmd := range generators.DefaultManager.GetCommands() { + generateCmd.AddCommand(subCmd) + } + + addStabilityInfo(generateCmd) + + return generateCmd +} + // addStabilityInfo adds stability information to the command's help template before "Usage:" func addStabilityInfo(cmd *cobra.Command) { // Only modify commands that have a stability annotation @@ -37,7 +64,7 @@ func addStabilityInfo(cmd *cobra.Command) { } } -func GetGenerateNodeJSCmd() *cobra.Command { +func getGenerateNodeJSCmd() *cobra.Command { nodeJSCmd := &cobra.Command{ Use: "nodejs", Short: "Generate typesafe Node.js client.", @@ -69,9 +96,9 @@ func GetGenerateNodeJSCmd() *cobra.Command { if err != nil { return err } - + logger.Default.GenerationComplete("Node.js") - + return nil }, } @@ -81,7 +108,7 @@ func GetGenerateNodeJSCmd() *cobra.Command { return nodeJSCmd } -func GetGenerateReactCmd() *cobra.Command { +func getGenerateReactCmd() *cobra.Command { reactCmd := &cobra.Command{ Use: "react", Short: "Generate typesafe React Hooks.", @@ -95,7 +122,7 @@ func GetGenerateReactCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { manifestPath := config.GetManifestPath(cmd) outputPath := config.GetOutputPath(cmd) - + logger.Default.GenerationStarted("React") params := generators.Params[react.Params]{ @@ -113,9 +140,9 @@ func GetGenerateReactCmd() *cobra.Command { if err != nil { return err } - + logger.Default.GenerationComplete("React") - + return nil }, } @@ -125,7 +152,57 @@ func GetGenerateReactCmd() *cobra.Command { return reactCmd } -func GetGenerateGoCmd() *cobra.Command { +func getGenerateCSharpCmd() *cobra.Command { + csharpCmd := &cobra.Command{ + Use: "csharp", + Short: "Generate typesafe C# client.", + Long: `Generate typesafe C# client compatible with the OpenFeature .NET SDK.`, + Annotations: map[string]string{ + "stability": string(generators.Alpha), + }, + PreRunE: func(cmd *cobra.Command, args []string) error { + return initializeConfig(cmd, "generate.csharp") + }, + RunE: func(cmd *cobra.Command, args []string) error { + namespace := config.GetCSharpNamespace(cmd) + manifestPath := config.GetManifestPath(cmd) + outputPath := config.GetOutputPath(cmd) + + logger.Default.GenerationStarted("C#") + + params := generators.Params[csharp.Params]{ + OutputPath: outputPath, + Custom: csharp.Params{ + Namespace: namespace, + }, + } + flagset, err := flagset.Load(manifestPath) + if err != nil { + return err + } + + generator := csharp.NewGenerator(flagset) + logger.Default.Debug("Executing C# generator") + err = generator.Generate(¶ms) + if err != nil { + return err + } + + logger.Default.GenerationComplete("C#") + + return nil + }, + } + + // Add C#-specific flags + config.AddCSharpGenerateFlags(csharpCmd) + + addStabilityInfo(csharpCmd) + + return csharpCmd +} + +func getGenerateGoCmd() *cobra.Command { goCmd := &cobra.Command{ Use: "go", Short: "Generate typesafe accessors for OpenFeature.", @@ -140,7 +217,7 @@ func GetGenerateGoCmd() *cobra.Command { goPackageName := config.GetGoPackageName(cmd) manifestPath := config.GetManifestPath(cmd) outputPath := config.GetOutputPath(cmd) - + logger.Default.GenerationStarted("Go") params := generators.Params[golang.Params]{ @@ -161,9 +238,9 @@ func GetGenerateGoCmd() *cobra.Command { if err != nil { return err } - + logger.Default.GenerationComplete("Go") - + return nil }, } @@ -219,34 +296,9 @@ func getGeneratePythonCmd() *cobra.Command { func init() { // Register generators with the manager - generators.DefaultManager.Register(GetGenerateReactCmd) - generators.DefaultManager.Register(GetGenerateGoCmd) - generators.DefaultManager.Register(GetGenerateNodeJSCmd) + generators.DefaultManager.Register(getGenerateReactCmd) + generators.DefaultManager.Register(getGenerateGoCmd) + generators.DefaultManager.Register(getGenerateNodeJSCmd) generators.DefaultManager.Register(getGeneratePythonCmd) -} - -func GetGenerateCmd() *cobra.Command { - generateCmd := &cobra.Command{ - Use: "generate", - Short: "Generate typesafe OpenFeature accessors.", - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - return initializeConfig(cmd, "generate") - }, - RunE: func(cmd *cobra.Command, args []string) error { - cmd.Println("Available generators:") - return generators.DefaultManager.PrintGeneratorsTable() - }, - } - - // Add generate flags using the config package - config.AddGenerateFlags(generateCmd) - - // Add all registered generator commands - for _, subCmd := range generators.DefaultManager.GetCommands() { - generateCmd.AddCommand(subCmd) - } - - addStabilityInfo(generateCmd) - - return generateCmd + generators.DefaultManager.Register(getGenerateCSharpCmd) } diff --git a/cmd/generate_test.go b/cmd/generate_test.go index b7db5d1..0bc8466 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, only used for Go + packageName string // optional, used for Go (package-name) and C# (namespace) } func TestGenerate(t *testing.T) { @@ -55,6 +55,14 @@ func TestGenerate(t *testing.T) { outputGolden: "testdata/success_python.golden", outputFile: "openfeature.py", }, + { + name: "CSharp generation success", + command: "csharp", + manifestGolden: "testdata/success_manifest.golden", + outputGolden: "testdata/success_csharp.golden", + outputFile: "OpenFeature.g.cs", + packageName: "TestNamespace", // Using packageName field for namespace + }, // Add more test cases here as needed } @@ -67,7 +75,7 @@ func TestGenerate(t *testing.T) { // Constant paths const memoryManifestPath = "manifest/path.json" - + // Use default output path if not specified outputPath := tc.outputPath if outputPath == "" { @@ -86,9 +94,13 @@ func TestGenerate(t *testing.T) { "--output", outputPath, } - // Add package name if provided (for Go) + // Add parameters specific to each generator if tc.packageName != "" { - args = append(args, "--package-name", tc.packageName) + if tc.command == "csharp" { + args = append(args, "--namespace", tc.packageName) + } else if tc.command == "go" { + args = append(args, "--package-name", tc.packageName) + } } cmd.SetArgs(args) @@ -107,7 +119,7 @@ func TestGenerate(t *testing.T) { func readOsFileAndWriteToMemMap(t *testing.T, inputPath string, memPath string, memFs afero.Fs) { data, err := os.ReadFile(inputPath) - if (err != nil) { + if err != nil { t.Fatalf("error reading file %q: %v", inputPath, err) } if err := memFs.MkdirAll(filepath.Dir(memPath), os.ModePerm); err != nil { @@ -137,11 +149,11 @@ func compareOutput(t *testing.T, testFile, memoryOutputPath string, fs afero.Fs) if err != nil { t.Fatalf("error reading file %q: %v", memoryOutputPath, err) } - + // Convert to string arrays by splitting on newlines wantLines := strings.Split(string(want), "\n") gotLines := 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/init.go b/cmd/init.go index 9ca5bf5..d650940 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -34,7 +34,7 @@ func GetInitCmd() *cobra.Command { logger.Default.Info("No changes were made.") return nil } - + logger.Default.Debug("User confirmed override of existing manifest") } @@ -44,7 +44,7 @@ func GetInitCmd() *cobra.Command { logger.Default.Error(fmt.Sprintf("Failed to create manifest: %v", err)) return err } - + logger.Default.FileCreated(manifestPath) logger.Default.Success("Project initialized.") return nil diff --git a/cmd/root.go b/cmd/root.go index 993145b..4c9c3e4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -44,7 +44,7 @@ func GetRootCmd() *cobra.Command { }, RunE: func(cmd *cobra.Command, args []string) error { printBanner() - logger.Default.Println(""); + logger.Default.Println("") logger.Default.Println("To see all the options, try 'openfeature --help'") return nil }, diff --git a/cmd/testdata/success_csharp.golden b/cmd/testdata/success_csharp.golden new file mode 100644 index 0000000..28b6de1 --- /dev/null +++ b/cmd/testdata/success_csharp.golden @@ -0,0 +1,219 @@ +// AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Threading; +using Microsoft.Extensions.DependencyInjection; +using OpenFeature; +using OpenFeature.Model; + +namespace TestNamespace +{ + /// + /// Service collection extensions for OpenFeature + /// + public static class OpenFeatureServiceExtensions + { + /// + /// Adds OpenFeature services to the service collection with the generated client + /// + /// The service collection to add services to + /// The service collection for chaining + public static IServiceCollection AddOpenFeature(this IServiceCollection services) + { + return services + .AddSingleton(_ => Api.Instance) + .AddSingleton(provider => provider.GetRequiredService().GetClient()) + .AddSingleton(); + } + + /// + /// Adds OpenFeature services to the service collection with the generated client for a specific domain + /// + /// The service collection to add services to + /// The domain to get the client for + /// The service collection for chaining + public static IServiceCollection AddOpenFeature(this IServiceCollection services, string domain) + { + return services + .AddSingleton(_ => Api.Instance) + .AddSingleton(provider => provider.GetRequiredService().GetClient(domain)) + .AddSingleton(); + } + } + + /// + /// Generated OpenFeature client for typesafe flag access + /// + public class GeneratedClient + { + private readonly IFeatureClient _client; + + /// + /// Initializes a new instance of the class. + /// + /// The OpenFeature client to use for flag evaluations. + public GeneratedClient(IFeatureClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + /// + /// Discount percentage applied to purchases. + /// + /// + /// Flag key: discountPercentage + /// Default value: 0.15 + /// Type: double + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The flag value + public async Task DiscountPercentageAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetDoubleValueAsync("discountPercentage", 0.15, evaluationContext, options); + } + + /// + /// Discount percentage applied to purchases. + /// + /// + /// Flag key: discountPercentage + /// Default value: 0.15 + /// Type: double + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> DiscountPercentageDetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetDoubleDetailsAsync("discountPercentage", 0.15, evaluationContext, options); + } + + /// + /// Controls whether Feature A is enabled. + /// + /// + /// Flag key: enableFeatureA + /// Default value: false + /// Type: bool + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The flag value + public async Task EnableFeatureAAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetBooleanValueAsync("enableFeatureA", false, evaluationContext, options); + } + + /// + /// Controls whether Feature A is enabled. + /// + /// + /// Flag key: enableFeatureA + /// Default value: false + /// Type: bool + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> EnableFeatureADetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetBooleanDetailsAsync("enableFeatureA", false, evaluationContext, options); + } + + /// + /// The message to use for greeting users. + /// + /// + /// Flag key: greetingMessage + /// Default value: Hello there! + /// Type: string + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The flag value + public async Task GreetingMessageAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetStringValueAsync("greetingMessage", "Hello there!", evaluationContext, options); + } + + /// + /// The message to use for greeting users. + /// + /// + /// Flag key: greetingMessage + /// Default value: Hello there! + /// Type: string + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> GreetingMessageDetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetStringDetailsAsync("greetingMessage", "Hello there!", evaluationContext, options); + } + + /// + /// Maximum allowed length for usernames. + /// + /// + /// Flag key: usernameMaxLength + /// Default value: 50 + /// Type: int + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The flag value + public async Task UsernameMaxLengthAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetIntegerValueAsync("usernameMaxLength", 50, evaluationContext, options); + } + + /// + /// Maximum allowed length for usernames. + /// + /// + /// Flag key: usernameMaxLength + /// Default value: 50 + /// Type: int + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> UsernameMaxLengthDetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetIntegerDetailsAsync("usernameMaxLength", 50, evaluationContext, options); + } + + + /// + /// Creates a new GeneratedClient using the default OpenFeature client + /// + /// A new GeneratedClient instance + public static GeneratedClient CreateClient() + { + return new GeneratedClient(Api.Instance.GetClient()); + } + + /// + /// Creates a new GeneratedClient using a domain-specific OpenFeature client + /// + /// The domain to get the client for + /// A new GeneratedClient instance + public static GeneratedClient CreateClient(string domain) + { + return new GeneratedClient(Api.Instance.GetClient(domain)); + } + + /// + /// Creates a new GeneratedClient using a domain-specific OpenFeature client with context + /// + /// The domain to get the client for + /// Default context to use for evaluations + /// A new GeneratedClient instance + public static GeneratedClient CreateClient(string domain, EvaluationContext? evaluationContext = null) + { + return new GeneratedClient(Api.Instance.GetClient(domain)); + } + } +} \ No newline at end of file diff --git a/cmd/utils.go b/cmd/utils.go index c5ef3b8..4e78e27 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -17,4 +17,4 @@ func printBanner() { pterm.Println() pterm.Printf("version: %s | compiled: %s\n", pterm.LightGreen(Version), pterm.LightGreen(Date)) pterm.Println(pterm.Cyan("🔗 https://openfeature.dev | https://github.com/open-feature/cli")) -} \ No newline at end of file +} diff --git a/cmd/version.go b/cmd/version.go index dab3c41..a63c65d 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -38,4 +38,4 @@ func GetVersionCmd() *cobra.Command { } return versionCmd -} \ No newline at end of file +} diff --git a/docs/commands/openfeature_generate.md b/docs/commands/openfeature_generate.md index 4a80d20..a2107dd 100644 --- a/docs/commands/openfeature_generate.md +++ b/docs/commands/openfeature_generate.md @@ -26,6 +26,7 @@ openfeature generate [flags] ### SEE ALSO * [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 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_csharp.md b/docs/commands/openfeature_generate_csharp.md new file mode 100644 index 0000000..c72d3a1 --- /dev/null +++ b/docs/commands/openfeature_generate_csharp.md @@ -0,0 +1,37 @@ + + +## openfeature generate csharp + +Generate typesafe C# client. + + +> **Stability**: alpha + +### Synopsis + +Generate typesafe C# client compatible with the OpenFeature .NET SDK. + +``` +openfeature generate csharp [flags] +``` + +### Options + +``` + -h, --help help for csharp + --namespace string Namespace for the generated C# code (default "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 86e5dfb..2bb5526 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -6,19 +6,21 @@ import ( // Flag name constants to avoid duplication const ( - DebugFlagName = "debug" - ManifestFlagName = "manifest" - OutputFlagName = "output" - NoInputFlagName = "no-input" - GoPackageFlagName = "package-name" - OverrideFlagName = "override" + DebugFlagName = "debug" + ManifestFlagName = "manifest" + OutputFlagName = "output" + NoInputFlagName = "no-input" + GoPackageFlagName = "package-name" + CSharpNamespaceName = "namespace" + OverrideFlagName = "override" ) // Default values for flags const ( - DefaultManifestPath = "flags.json" - DefaultOutputPath = "" - DefaultGoPackageName = "openfeature" + DefaultManifestPath = "flags.json" + DefaultOutputPath = "" + DefaultGoPackageName = "openfeature" + DefaultCSharpNamespace = "OpenFeature" ) // AddRootFlags adds the common flags to the given command @@ -38,6 +40,11 @@ func AddGoGenerateFlags(cmd *cobra.Command) { cmd.Flags().String(GoPackageFlagName, DefaultGoPackageName, "Name of the generated Go package") } +// AddCSharpGenerateFlags adds the C# generator specific flags to the given command +func AddCSharpGenerateFlags(cmd *cobra.Command) { + cmd.Flags().String(CSharpNamespaceName, DefaultCSharpNamespace, "Namespace for the generated C# code") +} + // AddInitFlags adds the init command specific flags func AddInitFlags(cmd *cobra.Command) { cmd.Flags().Bool(OverrideFlagName, false, "Override an existing configuration") @@ -61,6 +68,12 @@ func GetGoPackageName(cmd *cobra.Command) string { return goPackageName } +// GetCSharpNamespace gets the C# namespace from the given command +func GetCSharpNamespace(cmd *cobra.Command) string { + namespace, _ := cmd.Flags().GetString(CSharpNamespaceName) + return namespace +} + // 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/flagset/flagset.go b/internal/flagset/flagset.go index 8b4aff0..33d3155 100644 --- a/internal/flagset/flagset.go +++ b/internal/flagset/flagset.go @@ -27,24 +27,24 @@ const ( func (f FlagType) String() string { switch f { case IntType: - return "int" + return "int" case FloatType: - return "float" + return "float" case BoolType: - return "bool" + return "bool" case StringType: - return "string" + return "string" case ObjectType: - return "object" + return "object" default: - return "unknown" + return "unknown" } } type Flag struct { - Key string - Type FlagType - Description string + Key string + Type FlagType + Description string DefaultValue any } @@ -90,9 +90,9 @@ func (fs *Flagset) Filter(unsupportedFlagTypes map[FlagType]bool) *Flagset { func (fs *Flagset) UnmarshalJSON(data []byte) error { var manifest struct { Flags map[string]struct { - FlagType string `json:"flagType"` - Description string `json:"description"` - DefaultValue any `json:"defaultValue"` + FlagType string `json:"flagType"` + Description string `json:"description"` + DefaultValue any `json:"defaultValue"` } `json:"flags"` } @@ -131,4 +131,4 @@ func (fs *Flagset) UnmarshalJSON(data []byte) error { }) return nil -} \ No newline at end of file +} diff --git a/internal/generators/csharp/csharp.go b/internal/generators/csharp/csharp.go new file mode 100644 index 0000000..3c79d55 --- /dev/null +++ b/internal/generators/csharp/csharp.go @@ -0,0 +1,74 @@ +package csharp + +import ( + _ "embed" + "fmt" + "text/template" + + "github.com/open-feature/cli/internal/flagset" + "github.com/open-feature/cli/internal/generators" +) + +type CsharpGenerator struct { + generators.CommonGenerator +} + +type Params struct { + // Add C# specific parameters here if needed + Namespace string +} + +//go:embed csharp.tmpl +var csharpTmpl string + +func openFeatureType(t flagset.FlagType) string { + switch t { + case flagset.IntType: + return "int" + case flagset.FloatType: + return "double" // .NET uses double, not float + case flagset.BoolType: + return "bool" + case flagset.StringType: + return "string" + default: + return "" + } +} + +func formatDefaultValue(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 *CsharpGenerator) Generate(params *generators.Params[Params]) error { + funcs := template.FuncMap{ + "OpenFeatureType": openFeatureType, + "FormatDefaultValue": formatDefaultValue, + } + + newParams := &generators.Params[any]{ + OutputPath: params.OutputPath, + Custom: params.Custom, + } + + return g.GenerateFile(funcs, csharpTmpl, newParams, "OpenFeature.g.cs") +} + +// NewGenerator creates a generator for C#. +func NewGenerator(fs *flagset.Flagset) *CsharpGenerator { + return &CsharpGenerator{ + CommonGenerator: *generators.NewGenerator(fs, map[flagset.FlagType]bool{ + flagset.ObjectType: true, + }), + } +} diff --git a/internal/generators/csharp/csharp.tmpl b/internal/generators/csharp/csharp.tmpl new file mode 100644 index 0000000..39d620d --- /dev/null +++ b/internal/generators/csharp/csharp.tmpl @@ -0,0 +1,145 @@ +// AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Threading; +using Microsoft.Extensions.DependencyInjection; +using OpenFeature; +using OpenFeature.Model; + +namespace {{ if .Params.Custom.Namespace }}{{ .Params.Custom.Namespace }}{{ else }}OpenFeatureGenerated{{ end }} +{ + /// + /// Service collection extensions for OpenFeature + /// + public static class OpenFeatureServiceExtensions + { + /// + /// Adds OpenFeature services to the service collection with the generated client + /// + /// The service collection to add services to + /// The service collection for chaining + public static IServiceCollection AddOpenFeature(this IServiceCollection services) + { + return services + .AddSingleton(_ => Api.Instance) + .AddSingleton(provider => provider.GetRequiredService().GetClient()) + .AddSingleton(); + } + + /// + /// Adds OpenFeature services to the service collection with the generated client for a specific domain + /// + /// The service collection to add services to + /// The domain to get the client for + /// The service collection for chaining + public static IServiceCollection AddOpenFeature(this IServiceCollection services, string domain) + { + return services + .AddSingleton(_ => Api.Instance) + .AddSingleton(provider => provider.GetRequiredService().GetClient(domain)) + .AddSingleton(); + } + } + + /// + /// Generated OpenFeature client for typesafe flag access + /// + public class GeneratedClient + { + private readonly IFeatureClient _client; + + /// + /// Initializes a new instance of the class. + /// + /// The OpenFeature client to use for flag evaluations. + public GeneratedClient(IFeatureClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + {{- range .Flagset.Flags }} + /// + /// {{ .Description }} + /// + /// + /// Flag key: {{ .Key }} + /// Default value: {{ .DefaultValue }} + /// Type: {{ .Type | OpenFeatureType }} + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The flag value + public async Task<{{ .Type | OpenFeatureType }}> {{ .Key | ToPascal }}Async(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + {{- if eq .Type 1 }} + return await _client.GetIntegerValueAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext, options); + {{- else if eq .Type 2 }} + return await _client.GetDoubleValueAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext, options); + {{- else if eq .Type 3 }} + return await _client.GetBooleanValueAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext, options); + {{- else if eq .Type 4 }} + return await _client.GetStringValueAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext, options); + {{- else }} + throw new NotSupportedException("Unsupported flag type"); + {{- end }} + } + + /// + /// {{ .Description }} + /// + /// + /// Flag key: {{ .Key }} + /// Default value: {{ .DefaultValue }} + /// Type: {{ .Type | OpenFeatureType }} + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> {{ .Key | ToPascal }}DetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + {{- if eq .Type 1 }} + return await _client.GetIntegerDetailsAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext, options); + {{- else if eq .Type 2 }} + return await _client.GetDoubleDetailsAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext, options); + {{- else if eq .Type 3 }} + return await _client.GetBooleanDetailsAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext, options); + {{- else if eq .Type 4 }} + return await _client.GetStringDetailsAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext, options); + {{- else }} + throw new NotSupportedException("Unsupported flag type"); + {{- end }} + } + {{ end }} + + /// + /// Creates a new GeneratedClient using the default OpenFeature client + /// + /// A new GeneratedClient instance + public static GeneratedClient CreateClient() + { + return new GeneratedClient(Api.Instance.GetClient()); + } + + /// + /// Creates a new GeneratedClient using a domain-specific OpenFeature client + /// + /// The domain to get the client for + /// A new GeneratedClient instance + public static GeneratedClient CreateClient(string domain) + { + return new GeneratedClient(Api.Instance.GetClient(domain)); + } + + /// + /// Creates a new GeneratedClient using a domain-specific OpenFeature client with context + /// + /// The domain to get the client for + /// Default context to use for evaluations + /// A new GeneratedClient instance + public static GeneratedClient CreateClient(string domain, EvaluationContext? evaluationContext = null) + { + return new GeneratedClient(Api.Instance.GetClient(domain)); + } + } +} \ No newline at end of file diff --git a/internal/generators/func.go b/internal/generators/func.go index ac7b877..abe3de8 100644 --- a/internal/generators/func.go +++ b/internal/generators/func.go @@ -17,16 +17,16 @@ func defaultFuncs() template.FuncMap { "ToPascal": strcase.ToCamel, // Remapping ToLowerCamel to ToCamel to match the expected behavior // Ref: See above - "ToCamel": strcase.ToLowerCamel, - "ToKebab": strcase.ToKebab, + "ToCamel": strcase.ToLowerCamel, + "ToKebab": strcase.ToKebab, "ToScreamingKebab": strcase.ToScreamingKebab, - "ToSnake": strcase.ToSnake, + "ToSnake": strcase.ToSnake, "ToScreamingSnake": strcase.ToScreamingSnake, - "ToUpper": strings.ToUpper, - "ToLower": strings.ToLower, - "Title": cases.Title, - "Quote": strconv.Quote, - "QuoteString": func (input any) any { + "ToUpper": strings.ToUpper, + "ToLower": strings.ToLower, + "Title": cases.Title, + "Quote": strconv.Quote, + "QuoteString": func(input any) any { if str, ok := input.(string); ok { return strconv.Quote(str) } @@ -39,4 +39,4 @@ func init() { // results in "Api" using ToCamel("API") // results in "api" using ToLowerCamel("API") strcase.ConfigureAcronym("API", "api") -} \ No newline at end of file +} diff --git a/internal/generators/generators.go b/internal/generators/generators.go index 7a11fba..31c6921 100644 --- a/internal/generators/generators.go +++ b/internal/generators/generators.go @@ -48,7 +48,7 @@ func (g *CommonGenerator) GenerateFile(customFunc template.FuncMap, tmpl string, maps.Copy(funcs, customFunc) logger.Default.Debug(fmt.Sprintf("Generating file: %s", name)) - + generatorTemplate, err := template.New("generator").Funcs(funcs).Parse(tmpl) if err != nil { return fmt.Errorf("error initializing template: %v", err) @@ -68,9 +68,9 @@ func (g *CommonGenerator) GenerateFile(customFunc template.FuncMap, tmpl string, logger.Default.FileFailed(fullPath, err) return err } - + // Log successful file creation logger.Default.FileCreated(fullPath) - + return nil } diff --git a/internal/generators/manager.go b/internal/generators/manager.go index d6c1c8f..737565b 100644 --- a/internal/generators/manager.go +++ b/internal/generators/manager.go @@ -12,10 +12,10 @@ type GeneratorCreator func() *cobra.Command // GeneratorInfo contains metadata about a generator type GeneratorInfo struct { - Name string - Description string - Stability Stability - Creator GeneratorCreator + Name string + Description string + Stability Stability + Creator GeneratorCreator } // GeneratorManager maintains a registry of available generators @@ -34,10 +34,10 @@ func NewGeneratorManager() *GeneratorManager { func (m *GeneratorManager) Register(cmdCreator func() *cobra.Command) { cmd := cmdCreator() m.generators[cmd.Use] = GeneratorInfo{ - Name: cmd.Use, - Description: cmd.Short, - Stability: Stability(cmd.Annotations["stability"]), - Creator: cmdCreator, + Name: cmd.Use, + Description: cmd.Short, + Stability: Stability(cmd.Annotations["stability"]), + Creator: cmdCreator, } } @@ -49,11 +49,11 @@ func (m *GeneratorManager) GetAll() map[string]GeneratorInfo { // GetCommands returns cobra commands for all registered generators func (m *GeneratorManager) GetCommands() []*cobra.Command { var commands []*cobra.Command - + for _, info := range m.generators { commands = append(commands, info.Creator()) } - + return commands } @@ -62,14 +62,14 @@ func (m *GeneratorManager) PrintGeneratorsTable() error { tableData := [][]string{ {"Generator", "Description", "Stability"}, } - + // Get generator names for consistent ordering var names []string for name := range m.generators { names = append(names, name) } sort.Strings(names) - + for _, name := range names { info := m.generators[name] tableData = append(tableData, []string{ @@ -78,7 +78,7 @@ func (m *GeneratorManager) PrintGeneratorsTable() error { string(info.Stability), }) } - + return pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() } diff --git a/internal/manifest/json-schema.go b/internal/manifest/json-schema.go index 4ba383b..45dc746 100644 --- a/internal/manifest/json-schema.go +++ b/internal/manifest/json-schema.go @@ -63,9 +63,9 @@ type Manifest struct { // Converts the Manifest struct to a JSON schema. func ToJSONSchema() *jsonschema.Schema { reflector := &jsonschema.Reflector{ - ExpandedStruct: true, + ExpandedStruct: true, AllowAdditionalProperties: true, - BaseSchemaID: "openfeature-cli", + BaseSchemaID: "openfeature-cli", } if err := reflector.AddGoComments("github.com/open-feature/cli", "./internal/manifest"); err != nil { @@ -121,4 +121,4 @@ func ToJSONSchema() *jsonschema.Schema { } return schema -} \ No newline at end of file +} diff --git a/schema/generate-schema.go b/schema/generate-schema.go index 7c5f31c..2f766d3 100644 --- a/schema/generate-schema.go +++ b/schema/generate-schema.go @@ -29,8 +29,8 @@ func main() { defer file.Close() if _, err := file.Write(data); err != nil { - log.Fatal(fmt.Errorf("failed to write JSON schema to file: %w", err)); + log.Fatal(fmt.Errorf("failed to write JSON schema to file: %w", err)) } fmt.Println("JSON schema generated successfully at " + schemaPath) -} \ No newline at end of file +} diff --git a/schema/v0/schema_test.go b/schema/v0/schema_test.go index 05d039f..9e9c08b 100644 --- a/schema/v0/schema_test.go +++ b/schema/v0/schema_test.go @@ -49,11 +49,11 @@ func walkPath(shouldPass bool, root string) error { schemaLoader := gojsonschema.NewStringLoader(SchemaFile) manifestLoader := gojsonschema.NewGoLoader(v) result, err := gojsonschema.Validate(schemaLoader, manifestLoader) - if (err != nil) { + if err != nil { return fmt.Errorf("Error validating json schema: %v", err) } - if (len(result.Errors()) >= 1 && shouldPass == true) { + if len(result.Errors()) >= 1 && shouldPass == true { var errorMessage strings.Builder errorMessage.WriteString("file " + path + " should be valid, but had the following issues:\n") @@ -63,7 +63,7 @@ func walkPath(shouldPass bool, root string) error { return fmt.Errorf("%s", errorMessage.String()) } - if (len(result.Errors()) == 0 && shouldPass == false) { + if len(result.Errors()) == 0 && shouldPass == false { return fmt.Errorf("file %s should be invalid, but no issues were detected", path) } diff --git a/test/csharp-integration/CompileTest.csproj b/test/csharp-integration/CompileTest.csproj new file mode 100644 index 0000000..98c3cbb --- /dev/null +++ b/test/csharp-integration/CompileTest.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + \ No newline at end of file diff --git a/test/csharp-integration/Dockerfile b/test/csharp-integration/Dockerfile new file mode 100644 index 0000000..3ef09a8 --- /dev/null +++ b/test/csharp-integration/Dockerfile @@ -0,0 +1,17 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 + +WORKDIR /app + +# Copy necessary files +COPY expected/OpenFeature.cs /app/ +COPY CompileTest.csproj /app/ +COPY Program.cs /app/ + +# Restore dependencies +RUN dotnet restore + +# Build the project +RUN dotnet build + +# The image will be used to validate C# compilation only +ENTRYPOINT ["dotnet", "run"] \ No newline at end of file diff --git a/test/csharp-integration/OpenFeature.cs b/test/csharp-integration/OpenFeature.cs new file mode 100644 index 0000000..80603d7 --- /dev/null +++ b/test/csharp-integration/OpenFeature.cs @@ -0,0 +1,176 @@ +// AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using OpenFeature; +using OpenFeature.Model; + +namespace OpenFeature +{ + /// + /// Generated OpenFeature client for typesafe flag access + /// + public class GeneratedClient + { + private readonly IFeatureClient _client; + + /// + /// Initializes a new instance of the class. + /// + /// The OpenFeature client to use for flag evaluations. + public GeneratedClient(IFeatureClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + /// + /// Discount percentage applied to purchases. + /// + /// + /// Flag key: discountPercentage + /// Default value: 0.15 + /// Type: float + /// + /// Optional context for the flag evaluation + /// The flag value + public async Task DiscountPercentageAsync(EvaluationContext evaluationContext = null) + { + return await _client.GetFloatValueAsync("discountPercentage", 0.15, evaluationContext); + } + + /// + /// Discount percentage applied to purchases. + /// + /// + /// Flag key: discountPercentage + /// Default value: 0.15 + /// Type: float + /// + /// Optional context for the flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> DiscountPercentageDetailsAsync(EvaluationContext evaluationContext = null) + { + return await _client.GetFloatDetailsAsync("discountPercentage", 0.15, evaluationContext); + } + + /// + /// Controls whether Feature A is enabled. + /// + /// + /// Flag key: enableFeatureA + /// Default value: false + /// Type: bool + /// + /// Optional context for the flag evaluation + /// The flag value + public async Task EnableFeatureAAsync(EvaluationContext evaluationContext = null) + { + return await _client.GetBoolValueAsync("enableFeatureA", false, evaluationContext); + } + + /// + /// Controls whether Feature A is enabled. + /// + /// + /// Flag key: enableFeatureA + /// Default value: false + /// Type: bool + /// + /// Optional context for the flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> EnableFeatureADetailsAsync(EvaluationContext evaluationContext = null) + { + return await _client.GetBoolDetailsAsync("enableFeatureA", false, evaluationContext); + } + + /// + /// The message to use for greeting users. + /// + /// + /// Flag key: greetingMessage + /// Default value: Hello there! + /// Type: string + /// + /// Optional context for the flag evaluation + /// The flag value + public async Task GreetingMessageAsync(EvaluationContext evaluationContext = null) + { + return await _client.GetStringValueAsync("greetingMessage", "Hello there!", evaluationContext); + } + + /// + /// The message to use for greeting users. + /// + /// + /// Flag key: greetingMessage + /// Default value: Hello there! + /// Type: string + /// + /// Optional context for the flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> GreetingMessageDetailsAsync(EvaluationContext evaluationContext = null) + { + return await _client.GetStringDetailsAsync("greetingMessage", "Hello there!", evaluationContext); + } + + /// + /// Maximum allowed length for usernames. + /// + /// + /// Flag key: usernameMaxLength + /// Default value: 50 + /// Type: int + /// + /// Optional context for the flag evaluation + /// The flag value + public async Task UsernameMaxLengthAsync(EvaluationContext evaluationContext = null) + { + return await _client.GetIntValueAsync("usernameMaxLength", 50, evaluationContext); + } + + /// + /// Maximum allowed length for usernames. + /// + /// + /// Flag key: usernameMaxLength + /// Default value: 50 + /// Type: int + /// + /// Optional context for the flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> UsernameMaxLengthDetailsAsync(EvaluationContext evaluationContext = null) + { + return await _client.GetIntDetailsAsync("usernameMaxLength", 50, evaluationContext); + } + + + /// + /// Creates a new GeneratedClient using the default OpenFeature client + /// + /// A new GeneratedClient instance + public static GeneratedClient CreateClient() + { + return new GeneratedClient(Api.GetClient()); + } + + /// + /// Creates a new GeneratedClient using a domain-specific OpenFeature client + /// + /// The domain to get the client for + /// A new GeneratedClient instance + public static GeneratedClient CreateClient(string domain) + { + return new GeneratedClient(Api.GetClient(domain)); + } + + /// + /// Creates a new GeneratedClient using a domain-specific OpenFeature client with context + /// + /// The domain to get the client for + /// Default context to use for evaluations + /// A new GeneratedClient instance + public static GeneratedClient CreateClient(string domain, EvaluationContext evaluationContext) + { + return new GeneratedClient(Api.GetClient(domain, evaluationContext)); + } + } +} \ No newline at end of file diff --git a/test/csharp-integration/Program.cs b/test/csharp-integration/Program.cs new file mode 100644 index 0000000..03bd66d --- /dev/null +++ b/test/csharp-integration/Program.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using OpenFeature; +using OpenFeature.Model; +using TestNamespace; + +// This program just validates that the generated OpenFeature C# client code compiles +// We don't need to run the code since the goal is to test compilation only +namespace CompileTest +{ + class Program + { + static void Main(string[] args) + { + Console.WriteLine("Testing compilation of generated OpenFeature client..."); + + // Test DI initialization + var services = new ServiceCollection(); + // Register OpenFeature services manually for the test + services.AddSingleton(_ => Api.Instance); + services.AddSingleton(_ => Api.Instance.GetClient()); + services.AddSingleton(); + var serviceProvider = services.BuildServiceProvider(); + + // Test client retrieval from DI + var client = serviceProvider.GetRequiredService(); + + // Also test the traditional factory method + var clientFromFactory = GeneratedClient.CreateClient(); + + // Success! + Console.WriteLine("Generated C# code compiles successfully!"); + } + } +} \ No newline at end of file diff --git a/test/csharp-integration/expected/OpenFeature.cs b/test/csharp-integration/expected/OpenFeature.cs new file mode 100644 index 0000000..b20990a --- /dev/null +++ b/test/csharp-integration/expected/OpenFeature.cs @@ -0,0 +1,252 @@ +// AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Threading; +using Microsoft.Extensions.DependencyInjection; +using OpenFeature; +using OpenFeature.Model; + + +namespace TestNamespace +{ + /// + /// Service collection extensions for OpenFeature + /// + public static class OpenFeatureServiceExtensions + { + /// + /// Adds OpenFeature services to the service collection with the generated client + /// + /// The service collection to add services to + /// The service collection for chaining + public static IServiceCollection AddOpenFeature(this IServiceCollection services) + { + return services + .AddSingleton(_ => Api.Instance) + .AddSingleton(provider => provider.GetRequiredService().GetClient()) + .AddSingleton(); + } + + /// + /// Adds OpenFeature services to the service collection with the generated client for a specific domain + /// + /// The service collection to add services to + /// The domain to get the client for + /// The service collection for chaining + public static IServiceCollection AddOpenFeature(this IServiceCollection services, string domain) + { + return services + .AddSingleton(_ => Api.Instance) + .AddSingleton(provider => provider.GetRequiredService().GetClient(domain)) + .AddSingleton(); + } + } + + /// + /// Generated OpenFeature client for typesafe flag access + /// + public class GeneratedClient + { + private readonly IFeatureClient _client; + + /// + /// Initializes a new instance of the class. + /// + /// The OpenFeature client to use for flag evaluations. + public GeneratedClient(IFeatureClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + /// + /// Discount percentage applied to purchases. + /// + /// + /// Flag key: discountPercentage + /// Default value: 0.15 + /// Type: double + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The flag value + public async Task DiscountPercentageAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetDoubleValueAsync("discountPercentage", 0.15, evaluationContext, options); + } + + /// + /// Discount percentage applied to purchases. + /// + /// + /// Flag key: discountPercentage + /// Default value: 0.15 + /// Type: double + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> DiscountPercentageDetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetDoubleDetailsAsync("discountPercentage", 0.15, evaluationContext, options); + } + + /// + /// Controls whether Feature A is enabled. + /// + /// + /// Flag key: enableFeatureA + /// Default value: false + /// Type: bool + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The flag value + public async Task EnableFeatureAAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetBooleanValueAsync("enableFeatureA", false, evaluationContext, options); + } + + /// + /// Controls whether Feature A is enabled. + /// + /// + /// Flag key: enableFeatureA + /// Default value: false + /// Type: bool + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> EnableFeatureADetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetBooleanDetailsAsync("enableFeatureA", false, evaluationContext, options); + } + + /// + /// The message to use for greeting users. + /// + /// + /// Flag key: greetingMessage + /// Default value: Hello there! + /// Type: string + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The flag value + public async Task GreetingMessageAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetStringValueAsync("greetingMessage", "Hello there!", evaluationContext, options); + } + + /// + /// The message to use for greeting users. + /// + /// + /// Flag key: greetingMessage + /// Default value: Hello there! + /// Type: string + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> GreetingMessageDetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetStringDetailsAsync("greetingMessage", "Hello there!", evaluationContext, options); + } + + /// + /// Allows customization of theme colors. + /// + /// + /// Flag key: themeCustomization + /// Default value: map[primaryColor:#007bff secondaryColor:#6c757d] + /// Type: Value + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The flag value + public async Task ThemeCustomizationAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + throw new NotSupportedException("Unsupported flag type"); + } + + /// + /// Allows customization of theme colors. + /// + /// + /// Flag key: themeCustomization + /// Default value: map[primaryColor:#007bff secondaryColor:#6c757d] + /// Type: Value + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> ThemeCustomizationDetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + throw new NotSupportedException("Unsupported flag type"); + } + + /// + /// Maximum allowed length for usernames. + /// + /// + /// Flag key: usernameMaxLength + /// Default value: 50 + /// Type: int + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The flag value + public async Task UsernameMaxLengthAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetIntegerValueAsync("usernameMaxLength", 50, evaluationContext, options); + } + + /// + /// Maximum allowed length for usernames. + /// + /// + /// Flag key: usernameMaxLength + /// Default value: 50 + /// Type: int + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> UsernameMaxLengthDetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetIntegerDetailsAsync("usernameMaxLength", 50, evaluationContext, options); + } + + + /// + /// Creates a new GeneratedClient using the default OpenFeature client + /// + /// A new GeneratedClient instance + public static GeneratedClient CreateClient() + { + return new GeneratedClient(Api.Instance.GetClient()); + } + + /// + /// Creates a new GeneratedClient using a domain-specific OpenFeature client + /// + /// The domain to get the client for + /// A new GeneratedClient instance + public static GeneratedClient CreateClient(string domain) + { + return new GeneratedClient(Api.Instance.GetClient(domain)); + } + + /// + /// Creates a new GeneratedClient using a domain-specific OpenFeature client with context + /// + /// The domain to get the client for + /// Default context to use for evaluations + /// A new GeneratedClient instance + public static GeneratedClient CreateClient(string domain, EvaluationContext? evaluationContext = null) + { + return new GeneratedClient(Api.Instance.GetClient(domain)); + } + } +} \ No newline at end of file diff --git a/test/csharp-integration/expected/OpenFeature.g.cs b/test/csharp-integration/expected/OpenFeature.g.cs new file mode 100644 index 0000000..28b6de1 --- /dev/null +++ b/test/csharp-integration/expected/OpenFeature.g.cs @@ -0,0 +1,219 @@ +// AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Threading; +using Microsoft.Extensions.DependencyInjection; +using OpenFeature; +using OpenFeature.Model; + +namespace TestNamespace +{ + /// + /// Service collection extensions for OpenFeature + /// + public static class OpenFeatureServiceExtensions + { + /// + /// Adds OpenFeature services to the service collection with the generated client + /// + /// The service collection to add services to + /// The service collection for chaining + public static IServiceCollection AddOpenFeature(this IServiceCollection services) + { + return services + .AddSingleton(_ => Api.Instance) + .AddSingleton(provider => provider.GetRequiredService().GetClient()) + .AddSingleton(); + } + + /// + /// Adds OpenFeature services to the service collection with the generated client for a specific domain + /// + /// The service collection to add services to + /// The domain to get the client for + /// The service collection for chaining + public static IServiceCollection AddOpenFeature(this IServiceCollection services, string domain) + { + return services + .AddSingleton(_ => Api.Instance) + .AddSingleton(provider => provider.GetRequiredService().GetClient(domain)) + .AddSingleton(); + } + } + + /// + /// Generated OpenFeature client for typesafe flag access + /// + public class GeneratedClient + { + private readonly IFeatureClient _client; + + /// + /// Initializes a new instance of the class. + /// + /// The OpenFeature client to use for flag evaluations. + public GeneratedClient(IFeatureClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + /// + /// Discount percentage applied to purchases. + /// + /// + /// Flag key: discountPercentage + /// Default value: 0.15 + /// Type: double + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The flag value + public async Task DiscountPercentageAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetDoubleValueAsync("discountPercentage", 0.15, evaluationContext, options); + } + + /// + /// Discount percentage applied to purchases. + /// + /// + /// Flag key: discountPercentage + /// Default value: 0.15 + /// Type: double + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> DiscountPercentageDetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetDoubleDetailsAsync("discountPercentage", 0.15, evaluationContext, options); + } + + /// + /// Controls whether Feature A is enabled. + /// + /// + /// Flag key: enableFeatureA + /// Default value: false + /// Type: bool + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The flag value + public async Task EnableFeatureAAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetBooleanValueAsync("enableFeatureA", false, evaluationContext, options); + } + + /// + /// Controls whether Feature A is enabled. + /// + /// + /// Flag key: enableFeatureA + /// Default value: false + /// Type: bool + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> EnableFeatureADetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetBooleanDetailsAsync("enableFeatureA", false, evaluationContext, options); + } + + /// + /// The message to use for greeting users. + /// + /// + /// Flag key: greetingMessage + /// Default value: Hello there! + /// Type: string + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The flag value + public async Task GreetingMessageAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetStringValueAsync("greetingMessage", "Hello there!", evaluationContext, options); + } + + /// + /// The message to use for greeting users. + /// + /// + /// Flag key: greetingMessage + /// Default value: Hello there! + /// Type: string + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> GreetingMessageDetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetStringDetailsAsync("greetingMessage", "Hello there!", evaluationContext, options); + } + + /// + /// Maximum allowed length for usernames. + /// + /// + /// Flag key: usernameMaxLength + /// Default value: 50 + /// Type: int + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The flag value + public async Task UsernameMaxLengthAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetIntegerValueAsync("usernameMaxLength", 50, evaluationContext, options); + } + + /// + /// Maximum allowed length for usernames. + /// + /// + /// Flag key: usernameMaxLength + /// Default value: 50 + /// Type: int + /// + /// Optional context for the flag evaluation + /// Options for flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> UsernameMaxLengthDetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) + { + return await _client.GetIntegerDetailsAsync("usernameMaxLength", 50, evaluationContext, options); + } + + + /// + /// Creates a new GeneratedClient using the default OpenFeature client + /// + /// A new GeneratedClient instance + public static GeneratedClient CreateClient() + { + return new GeneratedClient(Api.Instance.GetClient()); + } + + /// + /// Creates a new GeneratedClient using a domain-specific OpenFeature client + /// + /// The domain to get the client for + /// A new GeneratedClient instance + public static GeneratedClient CreateClient(string domain) + { + return new GeneratedClient(Api.Instance.GetClient(domain)); + } + + /// + /// Creates a new GeneratedClient using a domain-specific OpenFeature client with context + /// + /// The domain to get the client for + /// Default context to use for evaluations + /// A new GeneratedClient instance + public static GeneratedClient CreateClient(string domain, EvaluationContext? evaluationContext = null) + { + return new GeneratedClient(Api.Instance.GetClient(domain)); + } + } +} \ No newline at end of file diff --git a/test/csharp-integration/test-compilation.sh b/test/csharp-integration/test-compilation.sh new file mode 100755 index 0000000..c1fd4fe --- /dev/null +++ b/test/csharp-integration/test-compilation.sh @@ -0,0 +1,34 @@ +#!/bin/bash +set -e + +# Script to test if the generated C# code compiles correctly +SCRIPT_DIR=$(dirname "$0") +CLI_ROOT=$(realpath "$SCRIPT_DIR/../..") +OUTPUT_DIR=$(realpath "$SCRIPT_DIR") + +echo "=== Building OpenFeature CLI ===" +cd "$CLI_ROOT" +go build + +echo "=== Generating C# client ===" +./cli generate csharp --manifest="$CLI_ROOT/sample/sample_manifest.json" --output="$OUTPUT_DIR/expected" --namespace="TestNamespace" + +if [ ! -f "$OUTPUT_DIR/expected/OpenFeature.cs" ]; then + echo "Error: OpenFeature.cs was not generated" + exit 1 +fi + +echo "=== Building Docker image to compile C# code ===" +cd "$OUTPUT_DIR" +docker build -t openfeature-csharp-test . + +echo "=== Testing C# compilation and execution ===" +docker run --rm openfeature-csharp-test + +if [ $? -eq 0 ]; then + echo "=== Success: C# code compiles and executes correctly ===" + exit 0 +else + echo "=== Error: C# code fails to compile or execute ===" + exit 1 +fi \ No newline at end of file