From 234e49ce4ed8250dd8251678aa92c23bcd7e5a85 Mon Sep 17 00:00:00 2001 From: Kris Coleman Date: Sun, 6 Apr 2025 11:43:40 -0400 Subject: [PATCH 01/11] feat: adds c# generator Adds a new generator for C# to create typesafe clients. This allows users to generate C# code based on feature flag definitions, streamlining integration with .NET applications. Includes necessary command-line flags, templates, and tests. Signed-off-by: Kris Coleman --- cmd/generate.go | 52 ++++++ cmd/generate_test.go | 20 ++- cmd/testdata/success_csharp.golden | 176 +++++++++++++++++++ docs/commands/openfeature_generate_csharp.md | 33 ++++ internal/config/flags.go | 31 +++- internal/generators/csharp/csharp.go | 74 ++++++++ internal/generators/csharp/csharp.tmpl | 88 ++++++++++ 7 files changed, 461 insertions(+), 13 deletions(-) create mode 100644 cmd/testdata/success_csharp.golden create mode 100644 docs/commands/openfeature_generate_csharp.md create mode 100644 internal/generators/csharp/csharp.go create mode 100644 internal/generators/csharp/csharp.tmpl diff --git a/cmd/generate.go b/cmd/generate.go index 8a8b600..c64d525 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" @@ -125,6 +126,56 @@ func GetGenerateReactCmd() *cobra.Command { return reactCmd } +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", @@ -223,6 +274,7 @@ func init() { generators.DefaultManager.Register(GetGenerateGoCmd) generators.DefaultManager.Register(GetGenerateNodeJSCmd) generators.DefaultManager.Register(getGeneratePythonCmd) + generators.DefaultManager.Register(GetGenerateCSharpCmd) } func GetGenerateCmd() *cobra.Command { diff --git a/cmd/generate_test.go b/cmd/generate_test.go index b7db5d1..3316443 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.cs", + packageName: "TestNamespace", // Using packageName field for namespace + }, // Add more test cases here as needed } @@ -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) @@ -145,4 +157,4 @@ func compareOutput(t *testing.T, testFile, memoryOutputPath string, fs afero.Fs) if diff := cmp.Diff(wantLines, gotLines); diff != "" { t.Errorf("output mismatch (-want +got):\n%s", diff) } -} +} \ No newline at end of file diff --git a/cmd/testdata/success_csharp.golden b/cmd/testdata/success_csharp.golden new file mode 100644 index 0000000..a2c9d3f --- /dev/null +++ b/cmd/testdata/success_csharp.golden @@ -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 TestNamespace +{ + /// + /// 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/docs/commands/openfeature_generate_csharp.md b/docs/commands/openfeature_generate_csharp.md new file mode 100644 index 0000000..316df8e --- /dev/null +++ b/docs/commands/openfeature_generate_csharp.md @@ -0,0 +1,33 @@ +## openfeature generate csharp + +Generate typesafe C# client. + +### 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. + +###### Auto generated by spf13/cobra on 6-Apr-2025 \ No newline at end of file diff --git a/internal/config/flags.go b/internal/config/flags.go index 86e5dfb..1bbd83a 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/generators/csharp/csharp.go b/internal/generators/csharp/csharp.go new file mode 100644 index 0000000..856b13a --- /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 "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.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, + }), + } +} \ No newline at end of file diff --git a/internal/generators/csharp/csharp.tmpl b/internal/generators/csharp/csharp.tmpl new file mode 100644 index 0000000..f2bb7d3 --- /dev/null +++ b/internal/generators/csharp/csharp.tmpl @@ -0,0 +1,88 @@ +// AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using OpenFeature; +using OpenFeature.Model; + +namespace {{ if .Params.Custom.Namespace }}{{ .Params.Custom.Namespace }}{{ else }}OpenFeatureGenerated{{ end }} +{ + /// + /// 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 + /// The flag value + public async Task<{{ .Type | OpenFeatureType }}> {{ .Key | ToPascal }}Async(EvaluationContext evaluationContext = null) + { + return await _client.Get{{ .Type | OpenFeatureType | ToPascal }}ValueAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext); + } + + /// + /// {{ .Description }} + /// + /// + /// Flag key: {{ .Key }} + /// Default value: {{ .DefaultValue }} + /// Type: {{ .Type | OpenFeatureType }} + /// + /// Optional context for the flag evaluation + /// The evaluation details containing the flag value and metadata + public async Task> {{ .Key | ToPascal }}DetailsAsync(EvaluationContext evaluationContext = null) + { + return await _client.Get{{ .Type | OpenFeatureType | ToPascal }}DetailsAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext); + } + {{ end }} + + /// + /// 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 From 6bbf7fc1d9cc9b4c9d304a5fe58697ac0a78bb2c Mon Sep 17 00:00:00 2001 From: Kris Coleman Date: Sun, 6 Apr 2025 12:04:14 -0400 Subject: [PATCH 02/11] feat(csharp): adds C# generator integration test Adds a C# code generator integration test to ensure the generated C# code compiles correctly. This includes: - A new C# generator based on templates - Updates to the build process and documentation to support C# generation and testing - An integration test using Docker to compile the generated C# code - Fixes and adjustments to data type mappings for C# compatibility Signed-off-by: Kris Coleman --- .github/workflows/csharp-integration.yml | 33 +++ CONTRIBUTING.md | 17 ++ Makefile | 5 + cmd/testdata/success_csharp.golden | 53 +++-- docs/commands/openfeature_generate.md | 1 + docs/commands/openfeature_generate_csharp.md | 14 +- internal/generators/csharp/csharp.go | 4 +- internal/generators/csharp/csharp.tmpl | 39 +++- test/csharp-integration/CompileTest.csproj | 14 ++ test/csharp-integration/Dockerfile | 17 ++ test/csharp-integration/OpenFeature.cs | 176 ++++++++++++++ test/csharp-integration/Program.cs | 20 ++ .../expected/OpenFeature.cs | 217 ++++++++++++++++++ test/csharp-integration/test-compilation.sh | 34 +++ 14 files changed, 607 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/csharp-integration.yml create mode 100644 test/csharp-integration/CompileTest.csproj create mode 100644 test/csharp-integration/Dockerfile create mode 100644 test/csharp-integration/OpenFeature.cs create mode 100644 test/csharp-integration/Program.cs create mode 100644 test/csharp-integration/expected/OpenFeature.cs create mode 100755 test/csharp-integration/test-compilation.sh diff --git a/.github/workflows/csharp-integration.yml b/.github/workflows/csharp-integration.yml new file mode 100644 index 0000000..7d3e844 --- /dev/null +++ b/.github/workflows/csharp-integration.yml @@ -0,0 +1,33 @@ +name: C# Generator Integration Test + +on: + push: + branches: [ main ] + paths: + - 'internal/generators/csharp/**' + - 'cmd/generate.go' + - 'test/csharp-integration/**' + pull_request: + branches: [ main ] + paths: + - 'internal/generators/csharp/**' + - 'cmd/generate.go' + - 'test/csharp-integration/**' + +jobs: + csharp-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.23' + + - 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..6606707 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 Testing + +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/testdata/success_csharp.golden b/cmd/testdata/success_csharp.golden index a2c9d3f..76fe2fc 100644 --- a/cmd/testdata/success_csharp.golden +++ b/cmd/testdata/success_csharp.golden @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using System.Threading; using OpenFeature; using OpenFeature.Model; @@ -28,13 +29,14 @@ namespace TestNamespace /// /// Flag key: discountPercentage /// Default value: 0.15 - /// Type: float + /// Type: double /// /// Optional context for the flag evaluation + /// Options for flag evaluation /// The flag value - public async Task DiscountPercentageAsync(EvaluationContext evaluationContext = null) + public async Task DiscountPercentageAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) { - return await _client.GetFloatValueAsync("discountPercentage", 0.15, evaluationContext); + return await _client.GetDoubleValueAsync("discountPercentage", 0.15, evaluationContext, options); } /// @@ -43,13 +45,14 @@ namespace TestNamespace /// /// Flag key: discountPercentage /// Default value: 0.15 - /// Type: float + /// 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) + public async Task> DiscountPercentageDetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) { - return await _client.GetFloatDetailsAsync("discountPercentage", 0.15, evaluationContext); + return await _client.GetDoubleDetailsAsync("discountPercentage", 0.15, evaluationContext, options); } /// @@ -61,10 +64,11 @@ namespace TestNamespace /// Type: bool /// /// Optional context for the flag evaluation + /// Options for flag evaluation /// The flag value - public async Task EnableFeatureAAsync(EvaluationContext evaluationContext = null) + public async Task EnableFeatureAAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) { - return await _client.GetBoolValueAsync("enableFeatureA", false, evaluationContext); + return await _client.GetBooleanValueAsync("enableFeatureA", false, evaluationContext, options); } /// @@ -76,10 +80,11 @@ namespace TestNamespace /// 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) + public async Task> EnableFeatureADetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) { - return await _client.GetBoolDetailsAsync("enableFeatureA", false, evaluationContext); + return await _client.GetBooleanDetailsAsync("enableFeatureA", false, evaluationContext, options); } /// @@ -91,10 +96,11 @@ namespace TestNamespace /// Type: string /// /// Optional context for the flag evaluation + /// Options for flag evaluation /// The flag value - public async Task GreetingMessageAsync(EvaluationContext evaluationContext = null) + public async Task GreetingMessageAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) { - return await _client.GetStringValueAsync("greetingMessage", "Hello there!", evaluationContext); + return await _client.GetStringValueAsync("greetingMessage", "Hello there!", evaluationContext, options); } /// @@ -106,10 +112,11 @@ namespace TestNamespace /// 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) + public async Task> GreetingMessageDetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) { - return await _client.GetStringDetailsAsync("greetingMessage", "Hello there!", evaluationContext); + return await _client.GetStringDetailsAsync("greetingMessage", "Hello there!", evaluationContext, options); } /// @@ -121,10 +128,11 @@ namespace TestNamespace /// Type: int /// /// Optional context for the flag evaluation + /// Options for flag evaluation /// The flag value - public async Task UsernameMaxLengthAsync(EvaluationContext evaluationContext = null) + public async Task UsernameMaxLengthAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) { - return await _client.GetIntValueAsync("usernameMaxLength", 50, evaluationContext); + return await _client.GetIntegerValueAsync("usernameMaxLength", 50, evaluationContext, options); } /// @@ -136,10 +144,11 @@ namespace TestNamespace /// 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) + public async Task> UsernameMaxLengthDetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) { - return await _client.GetIntDetailsAsync("usernameMaxLength", 50, evaluationContext); + return await _client.GetIntegerDetailsAsync("usernameMaxLength", 50, evaluationContext, options); } @@ -149,7 +158,7 @@ namespace TestNamespace /// A new GeneratedClient instance public static GeneratedClient CreateClient() { - return new GeneratedClient(Api.GetClient()); + return new GeneratedClient(Api.Instance.GetClient()); } /// @@ -159,7 +168,7 @@ namespace TestNamespace /// A new GeneratedClient instance public static GeneratedClient CreateClient(string domain) { - return new GeneratedClient(Api.GetClient(domain)); + return new GeneratedClient(Api.Instance.GetClient(domain)); } /// @@ -168,9 +177,9 @@ namespace TestNamespace /// 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) + public static GeneratedClient CreateClient(string domain, EvaluationContext? evaluationContext = null) { - return new GeneratedClient(Api.GetClient(domain, evaluationContext)); + return new GeneratedClient(Api.Instance.GetClient(domain)); } } } \ 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 index 316df8e..c72d3a1 100644 --- a/docs/commands/openfeature_generate_csharp.md +++ b/docs/commands/openfeature_generate_csharp.md @@ -1,7 +1,12 @@ + + ## openfeature generate csharp Generate typesafe C# client. + +> **Stability**: alpha + ### Synopsis Generate typesafe C# client compatible with the OpenFeature .NET SDK. @@ -20,14 +25,13 @@ openfeature generate csharp [flags] ### 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 + --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. -###### Auto generated by spf13/cobra on 6-Apr-2025 \ No newline at end of file diff --git a/internal/generators/csharp/csharp.go b/internal/generators/csharp/csharp.go index 856b13a..dba09eb 100644 --- a/internal/generators/csharp/csharp.go +++ b/internal/generators/csharp/csharp.go @@ -26,7 +26,7 @@ func openFeatureType(t flagset.FlagType) string { case flagset.IntType: return "int" case flagset.FloatType: - return "float" + return "double" // .NET uses double, not float case flagset.BoolType: return "bool" case flagset.StringType: @@ -71,4 +71,4 @@ func NewGenerator(fs *flagset.Flagset) *CsharpGenerator { flagset.ObjectType: true, }), } -} \ No newline at end of file +} diff --git a/internal/generators/csharp/csharp.tmpl b/internal/generators/csharp/csharp.tmpl index f2bb7d3..87e9ea9 100644 --- a/internal/generators/csharp/csharp.tmpl +++ b/internal/generators/csharp/csharp.tmpl @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using System.Threading; using OpenFeature; using OpenFeature.Model; @@ -33,10 +34,21 @@ namespace {{ if .Params.Custom.Namespace }}{{ .Params.Custom.Namespace }}{{ else /// 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) + public async Task<{{ .Type | OpenFeatureType }}> {{ .Key | ToPascal }}Async(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) { - return await _client.Get{{ .Type | OpenFeatureType | ToPascal }}ValueAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext); + {{- 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 }} } /// @@ -48,10 +60,21 @@ namespace {{ if .Params.Custom.Namespace }}{{ .Params.Custom.Namespace }}{{ else /// 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) + public async Task> {{ .Key | ToPascal }}DetailsAsync(EvaluationContext? evaluationContext = null, FlagEvaluationOptions? options = null) { - return await _client.Get{{ .Type | OpenFeatureType | ToPascal }}DetailsAsync("{{ .Key }}", {{ . | FormatDefaultValue }}, evaluationContext); + {{- 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 }} @@ -61,7 +84,7 @@ namespace {{ if .Params.Custom.Namespace }}{{ .Params.Custom.Namespace }}{{ else /// A new GeneratedClient instance public static GeneratedClient CreateClient() { - return new GeneratedClient(Api.GetClient()); + return new GeneratedClient(Api.Instance.GetClient()); } /// @@ -71,7 +94,7 @@ namespace {{ if .Params.Custom.Namespace }}{{ .Params.Custom.Namespace }}{{ else /// A new GeneratedClient instance public static GeneratedClient CreateClient(string domain) { - return new GeneratedClient(Api.GetClient(domain)); + return new GeneratedClient(Api.Instance.GetClient(domain)); } /// @@ -80,9 +103,9 @@ namespace {{ if .Params.Custom.Namespace }}{{ .Params.Custom.Namespace }}{{ else /// 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) + public static GeneratedClient CreateClient(string domain, EvaluationContext? evaluationContext = null) { - return new GeneratedClient(Api.GetClient(domain, evaluationContext)); + return new GeneratedClient(Api.Instance.GetClient(domain)); } } } \ No newline at end of file diff --git a/test/csharp-integration/CompileTest.csproj b/test/csharp-integration/CompileTest.csproj new file mode 100644 index 0000000..e264e19 --- /dev/null +++ b/test/csharp-integration/CompileTest.csproj @@ -0,0 +1,14 @@ + + + + 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..91a931a --- /dev/null +++ b/test/csharp-integration/Program.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading.Tasks; +using OpenFeature; +using OpenFeature.Model; + +// 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..."); + + // 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..cc63c2c --- /dev/null +++ b/test/csharp-integration/expected/OpenFeature.cs @@ -0,0 +1,217 @@ +// AUTOMATICALLY GENERATED BY OPENFEATURE CLI, DO NOT EDIT. +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Threading; +using OpenFeature; +using OpenFeature.Model; + +namespace TestNamespace +{ + /// + /// 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/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 From 6a774a2a9fcb463fa318d80140ffa6b56e503276 Mon Sep 17 00:00:00 2001 From: Kris Coleman Date: Sun, 6 Apr 2025 14:03:27 -0400 Subject: [PATCH 03/11] chore: go fmt fixes Signed-off-by: Kris Coleman --- cmd/config.go | 17 ++++++++--------- cmd/generate.go | 24 ++++++++++++------------ cmd/generate_test.go | 10 +++++----- cmd/init.go | 4 ++-- cmd/root.go | 2 +- cmd/utils.go | 2 +- cmd/version.go | 2 +- internal/config/flags.go | 20 ++++++++++---------- internal/flagset/flagset.go | 26 +++++++++++++------------- internal/generators/csharp/csharp.go | 2 +- internal/generators/func.go | 18 +++++++++--------- internal/generators/generators.go | 6 +++--- internal/generators/manager.go | 26 +++++++++++++------------- internal/manifest/json-schema.go | 6 +++--- schema/generate-schema.go | 4 ++-- schema/v0/schema_test.go | 6 +++--- 16 files changed, 87 insertions(+), 88 deletions(-) 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 c64d525..876c6a8 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -70,9 +70,9 @@ func GetGenerateNodeJSCmd() *cobra.Command { if err != nil { return err } - + logger.Default.GenerationComplete("Node.js") - + return nil }, } @@ -96,7 +96,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]{ @@ -114,9 +114,9 @@ func GetGenerateReactCmd() *cobra.Command { if err != nil { return err } - + logger.Default.GenerationComplete("React") - + return nil }, } @@ -141,7 +141,7 @@ func GetGenerateCSharpCmd() *cobra.Command { namespace := config.GetCSharpNamespace(cmd) manifestPath := config.GetManifestPath(cmd) outputPath := config.GetOutputPath(cmd) - + logger.Default.GenerationStarted("C#") params := generators.Params[csharp.Params]{ @@ -161,9 +161,9 @@ func GetGenerateCSharpCmd() *cobra.Command { if err != nil { return err } - + logger.Default.GenerationComplete("C#") - + return nil }, } @@ -191,7 +191,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]{ @@ -212,9 +212,9 @@ func GetGenerateGoCmd() *cobra.Command { if err != nil { return err } - + logger.Default.GenerationComplete("Go") - + return nil }, } @@ -297,7 +297,7 @@ func GetGenerateCmd() *cobra.Command { for _, subCmd := range generators.DefaultManager.GetCommands() { generateCmd.AddCommand(subCmd) } - + addStabilityInfo(generateCmd) return generateCmd diff --git a/cmd/generate_test.go b/cmd/generate_test.go index 3316443..066d9e5 100644 --- a/cmd/generate_test.go +++ b/cmd/generate_test.go @@ -75,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 == "" { @@ -119,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 { @@ -149,12 +149,12 @@ 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) } -} \ No newline at end of file +} 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/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/internal/config/flags.go b/internal/config/flags.go index 1bbd83a..2bb5526 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -6,20 +6,20 @@ import ( // Flag name constants to avoid duplication const ( - DebugFlagName = "debug" - ManifestFlagName = "manifest" - OutputFlagName = "output" - NoInputFlagName = "no-input" - GoPackageFlagName = "package-name" - CSharpNamespaceName = "namespace" - 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" ) 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 index dba09eb..5a6414b 100644 --- a/internal/generators/csharp/csharp.go +++ b/internal/generators/csharp/csharp.go @@ -26,7 +26,7 @@ func openFeatureType(t flagset.FlagType) string { case flagset.IntType: return "int" case flagset.FloatType: - return "double" // .NET uses double, not float + return "double" // .NET uses double, not float case flagset.BoolType: return "bool" case flagset.StringType: 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) } From 2f68fbb8da51f882e703912c1bced6f9425a6faa Mon Sep 17 00:00:00 2001 From: Kris Coleman Date: Mon, 7 Apr 2025 14:10:24 -0400 Subject: [PATCH 04/11] Update .github/workflows/csharp-integration.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: AndrĂ© Silva <2493377+askpt@users.noreply.github.com> Signed-off-by: Kris Coleman Signed-off-by: Kris Coleman --- .github/workflows/csharp-integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/csharp-integration.yml b/.github/workflows/csharp-integration.yml index 7d3e844..50b4252 100644 --- a/.github/workflows/csharp-integration.yml +++ b/.github/workflows/csharp-integration.yml @@ -1,4 +1,4 @@ -name: C# Generator Integration Test +name: csharp Generator Integration Test on: push: From d110ce559a0ee9eaa58979c308bd0600dbfc2aa6 Mon Sep 17 00:00:00 2001 From: Kris Coleman Date: Mon, 7 Apr 2025 14:10:56 -0400 Subject: [PATCH 05/11] Update CONTRIBUTING.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: AndrĂ© Silva <2493377+askpt@users.noreply.github.com> Signed-off-by: Kris Coleman Signed-off-by: Kris Coleman --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6606707..4f0c385 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ 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 Testing +### Integration Tests To verify that generated code compiles correctly, the project includes integration tests, for example, for c#: From 199e9f083c903dcd7227a0240fcddc0e2a8b8668 Mon Sep 17 00:00:00 2001 From: Kris Coleman Date: Mon, 7 Apr 2025 14:11:50 -0400 Subject: [PATCH 06/11] Update internal/generators/csharp/csharp.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: AndrĂ© Silva <2493377+askpt@users.noreply.github.com> Signed-off-by: Kris Coleman Signed-off-by: Kris Coleman --- internal/generators/csharp/csharp.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/generators/csharp/csharp.go b/internal/generators/csharp/csharp.go index 5a6414b..3c79d55 100644 --- a/internal/generators/csharp/csharp.go +++ b/internal/generators/csharp/csharp.go @@ -61,7 +61,7 @@ func (g *CsharpGenerator) Generate(params *generators.Params[Params]) error { Custom: params.Custom, } - return g.GenerateFile(funcs, csharpTmpl, newParams, "OpenFeature.cs") + return g.GenerateFile(funcs, csharpTmpl, newParams, "OpenFeature.g.cs") } // NewGenerator creates a generator for C#. From 00a569d39c269a60be4af1023c3e89b226ab9484 Mon Sep 17 00:00:00 2001 From: Kris Coleman Date: Mon, 14 Apr 2025 11:14:31 -0400 Subject: [PATCH 07/11] chore(ci): moved the csharp integration into pr-test workflow as a separate job Signed-off-by: Kris Coleman --- .github/workflows/csharp-integration.yml | 33 ------------------------ .github/workflows/pr-test.yml | 19 +++++++++++++- 2 files changed, 18 insertions(+), 34 deletions(-) delete mode 100644 .github/workflows/csharp-integration.yml diff --git a/.github/workflows/csharp-integration.yml b/.github/workflows/csharp-integration.yml deleted file mode 100644 index 50b4252..0000000 --- a/.github/workflows/csharp-integration.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: csharp Generator Integration Test - -on: - push: - branches: [ main ] - paths: - - 'internal/generators/csharp/**' - - 'cmd/generate.go' - - 'test/csharp-integration/**' - pull_request: - branches: [ main ] - paths: - - 'internal/generators/csharp/**' - - 'cmd/generate.go' - - 'test/csharp-integration/**' - -jobs: - csharp-test: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version: '1.23' - - - 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/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index 15098d9..b00e1c0 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 From 787d0274479fe82d88958de9c8e66ca04ef04d83 Mon Sep 17 00:00:00 2001 From: Kris Coleman Date: Mon, 14 Apr 2025 11:26:41 -0400 Subject: [PATCH 08/11] chore: cleaned up generate code to private funcs are private Signed-off-by: Kris Coleman --- cmd/generate.go | 68 ++++++++++++++++++++++++------------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/cmd/generate.go b/cmd/generate.go index 876c6a8..d89f9b4 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -15,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 @@ -38,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.", @@ -82,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.", @@ -126,7 +152,7 @@ func GetGenerateReactCmd() *cobra.Command { return reactCmd } -func GetGenerateCSharpCmd() *cobra.Command { +func getGenerateCSharpCmd() *cobra.Command { csharpCmd := &cobra.Command{ Use: "csharp", Short: "Generate typesafe C# client.", @@ -176,7 +202,7 @@ func GetGenerateCSharpCmd() *cobra.Command { return csharpCmd } -func GetGenerateGoCmd() *cobra.Command { +func getGenerateGoCmd() *cobra.Command { goCmd := &cobra.Command{ Use: "go", Short: "Generate typesafe accessors for OpenFeature.", @@ -270,35 +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) - generators.DefaultManager.Register(GetGenerateCSharpCmd) -} - -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) } From 197c8eb0f4cc79465069c564ffb17da9ff907f9a Mon Sep 17 00:00:00 2001 From: Kris Coleman Date: Mon, 14 Apr 2025 11:21:11 -0400 Subject: [PATCH 09/11] feat(csharp): implemented di for generated code - updated openfeature to 2.3.2 - introduced IServiceCollection and DI patterns - updated tests and expectations Signed-off-by: Kris Coleman --- cmd/generate_test.go | 2 +- cmd/testdata/success_csharp.golden | 34 +++ internal/generators/csharp/csharp.tmpl | 34 +++ test/csharp-integration/CompileTest.csproj | 3 +- test/csharp-integration/Program.cs | 16 ++ .../expected/OpenFeature.cs | 35 +++ .../expected/OpenFeature.g.cs | 219 ++++++++++++++++++ 7 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 test/csharp-integration/expected/OpenFeature.g.cs diff --git a/cmd/generate_test.go b/cmd/generate_test.go index 066d9e5..0bc8466 100644 --- a/cmd/generate_test.go +++ b/cmd/generate_test.go @@ -60,7 +60,7 @@ func TestGenerate(t *testing.T) { command: "csharp", manifestGolden: "testdata/success_manifest.golden", outputGolden: "testdata/success_csharp.golden", - outputFile: "OpenFeature.cs", + outputFile: "OpenFeature.g.cs", packageName: "TestNamespace", // Using packageName field for namespace }, // Add more test cases here as needed diff --git a/cmd/testdata/success_csharp.golden b/cmd/testdata/success_csharp.golden index 76fe2fc..28b6de1 100644 --- a/cmd/testdata/success_csharp.golden +++ b/cmd/testdata/success_csharp.golden @@ -3,11 +3,45 @@ 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 /// diff --git a/internal/generators/csharp/csharp.tmpl b/internal/generators/csharp/csharp.tmpl index 87e9ea9..39d620d 100644 --- a/internal/generators/csharp/csharp.tmpl +++ b/internal/generators/csharp/csharp.tmpl @@ -3,11 +3,45 @@ 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 /// diff --git a/test/csharp-integration/CompileTest.csproj b/test/csharp-integration/CompileTest.csproj index e264e19..98c3cbb 100644 --- a/test/csharp-integration/CompileTest.csproj +++ b/test/csharp-integration/CompileTest.csproj @@ -8,7 +8,8 @@ - + + \ No newline at end of file diff --git a/test/csharp-integration/Program.cs b/test/csharp-integration/Program.cs index 91a931a..03bd66d 100644 --- a/test/csharp-integration/Program.cs +++ b/test/csharp-integration/Program.cs @@ -1,7 +1,9 @@ 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 @@ -13,6 +15,20 @@ 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!"); } diff --git a/test/csharp-integration/expected/OpenFeature.cs b/test/csharp-integration/expected/OpenFeature.cs index cc63c2c..b20990a 100644 --- a/test/csharp-integration/expected/OpenFeature.cs +++ b/test/csharp-integration/expected/OpenFeature.cs @@ -3,11 +3,46 @@ 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 /// 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 From f6329113ccb25aeb59c039cbe87c6c9cb2ddc9d6 Mon Sep 17 00:00:00 2001 From: Kris Coleman Date: Mon, 14 Apr 2025 13:39:49 -0400 Subject: [PATCH 10/11] Update .github/workflows/pr-test.yml Co-authored-by: Michael Beemer Signed-off-by: Kris Coleman --- .github/workflows/pr-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index b00e1c0..286d947 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -46,7 +46,7 @@ jobs: fi csharp-test: - name: C# Generator Test + name: 'C# Generator Test' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 From 3cf64eca9ce9988fcb720b28986511631372732d Mon Sep 17 00:00:00 2001 From: Kris Coleman Date: Mon, 14 Apr 2025 13:39:58 -0400 Subject: [PATCH 11/11] Update .github/workflows/pr-test.yml Co-authored-by: Michael Beemer Signed-off-by: Kris Coleman --- .github/workflows/pr-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index 286d947..050257d 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -59,5 +59,5 @@ jobs: - name: Set up Docker uses: docker/setup-buildx-action@v2 - - name: Run C# integration test + - name: 'Run C# integration test' run: ./test/csharp-integration/test-compilation.sh \ No newline at end of file